Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 17:51:56 -04:00
1 parent 9c5fb4c614
commit bd073fb00f
113 files changed
+4900 -271

No files matched your search

+69
View File
@@ -4,6 +4,21 @@ This file is the durable record of runtime behavior, regressions, and the
evidence required before calling a visual bug fixed. Update it whenever scene, evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes. camera, input, physics, mob-rendering, or asset-loading code changes.
## 2026-09-26 RuneWaker dungeon minimap and M-key map
- Created a fresh RoM Rogue, downloaded Forsaken Abbey, entered as damage, and
waited for the boss preview to finish. The minimap displayed the extracted
client map; moving backward changed the live player-marker coordinates.
- Pressing M opened the full map with the same artwork, player heading, entrance,
and eight numbered encounters. Pressing M again closed it. No browser page
errors were reported.
- Inspected `artifacts/playtest/runewaker-maps/forsaken-minimap.png` and
`forsaken-full-map.png`. The map was readable and did not cover the normal
playfield until opened. Navigation floorplans were checked by generated-image
inspection and catalog tests, not by live dungeon entry.
- Validation: 24 focused map/UI tests, TypeScript, production build, content
publication, and release candidate check passed.
## 2026-09-15 Reported Warder Stilgiss and Zereketh issues ## 2026-09-15 Reported Warder Stilgiss and Zereketh issues
- Reproduced the two submitted production reports in isolated Boss Testing - Reproduced the two submitted production reports in isolated Boss Testing
@@ -3932,3 +3947,57 @@ Vite's existing large-chunk advisory remains informational.
icon with a small `3`; the browser assertion also checked the icon count. icon with a small `3`; the browser assertion also checked the icon count.
This exercise checked HUD grouping, while focused combat tests checked four This exercise checked HUD grouping, while focused combat tests checked four
separate channel damage pulses. Source audio was not assessed by listening. separate channel damage pulses. Source audio was not assessed by listening.
## Raksha Temple collision performance (2026-09-26)
- A production-build offline playtest in headless Edge measured about 0.6 FPS
after Raksha Temple loaded. A CPU profile placed nearly all frame time in
Rapier character movement and physics. Its collision GLBs use interleaved
position buffers; passing the packed float array as XYZ vertices created
malformed trimeshes.
- After unpacking positions, the same 15-second sample recorded 600 frames at
16.67 ms average frame time (60 FPS). The entrance's WDB collision lacks a
walkable floor, so Raksha now uses its navigation mesh as physical ground.
The final browser pass retained 60 FPS, showed the party at 5/5 instead of
`ROUTE BLOCKED`, and kept the player at the entrance. An eight-second forward
movement sample explored 2 m and still held 60 FPS. Screenshots:
`playtest-artifacts/raksha-final-ground.png` and
`playtest-artifacts/raksha-moved.png`. The scene remains very dark;
this pass verified frame rate and floor support, not lighting quality.
## Scholomance rendering and population performance (2026-09-26)
- In a production-build offline run in headless Edge, Scholomance's entrance
averaged 31.78 ms per frame (about 31 FPS) over 15 seconds. A CPU profile
was dominated by Three.js render traversal and matrix updates, while the
visual asset audit counted 5,140 raw draw calls and 3,175 mesh nodes.
WebGL instrumentation saw about 867 draw calls per rendered frame before
the visual batching change.
- Scholomance had 191 static mob spawns within its former 95 m activation
radius at the entrance, across stacked indoor floors. Its 48 m radius and
global opaque instancing reduced the 15-second average to about 20.1 ms
per frame (about 50 FPS). Holding forward for 12 seconds explored 30 m;
the subsequent sample remained near 20.1 ms per frame with the party at
5/5. Entrance and movement screenshots showed intact geometry and no page
errors: `playtest-artifacts/scholomance-after.png` and
`playtest-artifacts/scholomance-moved.png`.
## Bite-Sized Dungeon Race (2026-09-26)
- `scripts/playtest-dungeon-race.cjs` used headless Edge with a fresh offline
character. Practice entered a Bite-Sized dungeon with five player-party seats,
displayed the top clock and NPC progress, and kept the game scene active
after a forced NPC win showed the “You lose” toast. The NPC roster contained
five members. Desktop and 960×540 Thor-size captures are in
`playtest-artifacts/dungeon-race/`.
- `scripts/playtest-dungeon-race-online.cjs` created two online accounts in
separate browser contexts. Both entered the same human match, dungeon, and
seed with each other as opponents. A third account queued alone and received
a five-member NPC team after the five-second fallback. Both scripts reported
no browser page errors.
- The practice screenshot check used a time jump to trigger the loss toast,
then injected a defeated boss-card fixture into the active combat store. The
normal automatic loot action added one Bazil Thredd card after the loss and
showed its reward window (`05-card-after-loss-fixture.png`). It did not
traverse and defeat the final boss through live combat. Server tests verify
that a losing clear is accepted for the dungeon time board.
+39
View File
@@ -0,0 +1,39 @@
# RuneWaker dungeon maps
The RuneWaker minimap shows a cropped dungeon map centered on the live player
marker. Press **M** or click the minimap to open the full map. The player marker
and heading, entrance, and numbered encounters use the same coordinate projection
in both views. The full map closes with **M**. The existing map keybind and minimap
rotation preference apply to RuneWaker dungeons too.
All 30 imported RuneWaker dungeon definitions have maps. Fifteen use calibrated
minimap tiles from the extracted RuneWaker client. Fifteen use floorplans rendered
from each dungeon's imported navigation mesh because matching client tiles are
missing or cannot be aligned confidently. The floorplans still place live markers
and encounters at their runtime coordinates. The UI labels a floorplan as such.
## Reimporting
Run `python scripts/runewaker-pipeline/import-maps.py` from the HealerMan root with
Pillow and NumPy available. `RUNEWAKER_ROOT` can point to the extracted RuneWaker
client; the default is `../Runewaker`. The importer reads DDS tiles under
`Resource/texture/minimap`, the primary WDB placement in each dungeon's
`import-metadata.json`, and the navigation GLB. It measures alignment between the
tile artwork and navigation geometry, accepts client artwork at confidence 0.65
or higher, and creates a navigation floorplan otherwise.
The generated PNGs live at `public/assets/game/dungeons/<id>/maps/runewaker-map.png`.
The runtime projection and source checksums live in
`src/game/generated/runewakerDungeonMaps.generated.json`. The map loader rejects
catalog entries when the dungeon map ID, coordinate transform, or visual checksum
no longer matches the runtime definition. Reimport after changing those inputs.
Run `npm run content:publish` after reimporting so downloadable dungeon packs
contain their map PNGs, then `npm run build` to refresh the bundled app.
## Verification
Run `node node_modules/vitest/vitest.mjs run src/game/dungeonArtworkMap.test.ts src/ui/DungeonMapGraphic.test.ts src/ui/MapPanel.test.ts`.
`node scripts/playtest-runewaker-maps.cjs` checks Forsaken Abbey in a browser,
including its downloaded artwork, minimap, M-key full map, and marker projection.
Screenshots are written to `artifacts/playtest/runewaker-maps/`.
+110
View File
@@ -0,0 +1,110 @@
# WoW bite-sized dungeon slice audit
## Scope and decision rule
This is a first-pass design audit of the 51 standard WoW dungeons already imported by Healer Man (Classic through Wrath of the Lich King). The map atlas and campaign catalog are the project source of truth for the layouts and encounter names. Three additional imported definitions are Healer Man / Ascension custom dungeons, so they are not part of this WoW pass.
A good slice should feel like a complete short outing: a clear reason to enter, one strong visual space or route, a handful of combat beats, and one satisfying final encounter. The closest existing model is **Stormwind Stockade: Cellblock Rush**: keep the original environment, retain one route, wall off the side wings, and finish on one seeded boss. Some source dungeons have a scripted event that makes an even better slice, but that event also raises implementation cost.
Ratings:
- **A — prototype first:** a distinctive, self-contained route or set piece with a natural finish.
- **B — promising:** a useful slice exists, but topology, gates, optional routes, or event handling need more design.
- **C — keep whole / no clean cut:** the full dungeon is already compact or event-led, or a cut would lose what makes the instance work.
The three first-pass candidates below are now registered as derived Bite-Sized dungeons. This is a content prototype, not a claim that the routes have passed live traversal: the corridor filters, collision, companion navigation, encounter starts, wipes, completion, and results still need an in-game pass.
| Prototype | Implemented slice | Open playtest question |
| --- | --- | --- |
| Wailing Caverns — Naralex’s Rescue | Starts at the Disciple’s camp, follows the imported escort waypoints, runs the source ravager/serpent/nightmare waves, then activates Mutanus. The four Fanglords are skipped. | Confirm the filtered trash corridor tracks the cave path and the moving Disciple stays reachable through the bends. |
| The Deadmines — Take the Ship | Starts at Mr. Smite’s ship ramp, clears the ship population, gates VanCleef behind Captain Greenskin, then ends in the cabin. | Check the ship corridor boundary and make sure the omitted Cookie branch does not invite a misleading detour. |
| Utgarde Pinnacle — Skadi’s Gauntlet | Starts at Eagle’s Eye, runs the source first wave, three harpoon interactions and finite reinforcements, then grounds and completes Skadi. | Check the long approach, interaction distances, and companion behavior around the event’s ground-based harpoon adaptation. |
Each derived ID resolves to its source dungeon’s content pack; the original full-length dungeon remains unchanged. The new entries are labeled Bite-Sized and beta until those playtests are recorded.
## Best first prototypes
| Rank | Dungeon slice | Why it fits | Main implementation question |
| --- | --- | --- | --- |
| 1 | **Wailing Caverns — Naralex’s Rescue:** start at the Disciple’s event point, escort through the lower caves, defend Naralex through the waves, then fight Mutanus. | It already has the “journey to the final boss” shape: a route through recognizable cave spaces that builds to a protected NPC and a final monster. This matches the example you gave. | In the original route the event is unlocked after the Fang leaders and starts back near the entrance. For a short version, provide a clean event start/checkpoint and preserve the escort plus wave sequence. [Dungeon overview](https://warcraft.wiki.gg/wiki/Wailing_Caverns) |
| 2 | **The Deadmines — Take the Ship:** enter at Mr. Smite’s ramp, clear the ship, fight Captain Greenskin, then finish Edwin VanCleef in the cabin. | One strong visual set piece, a straightforward forward route, and a natural boss finale. The optional Cookie detour can be left out. [Classic guide](https://warcraft.wiki.gg/wiki/Deadmines_%28Classic%29) |
| 3 | **Zul’Farrak — The Staircase:** start at the pyramid event, survive Sergeant Bly’s descending waves, and finish at Chief Ukorz’s chamber. | The stair defense is already a compact wave encounter with a clear spatial progression; it could be a bite-sized run by itself or a short prelude into a boss. | Choose whether Bly and his group are protected participants or replace the event with a controlled wave sequence; the event’s outcome opens the Ukorz route. [Instance map and event guide](https://ptgmedia.pearsoncmg.com/imprint_downloads/brady/wow/zulfarrak/zulfarrak_lr.pdf) |
| 4 | **The Blood Furnace — Broggok’s Breakout:** begin at the prison cells, release the waves, then push through the final corridor to Keli’dan. | A tight industrial route with a built-in escalation and a boss payoff. The map is compact and the three encounters already form a clean progression. [Dungeon overview](https://warcraft.wiki.gg/wiki/Blood_Furnace) |
| 5 | **Utgarde Pinnacle — Skadi’s Gauntlet:** start as Skadi retreats, fight down Eagle’s Eye through the advancing packs, collect harpoons, and bring Skadi down. | One of WoW’s clearest self-contained dungeon set pieces: moving waves, a visible target, a hazard lane, an objective pickup, and a boss. It can end at Skadi without carrying the whole dungeon. [Encounter guide](https://www.icy-veins.com/wotlk-classic/utgarde-pinnacle-dungeon-guide) |
| 6 | **Pit of Saron — The Quarry Breakout:** free the enslaved workers, clear the ambushes and tunnel, then confront Tyrannus. | A linear outdoor quarry gives the route space to breathe, and the rescue objective gives a strong reason to advance. The original instance already frames the final boss around freeing the slaves. [Dungeon overview](https://warcraft.wiki.gg/wiki/Pit_of_Saron) |
| 7 | **The Shattered Halls — Kargath’s Gauntlet:** start around Warbringer O’Mrogg, push through the final gauntlet, and finish in Kargath’s arena. | A direct late-dungeon route with a clear arena payoff. It works best if the final hallway pressure is the feature, not just a boss rush. |
| 8 | **Shadow Labyrinth — The Screaming Hall:** begin around Grandmaster Vorpil’s chamber and advance along the final hall to Murmur. | The project map shows a long, distinct finale corridor, and the two-boss ending provides a natural short arc without needing to reproduce the whole labyrinth. [Dungeon overview](https://warcraft.wiki.gg/wiki/Shadow_Labyrinth) |
| 9 | **Halls of Lightning — Ionar to Loken:** use the storm overlook and the route into Loken’s sanctum as the final ascent. | Strong visual progression from open storm space to titan interior; Ionar already guards the entrance to Loken’s sanctum. [Ionar’s encounter context](https://warcraft.wiki.gg/wiki/Ionar) |
**My pick for the first batch:** Wailing Caverns, Deadmines, and Skadi’s Gauntlet. Together they give three distinct forms—escort-and-defense, a compact boss route, and an event gauntlet—while staying close to routes the source game already supports.
## Full 51-dungeon scan
| Expansion | Dungeon | Best bite-sized slice to investigate | Rating |
| --- | --- | --- | :---: |
| Classic | Shadowfang Keep | Fenrus through Wolf Master Nandos and up to Arugal’s tower. | B |
| Classic | Stormwind Stockade | The existing central Cellblock Rush is already the reference design. | C |
| Classic | The Deadmines | Mr. Smite’s ship ramp through Greenskin to VanCleef’s cabin. | A |
| Classic | Wailing Caverns | Naralex escort route, defense waves, and Mutanus. | A |
| Classic | Razorfen Kraul | The deeper boar warrens and Charlga’s area; the open, branching route needs a firm start boundary. | B |
| Classic | Blackfathom Deeps | Kelris’s altar ritual and the opened route to Aku’mai. | B |
| Classic | Uldaman | The late titan-vault approach to Archaedas. | B |
| Classic | Gnomeregan | The Clean Zone and final approach to Thermaplugg. | B |
| Classic | Sunken Temple | The lower sanctum / Avatar of Hakkar ritual through Shade of Eranikus. | B |
| Classic | Razorfen Downs | The Spiral of Thorns and Amnennar’s crypt, with the optional Belnistrasz escort kept as a variant. | B |
| Classic | Scarlet Monastery | Use the Cathedral wing as a complete bite-sized outing; it is a wing-sized instance, not a clean internal cut. | B |
| Classic | Zul’Farrak | The staircase defense, optionally continuing to Ukorz. | A |
| Classic | Blackrock Spire | The Upper Spire final ascent from Rend’s arena toward Drakkisath. Strong setting, but multiple floors and gates. | B |
| Classic | Blackrock Depths | No first choice: the instance is a large connected maze of branches, keys, and optional bosses. A later pass could define one exact route. | C |
| Classic | Scholomance | The upper-school finale from the Barov wing to Ras Frostwhisper. | B |
| Classic | Stratholme | The undead-quarter approach and final Baron fight; keep the timed Gauntlet as the main pressure beat. | B |
| Classic | Maraudon | The inner cavern / waterfall route to Princess Theradras. | B |
| Classic | Ragefire Chasm | Keep the whole short instance; cutting it further would leave too little journey. | C |
| Classic | Dire Maul | Pick one wing only—especially the West Wing route through Immol’thar to Prince Tortheldrin. The combined map has too many cross-wing choices for a casual cut. | B |
| Burning Crusade | Hellfire Ramparts | Keep the full compact instance rather than extracting one corridor. | C |
| Burning Crusade | The Blood Furnace | Broggok’s prison event through Keli’dan. | A |
| Burning Crusade | The Shattered Halls | O’Mrogg through the final gauntlet into Kargath’s arena. | A |
| Burning Crusade | The Slave Pens | The lower tunnels from Rokmar to Quagmirran; very short, so include both encounters and the water route. | B |
| Burning Crusade | The Underbog | The late fungal/swamp route from Swamplord Musel’ek to the Black Stalker. | B |
| Burning Crusade | The Steamvault | The lower waterworks and final route to Kalithresh. | B |
| Burning Crusade | The Arcatraz | The last containment section and release sequence leading to Skyriss. | B |
| Burning Crusade | The Botanica | The final greenhouse route from Laj to Warp Splinter. | B |
| Burning Crusade | The Mechanar | The upper-platform route from Sepethrea to Pathaleon; elevators and crossing routes need to be kept legible. | B |
| Burning Crusade | Shadow Labyrinth | Vorpil through the Screaming Hall to Murmur. | A |
| Burning Crusade | Sethekk Halls | Keep the whole short two-boss instance. | C |
| Burning Crusade | Mana-Tombs | Keep the full three-boss route; no internal cut improves it much. | C |
| Burning Crusade | Auchenai Crypts | Keep the full compact two-boss run. | C |
| Burning Crusade | Magisters’ Terrace | Vexallus through the final Kael’thas chamber; a viable one-boss finale if the full instance is too long. | B |
| Burning Crusade | The Black Morass | Keep the full portal-defense event; splitting it would remove the core mechanic. | C |
| Burning Crusade | The Escape from Durnholde | Keep the full escort-and-escape story; it is already a self-contained event run. | C |
| Wrath | Utgarde Keep | Keep the full short three-boss route. | C |
| Wrath | Utgarde Pinnacle | Eagle’s Eye wave gauntlet, harpoons, and Skadi. | A |
| Wrath | The Nexus | A single mage/arcane wing could work, but the boss route crosses a hub; map a route before choosing a cut. | B |
| Wrath | The Oculus | No first choice: drake riding and multi-level flight are the dungeon’s identity and make a small walking route misleading. | C |
| Wrath | The Culling of Stratholme | Keep the whole compact timed story run, including its city push and final encounter. | C |
| Wrath | Halls of Stone | The Tribunal event into Sjonnir is a good short encounter episode; use the full instance if event staging is too costly. | B |
| Wrath | Drak’Tharon Keep | King Dred through the upper temple to Tharon’ja. | B |
| Wrath | Azjol-Nerub | Keep the full compact three-boss descent; the instance already reads as one short journey. | C |
| Wrath | Halls of Lightning | Ionar’s overlook into Loken’s sanctum. | A |
| Wrath | Gundrak | The late temple route from Moorabi toward Gal’darah; the connected ramps and branches need a deliberate route boundary. | B |
| Wrath | Violet Hold | Keep the full prison-defense event; the wave sequence is the dungeon. | C |
| Wrath | Ahn’kahet: The Old Kingdom | The last organic/Old God passage from Jedoga toward Herald Volazj; leave the optional Amanitar branch out. | B |
| Wrath | The Forge of Souls | Keep the full two-boss instance. | C |
| Wrath | Trial of the Champion | Keep the full arena event; its staged opponent sequence is already bite-sized. | C |
| Wrath | Pit of Saron | Slave rescue, quarry ambushes, tunnel, and Tyrannus. | A |
| Wrath | Halls of Reflection | Keep the full two-boss defense and escape sequence. | C |
## Design takeaways
- Favor a starting point at a real midpoint or event trigger, then seal or ignore side routes the way the Stockades variant does.
- One boss is enough when the route has a clear event or traversal payoff. Two bosses work when both are close together, as on the Deadmines ship.
- The best slice is not always the last two bosses. The Zul’Farrak stairs and Skadi’s gauntlet are strong because the route itself is the memorable content.
- Avoid slices whose identity depends on a complex escort, vehicle, multi-floor hub, or many interactable gates unless that event is the specific feature being built.
- Use the imported maps for candidate boundaries, then confirm start position, reachable path, enemy density, and encounter state in the running game before implementation.
## Project references
- [WoW dungeon maps and import scope](WOW_DUNGEON_MAPS.md)
- [Existing bite-sized Stockades definition](../src/game/dungeons/biteSizedStockades.ts)
- [Current WoW dungeon functionality gate](../dungeon-pipeline/WOW_DUNGEON_FUNCTIONALITY_REPORT.md)
- [Generated campaign catalog and encounter order](../src/game/generated/dungeonCampaignCatalog.compressed.ts)
+3 -2
View File
@@ -12,8 +12,9 @@ the selected floor are not clamped to a misleading marker at the map edge.
The current import includes 51 standard WoW dungeons and 107 unique map images. The The current import includes 51 standard WoW dungeons and 107 unique map images. The
custom Tinkertech Showdown, Frozen Reach, and Forgotten Mine have no matching map custom Tinkertech Showdown, Frozen Reach, and Forgotten Mine have no matching map
records in the supplied archives and retain their survey maps. RuneWaker maps are records in the supplied archives and retain their survey maps. Missing images also
unchanged. Missing images also fall back to the survey projection. fall back to the survey projection. RuneWaker dungeons use the same map controls;
see [RuneWaker dungeon maps](RUNEWAKER_DUNGEON_MAPS.md).
## Reimporting ## Reimporting
Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+69
View File
@@ -0,0 +1,69 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const base = process.env.RACE_TEST_URL || 'http://127.0.0.1:5173';
const nonce = Date.now().toString(36);
async function createPlayer(browser, suffix) {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.getByLabel('Username').fill(`Race${suffix}${nonce}`.slice(0, 20));
await page.getByLabel('Password').fill('raceTestPassword123');
await page.getByRole('button', { name: /Create account/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill(`Racer${suffix}`);
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({ force: true });
try { await page.locator('.main-menu-screen').waitFor(); }
catch (error) { console.error((await page.locator('body').innerText()).slice(0, 2500)); throw error; }
await page.getByRole('button', { name: /Dungeon Race/ }).click();
await page.locator('.dungeon-race-screen').waitFor();
assert.equal(await page.getByRole('button', { name: 'Online ranked' }).getAttribute('aria-pressed'), 'true');
return { page, context, errors };
}
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const players = [];
try {
players.push(await createPlayer(browser, 'A'));
players.push(await createPlayer(browser, 'B'));
await Promise.all(players.map(({ page }) => page.getByRole('button', { name: 'Enter race queue' }).click()));
await Promise.all(players.map(({ page }) => page.locator('.game-shell').waitFor({ timeout: 120_000 })));
const states = await Promise.all(players.map(async ({ page }) => page.evaluate(async () => {
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
const race = useDungeonRaceStore.getState();
return { matchId: race.matchId, dungeonId: race.dungeonId, seed: race.seed,
opponentType: race.opponent?.type, opponentName: race.opponent?.name };
})));
assert.equal(states[0].matchId, states[1].matchId);
assert.equal(states[0].dungeonId, states[1].dungeonId);
assert.equal(states[0].seed, states[1].seed);
assert.equal(states[0].opponentType, 'human');
assert.equal(states[1].opponentType, 'human');
assert.deepEqual(players.flatMap(({ errors }) => errors), []);
await Promise.all(players.map(({ context }) => context.close()));
const solo = await createPlayer(browser, 'Solo');
players.push(solo);
await solo.page.getByRole('button', { name: 'Enter race queue' }).click();
await solo.page.locator('.game-shell').waitFor({ timeout: 120_000 });
const fallback = await solo.page.evaluate(async () => {
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
const race = useDungeonRaceStore.getState();
return { matchId: race.matchId, opponentType: race.opponent?.type,
seats: race.opponent?.type === 'bot' ? race.opponent.roster.length : 0 };
});
assert.equal(fallback.opponentType, 'bot');
assert.equal(fallback.seats, 5);
assert.deepEqual(solo.errors, []);
console.log(JSON.stringify({ ok: true, states, fallback }, null, 2));
} finally {
await Promise.all(players.map(({ context }) => context.close()));
await browser.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+89
View File
@@ -0,0 +1,89 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const out = path.resolve('playtest-artifacts/dungeon-race');
fs.mkdirSync(out, { recursive: true });
const base = process.env.RACE_TEST_URL || 'http://127.0.0.1:5173';
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const errors = [];
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.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('RaceTester');
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();
await page.getByRole('button', { name: /Dungeon Race/ }).click();
await page.locator('.dungeon-race-screen').waitFor();
await page.screenshot({ path: path.join(out, '01-setup-desktop.png') });
assert.equal(await page.getByRole('button', { name: 'Offline practice' }).getAttribute('aria-pressed'), 'true');
await page.locator('.dungeon-race-screen__setup select').first()
.selectOption('bite-sized-stormwind-stockade');
await page.getByRole('button', { name: 'Start practice' }).click();
await page.locator('.game-shell').waitFor({ timeout: 120_000 });
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 });
await page.screenshot({ path: path.join(out, '02-active-desktop.png') });
const state = await page.evaluate(async () => {
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
const { useGameStore } = await import('/src/game/store.ts');
const race = useDungeonRaceStore.getState();
return { mode: race.mode, status: race.status, inGame: race.inGame,
dungeonId: race.dungeonId, activeDungeonId: useGameStore.getState().activeDungeonId,
opponentSeats: race.opponent?.type === 'bot' ? race.opponent.roster.length : 0 };
});
assert.equal(state.mode, 'practice');
assert.equal(state.status, 'active');
assert.equal(state.inGame, true);
assert.equal(state.opponentSeats, 5);
assert.equal(state.activeDungeonId, state.dungeonId);
await page.evaluate(async () => {
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
const race = useDungeonRaceStore.getState();
useDungeonRaceStore.setState({ startAt: Date.now() - race.opponent.finishMs - 1000, result: null });
useDungeonRaceStore.getState().tick();
});
await page.locator('.dungeon-race-toast--loss').waitFor();
assert.equal(await page.locator('.game-shell').count(), 1);
await page.screenshot({ path: path.join(out, '03-loss-continues-desktop.png') });
await page.setViewportSize({ width: 960, height: 540 });
await page.screenshot({ path: path.join(out, '04-loss-thor-size.png') });
const card = await page.evaluate(async () => {
const { useCombatStore } = await import('/src/game/combatStore.ts');
const { BOSS_CARD_CATALOG } = await import('/src/game/bossCardCatalog.ts');
const definition = BOSS_CARD_CATALOG.find((entry) => entry.dungeons.some((dungeon) => dungeon.id === 'stormwind-stockade'));
const mob = Object.values(useCombatStore.getState().mobs)[0];
if (!definition || !mob) throw new Error(`Boss-card fixture is unavailable: definition=${Boolean(definition)} mob=${Boolean(mob)}.`);
const before = useCombatStore.getState().bossCards.counts[definition.key]?.normal ?? 0;
useCombatStore.setState((state) => ({ mobs: { ...state.mobs,
[mob.id]: { ...mob, name: definition.name, boss: true, dead: true, hasLoot: true,
looted: false, despawned: false, pendingLoot: [],
bossCardDrop: { bossKey: definition.key, spawnId: mob.id,
claimId: `${Date.now()}:1`, rarities: ['normal'] } } } }));
const looted = useCombatStore.getState().lootMob(mob.id);
return { looted, before, after: useCombatStore.getState().bossCards.counts[definition.key]?.normal ?? 0 };
});
console.log('Post-loss boss-card result:', card);
assert.equal(card.after, card.before + 1);
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(out, '05-card-after-loss-fixture.png') });
assert.deepEqual(errors, []);
console.log(JSON.stringify({ ok: true, state, card, screenshots: out }, null, 2));
} catch (error) {
await page.screenshot({ path: path.join(out, 'failure.png') });
console.error((await page.locator('body').innerText()).slice(0, 5000));
throw error;
} finally { await browser.close(); }
})().catch((error) => { console.error(error); process.exitCode = 1; });
+89
View File
@@ -0,0 +1,89 @@
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 os = require('node:os');
const path = require('node:path');
const directory = path.resolve('artifacts/playtest/runewaker-maps');
const port = 4197;
const base = `http://127.0.0.1:${port}`;
fs.mkdirSync(directory, { recursive: true });
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-runewaker-maps-'));
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: runtimeData, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(port, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
page.setDefaultTimeout(180_000);
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.getByLabel('Username').fill('RunewakerMapQA');
await page.getByLabel('Password').fill('runewaker-map-playtest');
await page.getByRole('button', { name: /Create account/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
await page.locator('.class-option').filter({ hasText: 'Rogue' }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('RunewakerMapQA');
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.locator('.roster-enter').evaluate((button) => button.click());
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /Dungeons/ }).click();
await page.locator('.dungeon-category-tab').filter({ hasText: 'RoM' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Forsaken Abbey' }).click();
console.log('Selected Forsaken Abbey; installing its content pack');
await page.getByRole('button', { name: 'Download and continue' }).click();
await page.locator('.dungeon-role-picker').waitFor();
console.log('Content installed; entering as damage');
await page.locator('.dungeon-role-option--damage').click();
await page.locator('.hud-minimap svg').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'detached' });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached' });
await page.locator('.hud-minimap svg image').waitFor();
await page.screenshot({ path: path.join(directory, 'forsaken-minimap.png') });
const minimapArt = await page.locator('.hud-minimap svg image').getAttribute('href');
assert.match(minimapArt, /forsaken-abbey\/maps\/runewaker-map\.png/);
const playerPosition = async () => {
const marker = page.locator('.hud-minimap .map-player');
return [Number(await marker.getAttribute('cx')), Number(await marker.getAttribute('cy'))];
};
const beforeMovement = await playerPosition();
await page.keyboard.down('s');
await page.waitForTimeout(1000);
await page.keyboard.up('s');
await page.waitForTimeout(250);
const afterMovement = await playerPosition();
assert.ok(Math.hypot(afterMovement[0] - beforeMovement[0], afterMovement[1] - beforeMovement[1]) > 0.1,
`Minimap marker did not move: ${beforeMovement} -> ${afterMovement}`);
await page.keyboard.press('m');
await page.locator('.map-panel svg image').waitFor();
await page.locator('.map-panel svg[data-artwork="ready"]').waitFor();
await page.screenshot({ path: path.join(directory, 'forsaken-full-map.png') });
assert.match(await page.locator('.map-panel svg image').getAttribute('href'), /forsaken-abbey\/maps\/runewaker-map\.png/);
const marker = page.locator('.map-panel .map-player');
assert.ok(Number.isFinite(Number(await marker.getAttribute('cx'))));
assert.ok(Number.isFinite(Number(await marker.getAttribute('cy'))));
await page.keyboard.press('m');
await page.locator('.map-panel').waitFor({ state: 'detached' });
assert.deepEqual(pageErrors, []);
console.log(`RuneWaker minimap and M-key screenshots: ${directory}`);
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
const resolved = path.resolve(runtimeData);
assert.ok(resolved.startsWith(path.resolve(os.tmpdir()) + path.sep));
fs.rmSync(resolved, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+245 -115
View File
@@ -555,13 +555,13 @@ def launch_gui(*, smoke_test: bool = False) -> int:
from tkinter import filedialog, messagebox, scrolledtext, ttk from tkinter import filedialog, messagebox, scrolledtext, ttk
class ReleaseManagerApp: class ReleaseManagerApp:
BG = "#0c1515" BG = "#0b1220"
PANEL = "#122222" PANEL = "#172337"
PANEL_2 = "#172b2a" PANEL_2 = "#22344d"
TEXT = "#e9f4ef" TEXT = "#f2f6fc"
MUTED = "#91aaa2" MUTED = "#aabbd0"
ACCENT = "#63d9a4" ACCENT = "#63dfb5"
ACCENT_DARK = "#183b31" ACCENT_DARK = "#214a47"
WARNING = "#f2c66d" WARNING = "#f2c66d"
ERROR = "#ff8b83" ERROR = "#ff8b83"
@@ -580,63 +580,66 @@ def launch_gui(*, smoke_test: bool = False) -> int:
def _configure_window(self) -> None: def _configure_window(self) -> None:
self.root.title("Healer Man Release Manager") self.root.title("Healer Man Release Manager")
self.root.geometry("1120x820") width = min(1100, max(700, self.root.winfo_screenwidth() - 60))
self.root.minsize(960, 700) height = min(740, max(500, self.root.winfo_screenheight() - 100))
self.root.geometry(f"{width}x{height}")
self.root.minsize(700, 500)
self.root.configure(bg=self.BG) self.root.configure(bg=self.BG)
style = ttk.Style(self.root) style = ttk.Style(self.root)
try: try:
style.theme_use("clam") style.theme_use("clam")
except tk.TclError: except tk.TclError:
pass pass
style.configure(".", background=self.BG, foreground=self.TEXT) style.configure(".", background=self.BG, foreground=self.TEXT, font=("Segoe UI", 10))
style.configure("TFrame", background=self.BG) style.configure("TFrame", background=self.BG)
style.configure("Panel.TFrame", background=self.PANEL) style.configure("Panel.TFrame", background=self.PANEL)
style.configure(
"Title.TLabel",
background=self.BG,
foreground=self.TEXT,
font=("Segoe UI Semibold", 20),
)
style.configure(
"Sub.TLabel",
background=self.BG,
foreground=self.MUTED,
font=("Segoe UI", 10),
)
style.configure( style.configure(
"Panel.TLabel", background=self.PANEL, foreground=self.TEXT "Panel.TLabel", background=self.PANEL, foreground=self.TEXT
) )
style.configure( style.configure(
"Muted.Panel.TLabel", background=self.PANEL, foreground=self.MUTED "Muted.Panel.TLabel", background=self.PANEL, foreground=self.MUTED
) )
style.configure(
"Section.Panel.TLabel",
background=self.PANEL,
foreground=self.TEXT,
font=("Segoe UI Semibold", 13),
)
style.configure( style.configure(
"Accent.TButton", "Accent.TButton",
background=self.ACCENT, background=self.ACCENT,
foreground="#06110d", foreground="#082019",
font=("Segoe UI Semibold", 10), font=("Segoe UI Semibold", 10),
padding=(13, 9), padding=(16, 11),
) )
style.map("Accent.TButton", background=[("active", "#84e7bb")]) style.map("Accent.TButton", background=[("active", "#8af1cc"), ("disabled", self.PANEL_2)])
style.configure("TButton", padding=(10, 7)) style.configure("TButton", background=self.PANEL_2, foreground=self.TEXT, padding=(12, 8))
style.map("TButton", background=[("active", "#314866")], foreground=[("disabled", self.MUTED)])
style.configure("Panel.TCheckbutton", background=self.PANEL, foreground=self.TEXT)
style.map("Panel.TCheckbutton", background=[("active", self.PANEL)])
style.configure("Panel.TRadiobutton", background=self.PANEL, foreground=self.TEXT)
style.map("Panel.TRadiobutton", background=[("active", self.PANEL)])
style.configure( style.configure(
"TEntry", "TEntry",
fieldbackground="#0a1716", fieldbackground="#101b2b",
foreground=self.TEXT, foreground=self.TEXT,
insertcolor=self.TEXT, insertcolor=self.TEXT,
bordercolor="#31504a", bordercolor="#3b5068",
padding=6,
) )
style.configure( style.configure(
"TCombobox", "TCombobox",
fieldbackground="#0a1716", fieldbackground="#101b2b",
foreground=self.TEXT, foreground=self.TEXT,
arrowcolor=self.TEXT, arrowcolor=self.TEXT,
) )
style.configure("Horizontal.TProgressbar", background=self.ACCENT, troughcolor=self.PANEL_2)
style.configure("TNotebook", background=self.PANEL, borderwidth=0) style.configure("TNotebook", background=self.PANEL, borderwidth=0)
style.configure( style.configure(
"TNotebook.Tab", "TNotebook.Tab",
background=self.PANEL_2, background=self.PANEL_2,
foreground=self.MUTED, foreground=self.MUTED,
padding=(12, 7), padding=(17, 9),
) )
style.map( style.map(
"TNotebook.Tab", "TNotebook.Tab",
@@ -645,33 +648,81 @@ def launch_gui(*, smoke_test: bool = False) -> int:
) )
def _build_ui(self) -> None: def _build_ui(self) -> None:
shell = ttk.Frame(self.root, padding=18) shell = ttk.Frame(self.root, padding=10)
shell.pack(fill="both", expand=True) shell.pack(fill="both", expand=True)
shell.columnconfigure(0, weight=3) shell.columnconfigure(0, weight=1)
shell.columnconfigure(1, weight=2)
shell.rowconfigure(2, weight=1) shell.rowconfigure(2, weight=1)
title = ttk.Frame(shell) mode_panel = ttk.Frame(shell, style="Panel.TFrame", padding=(10, 6))
title.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 14)) mode_panel.grid(row=0, column=0, sticky="ew", pady=(0, 8))
title.columnconfigure(0, weight=1) mode_panel.columnconfigure(3, weight=1)
ttk.Label(title, text="Healer Man Release Manager", style="Title.TLabel").grid( ttk.Label(mode_panel, text="Mode", style="Muted.Panel.TLabel").grid(
row=0, column=0, sticky="w" row=0, column=0, sticky="w", padx=(0, 12)
) )
ttk.Label( self.git_truenas_only_var = tk.BooleanVar(value=DEFAULT_GIT_TRUENAS_ONLY)
title, self.release_mode_toggle = ttk.Radiobutton(
text="Validate → inspect → commit → push → deploy, with optional signed APK", mode_panel,
style="Sub.TLabel", text="Git + TrueNAS",
).grid(row=1, column=0, sticky="w", pady=(2, 0)) variable=self.git_truenas_only_var,
self.refresh_button = ttk.Button(title, text="Refresh", command=self.refresh) value=True,
self.refresh_button.grid(row=0, column=1, rowspan=2, sticky="e") command=self._update_release_mode,
style="Panel.TRadiobutton",
)
self.release_mode_toggle.grid(row=0, column=1, sticky="w", padx=(0, 18))
self.apk_mode_toggle = ttk.Radiobutton(
mode_panel,
text="Include signed APK",
variable=self.git_truenas_only_var,
value=False,
command=self._update_release_mode,
style="Panel.TRadiobutton",
)
self.apk_mode_toggle.grid(row=0, column=2, sticky="w")
self.refresh_button = ttk.Button(mode_panel, text="Refresh", command=self.refresh)
self.refresh_button.grid(row=0, column=4, sticky="e")
self.git_summary_var = tk.StringVar(value="Loading Git status…")
release_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15) setup_host = ttk.Frame(shell)
release_panel.grid(row=1, column=0, sticky="nsew", padx=(0, 8)) setup_host.grid(row=1, column=0, sticky="nsew")
setup_host.columnconfigure(0, weight=1)
setup_host.rowconfigure(0, weight=1)
self.setup_canvas = tk.Canvas(
setup_host, bg=self.BG, highlightthickness=0, borderwidth=0, height=220
)
self.setup_canvas.grid(row=0, column=0, sticky="nsew")
setup_scrollbar = ttk.Scrollbar(
setup_host, orient="vertical", command=self.setup_canvas.yview
)
setup_scrollbar.grid(row=0, column=1, sticky="ns", padx=(4, 0))
self.setup_canvas.configure(yscrollcommand=setup_scrollbar.set)
self.setup_content = ttk.Frame(self.setup_canvas)
self.setup_content.columnconfigure(0, weight=3)
self.setup_content.columnconfigure(1, weight=2)
self.setup_window = self.setup_canvas.create_window(
(0, 0), window=self.setup_content, anchor="nw"
)
self.setup_canvas.bind(
"<Configure>",
lambda event: self.setup_canvas.itemconfigure(self.setup_window, width=event.width),
)
self.setup_content.bind(
"<Configure>",
lambda _event: self.setup_canvas.configure(
scrollregion=self.setup_canvas.bbox("all")
),
)
self.setup_canvas.bind(
"<MouseWheel>",
self._scroll_setup,
)
release_panel = ttk.Frame(self.setup_content, style="Panel.TFrame", padding=12)
release_panel.grid(row=0, column=0, sticky="nsew")
self.release_panel = release_panel
release_panel.columnconfigure(1, weight=1) release_panel.columnconfigure(1, weight=1)
ttk.Label( ttk.Label(
release_panel, text="Release details", style="Panel.TLabel", release_panel, text="Release details", style="Section.Panel.TLabel",
font=("Segoe UI Semibold", 12), ).grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 8))
).grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 11))
self.current_version_var = tk.StringVar(value="—") self.current_version_var = tk.StringVar(value="—")
self.current_code_var = tk.StringVar(value="—") self.current_code_var = tk.StringVar(value="—")
@@ -679,11 +730,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.next_version_var = tk.StringVar() self.next_version_var = tk.StringVar()
self.next_code_var = tk.StringVar() self.next_code_var = tk.StringVar()
self.commit_message_var = tk.StringVar() self.commit_message_var = tk.StringVar()
self.git_truenas_only_var = tk.BooleanVar(
value=DEFAULT_GIT_TRUENAS_ONLY
)
self.run_tests_var = tk.BooleanVar(value=DEFAULT_RUN_TESTS) self.run_tests_var = tk.BooleanVar(value=DEFAULT_RUN_TESTS)
self.release_mode_hint_var = tk.StringVar()
self.test_mode_hint_var = tk.StringVar() self.test_mode_hint_var = tk.StringVar()
ttk.Label(release_panel, text="Current", style="Muted.Panel.TLabel").grid( ttk.Label(release_panel, text="Current", style="Muted.Panel.TLabel").grid(
@@ -700,7 +747,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
) )
ttk.Label(release_panel, text="Bump", style="Muted.Panel.TLabel").grid( ttk.Label(release_panel, text="Bump", style="Muted.Panel.TLabel").grid(
row=2, column=0, sticky="w", pady=(12, 0) row=2, column=0, sticky="w", pady=(7, 0)
) )
self.bump_box = ttk.Combobox( self.bump_box = ttk.Combobox(
release_panel, release_panel,
@@ -709,7 +756,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
state="readonly", state="readonly",
width=10, width=10,
) )
self.bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0)) self.bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(7, 0))
self.bump_box.bind("<<ComboboxSelected>>", lambda _event: self._suggest_versions()) self.bump_box.bind("<<ComboboxSelected>>", lambda _event: self._suggest_versions())
ttk.Label(release_panel, text="Next version", style="Muted.Panel.TLabel").grid( ttk.Label(release_panel, text="Next version", style="Muted.Panel.TLabel").grid(
row=3, column=0, sticky="w", pady=(9, 0) row=3, column=0, sticky="w", pady=(9, 0)
@@ -740,9 +787,9 @@ def launch_gui(*, smoke_test: bool = False) -> int:
) )
self.release_notes = tk.Text( self.release_notes = tk.Text(
release_panel, release_panel,
height=4, height=3,
wrap="word", wrap="word",
bg="#0a1716", bg="#101b2b",
fg=self.TEXT, fg=self.TEXT,
insertbackground=self.TEXT, insertbackground=self.TEXT,
relief="flat", relief="flat",
@@ -753,48 +800,40 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.release_notes.grid( self.release_notes.grid(
row=5, column=1, columnspan=3, sticky="nsew", padx=(8, 0), pady=(9, 0) row=5, column=1, columnspan=3, sticky="nsew", padx=(8, 0), pady=(9, 0)
) )
self.release_mode_toggle = ttk.Checkbutton(
release_panel,
text="Git + TrueNAS only (skip Android version bump and APK publication)",
variable=self.git_truenas_only_var,
command=self._update_release_mode,
)
self.release_mode_toggle.grid(
row=6, column=0, columnspan=4, sticky="w", pady=(12, 0)
)
ttk.Label(
release_panel,
textvariable=self.release_mode_hint_var,
style="Muted.Panel.TLabel",
wraplength=610,
).grid(row=7, column=0, columnspan=4, sticky="w", pady=(4, 0))
self.run_tests_toggle = ttk.Checkbutton( self.run_tests_toggle = ttk.Checkbutton(
release_panel, release_panel,
text="Run test suites (recommended)", text="Run all test suites before release",
variable=self.run_tests_var, variable=self.run_tests_var,
command=self._update_test_mode, command=self._update_test_mode,
style="Panel.TCheckbutton",
) )
self.run_tests_toggle.grid( self.run_tests_toggle.grid(
row=8, column=0, columnspan=4, sticky="w", pady=(10, 0) row=6, column=0, columnspan=4, sticky="w", pady=(10, 0)
) )
ttk.Label( ttk.Label(
release_panel, release_panel,
textvariable=self.test_mode_hint_var, textvariable=self.test_mode_hint_var,
style="Muted.Panel.TLabel", style="Muted.Panel.TLabel",
wraplength=610, wraplength=620,
).grid(row=9, column=0, columnspan=4, sticky="w", pady=(4, 0)) ).grid(row=7, column=0, columnspan=4, sticky="w", pady=(4, 0))
self.apk_detail_widgets = tuple(
widget
for row in (2, 3, 5)
for widget in release_panel.grid_slaves(row=row)
)
secrets_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15) secrets_panel = ttk.Frame(self.setup_content, style="Panel.TFrame", padding=12)
secrets_panel.grid(row=1, column=1, sticky="nsew", padx=(8, 0)) secrets_panel.grid(row=0, column=1, sticky="nsew", padx=(8, 0))
self.secrets_panel = secrets_panel
secrets_panel.columnconfigure(1, weight=1) secrets_panel.columnconfigure(1, weight=1)
ttk.Label( ttk.Label(
secrets_panel, text="Signing and Gitea (APK only)", style="Panel.TLabel", secrets_panel, text="Signing and Gitea", style="Section.Panel.TLabel",
font=("Segoe UI Semibold", 12),
).grid(row=0, column=0, columnspan=3, sticky="w") ).grid(row=0, column=0, columnspan=3, sticky="w")
ttk.Label( ttk.Label(
secrets_panel, secrets_panel,
text="Signing password and saved token are Windows-encrypted outside Git.", text="Signing password and saved token are Windows-encrypted outside Git.",
style="Muted.Panel.TLabel", style="Muted.Panel.TLabel",
wraplength=260,
).grid(row=1, column=0, columnspan=3, sticky="w", pady=(2, 10)) ).grid(row=1, column=0, columnspan=3, sticky="w", pady=(2, 10))
self.keystore_var = tk.StringVar(value=str(DEFAULT_KEYSTORE_FILE)) self.keystore_var = tk.StringVar(value=str(DEFAULT_KEYSTORE_FILE))
@@ -829,12 +868,13 @@ def launch_gui(*, smoke_test: bool = False) -> int:
secrets_panel, secrets_panel,
text="Remember Gitea token on this PC", text="Remember Gitea token on this PC",
variable=self.remember_token_var, variable=self.remember_token_var,
style="Panel.TCheckbutton",
).grid(row=7, column=0, columnspan=2, sticky="w", pady=(7, 0)) ).grid(row=7, column=0, columnspan=2, sticky="w", pady=(7, 0))
ttk.Button( ttk.Button(
secrets_panel, secrets_panel,
text="Forget saved token", text="Forget saved token",
command=self._forget_saved_token, command=self._forget_saved_token,
).grid(row=7, column=2, sticky="e", pady=(7, 0)) ).grid(row=8, column=0, columnspan=3, sticky="w", pady=(5, 0))
ttk.Label( ttk.Label(
secrets_panel, secrets_panel,
text=( text=(
@@ -842,16 +882,17 @@ def launch_gui(*, smoke_test: bool = False) -> int:
"enter the Gitea token once and it will load automatically." "enter the Gitea token once and it will load automatically."
), ),
style="Muted.Panel.TLabel", style="Muted.Panel.TLabel",
wraplength=360, wraplength=260,
).grid(row=8, column=0, columnspan=3, sticky="w", pady=(7, 0)) ).grid(row=9, column=0, columnspan=3, sticky="w", pady=(7, 0))
lower = ttk.Panedwindow(shell, orient="horizontal") workspace = ttk.Notebook(shell)
lower.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(16, 0)) workspace.grid(row=2, column=0, sticky="nsew", pady=(8, 0))
self.workspace_tabs = workspace
log_panel = ttk.Frame(lower, style="Panel.TFrame", padding=12) log_panel = ttk.Frame(workspace, style="Panel.TFrame", padding=14)
commands_panel = ttk.Frame(lower, style="Panel.TFrame", padding=12) commands_panel = ttk.Frame(workspace, style="Panel.TFrame", padding=14)
lower.add(log_panel, weight=3) workspace.add(log_panel, text="Activity")
lower.add(commands_panel, weight=2) workspace.add(commands_panel, text="TrueNAS commands")
log_panel.rowconfigure(2, weight=1) log_panel.rowconfigure(2, weight=1)
log_panel.columnconfigure(0, weight=1) log_panel.columnconfigure(0, weight=1)
commands_panel.rowconfigure(1, weight=1) commands_panel.rowconfigure(1, weight=1)
@@ -867,28 +908,27 @@ def launch_gui(*, smoke_test: bool = False) -> int:
).grid(row=0, column=0, sticky="w") ).grid(row=0, column=0, sticky="w")
self.progress = ttk.Progressbar(status_row, mode="indeterminate", length=150) self.progress = ttk.Progressbar(status_row, mode="indeterminate", length=150)
self.progress.grid(row=0, column=1, sticky="e") self.progress.grid(row=0, column=1, sticky="e")
self.git_summary_var = tk.StringVar(value="Loading Git status…")
ttk.Label( ttk.Label(
log_panel, log_panel,
textvariable=self.git_summary_var, textvariable=self.git_summary_var,
style="Muted.Panel.TLabel", style="Muted.Panel.TLabel",
wraplength=600, wraplength=1060,
).grid(row=1, column=0, sticky="ew", pady=(4, 8)) ).grid(row=1, column=0, sticky="ew", pady=(4, 8))
self.log = scrolledtext.ScrolledText( self.log = scrolledtext.ScrolledText(
log_panel, log_panel,
wrap="word", wrap="word",
bg="#071110", bg="#101b2b",
fg="#cfe2dc", fg=self.TEXT,
insertbackground=self.TEXT, insertbackground=self.TEXT,
relief="flat", relief="flat",
font=("Cascadia Mono", 9), font=("Cascadia Mono", 10),
state="disabled", state="disabled",
) )
self.log.grid(row=2, column=0, sticky="nsew") self.log.grid(row=2, column=0, sticky="nsew")
ttk.Label( ttk.Label(
commands_panel, commands_panel,
text="TrueNAS shell commands", text="Deploy from TrueNAS after the Git push",
style="Panel.TLabel", style="Panel.TLabel",
font=("Segoe UI Semibold", 11), font=("Segoe UI Semibold", 11),
).grid(row=0, column=0, sticky="w", pady=(0, 7)) ).grid(row=0, column=0, sticky="w", pady=(0, 7))
@@ -908,7 +948,8 @@ def launch_gui(*, smoke_test: bool = False) -> int:
) )
actions = ttk.Frame(shell) actions = ttk.Frame(shell)
actions.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(14, 0)) self.actions = actions
actions.grid(row=3, column=0, sticky="ew", pady=(8, 0))
actions.columnconfigure(0, weight=1) actions.columnconfigure(0, weight=1)
self.release_button = ttk.Button( self.release_button = ttk.Button(
actions, actions,
@@ -927,25 +968,79 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.cancel_button.grid(row=0, column=2, padx=(7, 0)) self.cancel_button.grid(row=0, column=2, padx=(7, 0))
self._update_release_mode() self._update_release_mode()
self._update_test_mode() self._update_test_mode()
self._bind_setup_scroll(self.setup_content)
self.root.bind("<Configure>", self._fit_setup_height)
self.root.after_idle(self._fit_setup_height)
def _bind_setup_scroll(self, widget: tk.Misc) -> None:
if not isinstance(widget, (tk.Text, ttk.Combobox)):
widget.bind("<MouseWheel>", self._scroll_setup, add="+")
for child in widget.winfo_children():
self._bind_setup_scroll(child)
def _scroll_setup(self, event: tk.Event[tk.Misc]) -> str:
if event.delta:
self.setup_canvas.yview_scroll(-1 if event.delta > 0 else 1, "units")
return "break"
def _fit_setup_height(self, event: tk.Event[tk.Misc] | None = None) -> None:
if event is not None and event.widget is not self.root:
return
self._layout_setup_panels()
available = max(140, int(self.root.winfo_height() * 0.43))
desired = min(self.setup_content.winfo_reqheight(), available)
if int(self.setup_canvas.cget("height")) != desired:
self.setup_canvas.configure(height=desired)
def _layout_setup_panels(self) -> None:
apk_mode = not self.git_truenas_only_var.get()
stacked = apk_mode and self.root.winfo_width() < 940
layout = (apk_mode, stacked)
if layout == getattr(self, "_setup_layout", None):
return
self._setup_layout = layout
if not apk_mode:
self.release_panel.grid_configure(
row=0, column=0, columnspan=2, padx=0, pady=0
)
self.secrets_panel.grid_remove()
elif stacked:
self.release_panel.grid_configure(
row=0, column=0, columnspan=2, padx=0, pady=0
)
self.secrets_panel.grid_configure(
row=1, column=0, columnspan=2, padx=0, pady=(8, 0)
)
else:
self.release_panel.grid_configure(
row=0, column=0, columnspan=1, padx=(0, 4), pady=0
)
self.secrets_panel.grid_configure(
row=0, column=1, columnspan=1, padx=(4, 0), pady=0
)
self.root.after_idle(self._fit_setup_height)
def _update_release_mode(self) -> None: def _update_release_mode(self) -> None:
git_truenas_only = self.git_truenas_only_var.get() git_truenas_only = self.git_truenas_only_var.get()
if git_truenas_only:
self.retry_button.grid_remove()
for widget in self.apk_detail_widgets:
widget.grid_remove()
else:
self.retry_button.grid()
for widget in self.apk_detail_widgets:
widget.grid()
self._layout_setup_panels()
self.setup_canvas.yview_moveto(0)
self.root.after_idle(self._fit_setup_height)
self.bump_box.configure(state="disabled" if git_truenas_only else "readonly") self.bump_box.configure(state="disabled" if git_truenas_only else "readonly")
entry_state = "disabled" if git_truenas_only else "normal" entry_state = "disabled" if git_truenas_only else "normal"
self.next_version_entry.configure(state=entry_state) self.next_version_entry.configure(state=entry_state)
self.next_code_entry.configure(state=entry_state) self.next_code_entry.configure(state=entry_state)
if git_truenas_only: if git_truenas_only:
self.release_button.configure(text="Validate and Push Git + TrueNAS Update") self.release_button.configure(text="Validate and Push Git + TrueNAS Update")
self.release_mode_hint_var.set(
"Default mode: keeps the current Android version, skips Android sync and APK "
"publication, and prepares the pinned TrueNAS deployment commands after push."
)
else: else:
self.release_button.configure(text="Validate, Push, and Publish APK") self.release_button.configure(text="Validate, Push, and Publish APK")
self.release_mode_hint_var.set(
"APK mode: bumps the app and Android versions, runs Android sync, and publishes "
"the signed APK after the Git push."
)
self._suggest_versions() self._suggest_versions()
current_notes = self.release_notes.get("1.0", "end").strip() current_notes = self.release_notes.get("1.0", "end").strip()
server_default = "Healer Man Git and TrueNAS server update." server_default = "Healer Man Git and TrueNAS server update."
@@ -980,15 +1075,18 @@ def launch_gui(*, smoke_test: bool = False) -> int:
frame.columnconfigure(0, weight=1) frame.columnconfigure(0, weight=1)
widget = tk.Text( widget = tk.Text(
frame, frame,
wrap="none", wrap="word",
bg="#071110", bg="#101b2b",
fg="#cfe2dc", fg=self.TEXT,
relief="flat", relief="flat",
padx=8, padx=12,
pady=8, pady=10,
font=("Cascadia Mono", 8), font=("Cascadia Mono", 10),
) )
widget.grid(row=0, column=0, sticky="nsew") widget.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(frame, orient="vertical", command=widget.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
widget.configure(yscrollcommand=scrollbar.set)
return widget return widget
def _browse_keystore(self) -> None: def _browse_keystore(self) -> None:
@@ -1265,11 +1363,13 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.busy = True self.busy = True
self.cancel_event.clear() self.cancel_event.clear()
self.status_var.set(label) self.status_var.set(label)
self.workspace_tabs.select(0)
self.progress.start(12) self.progress.start(12)
self.release_button.configure(state="disabled") self.release_button.configure(state="disabled")
self.retry_button.configure(state="disabled") self.retry_button.configure(state="disabled")
self.refresh_button.configure(state="disabled") self.refresh_button.configure(state="disabled")
self.release_mode_toggle.configure(state="disabled") self.release_mode_toggle.configure(state="disabled")
self.apk_mode_toggle.configure(state="disabled")
self.run_tests_toggle.configure(state="disabled") self.run_tests_toggle.configure(state="disabled")
self.cancel_button.configure(state="normal") self.cancel_button.configure(state="normal")
self._append_log(f"\n=== {label} ===") self._append_log(f"\n=== {label} ===")
@@ -1774,6 +1874,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.retry_button.configure(state="normal") self.retry_button.configure(state="normal")
self.refresh_button.configure(state="normal") self.refresh_button.configure(state="normal")
self.release_mode_toggle.configure(state="normal") self.release_mode_toggle.configure(state="normal")
self.apk_mode_toggle.configure(state="normal")
self.run_tests_toggle.configure(state="normal") self.run_tests_toggle.configure(state="normal")
self.cancel_button.configure(state="disabled") self.cancel_button.configure(state="disabled")
self.keystore_password_var.set("") self.keystore_password_var.set("")
@@ -1781,21 +1882,24 @@ def launch_gui(*, smoke_test: bool = False) -> int:
if self.remember_token_var.get() and SAVED_GITEA_TOKEN_FILE.is_file(): if self.remember_token_var.get() and SAVED_GITEA_TOKEN_FILE.is_file():
self.token_var.set(read_saved_gitea_token()) self.token_var.set(read_saved_gitea_token())
if result == "success": if result == "success":
self.status_var.set("Workflow completed")
self._append_log("\n✓ Workflow completed successfully.") self._append_log("\n✓ Workflow completed successfully.")
if self.git_truenas_only_var.get():
self.workspace_tabs.select(1)
messagebox.showinfo( messagebox.showinfo(
"Workflow complete", "Workflow complete",
detail, detail,
parent=self.root, parent=self.root,
) )
elif result == "cancelled": elif result == "cancelled":
self.status_var.set("Workflow cancelled")
self._append_log(f"\nCancelled: {detail}") self._append_log(f"\nCancelled: {detail}")
else: else:
self.status_var.set("Workflow failed")
self._append_log(f"\nERROR: {detail}") self._append_log(f"\nERROR: {detail}")
messagebox.showerror("Workflow failed", detail, parent=self.root) messagebox.showerror("Workflow failed", detail, parent=self.root)
self.refresh() self.refresh()
self.status_var.set({
"success": "Workflow completed",
"cancelled": "Workflow cancelled",
}.get(result, "Workflow failed"))
def _close(self) -> None: def _close(self) -> None:
if self.busy: if self.busy:
@@ -1809,11 +1913,37 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.root.destroy() self.root.destroy()
root = tk.Tk() root = tk.Tk()
ReleaseManagerApp(root) app = ReleaseManagerApp(root)
if smoke_test: if smoke_test:
root.withdraw() root.withdraw()
root.update_idletasks() root.update_idletasks()
root.update() root.update()
app.git_truenas_only_var.set(False)
app._update_release_mode()
root.update_idletasks()
assert app.secrets_panel.winfo_manager() == "grid"
assert app.release_panel.grid_info()["columnspan"] == 1
app.git_truenas_only_var.set(True)
app._update_release_mode()
root.update_idletasks()
assert app.release_panel.grid_info()["columnspan"] == 2
assert not app.secrets_panel.winfo_manager()
root.geometry("700x500")
root.deiconify()
root.update()
app.git_truenas_only_var.set(False)
app._update_release_mode()
root.update()
app._fit_setup_height()
assert app.secrets_panel.grid_info()["row"] == 1
assert app.release_panel.grid_info()["columnspan"] == 2
assert int(app.setup_canvas.cget("height")) <= 215
assert app.setup_canvas.yview()[1] < 1
app.setup_canvas.yview_moveto(1)
root.update_idletasks()
assert app.setup_canvas.yview()[0] > 0
assert app.release_button.winfo_manager() == "grid"
assert app.actions.winfo_y() + app.actions.winfo_height() <= root.winfo_height()
root.destroy() root.destroy()
return 0 return 0
root.mainloop() root.mainloop()
+244
View File
@@ -0,0 +1,244 @@
"""Import client minimap tiles and calibrated navigation plans for RuneWaker dungeons.
Requires Pillow and NumPy. RUNEWAKER_ROOT may point at another extracted client.
"""
import hashlib
import json
import os
import re
import struct
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parents[2]
SOURCE = Path(os.environ.get("RUNEWAKER_ROOT", ROOT.parent / "Runewaker")) / "Resource" / "texture" / "minimap"
RECIPES = ROOT / "scripts/runewaker-pipeline/recipes"
OUTPUT = ROOT / "public/assets/game/dungeons"
CATALOG = ROOT / "src/game/generated/runewakerDungeonMaps.generated.json"
IMAGE_SIZE = (1002, 668)
SOURCE_PIXELS_PER_UNIT = 0.12
ALIASES = {
"ystra-labyrinth-boss1": "tiledgn_hybora_labyrinth_01",
"ystra-labyrinth-boss2": "tiledgn_hybora_labyrinth_02",
"ystra-labyrinth-boss3": "tiledgn_hybora_labyrinth_03",
}
def read_json(path):
return json.loads(path.read_text(encoding="utf-8"))
def runtime_visual_checksum(slug):
file = (ROOT / "src/game/dungeons/forsakenAbbey.ts" if slug == "forsaken-abbey"
else ROOT / "src/game/generated/runewakerDungeonDefinitions.generated.ts")
source = file.read_text(encoding="utf-8")
identifier = f'id: "{slug}"' if slug == "forsaken-abbey" else f'"id": "{slug}"'
start = source.find(identifier)
if start < 0:
raise ValueError(f"Missing runtime definition for {slug}")
match = re.search(r'\bvisual\b[\s\S]*?\bchecksum["\s:]*"([0-9a-f]{64})"', source[start:])
if not match:
raise ValueError(f"Missing runtime visual checksum for {slug}")
return match.group(1)
def navigation_mesh(slug):
data = (ROOT / f"src/assets/game/dungeons/{slug}/{slug}-navigation.glb").read_bytes()
json_length = struct.unpack_from("<I", data, 12)[0]
gltf = json.loads(data[20:20 + json_length])
binary_start = 20 + ((json_length + 3) & ~3) + 8
def accessor(index):
item = gltf["accessors"][index]
view = gltf["bufferViews"][item["bufferView"]]
kind = {5126: "<f4", 5125: "<u4", 5123: "<u2"}[item["componentType"]]
width = {"VEC3": 3, "SCALAR": 1}[item["type"]]
offset = binary_start + view.get("byteOffset", 0) + item.get("byteOffset", 0)
return np.frombuffer(data, dtype=kind, count=item["count"] * width, offset=offset).reshape(-1, width)
primitive = gltf["meshes"][0]["primitives"][0]
return (accessor(primitive["attributes"]["POSITION"]).astype(np.float64),
accessor(primitive["indices"]).reshape(-1, 3))
def placement_for(slug):
metadata = read_json(ROOT / f"src/assets/game/dungeons/{slug}/import-metadata.json")
source = metadata["source"]
return source.get("wdbReport", source.get("wdb", {}))["primaryPlacement"]
def source_points(local_vertices, placement):
points = local_vertices * np.array([10, 10, -10], dtype=np.float64)
quaternion = np.array(placement["rotation"], dtype=np.float64)
cross = np.cross(quaternion[:3], points)
points = points + 2 * (quaternion[3] * cross + np.cross(quaternion[:3], cross))
return points * np.array(placement["scale"]) + np.array(placement["translation"])
def client_tiles(name):
directory = SOURCE / name / "0"
tiles = {}
for file in directory.glob("minimap_*.dds"):
match = re.fullmatch(r"minimap_(\d+)_(\d+)\.dds", file.name)
if match:
tiles[tuple(map(int, match.groups()))] = file
if not tiles:
return None
size = ((max(x for x, _ in tiles) + 1) * 512, (max(y for _, y in tiles) + 1) * 512)
image = Image.new("RGBA", size)
for (x, y), file in sorted(tiles.items()):
image.paste(Image.open(file).convert("RGBA"), (x * 512, y * 512))
return image, sorted(tiles.values())
def artwork_mask(image, size):
reduced = image.resize(size, Image.Resampling.BOX)
rgb = np.asarray(reduced.convert("RGB")).astype(np.int16)
background = np.asarray(image.convert("RGB").getpixel((0, 0)), dtype=np.int16)
opaque = np.asarray(reduced.getchannel("A")) > 8
visible = (np.max(np.abs(rgb - background), axis=2) > 18) & opaque
return np.asarray(Image.fromarray(visible.astype("uint8") * 255).filter(ImageFilter.MaxFilter(5))) > 8
def navmesh_mask(points, faces, size):
image = Image.new("L", size)
draw = ImageDraw.Draw(image)
for face in faces:
draw.polygon([tuple(points[index]) for index in face], fill=255)
return np.asarray(image) > 0
def calibrate(image, source_vertices, faces):
factor = min(1, 512 / max(image.size))
size = (round(image.width * factor), round(image.height * factor))
art = artwork_mask(image, size)
oriented = source_vertices[:, [0, 2]] * np.array([1, -1])
minimum = oriented.min(axis=0)
points = (oriented - minimum) * SOURCE_PIXELS_PER_UNIT * factor
if points[:, 0].max() >= size[0] or points[:, 1].max() >= size[1]:
return None
floor = navmesh_mask(points, faces, size)
if floor.sum() < 20 or art.sum() < 20:
return None
correlation = np.fft.irfft2(
np.fft.rfft2(art.astype(np.float32)) * np.conj(np.fft.rfft2(floor.astype(np.float32))),
s=art.shape,
)
valid = correlation[:size[1] - int(points[:, 1].max()), :size[0] - int(points[:, 0].max())]
row, column = np.unravel_index(np.argmax(valid), valid.shape)
confidence = float(valid[row, column]) / float(floor.sum())
return minimum, np.array([column / factor, row / factor]), confidence
def original_map(image, source_vertices, faces, calibrated):
minimum, shift, confidence = calibrated
points = ((source_vertices[:, [0, 2]] * np.array([1, -1]) - minimum)
* SOURCE_PIXELS_PER_UNIT + shift)
left = max(0, int(np.floor(points[:, 0].min() - 60)))
top = max(0, int(np.floor(points[:, 1].min() - 60)))
right = min(image.width, int(np.ceil(points[:, 0].max() + 60)))
bottom = min(image.height, int(np.ceil(points[:, 1].max() + 60)))
if right <= left or bottom <= top:
raise ValueError("Calibrated minimap crop is empty")
cropped = image.crop((left, top, right, bottom))
scale = min(IMAGE_SIZE[0] / cropped.width, IMAGE_SIZE[1] / cropped.height)
resized = cropped.resize((round(cropped.width * scale), round(cropped.height * scale)), Image.Resampling.LANCZOS)
output = Image.new("RGBA", IMAGE_SIZE, (10, 14, 17, 255))
origin = ((IMAGE_SIZE[0] - resized.width) // 2, (IMAGE_SIZE[1] - resized.height) // 2)
output.alpha_composite(resized, origin)
def projected(source):
point = (source[[0, 2]] * np.array([1, -1]) - minimum) * SOURCE_PIXELS_PER_UNIT + shift
return np.array(origin) + (point - np.array([left, top])) * scale
return output, projected, confidence
def navigation_plan(vertices, faces):
horizontal = vertices[:, [0, 2]] * np.array([1, -1])
minimum = horizontal.min(axis=0)
maximum = horizontal.max(axis=0)
span = np.maximum(maximum - minimum, 1)
scale = min((IMAGE_SIZE[0] - 80) / span[0], (IMAGE_SIZE[1] - 80) / span[1])
origin = np.array([(IMAGE_SIZE[0] - span[0] * scale) / 2,
(IMAGE_SIZE[1] - span[1] * scale) / 2])
points = origin + (horizontal - minimum) * scale
image = Image.new("RGBA", IMAGE_SIZE, (10, 14, 17, 255))
draw = ImageDraw.Draw(image)
for face in faces:
draw.polygon([tuple(points[index]) for index in face], fill=(43, 68, 76, 255))
def projected(runtime):
return origin + (runtime[[0, 2]] * np.array([1, -1]) - minimum) * scale
return image, projected
def affine(projected, placement=None):
def runtime_to_source(runtime):
return source_points(np.array([runtime], dtype=np.float64), placement)[0]
base = projected(runtime_to_source([0, 0, 0])) if placement else projected(np.array([0, 0, 0]))
axes = []
for axis in range(3):
unit = np.zeros(3)
unit[axis] = 1
point = projected(runtime_to_source(unit)) if placement else projected(unit)
axes.append(point - base)
return [round(float(value), 8) for row in range(2) for value in
(axes[0][row], axes[1][row], axes[2][row], base[row])]
def main():
maps = {}
summary = []
for recipe_file in sorted(RECIPES.glob("*.json")):
if recipe_file.name.endswith("-population.json") or recipe_file.stem == "runewaker-player-avatars":
continue
recipe = read_json(recipe_file)
slug = recipe["slug"]
vertices, faces = navigation_mesh(slug)
placement = placement_for(slug)
source_vertices = source_points(vertices, placement)
map_name = ALIASES.get(slug, Path(recipe["source"]["wdb"]).stem)
tiles = client_tiles(map_name)
calibrated = calibrate(tiles[0], source_vertices, faces) if tiles else None
if calibrated and calibrated[2] >= 0.65:
image, projected, confidence = original_map(tiles[0], source_vertices, faces, calibrated)
matrix = affine(projected, placement)
kind = "client"
source_hash = hashlib.sha256(b"".join(file.read_bytes() for file in tiles[1])).hexdigest()
else:
image, projected = navigation_plan(vertices, faces)
matrix = affine(projected)
kind = "navigation"
confidence = calibrated[2] if calibrated else 0
source_hash = hashlib.sha256((ROOT / f"src/assets/game/dungeons/{slug}/{slug}-navigation.glb").read_bytes()).hexdigest()
destination = OUTPUT / slug / "maps" / "runewaker-map.png"
destination.parent.mkdir(parents=True, exist_ok=True)
image.save(destination, optimize=True)
metadata = read_json(ROOT / f"src/assets/game/dungeons/{slug}/import-metadata.json")
maps[slug] = {
"mapId": read_json(next((ROOT / "scripts/runewaker-pipeline/snapshots").glob(f"{slug}-zone-*.json")))["zoneId"],
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": runtime_visual_checksum(slug),
"kind": kind,
"image": f"/assets/game/dungeons/{slug}/maps/runewaker-map.png",
"checksum": hashlib.sha256(destination.read_bytes()).hexdigest(),
"sourceHash": source_hash,
"runtimeToMap": matrix,
"width": IMAGE_SIZE[0],
"height": IMAGE_SIZE[1],
}
summary.append((slug, kind, round(confidence, 3), metadata["status"]))
CATALOG.write_text(json.dumps({"schemaVersion": 1, "dungeons": maps}, indent=2) + "\n", encoding="utf-8")
for entry in summary:
print(*entry)
if __name__ == "__main__":
main()
+134
View File
@@ -12,6 +12,8 @@ import {
cloneDefaultCompanionCooldownPolicy, cloneDefaultCompanionCooldownPolicy,
normalizeCompanionCooldownPolicy, normalizeCompanionCooldownPolicy,
} from "./companion-cooldown-policy.mjs"; } from "./companion-cooldown-policy.mjs";
import { DEFAULT_PARTY_BUFF_POLICY, normalizePartyBuffPolicy } from "./party-buff-policy.mjs";
import { DEFAULT_DUNGEON_QUEUE_POLICY, normalizeDungeonQueuePolicy } from "./dungeon-queue-policy.mjs";
import { import {
cloneDefaultCompanionSpecReadinessPolicy, cloneDefaultCompanionSpecReadinessPolicy,
normalizeCompanionSpecReadinessPolicy, normalizeCompanionSpecReadinessPolicy,
@@ -20,6 +22,7 @@ import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena
import { arenaRoleForSpecialization } from "./arena-specializations.mjs"; import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
import { createDungeonReportRepository } from "./dungeon-reports.mjs"; import { createDungeonReportRepository } from "./dungeon-reports.mjs";
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs"; import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs"; import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" }; import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
@@ -110,6 +113,24 @@ export function openGameDatabase(options = {}) {
updated_by_username TEXT, updated_by_username TEXT,
updated_at INTEGER updated_at INTEGER
) STRICT; ) STRICT;
CREATE TABLE IF NOT EXISTS party_buff_policy (
id INTEGER PRIMARY KEY CHECK (id = 1),
schema_version INTEGER NOT NULL,
revision INTEGER NOT NULL,
config_json TEXT NOT NULL,
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
updated_by_username TEXT,
updated_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS dungeon_queue_policy (
id INTEGER PRIMARY KEY CHECK (id = 1),
schema_version INTEGER NOT NULL,
revision INTEGER NOT NULL,
config_json TEXT NOT NULL,
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
updated_by_username TEXT,
updated_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS companion_spec_readiness ( CREATE TABLE IF NOT EXISTS companion_spec_readiness (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
schema_version INTEGER NOT NULL, schema_version INTEGER NOT NULL,
@@ -215,6 +236,16 @@ export function openGameDatabase(options = {}) {
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
) VALUES (1, 1, 0, ?, NULL, NULL, NULL) ) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
`).run(JSON.stringify(cloneDefaultCompanionCooldownPolicy())); `).run(JSON.stringify(cloneDefaultCompanionCooldownPolicy()));
database.prepare(`
INSERT OR IGNORE INTO party_buff_policy (
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
`).run(JSON.stringify(DEFAULT_PARTY_BUFF_POLICY));
database.prepare(`
INSERT OR IGNORE INTO dungeon_queue_policy (
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
`).run(JSON.stringify(DEFAULT_DUNGEON_QUEUE_POLICY));
database.prepare(` database.prepare(`
INSERT OR IGNORE INTO companion_spec_readiness ( INSERT OR IGNORE INTO companion_spec_readiness (
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
@@ -252,6 +283,22 @@ export function openGameDatabase(options = {}) {
RETURNING revision, updated_at RETURNING revision, updated_at
`), `),
companionCooldownPolicy: database.prepare("SELECT * FROM companion_cooldown_policy WHERE id = 1"), companionCooldownPolicy: database.prepare("SELECT * FROM companion_cooldown_policy WHERE id = 1"),
partyBuffPolicy: database.prepare("SELECT * FROM party_buff_policy WHERE id = 1"),
dungeonQueuePolicy: database.prepare("SELECT * FROM dungeon_queue_policy WHERE id = 1"),
updateDungeonQueuePolicy: database.prepare(`
UPDATE dungeon_queue_policy
SET schema_version = 1, revision = revision + 1, config_json = ?,
updated_by_account_id = ?, updated_by_username = ?, updated_at = ?
WHERE id = 1 AND revision = ?
RETURNING revision, updated_at
`),
updatePartyBuffPolicy: database.prepare(`
UPDATE party_buff_policy
SET schema_version = 1, revision = revision + 1, config_json = ?,
updated_by_account_id = ?, updated_by_username = ?, updated_at = ?
WHERE id = 1 AND revision = ?
RETURNING revision, updated_at
`),
updateCompanionCooldownPolicy: database.prepare(` updateCompanionCooldownPolicy: database.prepare(`
UPDATE companion_cooldown_policy UPDATE companion_cooldown_policy
SET schema_version = 1, revision = revision + 1, config_json = ?, SET schema_version = 1, revision = revision + 1, config_json = ?,
@@ -450,6 +497,74 @@ export function openGameDatabase(options = {}) {
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized }; return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized };
} }
function getPartyBuffPolicy() {
const row = statements.partyBuffPolicy.get();
try {
return {
revision: Number(row?.revision ?? 0),
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
updatedBy: row?.updated_by_username ?? null,
config: normalizePartyBuffPolicy(JSON.parse(row?.config_json ?? "null")),
};
} catch (error) {
console.error("Healer Man party buff policy is corrupt; using safe defaults", error);
return { revision: 0, updatedAt: null, updatedBy: null, config: DEFAULT_PARTY_BUFF_POLICY };
}
}
function getDungeonQueuePolicy() {
const row = statements.dungeonQueuePolicy.get();
try {
return {
revision: Number(row?.revision ?? 0),
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
updatedBy: row?.updated_by_username ?? null,
config: normalizeDungeonQueuePolicy(JSON.parse(row?.config_json ?? "null")),
};
} catch (error) {
console.error("Healer Man dungeon queue policy is corrupt; using defaults", error);
return { revision: 0, updatedAt: null, updatedBy: null, config: DEFAULT_DUNGEON_QUEUE_POLICY };
}
}
function putDungeonQueuePolicy(account, expectedRevision, config) {
if (!isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
}
let normalized;
try {
normalized = normalizeDungeonQueuePolicy(config);
} catch (error) {
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid dungeon queue policy.", 400, "invalid_dungeon_queue_policy");
}
const now = Date.now();
const row = statements.updateDungeonQueuePolicy.get(
JSON.stringify(normalized), account.id, account.username, now, expectedRevision,
);
if (!row) throw new GameDatabaseError("The dungeon queue policy changed. Reload and try again.", 409, "revision_conflict");
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized };
}
function putPartyBuffPolicy(account, expectedRevision, config) {
if (!isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
}
let normalized;
try {
normalized = normalizePartyBuffPolicy(config);
} catch (error) {
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid party buff policy.", 400, "invalid_party_buff_policy");
}
const now = Date.now();
const row = statements.updatePartyBuffPolicy.get(
JSON.stringify(normalized), account.id, account.username, now, expectedRevision,
);
if (!row) throw new GameDatabaseError("The party buff policy changed. Reload and try again.", 409, "revision_conflict");
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized };
}
function getCompanionSpecReadiness() { function getCompanionSpecReadiness() {
const row = statements.companionSpecReadiness.get(); const row = statements.companionSpecReadiness.get();
try { try {
@@ -1066,6 +1181,21 @@ export function openGameDatabase(options = {}) {
databasePath, databasePath,
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }), ...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }), ...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
...(() => {
const races = createDungeonRaceRepository(database, { ErrorType: GameDatabaseError });
return {
getDungeonRaceMatch: races.getMatch,
createDungeonRaceMatch: races.createMatch,
readyDungeonRace: races.ready,
startDungeonRace: races.start,
settleDungeonRace: races.settleMatch,
completeDungeonRaceRun: races.completeRaceRun,
replaceDungeonRaceWithBot: races.replaceWithBot,
voidDungeonRace: races.void,
dungeonRaceRecordForAccount: races.recordForAccount,
listDungeonRaceLeaderboard: races.leaderboard,
};
})(),
...createDungeonSimulationRepository(database, { isGameMaster, ErrorType: GameDatabaseError }), ...createDungeonSimulationRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
register, register,
login, login,
@@ -1078,6 +1208,10 @@ export function openGameDatabase(options = {}) {
putManastormConfig, putManastormConfig,
getCompanionCooldownPolicy, getCompanionCooldownPolicy,
putCompanionCooldownPolicy, putCompanionCooldownPolicy,
getPartyBuffPolicy,
putPartyBuffPolicy,
getDungeonQueuePolicy,
putDungeonQueuePolicy,
getCompanionSpecReadiness, getCompanionSpecReadiness,
putCompanionSpecReadiness, putCompanionSpecReadiness,
onlineGroupState, onlineGroupState,
+35
View File
@@ -0,0 +1,35 @@
import dungeonCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
const knownIds = new Set(dungeonCatalog.dungeons.map((dungeon) => dungeon.id));
const modes = ["regular", "biteSized", "randomizedBiteSized"];
export const DEFAULT_DUNGEON_QUEUE_POLICY = Object.freeze({
schemaVersion: 1,
disabled: { regular: [], biteSized: [], randomizedBiteSized: [] },
});
function modeForId(id) {
if (id.startsWith("randomized-bite-sized-")) return "randomizedBiteSized";
if (id.startsWith("bite-sized-")) return "biteSized";
return "regular";
}
export function normalizeDungeonQueuePolicy(value) {
if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1
|| !value.disabled || typeof value.disabled !== "object" || Array.isArray(value.disabled)) {
throw new Error("A version 1 dungeon queue policy is required.");
}
if (Object.keys(value.disabled).some((mode) => !modes.includes(mode))) {
throw new Error("Unknown dungeon queue mode.");
}
const disabled = {};
for (const mode of modes) {
const ids = value.disabled[mode];
if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string" || !knownIds.has(id) || modeForId(id) !== mode)
|| new Set(ids).size !== ids.length) {
throw new Error(`Invalid disabled dungeon IDs for ${mode}.`);
}
disabled[mode] = [...ids].sort();
}
return { schemaVersion: 1, disabled };
}
+74
View File
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { openGameDatabase } from "./database.mjs";
import { normalizeDungeonQueuePolicy } from "./dungeon-queue-policy.mjs";
import { createGameServer } from "./server.mjs";
test("queue policy validates dungeon IDs and mode membership", () => {
assert.throws(() => normalizeDungeonQueuePolicy({ schemaVersion: 1, disabled: {
regular: ["bite-sized-stormwind-stockade"], biteSized: [], randomizedBiteSized: [],
} }), /invalid disabled dungeon/i);
assert.throws(() => normalizeDungeonQueuePolicy({ schemaVersion: 1, disabled: {
regular: ["unknown-dungeon"], biteSized: [], randomizedBiteSized: [],
} }), /invalid disabled dungeon/i);
});
test("GM queue settings persist with revision checks", async () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-queue-policy-"));
let database = openGameDatabase({ dataDir });
try {
assert.deepEqual(database.getDungeonQueuePolicy().config.disabled, {
regular: [], biteSized: [], randomizedBiteSized: [],
});
const gm = (await database.register("codex_dev_gm", "test-password")).account;
const player = (await database.register("queue_player", "test-password")).account;
const config = { schemaVersion: 1, disabled: {
regular: ["stormwind-stockade"],
biteSized: ["bite-sized-stormwind-stockade"],
randomizedBiteSized: ["randomized-bite-sized-stormwind-stockade"],
} };
assert.throws(() => database.putDungeonQueuePolicy(player, 0, config), (error) => error.status === 403);
assert.equal(database.putDungeonQueuePolicy(gm, 0, config).revision, 1);
assert.throws(() => database.putDungeonQueuePolicy(gm, 0, config), (error) => error.status === 409);
database.close();
database = openGameDatabase({ dataDir });
assert.deepEqual(database.getDungeonQueuePolicy().config, config);
} finally {
database.close();
rmSync(dataDir, { recursive: true, force: true });
}
});
test("queue policy API is public to read and GM only to edit", async () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-queue-api-"));
const runtime = createGameServer({ dataDir, staticDir: dataDir, contentDir: dataDir });
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
try {
const initial = await (await fetch(`${origin}/api/dungeon-queue-policy`)).json();
assert.deepEqual(initial.config.disabled.regular, []);
const register = async (username) => (await (await fetch(`${origin}/api/auth/register`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password: "test-password" }),
})).json()).token;
const playerToken = await register("queue_reader");
const gmToken = await register("codex_dev_gm");
const config = { schemaVersion: 1, disabled: {
regular: [], biteSized: ["bite-sized-stormwind-stockade"], randomizedBiteSized: [],
} };
const put = (token, expectedRevision) => fetch(`${origin}/api/gm/dungeon-queue-policy`, {
method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ expectedRevision, config }),
});
assert.equal((await put(playerToken, 0)).status, 403);
assert.equal((await put(gmToken, 0)).status, 200);
assert.equal((await put(gmToken, 0)).status, 409);
assert.deepEqual((await (await fetch(`${origin}/api/dungeon-queue-policy`)).json()).config, config);
} finally {
await new Promise((resolve) => runtime.server.close(resolve));
rmSync(dataDir, { recursive: true, force: true });
}
});
+60
View File
@@ -0,0 +1,60 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import bots from "../src/game/dungeonRaceBotCatalog.json" with { type: "json" };
export const RACE_QUEUE_WAIT_MS = 5_000;
export const RACE_PREPARE_MS = 5 * 60_000;
export const RACE_COUNTDOWN_MS = 3_000;
export const RACE_DISCONNECT_MS = 30_000;
export const RACE_MAX_MS = 2 * 60 * 60_000;
export const RACE_PERIOD_MS = 30 * 24 * 60 * 60_000;
export function racePoolForDungeon(id) {
if (id.startsWith("randomized-bite-sized-")) return "randomized";
return id.startsWith("bite-sized-") ? "authored" : null;
}
export function raceDungeons(pool, policy) {
const disabled = new Set(policy?.disabled?.[pool === "randomized" ? "randomizedBiteSized" : "biteSized"] ?? []);
return catalog.dungeons.filter((dungeon) => racePoolForDungeon(dungeon.id) === pool
&& dungeon.difficulties.some((difficulty) => difficulty.finalBossId) && !disabled.has(dungeon.id));
}
export function compatibleDungeons(left, right, policy) {
if (left.pool !== right.pool) return [];
return raceDungeons(left.pool, policy).filter((dungeon) =>
(!left.dungeonId || left.dungeonId === dungeon.id) && (!right.dungeonId || right.dungeonId === dungeon.id));
}
export function hashRaceSeed(value) {
let hash = 2166136261;
for (const char of value) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16777619); }
return hash >>> 0;
}
export function botTierForRecord(wins, losses, seed) {
const total = wins + losses;
const rate = total < 5 ? 0.5 : wins / total;
const base = rate < 0.4 ? 0 : rate < 0.6 ? 1 : rate < 0.8 ? 2 : 3;
const roll = hashRaceSeed(seed) % 4;
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) {
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);
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) })) };
}
export function botProgress(elapsedMs, bot) {
if (elapsedMs <= 0) return 0;
const previous = [{ atMs: 0, progress: 0 }, ...bot.milestones]
.filter((entry) => entry.atMs <= elapsedMs).at(-1);
const next = bot.milestones.find((entry) => entry.atMs > elapsedMs);
if (!next) return 100;
return Math.floor(previous.progress + (next.progress - previous.progress)
* (elapsedMs - previous.atMs) / (next.atMs - previous.atMs));
}
+190
View File
@@ -0,0 +1,190 @@
import { randomUUID } from "node:crypto";
import { GameDatabaseError } from "./database.mjs";
import { botOpponent, botProgress, botTierForRecord, compatibleDungeons, hashRaceSeed, raceDungeons,
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 } = {}) {
const tickets = new Map();
const matches = new Map();
const accountMatch = new Map();
const lastSeen = new Map();
function currentMatch(accountId) {
const id = accountMatch.get(accountId);
return id ? matches.get(id) ?? null : null;
}
function requireParticipant(accountId, id) {
const match = matches.get(id) ?? database.getDungeonRaceMatch(id);
if (!match?.participants.some((entry) => entry.accountId === accountId))
throw new GameDatabaseError("That race is not yours.", 404, "race_not_found");
return match;
}
function publicState(accountId, timestamp = now()) {
const ticket = tickets.get(accountId);
if (ticket) return { status: "queued", serverTime: timestamp, joinedAt: ticket.joinedAt,
botAt: ticket.joinedAt + RACE_QUEUE_WAIT_MS, pool: ticket.pool, dungeonId: ticket.dungeonId };
const match = currentMatch(accountId);
if (!match) return { status: "idle", serverTime: timestamp };
const self = match.participants.find((entry) => entry.accountId === accountId);
const rival = match.participants.find((entry) => entry.side !== self?.side);
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,
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),
opponentProgress: match.bot ? botProgress(elapsedMs, match.bot) : null,
result: match.status === "settled" ? (match.winnerSide === self?.side ? "win" : "loss") : null,
clearDurationMs: self?.durationMs ?? null };
}
function refresh(id) {
const updated = database.getDungeonRaceMatch(id);
if (updated) matches.set(id, updated);
return updated;
}
function settle(match, winnerSide, timestamp) {
const updated = database.settleDungeonRace(match.id, winnerSide, timestamp);
if (updated) matches.set(match.id, updated);
}
function makeBot(seed, accountId) {
const { wins, losses } = database.dungeonRaceRecordForAccount(accountId, now());
return botOpponent(seed, botTierForRecord(wins, losses, seed));
}
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 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,
difficultyId: dungeon.defaultDifficultyId, createdAt: timestamp, participants, bot });
matches.set(id, match);
for (const participant of participants) if (participant.accountId) {
tickets.delete(participant.accountId);
accountMatch.set(participant.accountId, id);
lastSeen.set(participant.accountId, timestamp);
}
return match;
}
function replaceWithBot(match, side, timestamp) {
const leaving = match.participants.find((entry) => entry.side === side);
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 updated = database.replaceDungeonRaceWithBot(match.id, side, bot);
matches.set(match.id, updated);
if (updated.participants.find((entry) => entry.accountId === surviving.accountId)?.readyAt != null) {
matches.set(match.id, database.startDungeonRace(match.id, timestamp + RACE_COUNTDOWN_MS));
}
}
function pump() {
const timestamp = now();
const waiting = [...tickets.values()].sort((a, b) => a.joinedAt - b.joinedAt);
for (const left of waiting) {
if (!tickets.has(left.accountId)) continue;
const policy = database.getDungeonQueuePolicy().config;
const right = waiting.find((candidate) => candidate.accountId !== left.accountId
&& tickets.has(candidate.accountId) && compatibleDungeons(left, candidate, policy).length > 0);
if (right) { createMatch(left, right, compatibleDungeons(left, right, policy), timestamp); continue; }
if (timestamp - left.joinedAt >= RACE_QUEUE_WAIT_MS) {
const choices = raceDungeons(left.pool, policy).filter((entry) => !left.dungeonId || left.dungeonId === entry.id);
if (choices.length) createMatch(left, null, choices, timestamp);
else tickets.delete(left.accountId);
}
}
for (const match of [...matches.values()]) {
if (match.status === "preparing" && timestamp - match.createdAt >= RACE_PREPARE_MS) {
const ready = match.participants.filter((entry) => entry.accountId && entry.readyAt != null);
if (ready.length === 1 && match.participants.filter((entry) => entry.accountId).length === 2) {
const missing = match.participants.find((entry) => entry.accountId && entry.readyAt == null);
replaceWithBot(match, missing.side, timestamp);
} else if (ready.length === 0) {
matches.set(match.id, database.voidDungeonRace(match.id, timestamp));
}
}
if (match.status !== "active" || timestamp < match.startAt) continue;
if (timestamp - match.startAt >= RACE_MAX_MS) {
matches.set(match.id, database.voidDungeonRace(match.id, timestamp));
continue;
}
if (match.bot && timestamp >= match.startAt + match.bot.finishMs) {
settle(match, match.participants.find((entry) => !entry.accountId).side, match.startAt + match.bot.finishMs);
continue;
}
const stale = match.participants.filter((entry) => entry.accountId
&& timestamp - (lastSeen.get(entry.accountId) ?? match.startAt) >= RACE_DISCONNECT_MS);
if (stale.length === 2) { matches.set(match.id, database.voidDungeonRace(match.id, timestamp)); continue; }
if (stale.length === 1) settle(match, 1 - stale[0].side, timestamp);
}
}
function queue(account, input) {
if (tickets.has(account.id) || ["preparing", "active"].includes(currentMatch(account.id)?.status))
throw new GameDatabaseError("You are already in a race.", 409, "race_already_active");
if (!input || !["authored", "randomized"].includes(input.pool)
|| (input.dungeonId !== undefined && (typeof input.dungeonId !== "string" || input.dungeonId.length > 120))
|| !["tank", "healer", "damage"].includes(input.role))
throw new GameDatabaseError("Choose a valid race queue and party role.", 400, "invalid_race_queue");
const character = database.getCloudSave(account.id).data?.characters?.find((entry) => entry?.id === input.characterId);
if (!character || !["wow", "rom"].includes(character.categoryId ?? "wow"))
throw new GameDatabaseError("Choose a WoW or RoM character from your account.", 400, "invalid_race_character");
if (database.onlineGroupState(account.id).group?.members.length > 1)
throw new GameDatabaseError("Leave your online group before entering a solo race.", 409, "race_group_active");
const eligible = raceDungeons(input.pool, database.getDungeonQueuePolicy().config);
if (!eligible.length || (input.dungeonId && !eligible.some((entry) => entry.id === input.dungeonId)))
throw new GameDatabaseError("No eligible dungeon is enabled for this queue.", 409, "race_dungeon_unavailable");
tickets.set(account.id, { accountId: account.id, username: account.username,
characterId: character.id, source: character.categoryId ?? "wow", role: input.role,
pool: input.pool, dungeonId: input.dungeonId ?? null, joinedAt: now() });
pump();
return publicState(account.id);
}
function state(account) {
pump();
if (currentMatch(account.id)?.status === "active") lastSeen.set(account.id, now());
return publicState(account.id);
}
function ready(account, id) {
const match = requireParticipant(account.id, id);
if (match.status !== "preparing") return publicState(account.id);
lastSeen.set(account.id, now());
const updated = database.readyDungeonRace(id, account.id, now());
matches.set(id, updated);
const humans = updated.participants.filter((entry) => entry.accountId);
if (humans.every((entry) => entry.readyAt != null)) matches.set(id, database.startDungeonRace(id, now() + RACE_COUNTDOWN_MS));
return publicState(account.id);
}
function finish(account, id, input) {
const match = requireParticipant(account.id, id);
if (!accountMatch.has(account.id)) { accountMatch.set(account.id, id); matches.set(id, match); }
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());
matches.set(id, updated);
if (updated.status === "active") settle(updated, updated.participants.find((entry) => entry.accountId === account.id).side, now());
return publicState(account.id);
}
function leave(account, id = null) {
if (id && currentMatch(account.id)?.id !== id)
throw new GameDatabaseError("That race is not yours.", 404, "race_not_found");
tickets.delete(account.id);
const match = currentMatch(account.id);
if (!match) return publicState(account.id);
const self = match.participants.find((entry) => entry.accountId === account.id);
if (match.status === "preparing" || (match.status === "active" && now() < match.startAt)) {
replaceWithBot(match, self.side, now());
} else if (match.status === "active") settle(match, 1 - self.side, now());
accountMatch.delete(account.id);
lastSeen.delete(account.id);
return publicState(account.id);
}
const timer = autoPump ? setInterval(pump, 250) : null;
timer?.unref?.();
return { queue, state, ready, finish, leave, pump, inspect: () => ({ tickets, matches }),
close: () => { if (timer) clearInterval(timer); for (const match of matches.values())
if (["preparing", "active"].includes(match.status)) database.voidDungeonRace(match.id, now()); } };
}
+166
View File
@@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync, rmSync } from "node:fs";
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 } from "./dungeon-race-rules.mjs";
import { createGameServer } from "./server.mjs";
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
const dungeon = catalog.dungeons.find((entry) => entry.id === "bite-sized-stormwind-stockade");
const bossIds = [dungeon.difficulties[0].finalBossId];
const bossIdsFor = (match) => [catalog.dungeons.find((entry) => entry.id === match.dungeonId)
.difficulties.find((entry) => entry.id === match.difficultyId).finalBossId];
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"]) {
assert.ok(["novice", "regular", "veteran"].includes(botTierForRecord(0, 0, seed)));
assert.ok(["novice", "regular"].includes(botTierForRecord(0, 20, seed)));
assert.ok(["veteran", "elite"].includes(botTierForRecord(20, 0, seed)));
const bot = botOpponent(seed, "regular", "rom");
assert.equal(bot.roster.length, 5);
assert.equal(botProgress(0, bot), 0);
assert.equal(botProgress(bot.finishMs, bot), 100);
assert.ok(botProgress(bot.finishMs / 2, bot) > 48);
}
});
async function fixture(run) {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-race-"));
const database = openGameDatabase({ dataDir });
let clock = Date.now();
const service = createDungeonRaceService({ database, now: () => clock, autoPump: false });
async function player(username, source = "wow") {
const { account } = await database.register(username, "test-password");
database.putCloudSave(account.id, { version: 2, characters: [{ id: `${username}-character`,
name: username, categoryId: source, classId: source === "rom" ? "rom-priest" : "priest",
specializationId: source === "rom" ? undefined : "priest:discipline", level: 30 }] });
return account;
}
const advance = (duration) => { clock += duration; service.pump(); };
try { await run({ database, service, player, advance, time: () => clock }); }
finally { service.close(); database.close(); rmSync(dataDir, { recursive: true, force: true }); }
}
test("random and specific tickets pair across WoW and RoM, then a later losing clear still ranks", async () => fixture(async ({ database, service, player, advance, time }) => {
const wow = await player("race_wow");
const rom = await player("race_rom", "rom");
assert.equal(service.queue(wow, { characterId: "race_wow-character", role: "healer", pool: "authored" }).status, "queued");
const paired = service.queue(rom, { characterId: "race_rom-character", role: "healer", pool: "authored",
dungeonId: dungeon.id });
assert.equal(paired.status, "preparing");
assert.equal(service.state(wow).seed, paired.seed);
assert.equal(paired.opponent.source, "wow");
service.ready(wow, paired.matchId);
assert.equal(service.ready(rom, paired.matchId).status, "countdown");
advance(3_000);
assert.equal(service.state(wow).status, "active");
const first = service.finish(wow, paired.matchId, { runId: paired.seed, bossIds });
assert.equal(first.result, "win");
assert.equal(service.state(rom).result, "loss");
advance(1_000);
const late = service.finish(rom, paired.matchId, { runId: paired.seed, bossIds });
assert.equal(late.result, "loss");
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 }, time()).total, 2);
assert.equal(service.finish(rom, paired.matchId, { runId: paired.seed, bossIds }).clearDurationMs, late.clearDurationMs);
}));
test("five-second unmatched queue fills with a modeled NPC party", async () => fixture(async ({ database, service, player, advance, time }) => {
const account = await player("race_solo");
service.queue(account, { characterId: "race_solo-character", role: "healer", pool: "authored" });
advance(4_999);
assert.equal(service.state(account).status, "queued");
advance(1);
const state = service.state(account);
assert.equal(state.status, "preparing");
assert.equal(state.opponent.type, "bot");
assert.equal(state.opponent.roster.length, 5);
assert.equal(state.opponent.milestones.length, 5);
service.ready(account, state.matchId);
advance(3_000 + state.opponent.finishMs);
assert.equal(service.state(account).result, "loss");
assert.deepEqual(database.listDungeonRaceLeaderboard({ period: "all" }, time()).entries
.map((entry) => ({ username: entry.username, wins: entry.wins, losses: entry.losses })),
[{ username: "race_solo", wins: 0, losses: 1 }]);
}));
test("a missing human during preparation becomes a bot without charging the ready player", async () => fixture(async ({ service, player, advance }) => {
const left = await player("race_ready");
const right = await player("race_missing");
service.queue(left, { characterId: "race_ready-character", role: "healer", pool: "authored" });
const match = service.queue(right, { characterId: "race_missing-character", role: "healer", pool: "authored" });
service.ready(left, match.matchId);
advance(5 * 60_000);
assert.equal(service.state(left).opponent.type, "bot");
assert.equal(service.state(right).status, "idle");
assert.equal(service.state(left).status, "countdown");
}));
test("leaving during the countdown replaces the rival without a result", async () => fixture(async ({ service, player, advance }) => {
const left = await player("race_cancel_a");
const right = await player("race_cancel_b");
service.queue(left, { characterId: "race_cancel_a-character", role: "healer", pool: "authored" });
const match = service.queue(right, { characterId: "race_cancel_b-character", role: "healer", pool: "authored" });
service.ready(left, match.matchId); service.ready(right, match.matchId);
assert.equal(service.leave(left, match.matchId).status, "idle");
assert.equal(service.state(right).opponent.type, "bot");
assert.equal(service.state(right).result, null);
advance(3_000);
assert.equal(service.state(right).status, "active");
}));
test("the 30-day window expires while all-time wins and times remain", async () => fixture(async ({ database, service, player, advance, time }) => {
const left = await player("race_window_a");
const right = await player("race_window_b");
service.queue(left, { characterId: "race_window_a-character", role: "healer", pool: "authored" });
const match = service.queue(right, { characterId: "race_window_b-character", role: "healer", pool: "authored" });
service.ready(left, match.matchId); service.ready(right, match.matchId);
advance(3_000);
service.finish(left, match.matchId, { runId: match.seed, bossIds: bossIdsFor(match) });
assert.equal(database.listDungeonRaceLeaderboard({ period: "30d" }, time()).total, 2);
advance(30 * 24 * 60 * 60_000 + 1);
assert.equal(database.listDungeonRaceLeaderboard({ period: "30d" }, time()).total, 0);
assert.equal(database.listDungeonRaceLeaderboard({ period: "all" }, time()).total, 2);
}));
test("an active disconnect forfeits after the grace period and the other player can still clear", async () => fixture(async ({ service, player, advance }) => {
const left = await player("race_drop_a");
const right = await player("race_drop_b");
service.queue(left, { characterId: "race_drop_a-character", role: "healer", pool: "authored" });
const match = service.queue(right, { characterId: "race_drop_b-character", role: "healer", pool: "authored" });
service.ready(left, match.matchId); service.ready(right, match.matchId);
advance(3_000);
service.state(right);
advance(29_000);
assert.equal(service.state(left).result, "loss");
assert.equal(service.state(right).result, "win");
assert.equal(service.finish(right, match.matchId, { runId: match.seed, bossIds: bossIdsFor(match) }).result, "win");
}));
test("production HTTP routes expose ranked queue and public boards without shelved modes", async () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-race-http-"));
const runtime = createGameServer({ dataDir, staticDir: dataDir, contentDir: dataDir, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
const base = `http://127.0.0.1:${runtime.server.address().port}`;
try {
const auth = await (await fetch(`${base}/api/auth/register`, { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "race_http", password: "test-password" }) })).json();
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` };
await fetch(`${base}/api/cloud-save`, { method: "PUT", headers,
body: JSON.stringify({ data: { version: 2, characters: [{ id: "http-character", name: "Runner",
classId: "priest", categoryId: "wow", level: 30 }] } }) });
const response = await fetch(`${base}/api/dungeon-races/queue`, { method: "POST", headers,
body: JSON.stringify({ characterId: "http-character", role: "healer", pool: "authored" }) });
assert.equal(response.status, 200);
assert.equal((await response.json()).status, "queued");
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);
} finally {
await new Promise((resolve) => runtime.server.close(resolve));
rmSync(dataDir, { recursive: true, force: true });
}
});
+162
View File
@@ -0,0 +1,162 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import { RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
export function createDungeonRaceRepository(database, { ErrorType }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_race_matches (
id TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL, seed TEXT NOT NULL,
difficulty_id TEXT NOT NULL, boss_difficulty INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ('preparing', 'active', 'settled', 'void')),
created_at INTEGER NOT NULL, start_at INTEGER, settled_at INTEGER,
winner_side INTEGER CHECK (winner_side IN (0, 1)), bot_json TEXT
) STRICT;
CREATE TABLE IF NOT EXISTS dungeon_race_participants (
match_id TEXT NOT NULL REFERENCES dungeon_race_matches(id) ON DELETE CASCADE,
side INTEGER NOT NULL CHECK (side IN (0, 1)),
account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
character_id TEXT, username TEXT NOT NULL, source TEXT NOT NULL,
role TEXT, ready_at INTEGER, clear_at INTEGER, duration_ms INTEGER,
outcome TEXT CHECK (outcome IN ('win', 'loss')),
PRIMARY KEY (match_id, side)
) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_race_result_idx
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);
`);
database.prepare("UPDATE dungeon_race_matches SET status = 'void', settled_at = ? WHERE status IN ('preparing', 'active')")
.run(Date.now());
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)
VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?)`);
const insertParticipant = database.prepare(`INSERT INTO dungeon_race_participants
(match_id, side, account_id, character_id, username, source, role)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
const updateReady = database.prepare(`UPDATE dungeon_race_participants SET ready_at = COALESCE(ready_at, ?)
WHERE match_id = ? AND account_id = ?`);
const updateStart = database.prepare(`UPDATE dungeon_race_matches SET status = 'active', start_at = ?
WHERE id = ? AND status = 'preparing'`);
const updateBot = database.prepare(`UPDATE dungeon_race_matches SET bot_json = ?
WHERE id = ? AND status IN ('preparing', 'active')`);
const deleteParticipant = database.prepare("DELETE FROM dungeon_race_participants WHERE match_id = ? AND side = ?");
const updateClear = database.prepare(`UPDATE dungeon_race_participants
SET clear_at = COALESCE(clear_at, ?), duration_ms = COALESCE(duration_ms, ?)
WHERE match_id = ? AND account_id = ? AND clear_at IS NULL`);
const updateSettlement = database.prepare(`UPDATE dungeon_race_matches
SET status = 'settled', settled_at = ?, winner_side = ? WHERE id = ? AND status = 'active'`);
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 = ?`);
function transaction(action) {
database.exec("BEGIN IMMEDIATE");
try { const result = action(); database.exec("COMMIT"); return result; }
catch (error) { database.exec("ROLLBACK"); throw error; }
}
function getMatch(id) {
const match = matchById.get(id);
if (!match) return null;
return { id: match.id, dungeonId: match.dungeon_id, seed: match.seed,
difficultyId: match.difficulty_id, bossDifficulty: match.boss_difficulty,
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,
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 })) };
}
function createMatch(match) {
transaction(() => {
insertMatch.run(match.id, match.dungeonId, match.seed, match.difficultyId, match.createdAt,
match.bot ? JSON.stringify(match.bot) : null);
for (const participant of match.participants) insertParticipant.run(match.id, participant.side,
participant.accountId, participant.characterId, participant.username, participant.source, participant.role);
});
return getMatch(match.id);
}
function settleMatch(id, winnerSide, timestamp) {
return transaction(() => {
const changed = updateSettlement.run(timestamp, winnerSide, id).changes;
if (changed) for (const participant of participantsById.all(id)) {
if (participant.account_id) updateOutcome.run(participant.side === winnerSide ? "win" : "loss", id, participant.side);
}
return getMatch(id);
});
}
function completeRaceRun(id, accountId, bossIds, timestamp) {
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");
if (!match.startAt || timestamp < match.startAt || timestamp - match.startAt > 2 * 60 * 60_000
|| match.status === "void") throw new ErrorType("This race is not accepting clears.", 409, "race_not_active");
const difficulty = dungeons.get(match.dungeonId)?.difficulties.find((entry) => entry.id === match.difficultyId);
const finalIds = difficulty?.finalBossIds ?? (difficulty?.finalBossId ? [difficulty.finalBossId] : []);
if (!Array.isArray(bossIds) || bossIds.length > 100 || bossIds.some((value) => !difficulty?.bossIds.includes(value))
|| !bossIds.some((value) => finalIds.includes(value)))
throw new ErrorType("Defeat the race's final boss before reporting a clear.", 400, "invalid_race_clear");
return transaction(() => {
updateClear.run(timestamp, Math.max(1, timestamp - match.startAt), id, accountId);
return getMatch(id);
});
}
function replaceWithBot(id, side, bot) {
transaction(() => {
deleteParticipant.run(id, side);
insertParticipant.run(id, side, null, null, bot.name, bot.source, null);
updateBot.run(JSON.stringify(bot), id);
});
return getMatch(id);
}
function recordForAccount(accountId, timestamp) {
const row = database.prepare(`SELECT
SUM(CASE WHEN p.outcome = 'win' THEN 1 ELSE 0 END) AS wins,
SUM(CASE WHEN p.outcome = 'loss' THEN 1 ELSE 0 END) AS losses
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
WHERE p.account_id = ? AND m.status = 'settled' AND m.settled_at >= ?`)
.get(accountId, timestamp - RACE_PERIOD_MS);
return { wins: Number(row.wins ?? 0), losses: Number(row.losses ?? 0) };
}
function leaderboard({ view = "wins", period = "30d", dungeonId = null, limit = 20, offset = 0 } = {}, timestamp = Date.now()) {
if (!["wins", "times"].includes(view) || !["30d", "all"].includes(period)
|| (dungeonId !== null && (!dungeons.has(dungeonId) || !racePoolForDungeon(dungeonId)))
|| !Number.isSafeInteger(limit) || limit < 1 || limit > 100
|| !Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000)
throw new ErrorType("Choose a valid race leaderboard.", 400, "invalid_race_leaderboard");
if (view === "times" && !dungeonId) throw new ErrorType("Choose a dungeon for race times.", 400, "race_dungeon_required");
const since = period === "30d" ? timestamp - RACE_PERIOD_MS : 0;
const byDungeon = dungeonId ? "AND m.dungeon_id = ?" : "";
const args = dungeonId ? [since, dungeonId] : [since];
const query = view === "wins" ? `
SELECT p.account_id AS accountId, a.username AS username,
SUM(CASE WHEN p.outcome = 'win' THEN 1 ELSE 0 END) AS wins,
SUM(CASE WHEN p.outcome = 'loss' THEN 1 ELSE 0 END) AS losses
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
JOIN accounts a ON a.id = p.account_id
WHERE m.status = 'settled' AND m.settled_at >= ? ${byDungeon}
GROUP BY p.account_id, a.username ORDER BY wins DESC, losses ASC, a.username COLLATE NOCASE ASC`
: `SELECT p.account_id AS accountId, a.username AS username, MIN(p.duration_ms) AS durationMs
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
JOIN accounts a ON a.id = p.account_id
WHERE p.clear_at >= ? ${byDungeon} AND p.duration_ms IS NOT NULL
GROUP BY p.account_id, a.username ORDER BY durationMs ASC, a.username COLLATE NOCASE ASC`;
const all = database.prepare(query).all(...args);
let lastValue = null;
let rank = 0;
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) }) };
});
return { view, period, dungeonId, 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 };
}
+20
View File
@@ -0,0 +1,20 @@
export const DEFAULT_PARTY_BUFF_POLICY = Object.freeze({ schemaVersion: 1, rules: Object.freeze([]) });
export function normalizePartyBuffPolicy(value) {
if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1
|| !Array.isArray(value.rules) || value.rules.length > 500) {
throw new Error("Party buff policy must have schemaVersion 1 and at most 500 rules.");
}
const seen = new Set();
const rules = value.rules.map((rule, index) => {
if (!rule || typeof rule !== "object" || Array.isArray(rule)
|| typeof rule.abilityId !== "string" || !/^[a-z0-9][a-z0-9:._-]{0,127}$/i.test(rule.abilityId)
|| !["party", "tank", "healer"].includes(rule.scope)) {
throw new Error(`Party buff rule ${index} must have a valid abilityId and scope.`);
}
if (seen.has(rule.abilityId)) throw new Error(`Duplicate party buff rule: ${rule.abilityId}.`);
seen.add(rule.abilityId);
return { abilityId: rule.abilityId, scope: rule.scope };
});
return { schemaVersion: 1, rules };
}
+20
View File
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizePartyBuffPolicy } from "./party-buff-policy.mjs";
test("accepts explicit party, tank, and healer selections", () => {
assert.deepEqual(normalizePartyBuffPolicy({ schemaVersion: 1, rules: [
{ abilityId: "priest-fortitude", scope: "party" },
{ abilityId: "paladin-blessing", scope: "tank" },
{ abilityId: "mage-intellect", scope: "healer" },
] }).rules.map((rule) => rule.scope), ["party", "tank", "healer"]);
});
test("rejects duplicate rules and invalid scopes", () => {
assert.throws(() => normalizePartyBuffPolicy({ schemaVersion: 1, rules: [
{ abilityId: "fortitude", scope: "party" }, { abilityId: "fortitude", scope: "tank" },
] }), /Duplicate/);
assert.throws(() => normalizePartyBuffPolicy({ schemaVersion: 1, rules: [
{ abilityId: "fortitude", scope: "everyone" },
] }), /valid abilityId and scope/);
});
+75 -1
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { GameDatabaseError, openGameDatabase } from "./database.mjs"; import { GameDatabaseError, openGameDatabase } from "./database.mjs";
import { createArenaService } from "./arena-service.mjs"; import { createArenaService } from "./arena-service.mjs";
import { createDungeonRaceService } from "./dungeon-race-service.mjs";
import { publicManastormAdminConfig } from "./manastorm-config.mjs"; import { publicManastormAdminConfig } from "./manastorm-config.mjs";
import { createOnlineSessionManager } from "./online-session.mjs"; import { createOnlineSessionManager } from "./online-session.mjs";
import { upgradeArenaWebSocket } from "./websocket.mjs"; import { upgradeArenaWebSocket } from "./websocket.mjs";
@@ -188,6 +189,7 @@ export function createGameServer(options = {}) {
}); });
const onlineSessions = createOnlineSessionManager(); const onlineSessions = createOnlineSessionManager();
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes }); const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
const dungeonRaceService = createDungeonRaceService({ database: gameDatabase });
const allowAuthRequest = createAuthLimiter(); const allowAuthRequest = createAuthLimiter();
const allowReportRequest = createAuthLimiter(); const allowReportRequest = createAuthLimiter();
const allowDungeonRunRequest = createAuthLimiter(); const allowDungeonRunRequest = createAuthLimiter();
@@ -303,6 +305,40 @@ export function createGameServer(options = {}) {
json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) }); json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) });
return; return;
} }
if (url.pathname === "/api/dungeon-races/leaderboard" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonRaceLeaderboard({
view: url.searchParams.get("view") ?? "wins",
period: url.searchParams.get("period") ?? "30d",
dungeonId: url.searchParams.get("dungeon") || null,
limit: Number(url.searchParams.get("limit") ?? 20),
offset: Number(url.searchParams.get("offset") ?? 0),
}));
return;
}
if (url.pathname === "/api/dungeon-races/queue" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, dungeonRaceService.queue(account, await readJson(request, 4 * 1024)));
return;
}
if (url.pathname === "/api/dungeon-races/queue" && request.method === "DELETE") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, dungeonRaceService.leave(account));
return;
}
if (url.pathname === "/api/dungeon-races/state" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, dungeonRaceService.state(account));
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))
: dungeonRaceService.leave(account, id));
return;
}
if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") { if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"), json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"), difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
@@ -540,6 +576,43 @@ export function createGameServer(options = {}) {
json(response, 200, result); json(response, 200, result);
return; return;
} }
if (url.pathname === "/api/party-buff-policy" && request.method === "GET") {
const current = gameDatabase.getPartyBuffPolicy();
json(response, 200, { revision: current.revision, updatedAt: current.updatedAt, config: current.config });
return;
}
if (url.pathname === "/api/dungeon-queue-policy" && request.method === "GET") {
const current = gameDatabase.getDungeonQueuePolicy();
json(response, 200, { revision: current.revision, updatedAt: current.updatedAt, config: current.config });
return;
}
if (url.pathname === "/api/gm/dungeon-queue-policy" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
json(response, 200, gameDatabase.getDungeonQueuePolicy());
return;
}
if (url.pathname === "/api/gm/dungeon-queue-policy" && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
const body = await readJson(request, bodyLimit);
json(response, 200, gameDatabase.putDungeonQueuePolicy(account, body.expectedRevision, body.config));
return;
}
if (url.pathname === "/api/gm/party-buff-policy" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
json(response, 200, gameDatabase.getPartyBuffPolicy());
return;
}
if (url.pathname === "/api/gm/party-buff-policy" && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
const body = await readJson(request, bodyLimit);
const result = gameDatabase.putPartyBuffPolicy(account, body.expectedRevision, body.config);
json(response, 200, result);
return;
}
if (url.pathname === "/api/companion-spec-readiness" && request.method === "GET") { if (url.pathname === "/api/companion-spec-readiness" && request.method === "GET") {
const current = gameDatabase.getCompanionSpecReadiness(); const current = gameDatabase.getCompanionSpecReadiness();
json(response, 200, { json(response, 200, {
@@ -686,9 +759,10 @@ export function createGameServer(options = {}) {
balanceLabJobs.close?.(); balanceLabJobs.close?.();
onlineSessions.clear(); onlineSessions.clear();
arenaService.close(); arenaService.close();
dungeonRaceService.close();
gameDatabase.close(); gameDatabase.close();
}); });
return { server, database: gameDatabase, arenaService, balanceLabJobs, staticDir, contentDir, enableShelvedModes }; return { server, database: gameDatabase, arenaService, dungeonRaceService, balanceLabJobs, staticDir, contentDir, enableShelvedModes };
} }
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+20
View File
@@ -94,6 +94,14 @@ test("serves the game and authenticated cloud-save API", async () => {
headers: { Authorization: `Bearer ${auth.token}` }, headers: { Authorization: `Bearer ${auth.token}` },
}); });
assert.equal(deniedCooldownPolicy.status, 403); assert.equal(deniedCooldownPolicy.status, 403);
const publicPartyBuffPolicy = await (await fetch(`${origin}/api/party-buff-policy`)).json();
assert.deepEqual(publicPartyBuffPolicy.config.rules, []);
const deniedPartyBuffPolicy = await fetch(`${origin}/api/gm/party-buff-policy`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ expectedRevision: 0, config: { schemaVersion: 1, rules: [] } }),
});
assert.equal(deniedPartyBuffPolicy.status, 403);
const publicReadiness = await (await fetch(`${origin}/api/companion-spec-readiness`)).json(); const publicReadiness = await (await fetch(`${origin}/api/companion-spec-readiness`)).json();
assert.equal(publicReadiness.revision, 0); assert.equal(publicReadiness.revision, 0);
assert.equal(publicReadiness.config.statuses["warrior:protection"], "ready"); assert.equal(publicReadiness.config.statuses["warrior:protection"], "ready");
@@ -268,6 +276,18 @@ test("serves the game and authenticated cloud-save API", async () => {
assert.equal(publishedCooldownPolicyPayload.config.generic.defensiveHealthPercent, 61); assert.equal(publishedCooldownPolicyPayload.config.generic.defensiveHealthPercent, 61);
const playerCooldownPolicy = await (await fetch(`${origin}/api/companion-cooldown-policy`)).json(); const playerCooldownPolicy = await (await fetch(`${origin}/api/companion-cooldown-policy`)).json();
assert.equal(playerCooldownPolicy.config.generic.defensiveHealthPercent, 61); assert.equal(playerCooldownPolicy.config.generic.defensiveHealthPercent, 61);
const publishedPartyBuffPolicy = await fetch(`${origin}/api/gm/party-buff-policy`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
body: JSON.stringify({
expectedRevision: publicPartyBuffPolicy.revision,
config: { schemaVersion: 1, rules: [{ abilityId: "wow335-priest-power-word-fortitude", scope: "tank" }] },
}),
});
assert.equal(publishedPartyBuffPolicy.status, 200);
assert.equal((await publishedPartyBuffPolicy.json()).revision, 1);
const playerPartyBuffPolicy = await (await fetch(`${origin}/api/party-buff-policy`)).json();
assert.deepEqual(playerPartyBuffPolicy.config.rules, [{ abilityId: "wow335-priest-power-word-fortitude", scope: "tank" }]);
const publishedReadiness = await fetch(`${origin}/api/gm/companion-spec-readiness`, { const publishedReadiness = await fetch(`${origin}/api/gm/companion-spec-readiness`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` }, headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
+3
View File
@@ -15,6 +15,7 @@ import {
} from "./ui/gameplayResources"; } from "./ui/gameplayResources";
import { useCompanionCooldownPolicyStore } from "./game/companionCooldownPolicyStore"; import { useCompanionCooldownPolicyStore } from "./game/companionCooldownPolicyStore";
import { useCompanionSpecReadinessStore } from "./game/companionSpecReadinessStore"; import { useCompanionSpecReadinessStore } from "./game/companionSpecReadinessStore";
import { usePartyBuffPolicyStore } from "./game/partyBuffPolicyStore";
const GameRuntime = lazy(loadGameRuntime); const GameRuntime = lazy(loadGameRuntime);
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen }))); const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
@@ -76,9 +77,11 @@ export default function App() {
const PreloadedGameRuntime = getLoadedGameRuntime(); const PreloadedGameRuntime = getLoadedGameRuntime();
const refreshCooldownPolicy = useCompanionCooldownPolicyStore((state) => state.refresh); const refreshCooldownPolicy = useCompanionCooldownPolicyStore((state) => state.refresh);
const refreshSpecReadiness = useCompanionSpecReadinessStore((state) => state.refresh); const refreshSpecReadiness = useCompanionSpecReadinessStore((state) => state.refresh);
const refreshPartyBuffPolicy = usePartyBuffPolicyStore((state) => state.refresh);
useEffect(() => { void refreshCooldownPolicy(session); }, [refreshCooldownPolicy, session]); useEffect(() => { void refreshCooldownPolicy(session); }, [refreshCooldownPolicy, session]);
useEffect(() => { void refreshSpecReadiness(session); }, [refreshSpecReadiness, session]); useEffect(() => { void refreshSpecReadiness(session); }, [refreshSpecReadiness, session]);
useEffect(() => { void refreshPartyBuffPolicy(session); }, [refreshPartyBuffPolicy, session]);
useEffect(() => { useEffect(() => {
if (!SHELVED_MODES_ENABLED && isShelvedShellPhase(phase)) { if (!SHELVED_MODES_ENABLED && isShelvedShellPhase(phase)) {
+8
View File
@@ -23,6 +23,8 @@ import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
import { OnlineSessionBridge } from "./game/OnlineSessionBridge"; import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge"; import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
import { DungeonRunBridge } from "./game/DungeonRunBridge"; import { DungeonRunBridge } from "./game/DungeonRunBridge";
import { DungeonRaceBridge } from "./game/DungeonRaceBridge";
import { DungeonRaceHud } from "./ui/DungeonRaceHud";
import { DungeonRunResults } from "./ui/DungeonRunResults"; import { DungeonRunResults } from "./ui/DungeonRunResults";
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge"; import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction"; import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
@@ -38,6 +40,7 @@ import { DungeonBossIntro } from "./ui/DungeonBossIntro";
import { useDungeonRouteStore } from "./game/dungeonRouteStore"; import { useDungeonRouteStore } from "./game/dungeonRouteStore";
import { installCombatClock } from "./game/combatClock"; import { installCombatClock } from "./game/combatClock";
import { dungeonSimulationNow } from "./game/dungeonSimulationStore"; import { dungeonSimulationNow } from "./game/dungeonSimulationStore";
import { useDungeonRaceStore } from "./app/dungeonRaceStore";
function InputBridge() { function InputBridge() {
const overlay = useGameStore((state) => state.overlay); const overlay = useGameStore((state) => state.overlay);
@@ -47,6 +50,7 @@ function InputBridge() {
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars, desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play" inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play"
|| useGameStore.getState().dungeonIntroActive || useGameStore.getState().dungeonIntroActive
|| useGameStore.getState().raceStartBlocked
|| ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""), || ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => { getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences; const preferences = useUiLayoutStore.getState().preferences;
@@ -110,6 +114,8 @@ export default function GameRuntime() {
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout"); const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
const dungeonSimulation = useDungeonSimulationStore((state) => state.sessionMode !== "play"); const dungeonSimulation = useDungeonSimulationStore((state) => state.sessionMode !== "play");
useEffect(() => () => { useEffect(() => () => {
const race = useDungeonRaceStore.getState();
if (race.inGame && ["preparing", "countdown", "active"].includes(race.status)) void race.leave();
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId); const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
clearDungeonRuntimeCaches(); clearDungeonRuntimeCaches();
releaseDungeonGltfCache(activeDungeon); releaseDungeonGltfCache(activeDungeon);
@@ -135,10 +141,12 @@ export default function GameRuntime() {
<CombatBridge /> <CombatBridge />
<PartyBridge /> <PartyBridge />
<DungeonRunBridge /> <DungeonRunBridge />
<DungeonRaceBridge />
<BossTestingBridge /> <BossTestingBridge />
<ShelvedGameBridges /> <ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary> <SceneErrorBoundary><GameScene /></SceneErrorBoundary>
{dungeonSimulation ? <DungeonSimulationHud /> : <Hud />} {dungeonSimulation ? <DungeonSimulationHud /> : <Hud />}
{!dungeonSimulation && <DungeonRaceHud />}
{!dungeonSimulation && <DungeonBossIntro />} {!dungeonSimulation && <DungeonBossIntro />}
{!dungeonSimulation && <MapPanel />} {!dungeonSimulation && <MapPanel />}
{!dungeonSimulation && <GameplayPanels />} {!dungeonSimulation && <GameplayPanels />}
+30 -2
View File
@@ -4,6 +4,7 @@ import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
} from "../game/dungeonContentSource"; } from "../game/dungeonContentSource";
import type { DungeonRunProgress } from "../game/dungeonRunTypes"; import type { DungeonRunProgress } from "../game/dungeonRunTypes";
import { DEFAULT_DUNGEON_QUEUE_POLICY } from "../game/dungeonQueuePolicy";
import { canStartAnotherBiteSizedDungeon, startAnotherBiteSizedDungeon } from "./biteSizedDungeonRuntime"; import { canStartAnotherBiteSizedDungeon, startAnotherBiteSizedDungeon } from "./biteSizedDungeonRuntime";
import { useOnlineGroupStore } from "./onlineGroupStore"; import { useOnlineGroupStore } from "./onlineGroupStore";
import { useOnlineSessionStore } from "./onlineSessionStore"; import { useOnlineSessionStore } from "./onlineSessionStore";
@@ -38,6 +39,29 @@ afterEach(() => {
}); });
describe("bite-sized replay", () => { describe("bite-sized replay", () => {
it("does not replay a disabled randomized location", async () => {
const enterDungeon = vi.fn(() => true);
useShellStore.setState({
session: { kind: "offline", ownerId: "local-owner", displayName: "Tester" },
activeDungeonRole: "healer",
enterDungeon,
setNotice: vi.fn(),
});
await expect(startAnotherBiteSizedDungeon(
{ ...progress, dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID },
{
random: () => 0,
loadPolicy: async () => ({ schemaVersion: 1, disabled: {
regular: [], biteSized: [], randomizedBiteSized: [RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID],
} }),
createRunId: () => "unused",
contentIsUsable: () => true,
prepareRandomizedContent: vi.fn(),
},
)).resolves.toBe(false);
expect(enterDungeon).not.toHaveBeenCalled();
});
it("starts a fresh local run and treats the local player as party leader", async () => { it("starts a fresh local run and treats the local player as party leader", async () => {
const enterDungeon = vi.fn(() => true); const enterDungeon = vi.fn(() => true);
const saveCheckpoint = vi.fn(); const saveCheckpoint = vi.fn();
@@ -51,12 +75,14 @@ describe("bite-sized replay", () => {
expect(canStartAnotherBiteSizedDungeon()).toBe(true); expect(canStartAnotherBiteSizedDungeon()).toBe(true);
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true); await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
expect(saveCheckpoint).toHaveBeenCalledWith(null); expect(saveCheckpoint).toHaveBeenCalledWith(null);
expect(enterDungeon).toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", { expect(enterDungeon).toHaveBeenCalledWith(expect.stringMatching(/^bite-sized-/), "healer", {
difficultyId: "normal", difficultyId: "normal",
bossDifficulty: 1, bossDifficulty: 1,
freshRun: true, freshRun: true,
runId: expect.any(String), runId: expect.any(String),
}); });
expect(enterDungeon).not.toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID,
expect.anything(), expect.anything());
}); });
it("replays randomized mode with a new seed and prepares that seed's creature assets", async () => { it("replays randomized mode with a new seed and prepares that seed's creature assets", async () => {
@@ -73,6 +99,7 @@ describe("bite-sized replay", () => {
await expect(startAnotherBiteSizedDungeon(randomizedProgress, { await expect(startAnotherBiteSizedDungeon(randomizedProgress, {
random: () => 0, random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
createRunId: () => "next-randomized-seed", createRunId: () => "next-randomized-seed",
contentIsUsable: () => true, contentIsUsable: () => true,
prepareRandomizedContent, prepareRandomizedContent,
@@ -114,7 +141,7 @@ describe("bite-sized replay", () => {
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true); await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
expect(startActivity).toHaveBeenCalledWith(expect.objectContaining({ ownerId: "leader" }), { expect(startActivity).toHaveBeenCalledWith(expect.objectContaining({ ownerId: "leader" }), {
type: "dungeon", type: "dungeon",
contentId: BITE_SIZED_STOCKADES_DUNGEON_ID, contentId: expect.stringMatching(/^bite-sized-/),
difficultyId: "normal", difficultyId: "normal",
bossDifficulty: 1, bossDifficulty: 1,
partySize: 4, partySize: 4,
@@ -140,6 +167,7 @@ describe("bite-sized replay", () => {
await expect(startAnotherBiteSizedDungeon( await expect(startAnotherBiteSizedDungeon(
{ ...progress, dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID }, { ...progress, dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID },
{ random: () => 0, createRunId: () => "unused-local-seed", contentIsUsable: () => true, { random: () => 0, createRunId: () => "unused-local-seed", contentIsUsable: () => true,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
prepareRandomizedContent: vi.fn() }, prepareRandomizedContent: vi.fn() },
)).resolves.toBe(true); )).resolves.toBe(true);
+7 -2
View File
@@ -1,11 +1,13 @@
import { dungeonContentIsUsable } from "../content/contentManager"; import { dungeonContentIsUsable } from "../content/contentManager";
import { randomDungeonByCategory, randomRandomizedBiteSizedDungeon } from "../game/dungeonCatalog"; import { randomDungeonByCategory, randomRandomizedBiteSizedDungeon } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue, type DungeonQueuePolicy } from "../game/dungeonQueuePolicy";
import { isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource"; import { isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
import { dungeonDefinitionById } from "../game/dungeonRegistry"; import { dungeonDefinitionById } from "../game/dungeonRegistry";
import type { DungeonRunProgress } from "../game/dungeonRunTypes"; import type { DungeonRunProgress } from "../game/dungeonRunTypes";
import { useOnlineGroupStore } from "./onlineGroupStore"; import { useOnlineGroupStore } from "./onlineGroupStore";
import { useOnlineSessionStore } from "./onlineSessionStore"; import { useOnlineSessionStore } from "./onlineSessionStore";
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent"; import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
import { loadDungeonQueuePolicy } from "./dungeonQueuePolicyStore";
import { useShellStore } from "./shellStore"; import { useShellStore } from "./shellStore";
export function canStartAnotherBiteSizedDungeon(): boolean { export function canStartAnotherBiteSizedDungeon(): boolean {
@@ -18,6 +20,7 @@ export function canStartAnotherBiteSizedDungeon(): boolean {
interface BiteSizedReplayDependencies { interface BiteSizedReplayDependencies {
readonly random: () => number; readonly random: () => number;
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
readonly createRunId: () => string; readonly createRunId: () => string;
readonly contentIsUsable: (dungeonId: string) => boolean; readonly contentIsUsable: (dungeonId: string) => boolean;
readonly prepareRandomizedContent: (seed: string) => Promise<void>; readonly prepareRandomizedContent: (seed: string) => Promise<void>;
@@ -25,6 +28,7 @@ interface BiteSizedReplayDependencies {
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = { const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
random: Math.random, random: Math.random,
loadPolicy: loadDungeonQueuePolicy,
createRunId: () => globalThis.crypto.randomUUID(), createRunId: () => globalThis.crypto.randomUUID(),
contentIsUsable: dungeonContentIsUsable, contentIsUsable: dungeonContentIsUsable,
prepareRandomizedContent: ensureRandomizedBiteSizedRunContent, prepareRandomizedContent: ensureRandomizedBiteSizedRunContent,
@@ -45,11 +49,12 @@ export async function startAnotherBiteSizedDungeon(
} }
const randomized = isRandomizedBiteSizedDungeonId(progress.dungeonId); const randomized = isRandomizedBiteSizedDungeonId(progress.dungeonId);
const policy = await dependencies.loadPolicy();
const next = randomized const next = randomized
? randomRandomizedBiteSizedDungeon(progress.dungeonId, dependencies.random, ? randomRandomizedBiteSizedDungeon(progress.dungeonId, dependencies.random,
(dungeon) => dependencies.contentIsUsable(dungeon.id)) (dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon))
: randomDungeonByCategory("bite-sized", progress.dungeonId, dependencies.random, : randomDungeonByCategory("bite-sized", progress.dungeonId, dependencies.random,
(dungeon) => dependencies.contentIsUsable(dungeon.id)); (dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon));
if (!next) { if (!next) {
shell.setNotice(randomized shell.setNotice(randomized
? "No Randomized Bite-Sized location is ready to enter." ? "No Randomized Bite-Sized location is ready to enter."
+113
View File
@@ -0,0 +1,113 @@
import { create } from "zustand";
import { requestOnlineJson } from "./onlineAccountClient";
import type { PlayerSession } from "./types";
import {
DEFAULT_DUNGEON_QUEUE_POLICY,
normalizeDungeonQueuePolicy,
type DungeonQueueMode,
type DungeonQueuePolicy,
} from "../game/dungeonQueuePolicy";
interface PublishedPolicy {
readonly revision: number;
readonly updatedAt: number | null;
readonly updatedBy?: string | null;
readonly config: DungeonQueuePolicy;
}
interface PolicyState extends PublishedPolicy {
readonly draft: DungeonQueuePolicy;
readonly loading: boolean;
readonly saving: boolean;
readonly dirty: boolean;
readonly error: string | null;
refresh: (session: PlayerSession | null) => Promise<void>;
setEnabled: (mode: DungeonQueueMode, dungeonId: string, enabled: boolean) => void;
resetDraft: () => void;
publish: (session: PlayerSession | null) => Promise<boolean>;
}
const CACHE_KEY = "healer-man.dungeon-queue-policy.v1";
function cached(): PublishedPolicy {
try {
const value = JSON.parse(globalThis.localStorage?.getItem(CACHE_KEY) ?? "null") as PublishedPolicy;
if (!Number.isInteger(value.revision)) throw new Error("missing");
return { ...value, config: normalizeDungeonQueuePolicy(value.config) };
} catch {
return { revision: 0, updatedAt: null, config: DEFAULT_DUNGEON_QUEUE_POLICY };
}
}
function cache(value: PublishedPolicy): void {
try { globalThis.localStorage?.setItem(CACHE_KEY, JSON.stringify(value)); } catch { /* offline cache is optional */ }
}
function normalized(value: PublishedPolicy): PublishedPolicy {
return { ...value, config: normalizeDungeonQueuePolicy(value.config) };
}
/** Fetch for each random roll so a newly published GM setting is used without a reload. */
export async function loadDungeonQueuePolicy(): Promise<DungeonQueuePolicy> {
try {
const value = normalized(await requestOnlineJson<PublishedPolicy>("/api/dungeon-queue-policy"));
cache(value);
useDungeonQueuePolicyStore.setState((state) => state.dirty ? state : { ...value, draft: value.config });
return value.config;
} catch {
return cached().config;
}
}
const initial = cached();
export const useDungeonQueuePolicyStore = create<PolicyState>((set, get) => ({
...initial,
draft: initial.config,
loading: false,
saving: false,
dirty: false,
error: null,
refresh: async (session) => {
set({ loading: true, error: null });
try {
const gm = Boolean(session?.accessToken && session.roles?.includes("gm"));
const value = normalized(await requestOnlineJson<PublishedPolicy>(
gm ? "/api/gm/dungeon-queue-policy" : "/api/dungeon-queue-policy",
{}, gm ? session?.accessToken : undefined,
));
cache(value);
set({ ...value, draft: value.config, loading: false, dirty: false });
} catch (error) {
set({ loading: false, error: error instanceof Error ? error.message : "Could not load dungeon queue settings." });
}
},
setEnabled: (mode, dungeonId, enabled) => set((state) => {
const ids = new Set(state.draft.disabled[mode]);
if (enabled) ids.delete(dungeonId);
else ids.add(dungeonId);
return {
draft: { schemaVersion: 1, disabled: { ...state.draft.disabled, [mode]: [...ids].sort() } },
dirty: true,
error: null,
};
}),
resetDraft: () => set((state) => ({ draft: state.config, dirty: false, error: null })),
publish: async (session) => {
if (!session?.accessToken || !session.roles?.includes("gm")) {
set({ error: "Game Master access is required." });
return false;
}
set({ saving: true, error: null });
try {
const value = normalized(await requestOnlineJson<PublishedPolicy>("/api/gm/dungeon-queue-policy", {
method: "PUT", body: JSON.stringify({ expectedRevision: get().revision, config: get().draft }),
}, session.accessToken));
cache(value);
set({ ...value, draft: value.config, saving: false, dirty: false });
return true;
} catch (error) {
set({ saving: false, error: error instanceof Error ? error.message : "Could not publish dungeon queue settings." });
return false;
}
},
}));
+39
View File
@@ -0,0 +1,39 @@
import catalog from "../game/dungeonRaceBotCatalog.json";
export type RaceBotTier = "novice" | "regular" | "veteran" | "elite";
export type RaceBotSource = "wow" | "rom";
export interface RaceBot {
readonly type: "bot";
readonly name: string;
readonly tier: RaceBotTier;
readonly source: RaceBotSource;
readonly roster: readonly { readonly name: string; readonly classId: string; readonly role: string }[];
readonly finishMs: number;
readonly milestones: readonly { readonly atMs: number; readonly progress: number; readonly label: string }[];
}
export function hashRaceSeed(value: string): number {
let hash = 2166136261;
for (const char of value) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16777619); }
return hash >>> 0;
}
export function createRaceBot(seed: string, tierId: RaceBotTier, source: RaceBotSource): RaceBot {
const tier = catalog.tiers.find((entry) => entry.id === tierId) ?? catalog.tiers[1];
const variance = 0.92 + (hashRaceSeed(`${seed}:pace`) % 161) / 1000;
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),
milestones: catalog.milestones.map((entry) => ({ ...entry,
atMs: Math.round(catalog.baselineMs * tier.multiplier * variance * entry.at) })) };
}
export function raceBotProgress(elapsedMs: number, bot: RaceBot): number {
if (elapsedMs <= 0) return 0;
const previous = [{ atMs: 0, progress: 0 }, ...bot.milestones]
.filter((entry) => entry.atMs <= elapsedMs).at(-1)!;
const next = bot.milestones.find((entry) => entry.atMs > elapsedMs);
if (!next) return 100;
return Math.floor(previous.progress + (next.progress - previous.progress)
* (elapsedMs - previous.atMs) / (next.atMs - previous.atMs));
}
+232
View File
@@ -0,0 +1,232 @@
import { create } from "zustand";
import { requestOnlineJson } from "./onlineAccountClient";
import { useShellStore } from "./shellStore";
import type { CharacterProfile, PlayerSession } from "./types";
import { dungeonById, dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
import { createRaceBot, raceBotProgress, type RaceBot, type RaceBotSource, type RaceBotTier } from "./dungeonRaceBot";
import type { PartyRole } from "../game/partyRoles";
export type RacePool = "authored" | "randomized";
export type RaceStatus = "idle" | "queued" | "preparing" | "countdown" | "active" | "settled" | "void";
export interface RaceHumanOpponent { readonly type: "human"; readonly name: string;
readonly source: "wow" | "rom"; readonly ready: boolean; readonly finished: boolean }
export type RaceOpponent = RaceHumanOpponent | RaceBot;
export interface RaceServerState {
readonly status: RaceStatus;
readonly serverTime: number;
readonly joinedAt?: number;
readonly botAt?: number;
readonly matchId?: string;
readonly seed?: string;
readonly dungeonId?: string | null;
readonly difficultyId?: string;
readonly startAt?: number | null;
readonly prepareDeadline?: number;
readonly ready?: boolean;
readonly opponent?: RaceOpponent | null;
readonly opponentProgress?: number | null;
readonly result?: "win" | "loss" | null;
readonly clearDurationMs?: number | null;
}
interface RaceState {
mode: "online" | "practice" | null;
status: RaceStatus;
matchId: string | null;
seed: string | null;
dungeonId: string | null;
difficultyId: string | null;
startAt: number | null;
serverOffset: number;
opponent: RaceOpponent | null;
opponentProgress: number | null;
result: "win" | "loss" | null;
clearDurationMs: number | null;
ready: boolean;
inGame: boolean;
preparingContent: boolean;
toastAt: number | null;
error: string;
queueAt: number | null;
botAt: number | null;
startOnline: (session: PlayerSession, character: CharacterProfile, role: PartyRole,
pool: RacePool, dungeonId?: string) => Promise<void>;
startPractice: (character: CharacterProfile, role: PartyRole, dungeonId: string,
tier: RaceBotTier, source: RaceBotSource) => Promise<void>;
sync: () => Promise<void>;
readyUp: () => Promise<void>;
reportClear: (bossIds: readonly string[]) => Promise<void>;
tick: () => void;
leave: () => Promise<void>;
reset: () => void;
}
let sessionToken: string | null = null;
let selectedRole: PartyRole = "healer";
let pollTimer: ReturnType<typeof setInterval> | null = null;
let preparingSeed: string | null = null;
let readyPending = false;
let finishPromise: Promise<void> | 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."; }
function choosePracticeDungeon(pool: RacePool): string | null {
const entries = pool === "randomized" ? randomizedBiteSizedDungeons() : dungeonsByCategory("bite-sized");
return entries.filter((entry) => entry.available)[0]?.id ?? null;
}
async function prepareContent(dungeonId: string, seed: string): Promise<void> {
if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed);
else if (!dungeonContentIsUsable(dungeonId)) await installContentPack(dungeonContentPackId(dungeonId));
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The dungeon content could not be verified.");
}
export const useDungeonRaceStore = create<RaceState>((set, get) => {
async function enterPreparedRun(dungeonId: string, seed: string, difficultyId?: string) {
if (get().inGame || preparingSeed === seed) return;
preparingSeed = seed;
set({ preparingContent: true, error: "" });
try {
await prepareContent(dungeonId, seed);
if (get().seed !== seed || get().status === "void") return;
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime();
const entered = useShellStore.getState().enterDungeon(dungeonId, selectedRole, {
freshRun: true, runId: seed, difficultyId, bossDifficulty: 1, raceStartBlocked: true,
});
if (!entered) throw new Error(useShellStore.getState().notice || "Could not enter the race dungeon.");
set({ inGame: true, preparingContent: false });
} catch (error) {
set({ error: errorMessage(error), preparingContent: false });
if (get().mode === "online") await get().leave();
} finally { preparingSeed = null; }
}
function applyServerState(response: RaceServerState) {
const previous = get();
if (response.status === "idle" && previous.inGame) {
set({ status: "void", error: "The race server restarted. You can finish this dungeon for its rewards, but the race is unranked." });
stopPolling();
return;
}
const result = response.result ?? null;
const offset = response.serverTime - Date.now();
set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null,
dungeonId: response.dungeonId ?? null, difficultyId: response.difficultyId ?? null,
startAt: response.startAt ?? null,
serverOffset: offset, opponent: response.opponent ?? null,
opponentProgress: response.opponentProgress ?? null, result,
clearDurationMs: response.clearDurationMs ?? null, ready: response.ready ?? false,
queueAt: response.joinedAt ?? null, botAt: response.botAt ?? null,
toastAt: result && result !== previous.result ? Date.now() : previous.toastAt });
if (response.matchId && response.seed && response.dungeonId && response.status !== "void")
void enterPreparedRun(response.dungeonId, response.seed, response.difficultyId);
if (["settled", "void", "idle"].includes(response.status)) stopPolling();
}
return {
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: 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,
startOnline: async (session, character, role, pool, dungeonId) => {
if (!session.accessToken) throw new Error("Sign in online to enter a ranked race.");
get().reset();
selectedRole = role;
sessionToken = session.accessToken;
set({ mode: "online", status: "queued" });
try {
const response = await requestOnlineJson<RaceServerState>("/api/dungeon-races/queue", {
method: "POST", body: JSON.stringify({ characterId: character.id, role, pool, ...(dungeonId ? { dungeonId } : {}) }),
}, sessionToken);
applyServerState(response);
pollTimer = setInterval(() => { void get().sync(); }, 750);
} catch (error) { set({ status: "idle", error: errorMessage(error) }); }
},
startPractice: async (character, role, dungeonId, tier, source) => {
get().reset();
if (!["wow", "rom"].includes(character.categoryId ?? "wow")) {
set({ error: "Choose a WoW or RoM character for practice." }); return;
}
selectedRole = role;
const resolvedDungeon = dungeonId || choosePracticeDungeon("authored");
if (!resolvedDungeon || !(resolvedDungeon.startsWith("bite-sized-")
|| resolvedDungeon.startsWith("randomized-bite-sized-")) || !dungeonById(resolvedDungeon)?.available) {
set({ error: "No Bite-Sized dungeon is available for practice." }); return;
}
const seed = `practice-${globalThis.crypto.randomUUID()}`;
set({ mode: "practice", status: "preparing", matchId: seed, seed, dungeonId: resolvedDungeon,
difficultyId: null,
opponent: createRaceBot(seed, tier, source) });
await enterPreparedRun(resolvedDungeon, seed);
},
sync: async () => {
if (get().mode !== "online" || !sessionToken) return;
try { applyServerState(await requestOnlineJson<RaceServerState>("/api/dungeon-races/state", {}, sessionToken)); }
catch (error) { set({ error: errorMessage(error) }); }
},
readyUp: async () => {
const state = get();
if (!state.inGame || state.ready || readyPending || !state.matchId) return;
if (state.mode === "practice") {
set({ ready: true, status: "countdown", startAt: Date.now() + 3_000 }); return;
}
if (!sessionToken) return;
readyPending = true;
try { applyServerState(await requestOnlineJson<RaceServerState>(
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/ready`, { method: "POST" }, sessionToken)); }
catch (error) { set({ error: errorMessage(error) }); }
finally { readyPending = false; }
},
reportClear: (bossIds) => {
const state = get();
if (finishPromise) return finishPromise;
if (!state.matchId || !state.startAt || ["void", "idle"].includes(state.status)
|| state.clearDurationMs != null) return Promise.resolve();
if (state.mode === "practice") {
const now = Date.now();
const result = state.result ?? (now < state.startAt + (state.opponent as RaceBot).finishMs ? "win" : "loss");
set({ status: "settled", result, toastAt: state.result ? state.toastAt : now,
clearDurationMs: Math.max(1, now - state.startAt) });
return Promise.resolve();
}
if (!sessionToken) return Promise.resolve();
const matchId = state.matchId;
finishPromise = requestOnlineJson<RaceServerState>(
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/finish`, {
method: "POST", body: JSON.stringify({ runId: state.seed, bossIds }),
}, sessionToken)
.then((response) => { if (get().matchId === matchId) applyServerState(response); })
.catch((error: unknown) => { if (get().matchId === matchId) set({ error: errorMessage(error) }); })
.finally(() => { finishPromise = null; });
return finishPromise;
},
tick: () => {
const state = get();
if (state.mode !== "practice" || !state.startAt || state.result || state.opponent?.type !== "bot") return;
const now = Date.now();
if (now >= state.startAt + state.opponent.finishMs) set({ status: "settled", result: "loss", toastAt: now,
opponentProgress: 100 });
else if (now >= state.startAt) set({ status: "active",
opponentProgress: raceBotProgress(now - state.startAt, state.opponent) });
},
leave: async () => {
stopPolling();
if (get().mode === "online" && sessionToken) {
try { await requestOnlineJson("/api/dungeon-races/queue", { method: "DELETE" }, sessionToken); }
catch (error) { set({ error: errorMessage(error) }); }
}
if (!get().inGame) {
const message = get().error;
get().reset();
set({ error: message });
}
},
reset: () => {
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false;
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: 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 });
},
};
});
+1
View File
@@ -75,6 +75,7 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
}); });
return false; return false;
} }
if (settings?.raceStartBlocked) useGameStore.setState({ raceStartBlocked: true });
useGameStore.getState().setCompanionOpen(false); useGameStore.getState().setCompanionOpen(false);
useShellStore.setState({ useShellStore.setState({
phase: "game", phase: "game",
+1
View File
@@ -9,6 +9,7 @@ export interface DungeonEntrySettings {
readonly bossDifficulty?: number; readonly bossDifficulty?: number;
readonly freshRun?: boolean; readonly freshRun?: boolean;
readonly runId?: string; readonly runId?: string;
readonly raceStartBlocked?: boolean;
} }
let installedRuntime: DungeonShellRuntime | null = null; let installedRuntime: DungeonShellRuntime | null = null;
+31 -1
View File
@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import { createDefaultAppearance } from "./characterCatalog"; import { createDefaultAppearance } from "./characterCatalog";
import type { CharacterProfile, PlayerSession } from "./types"; import type { CharacterProfile, PlayerSession } from "./types";
import type { OnlineGroup } from "./onlineGroupTypes"; import type { OnlineGroup } from "./onlineGroupTypes";
import { dungeonById } from "../game/dungeonCatalog"; import { dungeonById, dungeonsByCategory, randomRegularDungeon } from "../game/dungeonCatalog";
import { DEFAULT_DUNGEON_QUEUE_POLICY } from "../game/dungeonQueuePolicy";
import { dungeonContentPackId } from "../game/dungeonContentSource"; import { dungeonContentPackId } from "../game/dungeonContentSource";
import { queueRandomDungeon, type RandomDungeonQueueOptions } from "./randomDungeonQueue"; import { queueRandomDungeon, type RandomDungeonQueueOptions } from "./randomDungeonQueue";
@@ -37,6 +38,7 @@ describe("random dungeon queue", () => {
const options = queueOptions(); const options = queueOptions();
const result = await queueRandomDungeon(options, { const result = await queueRandomDungeon(options, {
random: () => 0, random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => installed, contentIsUsable: () => installed,
installPack, installPack,
installDungeonRuntime, installDungeonRuntime,
@@ -58,6 +60,7 @@ describe("random dungeon queue", () => {
const options = queueOptions({ mode: "bite-sized" }); const options = queueOptions({ mode: "bite-sized" });
await expect(queueRandomDungeon(options, { await expect(queueRandomDungeon(options, {
random: () => 0, random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => true, contentIsUsable: () => true,
installPack, installPack,
installDungeonRuntime: vi.fn(async () => undefined), installDungeonRuntime: vi.fn(async () => undefined),
@@ -66,10 +69,35 @@ describe("random dungeon queue", () => {
expect(installPack).not.toHaveBeenCalled(); expect(installPack).not.toHaveBeenCalled();
}); });
it("skips a disabled full dungeon and rejects a queue with no enabled Bite-Sized locations", async () => {
const first = randomRegularDungeon(undefined, () => 0)!;
const options = queueOptions();
await expect(queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => ({ schemaVersion: 1, disabled: { regular: [first.id], biteSized: [], randomizedBiteSized: [] } }),
contentIsUsable: () => true,
installPack: vi.fn(),
installDungeonRuntime: vi.fn(),
})).resolves.toMatchObject({ status: "entered" });
expect(options.enterDungeon).not.toHaveBeenCalledWith(first.id, expect.anything(), expect.anything());
const biteOptions = queueOptions({ mode: "bite-sized" });
await expect(queueRandomDungeon(biteOptions, {
random: () => 0,
loadPolicy: async () => ({ schemaVersion: 1, disabled: { regular: [],
biteSized: dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id), randomizedBiteSized: [] } }),
contentIsUsable: () => true,
installPack: vi.fn(),
installDungeonRuntime: vi.fn(),
})).resolves.toMatchObject({ status: "error", message: expect.stringMatching(/no dungeon/i) });
expect(biteOptions.enterDungeon).not.toHaveBeenCalled();
});
it("does not enter when the downloaded pack cannot be verified", async () => { it("does not enter when the downloaded pack cannot be verified", async () => {
const options = queueOptions(); const options = queueOptions();
const result = await queueRandomDungeon(options, { const result = await queueRandomDungeon(options, {
random: () => 0, random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => false, contentIsUsable: () => false,
installPack: vi.fn(async () => undefined), installPack: vi.fn(async () => undefined),
installDungeonRuntime: vi.fn(async () => undefined), installDungeonRuntime: vi.fn(async () => undefined),
@@ -83,6 +111,7 @@ describe("random dungeon queue", () => {
const options = queueOptions({ session: accountSession, group }); const options = queueOptions({ session: accountSession, group });
const result = await queueRandomDungeon(options, { const result = await queueRandomDungeon(options, {
random: () => 0, random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => installed, contentIsUsable: () => installed,
installPack: vi.fn(async () => { installed = true; }), installPack: vi.fn(async () => { installed = true; }),
installDungeonRuntime: vi.fn(async () => undefined), installDungeonRuntime: vi.fn(async () => undefined),
@@ -100,6 +129,7 @@ describe("random dungeon queue", () => {
const installPack = vi.fn(async () => undefined); const installPack = vi.fn(async () => undefined);
await expect(queueRandomDungeon(options, { await expect(queueRandomDungeon(options, {
random: () => 0, random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => false, contentIsUsable: () => false,
installPack, installPack,
installDungeonRuntime: vi.fn(async () => undefined), installDungeonRuntime: vi.fn(async () => undefined),
+7 -2
View File
@@ -3,6 +3,8 @@ import type { OnlineGroup, StartOnlineGroupActivityInput } from "./onlineGroupTy
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager"; import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { dungeonContentPackId } from "../game/dungeonContentSource"; import { dungeonContentPackId } from "../game/dungeonContentSource";
import { randomDungeonByCategory, randomRegularDungeon, type DungeonCatalogEntry } from "../game/dungeonCatalog"; import { randomDungeonByCategory, randomRegularDungeon, type DungeonCatalogEntry } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue, type DungeonQueuePolicy } from "../game/dungeonQueuePolicy";
import { loadDungeonQueuePolicy } from "./dungeonQueuePolicyStore";
import { dungeonDefinitionById } from "../game/dungeonRegistry"; import { dungeonDefinitionById } from "../game/dungeonRegistry";
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles"; import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
import { specializationById } from "../game/specializationCatalog"; import { specializationById } from "../game/specializationCatalog";
@@ -28,6 +30,7 @@ export interface RandomDungeonQueueOptions {
interface RandomDungeonQueueDependencies { interface RandomDungeonQueueDependencies {
readonly random: () => number; readonly random: () => number;
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
readonly contentIsUsable: (dungeonId: string) => boolean; readonly contentIsUsable: (dungeonId: string) => boolean;
readonly installPack: (packId: string) => Promise<void>; readonly installPack: (packId: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>; readonly installDungeonRuntime: () => Promise<void>;
@@ -35,6 +38,7 @@ interface RandomDungeonQueueDependencies {
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = { const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
random: Math.random, random: Math.random,
loadPolicy: loadDungeonQueuePolicy,
contentIsUsable: dungeonContentIsUsable, contentIsUsable: dungeonContentIsUsable,
installPack: installContentPack, installPack: installContentPack,
installDungeonRuntime: async () => { installDungeonRuntime: async () => {
@@ -72,9 +76,10 @@ export async function queueRandomDungeon(
} }
options.onProgress("rolling", null); options.onProgress("rolling", null);
const policy = await dependencies.loadPolicy();
const dungeon = options.mode === "regular" const dungeon = options.mode === "regular"
? randomRegularDungeon(undefined, dependencies.random) ? randomRegularDungeon(undefined, dependencies.random, (entry) => dungeonEnabledForQueue(policy, entry))
: randomDungeonByCategory("bite-sized", undefined, dependencies.random); : randomDungeonByCategory("bite-sized", undefined, dependencies.random, (entry) => dungeonEnabledForQueue(policy, entry));
if (!dungeon) return { status: "error", message: "No dungeon is currently ready for this queue." }; if (!dungeon) return { status: "error", message: "No dungeon is currently ready for this queue." };
if (!dependencies.contentIsUsable(dungeon.id)) { if (!dependencies.contentIsUsable(dungeon.id)) {
options.onProgress("downloading", dungeon); options.onProgress("downloading", dungeon);
+1 -1
View File
@@ -185,7 +185,7 @@ export function CombatBridge() {
return; return;
} }
const game = useGameStore.getState(); const game = useGameStore.getState();
if (game.dungeonIntroActive) return; if (game.dungeonIntroActive || game.raceStartBlocked) return;
const sharedOnline = onlineSessionIsActive(); const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) { if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
advanceOneTick(delta, Date.now(), false); advanceOneTick(delta, Date.now(), false);
+32
View File
@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { useDungeonRunStore } from "./dungeonRunStore";
import { useGameStore } from "./store";
/** Keeps match readiness and settlement separate from the local boss and loot lifecycle. */
export function DungeonRaceBridge() {
useEffect(() => {
let reportingRun: string | null = null;
const interval = window.setInterval(() => {
const race = useDungeonRaceStore.getState();
if (!race.inGame || !race.seed || !race.dungeonId) return;
const game = useGameStore.getState();
if (game.activeDungeonId !== race.dungeonId) return;
race.tick();
const ready = !game.dungeonIntroActive && ["ready", "proxy"].includes(game.assetStatus);
if (ready && !race.ready && race.status === "preparing") void race.readyUp();
const started = race.startAt !== null && Date.now() + race.serverOffset >= race.startAt;
if (game.raceStartBlocked && started && ready) useGameStore.setState({ raceStartBlocked: false });
if (game.raceStartBlocked && ["void", "idle"].includes(race.status))
useGameStore.setState({ raceStartBlocked: false });
const progress = useDungeonRunStore.getState().progress;
if (started && !["void", "idle"].includes(race.status) && progress?.id === race.seed && progress.finishedAt !== null
&& race.clearDurationMs === null && reportingRun !== race.seed) {
reportingRun = race.seed;
void race.reportClear(progress.killedBossIds).finally(() => { reportingRun = null; });
}
}, 100);
return () => window.clearInterval(interval);
}, []);
return null;
}
+5 -1
View File
@@ -8,6 +8,7 @@ import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { useDungeonRunStore } from "./dungeonRunStore"; import { useDungeonRunStore } from "./dungeonRunStore";
import type { DungeonRunMember } from "./dungeonRunTypes"; import type { DungeonRunMember } from "./dungeonRunTypes";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
function currentRunParty(): readonly DungeonRunMember[] { function currentRunParty(): readonly DungeonRunMember[] {
const shell = useShellStore.getState(); const shell = useShellStore.getState();
@@ -36,6 +37,7 @@ export function DungeonRunBridge() {
const game = useGameStore.getState(); const game = useGameStore.getState();
const shell = useShellStore.getState(); const shell = useShellStore.getState();
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode || game.dungeonIntroActive if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode || game.dungeonIntroActive
|| game.raceStartBlocked
|| !["ready", "proxy"].includes(game.assetStatus)) return; || !["ready", "proxy"].includes(game.assetStatus)) return;
const run = useDungeonRunStore.getState(); const run = useDungeonRunStore.getState();
if (run.progress?.dungeonId !== game.activeDungeonId) return; if (run.progress?.dungeonId !== game.activeDungeonId) return;
@@ -46,7 +48,9 @@ export function DungeonRunBridge() {
useDungeonRunStore.setState({ progress: { ...run.progress, id: activity.id, useDungeonRunStore.setState({ progress: { ...run.progress, id: activity.id,
startedAt: game.dungeonIntroCompletedAt ?? activity.startedAt, party } }); startedAt: game.dungeonIntroCompletedAt ?? activity.startedAt, party } });
} }
run.start(game.dungeonIntroCompletedAt ?? Date.now(), party); const race = useDungeonRaceStore.getState();
run.start(race.inGame && race.startAt ? race.startAt - race.serverOffset
: game.dungeonIntroCompletedAt ?? Date.now(), party);
run.advance(activeDungeonCompletedObjectiveIds(game.activeDungeonId), run.advance(activeDungeonCompletedObjectiveIds(game.activeDungeonId),
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id), Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
Date.now(), party); Date.now(), party);
+10
View File
@@ -9,11 +9,13 @@ import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupS
import { useDungeonSimulationStore } from "./dungeonSimulationStore"; import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useCompanionSpecReadinessStore } from "./companionSpecReadinessStore"; import { useCompanionSpecReadinessStore } from "./companionSpecReadinessStore";
import { useCompanionPartyPreferences } from "./companionPartyPreferences"; import { useCompanionPartyPreferences } from "./companionPartyPreferences";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource"; import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource";
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */ /** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
export function PartyBridge() { export function PartyBridge() {
const character = useShellStore((state) => state.activeCharacter); const character = useShellStore((state) => state.activeCharacter);
const session = useShellStore((state) => state.session);
const playerRole = useShellStore((state) => state.activeDungeonRole); const playerRole = useShellStore((state) => state.activeDungeonRole);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const manastormPartySize = useManastormStore((state) => state.partySize); const manastormPartySize = useManastormStore((state) => state.partySize);
@@ -26,8 +28,11 @@ export function PartyBridge() {
const readinessPolicy = useCompanionSpecReadinessStore((state) => state.config); const readinessPolicy = useCompanionSpecReadinessStore((state) => state.config);
const readinessPolicyRevision = useCompanionSpecReadinessStore((state) => state.revision); const readinessPolicyRevision = useCompanionSpecReadinessStore((state) => state.revision);
const deviceIncludeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs); const deviceIncludeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
const partyBuffPolicy = usePartyBuffPolicyStore((state) => state.config);
const mountedResetRevision = useRef(resetRevision); const mountedResetRevision = useRef(resetRevision);
useEffect(() => { void usePartyBuffPolicyStore.getState().refresh(session); }, [session]);
useEffect(() => { useEffect(() => {
if (!character) return; if (!character) return;
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
@@ -97,6 +102,11 @@ export function PartyBridge() {
resetPartyRuntimeClock(); resetPartyRuntimeClock();
}, [resetRevision]); }, [resetRevision]);
useEffect(() => {
if (!character) return;
useCombatStore.getState().applyDefaultPartyBuffs();
}, [partyBuffPolicy]);
useEffect(() => useCombatStore.subscribe((state, previous) => { useEffect(() => useCombatStore.subscribe((state, previous) => {
if (state.selectedTargetId && state.selectedTargetId !== previous.selectedTargetId) { if (state.selectedTargetId && state.selectedTargetId !== previous.selectedTargetId) {
usePartyStore.getState().clearSelection(); usePartyStore.getState().clearSelection();
+40 -21
View File
@@ -8,6 +8,8 @@ import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime"; import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents"; import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { DEFAULT_PARTY_BUFF_POLICY } from "./partyBuffPolicy";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
function registerTarget(id = "target", distance = 2): void { function registerTarget(id = "target", distance = 2): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 }); useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
@@ -17,6 +19,7 @@ function registerTarget(id = "target", distance = 2): void {
describe("named aura combat runtime", () => { describe("named aura combat runtime", () => {
beforeEach(() => { beforeEach(() => {
usePartyBuffPolicyStore.setState({ config: DEFAULT_PARTY_BUFF_POLICY, draft: DEFAULT_PARTY_BUFF_POLICY });
clearMobRuntimeRegistry(); clearMobRuntimeRegistry();
clearPartyRuntimeRegistry(); clearPartyRuntimeRegistry();
usePartyStore.getState().initializeParty("aura-runtime", 80, 2_000); usePartyStore.getState().initializeParty("aura-runtime", 80, 2_000);
@@ -61,7 +64,7 @@ describe("named aura combat runtime", () => {
expect(useCombatStore.getState()).toMatchObject({ health: baseHealth, maxHealth: baseMaxHealth }); expect(useCombatStore.getState()).toMatchObject({ health: baseHealth, maxHealth: baseMaxHealth });
}); });
it("grants learned long-term stat buffs to the full party on load", () => { it("maintains only GM-selected buffs on the chosen party roles", () => {
useCombatStore.getState().initializeCharacter({ useCombatStore.getState().initializeCharacter({
classId: "priest", classId: "priest",
specializationId: "priest:holy", specializationId: "priest:holy",
@@ -75,31 +78,47 @@ describe("named aura combat runtime", () => {
const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth])); const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth]));
expect(playerMaxHealth).toBe(407); expect(playerMaxHealth).toBe(407);
useCombatStore.getState().applyDefaultPartyBuffs(20_000); useCombatStore.getState().applyDefaultPartyBuffs(20_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.exclusiveGroup?.startsWith("gm-party-buff:"))).toBe(false);
usePartyBuffPolicyStore.setState({ config: { schemaVersion: 1, rules: [
{ abilityId: "wow335-priest-power-word-fortitude", scope: "tank" },
] } });
useCombatStore.getState().applyDefaultPartyBuffs(20_000);
const state = useCombatStore.getState(); const state = useCombatStore.getState();
const targetIds = [PLAYER_AGGRO_ID, ...usePartyStore.getState().members.map((member) => member.id)]; const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
for (const targetId of targetIds) { expect(tank).toBeDefined();
expect(state.auras).toContainEqual(expect.objectContaining({ expect(state.auras.filter((aura) => aura.definition.exclusiveGroup === "gm-party-buff:fortitude")
targetId, .map((aura) => aura.targetId)).toEqual([tank!.id]);
definition: expect.objectContaining({ exclusiveGroup: "default-party-buff:fortitude" }), expect(state.maxHealth).toBe(playerMaxHealth);
}));
}
expect(state.maxHealth).toBe(437);
for (const member of usePartyStore.getState().members) { for (const member of usePartyStore.getState().members) {
expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!); expect(member.maxHealth > memberMaxHealth.get(member.id)!).toBe(member.role === "tank");
} }
const buffedPlayerMaxHealth = state.maxHealth; usePartyBuffPolicyStore.setState({ config: { schemaVersion: 1, rules: [
expect(state.auras.filter((aura) => ( { abilityId: "wow335-priest-power-word-fortitude", scope: "party" },
aura.targetId === PLAYER_AGGRO_ID ] } });
&& aura.definition.exclusiveGroup === "default-party-buff:fortitude" useCombatStore.getState().applyDefaultPartyBuffs(21_000);
))).toHaveLength(1); const targetIds = [PLAYER_AGGRO_ID, ...usePartyStore.getState().members.map((member) => member.id)];
expect(state.castAbility("wow335-priest-power-word-fortitude", undefined, 21_000).ok).toBe(true); expect(useCombatStore.getState().auras.filter((aura) => aura.definition.exclusiveGroup === "gm-party-buff:fortitude")
expect(useCombatStore.getState().maxHealth).toBe(buffedPlayerMaxHealth); .map((aura) => aura.targetId).sort()).toEqual(targetIds.sort());
expect(useCombatStore.getState().auras.filter((aura) => ( expect(useCombatStore.getState().maxHealth).toBe(437);
aura.targetId === PLAYER_AGGRO_ID expect(state.castAbility("wow335-priest-power-word-fortitude", undefined, 21_500).ok).toBe(true);
&& aura.definition.exclusiveGroup === "default-party-buff:fortitude" expect(useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID
))).toHaveLength(1); && aura.definition.exclusiveGroup === "gm-party-buff:fortitude")).toMatchObject([{ expiresAt: null }]);
usePartyBuffPolicyStore.setState({ config: { schemaVersion: 1, rules: [
{ abilityId: "wow335-priest-power-word-fortitude", scope: "healer" },
] } });
useCombatStore.getState().applyDefaultPartyBuffs(21_750);
const healerTargets = useCombatStore.getState().auras.filter((aura) => aura.definition.exclusiveGroup === "gm-party-buff:fortitude")
.map((aura) => aura.targetId);
expect(healerTargets).toEqual([
...(useCombatStore.getState().gearRole === "healer" ? [PLAYER_AGGRO_ID] : []),
...usePartyStore.getState().members.filter((member) => member.role === "healer").map((member) => member.id),
]);
usePartyBuffPolicyStore.setState({ config: DEFAULT_PARTY_BUFF_POLICY });
useCombatStore.getState().applyDefaultPartyBuffs(22_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.exclusiveGroup?.startsWith("gm-party-buff:"))).toBe(false);
expect(useCombatStore.getState().maxHealth).toBe(playerMaxHealth);
}); });
it("enters the dungeon with spec maintenance and permanent pets already prepared", () => { it("enters the dungeon with spec maintenance and permanent pets already prepared", () => {
+55 -15
View File
@@ -1,4 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { permanentBuffEffects } from "./partyBuffPolicy";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
import { combatNow } from "./combatClock"; import { combatNow } from "./combatClock";
import { useDungeonSimulationStore } from "./dungeonSimulationStore"; import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
@@ -2166,7 +2168,7 @@ function auraDefinitionForAbility(
description: definition.description ?? ability.description, description: definition.description ?? ability.description,
icon: definition.icon ?? ability.icon, icon: definition.icon ?? ability.icon,
durationMs, durationMs,
...(defaultPartyBuffFamily ? { ...(defaultPartyBuffFamily && !definition.exclusiveGroup ? {
uniqueBySource: false, uniqueBySource: false,
exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`, exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`,
} : {}), } : {}),
@@ -4699,19 +4701,50 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}, },
applyDefaultPartyBuffs: (now = combatNow()) => { applyDefaultPartyBuffs: (now = combatNow()) => {
get().synchronizeActors(); get().synchronizeActors();
const state = get();
const partyMembers = usePartyStore.getState().members; const partyMembers = usePartyStore.getState().members;
set((state) => {
const auras = state.auras.filter((aura) => !aura.definition.exclusiveGroup?.startsWith("gm-party-buff:"));
if (auras.length === state.auras.length) return state;
const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers,
state.gearStats, state.raceId, state.secondaryClassId, auras);
const health = state.health <= 0 ? 0 : clamp(state.health + maxHealth - state.maxHealth, 1, maxHealth);
const actors = { ...state.actors };
if (actors[PLAYER_AURA_ENTITY_ID]) actors[PLAYER_AURA_ENTITY_ID] = {
...actors[PLAYER_AURA_ENTITY_ID], health, maxHealth,
};
for (const member of partyMembers) {
const actor = actors[member.id];
if (!actor) continue;
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
const delta = auraMaximumHealthBonus(base, auras, member.id)
- auraMaximumHealthBonus(base, state.auras, member.id);
const memberMaxHealth = Math.max(1, actor.maxHealth + delta);
actors[member.id] = {
...actor,
maxHealth: memberMaxHealth,
health: actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, memberMaxHealth),
};
}
return { auras, health, maxHealth, actors };
});
get().synchronizeActors();
const state = get();
const rules = usePartyBuffPolicyStore.getState().config.rules;
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play"; const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
const sources = [ const sources = [
...(includePlayer ? [{ ...(includePlayer ? [{
id: PLAYER_AURA_ENTITY_ID, id: PLAYER_AURA_ENTITY_ID,
classId: state.classId,
role: state.gearRole,
level: state.level, level: state.level,
abilities: state.abilities, abilities: state.abilities,
}] : []), }] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => ({ ...partyMembers.filter((member) => member.health > 0).map((member) => ({
id: member.id, id: member.id,
classId: member.classId,
role: member.role,
level: member.level, level: member.level,
abilities: abilitiesForClass(member.classId), abilities: abilitiesForCharacter(member.classId, member.secondaryClassId),
})), })),
]; ];
const selected = new Map<string, { const selected = new Map<string, {
@@ -4719,34 +4752,33 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ability: AbilityDefinition; ability: AbilityDefinition;
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[]; effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[];
potency: number; potency: number;
scope: "party" | "tank" | "healer";
}>(); }>();
for (const rule of rules) {
for (const source of sources) { for (const source of sources) {
for (const catalogAbility of source.abilities) { const catalogAbility = source.abilities.find((ability) => ability.id === rule.abilityId);
const family = DEFAULT_PARTY_BUFF_FAMILY.get(catalogAbility.name.toLowerCase()); if (!catalogAbility || !isAbilityUnlocked(catalogAbility, source.level)) continue;
if (!family || catalogAbility.passive || !isAbilityUnlocked(catalogAbility, source.level)) continue;
const ability = abilityAtLevel(catalogAbility, source.level); const ability = abilityAtLevel(catalogAbility, source.level);
const effects = ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => ( const effects = permanentBuffEffects(ability);
effect.kind === "apply-aura" && effect.aura.disposition === "buff"
));
if (!effects.length) continue; if (!effects.length) continue;
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
const potency = defaultPartyBuffPotency(effects); const potency = defaultPartyBuffPotency(effects);
if ((selected.get(family)?.potency ?? Number.NEGATIVE_INFINITY) >= potency) continue; if ((selected.get(family)?.potency ?? Number.NEGATIVE_INFINITY) >= potency) continue;
selected.set(family, { sourceId: source.id, ability, effects, potency }); selected.set(family, { sourceId: source.id, ability, effects, potency, scope: rule.scope });
} }
} }
const recipients = [
...(includePlayer ? [PLAYER_AURA_ENTITY_ID] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => member.id),
];
for (const [family, buff] of selected) { for (const [family, buff] of selected) {
const recipients = sources.filter((source) => buff.scope === "party" || source.role === buff.scope)
.map((source) => source.id);
for (const recipientId of recipients) { for (const recipientId of recipients) {
for (const effect of buff.effects) { for (const effect of buff.effects) {
get().applyActorAura(buff.sourceId, recipientId, buff.ability, { get().applyActorAura(buff.sourceId, recipientId, buff.ability, {
...effect, ...effect,
aura: { aura: {
...effect.aura, ...effect.aura,
durationMs: null,
uniqueBySource: false, uniqueBySource: false,
exclusiveGroup: `default-party-buff:${family}`, exclusiveGroup: `gm-party-buff:${family}`,
}, },
}, now); }, now);
} }
@@ -6040,6 +6072,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: [targetId ?? PLAYER_AURA_ENTITY_ID]; : [targetId ?? PLAYER_AURA_ENTITY_ID];
for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AURA_ENTITY_ID)) { for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AURA_ENTITY_ID)) {
const previousAuras = work.auras; const previousAuras = work.auras;
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
if (previousAuras.some((aura) => aura.targetId === recipientId
&& aura.definition.exclusiveGroup === `gm-party-buff:${family}`)) continue;
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue; if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
const auraResult = applyCombatAura(previousAuras, { const auraResult = applyCombatAura(previousAuras, {
definition: auraDefinition, definition: auraDefinition,
@@ -7396,6 +7431,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const state = get(); const state = get();
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false; if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
if (ability.name === "Power Word: Shield" && hasWeakenedSoul(state.auras, targetActorId, now)) return false; if (ability.name === "Power Word: Shield" && hasWeakenedSoul(state.auras, targetActorId, now)) return false;
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
const gmGroup = `gm-party-buff:${family}`;
if (effect.aura.exclusiveGroup !== gmGroup && state.auras.some((aura) => (
aura.targetId === targetActorId && aura.definition.exclusiveGroup === gmGroup
))) return true;
const durationMs = effect.aura.durationMs; const durationMs = effect.aura.durationMs;
const definition = auraDefinitionForAbility(effect.aura, ability, durationMs); const definition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const applied = applyCombatAura(state.auras, { const applied = applyCombatAura(state.auras, {
+32
View File
@@ -0,0 +1,32 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
import { dungeonArtworkContains, dungeonArtworkFloorFor, dungeonArtworkHeading, dungeonArtworkMapFor, dungeonArtworkPoint } from "./dungeonArtworkMap";
describe("RuneWaker dungeon maps", () => {
it("packages a checksummed, position-aware map with every RuneWaker dungeon", () => {
const dungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion === "runewaker");
expect(dungeons).toHaveLength(30);
for (const dungeon of dungeons) {
const map = dungeonArtworkMapFor(dungeon);
expect(map, dungeon.id).not.toBeNull();
expect(map?.kind, dungeon.id).toBe("runewaker");
if (!map) continue;
const floor = dungeonArtworkFloorFor(map, dungeon.entrance.footPosition);
const image = readFileSync(`public${floor.image}`);
expect(image.toString("ascii", 1, 4), dungeon.id).toBe("PNG");
expect([image.readUInt32BE(16), image.readUInt32BE(20)]).toEqual([1002, 668]);
expect(createHash("sha256").update(image).digest("hex")).toBe(floor.checksum);
expect(dungeonArtworkContains(map, floor, dungeon.entrance.footPosition), dungeon.id).toBe(true);
expect(dungeonArtworkPoint(map, floor, dungeon.entrance.footPosition).every(Number.isFinite)).toBe(true);
expect(Math.hypot(...dungeonArtworkHeading(map, floor, dungeon.entrance.footPosition, 0.7))).toBeCloseTo(23, 5);
}
});
it("rejects stale maps when a dungeon environment changes", () => {
const dungeon = requireDungeonDefinition("forsaken-abbey");
expect(dungeonArtworkMapFor({ ...dungeon, provenance: { ...dungeon.provenance, coordinateTransform: "changed" } })).toBeNull();
expect(dungeonArtworkMapFor({ ...dungeon, assets: { ...dungeon.assets, visual: [] } })).toBeNull();
});
});
+78
View File
@@ -0,0 +1,78 @@
import runewakerCatalog from "./generated/runewakerDungeonMaps.generated.json";
import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes";
import {
wowDungeonMapFor,
wowMapFloorFor,
wowMapPoint,
type WowDungeonMap,
type WowMapFloor,
} from "./wowDungeonMap";
export interface RunewakerDungeonMap {
readonly mapId: number;
readonly coordinateTransform: string;
readonly visualChecksum: string;
readonly kind: "client" | "navigation";
readonly image: string;
readonly checksum: string;
readonly sourceHash: string;
/** Two affine rows converting runtime XYZ directly to image pixels. */
readonly runtimeToMap: readonly number[];
readonly width: number;
readonly height: number;
}
export interface ArtworkFloor {
readonly level: number;
readonly name: string;
readonly image: string;
readonly checksum: string;
}
export type DungeonArtworkMap =
| { readonly kind: "wow"; readonly source: WowDungeonMap; readonly floors: readonly WowMapFloor[]; readonly width: 1002; readonly height: 668 }
| { readonly kind: "runewaker"; readonly source: RunewakerDungeonMap; readonly floors: readonly ArtworkFloor[]; readonly width: number; readonly height: number };
const runewakerMaps = runewakerCatalog.dungeons as unknown as Readonly<Record<string, RunewakerDungeonMap>>;
export function dungeonArtworkMapFor(dungeon: DungeonDefinition): DungeonArtworkMap | null {
const wow = wowDungeonMapFor(dungeon);
if (wow) return { kind: "wow", source: wow, floors: wow.floors, width: 1002, height: 668 };
const source = runewakerMaps[dungeon.id];
if (source?.mapId !== dungeon.mapId
|| source.coordinateTransform !== dungeon.provenance.coordinateTransform
|| source.visualChecksum !== dungeon.assets.visual[0]?.checksum
|| source.runtimeToMap.length !== 8) return null;
return {
kind: "runewaker",
source,
floors: [{ level: 0, name: source.kind === "client" ? "Original dungeon map" : "Dungeon floorplan",
image: source.image, checksum: source.checksum }],
width: source.width,
height: source.height,
};
}
export function dungeonArtworkFloorFor(map: DungeonArtworkMap, position: Vector3Tuple, level?: number | null): ArtworkFloor {
return map.kind === "wow" ? wowMapFloorFor(map.source, position, level) : map.floors[0];
}
export function dungeonArtworkPoint(map: DungeonArtworkMap, floor: ArtworkFloor, position: Vector3Tuple): readonly [number, number] {
if (map.kind === "wow") return wowMapPoint(map.source, floor as WowMapFloor, position);
const matrix = map.source.runtimeToMap;
return [matrix[0] * position[0] + matrix[1] * position[1] + matrix[2] * position[2] + matrix[3],
matrix[4] * position[0] + matrix[5] * position[1] + matrix[6] * position[2] + matrix[7]];
}
export function dungeonArtworkContains(map: DungeonArtworkMap, floor: ArtworkFloor, position: Vector3Tuple): boolean {
const [x, y] = dungeonArtworkPoint(map, floor, position);
return Number.isFinite(x) && Number.isFinite(y) && x >= 0 && x <= map.width && y >= 0 && y <= map.height;
}
export function dungeonArtworkHeading(map: DungeonArtworkMap, floor: ArtworkFloor, position: Vector3Tuple, yaw: number): readonly [number, number] {
const [x, y] = dungeonArtworkPoint(map, floor, position);
const [targetX, targetY] = dungeonArtworkPoint(map, floor,
[position[0] + Math.sin(yaw), position[1], position[2] + Math.cos(yaw)]);
const length = Math.hypot(targetX - x, targetY - y) || 1;
return [(targetX - x) / length * 23, (targetY - y) / length * 23];
}
+22 -8
View File
@@ -13,10 +13,11 @@ import { DUNGEON_MANIFEST } from "./dungeonManifest";
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated"; import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
import { compareDungeonReleaseOrder } from "./dungeonReleaseStage"; import { compareDungeonReleaseOrder } from "./dungeonReleaseStage";
import availability from "./generated/dungeonAvailability.json"; import availability from "./generated/dungeonAvailability.json";
import queuePolicyCatalog from "./generated/dungeonLeaderboardCatalog.generated.json";
describe("dungeon catalog", () => { describe("dungeon catalog", () => {
it("lists the complete campaign plus both bite-sized Stockades variants", () => { it("lists the complete campaign and bite-sized variants", () => {
expect(DUNGEON_CATALOG).toHaveLength(86); expect(DUNGEON_CATALOG).toHaveLength(89);
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({ expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({
id: "stormwind-stockade", id: "stormwind-stockade",
releaseStage: "beta", releaseStage: "beta",
@@ -51,13 +52,14 @@ describe("dungeon catalog", () => {
expect.objectContaining({ id: "forsaken-abbey" }), expect.objectContaining({ id: "forsaken-abbey" }),
); );
expect(dungeonsByCategory("rom")).toHaveLength(30); expect(dungeonsByCategory("rom")).toHaveLength(30);
expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id)) expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id).sort())
.toEqual(["bite-sized-stormwind-stockade"]); .toEqual(["bite-sized-deadmines-ship", "bite-sized-stormwind-stockade",
"bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns"]);
}); });
it("selects another available category dungeon and falls back to the sole bite-sized entry", () => { it("selects another available category dungeon", () => {
expect(randomDungeonByCategory("bite-sized", "bite-sized-stormwind-stockade", () => 0)?.id) expect(randomDungeonByCategory("bite-sized", "bite-sized-stormwind-stockade", () => 0)?.id)
.toBe("bite-sized-stormwind-stockade"); .not.toBe("bite-sized-stormwind-stockade");
expect(randomDungeonByCategory("wow", "stormwind-stockade", () => 0)?.id) expect(randomDungeonByCategory("wow", "stormwind-stockade", () => 0)?.id)
.not.toBe("stormwind-stockade"); .not.toBe("stormwind-stockade");
}); });
@@ -87,6 +89,11 @@ describe("dungeon catalog", () => {
expect(dungeonById("missing-dungeon")).toBeNull(); expect(dungeonById("missing-dungeon")).toBeNull();
}); });
it("keeps every queue candidate in the server policy validation catalog", () => {
const policyIds = new Set(queuePolicyCatalog.dungeons.map((dungeon) => dungeon.id));
expect(DUNGEON_CATALOG.every((dungeon) => policyIds.has(dungeon.id))).toBe(true);
});
it("provides centralized display copy for every entry", () => { it("provides centralized display copy for every entry", () => {
for (const dungeon of DUNGEON_CATALOG) { for (const dungeon of DUNGEON_CATALOG) {
expect(dungeon.location.trim()).not.toBe(""); expect(dungeon.location.trim()).not.toBe("");
@@ -101,8 +108,12 @@ describe("dungeon catalog", () => {
"randomized-bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade",
"stormwind-stockade", "stormwind-stockade",
"bite-sized-stormwind-stockade", "bite-sized-stormwind-stockade",
"bite-sized-deadmines-ship",
"bite-sized-utgarde-pinnacle-skadi",
"bite-sized-wailing-caverns",
]); ]);
expect(DUNGEON_CATALOG.filter((dungeon) => !dungeon.id.includes("stormwind-stockade")) expect(DUNGEON_CATALOG.filter((dungeon) => !dungeon.id.includes("stormwind-stockade")
&& !dungeon.id.startsWith("bite-sized-"))
.every((dungeon) => dungeon.releaseStage === "alpha")) .every((dungeon) => dungeon.releaseStage === "alpha"))
.toBe(true); .toBe(true);
for (const category of DUNGEON_CATEGORIES) { for (const category of DUNGEON_CATEGORIES) {
@@ -123,6 +134,9 @@ describe("dungeon catalog", () => {
it("shares the complete dungeon identity list used to validate reports on the server", () => { it("shares the complete dungeon identity list used to validate reports on the server", () => {
expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort()) expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort())
.toEqual(["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade", "forsaken-abbey", ...Object.keys(availability.enabled)].sort()); .toEqual(["bite-sized-deadmines-ship", "bite-sized-stormwind-stockade",
"bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns",
"randomized-bite-sized-stormwind-stockade", "forsaken-abbey",
...Object.keys(availability.enabled)].sort());
}); });
}); });
+2 -4
View File
@@ -2,9 +2,8 @@ import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonR
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories"; import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage"; import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage";
import { import {
BITE_SIZED_STOCKADES_DUNGEON_ID, isBiteSizedDungeonId,
isRandomizedBiteSizedDungeonId, isRandomizedBiteSizedDungeonId,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
} from "./dungeonContentSource"; } from "./dungeonContentSource";
const SOURCE_DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[]; const SOURCE_DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
@@ -43,8 +42,7 @@ export interface DungeonCatalogEntry {
export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze( export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({ DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
id: definition.id as DungeonId, id: definition.id as DungeonId,
category: definition.id === BITE_SIZED_STOCKADES_DUNGEON_ID category: isBiteSizedDungeonId(definition.id)
|| definition.id === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
? "bite-sized" ? "bite-sized"
: definition.expansion === "runewaker" ? "rom" : "wow", : definition.expansion === "runewaker" ? "rom" : "wow",
title: definition.title, title: definition.title,
+18
View File
@@ -1,8 +1,23 @@
export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade"; export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade";
export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade"; export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade";
export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade"; export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade";
export const BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID = "bite-sized-wailing-caverns";
export const BITE_SIZED_DEADMINES_DUNGEON_ID = "bite-sized-deadmines-ship";
export const BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID = "bite-sized-utgarde-pinnacle-skadi";
export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized"; export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized";
const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([
BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
]);
export function isBiteSizedDungeonId(dungeonId: string): boolean {
return BITE_SIZED_DUNGEON_IDS.has(dungeonId);
}
/** Randomized Bite-Sized locations share this id namespace as the mode expands. */ /** Randomized Bite-Sized locations share this id namespace as the mode expands. */
export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean { export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean {
return dungeonId.startsWith("randomized-bite-sized-"); return dungeonId.startsWith("randomized-bite-sized-");
@@ -11,6 +26,9 @@ export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean {
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({ const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
[BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID, [BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID, [RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID]: "wailing-caverns",
[BITE_SIZED_DEADMINES_DUNGEON_ID]: "deadmines",
[BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID]: "utgarde-pinnacle",
}); });
export function isDerivedStockadesDungeonId(dungeonId: string): boolean { export function isDerivedStockadesDungeonId(dungeonId: string): boolean {
+1 -1
View File
@@ -35,7 +35,7 @@ describe("dungeon leaderboard catalog", () => {
})).sort((a, b) => a.id.localeCompare(b.id)), })).sort((a, b) => a.id.localeCompare(b.id)),
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })), classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
}; };
expect(catalog.dungeons).toHaveLength(86); expect(catalog.dungeons).toHaveLength(89);
expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]); expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]);
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json"; const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`); if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`);
+40
View File
@@ -0,0 +1,40 @@
import type { DungeonCatalogEntry } from "./dungeonCatalog";
export type DungeonQueueMode = "regular" | "biteSized" | "randomizedBiteSized";
export interface DungeonQueuePolicy {
readonly schemaVersion: 1;
readonly disabled: Readonly<Record<DungeonQueueMode, readonly string[]>>;
}
export const DEFAULT_DUNGEON_QUEUE_POLICY: DungeonQueuePolicy = {
schemaVersion: 1,
disabled: { regular: [], biteSized: [], randomizedBiteSized: [] },
};
export function queueModeForDungeon(dungeon: DungeonCatalogEntry): DungeonQueueMode {
if (dungeon.category !== "bite-sized") return "regular";
return dungeon.id.startsWith("randomized-bite-sized-") ? "randomizedBiteSized" : "biteSized";
}
export function dungeonEnabledForQueue(policy: DungeonQueuePolicy, dungeon: DungeonCatalogEntry): boolean {
return !policy.disabled[queueModeForDungeon(dungeon)].includes(dungeon.id);
}
export function normalizeDungeonQueuePolicy(value: unknown): DungeonQueuePolicy {
if (!value || typeof value !== "object" || !("schemaVersion" in value) || value.schemaVersion !== 1
|| !("disabled" in value) || !value.disabled || typeof value.disabled !== "object") {
throw new Error("Invalid dungeon queue policy.");
}
const disabled = value.disabled as Record<string, unknown>;
for (const mode of ["regular", "biteSized", "randomizedBiteSized"] as const) {
if (!Array.isArray(disabled[mode]) || !disabled[mode].every((id: unknown) => typeof id === "string")) {
throw new Error("Invalid dungeon queue policy.");
}
}
return { schemaVersion: 1, disabled: {
regular: [...disabled.regular as string[]],
biteSized: [...disabled.biteSized as string[]],
randomizedBiteSized: [...disabled.randomizedBiteSized as string[]],
} };
}
+32
View File
@@ -0,0 +1,32 @@
{
"baselineMs": 240000,
"milestones": [
{ "at": 0.18, "progress": 20, "label": "First pack cleared" },
{ "at": 0.44, "progress": 48, "label": "Midpoint reached" },
{ "at": 0.72, "progress": 77, "label": "Final approach" },
{ "at": 0.9, "progress": 93, "label": "Boss engaged" },
{ "at": 1, "progress": 100, "label": "Boss defeated" }
],
"tiers": [
{ "id": "novice", "label": "Novice", "multiplier": 1.35 },
{ "id": "regular", "label": "Regular", "multiplier": 1.1 },
{ "id": "veteran", "label": "Veteran", "multiplier": 0.9 },
{ "id": "elite", "label": "Elite", "multiplier": 0.75 }
],
"teams": {
"wow": [
{ "name": "Darin", "classId": "warrior", "role": "tank" },
{ "name": "Mara", "classId": "priest", "role": "healer" },
{ "name": "Ilyra", "classId": "mage", "role": "damage" },
{ "name": "Rowan", "classId": "hunter", "role": "damage" },
{ "name": "Voss", "classId": "rogue", "role": "damage" }
],
"rom": [
{ "name": "Aldric", "classId": "rom-warrior", "role": "tank" },
{ "name": "Liora", "classId": "rom-priest", "role": "healer" },
{ "name": "Kael", "classId": "rom-scout", "role": "damage" },
{ "name": "Morgha", "classId": "rom-rogue", "role": "damage" },
{ "name": "Elyra", "classId": "rom-mage", "role": "damage" }
]
}
}
+17
View File
@@ -28,6 +28,11 @@ import {
dungeonContentSourceId, dungeonContentSourceId,
} from "./dungeonContentSource"; } from "./dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades"; import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
import {
createBiteSizedDeadminesDefinition,
createBiteSizedUtgardePinnacleDefinition,
createBiteSizedWailingCavernsDefinition,
} from "./dungeons/biteSizedWoWDungeons";
import { import {
createRandomizedBiteSizedStockadesDefinition, createRandomizedBiteSizedStockadesDefinition,
randomizedBiteSizedCreatureCatalog, randomizedBiteSizedCreatureCatalog,
@@ -203,6 +208,12 @@ const adaptedSourceEntries = sourceDungeonRegistryEntries.map(
const adaptedSourceRegistry = Object.fromEntries(adaptedSourceEntries) as Readonly<Record<string, DungeonDefinition>>; const adaptedSourceRegistry = Object.fromEntries(adaptedSourceEntries) as Readonly<Record<string, DungeonDefinition>>;
const stockadesDefinition = adaptedSourceRegistry[STORMWIND_STOCKADE_DUNGEON_ID]; const stockadesDefinition = adaptedSourceRegistry[STORMWIND_STOCKADE_DUNGEON_ID];
if (!stockadesDefinition) throw new Error("Stormwind Stockade is required for its bite-sized variant."); if (!stockadesDefinition) throw new Error("Stormwind Stockade is required for its bite-sized variant.");
const wailingCavernsDefinition = adaptedSourceRegistry["wailing-caverns"];
const deadminesDefinition = adaptedSourceRegistry["deadmines"];
const utgardePinnacleDefinition = adaptedSourceRegistry["utgarde-pinnacle"];
if (!wailingCavernsDefinition || !deadminesDefinition || !utgardePinnacleDefinition) {
throw new Error("Wailing Caverns, The Deadmines, and Utgarde Pinnacle are required for their bite-sized variants.");
}
export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze( export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(adaptedSourceEntries.map(([, definition]) => definition)), randomizedBiteSizedCreatureCatalog(adaptedSourceEntries.map(([, definition]) => definition)),
@@ -217,10 +228,16 @@ export function randomizedBiteSizedDefinitionForSeed(seed: string): DungeonDefin
} }
const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template"); const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template"); const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template");
const biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition);
const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition);
const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition);
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries, ...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate], [biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
[randomizedBiteSizedStockadesTemplate.id, randomizedBiteSizedStockadesTemplate], [randomizedBiteSizedStockadesTemplate.id, randomizedBiteSizedStockadesTemplate],
[biteSizedWailingCaverns.id, biteSizedWailingCaverns],
[biteSizedDeadmines.id, biteSizedDeadmines],
[biteSizedUtgardePinnacle.id, biteSizedUtgardePinnacle],
]; ];
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze( export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
+3 -2
View File
@@ -1,3 +1,5 @@
import { isBiteSizedDungeonId } from "./dungeonContentSource";
export const DUNGEON_RELEASE_STAGES = ["full-release", "beta", "alpha"] as const; export const DUNGEON_RELEASE_STAGES = ["full-release", "beta", "alpha"] as const;
export type DungeonReleaseStage = (typeof DUNGEON_RELEASE_STAGES)[number]; export type DungeonReleaseStage = (typeof DUNGEON_RELEASE_STAGES)[number];
@@ -9,8 +11,7 @@ export const DUNGEON_RELEASE_STAGE_LABELS: Readonly<Record<DungeonReleaseStage,
export function dungeonReleaseStage(id: string): DungeonReleaseStage { export function dungeonReleaseStage(id: string): DungeonReleaseStage {
return id === "stormwind-stockade" return id === "stormwind-stockade"
|| id === "bite-sized-stormwind-stockade" || isBiteSizedDungeonId(id)
|| id === "randomized-bite-sized-stormwind-stockade"
? "beta" ? "beta"
: "alpha"; : "alpha";
} }
+485
View File
@@ -0,0 +1,485 @@
import type {
DungeonBossObjective,
DungeonDefinition,
DungeonEncounterSpawnGroupDefinition,
Vector3Tuple,
} from "../dungeonTypes";
import type {
DungeonEncounterAction,
DungeonEncounterDefinition,
DungeonEncounterRule,
} from "../dungeonEncounterTypes";
import type {
MobPackDefinition,
PopulationEntityDefinition,
StaticMobSpawnDefinition,
} from "../mobPopulation";
import type { DungeonWorldObjectDefinition } from "../dungeonWorldObjects";
import {
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
} from "../dungeonContentSource";
import {
WAILING_NARALEX_ENCOUNTER,
WAILING_NARALEX_SPAWN_GROUPS,
WAILING_NARALEX_START_POSITION,
WAILING_NARALEX_ESCORT_WAYPOINTS,
} from "../wailingCavernsEncounter";
const WAILING_ROUTE_WIDTH = 36;
const DEADMINES_ROUTE_WIDTH = 34;
const SKADI_ROUTE_WIDTH = 32;
function normalizedName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function distanceToSegment(point: Vector3Tuple, start: Vector3Tuple, end: Vector3Tuple): number {
const direction = end.map((value, axis) => value - start[axis]) as unknown as Vector3Tuple;
const offset = point.map((value, axis) => value - start[axis]) as unknown as Vector3Tuple;
const lengthSquared = direction.reduce((total, value) => total + value * value, 0);
const projection = lengthSquared === 0
? 0
: Math.max(0, Math.min(1, offset.reduce((total, value, axis) => total + value * direction[axis], 0) / lengthSquared));
return Math.hypot(...point.map((value, axis) => value - start[axis] - direction[axis] * projection));
}
function distanceToRoute(point: Vector3Tuple, route: readonly Vector3Tuple[]): number {
if (route.length < 2) return Number.POSITIVE_INFINITY;
return Math.min(...route.slice(1).map((end, index) => distanceToSegment(point, route[index], end)));
}
function routeSpawns(
source: DungeonDefinition,
route: readonly Vector3Tuple[],
width: number,
): readonly StaticMobSpawnDefinition[] {
return source.staticSpawns.filter((spawn) => (
source.entities[spawn.entityId]?.kind !== "boss"
&& distanceToRoute(spawn.position, route) <= width
));
}
function routeRoamingPacks(
source: DungeonDefinition,
route: readonly Vector3Tuple[],
width: number,
): readonly MobPackDefinition[] {
return source.roamingPacks.flatMap((pack) => {
const members = pack.members.filter((member) => source.entities[member.entityId]?.kind !== "boss");
return members.length > 0 && pack.waypoints.every((point) => distanceToRoute(point, route) <= width * 1.7)
? [{ ...pack, members }]
: [];
});
}
function entitiesFor(
source: DungeonDefinition,
staticSpawns: readonly StaticMobSpawnDefinition[],
roamingPacks: readonly MobPackDefinition[],
groups: readonly DungeonEncounterSpawnGroupDefinition[],
additionalEntityIds: readonly string[] = [],
): Readonly<Record<string, PopulationEntityDefinition>> {
const usedIds = new Set([
...staticSpawns.map((spawn) => spawn.entityId),
...roamingPacks.flatMap((pack) => pack.members.map((member) => member.entityId)),
...groups.flatMap((group) => group.spawns.map((spawn) => spawn.entityId)),
...additionalEntityIds,
]);
const entities: Record<string, PopulationEntityDefinition> = {};
for (const entityId of usedIds) {
const entity = source.entities[entityId];
if (!entity) throw new Error(`Bite-sized route references missing entity: ${entityId}`);
entities[entityId] = entity;
}
return entities;
}
function bossByName(source: DungeonDefinition, name: string): {
readonly objective: DungeonBossObjective;
readonly spawn: StaticMobSpawnDefinition;
readonly entity: PopulationEntityDefinition;
} {
const normalized = normalizedName(name);
const objective = source.objectiveOrder.find((candidate) => normalizedName(candidate.name) === normalized);
const spawn = objective && source.staticSpawns.find((candidate) => candidate.id === objective.id);
const entity = spawn && source.entities[spawn.entityId];
if (!objective || !spawn || !entity || entity.kind !== "boss") {
throw new Error(`${source.id} is missing its ${name} boss source.`);
}
return { objective, spawn, entity };
}
function routeArea(id: string, name: string, center: Vector3Tuple, radius = 36) {
return { id, name, center, radius };
}
function runCompletionActions(prefix: string, objectiveId: string): readonly DungeonEncounterAction[] {
return [
{ id: `${prefix}-complete-objective`, kind: "complete-objective", objectiveId, persistence: "run" },
{ id: `${prefix}-checkpoint`, kind: "set-checkpoint", checkpointId: objectiveId, persistence: "checkpoint" },
{ id: `${prefix}-mark-dungeon-complete`, kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: `${prefix}-complete-phase`, kind: "set-phase", phase: "complete", persistence: "run" },
];
}
function appendPrototypeWarning(source: DungeonDefinition, note: string): DungeonDefinition["validation"] {
return {
...source.validation,
warnings: [...source.validation.warnings, note],
};
}
export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "wailing-caverns") throw new Error("The Wailing Caverns slice requires its source dungeon.");
const sourceFinalBoss = bossByName(source, "Mutanus the Devourer");
const route = [WAILING_NARALEX_START_POSITION, ...WAILING_NARALEX_ESCORT_WAYPOINTS];
const staticSpawns = routeSpawns(source, route, WAILING_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, WAILING_ROUTE_WIDTH);
const mutanusSpawn: StaticMobSpawnDefinition = {
...sourceFinalBoss.spawn,
position: sourceFinalBoss.objective.position,
};
const mutanusGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:mutanus`;
const mutanusGroup: DungeonEncounterSpawnGroupDefinition = {
id: mutanusGroupId,
spawns: [mutanusSpawn],
};
const encounterId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-escort`;
const encounter: DungeonEncounterDefinition = {
...WAILING_NARALEX_ENCOUNTER,
id: encounterId,
dungeonId: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
initialPhase: "ready",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "ready", checkpointPhase: "ready" },
source: {
...WAILING_NARALEX_ENCOUNTER.source,
reviewedBehavior: "A standalone run begins at the source escort start, follows the Naralex waypoint route, defends the Disciple through the source waves, and finishes with Mutanus.",
adaptations: [
...WAILING_NARALEX_ENCOUNTER.source.adaptations,
"The four Fanglords are skipped; the bite-sized run begins at the actual Naralex escort start.",
"Mutanus is activated by the encounter runtime as the final event spawn in this derived definition.",
],
},
rules: WAILING_NARALEX_ENCOUNTER.rules
.filter((rule) => rule.id !== "fanglords-defeated" && rule.id !== "restore-ready-after-checkpoint")
.map((rule) => {
if (rule.id === "start-escort") {
return {
...rule,
trigger: { kind: "signal", signalId: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-start` },
};
}
if (rule.id === "nightmare-wave-cleared") {
return {
...rule,
actions: [
...rule.actions,
{ id: "activate-mutanus", kind: "activate-spawn-group", spawnGroupId: mutanusGroupId },
],
};
}
return rule;
}),
};
const encounterGroups = [
...WAILING_NARALEX_SPAWN_GROUPS,
mutanusGroup,
];
const finalObjective: DungeonBossObjective = {
...sourceFinalBoss.objective,
eventOnly: true,
};
return {
...source,
id: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
title: "Wailing Caverns: Naralex’s Rescue",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Disciple of Naralex’s Camp",
footPosition: WAILING_NARALEX_START_POSITION,
forward: [-0.433, 0, 0.902],
yaw: -0.448,
},
presentation: {
...source.presentation,
location: "Wailing Caverns · Naralex’s Route",
theme: "Bite-Sized Dungeon",
summary: "Follow the winding escort route, defend Naralex’s Disciple through the awakening waves, and defeat Mutanus.",
loadingMessage: "Opening the path to the Dreamer’s Rock...",
unchartedAreaName: "Naralex’s Route",
},
areas: [
routeArea("entrance", "Disciple of Naralex’s Camp", WAILING_NARALEX_START_POSITION, 26),
routeArea("naralex-route", "The Winding Caverns", WAILING_NARALEX_ESCORT_WAYPOINTS[12], 64),
routeArea("mutanus", "Mutanus’s Chamber", sourceFinalBoss.objective.position, 42),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, encounterGroups),
staticSpawns,
roamingPacks,
bosses: [finalObjective],
objectiveOrder: [finalObjective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: encounterGroups,
serverGameObjects: source.serverGameObjects,
validation: appendPrototypeWarning(
source,
"Naralex’s route corridor and bite-sized objective list are Healer Man-authored; the source cave environment, escort waypoints, wave rosters, and Mutanus combat data are shared with Wailing Caverns.",
),
};
}
export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "deadmines") throw new Error("The Deadmines slice requires its source dungeon.");
const captain = bossByName(source, "Captain Greenskin");
const finalBoss = bossByName(source, "Edwin VanCleef");
const route: readonly Vector3Tuple[] = [
[-785, 17, -28],
[-797.2147, 20.4973, -22.8489],
[-807, 30, -42],
captain.objective.position,
finalBoss.objective.position,
];
const routeTrash = routeSpawns(source, route, DEADMINES_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, DEADMINES_ROUTE_WIDTH);
const vanCleefSpawn: StaticMobSpawnDefinition = {
...finalBoss.spawn,
position: finalBoss.objective.position,
};
const vanCleefGroupId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:van-cleef`;
const vanCleefGroup: DungeonEncounterSpawnGroupDefinition = {
id: vanCleefGroupId,
spawns: [vanCleefSpawn],
};
const encounterId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:ship-progression`;
const encounter: DungeonEncounterDefinition = {
id: encounterId,
dungeonId: BITE_SIZED_DEADMINES_DUNGEON_ID,
initialPhase: "greenskin",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "greenskin", checkpointPhase: "greenskin" },
source: {
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/generated/dungeonCampaignCatalog.compressed.ts"],
reviewedBehavior: "Start at the ship ramp, clear the Defias crew, defeat Captain Greenskin, then confront Edwin VanCleef in his cabin.",
adaptations: [
"The mine, foundry, Cookie branch, and other Deadmines bosses are omitted from this run.",
"VanCleef is held in an encounter spawn group until Greenskin is defeated.",
"Source boss IDs are retained for objective records and loot lookup.",
],
remainingGaps: [
"The ship route boundary and the optional Cookie branch need live traversal review; this first pass filters population by a spatial corridor.",
"Original boss-specific scripted actions beyond their imported combat profiles are not included in this two-boss slice.",
],
},
rules: [
{
id: "ship-greenskin-defeated",
trigger: { kind: "entities-dead", entityIds: [captain.objective.id] },
guards: [{ kind: "phase", phases: ["greenskin"] }],
actions: [
{ id: "ship-complete-greenskin", kind: "complete-objective", objectiveId: captain.objective.id, persistence: "run" },
{ id: "ship-checkpoint-greenskin", kind: "set-checkpoint", checkpointId: captain.objective.id, persistence: "checkpoint" },
{ id: "ship-activate-vancleef", kind: "activate-spawn-group", spawnGroupId: vanCleefGroupId },
{ id: "ship-enter-vancleef-phase", kind: "set-phase", phase: "van-cleef" },
],
},
{
id: "ship-vancleef-defeated",
trigger: { kind: "entities-dead", entityIds: [vanCleefSpawn.id] },
guards: [{ kind: "phase", phases: ["van-cleef"] }],
actions: runCompletionActions("ship-complete-vancleef", finalBoss.objective.id),
},
],
};
const encounterGroups = [vanCleefGroup];
const objectives: readonly DungeonBossObjective[] = [
captain.objective,
{ ...finalBoss.objective, eventOnly: true },
];
const startPosition: Vector3Tuple = [-785, 17, -28];
return {
...source,
id: BITE_SIZED_DEADMINES_DUNGEON_ID,
title: "The Deadmines: Take the Ship",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Mr. Smite’s Ship Ramp",
footPosition: startPosition,
forward: [-0.5, 0, -0.866],
yaw: -2.618,
},
presentation: {
...source.presentation,
location: "The Deadmines · The Defias Ship",
theme: "Bite-Sized Dungeon",
summary: "Board the Defias ship, push through the crew, defeat Captain Greenskin, and finish VanCleef in the cabin.",
loadingMessage: "Boarding the Defias ship...",
unchartedAreaName: "Defias Ship",
},
areas: [
routeArea("entrance", "Mr. Smite’s Ship Ramp", startPosition, 25),
routeArea("captain-greenskin", "Greenskin’s Deck", captain.objective.position, 32),
routeArea("edwin-vancleef", "VanCleef’s Cabin", finalBoss.objective.position, 28),
],
entities: entitiesFor(source, routeTrash, roamingPacks, encounterGroups, [captain.spawn.entityId]),
staticSpawns: [...routeTrash, captain.spawn],
roamingPacks,
bosses: objectives,
objectiveOrder: objectives,
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: encounterGroups,
serverGameObjects: source.serverGameObjects.filter((object) => (
distanceToRoute(object.position, route) <= DEADMINES_ROUTE_WIDTH * 1.7
)),
validation: appendPrototypeWarning(
source,
"The Deadmines ship route and two-boss progression are Healer Man-authored; source ship geometry, crew, Greenskin, and VanCleef assets are shared with The Deadmines.",
),
};
}
const SKADI_GROUP_IDS = new Set([
"utgarde-pinnacle:skadi-first-wave",
"utgarde-pinnacle:skadi-reinforcement-one",
"utgarde-pinnacle:skadi-reinforcement-two",
"utgarde-pinnacle:skadi-ground",
]);
const SKADI_RULE_IDS = new Set([
"utgarde-pinnacle-start-skadi",
"utgarde-pinnacle-first-wave-cleared",
"utgarde-pinnacle-fire-harpoon-one",
"utgarde-pinnacle-reinforcement-one-cleared",
"utgarde-pinnacle-fire-harpoon-two",
"utgarde-pinnacle-reinforcement-two-cleared",
"utgarde-pinnacle-fire-harpoon-three",
"utgarde-pinnacle-skadi-defeated",
]);
function skadiSliceRule(rule: DungeonEncounterRule, objectiveId: string): DungeonEncounterRule {
if (rule.id !== "utgarde-pinnacle-skadi-defeated") return rule;
return {
...rule,
trigger: { kind: "entities-dead", entityIds: [objectiveId], timeoutMs: 300_000 },
actions: [
...rule.actions.filter((action) => (
action.kind === "deactivate-spawn-group"
|| action.kind === "complete-objective"
|| action.kind === "set-checkpoint"
)),
{ id: "utgarde-pinnacle-skadi-slice-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "utgarde-pinnacle-skadi-slice-finish", kind: "set-phase", phase: "complete", persistence: "run" },
],
};
}
export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "utgarde-pinnacle") throw new Error("The Skadi slice requires Utgarde Pinnacle.");
const skadi = source.objectiveOrder.find((objective) => normalizedName(objective.name) === "skaditheruthless");
const sourceEncounter = source.encounters?.find((encounter) => (
encounter.rules.some((rule) => rule.id === "utgarde-pinnacle-start-skadi")
));
const sourceSkadiGroup = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-ground");
const skadiStart = source.serverGameObjects.find((object) => object.id === "utgarde-pinnacle:skadi-start");
const harpoons = [1, 2, 3].map((index) => source.serverGameObjects.find((object) => (
object.id === `utgarde-pinnacle:harpoon-${index}`
)));
if (!skadi || !sourceEncounter || !sourceSkadiGroup || !skadiStart || harpoons.some((object) => !object)) {
throw new Error("Utgarde Pinnacle is missing the reviewed Skadi gauntlet content.");
}
const route: readonly Vector3Tuple[] = [skadiStart.position, skadi.position];
const staticSpawns = routeSpawns(source, route, SKADI_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, SKADI_ROUTE_WIDTH);
const skadiSpawn: StaticMobSpawnDefinition = {
...sourceSkadiGroup.spawns[0],
id: skadi.id,
position: skadi.position,
};
const skadiGroup: DungeonEncounterSpawnGroupDefinition = {
...sourceSkadiGroup,
spawns: [skadiSpawn],
};
const groups = source.encounterSpawnGroups!.filter((group) => SKADI_GROUP_IDS.has(group.id))
.map((group) => group.id === skadiGroup.id ? skadiGroup : group);
if (!groups.some((group) => group.id === skadiGroup.id)) groups.push(skadiGroup);
const encounter: DungeonEncounterDefinition = {
...sourceEncounter,
id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-gauntlet`,
dungeonId: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
initialPhase: "skadi-ready",
initialState: {
flags: { "dungeon-complete": false },
counters: { "harpoon-hits": 0 },
worldObjectStates: {
[skadiStart.id]: "ready",
...Object.fromEntries(harpoons.map((object) => [object!.id, "unavailable"])),
},
},
resetPolicy: { bossWipePhase: "skadi-ready", checkpointPhase: "skadi-ready" },
source: {
...sourceEncounter.source!,
reviewedBehavior: "Begin at Skadi’s gauntlet, clear the source first wave, fire three harpoons through the bounded reinforcement packs, then defeat Skadi.",
adaptations: [
...sourceEncounter.source!.adaptations,
"The Svala and Gortok stages are skipped; the source Skadi event begins ready at its gauntlet trigger.",
"Skadi’s event actor uses the source boss objective ID so party targeting, loot, and completion stay aligned.",
],
},
rules: sourceEncounter.rules.filter((rule) => SKADI_RULE_IDS.has(rule.id))
.map((rule) => skadiSliceRule(rule, skadi.id)),
};
const objective: DungeonBossObjective = { ...skadi, eventOnly: true };
const routeNames = new Map<string, DungeonWorldObjectDefinition>([
[skadiStart.id, skadiStart],
...harpoons.map((object) => [object!.id, object!] as const),
]);
return {
...source,
id: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
title: "Utgarde Pinnacle: Skadi’s Gauntlet",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Eagle’s Eye Gauntlet",
footPosition: skadiStart.position,
forward: [-0.167, 0, -0.986],
yaw: -2.974,
},
presentation: {
...source.presentation,
location: "Utgarde Pinnacle · Eagle’s Eye",
theme: "Bite-Sized Dungeon",
summary: "Enter Skadi’s gauntlet, push through the moving waves, fire the harpoons, and finish the fight on the ground.",
loadingMessage: "Preparing Skadi’s gauntlet...",
unchartedAreaName: "Eagle’s Eye",
},
areas: [
routeArea("entrance", "Eagle’s Eye Gauntlet", skadiStart.position, 30),
routeArea("skadi", "Skadi’s Arena", skadi.position, 34),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, groups),
staticSpawns,
roamingPacks,
bosses: [objective],
objectiveOrder: [objective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: groups,
serverGameObjects: [...routeNames.values()],
validation: appendPrototypeWarning(
source,
"The standalone Skadi route is Healer Man-authored; source gauntlet waves, harpoon controls, Skadi combat, and the Utgarde Pinnacle environment are reused. Grauf’s flying vehicle phase remains adapted to ground-based harpoon interactions.",
),
};
}
@@ -216,6 +216,33 @@
], ],
"defaultDifficultyId": "normal" "defaultDifficultyId": "normal"
}, },
{
"id": "bite-sized-deadmines-ship",
"title": "The Deadmines: Take the Ship",
"finalBossId": "creature-79336",
"bosses": [
{
"id": "creature-79333",
"name": "Captain Greenskin"
},
{
"id": "creature-79336",
"name": "Edwin VanCleef"
}
],
"difficulties": [
{
"id": "normal",
"name": "normal",
"bossIds": [
"creature-79333",
"creature-79336"
],
"finalBossId": "creature-79336"
}
],
"defaultDifficultyId": "normal"
},
{ {
"id": "bite-sized-stormwind-stockade", "id": "bite-sized-stormwind-stockade",
"title": "Stormwind Stockade: Cellblock Rush", "title": "Stormwind Stockade: Cellblock Rush",
@@ -265,6 +292,58 @@
], ],
"defaultDifficultyId": "normal" "defaultDifficultyId": "normal"
}, },
{
"id": "bite-sized-utgarde-pinnacle-skadi",
"title": "Utgarde Pinnacle: Skadi’s Gauntlet",
"finalBossId": "creature-126103",
"bosses": [
{
"id": "creature-126103",
"name": "Skadi the Ruthless"
}
],
"difficulties": [
{
"id": "normal",
"name": "normal",
"bossIds": [
"creature-126103"
],
"finalBossId": "creature-126103"
},
{
"id": "heroic",
"name": "heroic",
"bossIds": [
"creature-126103"
],
"finalBossId": "creature-126103"
}
],
"defaultDifficultyId": "normal"
},
{
"id": "bite-sized-wailing-caverns",
"title": "Wailing Caverns: Naralex’s Rescue",
"finalBossId": "mutanus-spawn",
"bosses": [
{
"id": "mutanus-spawn",
"name": "Mutanus the Devourer"
}
],
"difficulties": [
{
"id": "normal",
"name": "normal",
"bossIds": [
"mutanus-spawn"
],
"finalBossId": "mutanus-spawn"
}
],
"defaultDifficultyId": "normal"
},
{ {
"id": "black-morass", "id": "black-morass",
"title": "The Black Morass", "title": "The Black Morass",
@@ -0,0 +1,635 @@
{
"schemaVersion": 1,
"dungeons": {
"aeternal-circle": {
"mapId": 144,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "de1ba06b7e3a9237fcff75aff2b00093287975e755cc7e98e77de1b7af6a9c14",
"kind": "client",
"image": "/assets/game/dungeons/aeternal-circle/maps/runewaker-map.png",
"checksum": "18e3f483734f5322a36b1e682480534b51875d1b99f8384d5d1fd45d7bc2c22f",
"sourceHash": "a555d52a8669d2aa5990ee55a71f476e063592d7140e6e934ac433d02273050d",
"runtimeToMap": [
0.94305882,
0.0,
0.0,
496.56034639,
0.0,
0.0,
0.94305882,
408.53672013
],
"width": 1002,
"height": 668
},
"barren-caves": {
"mapId": 110,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "e6dc49e6aa6fd20cee1b2afa43324a714f8f8284a6becef8dc514509b432f9bb",
"kind": "client",
"image": "/assets/game/dungeons/barren-caves/maps/runewaker-map.png",
"checksum": "0695bbba846094cd54a01edede7eae8c9ecd6c071ba6cc6fd2125c592fb82d00",
"sourceHash": "2ae51435420b81ae9cf423bff9bd2a8251d00d399a641c51c4403c8060a5423e",
"runtimeToMap": [
2.31008646,
0.0,
0.0,
318.25748741,
0.0,
0.0,
2.31008646,
320.74642415
],
"width": 1002,
"height": 668
},
"cave-water-dragon": {
"mapId": 118,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "dd1887f88d6ee9102da0e34ea2b4087742e37a2b8ce779c722bc124effe1faca",
"kind": "client",
"image": "/assets/game/dungeons/cave-water-dragon/maps/runewaker-map.png",
"checksum": "587745b4a040cefc1eb3e585825861d85ddaa6522dff60d5d8175a5895e8d2a2",
"sourceHash": "20526f42f0a71c1e038633e7a6f6ee767120782966d3f576ff249079e5c08bb3",
"runtimeToMap": [
1.01725888,
0.0,
0.0,
372.47804085,
0.0,
0.0,
1.01725888,
590.52902634
],
"width": 1002,
"height": 668
},
"demon-stronghold-125": {
"mapId": 125,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "e2106115dcc216999d2e37d26eeb52844ce4f41b44e19594699f37a54d7f19b9",
"kind": "navigation",
"image": "/assets/game/dungeons/demon-stronghold-125/maps/runewaker-map.png",
"checksum": "4816bface105c5bfccb579f1adbd602576c5e68691b0b594e0c3fb0ad93225ed",
"sourceHash": "a19dbc7ddd5e84ffedac76889a8673d8238c8a2240d7c7427886be5088b9863f",
"runtimeToMap": [
0.75675676,
0.0,
0.0,
448.97233788,
0.0,
0.0,
-0.75675676,
202.75896062
],
"width": 1002,
"height": 668
},
"demon-stronghold": {
"mapId": 122,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "664181dc790b9e68cdc7451dbd0b1170a033a71ca19375743b6e66ca25889723",
"kind": "navigation",
"image": "/assets/game/dungeons/demon-stronghold/maps/runewaker-map.png",
"checksum": "ce2e98b267e6b54559bd43c33f6d0c378e2c4e4721f7292610abc7e962bce72a",
"sourceHash": "0d10681eec4a2a1648b4d2699371672fea4736e5bc38457ea360916a32f1ba39",
"runtimeToMap": [
0.64494899,
0.0,
0.0,
456.65921583,
0.0,
0.0,
-0.64494899,
265.58661492
],
"width": 1002,
"height": 668
},
"dungeon-of-dalanis": {
"mapId": 127,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "45cad819bc348dec8926f7de237c625e9d18697b4200804e0440dd16b8976959",
"kind": "client",
"image": "/assets/game/dungeons/dungeon-of-dalanis/maps/runewaker-map.png",
"checksum": "674203d27fa37fbb3e8e01642995b1c1e5e31526cf05533ff8c904b354ffd632",
"sourceHash": "c74e6acccad66e905a746816d1c979b6e8ef766c18ceecd966bfc84196872e96",
"runtimeToMap": [
1.86418605,
0.0,
0.0,
134.43856543,
0.0,
0.0,
1.86418605,
169.54665566
],
"width": 1002,
"height": 668
},
"forsaken-abbey": {
"mapId": 102,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b534f66c693ac57f560e8a7aeeff59ee955b8de4bf97dd8f0aa368e10d43e79f",
"kind": "client",
"image": "/assets/game/dungeons/forsaken-abbey/maps/runewaker-map.png",
"checksum": "004fb7c759eacdbb81bcc66586f33578c332511e33cc0daf53bdcf1cc418b15d",
"sourceHash": "fd5e150607472b0dc718900aa5cebbf8ed7da32860003e3416f11536bc08efd5",
"runtimeToMap": [
1.84417178,
0.0,
0.0,
143.07589407,
0.0,
0.0,
1.84417178,
309.91862641
],
"width": 1002,
"height": 668
},
"grafu-castle": {
"mapId": 137,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b775778f457d639e3f21982aaa6bd3ee1b0eba951c711d1ba319b0588e24a38f",
"kind": "navigation",
"image": "/assets/game/dungeons/grafu-castle/maps/runewaker-map.png",
"checksum": "5db7bfe6de91a60321f1d46a42d3d7540570dcd0944125a1dbe6434d21068b1d",
"sourceHash": "f3925c7005706d5a11dfd9fc6e41c4f8af6993d22b4c84c62419346b16c4556e",
"runtimeToMap": [
1.44970412,
0.0,
0.0,
521.18524298,
0.0,
0.0,
-1.44970412,
278.88786182
],
"width": 1002,
"height": 668
},
"hall-of-survivors": {
"mapId": 117,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "96b91b24462b8428e8808295dcea29d26de9be8431ea6667dde92cdded4ddf2a",
"kind": "client",
"image": "/assets/game/dungeons/hall-of-survivors/maps/runewaker-map.png",
"checksum": "672593945d1f7938590d11f4612a49c0bd82e3e2adec04ca165677101e8e00ce",
"sourceHash": "2dcb8c2b86319314403da3643ffc9aeecc00e3a01e807bc928b5e9ce0960bd86",
"runtimeToMap": [
0.87702407,
0.0,
0.0,
470.48853677,
0.0,
0.0,
0.87702407,
327.67835904
],
"width": 1002,
"height": 668
},
"hoto": {
"mapId": 115,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "beff3a180c55166d5ebd7e3610e65b59f8104edfd7596aa22ac3a49c578fcdc0",
"kind": "navigation",
"image": "/assets/game/dungeons/hoto/maps/runewaker-map.png",
"checksum": "7acb38dc1a3c3a557e8b3a8e661221f7d16d1517d5ab287f3651332156b24963",
"sourceHash": "a2bde2e4b8ed0d0bac8c4669a05192ff0a35ae9c64df0c0a29fe3735b65cbf4b",
"runtimeToMap": [
1.75313054,
0.0,
0.0,
534.5109662,
0.0,
0.0,
-1.75313054,
310.51824005
],
"width": 1002,
"height": 668
},
"ice-dwarf-kingdom": {
"mapId": 114,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "41cef8ce1bf6dbf7df0c4e2797a325e4d50a3870d435d46684600894d4aa92e7",
"kind": "navigation",
"image": "/assets/game/dungeons/ice-dwarf-kingdom/maps/runewaker-map.png",
"checksum": "c1f48800e8e29b3a137664bc6bcdcd46e806784490daa416349ef930e6bb57f9",
"sourceHash": "05c1a2a9541b5a463b7032b47e509f66bb4db6466b279d3612b7f6add9cd0c46",
"runtimeToMap": [
1.27853874,
0.0,
0.0,
529.59821325,
0.0,
0.0,
-1.27853874,
220.50323225
],
"width": 1002,
"height": 668
},
"kal-turok-hive": {
"mapId": 105,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "75c0c596218cec07003e2def1d74c8e1f5ca3010ed370db558a1786b003f4f1c",
"kind": "client",
"image": "/assets/game/dungeons/kal-turok-hive/maps/runewaker-map.png",
"checksum": "09e9b2ae4d6fac6c96dc12d7bf1da3cc276031450f7810a6f8bef855e6373487",
"sourceHash": "b03787597089a676b1f81144f3e9602fc760fcf7e207269d2820d450cd69b3d3",
"runtimeToMap": [
3.48521739,
0.0,
0.0,
490.60879608,
0.0,
0.0,
3.48521739,
509.76018772
],
"width": 1002,
"height": 668
},
"kalins-shrine": {
"mapId": 107,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "023c43a56253e97fa0526185c61f76e0e9fa8398b7a35771728566c331cd22a9",
"kind": "navigation",
"image": "/assets/game/dungeons/kalins-shrine/maps/runewaker-map.png",
"checksum": "3660de8c80eb07f0612c24586f51149ff03a14ae82b43abf9a11db60bcf59275",
"sourceHash": "6b77496f7b46890d401f955f9254ae37d25d9a1287a3bc9e787e9a254f9d9071",
"runtimeToMap": [
0.8824853,
0.0,
0.0,
632.14257291,
0.0,
0.0,
-0.8824853,
59.90035597
],
"width": 1002,
"height": 668
},
"kawaks-tomb": {
"mapId": 134,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "3f1755780540c54a1ba9b528e646ebd39b12ab127e41fa2d497fd7749d202018",
"kind": "client",
"image": "/assets/game/dungeons/kawaks-tomb/maps/runewaker-map.png",
"checksum": "6fedf7501b2560b06bc8bd8d474bec5530231318ef6c0a1aa19cd2cb61766843",
"sourceHash": "dd8f5e3e95a3df505e891a6477d40f4f011311ff6fd6bde9ef6d703a29a66ac9",
"runtimeToMap": [
0.91090909,
0.0,
0.0,
514.89942508,
0.0,
0.0,
0.91090909,
608.00390825
],
"width": 1002,
"height": 668
},
"kulech-bones-nest": {
"mapId": 147,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b06b42b3c7f19abf9d645eec6be78f868cf6966e0b472e2ca4a1c36d3cf4d2d1",
"kind": "client",
"image": "/assets/game/dungeons/kulech-bones-nest/maps/runewaker-map.png",
"checksum": "b3246989b267d600bc6d6185697ecf02771928779d31bd4594920bb8b3a76760",
"sourceHash": "066ee37932c543e6715243fcef3446f70a8eb9aa2a90758d7346262e46823df5",
"runtimeToMap": [
1.59363817,
0.0,
0.0,
508.5092438,
0.0,
0.0,
1.59363817,
342.87627637
],
"width": 1002,
"height": 668
},
"lair-demon-dragon": {
"mapId": 119,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b371465e96ef978915c9a515846ecf9dccb98607fd549617a1c71f45468fa5a7",
"kind": "navigation",
"image": "/assets/game/dungeons/lair-demon-dragon/maps/runewaker-map.png",
"checksum": "cf2a67781897d772cb1178aa77539ad4700622699dc99954c5abdafc6fedd562",
"sourceHash": "3bb62814056d133d0f5942f6b5fb7c4c4f4178ea9df311f0363298beb0c6a8f9",
"runtimeToMap": [
1.55186061,
0.0,
0.0,
558.97494417,
0.0,
0.0,
-1.55186061,
292.85074742
],
"width": 1002,
"height": 668
},
"mystic-altar": {
"mapId": 104,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "c37f27643598c8fe6401828be2950bc08f299dfe491df37736fda4662c70a96e",
"kind": "navigation",
"image": "/assets/game/dungeons/mystic-altar/maps/runewaker-map.png",
"checksum": "245f4414aa6daa7251cdeae277800a1d03566fd9ee8aea7e08c62205bc4d191b",
"sourceHash": "3dbf8ce85b83f0d515b6925a784a36191f4b9e760b56205a8b20bb927e389ed1",
"runtimeToMap": [
0.62499999,
0.0,
0.0,
627.42226927,
0.0,
0.0,
-0.62499999,
339.38813581
],
"width": 1002,
"height": 668
},
"paspers-shrine": {
"mapId": 106,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "bcf6443e8adb8b7f7dd81f4a97b79aac1c99756a83289bbada31853d2d9adb7c",
"kind": "navigation",
"image": "/assets/game/dungeons/paspers-shrine/maps/runewaker-map.png",
"checksum": "d353dd3e3e7bbea7985fa24efe167bbf7eb88f71890ebdf27a5d56c10cbba7b7",
"sourceHash": "4ebba3fb97a99df49c7301dbfcdb8154634cf4e56a85c5ed587c7515221d0a10",
"runtimeToMap": [
1.19512195,
0.0,
0.0,
462.10446651,
0.0,
0.0,
-1.19512195,
248.31707243
],
"width": 1002,
"height": 668
},
"raksha-temple": {
"mapId": 130,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "1fad5149acfce870be822f5355369f15dd76fba280bbeda31ff7f371c442082f",
"kind": "client",
"image": "/assets/game/dungeons/raksha-temple/maps/runewaker-map.png",
"checksum": "5f46c29a4fcd7b3a537d72996a785b41db8d3dd7b52485292ea8135b2effba68",
"sourceHash": "7bc925e7f463099c596ffceebfc26969b1f8549952da81688f18da8b80070f7d",
"runtimeToMap": [
0.49975062,
0.0,
0.0,
592.25597645,
0.0,
0.0,
0.49975062,
354.44431688
],
"width": 1002,
"height": 668
},
"sand-vortex": {
"mapId": 121,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "48be6befbd24870a948a5a0f49f22098b44a5a3502c4749e292c42bde1df7baa",
"kind": "navigation",
"image": "/assets/game/dungeons/sand-vortex/maps/runewaker-map.png",
"checksum": "5cf410dbfab40f4337dad936ebf4db9a7bbe161a0e8d46674b678b8e3a4aa0e7",
"sourceHash": "8dab93dfda9145a6d10c1526281bee6d531e80426300c704b348ab562c7843ef",
"runtimeToMap": [
6.87719298,
0.0,
0.0,
508.32039709,
0.0,
0.0,
-6.87719298,
151.2326885
],
"width": 1002,
"height": 668
},
"sardo-castle": {
"mapId": 139,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "191396625e301a41afab38e168848a198a9be5918a071dadda841683a7c6261e",
"kind": "client",
"image": "/assets/game/dungeons/sardo-castle/maps/runewaker-map.png",
"checksum": "b3f418fd1e8a539e475a2823387791b8ae87cc7c2ec56d8590beb12c63d398f0",
"sourceHash": "f435dae1726d80a9903dc278bd1933a4f6a9afe59ca213846366c63e3a8c41af",
"runtimeToMap": [
1.52106262,
0.0,
0.0,
493.75955465,
0.0,
0.0,
1.52106262,
322.77658515
],
"width": 1002,
"height": 668
},
"the-origin": {
"mapId": 116,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "1f96c2b57a24470a003834a8c91cb0ceda5b01824d5bfa31904afd247c404e88",
"kind": "navigation",
"image": "/assets/game/dungeons/the-origin/maps/runewaker-map.png",
"checksum": "42b4240eae2118a030bd91145218f4be28c2a5a72b9cdbbbbcf5e1249fc35bbc",
"sourceHash": "70c0a78073d7c3cd7a95e0dc3531c25c0de9815376001f9b80cfbb59a72596cd",
"runtimeToMap": [
0.63947793,
0.0,
0.0,
554.13766371,
0.0,
0.0,
-0.63947793,
351.65470791
],
"width": 1002,
"height": 668
},
"tomb-seven-heroes": {
"mapId": 142,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "09250a1fbabf8a03f922c9a3950aee3daa1edc54215cd03ddc9bf5c96e1429bd",
"kind": "navigation",
"image": "/assets/game/dungeons/tomb-seven-heroes/maps/runewaker-map.png",
"checksum": "81260997140c71fc7c903e70325143eb4c113cafdf4dbe5fef9c7f44f3c5aaad",
"sourceHash": "617cac4b74b580ca214c241a9ffa9574aa19d1cd59efac71e4f9c4899709df51",
"runtimeToMap": [
1.23192956,
0.0,
0.0,
461.29778231,
0.0,
0.0,
-1.23192956,
281.72970787
],
"width": 1002,
"height": 668
},
"treasure-trove": {
"mapId": 108,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "c56d429187fd2ffbe20e11d5e7bbfd2128a779c9251fa2ab4b12286caf0de0b6",
"kind": "navigation",
"image": "/assets/game/dungeons/treasure-trove/maps/runewaker-map.png",
"checksum": "3d0a053648aa902ac2d6677f281590aaf783ab3f8116751b6ce70d410d1939b9",
"sourceHash": "73a70fcf482f7d13c9140901d8dd66c5b339a300f0aece69b00e863b83652657",
"runtimeToMap": [
0.74686104,
0.0,
0.0,
520.62262966,
0.0,
0.0,
-0.74686104,
296.69564946
],
"width": 1002,
"height": 668
},
"warnorken-arena": {
"mapId": 129,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "ee81c9e7c94d624977143297ca6c070c40799fdda1c51b92044c70dff53987ea",
"kind": "client",
"image": "/assets/game/dungeons/warnorken-arena/maps/runewaker-map.png",
"checksum": "74506c606c6fc4b2f5fe08b2147de60f8258932e3ca3d473b0299983e9ed0111",
"sourceHash": "6756f969314429818c12aedcf74ae399d1b8505a3c9ea65fb0b04b8014876265",
"runtimeToMap": [
1.13115715,
0.0,
-1.26195978,
515.13353291,
1.26195978,
0.0,
1.13115715,
335.10954034
],
"width": 1002,
"height": 668
},
"ystra-labyrinth-boss1": {
"mapId": 109,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "5e35eeccf5e207477bfb3a38dee75d53a5c5467433a90270b79a9ba8619a6260",
"kind": "client",
"image": "/assets/game/dungeons/ystra-labyrinth-boss1/maps/runewaker-map.png",
"checksum": "bee49409ca78c29ef428f841f7dfbbe70e7a1ea85e61a248209f269d6d54d661",
"sourceHash": "ce8efa65bf8d1e28557f36270551a6089567c6e76f743d30a501d83c1d44c374",
"runtimeToMap": [
4.175,
0.0,
0.0,
499.74712348,
0.0,
0.0,
4.175,
339.02184443
],
"width": 1002,
"height": 668
},
"ystra-labyrinth-boss2": {
"mapId": 111,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a",
"kind": "client",
"image": "/assets/game/dungeons/ystra-labyrinth-boss2/maps/runewaker-map.png",
"checksum": "18210f5f16aa2ed7c0f5d2356cb3a8bb4382bf5043fe396f103fbb38464b0e97",
"sourceHash": "02c2e7792242aedaa15075d24a1e6e571533f5e57e38cf94e810c2db35977022",
"runtimeToMap": [
4.175,
0.0,
0.0,
499.74712348,
0.0,
0.0,
4.175,
339.02184443
],
"width": 1002,
"height": 668
},
"ystra-labyrinth-boss3": {
"mapId": 112,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a",
"kind": "client",
"image": "/assets/game/dungeons/ystra-labyrinth-boss3/maps/runewaker-map.png",
"checksum": "0397004fbf5d52a9f887d350dc9d52f0974228fc9bd8b28664d1848a5c6d8a63",
"sourceHash": "f4552058b24988dfe546bee975f815f007872cd2ed09838fbda4f014da609328",
"runtimeToMap": [
4.175,
0.0,
0.0,
499.74712348,
0.0,
0.0,
4.175,
339.02184443
],
"width": 1002,
"height": 668
},
"zurhidon-stronghold-124": {
"mapId": 124,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a",
"kind": "navigation",
"image": "/assets/game/dungeons/zurhidon-stronghold-124/maps/runewaker-map.png",
"checksum": "e9fe448187cf87f46317b36967bc6eeb97f7b56be41a495aca2616c0d4b9cbee",
"sourceHash": "e57509105e15e67b1e4f3075c1c98f082552001a566e01a81fc1c8be9af41f29",
"runtimeToMap": [
0.86457869,
0.0,
0.0,
412.42758975,
0.0,
0.0,
-0.86457869,
55.43724674
],
"width": 1002,
"height": 668
},
"zurhidon-stronghold": {
"mapId": 120,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a",
"kind": "navigation",
"image": "/assets/game/dungeons/zurhidon-stronghold/maps/runewaker-map.png",
"checksum": "e9fe448187cf87f46317b36967bc6eeb97f7b56be41a495aca2616c0d4b9cbee",
"sourceHash": "e57509105e15e67b1e4f3075c1c98f082552001a566e01a81fc1c8be9af41f29",
"runtimeToMap": [
0.86457869,
0.0,
0.0,
412.42758975,
0.0,
0.0,
-0.86457869,
55.43724674
],
"width": 1002,
"height": 668
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { preloadAbilityCatalogsForClasses, sourceAbilitiesForClass } from "./abilityCatalog";
import { permanentBuffEffects } from "./partyBuffPolicy";
describe("permanent party buff catalog", () => {
it("offers learned active stat buffs without treating passive or hostile auras as GM candidates", async () => {
await preloadAbilityCatalogsForClasses(["priest"]);
const priest = sourceAbilitiesForClass("priest");
const fortitude = priest.find((ability) => ability.id === "wow335-priest-power-word-fortitude");
expect(fortitude).toBeDefined();
expect(permanentBuffEffects(fortitude!)).not.toHaveLength(0);
expect(priest.filter((ability) => ability.passive || ability.target === "hostile")
.every((ability) => permanentBuffEffects(ability).length === 0)).toBe(true);
});
});
+33
View File
@@ -0,0 +1,33 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
export type PartyBuffScope = "party" | "tank" | "healer";
export interface PartyBuffRule { readonly abilityId: string; readonly scope: PartyBuffScope }
export interface PartyBuffPolicy { readonly schemaVersion: 1; readonly rules: readonly PartyBuffRule[] }
export const DEFAULT_PARTY_BUFF_POLICY: PartyBuffPolicy = { schemaVersion: 1, rules: [] };
export function normalizePartyBuffPolicy(value: unknown): PartyBuffPolicy {
if (!value || typeof value !== "object") return DEFAULT_PARTY_BUFF_POLICY;
const policy = value as Partial<PartyBuffPolicy>;
if (policy.schemaVersion !== 1 || !Array.isArray(policy.rules)) return DEFAULT_PARTY_BUFF_POLICY;
const seen = new Set<string>();
return {
schemaVersion: 1,
rules: policy.rules.flatMap((rule) => {
if (!rule || typeof rule.abilityId !== "string" || !/^[a-z0-9][a-z0-9:._-]{0,127}$/i.test(rule.abilityId)
|| !["party", "tank", "healer"].includes(rule.scope) || seen.has(rule.abilityId)) return [];
seen.add(rule.abilityId);
return [{ abilityId: rule.abilityId, scope: rule.scope }];
}).slice(0, 500),
};
}
export function permanentBuffEffects(ability: AbilityDefinition): readonly Extract<AbilityEffect, { kind: "apply-aura" }>[] {
if (ability.passive || ability.target === "hostile" || ability.talentNodeId || ability.talentEntryId !== undefined) return [];
if (ability.effects.some((effect) => effect.kind !== "apply-aura" && effect.kind !== "unsupported")) return [];
return ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura" && effect.aura.disposition === "buff"
&& !effect.aura.procs?.length && !effect.aura.controlImmunities?.length
&& Boolean(effect.aura.modifiers?.length)
));
}
+81
View File
@@ -0,0 +1,81 @@
import { create } from "zustand";
import type { PlayerSession } from "../app/types";
import { requestOnlineJson } from "../app/onlineAccountClient";
import { DEFAULT_PARTY_BUFF_POLICY, normalizePartyBuffPolicy, type PartyBuffPolicy } from "./partyBuffPolicy";
interface PublishedPolicy {
readonly revision: number;
readonly updatedAt: number | null;
readonly updatedBy?: string | null;
readonly config: PartyBuffPolicy;
}
interface PolicyState extends PublishedPolicy {
readonly draft: PartyBuffPolicy;
readonly loading: boolean;
readonly saving: boolean;
readonly dirty: boolean;
readonly error: string | null;
refresh: (session?: PlayerSession | null) => Promise<void>;
replaceDraft: (draft: PartyBuffPolicy) => void;
resetDraft: () => void;
publish: (session: PlayerSession | null) => Promise<boolean>;
}
const CACHE_KEY = "healer-man.party-buff-policy.v1";
function cached(): PublishedPolicy {
try {
const value = JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? "null") as PublishedPolicy;
if (!Number.isInteger(value.revision)) throw new Error("missing");
return { ...value, config: normalizePartyBuffPolicy(value.config) };
} catch {
return { revision: 0, updatedAt: null, config: DEFAULT_PARTY_BUFF_POLICY };
}
}
function cache(payload: PublishedPolicy): void {
try { window.localStorage.setItem(CACHE_KEY, JSON.stringify(payload)); } catch { /* optional cache */ }
}
const initial = cached();
export const usePartyBuffPolicyStore = create<PolicyState>((set, get) => ({
...initial,
draft: initial.config,
loading: false,
saving: false,
dirty: false,
error: null,
refresh: async (session = null) => {
set({ loading: true, error: null });
try {
const gm = Boolean(session?.accessToken && session.roles?.includes("gm"));
const payload = await requestOnlineJson<PublishedPolicy>(
gm ? "/api/gm/party-buff-policy" : "/api/party-buff-policy", {}, gm ? session?.accessToken : undefined,
);
const normalized = { ...payload, config: normalizePartyBuffPolicy(payload.config) };
cache(normalized);
set({ ...normalized, draft: normalized.config, loading: false, dirty: false });
} catch (error) {
set({ loading: false, error: error instanceof Error ? error.message : "Could not load party buff policy." });
}
},
replaceDraft: (draft) => set({ draft: normalizePartyBuffPolicy(draft), dirty: true, error: null }),
resetDraft: () => set((state) => ({ draft: state.config, dirty: false, error: null })),
publish: async (session) => {
if (!session?.accessToken || !session.roles?.includes("gm")) {
set({ error: "Game Master access is required." });
return false;
}
set({ saving: true, error: null });
try {
const payload = await requestOnlineJson<PublishedPolicy>("/api/gm/party-buff-policy", {
method: "PUT", body: JSON.stringify({ expectedRevision: get().revision, config: get().draft }),
}, session.accessToken);
const normalized = { ...payload, config: normalizePartyBuffPolicy(payload.config) };
cache(normalized);
set({ ...normalized, draft: normalized.config, saving: false, dirty: false });
return true;
} catch (error) {
set({ saving: false, error: error instanceof Error ? error.message : "Could not publish party buff policy." });
return false;
}
},
}));
+3
View File
@@ -38,6 +38,7 @@ export interface GameState {
sessionRevision: number; sessionRevision: number;
dungeonIntroActive: boolean; dungeonIntroActive: boolean;
dungeonIntroCompletedAt: number | null; dungeonIntroCompletedAt: number | null;
raceStartBlocked: boolean;
overlay: GameOverlay; overlay: GameOverlay;
paused: boolean; paused: boolean;
mapOpen: boolean; mapOpen: boolean;
@@ -108,6 +109,7 @@ export const useGameStore = create<GameState>((set) => ({
sessionRevision: 0, sessionRevision: 0,
dungeonIntroActive: false, dungeonIntroActive: false,
dungeonIntroCompletedAt: null, dungeonIntroCompletedAt: null,
raceStartBlocked: false,
overlay: null, overlay: null,
paused: false, paused: false,
mapOpen: false, mapOpen: false,
@@ -148,6 +150,7 @@ export const useGameStore = create<GameState>((set) => ({
sessionRevision: state.sessionRevision + 1, sessionRevision: state.sessionRevision + 1,
dungeonIntroActive: options?.showDungeonIntro ?? false, dungeonIntroActive: options?.showDungeonIntro ?? false,
dungeonIntroCompletedAt: null, dungeonIntroCompletedAt: null,
raceStartBlocked: false,
overlay: null, overlay: null,
paused: false, paused: false,
mapOpen: false, mapOpen: false,
+11 -7
View File
@@ -48,6 +48,10 @@ interface WailingReferenceShape {
} }
const REFERENCE = (referenceData as unknown as WailingReferenceShape).wailingCaverns; const REFERENCE = (referenceData as unknown as WailingReferenceShape).wailingCaverns;
export const WAILING_NARALEX_START_POSITION = REFERENCE.disciple.position;
export const WAILING_NARALEX_ESCORT_WAYPOINTS = Object.freeze(
REFERENCE.disciple.escortWaypoints.map((waypoint) => waypoint.position),
);
const FANG_LORD_RUNTIME_IDS = Object.freeze([ const FANG_LORD_RUNTIME_IDS = Object.freeze([
"lady-anacondra-spawn", "lady-anacondra-spawn",
"lord-cobrahn-spawn", "lord-cobrahn-spawn",
@@ -343,34 +347,34 @@ export function wailingDisciplePosition(
phaseStartedAt: number, phaseStartedAt: number,
now = Date.now(), now = Date.now(),
): MobVector3 { ): MobVector3 {
const waypoints = REFERENCE.disciple.escortWaypoints; const waypoints = WAILING_NARALEX_ESCORT_WAYPOINTS;
if (!waypoints.length || phase === "locked" || phase === "ready") { if (!waypoints.length || phase === "locked" || phase === "ready") {
return REFERENCE.disciple.position; return WAILING_NARALEX_START_POSITION;
} }
let start = 0; let start = 0;
let end = 4; let end = 4;
let duration = 8_000; let duration = 8_000;
if (phase === "ravager-wave") return waypoints[4]?.position ?? REFERENCE.disciple.position; if (phase === "ravager-wave") return waypoints[4] ?? WAILING_NARALEX_START_POSITION;
if (phase === "escort-serpents") { if (phase === "escort-serpents") {
start = 5; start = 5;
end = 11; end = 11;
duration = 12_000; duration = 12_000;
} else if (phase === "serpent-wave") { } else if (phase === "serpent-wave") {
return waypoints[11]?.position ?? REFERENCE.disciple.position; return waypoints[11] ?? WAILING_NARALEX_START_POSITION;
} else if (phase === "cleansing") { } else if (phase === "cleansing") {
start = 12; start = 12;
end = waypoints.length - 1; end = waypoints.length - 1;
duration = 32_500; duration = 32_500;
} else { } else {
return waypoints.at(-1)?.position ?? REFERENCE.disciple.position; return waypoints.at(-1) ?? WAILING_NARALEX_START_POSITION;
} }
const progress = Math.max(0, Math.min(1, (now - phaseStartedAt) / duration)); const progress = Math.max(0, Math.min(1, (now - phaseStartedAt) / duration));
const scaled = start + (end - start) * progress; const scaled = start + (end - start) * progress;
const lower = Math.floor(scaled); const lower = Math.floor(scaled);
const upper = Math.min(end, lower + 1); const upper = Math.min(end, lower + 1);
const alpha = scaled - lower; const alpha = scaled - lower;
const left = waypoints[lower]?.position ?? REFERENCE.disciple.position; const left = waypoints[lower] ?? WAILING_NARALEX_START_POSITION;
const right = waypoints[upper]?.position ?? left; const right = waypoints[upper] ?? left;
return [ return [
left[0] + (right[0] - left[0]) * alpha, left[0] + (right[0] - left[0]) * alpha,
left[1] + (right[1] - left[1]) * alpha, left[1] + (right[1] - left[1]) * alpha,
+7 -5
View File
@@ -34,6 +34,7 @@ import {
advanceCombatEffectFrame, advanceCombatEffectFrame,
addOriginalInstantCastEffect, addOriginalInstantCastEffect,
capCombatEffects, capCombatEffects,
combatEffectVisualTuning,
updateCombatEffectQueue, updateCombatEffectQueue,
originalInstantCastEvent, originalInstantCastEvent,
originalInstantTerminalEvent, originalInstantTerminalEvent,
@@ -151,6 +152,7 @@ const CombatEffectPrimitive = memo(function CombatEffectPrimitive({
const elapsedRef = useRef(0); const elapsedRef = useRef(0);
const doneRef = useRef(false); const doneRef = useRef(false);
const style = useMemo(() => combatEffectStyle(event), [event]); const style = useMemo(() => combatEffectStyle(event), [event]);
const tuning = combatEffectVisualTuning(event);
const particleCount = QUALITY_PARTICLE_COUNT[quality]; const particleCount = QUALITY_PARTICLE_COUNT[quality];
const positions = useMemo( const positions = useMemo(
() => deterministicParticles(event.id, particleCount), () => deterministicParticles(event.id, particleCount),
@@ -182,21 +184,21 @@ const CombatEffectPrimitive = memo(function CombatEffectPrimitive({
if (projectile) { if (projectile) {
groupRef.current.position.lerpVectors(origin, target, eased); groupRef.current.position.lerpVectors(origin, target, eased);
groupRef.current.position.y += 1.05 + Math.sin(progress * Math.PI) * 0.28; groupRef.current.position.y += 1.05 + Math.sin(progress * Math.PI) * 0.28;
groupRef.current.scale.setScalar(0.72 + Math.sin(progress * Math.PI) * 0.32); groupRef.current.scale.setScalar((0.72 + Math.sin(progress * Math.PI) * 0.32) * tuning.scale);
} else { } else {
groupRef.current.rotation.y += frame.motionDeltaSeconds * (event.school === "physical" ? 4 : 1.8); groupRef.current.rotation.y += frame.motionDeltaSeconds * (event.school === "physical" ? 4 : 1.8);
const scale = event.phase === "cast-start" const scale = event.phase === "cast-start"
? 0.85 + Math.sin(progress * Math.PI * 3) * 0.08 ? 0.85 + Math.sin(progress * Math.PI * 3) * 0.08
: 0.35 + eased * (event.delivery === "area" ? Math.max(1.2, event.radius ?? 2.4) : 1.75); : 0.35 + eased * (event.delivery === "area" ? Math.max(1.2, event.radius ?? 2.4) : 1.75);
groupRef.current.scale.setScalar(scale); groupRef.current.scale.setScalar(scale * tuning.scale);
if (!isGround && event.delivery !== "melee") groupRef.current.position.y = initialY + progress * 0.55; if (!isGround && event.delivery !== "melee") groupRef.current.position.y = initialY + progress * 0.55;
} }
const opacity = event.phase === "cast-start" const opacity = event.phase === "cast-start"
? Math.min(1, (1 - progress) * 1.8) ? Math.min(1, (1 - progress) * 1.8)
: Math.max(0, 1 - progress); : Math.max(0, 1 - progress);
if (pointsMaterialRef.current) opacity >= 0 && (pointsMaterialRef.current.opacity = opacity * 0.9); if (pointsMaterialRef.current) pointsMaterialRef.current.opacity = opacity * 0.9 * tuning.opacity;
if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72; if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72 * tuning.opacity;
if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55; if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55 * tuning.opacity;
if (frame.done) { if (frame.done) {
doneRef.current = true; doneRef.current = true;
onDone(); onDone();
+2
View File
@@ -172,6 +172,7 @@ export function GameScene() {
const roamingPacks = sessionPopulation.roamingPacks; const roamingPacks = sessionPopulation.roamingPacks;
const paused = useGameStore((state) => state.paused); const paused = useGameStore((state) => state.paused);
const dungeonIntroActive = useGameStore((state) => state.dungeonIntroActive); const dungeonIntroActive = useGameStore((state) => state.dungeonIntroActive);
const raceStartBlocked = useGameStore((state) => state.raceStartBlocked);
const mapOpen = useGameStore((state) => state.mapOpen); const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen); const companionOpen = useGameStore((state) => state.companionOpen);
const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode); const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode);
@@ -193,6 +194,7 @@ export function GameScene() {
const simulationBlocked = graphicsRecovery !== null const simulationBlocked = graphicsRecovery !== null
|| !worldReady || !worldReady
|| dungeonIntroActive || dungeonIntroActive
|| raceStartBlocked
|| (!documentVisible && !sharedOnline) || (!documentVisible && !sharedOnline)
|| (!sharedOnline && (paused || mapOpen || companionOpen)); || (!sharedOnline && (paused || mapOpen || companionOpen));
const frameloop = sceneFrameLoop({ const frameloop = sceneFrameLoop({
+8 -2
View File
@@ -119,6 +119,9 @@ const lootLabelStyle: CSSProperties = {
}; };
const POPULATION_ACTIVATION_DISTANCE = 95; const POPULATION_ACTIVATION_DISTANCE = 95;
// Scholomance stacks rooms on several floors around its entrance. Its standard
// radius mounts nearly 200 mobs at once, including creatures behind walls.
const SCHOLOMANCE_ACTIVATION_DISTANCE = 48;
function mobTargetPosition( function mobTargetPosition(
targetActorId: string | null | undefined, targetActorId: string | null | undefined,
@@ -808,7 +811,10 @@ function isPlayerOrPartyNear(points: readonly MobVector3[], maxDistance: number)
* so the player's 10 Hz HUD snapshot does not re-render the population tree. * so the player's 10 Hz HUD snapshot does not re-render the population tree.
*/ */
function usePopulationProximity(points: readonly MobVector3[]): boolean { function usePopulationProximity(points: readonly MobVector3[]): boolean {
const initial = isPlayerOrPartyNear(points, POPULATION_ACTIVATION_DISTANCE); const activationDistance = useGameStore.getState().activeDungeonId === "scholomance"
? SCHOLOMANCE_ACTIVATION_DISTANCE
: POPULATION_ACTIVATION_DISTANCE;
const initial = isPlayerOrPartyNear(points, activationDistance);
const nearbyRef = useRef(initial); const nearbyRef = useRef(initial);
const [nearby, setNearby] = useState(initial); const [nearby, setNearby] = useState(initial);
const elapsedRef = useRef(0); const elapsedRef = useRef(0);
@@ -819,7 +825,7 @@ function usePopulationProximity(points: readonly MobVector3[]): boolean {
elapsedRef.current = 0; elapsedRef.current = 0;
// Small hysteresis prevents mount/unmount churn at the boundary. // Small hysteresis prevents mount/unmount churn at the boundary.
const distance = POPULATION_ACTIVATION_DISTANCE + (nearbyRef.current ? 18 : 0); const distance = activationDistance + (nearbyRef.current ? 18 : 0);
const next = isPlayerOrPartyNear(points, distance); const next = isPlayerOrPartyNear(points, distance);
if (next === nearbyRef.current) return; if (next === nearbyRef.current) return;
nearbyRef.current = next; nearbyRef.current = next;
+8 -4
View File
@@ -51,7 +51,7 @@ import {
} from "./originalCombatMeshRuntime"; } from "./originalCombatMeshRuntime";
import { combatEffectTargetVisualSize, updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry"; import { combatEffectTargetVisualSize, updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
import { import {
projectileTravelDurationMs, risingTideImpactPlacement, shouldUseGeneratedOriginalFallback, combatEffectVisualTuning, projectileTravelDurationMs, risingTideImpactPlacement, shouldUseGeneratedOriginalFallback,
} from "./combatEffectRuntime"; } from "./combatEffectRuntime";
import { OriginalWowModel } from "./OriginalWowModel"; import { OriginalWowModel } from "./OriginalWowModel";
import { OriginalParticleGeometry } from "./OriginalParticleGeometry"; import { OriginalParticleGeometry } from "./OriginalParticleGeometry";
@@ -329,6 +329,7 @@ export const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
modelGraph, modelGraph,
sourceTextures, sourceTextures,
timelineRef, timelineRef,
opacity = 1,
}: { }: {
readonly graph: OriginalCombatEffectGraph; readonly graph: OriginalCombatEffectGraph;
readonly particle: OriginalCombatEffectParticle; readonly particle: OriginalCombatEffectParticle;
@@ -341,6 +342,7 @@ export const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
readonly modelGraph?: OriginalWowModelGraph; readonly modelGraph?: OriginalWowModelGraph;
readonly sourceTextures?: ReadonlyMap<string, Texture>; readonly sourceTextures?: ReadonlyMap<string, Texture>;
readonly timelineRef?: MutableRefObject<number>; readonly timelineRef?: MutableRefObject<number>;
readonly opacity?: number;
}) { }) {
const elapsedRef = useRef(0); const elapsedRef = useRef(0);
const sourcePose = useMemo(() => ({ const sourcePose = useMemo(() => ({
@@ -485,7 +487,7 @@ export const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
const scaleVariation = 1 + (sourceRandom(cycleSeed + 8) * 2 - 1) const scaleVariation = 1 + (sourceRandom(cycleSeed + 8) * 2 - 1)
* (particle.scaleVariation[0] + particle.scaleVariation[1]) * 0.5; * (particle.scaleVariation[0] + particle.scaleVariation[1]) * 0.5;
colors.setXYZ(index, color[0] / 255, color[1] / 255, color[2] / 255); colors.setXYZ(index, color[0] / 255, color[1] / 255, color[2] / 255);
alphas.setX(index, Math.min(1, Math.max(0, alpha / 32_767))); alphas.setX(index, Math.min(1, Math.max(0, alpha / 32_767)) * opacity);
sizes.setX(index, Math.max(0.002, (scale[0] + scale[1]) * 0.5 * Math.max(0.05, scaleVariation))); sizes.setX(index, Math.max(0.002, (scale[0] + scale[1]) * 0.5 * Math.max(0.05, scaleVariation)));
const frameSamples = particle.samples.headCell.length const frameSamples = particle.samples.headCell.length
? particle.samples.headCell ? particle.samples.headCell
@@ -729,6 +731,7 @@ function LoadedOriginalCombatEffect({
? event.origin ? event.origin
: event.targetPosition ?? event.origin; : event.targetPosition ?? event.origin;
const loopTimeline = event.phase === "cast-start" || event.phase === "aura-start"; const loopTimeline = event.phase === "cast-start" || event.phase === "aura-start";
const tuning = combatEffectVisualTuning(event);
useFrame((_state, delta) => { useFrame((_state, delta) => {
if (!active || doneRef.current) return; if (!active || doneRef.current) return;
@@ -796,7 +799,7 @@ function LoadedOriginalCombatEffect({
&& item.asset.model?.toLowerCase() === "model/fx/skill/ice/act_riptide_t_freeze_hit.ros" && item.asset.model?.toLowerCase() === "model/fx/skill/ice/act_riptide_t_freeze_hit.ros"
? risingTideImpactPlacement(event, combatEffectTargetVisualSize(event.targetActorId)) ? risingTideImpactPlacement(event, combatEffectTargetVisualSize(event.targetActorId))
: null; : null;
const scale = effectScale(item.asset) * (risingTideTarget?.scale ?? 1); const scale = effectScale(item.asset) * (risingTideTarget?.scale ?? 1) * tuning.scale;
return ( return (
<group <group
key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`} key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`}
@@ -806,7 +809,7 @@ function LoadedOriginalCombatEffect({
scale={scale} scale={scale}
> >
{graph.kind === "wow-m2-model" && <OriginalWowModel graph={graph} textures={item.textures} {graph.kind === "wow-m2-model" && <OriginalWowModel graph={graph} textures={item.textures}
active={active} playbackRef={staticMeshPlaybackRef} loop={loopTimeline} />} active={active} playbackRef={staticMeshPlaybackRef} loop={loopTimeline} opacity={tuning.opacity} />}
{graph.kind === "runewaker-ros-particle-archive" ? ( {graph.kind === "runewaker-ros-particle-archive" ? (
<RunewakerParticleEffect graph={graph} textures={item.sourceTextures} <RunewakerParticleEffect graph={graph} textures={item.sourceTextures}
phase={event.phase} quality={quality} active={active} eventId={event.id} phase={event.phase} quality={quality} active={active} eventId={event.id}
@@ -835,6 +838,7 @@ function LoadedOriginalCombatEffect({
eventId={event.id} eventId={event.id}
emitterIndex={loadedIndex * 4_096 + particleIndex} emitterIndex={loadedIndex * 4_096 + particleIndex}
loopTimeline={loopTimeline} loopTimeline={loopTimeline}
opacity={tuning.opacity}
/> />
) : null; ) : null;
})} })}
+7 -5
View File
@@ -11,12 +11,13 @@ import { updateWowBoneMatrices, wowModelFrame, type OriginalWowModelGraph, type
const blend = (mode: number) => [3, 4, 7].includes(mode) ? AdditiveBlending const blend = (mode: number) => [3, 4, 7].includes(mode) ? AdditiveBlending
: [5, 6].includes(mode) ? MultiplyBlending : NormalBlending; : [5, 6].includes(mode) ? MultiplyBlending : NormalBlending;
export const OriginalWowModel = memo(function OriginalWowModel({ graph, textures, active, playbackRef, loop }: { export const OriginalWowModel = memo(function OriginalWowModel({ graph, textures, active, playbackRef, loop, opacity = 1 }: {
readonly graph: OriginalWowModelGraph; readonly graph: OriginalWowModelGraph;
readonly textures: ReadonlyMap<number, Texture>; readonly textures: ReadonlyMap<number, Texture>;
readonly active: boolean; readonly active: boolean;
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
readonly loop: boolean; readonly loop: boolean;
readonly opacity?: number;
}) { }) {
const root = useRef<Group>(null); const root = useRef<Group>(null);
const scratch = useMemo(() => ({ const scratch = useMemo(() => ({
@@ -111,7 +112,7 @@ export const OriginalWowModel = memo(function OriginalWowModel({ graph, textures
const rgb = color ? sampleOriginalCombatMeshTrack(graph, color.color, frame) : undefined; const rgb = color ? sampleOriginalCombatMeshTrack(graph, color.color, frame) : undefined;
material.color.setRGB(...(rgb ?? [1, 1, 1])); material.color.setRGB(...(rgb ?? [1, 1, 1]));
material.opacity = Math.max(0, Math.min(1, (color ? sampleOriginalCombatMeshTrack(graph, color.alpha, frame) ?? 32767 : 32767) material.opacity = Math.max(0, Math.min(1, (color ? sampleOriginalCombatMeshTrack(graph, color.alpha, frame) ?? 32767 : 32767)
* (weight ? sampleOriginalCombatMeshTrack(graph, weight, frame) ?? 32767 : 32767) / (32767 * 32767))); * (weight ? sampleOriginalCombatMeshTrack(graph, weight, frame) ?? 32767 : 32767) / (32767 * 32767))) * opacity;
maps.forEach((texture, slot) => { maps.forEach((texture, slot) => {
const transform = graph.textureTransforms[layer.textureTransformIndices[slot]]; const transform = graph.textureTransforms[layer.textureTransformIndices[slot]];
if (!transform) return; if (!transform) return;
@@ -127,14 +128,15 @@ export const OriginalWowModel = memo(function OriginalWowModel({ graph, textures
{geometry && <mesh name="original-wow-animated-mesh" geometry={geometry} {geometry && <mesh name="original-wow-animated-mesh" geometry={geometry}
material={materials.map((entry) => entry.material)} frustumCulled={false} />} material={materials.map((entry) => entry.material)} frustumCulled={false} />}
{graph.ribbons.map((ribbon, index) => <OriginalWowRibbon key={index} graph={graph} ribbon={ribbon} {graph.ribbons.map((ribbon, index) => <OriginalWowRibbon key={index} graph={graph} ribbon={ribbon}
textures={textures} active={active} bones={scratch.bones} playbackRef={playbackRef} loop={loop} />)} textures={textures} active={active} bones={scratch.bones} playbackRef={playbackRef} loop={loop} opacity={opacity} />)}
</group>; </group>;
}); });
const OriginalWowRibbon = memo(function OriginalWowRibbon({ graph, ribbon, textures, active, bones, playbackRef, loop }: { const OriginalWowRibbon = memo(function OriginalWowRibbon({ graph, ribbon, textures, active, bones, playbackRef, loop, opacity }: {
readonly graph: OriginalWowModelGraph; readonly ribbon: WowRibbon; readonly textures: ReadonlyMap<number, Texture>; readonly graph: OriginalWowModelGraph; readonly ribbon: WowRibbon; readonly textures: ReadonlyMap<number, Texture>;
readonly active: boolean; readonly bones: readonly Matrix4[]; readonly active: boolean; readonly bones: readonly Matrix4[];
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; readonly loop: boolean; readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; readonly loop: boolean;
readonly opacity: number;
}) { }) {
const root = useRef<Group>(null); const root = useRef<Group>(null);
const capacity = Math.min(128, Math.max(4, Math.ceil(ribbon.edgesPerSecond * ribbon.edgeLifetime) + 2)); const capacity = Math.min(128, Math.max(4, Math.ceil(ribbon.edgesPerSecond * ribbon.edgeLifetime) + 2));
@@ -194,7 +196,7 @@ const OriginalWowRibbon = memo(function OriginalWowRibbon({ graph, ribbon, textu
} }
}); });
material.color.setRGB(...(sampleOriginalCombatMeshTrack(graph, ribbon.tracks.color, frame) ?? [1, 1, 1])); material.color.setRGB(...(sampleOriginalCombatMeshTrack(graph, ribbon.tracks.color, frame) ?? [1, 1, 1]));
material.opacity = Math.max(0, Math.min(1, (sample("alpha") ?? 32767) / 32767)); material.opacity = Math.max(0, Math.min(1, (sample("alpha") ?? 32767) / 32767)) * opacity;
positions.needsUpdate = true; uv.needsUpdate = true; positions.needsUpdate = true; uv.needsUpdate = true;
geometry.setDrawRange(0, Math.max(0, history.edges.length - 1) * 6); geometry.setDrawRange(0, Math.max(0, history.edges.length - 1) * 6);
}); });
+34 -6
View File
@@ -10,6 +10,7 @@ import {
useMemo, useMemo,
useRef, useRef,
useState, useState,
useSyncExternalStore,
type CSSProperties, type CSSProperties,
type ErrorInfo, type ErrorInfo,
type ReactNode, type ReactNode,
@@ -29,6 +30,11 @@ import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dunge
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader"; import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { derivePartySpawnSlots } from "../game/partySpawnSlots"; import { derivePartySpawnSlots } from "../game/partySpawnSlots";
import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { requireDungeonDefinition } from "../game/dungeonRegistry";
import {
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
} from "../game/dungeonContentSource";
import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes"; import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
import { getMobPosition } from "../game/mobRuntimeRegistry"; import { getMobPosition } from "../game/mobRuntimeRegistry";
import { import {
@@ -82,6 +88,11 @@ import {
import { partyCombatRangeBand } from "../game/partyRuntime"; import { partyCombatRangeBand } from "../game/partyRuntime";
import { partyRejoinMovement } from "../game/partyRejoin"; import { partyRejoinMovement } from "../game/partyRejoin";
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter"; import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
import {
activeDungeonEncounterRevision,
activeDungeonEncounterSpawnGroupIds,
subscribeDungeonEncounterSnapshots,
} from "../game/dungeonEncounterRuntime";
import { import {
clearPartyRuntimeAttackCorridor, clearPartyRuntimeAttackCorridor,
consumePartyRuntimeRejoin, consumePartyRuntimeRejoin,
@@ -1578,10 +1589,31 @@ export function PartyPopulation({
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const sessionRevision = useGameStore((state) => state.sessionRevision); const sessionRevision = useGameStore((state) => state.sessionRevision);
const wailingPhase = useWailingEncounterStore((state) => state.phase); const wailingPhase = useWailingEncounterStore((state) => state.phase);
useSyncExternalStore(
subscribeDungeonEncounterSnapshots,
activeDungeonEncounterRevision,
activeDungeonEncounterRevision,
);
const manastormEncounter = useManastormStore((state) => state.currentEncounter); const manastormEncounter = useManastormStore((state) => state.currentEncounter);
const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode); const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode);
useDungeonRouteStore((state) => state.approvals); useDungeonRouteStore((state) => state.approvals);
const dungeon = requireDungeonDefinition(activeDungeonId); const dungeon = requireDungeonDefinition(activeDungeonId);
const activeSpawnGroups = new Set(activeDungeonEncounterSpawnGroupIds());
const eventObjectiveIds = new Set((dungeon.encounterSpawnGroups ?? [])
.filter((group) => activeSpawnGroups.has(group.id))
.flatMap((group) => group.spawns.map((spawn) => spawn.id)));
const gateEventObjectives = activeDungeonId === "wailing-caverns"
|| activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
|| activeDungeonId === BITE_SIZED_DEADMINES_DUNGEON_ID
|| activeDungeonId === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID;
const availableObjectives = dungeon.objectiveOrder.filter((objective) => (
!objective.optional
&& (!objective.eventOnly || !gateEventObjectives
|| eventObjectiveIds.has(objective.id)
|| Boolean(useCombatStore.getState().mobs[objective.id])
|| (activeDungeonId === "wailing-caverns"
&& (wailingPhase === "mutanus" || wailingPhase === "complete")))
));
const navigationReviewed = dungeon.validation.status === "green" const navigationReviewed = dungeon.validation.status === "green"
|| dungeon.validation.navigation === "green"; || dungeon.validation.navigation === "green";
const manastormPackage = gameMode === "manastorm" const manastormPackage = gameMode === "manastorm"
@@ -1598,13 +1630,9 @@ export function PartyPopulation({
// reviewed links make navigation green, the player leads and the party // reviewed links make navigation green, the player leads and the party
// follows/engages locally instead of displaying a false blocked route. // follows/engages locally instead of displaying a false blocked route.
: simulationSessionMode === "spectate-live" || Boolean(routeForActiveDungeon(activeDungeonId)) : simulationSessionMode === "spectate-live" || Boolean(routeForActiveDungeon(activeDungeonId))
? dungeon.objectiveOrder.filter((objective) => !objective.optional) ? availableObjectives
: navigationReviewed : navigationReviewed
? activeDungeonId === "wailing-caverns" ? availableObjectives
&& wailingPhase !== "mutanus"
&& wailingPhase !== "complete"
? dungeon.objectiveOrder.filter((objective) => !objective.eventOnly)
: dungeon.objectiveOrder.filter((objective) => !objective.optional)
: []; : [];
const assets = useMemo( const assets = useMemo(
() => manastormPackage () => manastormPackage
+22
View File
@@ -6,6 +6,8 @@ import {
DoubleSide, DoubleSide,
Float32BufferAttribute, Float32BufferAttribute,
Group, Group,
InterleavedBuffer,
InterleavedBufferAttribute,
Mesh, Mesh,
MeshBasicMaterial, MeshBasicMaterial,
MeshStandardMaterial, MeshStandardMaterial,
@@ -75,6 +77,26 @@ describe("streamed dungeon collision preparation", () => {
expect(Array.from(trimesh.indices)).toEqual([0, 1, 2]); expect(Array.from(trimesh.indices)).toEqual([0, 1, 2]);
}); });
it("unpacks interleaved position buffers before building collision trimeshes", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new InterleavedBufferAttribute(
new InterleavedBuffer(new Float32Array([
0, 0, 0, 101,
2, 0, 0, 102,
0, 2, 0, 103,
]), 4),
3,
0,
));
geometry.setIndex([0, 1, 2]);
const root = new Group();
root.add(new Mesh(geometry, new MeshBasicMaterial()));
const [trimesh] = collisionTrimeshes(prepareCollision(root));
expect(Array.from(trimesh.vertices)).toEqual([0, 0, 0, 2, 0, 0, 0, 2, 0]);
expect(Array.from(trimesh.indices)).toEqual([0, 1, 2]);
});
it("builds sight collision from walls while excluding ground and ramps", () => { it("builds sight collision from walls while excluding ground and ramps", () => {
const geometry = new BufferGeometry(); const geometry = new BufferGeometry();
geometry.setAttribute( geometry.setAttribute(
+59 -3
View File
@@ -53,7 +53,9 @@ function prepareVisual(root: Object3D, definition: DungeonDefinition) {
instanced.computeBoundingSphere(); instanced.computeBoundingSphere();
} }
}); });
instanceRepeatedStaticMeshes(clone); // Scholomance's compact visual has thousands of opaque repetitions. Grouping
// them across the level saves draw calls without expanding a large triangle set.
instanceRepeatedStaticMeshes(clone, 3, undefined, definition.id === "scholomance");
const cells = createStaticVisibilityCells(clone); const cells = createStaticVisibilityCells(clone);
cacheStaticMatrices(clone); cacheStaticMatrices(clone);
return { visual: clone, cells }; return { visual: clone, cells };
@@ -163,6 +165,46 @@ function CollisionChunk({
); );
} }
function NavigationGround({
url,
definition,
registerPrepared,
}: {
readonly url: string;
readonly definition: DungeonDefinition;
readonly registerPrepared: (url: string, ready: boolean) => void;
}) {
const gltf = useGameGLTF(url);
const ground = useMemo(() => prepareCollision(gltf.scene), [gltf.scene]);
const trimeshes = useMemo(() => collisionTrimeshes(ground), [ground]);
useEffect(() => () => {
disposeObject3DResources(ground, { materials: false, textures: false, skeletons: false });
}, [ground]);
useEffect(() => {
registerPrepared(url, trimeshes.length > 0);
return () => registerPrepared(url, false);
}, [registerPrepared, trimeshes.length, url]);
const { position, rotation, scale } = definition.transform;
return (
<RigidBody
type="fixed"
colliders={false}
name={`${definition.id}-navigation-ground`}
position={position}
rotation={rotation}
scale={scale}
>
{trimeshes.map((trimesh) => (
<TrimeshCollider
key={trimesh.id}
args={[trimesh.vertices, trimesh.indices]}
collisionGroups={DUNGEON_PHYSICAL_COLLISION_GROUPS}
/>
))}
</RigidBody>
);
}
export function ProductionDungeon({ export function ProductionDungeon({
definition, definition,
assets, assets,
@@ -175,6 +217,11 @@ export function ProductionDungeon({
if (!assets.visual.length || !assets.collision.length) { if (!assets.visual.length || !assets.collision.length) {
throw new Error(`${definition.title} mounted without its required visual and collision GLBs.`); throw new Error(`${definition.title} mounted without its required visual and collision GLBs.`);
} }
// Raksha's WDB collision omits the walkable entrance floor present in its navmesh.
const navigationGroundUrl = definition.id === "raksha-temple" ? assets.navigation : null;
if (definition.id === "raksha-temple" && !navigationGroundUrl) {
throw new Error("Raksha Temple mounted without its required navigation ground GLB.");
}
const [loadedVisualCount, setLoadedVisualCount] = useState(0); const [loadedVisualCount, setLoadedVisualCount] = useState(0);
const [loadedCollisionCount, setLoadedCollisionCount] = useState(0); const [loadedCollisionCount, setLoadedCollisionCount] = useState(0);
const preparedCollisions = useRef(new Set<string>()); const preparedCollisions = useRef(new Set<string>());
@@ -229,10 +276,10 @@ export function ProductionDungeon({
} }
preparedCollisions.current.add(url); preparedCollisions.current.add(url);
if (signalledFirstFrame.current) return; if (signalledFirstFrame.current) return;
if (preparedCollisions.current.size < assets.collision.length) return; if (preparedCollisions.current.size < assets.collision.length + (navigationGroundUrl ? 1 : 0)) return;
signalledFirstFrame.current = true; signalledFirstFrame.current = true;
onReady?.(); onReady?.();
}, [assets.collision.length, onReady]); }, [assets.collision.length, navigationGroundUrl, onReady]);
return ( return (
<group name={`${definition.id}-production`}> <group name={`${definition.id}-production`}>
@@ -247,6 +294,15 @@ export function ProductionDungeon({
registerPrepared={registerPrepared} registerPrepared={registerPrepared}
/> />
))} ))}
{navigationGroundUrl && (
<Suspense fallback={null}>
<NavigationGround
url={navigationGroundUrl}
definition={definition}
registerPrepared={registerPrepared}
/>
</Suspense>
)}
<AssetBatchLoader <AssetBatchLoader
key={`${loadedVisualCount}:${loadedCollisionCount}:${pendingUrls.join("|")}`} key={`${loadedVisualCount}:${loadedCollisionCount}:${pendingUrls.join("|")}`}
urls={pendingUrls} urls={pendingUrls}
+44 -9
View File
@@ -1,10 +1,17 @@
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { useRef } from "react"; import { useRef, useSyncExternalStore } from "react";
import type { Group } from "three"; import type { Group } from "three";
import { BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID } from "../game/dungeonContentSource";
import {
dispatchDungeonEncounterSignal,
dungeonEncounterSnapshot,
subscribeDungeonEncounterSnapshots,
} from "../game/dungeonEncounterRuntime";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { import {
useWailingEncounterStore, useWailingEncounterStore,
wailingDisciplePosition, wailingDisciplePosition,
type WailingEncounterPhase,
} from "../game/wailingCavernsEncounter"; } from "../game/wailingCavernsEncounter";
import { WorldNpcPrompt } from "./WorldNpcPrompt"; import { WorldNpcPrompt } from "./WorldNpcPrompt";
import { dungeonSimulationNow } from "../game/dungeonSimulationStore"; import { dungeonSimulationNow } from "../game/dungeonSimulationStore";
@@ -12,23 +19,44 @@ import { dungeonSimulationNow } from "../game/dungeonSimulationStore";
export function WailingNaralexEvent() { export function WailingNaralexEvent() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const phase = useWailingEncounterStore((state) => state.phase); const sourcePhase = useWailingEncounterStore((state) => state.phase);
const phaseStartedAt = useWailingEncounterStore((state) => state.phaseStartedAt); const sourcePhaseStartedAt = useWailingEncounterStore((state) => state.phaseStartedAt);
const biteEncounterId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-escort`;
useSyncExternalStore(
subscribeDungeonEncounterSnapshots,
() => dungeonEncounterSnapshot(biteEncounterId)?.revision ?? 0,
() => 0,
);
const biteSnapshot = dungeonEncounterSnapshot(biteEncounterId);
const phase = activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
? biteSnapshot?.phase ?? "ready"
: sourcePhase;
const phaseStartedAt = activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
? biteSnapshot?.phaseStartedAt ?? 0
: sourcePhaseStartedAt;
const isBiteSized = activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID;
const groupRef = useRef<Group>(null); const groupRef = useRef<Group>(null);
useFrame(() => { useFrame(() => {
if (activeDungeonId !== "wailing-caverns" || gameMode !== "dungeon") return; if (
(activeDungeonId !== "wailing-caverns" && !isBiteSized)
|| gameMode !== "dungeon"
) return;
const liveSnapshot = isBiteSized ? dungeonEncounterSnapshot(biteEncounterId) : null;
const now = dungeonSimulationNow(); const now = dungeonSimulationNow();
const position = wailingDisciplePosition( const position = wailingDisciplePosition(
useWailingEncounterStore.getState().phase, (liveSnapshot?.phase ?? useWailingEncounterStore.getState().phase) as Parameters<typeof wailingDisciplePosition>[0],
useWailingEncounterStore.getState().phaseStartedAt, liveSnapshot?.phaseStartedAt ?? useWailingEncounterStore.getState().phaseStartedAt,
now, now,
); );
groupRef.current?.position.set(...position); groupRef.current?.position.set(...position);
}); });
if (activeDungeonId !== "wailing-caverns" || gameMode !== "dungeon") return null; if (
const initialPosition = wailingDisciplePosition(phase, phaseStartedAt); (activeDungeonId !== "wailing-caverns" && !isBiteSized)
|| gameMode !== "dungeon"
) return null;
const initialPosition = wailingDisciplePosition(phase as WailingEncounterPhase, phaseStartedAt);
return ( return (
<group ref={groupRef} name="disciple-of-naralex-event" position={initialPosition}> <group ref={groupRef} name="disciple-of-naralex-event" position={initialPosition}>
<mesh position={[0, 0.9, 0]} castShadow> <mesh position={[0, 0.9, 0]} castShadow>
@@ -48,14 +76,21 @@ export function WailingNaralexEvent() {
type="button" type="button"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (isBiteSized) {
dispatchDungeonEncounterSignal(
`${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-start`,
dungeonSimulationNow(),
);
} else {
useWailingEncounterStore.getState().start(); useWailingEncounterStore.getState().start();
}
}} }}
> >
Begin the awakening Begin the awakening
</button> </button>
) : null} ) : null}
{phase === "cleansing" ? <small>Channeling Serpentine Cleansing…</small> : null} {phase === "cleansing" ? <small>Channeling Serpentine Cleansing…</small> : null}
{phase === "complete" ? <small>Naralex has awakened</small> : null} {phase === "complete" ? <small>{isBiteSized ? "Naralex has been rescued" : "Naralex has awakened"}</small> : null}
</div> </div>
</WorldNpcPrompt> </WorldNpcPrompt>
</group> </group>
+10
View File
@@ -5,6 +5,7 @@ import {
addOriginalInstantCastEffect, addOriginalInstantCastEffect,
addOriginalOnlyEffect, addOriginalOnlyEffect,
advanceCombatEffectFrame, advanceCombatEffectFrame,
combatEffectVisualTuning,
originalInstantCastEvent, originalInstantCastEvent,
originalInstantTerminalEvent, originalInstantTerminalEvent,
projectileTravelDurationMs, projectileTravelDurationMs,
@@ -49,6 +50,15 @@ function enqueue(
} }
describe("combat-effect runtime budget", () => { describe("combat-effect runtime budget", () => {
it("contains the Dispel Magic impact without shrinking Mass Dispel", () => {
expect(combatEffectVisualTuning(event(1, { abilityId: "wow335-priest-dispel-magic" })))
.toEqual({ scale: 0.45, opacity: 0.4 });
expect(combatEffectVisualTuning(event(2, { abilityId: "wow335-priest-dispel-magic", phase: "cast-start" })))
.toEqual({ scale: 1, opacity: 1 });
expect(combatEffectVisualTuning(event(3, { abilityId: "wow335-priest-mass-dispel" })))
.toEqual({ scale: 1, opacity: 1 });
});
it("delays only the Rising Tide target arrival by the projectile's travel time", () => { it("delays only the Rising Tide target arrival by the projectile's travel time", () => {
const release = event(1, { source: "runewaker", sourceSpellId: 490256, const release = event(1, { source: "runewaker", sourceSpellId: 490256,
phase: "release", origin: [0, 0, 0], targetPosition: [14, 0, 0] }); phase: "release", origin: [0, 0, 0], targetPosition: [14, 0, 0] });
+9
View File
@@ -16,6 +16,15 @@ export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, numbe
high: 32, high: 32,
}; };
/** Keep the single-target Dispel Magic flash within the target silhouette. */
export function combatEffectVisualTuning(
event: Pick<CombatPresentationEvent, "abilityId" | "phase">,
): { readonly scale: number; readonly opacity: number } {
return event.abilityId === "wow335-priest-dispel-magic" && event.phase === "impact"
? { scale: 0.45, opacity: 0.4 }
: { scale: 1, opacity: 1 };
}
/** Keep source projectile travel and its arrival cue on the same presentation clock. */ /** Keep source projectile travel and its arrival cue on the same presentation clock. */
export function projectileTravelDurationMs( export function projectileTravelDurationMs(
event: Pick<CombatPresentationEvent, "origin" | "targetPosition" | "delivery">, event: Pick<CombatPresentationEvent, "origin" | "targetPosition" | "delivery">,
+6 -1
View File
@@ -1,6 +1,7 @@
import { import {
Float32BufferAttribute, Float32BufferAttribute,
Group, Group,
InterleavedBufferAttribute,
Mesh, Mesh,
type Object3D, type Object3D,
} from "three"; } from "three";
@@ -27,7 +28,11 @@ export function prepareCollision(root: Object3D, ramps: readonly DungeonTraversa
if (!mesh.isMesh) return; if (!mesh.isMesh) return;
const geometry = mesh.geometry.clone(); const geometry = mesh.geometry.clone();
const position = geometry.getAttribute("position"); const position = geometry.getAttribute("position");
if (position.normalized || !(position.array instanceof Float32Array)) { if (
position instanceof InterleavedBufferAttribute
|| position.normalized
|| !(position.array instanceof Float32Array)
) {
const dequantized = new Float32Array(position.count * position.itemSize); const dequantized = new Float32Array(position.count * position.itemSize);
for (let vertex = 0; vertex < position.count; vertex += 1) { for (let vertex = 0; vertex < position.count; vertex += 1) {
const offset = vertex * position.itemSize; const offset = vertex * position.itemSize;
+21
View File
@@ -153,4 +153,25 @@ describe("static scene instancing", () => {
}); });
expect(batchesIn(root).map((batch) => batch.count)).toEqual([3, 3]); expect(batchesIn(root).map((batch) => batch.count)).toEqual([3, 3]);
}); });
it("can combine distant opaque repetitions while retaining transparent cells", () => {
const root = new Group();
const geometry = new BoxGeometry();
const opaque = new MeshBasicMaterial();
const transparent = new MeshBasicMaterial({ transparent: true });
for (const x of [1, 2, 3, 385, 386, 387]) {
for (const material of [opaque, transparent]) {
const mesh = new Mesh(geometry, material);
mesh.position.x = x;
root.add(mesh);
}
}
expect(instanceRepeatedStaticMeshes(root, 3, 192, true)).toEqual({
batches: 3,
instances: 12,
removedMeshes: 12,
});
expect(batchesIn(root).map((batch) => batch.count)).toEqual([6, 3, 3]);
});
}); });
+10 -1
View File
@@ -47,6 +47,11 @@ function materialKey(material: Material | readonly Material[]): string {
.join(","); .join(",");
} }
function hasTransparency(material: Material | readonly Material[]): boolean {
return (Array.isArray(material) ? material : [material])
.some((entry) => entry.transparent);
}
function candidateKey(mesh: Mesh): string { function candidateKey(mesh: Mesh): string {
return [ return [
mesh.geometry.uuid, mesh.geometry.uuid,
@@ -93,6 +98,7 @@ export function instanceRepeatedStaticMeshes(
root: Object3D, root: Object3D,
minimumInstances = 3, minimumInstances = 3,
cellSize = STATIC_INSTANCE_CELL_SIZE, cellSize = STATIC_INSTANCE_CELL_SIZE,
globalOpaque = false,
): StaticSceneInstancingReport { ): StaticSceneInstancingReport {
root.updateWorldMatrix(true, true); root.updateWorldMatrix(true, true);
const safeCellSize = Number.isFinite(cellSize) && cellSize > 0 const safeCellSize = Number.isFinite(cellSize) && cellSize > 0
@@ -102,7 +108,10 @@ export function instanceRepeatedStaticMeshes(
const groups = new Map<string, Mesh[]>(); const groups = new Map<string, Mesh[]>();
root.traverse((node) => { root.traverse((node) => {
if (!isStaticInstancingCandidate(root, node)) return; if (!isStaticInstancingCandidate(root, node)) return;
const key = `${candidateKey(node)}|cell:${candidateCell(node, safeCellSize, center)}`; const cell = globalOpaque && !hasTransparency(node.material)
? "all"
: candidateCell(node, safeCellSize, center);
const key = `${candidateKey(node)}|cell:${cell}`;
const group = groups.get(key); const group = groups.get(key);
if (group) group.push(node); if (group) group.push(node);
else groups.set(key, [node]); else groups.set(key, [node]);
+52
View File
@@ -1992,6 +1992,58 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.companion-readiness__actions { flex-wrap: wrap; } .companion-readiness__actions { flex-wrap: wrap; }
} }
.balance-tuning { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; gap: 12px; } .balance-tuning { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; gap: 12px; }
.party-buff-policy { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; gap: 12px; }
.party-buff-policy__header { display: flex; align-items: center; gap: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--front-line); }
.party-buff-policy__header > div { display: grid; gap: 3px; flex: 1; }
.party-buff-policy__header span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; }
.party-buff-policy__header strong { font: 20px Georgia, serif; }
.party-buff-policy__intro { max-width: 900px; margin: 0; color: var(--front-dim); font-size: 10px; line-height: 1.5; }
.party-buff-policy__filters { display: flex; align-items: end; gap: 10px; }
.party-buff-policy__filters label { display: grid; gap: 4px; flex: 1; color: var(--front-dim); font-size: 8px; text-transform: uppercase; }
.party-buff-policy input, .party-buff-policy select { min-height: 34px; padding: 6px 8px; border: 1px solid var(--front-line); border-radius: 6px; background: #08140f; color: var(--front-cream); }
.party-buff-policy__filters > span { color: var(--front-gold); font-size: 9px; white-space: nowrap; }
.party-buff-policy__list { min-height: 0; overflow: auto; border: 1px solid var(--front-line); border-radius: 10px; background: rgba(5, 14, 11, .9); }
.party-buff-policy__list > p { padding: 16px; color: var(--front-dim); font-size: 10px; }
.party-buff-policy__row { display: grid; grid-template-columns: 36px minmax(0, 1fr) 170px; align-items: center; gap: 12px; padding: 10px 12px; border-bottom: 1px solid rgba(204,224,180,.1); }
.party-buff-policy__row img { width: 36px; height: 36px; border: 1px solid var(--front-line); border-radius: 6px; object-fit: cover; }
.party-buff-policy__row strong, .party-buff-policy__row small { display: block; }
.party-buff-policy__row strong { font-size: 11px; }
.party-buff-policy__row small { margin-top: 3px; color: var(--front-dim); font-size: 8px; line-height: 1.4; }
.party-buff-policy__row label { display: grid; gap: 4px; color: var(--front-dim); font-size: 8px; text-transform: uppercase; }
.party-buff-policy__actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; border-top: 1px solid var(--front-line); padding-top: 9px; }
.party-buff-policy__actions > span { margin-right: auto; color: var(--front-gold); font-size: 9px; }
.dungeon-queue-policy { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; gap: 12px; }
.dungeon-queue-policy__header { display: flex; align-items: center; gap: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--front-line); }
.dungeon-queue-policy__header > div { display: grid; gap: 3px; flex: 1; }
.dungeon-queue-policy__header span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; }
.dungeon-queue-policy__header strong { font: 20px Georgia, serif; }
.dungeon-queue-policy__intro { max-width: 900px; margin: 0; color: var(--front-dim); font-size: 10px; line-height: 1.5; }
.dungeon-queue-policy__filters { display: flex; align-items: end; gap: 10px; }
.dungeon-queue-policy__filters label { display: grid; gap: 4px; flex: 1; color: var(--front-dim); font-size: 8px; text-transform: uppercase; }
.dungeon-queue-policy input:not([type="checkbox"]), .dungeon-queue-policy select { min-height: 34px; padding: 6px 8px; border: 1px solid var(--front-line); border-radius: 6px; background: #08140f; color: var(--front-cream); }
.dungeon-queue-policy__filters > span { color: var(--front-gold); font-size: 9px; white-space: nowrap; }
.dungeon-queue-policy__list { min-height: 0; overflow: auto; border: 1px solid var(--front-line); border-radius: 10px; background: rgba(5, 14, 11, .9); }
.dungeon-queue-policy__list > p { padding: 16px; color: var(--front-dim); font-size: 10px; }
.dungeon-queue-policy__row { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 10px 12px; border-bottom: 1px solid rgba(204,224,180,.1); cursor: pointer; }
.dungeon-queue-policy__row input { width: 17px; height: 17px; accent-color: var(--front-gold); }
.dungeon-queue-policy__row strong, .dungeon-queue-policy__row small { display: block; }
.dungeon-queue-policy__row strong { font-size: 11px; }
.dungeon-queue-policy__row small { margin-top: 3px; color: var(--front-dim); font-size: 8px; }
.dungeon-queue-policy__row em { color: var(--front-gold); font-size: 9px; font-style: normal; }
.dungeon-queue-policy__actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; border-top: 1px solid var(--front-line); padding-top: 9px; }
.dungeon-queue-policy__actions > span { margin-right: auto; color: var(--front-gold); font-size: 9px; }
@media (max-width: 640px) {
.dungeon-queue-policy__header, .dungeon-queue-policy__filters, .dungeon-queue-policy__actions { flex-wrap: wrap; }
.dungeon-queue-policy__filters label { min-width: 140px; }
.dungeon-queue-policy__actions > span { width: 100%; }
.party-buff-policy__header { flex-wrap: wrap; }
.party-buff-policy__filters { flex-wrap: wrap; }
.party-buff-policy__filters label { min-width: 140px; }
.party-buff-policy__row { grid-template-columns: 36px minmax(0, 1fr); }
.party-buff-policy__row label { grid-column: 2; }
.party-buff-policy__actions { flex-wrap: wrap; }
.party-buff-policy__actions > span { width: 100%; }
}
.balance-tuning__header { display: flex; align-items: center; gap: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--front-line); } .balance-tuning__header { display: flex; align-items: center; gap: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--front-line); }
.balance-tuning__header > div:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; } .balance-tuning__header > div:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; }
.balance-tuning__header > div:nth-child(2) span, .balance-tuning__details header span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; } .balance-tuning__header > div:nth-child(2) span, .balance-tuning__details header span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; }
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more