Release Healer Man 0.1.6
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{bat,cmd}]
|
||||||
|
end_of_line = crlf
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files.eol": "\n",
|
||||||
|
"files.insertFinalNewline": true,
|
||||||
|
"files.trimFinalNewlines": true,
|
||||||
|
"files.trimTrailingWhitespace": true
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
These rules apply to every change under this project root.
|
These rules apply to every change under this project root.
|
||||||
|
|
||||||
|
## Text-file hygiene
|
||||||
|
|
||||||
|
- Every text file must end with exactly one newline, with no extra blank line
|
||||||
|
at EOF and no trailing whitespace.
|
||||||
|
- Before handing off changes, run `npm run release:check`. This check includes
|
||||||
|
untracked files; `git diff --check` alone does not.
|
||||||
|
|
||||||
## Workspace and reference boundaries
|
## Workspace and reference boundaries
|
||||||
|
|
||||||
- The active project root is `D:\Projects\HealerMan`. All implementation,
|
- The active project root is `D:\Projects\HealerMan`. All implementation,
|
||||||
@@ -44,4 +51,3 @@ These rules apply to every change under this project root.
|
|||||||
- `document.pointerLockElement` remains `null`;
|
- `document.pointerLockElement` remains `null`;
|
||||||
- releasing right mouse stops camera motion;
|
- releasing right mouse stops camera motion;
|
||||||
- no browser mouse-capture prompt appears.
|
- no browser mouse-capture prompt appears.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -185,6 +185,7 @@ on the development PC. Stop if any test or build fails:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-Location F:\Projects\HealerMan
|
Set-Location F:\Projects\HealerMan
|
||||||
|
npm run release:check
|
||||||
|
|
||||||
$VersionName = '0.1.3'
|
$VersionName = '0.1.3'
|
||||||
$VersionCode = 1003
|
$VersionCode = 1003
|
||||||
@@ -206,8 +207,8 @@ npm run build
|
|||||||
npm test -- --run src/avatar
|
npm test -- --run src/avatar
|
||||||
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
|
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
|
||||||
|
|
||||||
|
npm run release:check
|
||||||
git status --short
|
git status --short
|
||||||
git diff --check
|
|
||||||
git add -A
|
git add -A
|
||||||
git status --short
|
git status --short
|
||||||
git diff --cached --stat
|
git diff --cached --stat
|
||||||
|
|||||||
@@ -1421,3 +1421,33 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
- Verification: 38 focused party/pathing/jump/animation tests passed, all 28
|
- Verification: 38 focused party/pathing/jump/animation tests passed, all 28
|
||||||
avatar tests passed, and `npm run build` completed successfully with 954
|
avatar tests passed, and `npm run build` completed successfully with 954
|
||||||
modules transformed. The existing large-chunk advisory remains informational.
|
modules transformed. The existing large-chunk advisory remains informational.
|
||||||
|
|
||||||
|
# 2026-08-20 — shared online dungeon session with AI fill
|
||||||
|
|
||||||
|
- Ran two signed-in production clients against the local account server on
|
||||||
|
separate browser origins. **SharedLeader / Bulwarka** invited
|
||||||
|
**SharedMember / Mendara**; the accepted group roster showed both selected
|
||||||
|
characters and their Tank and Healer roles as ready.
|
||||||
|
- The leader launched Wailing Caverns with **Fill open slots with AI** enabled.
|
||||||
|
Both clients automatically entered the same activity, each rendered the
|
||||||
|
other player through the normal equipped `CharacterModel` path, and each
|
||||||
|
showed a live, targetable remote party frame. Three AI companions filled the
|
||||||
|
remaining slots, producing the expected five-member party on both clients.
|
||||||
|
- The reciprocal scene captures showed Bulwarka and Mendara at the same shared
|
||||||
|
dungeon position with matching environment, objective, AI roster, mob state,
|
||||||
|
and remote role labels. The leader client continued encounter simulation;
|
||||||
|
the member client received the synchronized mob and AI state without running
|
||||||
|
a second competing world simulation.
|
||||||
|
- From the member client, selected Bulwarka's online party frame and completed
|
||||||
|
a Lesser Heal cast. The member's mana/resource change propagated to the
|
||||||
|
leader client and both clients retained the same live remote health state.
|
||||||
|
Enemy combat and AI resource/health changes also continued to propagate while
|
||||||
|
the two clients remained connected.
|
||||||
|
- Evidence reviewed during the browser run: invitation/group roster DOM,
|
||||||
|
coordinated-launch DOM from both clients, reciprocal full-window dungeon
|
||||||
|
captures, and before/cast-complete remote-healing DOM snapshots. No missing
|
||||||
|
character-body or equipment fallback appeared in either dungeon capture.
|
||||||
|
- Verification: server/API tests passed all 11 cases, the complete Vitest suite
|
||||||
|
passed all 755 cases across 132 files (including the 28 avatar cases), and the
|
||||||
|
TypeScript production build completed successfully with 983 modules
|
||||||
|
transformed. The existing large-chunk advisory remains informational.
|
||||||
|
|||||||
+20
-2
@@ -18,6 +18,21 @@ approval before it commits, pushes `main`, builds the signed APK, and publishes
|
|||||||
the Gitea release. Signing passwords and the Gitea token are passed to the
|
the Gitea release. Signing passwords and the Gitea token are passed to the
|
||||||
child process in memory rather than on its command line.
|
child process in memory rather than on its command line.
|
||||||
|
|
||||||
|
Before changing versions or running the longer test/build sequence, the manager
|
||||||
|
runs an isolated release-candidate check. It validates tracked and untracked
|
||||||
|
files without changing the real Git index, so extra blank lines at EOF and
|
||||||
|
other whitespace errors fail immediately. Run the same fast check at any time:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run release:check
|
||||||
|
```
|
||||||
|
|
||||||
|
Editors should honor the repository's `.editorconfig`. The checked-in VS Code
|
||||||
|
settings also trim extra final newlines whenever a file is saved.
|
||||||
|
Git may print a one-time CRLF-to-LF conversion warning when an existing Windows
|
||||||
|
working copy is first staged under `.gitattributes`; that warning is not a
|
||||||
|
release failure, and subsequent repository text stays consistently LF.
|
||||||
|
|
||||||
The local release key is `F:\secure\healer-man-release.jks`. When the companion
|
The local release key is `F:\secure\healer-man-release.jks`. When the companion
|
||||||
`healer-man-release-password.dpapi` file is present, blank signing-password
|
`healer-man-release-password.dpapi` file is present, blank signing-password
|
||||||
fields are filled from that Windows user-encrypted credential in memory. The
|
fields are filled from that Windows user-encrypted credential in memory. The
|
||||||
@@ -254,11 +269,14 @@ npm run dev
|
|||||||
First check for whitespace errors and review the final file list:
|
First check for whitespace errors and review the final file list:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git diff --check
|
npm run release:check
|
||||||
git status --short
|
git status --short
|
||||||
git diff --stat
|
git diff --stat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `npm run release:check` before the longer release validation. Unlike
|
||||||
|
`git diff --check`, it includes new, untracked files.
|
||||||
|
|
||||||
Stage the intended release:
|
Stage the intended release:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -520,7 +538,7 @@ Set-Location F:\Projects\HealerMan
|
|||||||
npm run content:test
|
npm run content:test
|
||||||
npm run build
|
npm run build
|
||||||
git status --short
|
git status --short
|
||||||
git diff --check
|
npm run release:check
|
||||||
git add -A
|
git add -A
|
||||||
git status --short
|
git status --short
|
||||||
git diff --cached --stat
|
git diff --cached --stat
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
@@ -98,6 +98,7 @@
|
|||||||
"android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug",
|
"android:apk": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleDebug",
|
||||||
"android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease",
|
"android:apk:release": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 assembleRelease",
|
||||||
"android:install": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 installDebug",
|
"android:install": "npm run android:sync && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/runAndroidGradle.ps1 installDebug",
|
||||||
|
"release:check": "python scripts/release_manager.py --check-release",
|
||||||
"release:gui": "python scripts/release_manager.py",
|
"release:gui": "python scripts/release_manager.py",
|
||||||
"preview": "vite preview --host 0.0.0.0",
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
"pretest": "npm run loot:generate",
|
"pretest": "npm run loot:generate",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.5",
|
"versionName": "0.1.6",
|
||||||
"versionCode": 1005
|
"versionCode": 1006
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(import.meta.dirname, "..");
|
||||||
|
const clientRoot = path.resolve(projectRoot, "..", "LadiksMPQEditor", "client-current");
|
||||||
|
const spellPath = path.join(clientRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc");
|
||||||
|
const supportRoot = path.join(clientRoot, "patch-S", "DBFilesClient");
|
||||||
|
const runtimePath = path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json");
|
||||||
|
const outputPath = path.join(projectRoot, "src", "game", "generated", "coaTriggeredSpellCatalog.json");
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbcNumberTable(filePath, valueField, floating = false) {
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
|
||||||
|
const recordCount = buffer.readUInt32LE(4);
|
||||||
|
const recordSize = buffer.readUInt32LE(12);
|
||||||
|
return new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||||
|
const base = 20 + index * recordSize;
|
||||||
|
return [
|
||||||
|
buffer.readUInt32LE(base),
|
||||||
|
floating ? buffer.readFloatLE(base + valueField * 4) : buffer.readInt32LE(base + valueField * 4),
|
||||||
|
];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbcRangeTable(filePath) {
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
|
||||||
|
const recordCount = buffer.readUInt32LE(4);
|
||||||
|
const recordSize = buffer.readUInt32LE(12);
|
||||||
|
return new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||||
|
const base = 20 + index * recordSize;
|
||||||
|
return [buffer.readUInt32LE(base), {
|
||||||
|
min: Math.max(0, buffer.readFloatLE(base + 4), buffer.readFloatLE(base + 8)),
|
||||||
|
max: Math.max(0, buffer.readFloatLE(base + 12), buffer.readFloatLE(base + 16)),
|
||||||
|
}];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function referencedSpellIds(runtime) {
|
||||||
|
const ids = new Set();
|
||||||
|
const inspectEffects = (effects) => {
|
||||||
|
for (const effect of effects ?? []) {
|
||||||
|
if (effect.kind === "trigger-spell" && effect.spellId > 0) ids.add(effect.spellId);
|
||||||
|
if (effect.kind !== "apply-aura") continue;
|
||||||
|
for (const proc of effect.aura?.procs ?? []) {
|
||||||
|
const spellId = proc.action?.kind === "custom" ? proc.action.data?.spellId : 0;
|
||||||
|
if (typeof spellId === "number" && spellId > 0) ids.add(spellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const abilities of Object.values(runtime.abilitiesByClass)) {
|
||||||
|
for (const ability of abilities) {
|
||||||
|
inspectEffects(ability.effects);
|
||||||
|
for (const rank of ability.ranks ?? []) inspectEffects(rank.effects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
return value.replace(/\r/g, "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function spellParser(buffer, castTimes, durations, radii, ranges) {
|
||||||
|
assert(buffer.toString("ascii", 0, 4) === "WDBC", "Spell.dbc is not a WDBC file");
|
||||||
|
const recordCount = buffer.readUInt32LE(4);
|
||||||
|
const fieldCount = buffer.readUInt32LE(8);
|
||||||
|
const recordSize = buffer.readUInt32LE(12);
|
||||||
|
const stringSize = buffer.readUInt32LE(16);
|
||||||
|
assert(fieldCount >= 234 && recordSize >= fieldCount * 4, "Unexpected Spell.dbc schema");
|
||||||
|
const stringsOffset = 20 + recordCount * recordSize;
|
||||||
|
const offsets = new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||||
|
const base = 20 + index * recordSize;
|
||||||
|
return [buffer.readUInt32LE(base), base];
|
||||||
|
}));
|
||||||
|
const readString = (offset) => {
|
||||||
|
if (!offset || offset >= stringSize) return "";
|
||||||
|
const start = stringsOffset + offset;
|
||||||
|
const end = buffer.indexOf(0, start);
|
||||||
|
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
|
||||||
|
};
|
||||||
|
return (id) => {
|
||||||
|
const base = offsets.get(id);
|
||||||
|
if (base === undefined) return null;
|
||||||
|
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
|
||||||
|
const signed = (field) => buffer.readInt32LE(base + field * 4);
|
||||||
|
const float = (field) => buffer.readFloatLE(base + field * 4);
|
||||||
|
const localized = (firstField) => {
|
||||||
|
for (let locale = 0; locale < 16; locale += 1) {
|
||||||
|
const value = readString(unsigned(firstField + locale));
|
||||||
|
if (value) return cleanText(value);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
const effects = Array.from({ length: 3 }, (_, effectIndex) => ({
|
||||||
|
index: effectIndex,
|
||||||
|
effectType: unsigned(71 + effectIndex),
|
||||||
|
auraType: unsigned(95 + effectIndex),
|
||||||
|
basePoints: signed(80 + effectIndex) + 1,
|
||||||
|
dieSides: signed(74 + effectIndex),
|
||||||
|
pointsPerLevel: float(77 + effectIndex),
|
||||||
|
mechanic: unsigned(83 + effectIndex),
|
||||||
|
implicitTargetA: unsigned(86 + effectIndex),
|
||||||
|
implicitTargetB: unsigned(89 + effectIndex),
|
||||||
|
radiusIndex: unsigned(92 + effectIndex),
|
||||||
|
radius: radii.get(unsigned(92 + effectIndex)) ?? 0,
|
||||||
|
periodMs: unsigned(98 + effectIndex),
|
||||||
|
valueMultiplier: float(101 + effectIndex),
|
||||||
|
chainTargets: unsigned(104 + effectIndex),
|
||||||
|
itemType: unsigned(107 + effectIndex),
|
||||||
|
miscValue: signed(110 + effectIndex),
|
||||||
|
miscValueB: signed(113 + effectIndex),
|
||||||
|
triggerSpellId: unsigned(116 + effectIndex),
|
||||||
|
pointsPerCombo: float(119 + effectIndex),
|
||||||
|
coefficient: float(229 + effectIndex),
|
||||||
|
})).filter((effect) => effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: localized(136),
|
||||||
|
rankText: localized(153),
|
||||||
|
description: localized(170) || localized(187),
|
||||||
|
categoryId: unsigned(1),
|
||||||
|
dispelType: unsigned(2),
|
||||||
|
mechanic: unsigned(3),
|
||||||
|
attributes: Array.from({ length: 8 }, (_, index) => unsigned(4 + index)),
|
||||||
|
castTimeIndex: unsigned(28),
|
||||||
|
castTimeMs: Math.max(0, castTimes.get(unsigned(28)) ?? 0),
|
||||||
|
recoveryTimeMs: unsigned(29),
|
||||||
|
categoryRecoveryTimeMs: unsigned(30),
|
||||||
|
interruptFlags: unsigned(31),
|
||||||
|
auraInterruptFlags: unsigned(32),
|
||||||
|
channelInterruptFlags: unsigned(33),
|
||||||
|
procFlags: unsigned(34),
|
||||||
|
procChance: unsigned(35),
|
||||||
|
procCharges: unsigned(36),
|
||||||
|
maxLevel: unsigned(37),
|
||||||
|
baseLevel: unsigned(38),
|
||||||
|
spellLevel: unsigned(39),
|
||||||
|
durationIndex: unsigned(40),
|
||||||
|
durationMs: durations.get(unsigned(40)) ?? 0,
|
||||||
|
powerType: signed(41),
|
||||||
|
powerCost: unsigned(42),
|
||||||
|
powerCostPerLevel: unsigned(43),
|
||||||
|
powerCostPerSecond: unsigned(44),
|
||||||
|
powerCostPerSecondPerLevel: unsigned(45),
|
||||||
|
rangeIndex: unsigned(46),
|
||||||
|
rangeMin: ranges.get(unsigned(46))?.min ?? 0,
|
||||||
|
rangeMax: ranges.get(unsigned(46))?.max ?? 0,
|
||||||
|
projectileSpeed: float(47),
|
||||||
|
stackAmount: unsigned(49),
|
||||||
|
equippedItemClass: signed(67),
|
||||||
|
equippedItemSubclassMask: signed(68),
|
||||||
|
equippedItemInventoryMask: signed(69),
|
||||||
|
iconId: unsigned(133),
|
||||||
|
powerCostPercentage: unsigned(204),
|
||||||
|
startRecoveryCategory: unsigned(205),
|
||||||
|
startRecoveryTimeMs: unsigned(206),
|
||||||
|
maximumTargets: unsigned(212),
|
||||||
|
damageClass: unsigned(213),
|
||||||
|
preventionType: unsigned(214),
|
||||||
|
schoolMask: unsigned(225),
|
||||||
|
runeCostId: unsigned(226),
|
||||||
|
powerDisplayId: unsigned(228),
|
||||||
|
effects,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(fs.existsSync(spellPath), `CoA Spell.dbc was not found at ${spellPath}`);
|
||||||
|
const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8"));
|
||||||
|
const castTimes = dbcNumberTable(path.join(supportRoot, "SpellCastTimes.dbc"), 1);
|
||||||
|
const durations = dbcNumberTable(path.join(supportRoot, "SpellDuration.dbc"), 3);
|
||||||
|
const radii = dbcNumberTable(path.join(supportRoot, "SpellRadius.dbc"), 3, true);
|
||||||
|
const ranges = dbcRangeTable(path.join(supportRoot, "SpellRange.dbc"));
|
||||||
|
const parseSpell = spellParser(fs.readFileSync(spellPath), castTimes, durations, radii, ranges);
|
||||||
|
const pending = [...referencedSpellIds(runtime)];
|
||||||
|
const spells = new Map();
|
||||||
|
while (pending.length) {
|
||||||
|
const id = pending.shift();
|
||||||
|
if (spells.has(id)) continue;
|
||||||
|
const spell = parseSpell(id);
|
||||||
|
if (!spell) continue;
|
||||||
|
spells.set(id, spell);
|
||||||
|
for (const effect of spell.effects) {
|
||||||
|
if (effect.triggerSpellId > 0 && !spells.has(effect.triggerSpellId)) pending.push(effect.triggerSpellId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, `${JSON.stringify({ schemaVersion: 1, spells: [...spells.values()] })}\n`, "utf8");
|
||||||
|
console.log(`Wrote ${outputPath} with ${spells.size} triggered spell records.`);
|
||||||
@@ -4,6 +4,7 @@ import { CLASSES, type CoaClassId } from "../src/app/characterCatalog";
|
|||||||
import {
|
import {
|
||||||
COA_ABILITIES_BY_CLASS,
|
COA_ABILITIES_BY_CLASS,
|
||||||
COA_CLASS_RESOURCES,
|
COA_CLASS_RESOURCES,
|
||||||
|
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
} from "../src/game/coaAbilityCatalog";
|
} from "../src/game/coaAbilityCatalog";
|
||||||
import {
|
import {
|
||||||
COA_LIVE_SOURCE,
|
COA_LIVE_SOURCE,
|
||||||
@@ -25,6 +26,7 @@ writeJson("src/game/generated/coaAbilityRuntimeCatalog.json", {
|
|||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
abilitiesByClass: COA_ABILITIES_BY_CLASS,
|
abilitiesByClass: COA_ABILITIES_BY_CLASS,
|
||||||
resourcesByClass: COA_CLASS_RESOURCES,
|
resourcesByClass: COA_CLASS_RESOURCES,
|
||||||
|
triggeredAbilitiesBySpellId: COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
});
|
});
|
||||||
|
|
||||||
const coaBudgetsByClass = Object.fromEntries(CLASSES
|
const coaBudgetsByClass = Object.fromEntries(CLASSES
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -226,6 +227,67 @@ def current_commit() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
|
||||||
|
"""Check tracked and untracked release files without changing the real index."""
|
||||||
|
with tempfile.TemporaryDirectory(prefix="healer-man-release-index-") as temp_dir:
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["GIT_INDEX_FILE"] = str(Path(temp_dir) / "index")
|
||||||
|
|
||||||
|
def run_temporary_index_command(*args: str) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=project_root,
|
||||||
|
env=environment,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
output = result.stdout.decode("utf-8", errors="replace").strip()
|
||||||
|
raise ReleaseError(
|
||||||
|
f"Release-candidate Git check failed ({result.returncode}): "
|
||||||
|
f"git {' '.join(args)}\n{output}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
run_temporary_index_command("read-tree", "HEAD")
|
||||||
|
run_temporary_index_command("add", "-A")
|
||||||
|
raw_paths = run_temporary_index_command(
|
||||||
|
"diff", "--cached", "--name-only", "-z"
|
||||||
|
).stdout
|
||||||
|
staged_paths = [
|
||||||
|
path.decode("utf-8", errors="replace")
|
||||||
|
for path in raw_paths.split(b"\0")
|
||||||
|
if path
|
||||||
|
]
|
||||||
|
|
||||||
|
whitespace_result = subprocess.run(
|
||||||
|
["git", "diff", "--cached", "--check"],
|
||||||
|
cwd=project_root,
|
||||||
|
env=environment,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if whitespace_result.returncode != 0:
|
||||||
|
detail = whitespace_result.stdout.strip()
|
||||||
|
raise ReleaseError(
|
||||||
|
"Release-candidate whitespace check failed. This check includes "
|
||||||
|
f"tracked and untracked files:\n{detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
dangerous = find_dangerous_staged_paths(staged_paths)
|
||||||
|
if dangerous:
|
||||||
|
raise ReleaseError(
|
||||||
|
"Refusing to release forbidden output or a possible secret:\n"
|
||||||
|
+ "\n".join(f" - {path}" for path in dangerous)
|
||||||
|
)
|
||||||
|
return staged_paths
|
||||||
|
|
||||||
|
|
||||||
def windows_powershell_environment(powershell: str) -> dict[str, str]:
|
def windows_powershell_environment(powershell: str) -> dict[str, str]:
|
||||||
environment = os.environ.copy()
|
environment = os.environ.copy()
|
||||||
if Path(powershell).name.lower() == "powershell.exe":
|
if Path(powershell).name.lower() == "powershell.exe":
|
||||||
@@ -1003,6 +1065,15 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
credentials: dict[str, str],
|
credentials: dict[str, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
self._preflight_git(require_clean=False)
|
self._preflight_git(require_clean=False)
|
||||||
|
self._log_event(
|
||||||
|
"\n--- Early release-candidate check ---\n"
|
||||||
|
"$ npm run release:check"
|
||||||
|
)
|
||||||
|
candidate_paths = validate_release_candidates()
|
||||||
|
self._log_event(
|
||||||
|
f"✓ Early release-candidate check passed "
|
||||||
|
f"({len(candidate_paths)} changed path(s), including untracked files)"
|
||||||
|
)
|
||||||
npm = shutil.which("npm")
|
npm = shutil.which("npm")
|
||||||
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||||
if not npm or not powershell:
|
if not npm or not powershell:
|
||||||
@@ -1034,7 +1105,6 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
|||||||
)
|
)
|
||||||
for command, label in checks:
|
for command, label in checks:
|
||||||
self._run_command(command, label)
|
self._run_command(command, label)
|
||||||
self._run_command(["git", "diff", "--check"], "Whitespace check")
|
|
||||||
self._run_command(["git", "add", "-A"], "Stage release")
|
self._run_command(["git", "add", "-A"], "Stage release")
|
||||||
staged = True
|
staged = True
|
||||||
|
|
||||||
@@ -1371,12 +1441,28 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="print release/version/Git readiness without opening the GUI",
|
help="print release/version/Git readiness without opening the GUI",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check-release",
|
||||||
|
action="store_true",
|
||||||
|
help="check all release candidates, including untracked files",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--smoke-test",
|
"--smoke-test",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help=argparse.SUPPRESS,
|
help=argparse.SUPPRESS,
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
if args.check_release:
|
||||||
|
try:
|
||||||
|
paths = validate_release_candidates()
|
||||||
|
print(
|
||||||
|
f"Release candidate check passed: {len(paths)} changed path(s), "
|
||||||
|
"including untracked files."
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Release candidate check failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
if args.check:
|
if args.check:
|
||||||
try:
|
try:
|
||||||
print(json.dumps(check_summary(), indent=2))
|
print(json.dumps(check_summary(), indent=2))
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import release_manager
|
import release_manager
|
||||||
|
|
||||||
@@ -45,5 +48,57 @@ class SafetyTests(unittest.TestCase):
|
|||||||
self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', block)
|
self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', block)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseCandidateTests(unittest.TestCase):
|
||||||
|
def test_checks_untracked_files_without_changing_the_real_index(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
project_root = Path(temp_dir)
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=project_root,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
git("init", "--quiet")
|
||||||
|
(project_root / "tracked.ts").write_text("export const tracked = true;\n")
|
||||||
|
git("add", "tracked.ts")
|
||||||
|
git(
|
||||||
|
"-c",
|
||||||
|
"user.name=Healer Man Tests",
|
||||||
|
"-c",
|
||||||
|
"user.email=tests@example.invalid",
|
||||||
|
"commit",
|
||||||
|
"--quiet",
|
||||||
|
"-m",
|
||||||
|
"Initial commit",
|
||||||
|
)
|
||||||
|
|
||||||
|
untracked = project_root / "untracked.ts"
|
||||||
|
untracked.write_text("export const untracked = true;\n\n")
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
release_manager.ReleaseError, "untracked.ts.*new blank line at EOF"
|
||||||
|
):
|
||||||
|
release_manager.validate_release_candidates(project_root)
|
||||||
|
|
||||||
|
untracked.write_text("export const untracked = true;\n")
|
||||||
|
self.assertEqual(
|
||||||
|
release_manager.validate_release_candidates(project_root),
|
||||||
|
["untracked.ts"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
subprocess.run(
|
||||||
|
["git", "diff", "--cached", "--name-only"],
|
||||||
|
cwd=project_root,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
check=True,
|
||||||
|
).stdout,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ function componentFor(id) {
|
|||||||
};
|
};
|
||||||
const attack = {
|
const attack = {
|
||||||
type: int32(buffers.magicObject, offset + 0x194),
|
type: int32(buffers.magicObject, offset + 0x194),
|
||||||
|
calculationType: int32(buffers.magicObject, offset + 0x2d4),
|
||||||
damagePower: cleanFloat(float32(buffers.magicObject, offset + 0x198)),
|
damagePower: cleanFloat(float32(buffers.magicObject, offset + 0x198)),
|
||||||
damagePowerSkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x19c)),
|
damagePowerSkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x19c)),
|
||||||
fixedValue: cleanFloat(float32(buffers.magicObject, offset + 0x1a0)),
|
fixedValue: cleanFloat(float32(buffers.magicObject, offset + 0x1a0)),
|
||||||
@@ -297,7 +298,9 @@ function sourceSpell(spellId, classIndex) {
|
|||||||
})).filter((cost) => cost.type > 0 && cost.value !== 0 && ![9, 10, 11, 12, 13, 14, 15].includes(cost.type));
|
})).filter((cost) => cost.type > 0 && cost.value !== 0 && ![9, 10, 11, 12, 13, 14, 15].includes(cost.type));
|
||||||
const costs = costRows.map((cost) => ({
|
const costs = costRows.map((cost) => ({
|
||||||
resource: resourceForCostType(cost.type, classIndex),
|
resource: resourceForCostType(cost.type, classIndex),
|
||||||
amount: Math.max(0, Math.min(100, Math.abs(cost.value))),
|
amount: Math.max(0, cost.type === 3 || cost.type === 4
|
||||||
|
? Math.min(100, Math.abs(cost.value))
|
||||||
|
: Math.abs(cost.value)),
|
||||||
percentage: cost.type === 3 || cost.type === 4,
|
percentage: cost.type === 3 || cost.type === 4,
|
||||||
}));
|
}));
|
||||||
const attackDistance = int32(buffers.magicCollect, offset + 0xbc);
|
const attackDistance = int32(buffers.magicCollect, offset + 0xbc);
|
||||||
|
|||||||
@@ -86,6 +86,34 @@ 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 online_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
leader_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
activity_json TEXT,
|
||||||
|
activity_revision INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS online_group_members (
|
||||||
|
group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE,
|
||||||
|
account_id TEXT NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
character_json TEXT,
|
||||||
|
role TEXT,
|
||||||
|
joined_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, account_id)
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX IF NOT EXISTS online_group_members_group_idx ON online_group_members(group_id, joined_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS online_group_invites (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE,
|
||||||
|
inviter_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
invitee_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX IF NOT EXISTS online_group_invites_invitee_idx
|
||||||
|
ON online_group_invites(invitee_account_id, status, expires_at);
|
||||||
`);
|
`);
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
INSERT OR IGNORE INTO manastorm_admin_config (
|
INSERT OR IGNORE INTO manastorm_admin_config (
|
||||||
@@ -253,6 +281,282 @@ export function openGameDatabase(options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneExpiredGroupInvites() {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_group_invites SET status = 'expired'
|
||||||
|
WHERE status = 'pending' AND expires_at <= ?
|
||||||
|
`).run(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredJson(value, fallback = null) {
|
||||||
|
if (typeof value !== "string" || !value) return fallback;
|
||||||
|
try { return JSON.parse(value); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMembership(accountId) {
|
||||||
|
return database.prepare(`
|
||||||
|
SELECT online_groups.*
|
||||||
|
FROM online_group_members
|
||||||
|
JOIN online_groups ON online_groups.id = online_group_members.group_id
|
||||||
|
WHERE online_group_members.account_id = ?
|
||||||
|
`).get(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicGroup(groupId) {
|
||||||
|
const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(groupId);
|
||||||
|
if (!group) return null;
|
||||||
|
const members = database.prepare(`
|
||||||
|
SELECT accounts.id AS account_id, accounts.username,
|
||||||
|
online_group_members.character_json, online_group_members.role,
|
||||||
|
online_group_members.joined_at
|
||||||
|
FROM online_group_members
|
||||||
|
JOIN accounts ON accounts.id = online_group_members.account_id
|
||||||
|
WHERE online_group_members.group_id = ?
|
||||||
|
ORDER BY online_group_members.joined_at, accounts.username_key
|
||||||
|
`).all(groupId).map((member) => ({
|
||||||
|
accountId: member.account_id,
|
||||||
|
username: member.username,
|
||||||
|
character: parseStoredJson(member.character_json),
|
||||||
|
role: ["tank", "healer", "damage"].includes(member.role) ? member.role : null,
|
||||||
|
joinedAt: Number(member.joined_at),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
leaderAccountId: group.leader_account_id,
|
||||||
|
members,
|
||||||
|
activity: parseStoredJson(group.activity_json),
|
||||||
|
activityRevision: Number(group.activity_revision),
|
||||||
|
createdAt: Number(group.created_at),
|
||||||
|
updatedAt: Number(group.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onlineGroupState(accountId) {
|
||||||
|
pruneExpiredGroupInvites();
|
||||||
|
const membership = groupMembership(accountId);
|
||||||
|
const invitations = database.prepare(`
|
||||||
|
SELECT online_group_invites.id, online_group_invites.group_id,
|
||||||
|
online_group_invites.created_at, online_group_invites.expires_at,
|
||||||
|
accounts.id AS inviter_account_id, accounts.username AS inviter_username
|
||||||
|
FROM online_group_invites
|
||||||
|
JOIN accounts ON accounts.id = online_group_invites.inviter_account_id
|
||||||
|
WHERE online_group_invites.invitee_account_id = ?
|
||||||
|
AND online_group_invites.status = 'pending'
|
||||||
|
AND online_group_invites.expires_at > ?
|
||||||
|
ORDER BY online_group_invites.created_at DESC
|
||||||
|
`).all(accountId, Date.now()).map((invite) => ({
|
||||||
|
id: invite.id,
|
||||||
|
groupId: invite.group_id,
|
||||||
|
inviterAccountId: invite.inviter_account_id,
|
||||||
|
inviterUsername: invite.inviter_username,
|
||||||
|
createdAt: Number(invite.created_at),
|
||||||
|
expiresAt: Number(invite.expires_at),
|
||||||
|
}));
|
||||||
|
return { group: membership ? publicGroup(membership.id) : null, invitations };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOnlineGroup(accountId) {
|
||||||
|
const existing = groupMembership(accountId);
|
||||||
|
if (existing) return existing;
|
||||||
|
const id = `group-${randomUUID()}`;
|
||||||
|
const now = Date.now();
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_groups (id, leader_account_id, activity_json, activity_revision, created_at, updated_at)
|
||||||
|
VALUES (?, ?, NULL, 0, ?, ?)
|
||||||
|
`).run(id, accountId, now, now);
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at)
|
||||||
|
VALUES (?, ?, NULL, NULL, ?)
|
||||||
|
`).run(id, accountId, now);
|
||||||
|
return database.prepare("SELECT * FROM online_groups WHERE id = ?").get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inviteOnlineGroupMember(account, usernameInput) {
|
||||||
|
pruneExpiredGroupInvites();
|
||||||
|
const username = typeof usernameInput === "string" ? usernameInput.trim() : "";
|
||||||
|
if (!username) throw new GameDatabaseError("Enter the player's account name.", 400, "missing_username");
|
||||||
|
const invitee = statements.accountByUsername.get(username.toLowerCase());
|
||||||
|
if (!invitee) throw new GameDatabaseError("No online player has that account name.", 404, "player_not_found");
|
||||||
|
if (invitee.id === account.id) throw new GameDatabaseError("You cannot invite yourself.", 400, "cannot_invite_self");
|
||||||
|
if (groupMembership(invitee.id)) throw new GameDatabaseError("That player is already in a group.", 409, "player_already_grouped");
|
||||||
|
const group = createOnlineGroup(account.id);
|
||||||
|
const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count);
|
||||||
|
if (memberCount >= 5) throw new GameDatabaseError("Your group is already full.", 409, "group_full");
|
||||||
|
const existing = database.prepare(`
|
||||||
|
SELECT id FROM online_group_invites
|
||||||
|
WHERE group_id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ?
|
||||||
|
`).get(group.id, invitee.id, Date.now());
|
||||||
|
if (existing) throw new GameDatabaseError("That player already has an invitation from your group.", 409, "invite_exists");
|
||||||
|
const id = `invite-${randomUUID()}`;
|
||||||
|
const createdAt = Date.now();
|
||||||
|
const expiresAt = createdAt + 10 * 60 * 1000;
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_group_invites (
|
||||||
|
id, group_id, inviter_account_id, invitee_account_id, status, created_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, 'pending', ?, ?)
|
||||||
|
`).run(id, group.id, account.id, invitee.id, createdAt, expiresAt);
|
||||||
|
return { id, expiresAt, state: onlineGroupState(account.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondToOnlineGroupInvite(accountId, inviteId, accept) {
|
||||||
|
pruneExpiredGroupInvites();
|
||||||
|
const invite = database.prepare(`
|
||||||
|
SELECT * FROM online_group_invites
|
||||||
|
WHERE id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ?
|
||||||
|
`).get(inviteId, accountId, Date.now());
|
||||||
|
if (!invite) throw new GameDatabaseError("That group invitation is no longer available.", 404, "invite_not_found");
|
||||||
|
if (!accept) {
|
||||||
|
database.prepare("UPDATE online_group_invites SET status = 'declined' WHERE id = ?").run(invite.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
if (groupMembership(accountId)) throw new GameDatabaseError("Leave your current group before accepting another invitation.", 409, "already_grouped");
|
||||||
|
const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(invite.group_id);
|
||||||
|
if (!group) throw new GameDatabaseError("That group no longer exists.", 404, "group_not_found");
|
||||||
|
const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count);
|
||||||
|
if (memberCount >= 5) throw new GameDatabaseError("That group is already full.", 409, "group_full");
|
||||||
|
const now = Date.now();
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at)
|
||||||
|
VALUES (?, ?, NULL, NULL, ?)
|
||||||
|
`).run(group.id, accountId, now);
|
||||||
|
database.prepare("UPDATE online_group_invites SET status = 'accepted' WHERE id = ?").run(invite.id);
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_group_invites SET status = 'expired'
|
||||||
|
WHERE invitee_account_id = ? AND status = 'pending'
|
||||||
|
`).run(accountId);
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
||||||
|
`).run(now, group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveOnlineGroup(accountId) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group) return onlineGroupState(accountId);
|
||||||
|
database.prepare("DELETE FROM online_group_members WHERE group_id = ? AND account_id = ?").run(group.id, accountId);
|
||||||
|
const remaining = database.prepare(`
|
||||||
|
SELECT account_id FROM online_group_members WHERE group_id = ? ORDER BY joined_at LIMIT 1
|
||||||
|
`).get(group.id);
|
||||||
|
if (!remaining) {
|
||||||
|
database.prepare("DELETE FROM online_groups WHERE id = ?").run(group.id);
|
||||||
|
} else {
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups
|
||||||
|
SET leader_account_id = CASE WHEN leader_account_id = ? THEN ? ELSE leader_account_id END,
|
||||||
|
activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(accountId, remaining.account_id, Date.now(), group.id);
|
||||||
|
}
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOnlineGroupMember(accountId, targetAccountId) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group || group.leader_account_id !== accountId) {
|
||||||
|
throw new GameDatabaseError("Only the group leader can remove players.", 403, "leader_required");
|
||||||
|
}
|
||||||
|
if (targetAccountId === accountId) return leaveOnlineGroup(accountId);
|
||||||
|
const removed = database.prepare(`
|
||||||
|
DELETE FROM online_group_members WHERE group_id = ? AND account_id = ?
|
||||||
|
`).run(group.id, targetAccountId);
|
||||||
|
if (!removed.changes) throw new GameDatabaseError("That player is not in your group.", 404, "member_not_found");
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
||||||
|
`).run(Date.now(), group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGroupCharacter(character) {
|
||||||
|
if (!character || typeof character !== "object" || Array.isArray(character)) return null;
|
||||||
|
const id = String(character.id ?? "").slice(0, 100);
|
||||||
|
const name = String(character.name ?? "").trim().slice(0, 30);
|
||||||
|
const classId = String(character.classId ?? "").slice(0, 50);
|
||||||
|
if (!id || !name || !classId) throw new GameDatabaseError("Select a valid character for the group.", 400, "invalid_group_character");
|
||||||
|
let appearance = {};
|
||||||
|
if (character.appearance && typeof character.appearance === "object" && !Array.isArray(character.appearance)) {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(character.appearance);
|
||||||
|
if (serialized.length <= 8 * 1024) appearance = JSON.parse(serialized);
|
||||||
|
} catch {
|
||||||
|
appearance = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
classId,
|
||||||
|
categoryId: String(character.categoryId ?? "wow").slice(0, 20),
|
||||||
|
raceId: String(character.raceId ?? "human").slice(0, 50),
|
||||||
|
gender: character.gender === "female" ? "female" : "male",
|
||||||
|
level: Math.max(1, Math.min(1000, Math.trunc(Number(character.level) || 1))),
|
||||||
|
appearance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOnlineGroupMember(accountId, character, role) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group) throw new GameDatabaseError("Join or create a group first.", 409, "group_required");
|
||||||
|
if (!["tank", "healer", "damage"].includes(role)) {
|
||||||
|
throw new GameDatabaseError("Choose a valid party role.", 400, "invalid_party_role");
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_group_members SET character_json = ?, role = ?
|
||||||
|
WHERE group_id = ? AND account_id = ?
|
||||||
|
`).run(JSON.stringify(normalizeGroupCharacter(character)), role, group.id, accountId);
|
||||||
|
database.prepare("UPDATE online_groups SET updated_at = ? WHERE id = ?").run(Date.now(), group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOnlineGroupActivity(accountId, input) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group || group.leader_account_id !== accountId) {
|
||||||
|
throw new GameDatabaseError("Only the group leader can start an activity.", 403, "leader_required");
|
||||||
|
}
|
||||||
|
const type = input?.type;
|
||||||
|
if (type !== "dungeon" && type !== "manastorm") {
|
||||||
|
throw new GameDatabaseError("Choose a Dungeon or Manastorm.", 400, "invalid_activity_type");
|
||||||
|
}
|
||||||
|
const contentId = String(input?.contentId ?? "").trim().slice(0, 120);
|
||||||
|
if (!contentId) throw new GameDatabaseError("Choose content before starting.", 400, "missing_content");
|
||||||
|
const members = database.prepare(`
|
||||||
|
SELECT account_id, character_json, role FROM online_group_members WHERE group_id = ? ORDER BY joined_at
|
||||||
|
`).all(group.id);
|
||||||
|
if (members.some((member) => !member.character_json || !["tank", "healer", "damage"].includes(member.role))) {
|
||||||
|
throw new GameDatabaseError("Every player must select a character and party role before the group can start.", 409, "group_not_ready");
|
||||||
|
}
|
||||||
|
const fillWithAi = input?.fillWithAi !== false;
|
||||||
|
const requestedSize = Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5)));
|
||||||
|
const partySize = fillWithAi ? Math.max(members.length, requestedSize) : members.length;
|
||||||
|
const activity = {
|
||||||
|
id: `activity-${randomUUID()}`,
|
||||||
|
type,
|
||||||
|
contentId,
|
||||||
|
fillWithAi,
|
||||||
|
partySize,
|
||||||
|
startingLevel: type === "manastorm"
|
||||||
|
? Math.max(1, Math.trunc(Number(input?.startingLevel) || 1))
|
||||||
|
: null,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
};
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups
|
||||||
|
SET activity_json = ?, activity_revision = activity_revision + 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(JSON.stringify(activity), activity.startedAt, group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOnlineGroupActivity(accountId) {
|
||||||
|
const group = groupMembership(accountId);
|
||||||
|
if (!group || group.leader_account_id !== accountId) {
|
||||||
|
throw new GameDatabaseError("Only the group leader can reset the activity.", 403, "leader_required");
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
||||||
|
`).run(Date.now(), group.id);
|
||||||
|
return onlineGroupState(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
databasePath,
|
databasePath,
|
||||||
register,
|
register,
|
||||||
@@ -264,6 +568,14 @@ export function openGameDatabase(options = {}) {
|
|||||||
isGameMaster,
|
isGameMaster,
|
||||||
getManastormConfig,
|
getManastormConfig,
|
||||||
putManastormConfig,
|
putManastormConfig,
|
||||||
|
onlineGroupState,
|
||||||
|
inviteOnlineGroupMember,
|
||||||
|
respondToOnlineGroupInvite,
|
||||||
|
leaveOnlineGroup,
|
||||||
|
removeOnlineGroupMember,
|
||||||
|
updateOnlineGroupMember,
|
||||||
|
startOnlineGroupActivity,
|
||||||
|
clearOnlineGroupActivity,
|
||||||
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
||||||
close: () => database.close(),
|
close: () => database.close(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -122,3 +122,60 @@ test("persists revisioned Manastorm GM configuration and rejects stale or unauth
|
|||||||
bosses: { "boss:incomplete": { status: "ready" } },
|
bosses: { "boss:incomplete": { status: "ready" } },
|
||||||
}), (error) => error instanceof GameDatabaseError && error.status === 400);
|
}), (error) => error instanceof GameDatabaseError && error.status === 400);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
test("creates online groups through invitations and transfers leadership when the leader leaves", () => withDatabase(async (database) => {
|
||||||
|
const leaderAuth = await database.register("GroupLeader", "group-pass");
|
||||||
|
const healerAuth = await database.register("GroupHealer", "healer-pass");
|
||||||
|
const leader = database.authenticate(leaderAuth.token);
|
||||||
|
const healer = database.authenticate(healerAuth.token);
|
||||||
|
|
||||||
|
const invitation = database.inviteOnlineGroupMember(leader, "grouphealer");
|
||||||
|
assert.equal(invitation.state.group.members.length, 1);
|
||||||
|
const incoming = database.onlineGroupState(healer.id).invitations;
|
||||||
|
assert.equal(incoming.length, 1);
|
||||||
|
assert.equal(incoming[0].inviterUsername, "GroupLeader");
|
||||||
|
|
||||||
|
const accepted = database.respondToOnlineGroupInvite(healer.id, incoming[0].id, true);
|
||||||
|
assert.equal(accepted.group.members.length, 2);
|
||||||
|
assert.equal(accepted.group.leaderAccountId, leader.id);
|
||||||
|
|
||||||
|
database.leaveOnlineGroup(leader.id);
|
||||||
|
const transferred = database.onlineGroupState(healer.id);
|
||||||
|
assert.equal(transferred.group.leaderAccountId, healer.id);
|
||||||
|
assert.deepEqual(transferred.group.members.map((member) => member.username), ["GroupHealer"]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("launches players-only and AI-filled group activities only when every member is ready", () => withDatabase(async (database) => {
|
||||||
|
const leaderAuth = await database.register("ReadyLeader", "group-pass");
|
||||||
|
const memberAuth = await database.register("ReadyMember", "member-pass");
|
||||||
|
const leader = database.authenticate(leaderAuth.token);
|
||||||
|
const member = database.authenticate(memberAuth.token);
|
||||||
|
database.inviteOnlineGroupMember(leader, member.username);
|
||||||
|
const invite = database.onlineGroupState(member.id).invitations[0];
|
||||||
|
database.respondToOnlineGroupInvite(member.id, invite.id, true);
|
||||||
|
|
||||||
|
database.updateOnlineGroupMember(leader.id, {
|
||||||
|
id: "leader-character", name: "Leadwell", classId: "warrior", raceId: "human", gender: "male", level: 20,
|
||||||
|
}, "tank");
|
||||||
|
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
||||||
|
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
||||||
|
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
|
||||||
|
|
||||||
|
database.updateOnlineGroupMember(member.id, {
|
||||||
|
id: "member-character", name: "Mendwell", classId: "priest", raceId: "human", gender: "female", level: 20,
|
||||||
|
}, "healer");
|
||||||
|
const playersOnly = database.startOnlineGroupActivity(leader.id, {
|
||||||
|
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
|
||||||
|
});
|
||||||
|
assert.equal(playersOnly.group.activity.partySize, 2);
|
||||||
|
assert.equal(playersOnly.group.activity.fillWithAi, false);
|
||||||
|
|
||||||
|
const filled = database.startOnlineGroupActivity(leader.id, {
|
||||||
|
type: "manastorm", contentId: "manastorm", fillWithAi: true, partySize: 5, startingLevel: 10,
|
||||||
|
});
|
||||||
|
assert.equal(filled.group.activity.partySize, 5);
|
||||||
|
assert.equal(filled.group.activity.startingLevel, 10);
|
||||||
|
assert.throws(() => database.startOnlineGroupActivity(member.id, {
|
||||||
|
type: "dungeon", contentId: "wailing-caverns",
|
||||||
|
}), (error) => error instanceof GameDatabaseError && error.status === 403);
|
||||||
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { GameDatabaseError } from "./database.mjs";
|
||||||
|
|
||||||
|
const PLAYER_TIMEOUT_MS = 15_000;
|
||||||
|
const ROOM_TIMEOUT_MS = 30 * 60_000;
|
||||||
|
const MAX_EVENTS = 256;
|
||||||
|
const MAX_BATCH_EVENTS = 48;
|
||||||
|
const ALLOWED_EVENT_KINDS = new Set([
|
||||||
|
"damage",
|
||||||
|
"healing",
|
||||||
|
"resurrection",
|
||||||
|
"loot",
|
||||||
|
"portal",
|
||||||
|
]);
|
||||||
|
const ALLOWED_SCHOOLS = new Set([
|
||||||
|
"physical",
|
||||||
|
"holy",
|
||||||
|
"fire",
|
||||||
|
"nature",
|
||||||
|
"frost",
|
||||||
|
"shadow",
|
||||||
|
"arcane",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function finite(value, fallback = 0) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedString(value, maximum = 160) {
|
||||||
|
return String(value ?? "").slice(0, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vector3(value) {
|
||||||
|
if (!Array.isArray(value) || value.length < 3) return [0, 0, 0];
|
||||||
|
return value.slice(0, 3).map((entry) => Math.max(-100_000, Math.min(100_000, finite(entry))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonClone(value, fallback = null) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireActivity(account, snapshot) {
|
||||||
|
const group = snapshot?.group;
|
||||||
|
const activity = group?.activity;
|
||||||
|
const member = group?.members?.find((candidate) => candidate.accountId === account.id);
|
||||||
|
if (!group || !activity || !member) {
|
||||||
|
throw new GameDatabaseError("The shared activity is no longer active.", 409, "online_activity_required");
|
||||||
|
}
|
||||||
|
return { group, activity, member };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePresence(input, member, now) {
|
||||||
|
const maximumHealth = Math.max(1, Math.min(1_000_000_000, Math.round(finite(input?.maxHealth, 1))));
|
||||||
|
const maximumResource = Math.max(0, Math.min(1_000_000_000, Math.round(finite(input?.maxResource))));
|
||||||
|
return {
|
||||||
|
accountId: member.accountId,
|
||||||
|
username: member.username,
|
||||||
|
character: member.character,
|
||||||
|
role: member.role,
|
||||||
|
position: vector3(input?.position),
|
||||||
|
yaw: Math.max(-Math.PI * 8, Math.min(Math.PI * 8, finite(input?.yaw))),
|
||||||
|
grounded: input?.grounded !== false,
|
||||||
|
health: Math.max(0, Math.min(maximumHealth, Math.round(finite(input?.health, maximumHealth)))),
|
||||||
|
maxHealth: maximumHealth,
|
||||||
|
resource: Math.max(0, Math.min(maximumResource, finite(input?.resource))),
|
||||||
|
maxResource: maximumResource,
|
||||||
|
resourceName: boundedString(input?.resourceName, 40),
|
||||||
|
selectedTargetId: input?.selectedTargetId ? boundedString(input.selectedTargetId) : null,
|
||||||
|
activeCast: input?.activeCast && typeof input.activeCast === "object"
|
||||||
|
? jsonClone(input.activeCast)
|
||||||
|
: null,
|
||||||
|
animationEvent: input?.animationEvent && typeof input.animationEvent === "object"
|
||||||
|
? jsonClone(input.animationEvent)
|
||||||
|
: null,
|
||||||
|
equipment: Array.isArray(input?.equipment)
|
||||||
|
? jsonClone(input.equipment.slice(0, 24), [])
|
||||||
|
: [],
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEvent(input, accountId, leaderAccountId) {
|
||||||
|
const kind = boundedString(input?.kind, 32);
|
||||||
|
if (!ALLOWED_EVENT_KINDS.has(kind)) return null;
|
||||||
|
const targetActorId = boundedString(input?.targetActorId);
|
||||||
|
if (!targetActorId && kind !== "portal") return null;
|
||||||
|
const sourceActorId = boundedString(input?.sourceActorId);
|
||||||
|
const localPlayerActorId = `online-player:${accountId}`;
|
||||||
|
// Non-leaders may submit only their own player actions. Enemy and AI events
|
||||||
|
// are produced by the activity authority and cannot be forged by members.
|
||||||
|
if (accountId !== leaderAccountId && sourceActorId !== localPlayerActorId) return null;
|
||||||
|
const school = boundedString(input?.school, 24);
|
||||||
|
return {
|
||||||
|
clientEventId: boundedString(input?.clientEventId, 200),
|
||||||
|
kind,
|
||||||
|
sourceActorId: sourceActorId || localPlayerActorId,
|
||||||
|
targetActorId,
|
||||||
|
abilityId: input?.abilityId ? boundedString(input.abilityId) : null,
|
||||||
|
school: ALLOWED_SCHOOLS.has(school) ? school : null,
|
||||||
|
rawAmount: Math.max(0, Math.min(1_000_000_000, finite(input?.rawAmount))),
|
||||||
|
effectiveAmount: Math.max(0, Math.min(1_000_000_000, finite(input?.effectiveAmount))),
|
||||||
|
critical: input?.critical === true,
|
||||||
|
occurredAt: Math.max(0, Math.trunc(finite(input?.occurredAt, Date.now()))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOnlineSessionManager() {
|
||||||
|
const rooms = new Map();
|
||||||
|
|
||||||
|
function prune(now) {
|
||||||
|
for (const [activityId, room] of rooms) {
|
||||||
|
if (now - room.updatedAt > ROOM_TIMEOUT_MS) rooms.delete(activityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomFor(group, activity, now) {
|
||||||
|
prune(now);
|
||||||
|
let room = rooms.get(activity.id);
|
||||||
|
if (!room || room.groupId !== group.id) {
|
||||||
|
room = {
|
||||||
|
activityId: activity.id,
|
||||||
|
groupId: group.id,
|
||||||
|
revision: 0,
|
||||||
|
nextEventId: 1,
|
||||||
|
players: new Map(),
|
||||||
|
events: [],
|
||||||
|
eventKeys: new Set(),
|
||||||
|
world: null,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
rooms.set(activity.id, room);
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync(account, groupSnapshot, input = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
const { group, activity, member } = requireActivity(account, groupSnapshot);
|
||||||
|
const room = roomFor(group, activity, now);
|
||||||
|
const presence = sanitizePresence(input.presence, member, now);
|
||||||
|
room.players.set(account.id, presence);
|
||||||
|
room.revision += 1;
|
||||||
|
room.updatedAt = now;
|
||||||
|
|
||||||
|
if (input.world !== undefined) {
|
||||||
|
if (group.leaderAccountId !== account.id) {
|
||||||
|
throw new GameDatabaseError("Only the activity leader can publish shared world state.", 403, "activity_authority_required");
|
||||||
|
}
|
||||||
|
if (!input.world || typeof input.world !== "object" || Array.isArray(input.world)) {
|
||||||
|
throw new GameDatabaseError("Shared world state must be an object.", 400, "invalid_world_state");
|
||||||
|
}
|
||||||
|
room.world = {
|
||||||
|
...jsonClone(input.world, {}),
|
||||||
|
publishedAt: now,
|
||||||
|
publishedBy: account.id,
|
||||||
|
};
|
||||||
|
room.revision += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = Array.isArray(input.events) ? input.events.slice(0, MAX_BATCH_EVENTS) : [];
|
||||||
|
for (const candidate of batch) {
|
||||||
|
const event = sanitizeEvent(candidate, account.id, group.leaderAccountId);
|
||||||
|
if (!event?.clientEventId) continue;
|
||||||
|
const eventKey = `${account.id}:${event.clientEventId}`;
|
||||||
|
if (room.eventKeys.has(eventKey)) continue;
|
||||||
|
room.eventKeys.add(eventKey);
|
||||||
|
room.events.push({
|
||||||
|
...event,
|
||||||
|
id: room.nextEventId,
|
||||||
|
sourceAccountId: account.id,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
room.nextEventId += 1;
|
||||||
|
room.revision += 1;
|
||||||
|
}
|
||||||
|
if (room.events.length > MAX_EVENTS) {
|
||||||
|
const removed = room.events.splice(0, room.events.length - MAX_EVENTS);
|
||||||
|
for (const event of removed) room.eventKeys.delete(`${event.sourceAccountId}:${event.clientEventId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberIds = new Set(group.members.map((candidate) => candidate.accountId));
|
||||||
|
for (const [accountId, player] of room.players) {
|
||||||
|
if (!memberIds.has(accountId) || now - player.updatedAt > PLAYER_TIMEOUT_MS) {
|
||||||
|
room.players.delete(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const afterEventId = Math.max(0, Math.trunc(finite(input.afterEventId)));
|
||||||
|
return {
|
||||||
|
activity,
|
||||||
|
groupId: group.id,
|
||||||
|
leaderAccountId: group.leaderAccountId,
|
||||||
|
localAccountId: account.id,
|
||||||
|
authority: group.leaderAccountId === account.id,
|
||||||
|
revision: room.revision,
|
||||||
|
serverTime: now,
|
||||||
|
players: [...room.players.values()].sort((left, right) => left.accountId.localeCompare(right.accountId)),
|
||||||
|
world: room.world,
|
||||||
|
events: room.events.filter((event) => event.id > afterEventId),
|
||||||
|
latestEventId: room.nextEventId - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sync,
|
||||||
|
clear: () => rooms.clear(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { GameDatabaseError } from "./database.mjs";
|
||||||
|
import { createOnlineSessionManager } from "./online-session.mjs";
|
||||||
|
|
||||||
|
function groupSnapshot() {
|
||||||
|
return {
|
||||||
|
invitations: [],
|
||||||
|
group: {
|
||||||
|
id: "group-one",
|
||||||
|
leaderAccountId: "leader",
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
accountId: "leader",
|
||||||
|
username: "Leader",
|
||||||
|
character: { id: "lead", name: "Bulwark", classId: "warrior", raceId: "human", gender: "male", level: 20 },
|
||||||
|
role: "tank",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: "healer",
|
||||||
|
username: "Healer",
|
||||||
|
character: { id: "heal", name: "Mendara", classId: "priest", raceId: "human", gender: "female", level: 20 },
|
||||||
|
role: "healer",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activity: {
|
||||||
|
id: "activity-one",
|
||||||
|
type: "dungeon",
|
||||||
|
contentId: "wailing-caverns",
|
||||||
|
fillWithAi: false,
|
||||||
|
partySize: 2,
|
||||||
|
startingLevel: null,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const presence = {
|
||||||
|
position: [1, 2, 3],
|
||||||
|
yaw: 0.5,
|
||||||
|
health: 900,
|
||||||
|
maxHealth: 1_000,
|
||||||
|
resource: 400,
|
||||||
|
maxResource: 500,
|
||||||
|
resourceName: "Mana",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("synchronizes player presence, leader world state, and ordered combat events", () => {
|
||||||
|
const manager = createOnlineSessionManager();
|
||||||
|
const snapshot = groupSnapshot();
|
||||||
|
const leader = manager.sync({ id: "leader" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
world: { mobs: { boss: { health: 500, maxHealth: 500 } }, encounter: { phase: "boss" } },
|
||||||
|
});
|
||||||
|
assert.equal(leader.authority, true);
|
||||||
|
assert.equal(leader.players[0].character.name, "Bulwark");
|
||||||
|
assert.equal(leader.world.mobs.boss.health, 500);
|
||||||
|
|
||||||
|
const healer = manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence: { ...presence, position: [4, 5, 6] },
|
||||||
|
afterEventId: 0,
|
||||||
|
events: [{
|
||||||
|
clientEventId: "heal:1",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: "online-player:healer",
|
||||||
|
targetActorId: "boss",
|
||||||
|
school: "holy",
|
||||||
|
rawAmount: 75,
|
||||||
|
effectiveAmount: 60,
|
||||||
|
occurredAt: 123,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(healer.players.length, 2);
|
||||||
|
assert.equal(healer.events.length, 1);
|
||||||
|
assert.equal(healer.events[0].id, 1);
|
||||||
|
assert.equal(healer.events[0].sourceAccountId, "healer");
|
||||||
|
|
||||||
|
const deduplicated = manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
afterEventId: 1,
|
||||||
|
events: [{
|
||||||
|
clientEventId: "heal:1",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: "online-player:healer",
|
||||||
|
targetActorId: "boss",
|
||||||
|
rawAmount: 75,
|
||||||
|
effectiveAmount: 60,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(deduplicated.events.length, 0);
|
||||||
|
assert.equal(deduplicated.latestEventId, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-leader world updates and forged enemy events", () => {
|
||||||
|
const manager = createOnlineSessionManager();
|
||||||
|
const snapshot = groupSnapshot();
|
||||||
|
assert.throws(() => manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
world: { mobs: {} },
|
||||||
|
}), (error) => error instanceof GameDatabaseError && error.code === "activity_authority_required");
|
||||||
|
|
||||||
|
const state = manager.sync({ id: "healer" }, snapshot, {
|
||||||
|
presence,
|
||||||
|
events: [{
|
||||||
|
clientEventId: "forged",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: "boss",
|
||||||
|
targetActorId: "online-player:leader",
|
||||||
|
rawAmount: 1_000_000,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(state.events.length, 0);
|
||||||
|
});
|
||||||
+58
-1
@@ -3,6 +3,7 @@ import { createServer } from "node:http";
|
|||||||
import path from "node:path";
|
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 { createOnlineSessionManager } from "./online-session.mjs";
|
||||||
|
|
||||||
const MIME_TYPES = new Map([
|
const MIME_TYPES = new Map([
|
||||||
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
|
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
|
||||||
@@ -148,6 +149,7 @@ export function createGameServer(options = {}) {
|
|||||||
const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024));
|
const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024));
|
||||||
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
|
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
|
||||||
const gameDatabase = options.database ?? openGameDatabase(options);
|
const gameDatabase = options.database ?? openGameDatabase(options);
|
||||||
|
const onlineSessions = createOnlineSessionManager();
|
||||||
const allowAuthRequest = createAuthLimiter();
|
const allowAuthRequest = createAuthLimiter();
|
||||||
gameDatabase.pruneExpiredSessions();
|
gameDatabase.pruneExpiredSessions();
|
||||||
|
|
||||||
@@ -209,6 +211,58 @@ export function createGameServer(options = {}) {
|
|||||||
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/api/online-group" && request.method === "GET") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
json(response, 200, gameDatabase.onlineGroupState(account.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/invite" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 201, gameDatabase.inviteOnlineGroupMember(account, body.username));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/invite/respond" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.respondToOnlineGroupInvite(account.id, body.inviteId, body.accept === true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/leave" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
json(response, 200, gameDatabase.leaveOnlineGroup(account.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/remove" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.removeOnlineGroupMember(account.id, body.accountId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/member" && request.method === "PUT") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.updateOnlineGroupMember(account.id, body.character, body.role));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/activity" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, 64 * 1024);
|
||||||
|
json(response, 200, gameDatabase.startOnlineGroupActivity(account.id, body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-group/activity/clear" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
json(response, 200, gameDatabase.clearOnlineGroupActivity(account.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/online-session/sync" && request.method === "POST") {
|
||||||
|
const account = gameDatabase.authenticate(bearerToken(request));
|
||||||
|
const body = await readJson(request, bodyLimit);
|
||||||
|
const groupSnapshot = gameDatabase.onlineGroupState(account.id);
|
||||||
|
json(response, 200, onlineSessions.sync(account, groupSnapshot, body));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
|
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
|
||||||
const current = gameDatabase.getManastormConfig();
|
const current = gameDatabase.getManastormConfig();
|
||||||
json(response, 200, {
|
json(response, 200, {
|
||||||
@@ -259,7 +313,10 @@ export function createGameServer(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("close", () => gameDatabase.close());
|
server.on("close", () => {
|
||||||
|
onlineSessions.clear();
|
||||||
|
gameDatabase.close();
|
||||||
|
});
|
||||||
return { server, database: gameDatabase, staticDir, contentDir };
|
return { server, database: gameDatabase, staticDir, contentDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,84 @@ test("serves the game and authenticated cloud-save API", async () => {
|
|||||||
});
|
});
|
||||||
assert.equal(denied.status, 403);
|
assert.equal(denied.status, 403);
|
||||||
|
|
||||||
|
const memberRegistration = await fetch(`${origin}/api/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "WebTank", password: "tanking-pass" }),
|
||||||
|
});
|
||||||
|
const memberAuth = await memberRegistration.json();
|
||||||
|
const invited = await fetch(`${origin}/api/online-group/invite`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||||
|
body: JSON.stringify({ username: "WebTank" }),
|
||||||
|
});
|
||||||
|
assert.equal(invited.status, 201);
|
||||||
|
const memberGroupState = await (await fetch(`${origin}/api/online-group`, {
|
||||||
|
headers: { Authorization: `Bearer ${memberAuth.token}` },
|
||||||
|
})).json();
|
||||||
|
assert.equal(memberGroupState.invitations.length, 1);
|
||||||
|
const accepted = await fetch(`${origin}/api/online-group/invite/respond`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${memberAuth.token}` },
|
||||||
|
body: JSON.stringify({ inviteId: memberGroupState.invitations[0].id, accept: true }),
|
||||||
|
});
|
||||||
|
assert.equal(accepted.status, 200);
|
||||||
|
assert.equal((await accepted.json()).group.members.length, 2);
|
||||||
|
|
||||||
|
for (const [token, character, role] of [
|
||||||
|
[auth.token, { id: "web-healer", name: "Mendara", classId: "priest", raceId: "human", gender: "female", level: 20 }, "healer"],
|
||||||
|
[memberAuth.token, { id: "web-tank", name: "Bulwarka", classId: "warrior", raceId: "human", gender: "male", level: 20 }, "tank"],
|
||||||
|
]) {
|
||||||
|
const ready = await fetch(`${origin}/api/online-group/member`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ character, role }),
|
||||||
|
});
|
||||||
|
assert.equal(ready.status, 200);
|
||||||
|
}
|
||||||
|
const activityResponse = await fetch(`${origin}/api/online-group/activity`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: "dungeon",
|
||||||
|
contentId: "wailing-caverns",
|
||||||
|
fillWithAi: false,
|
||||||
|
partySize: 2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(activityResponse.status, 200);
|
||||||
|
const leaderSync = await fetch(`${origin}/api/online-session/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
afterEventId: 0,
|
||||||
|
presence: { position: [1, 0, 2], health: 900, maxHealth: 1_000 },
|
||||||
|
world: { mobs: { boss: { health: 500 } }, mobTransforms: {} },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(leaderSync.status, 200);
|
||||||
|
assert.equal((await leaderSync.json()).authority, true);
|
||||||
|
const memberSync = await fetch(`${origin}/api/online-session/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${memberAuth.token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
afterEventId: 0,
|
||||||
|
presence: { position: [3, 0, 4], health: 800, maxHealth: 1_000 },
|
||||||
|
events: [{
|
||||||
|
clientEventId: "web-tank-hit-1",
|
||||||
|
kind: "damage",
|
||||||
|
sourceActorId: `online-player:${memberAuth.account.id}`,
|
||||||
|
targetActorId: "boss",
|
||||||
|
rawAmount: 50,
|
||||||
|
effectiveAmount: 45,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(memberSync.status, 200);
|
||||||
|
const memberSession = await memberSync.json();
|
||||||
|
assert.equal(memberSession.players.length, 2);
|
||||||
|
assert.equal(memberSession.events[0].targetActorId, "boss");
|
||||||
|
|
||||||
const gmRegistration = await fetch(`${origin}/api/auth/register`, {
|
const gmRegistration = await fetch(`${origin}/api/auth/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
+12
-14
@@ -3,6 +3,7 @@ import { useShellStore } from "./app/shellStore";
|
|||||||
import { useManastormAdminStore } from "./game/manastormAdminStore";
|
import { useManastormAdminStore } from "./game/manastormAdminStore";
|
||||||
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
|
||||||
import { LoginScreen } from "./ui/LoginScreen";
|
import { LoginScreen } from "./ui/LoginScreen";
|
||||||
|
import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator";
|
||||||
|
|
||||||
const GameRuntime = lazy(() => import("./GameRuntime"));
|
const GameRuntime = lazy(() => import("./GameRuntime"));
|
||||||
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
|
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
|
||||||
@@ -28,18 +29,15 @@ export default function App() {
|
|||||||
void refreshManastormConfig();
|
void refreshManastormConfig();
|
||||||
}, [refreshManastormConfig, sessionOwnerId]);
|
}, [refreshManastormConfig, sessionOwnerId]);
|
||||||
|
|
||||||
if (phase === "login") return <LoginScreen />;
|
let screen = null;
|
||||||
if (phase === "characters") return <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
|
if (phase === "login") screen = <LoginScreen />;
|
||||||
if (phase === "create-character") return <Suspense fallback={<ShellTransition />}><CharacterCreateScreen /></Suspense>;
|
else if (phase === "characters") screen = <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
|
||||||
if (phase === "main-menu") return <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
|
else if (phase === "create-character") screen = <Suspense fallback={<ShellTransition />}><CharacterCreateScreen /></Suspense>;
|
||||||
if (phase === "class-hall") return <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
else if (phase === "main-menu") screen = <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
|
||||||
if (phase === "dungeons") return <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||||
if (phase === "manastorms") return <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
||||||
if (phase === "gm-admin") return <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
|
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
|
||||||
if (phase !== "game") return null;
|
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
|
||||||
return (
|
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
|
||||||
<Suspense fallback={<ShellTransition />}>
|
return <><OnlineGroupCoordinator />{screen}</>;
|
||||||
<GameRuntime />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { MapPanel } from "./ui/MapPanel";
|
|||||||
import { PauseMenu } from "./ui/PauseMenu";
|
import { PauseMenu } from "./ui/PauseMenu";
|
||||||
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
|
||||||
import { GmWorldEditor } from "./ui/GmWorldEditor";
|
import { GmWorldEditor } from "./ui/GmWorldEditor";
|
||||||
|
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
|
||||||
|
|
||||||
function InputBridge() {
|
function InputBridge() {
|
||||||
const overlay = useGameStore((state) => state.overlay);
|
const overlay = useGameStore((state) => state.overlay);
|
||||||
@@ -51,6 +52,9 @@ function InputBridge() {
|
|||||||
useCombatStore.getState().clearTarget();
|
useCombatStore.getState().clearTarget();
|
||||||
usePartyStore.getState().clearSelection();
|
usePartyStore.getState().clearSelection();
|
||||||
},
|
},
|
||||||
|
groundTargetActive: () => useCombatStore.getState().pendingGroundTarget !== null,
|
||||||
|
confirmGroundTarget: () => { useCombatStore.getState().confirmGroundTarget(); },
|
||||||
|
cancelGroundTarget: () => useCombatStore.getState().cancelGroundTarget(),
|
||||||
gameplayActionsBlocked: () => {
|
gameplayActionsBlocked: () => {
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
return game.overlay !== null || game.companionOpen;
|
return game.overlay !== null || game.companionOpen;
|
||||||
@@ -81,6 +85,7 @@ export default function GameRuntime() {
|
|||||||
top={
|
top={
|
||||||
<main className="game-shell">
|
<main className="game-shell">
|
||||||
<InputBridge />
|
<InputBridge />
|
||||||
|
<OnlineSessionBridge />
|
||||||
<CombatBridge />
|
<CombatBridge />
|
||||||
<PartyBridge />
|
<PartyBridge />
|
||||||
<ManastormBridge />
|
<ManastormBridge />
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { selectedCharacter, useShellStore } from "./shellStore";
|
||||||
|
import { useOnlineGroupStore } from "./onlineGroupStore";
|
||||||
|
import {
|
||||||
|
classCanFillPartyRole,
|
||||||
|
defaultPartyRoleForClass,
|
||||||
|
type PartyRole,
|
||||||
|
} from "../game/partyRoles";
|
||||||
|
import { isManastormPartySize } from "../game/manastormProgress";
|
||||||
|
|
||||||
|
const GROUP_POLL_MS = 2_500;
|
||||||
|
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export function OnlineGroupCoordinator() {
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const character = useShellStore(selectedCharacter);
|
||||||
|
const phase = useShellStore((state) => state.phase);
|
||||||
|
const snapshot = useOnlineGroupStore((state) => state.snapshot);
|
||||||
|
const consumedActivityIds = useOnlineGroupStore((state) => state.consumedActivityIds);
|
||||||
|
const refresh = useOnlineGroupStore((state) => state.refresh);
|
||||||
|
const updateMember = useOnlineGroupStore((state) => state.updateMember);
|
||||||
|
const consumeActivity = useOnlineGroupStore((state) => state.consumeActivity);
|
||||||
|
const reset = useOnlineGroupStore((state) => state.reset);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || session.kind !== "account" || !session.accessToken) {
|
||||||
|
reset();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
void refresh(session);
|
||||||
|
const timer = window.setInterval(() => void refresh(session, true), GROUP_POLL_MS);
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === "visible") void refresh(session, true);
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
};
|
||||||
|
}, [refresh, reset, session?.accessToken, session?.ownerId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const group = snapshot.group;
|
||||||
|
if (!session || !character || !group || group.activity) return;
|
||||||
|
const member = group.members.find((candidate) => candidate.accountId === session.ownerId);
|
||||||
|
if (!member) return;
|
||||||
|
const role: PartyRole = member.role && classCanFillPartyRole(character.classId, member.role)
|
||||||
|
? member.role
|
||||||
|
: defaultPartyRoleForClass(character.classId);
|
||||||
|
const characterChanged = member.character?.id !== character.id
|
||||||
|
|| member.character?.level !== character.level
|
||||||
|
|| member.character?.classId !== character.classId;
|
||||||
|
if (!characterChanged && member.role === role) return;
|
||||||
|
void updateMember(session, character, role);
|
||||||
|
}, [character?.classId, character?.id, character?.level, session?.ownerId, snapshot.group?.id, snapshot.group?.updatedAt, updateMember]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activity = snapshot.group?.activity;
|
||||||
|
if (!session || !activity || phase === "game" || consumedActivityIds.includes(activity.id)) return;
|
||||||
|
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return;
|
||||||
|
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
|
||||||
|
if (!member?.character || !member.role) return;
|
||||||
|
consumeActivity(activity.id);
|
||||||
|
void (async () => {
|
||||||
|
if (activity.type === "dungeon") {
|
||||||
|
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
|
||||||
|
installDungeonShellRuntime();
|
||||||
|
useShellStore.getState().enterDungeon(activity.contentId, member.role ?? undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
|
||||||
|
installManastormShellRuntime();
|
||||||
|
const partySize = isManastormPartySize(activity.partySize) ? activity.partySize : 5;
|
||||||
|
useShellStore.getState().enterManastorm(
|
||||||
|
member.role ?? undefined,
|
||||||
|
partySize,
|
||||||
|
activity.startingLevel ?? undefined,
|
||||||
|
);
|
||||||
|
})().catch((error) => {
|
||||||
|
useShellStore.getState().setNotice(error instanceof Error ? error.message : "The group activity could not be opened.");
|
||||||
|
});
|
||||||
|
}, [consumeActivity, consumedActivityIds, phase, session?.ownerId, snapshot.group?.activity?.id]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
type AccountResult,
|
type AccountResult,
|
||||||
} from "./accountRepository";
|
} from "./accountRepository";
|
||||||
import type { CharacterProfile, PlayerSession } from "./types";
|
import type { CharacterProfile, PlayerSession } from "./types";
|
||||||
|
import type { OnlineGroupSnapshot, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
|
||||||
|
import type { OnlineSessionSnapshot, OnlineSessionSyncInput } from "./onlineSessionTypes";
|
||||||
|
|
||||||
const NATIVE_API_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
const NATIVE_API_ORIGIN = "https://iwanttoheal.phenomrom.com";
|
||||||
const SAVE_DEBOUNCE_MS = 500;
|
const SAVE_DEBOUNCE_MS = 500;
|
||||||
@@ -159,3 +161,77 @@ export async function logoutOnlineSession(session: PlayerSession | null): Promis
|
|||||||
}
|
}
|
||||||
if (activeSession?.ownerId === session?.ownerId) activeSession = null;
|
if (activeSession?.ownerId === session?.ownerId) activeSession = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onlineGroupRequest<T>(session: PlayerSession, pathname: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
if (!session.accessToken) throw new OnlineApiError("Online grouping requires a signed-in account.", 401);
|
||||||
|
return requestOnlineJson<T>(pathname, init, session.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchOnlineGroup(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inviteOnlineGroupPlayer(session: PlayerSession, username: string): Promise<OnlineGroupSnapshot> {
|
||||||
|
const response = await onlineGroupRequest<{ state: OnlineGroupSnapshot }>(session, "/api/online-group/invite", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username }),
|
||||||
|
});
|
||||||
|
return response.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function respondToOnlineGroupInvite(
|
||||||
|
session: PlayerSession,
|
||||||
|
inviteId: string,
|
||||||
|
accept: boolean,
|
||||||
|
): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/invite/respond", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ inviteId, accept }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leaveOnlineGroup(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/leave", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeOnlineGroupPlayer(session: PlayerSession, accountId: string): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/remove", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ accountId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOnlineGroupMember(
|
||||||
|
session: PlayerSession,
|
||||||
|
character: CharacterProfile,
|
||||||
|
role: string,
|
||||||
|
): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/member", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ character, role }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOnlineGroupActivity(
|
||||||
|
session: PlayerSession,
|
||||||
|
activity: StartOnlineGroupActivityInput,
|
||||||
|
): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/activity", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(activity),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOnlineGroupActivity(session: PlayerSession): Promise<OnlineGroupSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-group/activity/clear", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function synchronizeOnlineSession(
|
||||||
|
session: PlayerSession,
|
||||||
|
input: OnlineSessionSyncInput,
|
||||||
|
): Promise<OnlineSessionSnapshot> {
|
||||||
|
return onlineGroupRequest(session, "/api/online-session/sync", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { onlineGroupAiPartySize, useOnlineGroupStore } from "./onlineGroupStore";
|
||||||
|
|
||||||
|
describe("online group AI fill sizing", () => {
|
||||||
|
beforeEach(() => useOnlineGroupStore.getState().reset());
|
||||||
|
|
||||||
|
it("reserves one simulated slot for every grouped human other than the local player", () => {
|
||||||
|
useOnlineGroupStore.setState({
|
||||||
|
snapshot: {
|
||||||
|
invitations: [],
|
||||||
|
group: {
|
||||||
|
id: "group-test",
|
||||||
|
leaderAccountId: "one",
|
||||||
|
activity: null,
|
||||||
|
activityRevision: 0,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
members: [
|
||||||
|
{ accountId: "one", username: "One", character: null, role: null, joinedAt: 1 },
|
||||||
|
{ accountId: "two", username: "Two", character: null, role: null, joinedAt: 2 },
|
||||||
|
{ accountId: "three", username: "Three", character: null, role: null, joinedAt: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onlineGroupAiPartySize(5)).toBe(3);
|
||||||
|
expect(onlineGroupAiPartySize(3)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps ordinary solo activity sizes unchanged", () => {
|
||||||
|
expect(onlineGroupAiPartySize(5)).toBe(5);
|
||||||
|
expect(onlineGroupAiPartySize(1)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { CharacterProfile, PlayerSession } from "./types";
|
||||||
|
import type { PartyRole } from "../game/partyRoles";
|
||||||
|
import type { PartySize } from "../game/partyRoles";
|
||||||
|
import {
|
||||||
|
clearOnlineGroupActivity,
|
||||||
|
fetchOnlineGroup,
|
||||||
|
inviteOnlineGroupPlayer,
|
||||||
|
leaveOnlineGroup,
|
||||||
|
removeOnlineGroupPlayer,
|
||||||
|
respondToOnlineGroupInvite,
|
||||||
|
startOnlineGroupActivity,
|
||||||
|
updateOnlineGroupMember,
|
||||||
|
} from "./onlineAccountClient";
|
||||||
|
import type { OnlineGroupActivity, OnlineGroupSnapshot, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
|
||||||
|
|
||||||
|
const EMPTY_SNAPSHOT: OnlineGroupSnapshot = Object.freeze({ group: null, invitations: Object.freeze([]) });
|
||||||
|
|
||||||
|
interface OnlineGroupState {
|
||||||
|
snapshot: OnlineGroupSnapshot;
|
||||||
|
loading: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
error: string;
|
||||||
|
fillWithAi: boolean;
|
||||||
|
activeActivity: OnlineGroupActivity | null;
|
||||||
|
consumedActivityIds: readonly string[];
|
||||||
|
refresh: (session: PlayerSession, silent?: boolean) => Promise<void>;
|
||||||
|
invite: (session: PlayerSession, username: string) => Promise<boolean>;
|
||||||
|
respond: (session: PlayerSession, inviteId: string, accept: boolean) => Promise<boolean>;
|
||||||
|
leave: (session: PlayerSession) => Promise<boolean>;
|
||||||
|
remove: (session: PlayerSession, accountId: string) => Promise<boolean>;
|
||||||
|
updateMember: (session: PlayerSession, character: CharacterProfile, role: PartyRole) => Promise<boolean>;
|
||||||
|
startActivity: (session: PlayerSession, input: Omit<StartOnlineGroupActivityInput, "fillWithAi">) => Promise<boolean>;
|
||||||
|
clearActivity: (session: PlayerSession) => Promise<boolean>;
|
||||||
|
setFillWithAi: (fillWithAi: boolean) => void;
|
||||||
|
consumeActivity: (activityId: string) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFor(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : "The online group could not be updated.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOnlineGroupStore = create<OnlineGroupState>((set, get) => ({
|
||||||
|
snapshot: EMPTY_SNAPSHOT,
|
||||||
|
loading: false,
|
||||||
|
busy: false,
|
||||||
|
error: "",
|
||||||
|
fillWithAi: true,
|
||||||
|
activeActivity: null,
|
||||||
|
consumedActivityIds: [],
|
||||||
|
|
||||||
|
refresh: async (session, silent = false) => {
|
||||||
|
if (session.kind !== "account" || !session.accessToken) {
|
||||||
|
set({ snapshot: EMPTY_SNAPSHOT, loading: false, busy: false, error: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!silent) set({ loading: true });
|
||||||
|
try {
|
||||||
|
const snapshot = await fetchOnlineGroup(session);
|
||||||
|
set({ snapshot, loading: false, error: "" });
|
||||||
|
} catch (error) {
|
||||||
|
set({ loading: false, error: messageFor(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
invite: async (session, username) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await inviteOnlineGroupPlayer(session, username);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
respond: async (session, inviteId, accept) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await respondToOnlineGroupInvite(session, inviteId, accept);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
leave: async (session) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await leaveOnlineGroup(session);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (session, accountId) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await removeOnlineGroupPlayer(session, accountId);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMember: async (session, character, role) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await updateOnlineGroupMember(session, character, role);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startActivity: async (session, input) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await startOnlineGroupActivity(session, { ...input, fillWithAi: get().fillWithAi });
|
||||||
|
set({ snapshot, busy: false });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearActivity: async (session) => {
|
||||||
|
set({ busy: true, error: "" });
|
||||||
|
try {
|
||||||
|
const snapshot = await clearOnlineGroupActivity(session);
|
||||||
|
set({ snapshot, busy: false, activeActivity: null });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
set({ busy: false, error: messageFor(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setFillWithAi: (fillWithAi) => set({ fillWithAi }),
|
||||||
|
consumeActivity: (activityId) => set((state) => {
|
||||||
|
if (state.consumedActivityIds.includes(activityId)) return state;
|
||||||
|
return {
|
||||||
|
activeActivity: state.snapshot.group?.activity?.id === activityId
|
||||||
|
? state.snapshot.group.activity
|
||||||
|
: state.activeActivity,
|
||||||
|
consumedActivityIds: [...state.consumedActivityIds.slice(-7), activityId],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
reset: () => set({
|
||||||
|
snapshot: EMPTY_SNAPSHOT,
|
||||||
|
loading: false,
|
||||||
|
busy: false,
|
||||||
|
error: "",
|
||||||
|
activeActivity: null,
|
||||||
|
consumedActivityIds: [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function onlineGroupHumanCount(): number {
|
||||||
|
return useOnlineGroupStore.getState().snapshot.group?.members.length ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineGroupAiPartySize(activityPartySize: number): PartySize {
|
||||||
|
return Math.max(1, Math.min(5, activityPartySize - Math.max(0, onlineGroupHumanCount() - 1))) as PartySize;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { PartyRole } from "../game/partyRoles";
|
||||||
|
import type { CharacterAppearance } from "./characterCatalog";
|
||||||
|
|
||||||
|
export interface OnlineGroupCharacter {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly classId: string;
|
||||||
|
readonly categoryId: string;
|
||||||
|
readonly raceId: string;
|
||||||
|
readonly gender: "female" | "male";
|
||||||
|
readonly level: number;
|
||||||
|
readonly appearance?: CharacterAppearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupMember {
|
||||||
|
readonly accountId: string;
|
||||||
|
readonly username: string;
|
||||||
|
readonly character: OnlineGroupCharacter | null;
|
||||||
|
readonly role: PartyRole | null;
|
||||||
|
readonly joinedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupActivity {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: "dungeon" | "manastorm";
|
||||||
|
readonly contentId: string;
|
||||||
|
readonly fillWithAi: boolean;
|
||||||
|
readonly partySize: number;
|
||||||
|
readonly startingLevel: number | null;
|
||||||
|
readonly startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroup {
|
||||||
|
readonly id: string;
|
||||||
|
readonly leaderAccountId: string;
|
||||||
|
readonly members: readonly OnlineGroupMember[];
|
||||||
|
readonly activity: OnlineGroupActivity | null;
|
||||||
|
readonly activityRevision: number;
|
||||||
|
readonly createdAt: number;
|
||||||
|
readonly updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupInvitation {
|
||||||
|
readonly id: string;
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly inviterAccountId: string;
|
||||||
|
readonly inviterUsername: string;
|
||||||
|
readonly createdAt: number;
|
||||||
|
readonly expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineGroupSnapshot {
|
||||||
|
readonly group: OnlineGroup | null;
|
||||||
|
readonly invitations: readonly OnlineGroupInvitation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartOnlineGroupActivityInput {
|
||||||
|
readonly type: OnlineGroupActivity["type"];
|
||||||
|
readonly contentId: string;
|
||||||
|
readonly fillWithAi: boolean;
|
||||||
|
readonly partySize: number;
|
||||||
|
readonly startingLevel?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { OnlineSessionSnapshot } from "./onlineSessionTypes";
|
||||||
|
|
||||||
|
interface OnlineSessionState {
|
||||||
|
snapshot: OnlineSessionSnapshot | null;
|
||||||
|
connected: boolean;
|
||||||
|
synchronizing: boolean;
|
||||||
|
error: string;
|
||||||
|
setSynchronizing: (synchronizing: boolean) => void;
|
||||||
|
accept: (snapshot: OnlineSessionSnapshot) => void;
|
||||||
|
fail: (message: string) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOnlineSessionStore = create<OnlineSessionState>((set) => ({
|
||||||
|
snapshot: null,
|
||||||
|
connected: false,
|
||||||
|
synchronizing: false,
|
||||||
|
error: "",
|
||||||
|
setSynchronizing: (synchronizing) => set({ synchronizing }),
|
||||||
|
accept: (snapshot) => set({ snapshot, connected: true, synchronizing: false, error: "" }),
|
||||||
|
fail: (error) => set({ connected: false, synchronizing: false, error }),
|
||||||
|
reset: () => set({ snapshot: null, connected: false, synchronizing: false, error: "" }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
||||||
|
import type { ActiveCast, MobCombatState } from "../game/combatStore";
|
||||||
|
import type { DamageSchool } from "../game/combatAuras";
|
||||||
|
import type { InventoryItem } from "../game/lootTypes";
|
||||||
|
import type { PartyMember } from "../game/partyStore";
|
||||||
|
import type { OnlineGroupActivity, OnlineGroupCharacter } from "./onlineGroupTypes";
|
||||||
|
import type { PartyRole } from "../game/partyRoles";
|
||||||
|
|
||||||
|
export type OnlineSessionVector = readonly [number, number, number];
|
||||||
|
|
||||||
|
export interface OnlineSessionPlayer {
|
||||||
|
readonly accountId: string;
|
||||||
|
readonly username: string;
|
||||||
|
readonly character: OnlineGroupCharacter | null;
|
||||||
|
readonly role: PartyRole | null;
|
||||||
|
readonly position: OnlineSessionVector;
|
||||||
|
readonly yaw: number;
|
||||||
|
readonly grounded: boolean;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly resource: number;
|
||||||
|
readonly maxResource: number;
|
||||||
|
readonly resourceName: string;
|
||||||
|
readonly selectedTargetId: string | null;
|
||||||
|
readonly activeCast: ActiveCast | null;
|
||||||
|
readonly animationEvent: CharacterCombatAnimationEvent | null;
|
||||||
|
readonly equipment: readonly InventoryItem[];
|
||||||
|
readonly updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineMobTransform {
|
||||||
|
readonly position: OnlineSessionVector;
|
||||||
|
readonly yaw: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineSharedWorld {
|
||||||
|
readonly mobs: Readonly<Record<string, MobCombatState>>;
|
||||||
|
readonly mobTransforms: Readonly<Record<string, OnlineMobTransform>>;
|
||||||
|
readonly partyMembers: readonly PartyMember[];
|
||||||
|
readonly partyPositions: Readonly<Record<string, OnlineSessionVector>>;
|
||||||
|
readonly partyRuntime: {
|
||||||
|
readonly command: string;
|
||||||
|
readonly activeMobId: string | null;
|
||||||
|
readonly objectiveBossId: string | null;
|
||||||
|
readonly memberTargetIds: Readonly<Record<string, string>>;
|
||||||
|
};
|
||||||
|
readonly wailing: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly manastorm: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly publishedAt?: number;
|
||||||
|
readonly publishedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OnlineRelayEventKind = "damage" | "healing" | "resurrection" | "loot" | "portal";
|
||||||
|
|
||||||
|
export interface OnlineRelayEventInput {
|
||||||
|
readonly clientEventId: string;
|
||||||
|
readonly kind: OnlineRelayEventKind;
|
||||||
|
readonly sourceActorId: string;
|
||||||
|
readonly targetActorId: string;
|
||||||
|
readonly abilityId?: string | null;
|
||||||
|
readonly school?: DamageSchool | null;
|
||||||
|
readonly rawAmount?: number;
|
||||||
|
readonly effectiveAmount?: number;
|
||||||
|
readonly critical?: boolean;
|
||||||
|
readonly occurredAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineRelayEvent extends OnlineRelayEventInput {
|
||||||
|
readonly id: number;
|
||||||
|
readonly sourceAccountId: string;
|
||||||
|
readonly createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineSessionSnapshot {
|
||||||
|
readonly activity: OnlineGroupActivity;
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly leaderAccountId: string;
|
||||||
|
readonly localAccountId: string;
|
||||||
|
readonly authority: boolean;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly serverTime: number;
|
||||||
|
readonly players: readonly OnlineSessionPlayer[];
|
||||||
|
readonly world: OnlineSharedWorld | null;
|
||||||
|
readonly events: readonly OnlineRelayEvent[];
|
||||||
|
readonly latestEventId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlinePresenceInput {
|
||||||
|
readonly position: OnlineSessionVector;
|
||||||
|
readonly yaw: number;
|
||||||
|
readonly grounded: boolean;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly resource: number;
|
||||||
|
readonly maxResource: number;
|
||||||
|
readonly resourceName: string;
|
||||||
|
readonly selectedTargetId: string | null;
|
||||||
|
readonly activeCast: ActiveCast | null;
|
||||||
|
readonly animationEvent: CharacterCombatAnimationEvent | null;
|
||||||
|
readonly equipment: readonly InventoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineSessionSyncInput {
|
||||||
|
readonly afterEventId: number;
|
||||||
|
readonly presence: OnlinePresenceInput;
|
||||||
|
readonly world?: OnlineSharedWorld;
|
||||||
|
readonly events?: readonly OnlineRelayEventInput[];
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import { useGameStore } from "./store";
|
|||||||
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
||||||
import { useManastormStore } from "./manastormStore";
|
import { useManastormStore } from "./manastormStore";
|
||||||
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||||
|
import {
|
||||||
|
onlineSessionIsActive,
|
||||||
|
onlineSessionOwnsWorldSimulation,
|
||||||
|
} from "./onlineSessionRuntime";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects the pure combat runtime to the active character and persistent
|
* Connects the pure combat runtime to the active character and persistent
|
||||||
@@ -21,6 +25,7 @@ export function CombatBridge() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!character) return;
|
if (!character) return;
|
||||||
useCombatStore.getState().initializeCharacter(character);
|
useCombatStore.getState().initializeCharacter(character);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
if (gameMode === "manastorm") {
|
if (gameMode === "manastorm") {
|
||||||
activateManastormActiveSpellLoadout(
|
activateManastormActiveSpellLoadout(
|
||||||
useManastormStore.getState().activeSpellLoadout,
|
useManastormStore.getState().activeSpellLoadout,
|
||||||
@@ -108,17 +113,25 @@ export function CombatBridge() {
|
|||||||
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
||||||
previous = now;
|
previous = now;
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
if (!game.paused && !game.companionOpen) {
|
const sharedOnline = onlineSessionIsActive();
|
||||||
|
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
|
||||||
const epochNow = Date.now();
|
const epochNow = Date.now();
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
combat.setPlayerPosition(game.playerPosition);
|
combat.setPlayerPosition(game.playerPosition);
|
||||||
combat.tick(delta, epochNow);
|
combat.tick(delta, epochNow);
|
||||||
if (game.gameMode === "dungeon") {
|
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
||||||
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
|
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
|
||||||
}
|
}
|
||||||
|
if (onlineSessionOwnsWorldSimulation()) {
|
||||||
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
||||||
advanceDungeonPartyCombat(epochNow);
|
advanceDungeonPartyCombat(epochNow);
|
||||||
if (game.gameMode === "dungeon" && game.activeDungeonId === "wailing-caverns") {
|
}
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
if (
|
||||||
|
onlineSessionOwnsWorldSimulation()
|
||||||
|
&& game.gameMode === "dungeon"
|
||||||
|
&& game.activeDungeonId === "wailing-caverns"
|
||||||
|
) {
|
||||||
useWailingEncounterStore.getState().advance(
|
useWailingEncounterStore.getState().advance(
|
||||||
useCombatStore.getState().mobs,
|
useCombatStore.getState().mobs,
|
||||||
epochNow,
|
epochNow,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getManastormCatalog, manastormEncounterById } from "./manastorm";
|
|||||||
import { synchronizeManastormProgress } from "./manastormProgressSync";
|
import { synchronizeManastormProgress } from "./manastormProgressSync";
|
||||||
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
|
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
|
||||||
import { preloadManastormStage } from "./manastormStageLoader";
|
import { preloadManastormStage } from "./manastormStageLoader";
|
||||||
|
import { onlineSessionIsActive, onlineSessionOwnsWorldSimulation } from "./onlineSessionRuntime";
|
||||||
|
|
||||||
/** Connects staged combat, party sizing, and shared resurrections to Manastorm state. */
|
/** Connects staged combat, party sizing, and shared resurrections to Manastorm state. */
|
||||||
export function ManastormBridge() {
|
export function ManastormBridge() {
|
||||||
@@ -30,11 +31,12 @@ export function ManastormBridge() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameMode !== "manastorm") return;
|
if (gameMode !== "manastorm") return;
|
||||||
|
if (!onlineSessionOwnsWorldSimulation()) return;
|
||||||
synchronizeManastormPartySize();
|
synchronizeManastormPartySize();
|
||||||
}, [gameMode, partyMemberCount, partySize]);
|
}, [gameMode, partyMemberCount, partySize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameMode !== "manastorm" || status !== "entering" || !encounter) return;
|
if (gameMode !== "manastorm" || status !== "entering" || !encounter || !onlineSessionOwnsWorldSimulation()) return;
|
||||||
useManastormStore.getState().enterStage();
|
useManastormStore.getState().enterStage();
|
||||||
}, [encounter, gameMode, status]);
|
}, [encounter, gameMode, status]);
|
||||||
|
|
||||||
@@ -42,12 +44,14 @@ export function ManastormBridge() {
|
|||||||
if (
|
if (
|
||||||
gameMode !== "manastorm"
|
gameMode !== "manastorm"
|
||||||
|| !encounter
|
|| !encounter
|
||||||
|
|| !onlineSessionOwnsWorldSimulation()
|
||||||
) return;
|
) return;
|
||||||
synchronizeManastormCombat();
|
synchronizeManastormCombat();
|
||||||
}, [encounter, gameMode, mobs, status]);
|
}, [encounter, gameMode, mobs, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameMode !== "manastorm") return;
|
if (gameMode !== "manastorm") return;
|
||||||
|
if (!onlineSessionOwnsWorldSimulation()) return;
|
||||||
const timer = window.setInterval(() => synchronizeManastormAffixes(Date.now()), 250);
|
const timer = window.setInterval(() => synchronizeManastormAffixes(Date.now()), 250);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [gameMode]);
|
}, [gameMode]);
|
||||||
@@ -71,6 +75,7 @@ export function ManastormBridge() {
|
|||||||
|| previous.health <= 0
|
|| previous.health <= 0
|
||||||
|| useGameStore.getState().gameMode !== "manastorm"
|
|| useGameStore.getState().gameMode !== "manastorm"
|
||||||
|| resolvingDefeatRef.current
|
|| resolvingDefeatRef.current
|
||||||
|
|| onlineSessionIsActive()
|
||||||
) return;
|
) return;
|
||||||
resolvingDefeatRef.current = true;
|
resolvingDefeatRef.current = true;
|
||||||
try {
|
try {
|
||||||
@@ -82,7 +87,11 @@ export function ManastormBridge() {
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
useEffect(() => usePartyStore.subscribe((state, previous) => {
|
useEffect(() => usePartyStore.subscribe((state, previous) => {
|
||||||
if (useGameStore.getState().gameMode !== "manastorm" || resolvingDefeatRef.current) return;
|
if (
|
||||||
|
useGameStore.getState().gameMode !== "manastorm"
|
||||||
|
|| resolvingDefeatRef.current
|
||||||
|
|| !onlineSessionOwnsWorldSimulation()
|
||||||
|
) return;
|
||||||
const previousById = new Map(previous.members.map((member) => [member.id, member]));
|
const previousById = new Map(previous.members.map((member) => [member.id, member]));
|
||||||
const downedMemberIds = state.members.filter((member) => (
|
const downedMemberIds = state.members.filter((member) => (
|
||||||
member.health <= 0 && (previousById.get(member.id)?.health ?? 0) > 0
|
member.health <= 0 && (previousById.get(member.id)?.health ?? 0) > 0
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { synchronizeOnlineSession } from "../app/onlineAccountClient";
|
||||||
|
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||||
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||||
|
import type {
|
||||||
|
OnlineRelayEvent,
|
||||||
|
OnlineRelayEventInput,
|
||||||
|
OnlineSessionSnapshot,
|
||||||
|
OnlineSharedWorld,
|
||||||
|
} from "../app/onlineSessionTypes";
|
||||||
|
import { useShellStore } from "../app/shellStore";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||||
|
import { getMobRuntimeTransform } from "./mobRuntimeRegistry";
|
||||||
|
import {
|
||||||
|
configureOnlineSessionRuntime,
|
||||||
|
drainOnlineInteractions,
|
||||||
|
isOnlinePlayerActorId,
|
||||||
|
onlineAccountIdFromActor,
|
||||||
|
onlinePlayerActorId,
|
||||||
|
} from "./onlineSessionRuntime";
|
||||||
|
import {
|
||||||
|
getPartyRuntimePosition,
|
||||||
|
removePartyRuntimePosition,
|
||||||
|
updatePartyRuntimePosition,
|
||||||
|
} from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
import { useGameStore } from "./store";
|
||||||
|
import { useManastormStore } from "./manastormStore";
|
||||||
|
import { advanceManastormSession } from "./manastormSession";
|
||||||
|
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||||
|
|
||||||
|
const SYNC_INTERVAL_MS = 100;
|
||||||
|
const WORLD_INTERVAL_MS = 200;
|
||||||
|
|
||||||
|
function serializableState<T extends object>(state: T): Readonly<Record<string, unknown>> {
|
||||||
|
return JSON.parse(JSON.stringify(state)) as Readonly<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharedWorld(): OnlineSharedWorld {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const party = usePartyStore.getState();
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
const mobTransforms = Object.fromEntries(
|
||||||
|
Object.keys(combat.mobs).flatMap((id) => {
|
||||||
|
const transform = getMobRuntimeTransform(id);
|
||||||
|
return transform ? [[id, transform]] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const partyPositions = Object.fromEntries(
|
||||||
|
party.members.flatMap((member) => {
|
||||||
|
const position = getPartyRuntimePosition(member.id);
|
||||||
|
return position ? [[member.id, position]] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
mobs: combat.mobs,
|
||||||
|
mobTransforms,
|
||||||
|
partyMembers: party.members,
|
||||||
|
partyPositions,
|
||||||
|
partyRuntime: {
|
||||||
|
command: party.command,
|
||||||
|
activeMobId: party.activeMobId,
|
||||||
|
objectiveBossId: party.objectiveBossId,
|
||||||
|
memberTargetIds: party.memberTargetIds,
|
||||||
|
},
|
||||||
|
wailing: game.gameMode === "dungeon"
|
||||||
|
? serializableState(useWailingEncounterStore.getState())
|
||||||
|
: null,
|
||||||
|
manastorm: game.gameMode === "manastorm"
|
||||||
|
? serializableState(useManastormStore.getState())
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySharedWorld(world: OnlineSharedWorld): void {
|
||||||
|
useCombatStore.setState({ mobs: world.mobs });
|
||||||
|
usePartyStore.setState({
|
||||||
|
members: world.partyMembers,
|
||||||
|
command: world.partyRuntime.command as ReturnType<typeof usePartyStore.getState>["command"],
|
||||||
|
activeMobId: world.partyRuntime.activeMobId,
|
||||||
|
objectiveBossId: world.partyRuntime.objectiveBossId,
|
||||||
|
memberTargetIds: world.partyRuntime.memberTargetIds,
|
||||||
|
});
|
||||||
|
for (const [id, position] of Object.entries(world.partyPositions)) {
|
||||||
|
updatePartyRuntimePosition(id, position[0], position[1], position[2]);
|
||||||
|
}
|
||||||
|
if (world.wailing) useWailingEncounterStore.setState(world.wailing as never);
|
||||||
|
if (world.manastorm) useManastormStore.setState(world.manastorm as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventForRelay(
|
||||||
|
event: ReturnType<typeof useCombatStore.getState>["combatEvents"][number],
|
||||||
|
localAccountId: string,
|
||||||
|
authority: boolean,
|
||||||
|
): OnlineRelayEventInput | null {
|
||||||
|
if (!["damage", "healing", "resurrection"].includes(event.kind)) return null;
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const playerOwnedSummon = combat.summons.some((summon) => (
|
||||||
|
summon.id === event.sourceActorId && summon.ownerActorId === PLAYER_AGGRO_ID
|
||||||
|
));
|
||||||
|
const sourceIsLocal = event.sourceActorId === PLAYER_AGGRO_ID || playerOwnedSummon;
|
||||||
|
const targetsRemotePlayer = isOnlinePlayerActorId(event.targetActorId);
|
||||||
|
if (!sourceIsLocal && !(authority && targetsRemotePlayer)) return null;
|
||||||
|
const localActorId = onlinePlayerActorId(localAccountId);
|
||||||
|
const targetActorId = event.targetActorId === PLAYER_AGGRO_ID
|
||||||
|
? localActorId
|
||||||
|
: event.targetActorId;
|
||||||
|
if (sourceIsLocal && targetActorId === localActorId) return null;
|
||||||
|
return {
|
||||||
|
clientEventId: `combat:${localAccountId}:${event.occurredAt}:${event.id}:${event.kind}`,
|
||||||
|
kind: event.kind as "damage" | "healing" | "resurrection",
|
||||||
|
sourceActorId: sourceIsLocal ? localActorId : event.sourceActorId,
|
||||||
|
targetActorId,
|
||||||
|
abilityId: event.abilityId,
|
||||||
|
school: event.school,
|
||||||
|
rawAmount: event.rawAmount,
|
||||||
|
effectiveAmount: event.effectiveAmount,
|
||||||
|
critical: event.critical,
|
||||||
|
occurredAt: event.occurredAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRelayEvent(
|
||||||
|
event: OnlineRelayEvent,
|
||||||
|
snapshot: OnlineSessionSnapshot,
|
||||||
|
applyingRef: React.MutableRefObject<boolean>,
|
||||||
|
): void {
|
||||||
|
if (event.sourceAccountId === snapshot.localAccountId) return;
|
||||||
|
const localActorId = onlinePlayerActorId(snapshot.localAccountId);
|
||||||
|
applyingRef.current = true;
|
||||||
|
try {
|
||||||
|
if (event.kind === "portal" && snapshot.authority) {
|
||||||
|
advanceManastormSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.kind === "loot" && snapshot.authority && event.targetActorId) {
|
||||||
|
useCombatStore.getState().lootMob(event.targetActorId, event.occurredAt || Date.now());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.targetActorId === localActorId) {
|
||||||
|
if (event.kind === "damage") {
|
||||||
|
const sourceLevel = useCombatStore.getState().mobs[event.sourceActorId]?.level;
|
||||||
|
useCombatStore.getState().damagePlayer(
|
||||||
|
event.rawAmount ?? event.effectiveAmount ?? 0,
|
||||||
|
event.school ?? "physical",
|
||||||
|
sourceLevel,
|
||||||
|
);
|
||||||
|
} else if (event.kind === "healing") {
|
||||||
|
useCombatStore.getState().healActor(PLAYER_AGGRO_ID, event.effectiveAmount ?? 0, {
|
||||||
|
sourceActorId: event.sourceActorId,
|
||||||
|
abilityId: event.abilityId ?? undefined,
|
||||||
|
school: event.school ?? undefined,
|
||||||
|
now: event.occurredAt,
|
||||||
|
amountAlreadyModified: true,
|
||||||
|
});
|
||||||
|
} else if (event.kind === "resurrection") {
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
combat.revivePlayer((event.effectiveAmount ?? 1) / Math.max(1, combat.maxHealth));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!snapshot.authority) return;
|
||||||
|
const partyMember = usePartyStore.getState().members.find((member) => member.id === event.targetActorId);
|
||||||
|
if (partyMember) {
|
||||||
|
if (event.kind === "healing") {
|
||||||
|
useCombatStore.getState().healActor(event.targetActorId, event.effectiveAmount ?? 0, {
|
||||||
|
sourceActorId: event.sourceActorId,
|
||||||
|
abilityId: event.abilityId ?? undefined,
|
||||||
|
school: event.school ?? undefined,
|
||||||
|
now: event.occurredAt,
|
||||||
|
amountAlreadyModified: true,
|
||||||
|
});
|
||||||
|
} else if (event.kind === "resurrection") {
|
||||||
|
useCombatStore.getState().reviveActor(
|
||||||
|
event.targetActorId,
|
||||||
|
(event.effectiveAmount ?? 1) / Math.max(1, partyMember.maxHealth),
|
||||||
|
event.sourceActorId,
|
||||||
|
event.abilityId ?? undefined,
|
||||||
|
event.occurredAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mob = useCombatStore.getState().mobs[event.targetActorId];
|
||||||
|
if (mob && event.kind === "damage") {
|
||||||
|
const sourceMember = snapshot.players.find((player) => player.accountId === event.sourceAccountId);
|
||||||
|
useCombatStore.getState().damageMob(
|
||||||
|
mob.id,
|
||||||
|
event.rawAmount ?? event.effectiveAmount ?? 0,
|
||||||
|
event.occurredAt,
|
||||||
|
{
|
||||||
|
actorId: event.sourceActorId,
|
||||||
|
role: sourceMember?.role ?? "damage",
|
||||||
|
abilityId: event.abilityId ?? undefined,
|
||||||
|
},
|
||||||
|
event.school ?? "physical",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
applyingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnlineSessionBridge() {
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||||
|
const group = useOnlineGroupStore((state) => state.snapshot.group);
|
||||||
|
const accept = useOnlineSessionStore((state) => state.accept);
|
||||||
|
const fail = useOnlineSessionStore((state) => state.fail);
|
||||||
|
const reset = useOnlineSessionStore((state) => state.reset);
|
||||||
|
const pendingEventsRef = useRef<OnlineRelayEventInput[]>([]);
|
||||||
|
const applyingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session?.accessToken || !activeActivity || !group) {
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
reset();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const localAccountId = session.ownerId;
|
||||||
|
const immediateAuthority = group.leaderAccountId === localAccountId;
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: immediateAuthority,
|
||||||
|
localAccountId,
|
||||||
|
});
|
||||||
|
let disposed = false;
|
||||||
|
let syncing = false;
|
||||||
|
let latestEventId = 0;
|
||||||
|
let lastWorldSentAt = 0;
|
||||||
|
const remotePositionIds = new Set<string>();
|
||||||
|
|
||||||
|
const unsubscribeCombat = useCombatStore.subscribe((state, previous) => {
|
||||||
|
if (applyingRef.current || state.combatEvents === previous.combatEvents) return;
|
||||||
|
const previousKeys = new Set(previous.combatEvents.map((event) => `${event.id}:${event.occurredAt}:${event.kind}`));
|
||||||
|
for (const event of state.combatEvents) {
|
||||||
|
if (previousKeys.has(`${event.id}:${event.occurredAt}:${event.kind}`)) continue;
|
||||||
|
const relay = eventForRelay(event, localAccountId, group.leaderAccountId === localAccountId);
|
||||||
|
if (relay) pendingEventsRef.current.push(relay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const synchronize = async () => {
|
||||||
|
if (disposed || syncing) return;
|
||||||
|
syncing = true;
|
||||||
|
const combat = useCombatStore.getState();
|
||||||
|
const game = useGameStore.getState();
|
||||||
|
const outgoingEvents = [...pendingEventsRef.current.splice(0), ...drainOnlineInteractions()];
|
||||||
|
const now = Date.now();
|
||||||
|
const publishWorld = group.leaderAccountId === localAccountId && now - lastWorldSentAt >= WORLD_INTERVAL_MS;
|
||||||
|
if (publishWorld) lastWorldSentAt = now;
|
||||||
|
try {
|
||||||
|
const snapshot = await synchronizeOnlineSession(session, {
|
||||||
|
afterEventId: latestEventId,
|
||||||
|
presence: {
|
||||||
|
position: game.playerPosition,
|
||||||
|
yaw: game.cameraYaw,
|
||||||
|
grounded: game.playerGrounded,
|
||||||
|
health: combat.health,
|
||||||
|
maxHealth: combat.maxHealth,
|
||||||
|
resource: combat.resource,
|
||||||
|
maxResource: combat.maxResource,
|
||||||
|
resourceName: combat.resourceName,
|
||||||
|
selectedTargetId: combat.selectedTargetId,
|
||||||
|
activeCast: combat.activeCast,
|
||||||
|
animationEvent: combat.playerAnimationEvent,
|
||||||
|
equipment: equipmentItemsForOwner(
|
||||||
|
combat.equipment,
|
||||||
|
combat.inventory,
|
||||||
|
PLAYER_EQUIPMENT_OWNER_ID,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(publishWorld ? { world: sharedWorld() } : {}),
|
||||||
|
...(outgoingEvents.length ? { events: outgoingEvents } : {}),
|
||||||
|
});
|
||||||
|
if (disposed) return;
|
||||||
|
latestEventId = Math.max(latestEventId, snapshot.latestEventId);
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: snapshot.authority,
|
||||||
|
localAccountId,
|
||||||
|
players: snapshot.players,
|
||||||
|
mobTransforms: snapshot.world?.mobTransforms,
|
||||||
|
});
|
||||||
|
for (const player of snapshot.players) {
|
||||||
|
if (player.accountId === localAccountId) continue;
|
||||||
|
const actorId = onlinePlayerActorId(player.accountId);
|
||||||
|
remotePositionIds.add(actorId);
|
||||||
|
updatePartyRuntimePosition(actorId, player.position[0], player.position[1], player.position[2]);
|
||||||
|
}
|
||||||
|
if (!snapshot.authority && snapshot.world) applySharedWorld(snapshot.world);
|
||||||
|
for (const event of snapshot.events) applyRelayEvent(event, snapshot, applyingRef);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
accept(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
if (outgoingEvents.length) pendingEventsRef.current.unshift(...outgoingEvents);
|
||||||
|
if (!disposed) fail(error instanceof Error ? error.message : "The shared activity lost connection.");
|
||||||
|
} finally {
|
||||||
|
syncing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void synchronize();
|
||||||
|
const timer = window.setInterval(() => void synchronize(), SYNC_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
unsubscribeCombat();
|
||||||
|
for (const id of remotePositionIds) removePartyRuntimePosition(id);
|
||||||
|
pendingEventsRef.current = [];
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
}, [accept, activeActivity?.id, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { resetPartyRuntimeClock } from "./partyRuntime";
|
|||||||
import { usePartyStore } from "./partyStore";
|
import { usePartyStore } from "./partyStore";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import { useManastormStore } from "./manastormStore";
|
import { useManastormStore } from "./manastormStore";
|
||||||
|
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||||
|
|
||||||
/** 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() {
|
||||||
@@ -12,6 +13,7 @@ export function PartyBridge() {
|
|||||||
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);
|
||||||
|
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||||
const level = useCombatStore((state) => state.level);
|
const level = useCombatStore((state) => state.level);
|
||||||
const resetRevision = useGameStore((state) => state.resetRevision);
|
const resetRevision = useGameStore((state) => state.resetRevision);
|
||||||
const mountedResetRevision = useRef(resetRevision);
|
const mountedResetRevision = useRef(resetRevision);
|
||||||
@@ -20,17 +22,22 @@ export function PartyBridge() {
|
|||||||
if (!character) return;
|
if (!character) return;
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
|
const requestedPartySize = gameMode === "manastorm" ? manastormPartySize : 5;
|
||||||
|
const aiPartySize = onlineActivity
|
||||||
|
&& onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon")
|
||||||
|
? onlineGroupAiPartySize(onlineActivity.partySize)
|
||||||
|
: requestedPartySize;
|
||||||
party.initializeParty(
|
party.initializeParty(
|
||||||
character.id,
|
character.id,
|
||||||
combat.level,
|
combat.level,
|
||||||
combat.health,
|
combat.health,
|
||||||
playerRole,
|
playerRole,
|
||||||
gameMode === "manastorm" ? manastormPartySize : 5,
|
aiPartySize,
|
||||||
character.categoryId ?? "wow",
|
character.categoryId ?? "wow",
|
||||||
);
|
);
|
||||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||||
resetPartyRuntimeClock();
|
resetPartyRuntimeClock();
|
||||||
}, [character?.id, gameMode, manastormPartySize, playerRole]);
|
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
usePartyStore.getState().syncLevel(level);
|
usePartyStore.getState().syncLevel(level);
|
||||||
|
|||||||
@@ -38,7 +38,20 @@ describe("baseline ability catalog", () => {
|
|||||||
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
|
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
|
||||||
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
|
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
|
||||||
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
|
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
|
||||||
expect(ability.cost.resource).toBe(resource.type);
|
if (characterClass.mode !== "classic" || ability.cost.amount > 0) {
|
||||||
|
const classicResources: Partial<Record<typeof characterClass.id, readonly string[]>> = {
|
||||||
|
warrior: ["rage", "health"],
|
||||||
|
rogue: ["energy"],
|
||||||
|
"death-knight": ["runic-power", "health"],
|
||||||
|
druid: ["mana", "rage", "energy"],
|
||||||
|
warlock: ["mana", "health"],
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
characterClass.mode === "classic"
|
||||||
|
? classicResources[characterClass.id] ?? [resource.type]
|
||||||
|
: ["mana", "rage", "energy", "runic-power", "focus", "health"],
|
||||||
|
).toContain(ability.cost.resource);
|
||||||
|
}
|
||||||
expect(ability.cost.amount).toBeGreaterThanOrEqual(0);
|
expect(ability.cost.amount).toBeGreaterThanOrEqual(0);
|
||||||
expect(ability.effects.length).toBeGreaterThan(0);
|
expect(ability.effects.length).toBeGreaterThan(0);
|
||||||
expect(allIds.has(ability.id)).toBe(false);
|
expect(allIds.has(ability.id)).toBe(false);
|
||||||
|
|||||||
@@ -6,11 +6,68 @@ import {
|
|||||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||||
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES, ROM_ORDINARY_ABILITIES_BY_CLASS, type RomSkillGroup } from "./romAbilityCatalog";
|
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES, ROM_ORDINARY_ABILITIES_BY_CLASS, type RomSkillGroup } from "./romAbilityCatalog";
|
||||||
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
|
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
|
||||||
import type { AuraDefinition, DispelCategory } from "./combatAuras";
|
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
|
||||||
|
|
||||||
export type ResourceType = "mana" | "rage" | "energy" | "runic-power" | "focus" | "nature-power" | "psi" | "health";
|
export type ResourceType =
|
||||||
export type AbilityTarget = "hostile" | "friendly" | "self";
|
| "mana"
|
||||||
|
| "rage"
|
||||||
|
| "energy"
|
||||||
|
| "runic-power"
|
||||||
|
| "focus"
|
||||||
|
| "nature-power"
|
||||||
|
| "psi"
|
||||||
|
| "health"
|
||||||
|
| "holy-power"
|
||||||
|
| "chi"
|
||||||
|
| "soul-power"
|
||||||
|
| `custom:${string}`;
|
||||||
|
export type AbilityTarget = "hostile" | "friendly" | "any" | "self";
|
||||||
export type AbilityCastMode = "instant" | "cast" | "channel";
|
export type AbilityCastMode = "instant" | "cast" | "channel";
|
||||||
|
export type AbilityTargetShape = "unit" | "circle" | "cone" | "chain" | "ground";
|
||||||
|
|
||||||
|
export interface AbilityTargetingRules {
|
||||||
|
readonly shape: AbilityTargetShape;
|
||||||
|
readonly requiresLineOfSight?: boolean;
|
||||||
|
readonly requiresFacing?: boolean;
|
||||||
|
readonly requiresBehindTarget?: boolean;
|
||||||
|
readonly attackArcRadians?: number;
|
||||||
|
readonly radius?: number;
|
||||||
|
readonly maximumTargets?: number;
|
||||||
|
readonly jumpRange?: number;
|
||||||
|
readonly groundRange?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbilityCastRules {
|
||||||
|
readonly school?: DamageSchool;
|
||||||
|
readonly canCastWhileMoving?: boolean;
|
||||||
|
readonly pushbackImmune?: boolean;
|
||||||
|
/** A fraction from zero to one, applied independently to each pushback hit. */
|
||||||
|
readonly pushbackResistance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbilityRuneCost {
|
||||||
|
readonly blood?: number;
|
||||||
|
readonly frost?: number;
|
||||||
|
readonly unholy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AbilityAiHints {
|
||||||
|
readonly categories: readonly (
|
||||||
|
| "damage"
|
||||||
|
| "execute"
|
||||||
|
| "heal"
|
||||||
|
| "emergency-heal"
|
||||||
|
| "area-heal"
|
||||||
|
| "mitigation"
|
||||||
|
| "taunt"
|
||||||
|
| "interrupt"
|
||||||
|
| "dispel"
|
||||||
|
| "resurrection"
|
||||||
|
| "control"
|
||||||
|
| "debuff"
|
||||||
|
)[];
|
||||||
|
readonly priority?: number;
|
||||||
|
}
|
||||||
export type MobStatusKind =
|
export type MobStatusKind =
|
||||||
| "slow"
|
| "slow"
|
||||||
| "stun"
|
| "stun"
|
||||||
@@ -63,6 +120,8 @@ export interface AbilityAmountScaling {
|
|||||||
readonly basePoints?: number;
|
readonly basePoints?: number;
|
||||||
readonly dieSides?: number;
|
readonly dieSides?: number;
|
||||||
readonly pointsPerLevel?: number;
|
readonly pointsPerLevel?: number;
|
||||||
|
/** Additional power coefficient gained per source skill level. */
|
||||||
|
readonly coefficientPerLevel?: number;
|
||||||
readonly bonusPowerCoefficient?: number;
|
readonly bonusPowerCoefficient?: number;
|
||||||
readonly sourceLevel?: number;
|
readonly sourceLevel?: number;
|
||||||
readonly maxScalingLevel?: number;
|
readonly maxScalingLevel?: number;
|
||||||
@@ -77,15 +136,17 @@ export type AbilityEffect =
|
|||||||
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling)
|
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling)
|
||||||
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
|
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
|
||||||
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
|
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
|
||||||
| { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType }
|
| { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType; readonly percentage?: boolean }
|
||||||
| { readonly kind: "combo"; readonly amount: number }
|
| { readonly kind: "combo"; readonly amount: number }
|
||||||
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
|
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
|
||||||
| { readonly kind: "status"; readonly status: MobStatusKind; readonly durationMs: number; readonly magnitude?: number; readonly maxTargets?: number }
|
| { readonly kind: "status"; readonly status: MobStatusKind; readonly durationMs: number; readonly magnitude?: number; readonly maxTargets?: number; readonly breakOnDamage?: boolean }
|
||||||
| {
|
| {
|
||||||
readonly kind: "apply-aura";
|
readonly kind: "apply-aura";
|
||||||
readonly aura: AuraDefinition;
|
readonly aura: AuraDefinition;
|
||||||
/** Some hostile spells apply their beneficial rider to the caster. */
|
/** Some hostile spells apply their beneficial rider to the caster. */
|
||||||
readonly recipient?: "ability-target" | "caster";
|
readonly recipient?: "ability-target" | "caster";
|
||||||
|
/** Party/raid area auras are applied to the player and every active party member. */
|
||||||
|
readonly scope?: "target" | "party";
|
||||||
readonly control?: AbilityAuraControl;
|
readonly control?: AbilityAuraControl;
|
||||||
readonly sourceEffectType: number;
|
readonly sourceEffectType: number;
|
||||||
readonly sourceAuraType: number;
|
readonly sourceAuraType: number;
|
||||||
@@ -109,6 +170,7 @@ export type AbilityEffect =
|
|||||||
readonly kind: "threat";
|
readonly kind: "threat";
|
||||||
readonly mode: "flat" | "percent" | "redirect";
|
readonly mode: "flat" | "percent" | "redirect";
|
||||||
readonly amount: number;
|
readonly amount: number;
|
||||||
|
readonly recipient?: "caster" | "ability-target";
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly kind: "movement";
|
readonly kind: "movement";
|
||||||
@@ -119,7 +181,14 @@ export type AbilityEffect =
|
|||||||
| { readonly kind: "pet"; readonly action: "summon" | "call" | "recall" | "dismiss" | "resurrect" | "feed" | "tame"; readonly creatureId?: number }
|
| { readonly kind: "pet"; readonly action: "summon" | "call" | "recall" | "dismiss" | "resurrect" | "feed" | "tame"; readonly creatureId?: number }
|
||||||
| { readonly kind: "totem"; readonly action: "destroy-all" }
|
| { readonly kind: "totem"; readonly action: "destroy-all" }
|
||||||
| { readonly kind: "rune"; readonly action: "activate"; readonly count: number }
|
| { readonly kind: "rune"; readonly action: "activate"; readonly count: number }
|
||||||
| { readonly kind: "cooldown-reset"; readonly mode: "reset" | "reduce"; readonly abilityName?: string; readonly amountMs?: number }
|
| {
|
||||||
|
readonly kind: "cooldown-reset";
|
||||||
|
readonly mode: "reset" | "reduce";
|
||||||
|
readonly abilityName?: string;
|
||||||
|
readonly amountMs?: number;
|
||||||
|
/** Fraction of the target ability's remaining cooldown removed at execution time. */
|
||||||
|
readonly amountPercent?: number;
|
||||||
|
}
|
||||||
| { readonly kind: "trigger-spell"; readonly spellId: number; readonly withValue?: number }
|
| { readonly kind: "trigger-spell"; readonly spellId: number; readonly withValue?: number }
|
||||||
| {
|
| {
|
||||||
/** A known combat-side DBC script hook that requires a spell-specific handler. */
|
/** A known combat-side DBC script hook that requires a spell-specific handler. */
|
||||||
@@ -140,6 +209,7 @@ export type AbilityEffect =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly kind: "utility";
|
readonly kind: "utility";
|
||||||
|
readonly action?: "create-item" | "open-lock" | "portal" | "weapon-enchant" | "pickpocket" | "farsight" | "class-script";
|
||||||
readonly effectType: number;
|
readonly effectType: number;
|
||||||
readonly auraType: number;
|
readonly auraType: number;
|
||||||
readonly miscValue: number;
|
readonly miscValue: number;
|
||||||
@@ -165,6 +235,9 @@ export interface AbilityRankDefinition {
|
|||||||
readonly cost: AbilityCost;
|
readonly cost: AbilityCost;
|
||||||
readonly costs?: readonly AbilityCost[];
|
readonly costs?: readonly AbilityCost[];
|
||||||
readonly effects: readonly AbilityEffect[];
|
readonly effects: readonly AbilityEffect[];
|
||||||
|
readonly targeting?: AbilityTargetingRules;
|
||||||
|
readonly castRules?: AbilityCastRules;
|
||||||
|
readonly runeCost?: AbilityRuneCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AbilityDefinition {
|
export interface AbilityDefinition {
|
||||||
@@ -188,6 +261,10 @@ export interface AbilityDefinition {
|
|||||||
readonly cost: AbilityCost;
|
readonly cost: AbilityCost;
|
||||||
readonly costs?: readonly AbilityCost[];
|
readonly costs?: readonly AbilityCost[];
|
||||||
readonly effects: readonly AbilityEffect[];
|
readonly effects: readonly AbilityEffect[];
|
||||||
|
readonly targeting?: AbilityTargetingRules;
|
||||||
|
readonly castRules?: AbilityCastRules;
|
||||||
|
readonly runeCost?: AbilityRuneCost;
|
||||||
|
readonly ai?: AbilityAiHints;
|
||||||
readonly passive?: boolean;
|
readonly passive?: boolean;
|
||||||
readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker";
|
readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker";
|
||||||
readonly romSkillGroup?: RomSkillGroup;
|
readonly romSkillGroup?: RomSkillGroup;
|
||||||
@@ -793,6 +870,9 @@ export function abilityAtLevel(
|
|||||||
cost: rank.cost,
|
cost: rank.cost,
|
||||||
costs: rank.costs ?? [rank.cost],
|
costs: rank.costs ?? [rank.cost],
|
||||||
effects: rank.effects,
|
effects: rank.effects,
|
||||||
|
targeting: rank.targeting ?? ability.targeting,
|
||||||
|
castRules: rank.castRules ?? ability.castRules,
|
||||||
|
runeCost: rank.runeCost ?? ability.runeCost,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type {
|
||||||
|
AbilityAiHints,
|
||||||
|
AbilityDefinition,
|
||||||
|
AbilityRuneCost,
|
||||||
|
AbilityTargetingRules,
|
||||||
|
} from "./abilityCatalog";
|
||||||
|
|
||||||
|
const CHAIN_ABILITIES = new Set(["chain heal", "chain lightning", "prayer of mending"]);
|
||||||
|
const GROUND_ABILITIES = new Set([
|
||||||
|
"blizzard", "rain of fire", "flamestrike", "death and decay", "consecration",
|
||||||
|
"volley", "hurricane", "earthquake", "meteor", "healing rain",
|
||||||
|
]);
|
||||||
|
const CONE_ABILITIES = new Set([
|
||||||
|
"cone of cold", "dragon's breath", "dragons breath", "shockwave", "cleave",
|
||||||
|
"swipe", "swipe (cat)", "fan of knives", "whirlwind",
|
||||||
|
]);
|
||||||
|
const BEHIND_ABILITIES = new Set(["backstab", "ambush", "garrote", "ravage", "shred", "pounce"]);
|
||||||
|
const MOVEMENT_CAST_ABILITIES = new Set(["scorch", "steady shot", "spiritwalker's grace"]);
|
||||||
|
const EMERGENCY_HEAL_ABILITIES = new Set([
|
||||||
|
"flash heal", "flash of light", "swiftmend", "lay on hands", "nature's swiftness",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RUNE_COSTS_BY_SPELL_ID: Readonly<Record<number, AbilityRuneCost>> = Object.freeze({
|
||||||
|
49909: { frost: 1 }, 45524: { frost: 1 }, 49184: { frost: 1 }, 47528: { frost: 1 },
|
||||||
|
49921: { unholy: 1 }, 55271: { unholy: 1 }, 49222: { unholy: 1 }, 51052: { unholy: 1 },
|
||||||
|
49930: { blood: 1 }, 55262: { blood: 1 }, 49941: { blood: 1 }, 50842: { blood: 1 },
|
||||||
|
48982: { blood: 1 }, 49005: { blood: 1 },
|
||||||
|
49924: { frost: 1, unholy: 1 }, 51425: { frost: 1, unholy: 1 },
|
||||||
|
49938: { blood: 1, frost: 1, unholy: 1 },
|
||||||
|
});
|
||||||
|
const RUNE_COSTS_BY_NAME: Readonly<Record<string, AbilityRuneCost>> = Object.freeze({
|
||||||
|
"icy touch": { frost: 1 }, "chains of ice": { frost: 1 }, "howling blast": { frost: 1 }, "mind freeze": { frost: 1 },
|
||||||
|
"plague strike": { unholy: 1 }, "scourge strike": { unholy: 1 }, "bone shield": { unholy: 1 }, "anti-magic zone": { unholy: 1 },
|
||||||
|
"blood strike": { blood: 1 }, "heart strike": { blood: 1 }, "blood boil": { blood: 1 }, "pestilence": { blood: 1 },
|
||||||
|
"rune tap": { blood: 1 }, "mark of blood": { blood: 1 },
|
||||||
|
"death strike": { frost: 1, unholy: 1 }, "obliterate": { frost: 1, unholy: 1 },
|
||||||
|
"death and decay": { blood: 1, frost: 1, unholy: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function targetingRulesForAbility(ability: AbilityDefinition): AbilityTargetingRules {
|
||||||
|
if (ability.targeting) return ability.targeting;
|
||||||
|
const name = ability.name.toLowerCase();
|
||||||
|
const shape = CHAIN_ABILITIES.has(name)
|
||||||
|
? "chain"
|
||||||
|
: GROUND_ABILITIES.has(name) || name.endsWith(" trap")
|
||||||
|
? "ground"
|
||||||
|
: CONE_ABILITIES.has(name) || name.endsWith(" breath")
|
||||||
|
? "cone"
|
||||||
|
: ability.radius && ability.radius > 0
|
||||||
|
? "circle"
|
||||||
|
: "unit";
|
||||||
|
const hostile = ability.target === "hostile" || ability.target === "any";
|
||||||
|
return Object.freeze({
|
||||||
|
shape,
|
||||||
|
requiresLineOfSight: ability.target !== "self",
|
||||||
|
// WoW friendly spells are deliberately facing independent.
|
||||||
|
requiresFacing: hostile && shape !== "circle" && shape !== "ground",
|
||||||
|
requiresBehindTarget: hostile && BEHIND_ABILITIES.has(name),
|
||||||
|
attackArcRadians: shape === "cone" ? Math.PI / 2 : Math.PI,
|
||||||
|
...(ability.radius ? { radius: ability.radius } : {}),
|
||||||
|
...(shape === "chain" ? { maximumTargets: 3, jumpRange: 12 } : {}),
|
||||||
|
...(shape === "ground" ? { groundRange: ability.range.max, radius: ability.radius ?? 8 } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAbilityCastWhileMoving(ability: AbilityDefinition): boolean {
|
||||||
|
return ability.castRules?.canCastWhileMoving ?? MOVEMENT_CAST_ABILITIES.has(ability.name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abilityPushbackResistance(ability: AbilityDefinition): number {
|
||||||
|
if (ability.castRules?.pushbackImmune) return 1;
|
||||||
|
return Math.max(0, Math.min(1, ability.castRules?.pushbackResistance ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WotLK 3.3.5 rune costs for the active DK toolkit imported by the game. */
|
||||||
|
export function runeCostForAbility(ability: AbilityDefinition): AbilityRuneCost | null {
|
||||||
|
if (ability.runeCost) return ability.runeCost;
|
||||||
|
return RUNE_COSTS_BY_SPELL_ID[ability.dbcSpellId]
|
||||||
|
?? RUNE_COSTS_BY_NAME[ability.name.toLowerCase()]
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
|
||||||
|
if (ability.ai) return ability.ai;
|
||||||
|
const name = ability.name.toLowerCase();
|
||||||
|
const categories = new Set<AbilityAiHints["categories"][number]>();
|
||||||
|
for (const effect of ability.effects) {
|
||||||
|
if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage");
|
||||||
|
if (effect.kind === "execute") categories.add("execute");
|
||||||
|
if (effect.kind === "heal" || effect.kind === "hot") categories.add("heal");
|
||||||
|
if (effect.kind === "shield") categories.add("mitigation");
|
||||||
|
if (effect.kind === "taunt") categories.add("taunt");
|
||||||
|
if (effect.kind === "interrupt") categories.add("interrupt");
|
||||||
|
if (effect.kind === "dispel" || effect.kind === "dispel-mechanic") categories.add("dispel");
|
||||||
|
if (effect.kind === "resurrection") categories.add("resurrection");
|
||||||
|
if (effect.kind === "status") {
|
||||||
|
categories.add(effect.status === "interrupt" ? "interrupt" : "control");
|
||||||
|
}
|
||||||
|
if (effect.kind === "dot" || (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")) categories.add("debuff");
|
||||||
|
}
|
||||||
|
if (EMERGENCY_HEAL_ABILITIES.has(name)) categories.add("emergency-heal");
|
||||||
|
if ((ability.radius ?? 0) > 0 && categories.has("heal")) categories.add("area-heal");
|
||||||
|
return Object.freeze({ categories: Object.freeze([...categories]) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { COA_CLASS_IDS, type CoaClassId } from "../app/characterCatalog";
|
||||||
|
import type { AbilityDefinition, AbilityEffect, AbilityRankDefinition } from "./abilityCatalog";
|
||||||
|
import {
|
||||||
|
COA_ABILITIES_BY_CLASS,
|
||||||
|
COA_CLASS_RESOURCES,
|
||||||
|
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
|
} from "./coaAbilityCatalog";
|
||||||
|
import {
|
||||||
|
COA_PROGRESSION_CHAINS,
|
||||||
|
COA_TREE_ENTRIES,
|
||||||
|
coaClassIdFromDbcId,
|
||||||
|
coaSpellById,
|
||||||
|
} from "./coaLiveCatalog";
|
||||||
|
|
||||||
|
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
|
||||||
|
const ranksFor = (ability: AbilityDefinition): readonly AbilityRankDefinition[] => ability.ranks ?? [{
|
||||||
|
rank: 1,
|
||||||
|
spellId: ability.dbcSpellId,
|
||||||
|
level: ability.unlockLevel,
|
||||||
|
description: ability.description,
|
||||||
|
castMode: ability.castMode,
|
||||||
|
castTimeMs: ability.castTimeMs,
|
||||||
|
cooldownMs: ability.cooldownMs,
|
||||||
|
gcdMs: ability.gcdMs,
|
||||||
|
cost: ability.cost,
|
||||||
|
effects: ability.effects,
|
||||||
|
}];
|
||||||
|
const ranks = abilities.flatMap((ability) => ranksFor(ability).map((rank) => ({ ability, rank })));
|
||||||
|
const byName = (classId: CoaClassId, name: string): AbilityDefinition => {
|
||||||
|
const ability = COA_ABILITIES_BY_CLASS[classId].find((candidate) => candidate.name === name);
|
||||||
|
if (!ability) throw new Error(`Missing ${classId} ability ${name}`);
|
||||||
|
return ability;
|
||||||
|
};
|
||||||
|
const rankEffectsForSpell = (spellId: number): readonly AbilityEffect[] => ranks
|
||||||
|
.find(({ rank }) => rank.spellId === spellId)?.rank.effects ?? [];
|
||||||
|
|
||||||
|
describe("Ascension Conquest ability catalog", () => {
|
||||||
|
it("covers every class, source family, and playable rank", () => {
|
||||||
|
expect(Object.keys(COA_ABILITIES_BY_CLASS).sort()).toEqual([...COA_CLASS_IDS].sort());
|
||||||
|
expect(Object.keys(COA_CLASS_RESOURCES).sort()).toEqual([...COA_CLASS_IDS].sort());
|
||||||
|
expect(abilities).toHaveLength(1_275);
|
||||||
|
expect(ranks).toHaveLength(3_394);
|
||||||
|
expect(new Set(ranks.map(({ rank }) => rank.spellId)).size).toBe(3_347);
|
||||||
|
|
||||||
|
const runtimeSpellIds = new Set(ranks.map(({ rank }) => rank.spellId));
|
||||||
|
for (const chain of COA_PROGRESSION_CHAINS) {
|
||||||
|
const classId = coaClassIdFromDbcId(chain.classId);
|
||||||
|
expect(classId, chain.name).not.toBeNull();
|
||||||
|
for (const rank of chain.ranks) {
|
||||||
|
expect(runtimeSpellIds.has(rank.spellId), `${classId}: ${chain.name} rank ${rank.rank}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of COA_TREE_ENTRIES.filter((candidate) => candidate.entryType === "Ability")) {
|
||||||
|
const classId = coaClassIdFromDbcId(entry.classId);
|
||||||
|
if (!classId) continue;
|
||||||
|
for (const rank of entry.rankDescriptions) {
|
||||||
|
expect(runtimeSpellIds.has(rank.spellId), `${classId}: ${entry.name} rank ${rank.rank}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every family and rank finite, executable runtime data", () => {
|
||||||
|
expect(new Set(abilities.map((ability) => ability.id)).size).toBe(abilities.length);
|
||||||
|
for (const ability of abilities) {
|
||||||
|
expect(ability.classId, ability.id).toBeTruthy();
|
||||||
|
expect(ability.name.length, ability.id).toBeGreaterThan(0);
|
||||||
|
expect(ability.icon.startsWith("/assets/ui/spells/"), ability.id).toBe(true);
|
||||||
|
expect(Number.isFinite(ability.range.min), ability.id).toBe(true);
|
||||||
|
expect(Number.isFinite(ability.range.max), ability.id).toBe(true);
|
||||||
|
expect(ability.range.max, ability.id).toBeGreaterThanOrEqual(ability.range.min);
|
||||||
|
expect(ability.cooldownMs, ability.id).toBeGreaterThanOrEqual(0);
|
||||||
|
for (const rank of ranksFor(ability)) {
|
||||||
|
const label = `${ability.id} rank ${rank.rank} (${rank.spellId})`;
|
||||||
|
expect(rank.spellId, label).toBeGreaterThan(0);
|
||||||
|
expect(rank.level, label).toBeGreaterThan(0);
|
||||||
|
expect(rank.description.length, label).toBeGreaterThan(0);
|
||||||
|
expect(rank.castTimeMs, label).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(rank.cooldownMs, label).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(rank.gcdMs, label).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(Number.isFinite(rank.cost.amount), label).toBe(true);
|
||||||
|
expect(rank.cost.amount, label).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(rank.effects.length, label).toBeGreaterThan(0);
|
||||||
|
expect(rank.effects.some((effect) => effect.kind === "scripted" || effect.kind === "unsupported"), label).toBe(false);
|
||||||
|
if (!ability.passive) {
|
||||||
|
expect(rank.effects.some((effect) => effect.kind !== "utility" || effect.action !== "class-script"), label).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("translates every standard DBC combat effect into its executable semantic", () => {
|
||||||
|
const applyAuraTypes = new Set([0, 3, 6, 27, 35, 65, 119, 128, 129, 143, 190]);
|
||||||
|
const triggerTypes = new Set([32, 64, 140, 141, 142, 148, 151, 160, 165, 169, 175, 178, 183]);
|
||||||
|
const movementTypes = new Set([5, 29, 41, 42, 43, 79, 96, 98, 138, 144, 149]);
|
||||||
|
for (const { ability, rank } of ranks) {
|
||||||
|
const dbc = coaSpellById(rank.spellId)?.dbc;
|
||||||
|
if (!dbc) continue;
|
||||||
|
const kinds = new Set(rank.effects.map((effect) => effect.kind));
|
||||||
|
for (const effect of dbc.effects) {
|
||||||
|
const label = `${ability.classId}: ${ability.name} rank ${rank.rank}, DBC ${effect.effectType}/${effect.auraType}`;
|
||||||
|
if (effect.effectType === 2 || [17, 31, 58, 121].includes(effect.effectType)) expect(kinds.has("damage"), label).toBe(true);
|
||||||
|
if ([10, 67, 136].includes(effect.effectType)) expect(kinds.has("heal"), label).toBe(true);
|
||||||
|
if (applyAuraTypes.has(effect.effectType) && effect.auraType > 0) expect(kinds.has("apply-aura"), label).toBe(true);
|
||||||
|
if (effect.auraType === 3 && effect.periodMs > 0) expect(kinds.has("dot"), label).toBe(true);
|
||||||
|
if ([8, 20].includes(effect.auraType) && effect.periodMs > 0) expect(kinds.has("hot"), label).toBe(true);
|
||||||
|
if (triggerTypes.has(effect.effectType) && effect.triggerSpellId > 0) expect(kinds.has("trigger-spell"), label).toBe(true);
|
||||||
|
if (movementTypes.has(effect.effectType)) expect(kinds.has("movement"), label).toBe(true);
|
||||||
|
if (effect.effectType === 38) expect(kinds.has("dispel"), label).toBe(true);
|
||||||
|
if (effect.effectType === 68) expect(kinds.has("interrupt"), label).toBe(true);
|
||||||
|
if ([18, 94, 113, 117].includes(effect.effectType)) expect(kinds.has("resurrection"), label).toBe(true);
|
||||||
|
if ([192, 195].includes(effect.effectType)) expect(kinds.has("cooldown-reset"), label).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves percentages, actual power pools, and shared cooldown groups", () => {
|
||||||
|
const percentageCosts = ranks.filter(({ rank }) => rank.cost.percentage);
|
||||||
|
expect(percentageCosts.length).toBeGreaterThan(1_500);
|
||||||
|
expect(ranks.some(({ rank }) => rank.cost.resource === "health")).toBe(true);
|
||||||
|
expect(ranks.some(({ rank }) => rank.cost.resource === "energy")).toBe(true);
|
||||||
|
expect(ranks.some(({ rank }) => rank.cost.resource === "rage")).toBe(true);
|
||||||
|
expect(ranks.some(({ rank }) => rank.cost.resource === "runic-power")).toBe(true);
|
||||||
|
|
||||||
|
const ascension = byName("sun-cleric", "Solar Invocation: Ascension");
|
||||||
|
const revelation = byName("sun-cleric", "Solar Invocation: Revelation");
|
||||||
|
expect(ascension.cooldownGroup).toBe("coa:solar-invocation-spells");
|
||||||
|
expect(revelation.cooldownGroup).toBe(ascension.cooldownGroup);
|
||||||
|
expect(revelation.target).toBe("any");
|
||||||
|
|
||||||
|
const profound = byName("templar", "Profound Enlightenment");
|
||||||
|
expect(profound.effects.filter((effect) => effect.kind === "cooldown-reset")).toEqual([
|
||||||
|
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
|
||||||
|
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
|
||||||
|
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps representative healing, protection, control, proc, trigger, and reset spells functional", () => {
|
||||||
|
expect(byName("sun-cleric", "Solar Invocation: Ascension")).toMatchObject({ target: "self", radius: 40 });
|
||||||
|
expect(byName("guardian", "Feats of Strength")).toMatchObject({ target: "friendly" });
|
||||||
|
expect(byName("knight-of-xoroth", "Black Shield").ranks?.every((rank) => rank.effects.some((effect) => effect.kind === "shield"))).toBe(true);
|
||||||
|
expect(byName("witch-hunter", "Brand of the Unworthy").effects).toContainEqual(expect.objectContaining({ kind: "status", status: "sleep" }));
|
||||||
|
expect(byName("necromancer", "Plaguestorm").ranks?.every((rank) => rank.effects.some((effect) => effect.kind === "dot"))).toBe(true);
|
||||||
|
expect(byName("templar", "Profound Enlightenment").effects.filter((effect) => effect.kind === "cooldown-reset")).toHaveLength(3);
|
||||||
|
expect(byName("sun-cleric", "New Day").effects.filter((effect) => effect.kind === "cooldown-reset")).toHaveLength(3);
|
||||||
|
|
||||||
|
const gaze = byName("cultist", "Gaze of C'Thun");
|
||||||
|
expect(gaze.target).toBe("any");
|
||||||
|
expect(gaze.effects.some((effect) => effect.kind === "trigger-spell")).toBe(true);
|
||||||
|
expect(gaze.effects.some((effect) => effect.kind === "damage")).toBe(true);
|
||||||
|
expect(gaze.effects.some((effect) => effect.kind === "heal")).toBe(true);
|
||||||
|
|
||||||
|
const razorice = byName("necromancer", "Razorice");
|
||||||
|
const procs = razorice.effects
|
||||||
|
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
||||||
|
.flatMap((effect) => effect.aura.procs ?? []);
|
||||||
|
expect(procs.length).toBeGreaterThan(0);
|
||||||
|
expect(procs.every((proc) => proc.action.kind === "damage" || proc.action.kind === "custom")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every rank spell lookup executable, including recursively expanded trigger spells", () => {
|
||||||
|
for (const { ability, rank } of ranks) {
|
||||||
|
expect(rankEffectsForSpell(rank.spellId).length, `${ability.name} ${rank.spellId}`).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
const gaze = byName("cultist", "Gaze of C'Thun");
|
||||||
|
expect(gaze.effects.filter((effect) => effect.kind === "trigger-spell")).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ spellId: 520334 }),
|
||||||
|
expect.objectContaining({ spellId: 520335 }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ships executable hidden payloads for proc-triggered DBC spell IDs", () => {
|
||||||
|
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(585);
|
||||||
|
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[800951]).toMatchObject({
|
||||||
|
name: "Battle Rhythm",
|
||||||
|
effects: expect.arrayContaining([{ kind: "resource", amount: 5 }]),
|
||||||
|
});
|
||||||
|
expect(Object.values(COA_TRIGGERED_ABILITIES_BY_SPELL_ID).filter((ability) => (
|
||||||
|
ability.effects.some((effect) => effect.kind !== "utility")
|
||||||
|
))).toHaveLength(547);
|
||||||
|
for (const [spellId, ability] of Object.entries(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
|
||||||
|
expect(ability.dbcSpellId, spellId).toBe(Number(spellId));
|
||||||
|
expect(ability.effects.length, spellId).toBeGreaterThan(0);
|
||||||
|
expect(ability.effects.some((effect) => effect.kind === "scripted" || effect.kind === "unsupported"), spellId).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+893
-59
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,15 @@ interface CoaAbilityRuntimeSnapshot {
|
|||||||
readonly schemaVersion: 1;
|
readonly schemaVersion: 1;
|
||||||
readonly abilitiesByClass: Readonly<Record<CoaClassId, readonly AbilityDefinition[]>>;
|
readonly abilitiesByClass: Readonly<Record<CoaClassId, readonly AbilityDefinition[]>>;
|
||||||
readonly resourcesByClass: Readonly<Record<CoaClassId, ClassResourceProfile>>;
|
readonly resourcesByClass: Readonly<Record<CoaClassId, ClassResourceProfile>>;
|
||||||
|
readonly triggeredAbilitiesBySpellId: Readonly<Record<number, AbilityDefinition>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = generated as unknown as CoaAbilityRuntimeSnapshot;
|
const snapshot = generated as unknown as CoaAbilityRuntimeSnapshot;
|
||||||
|
|
||||||
export const COA_RUNTIME_ABILITIES_BY_CLASS = snapshot.abilitiesByClass;
|
export const COA_RUNTIME_ABILITIES_BY_CLASS = snapshot.abilitiesByClass;
|
||||||
export const COA_RUNTIME_CLASS_RESOURCES = snapshot.resourcesByClass;
|
export const COA_RUNTIME_CLASS_RESOURCES = snapshot.resourcesByClass;
|
||||||
|
export const COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID = snapshot.triggeredAbilitiesBySpellId;
|
||||||
|
|
||||||
|
export function coaTriggeredAbilityBySpellId(spellId: number): AbilityDefinition | null {
|
||||||
|
return COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID[spellId] ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
|
||||||
|
describe("Ascension Conquest combat runtime", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("heals a selected ally with an enemy-or-ally spell", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "cultist", raceId: "human", level: 20 });
|
||||||
|
usePartyStore.getState().initializeParty("coa-any-friendly", 20, useCombatStore.getState().health, "healer", 2, "coa");
|
||||||
|
const member = usePartyStore.getState().members[0]!;
|
||||||
|
usePartyStore.getState().damageMember(member.id, 500);
|
||||||
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||||
|
usePartyStore.getState().selectMember(member.id);
|
||||||
|
const injuredHealth = usePartyStore.getState().members[0]!.health;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("cultist-gaze-of-cthun", undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("damages a selected enemy with the same enemy-or-ally spell", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "cultist", raceId: "human", level: 20 });
|
||||||
|
usePartyStore.getState().clearSelection();
|
||||||
|
useCombatStore.getState().registerMob("gaze-dummy", { name: "Gaze Dummy", maxHealth: 5_000 });
|
||||||
|
setMobPosition("gaze-dummy", [3, 0, 0]);
|
||||||
|
useCombatStore.getState().selectMob("gaze-dummy");
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("cultist-gaze-of-cthun", undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().mobs["gaze-dummy"]!.health).toBeLessThan(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes a hidden DBC payload when a passive proc fires", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "pyromancer", raceId: "human", level: 20 });
|
||||||
|
usePartyStore.getState().initializeParty("coa-hidden-proc", 20, useCombatStore.getState().health, "healer", 2, "coa");
|
||||||
|
const member = usePartyStore.getState().members[0]!;
|
||||||
|
usePartyStore.getState().damageMember(member.id, 500);
|
||||||
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||||
|
usePartyStore.getState().selectMember(member.id);
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.name.includes("Phoenix Handler"))).toBe(true);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("coa-24-cinderheart-476-502044", undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
useCombatStore.getState().tick(0.1, 4_100);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => (
|
||||||
|
aura.definition.id === "coa:704865:effect:0:aura:23"
|
||||||
|
))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog";
|
||||||
|
import {
|
||||||
|
normalizeActorResourcePool,
|
||||||
|
type CombatActorState,
|
||||||
|
} from "./combatActors";
|
||||||
|
import type { PartyMember } from "./partyStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the authoritative companion actor. Existing actor values win over
|
||||||
|
* the party-frame compatibility record once the actor has entered combat.
|
||||||
|
*/
|
||||||
|
export function companionActorFromMember(
|
||||||
|
member: PartyMember,
|
||||||
|
absorb = 0,
|
||||||
|
existing?: CombatActorState | null,
|
||||||
|
): CombatActorState {
|
||||||
|
const profile = resourceProfileForClass(member.classId);
|
||||||
|
const resources = existing?.kind === "companion"
|
||||||
|
? existing.resources
|
||||||
|
: Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id,
|
||||||
|
normalizeActorResourcePool(
|
||||||
|
id,
|
||||||
|
current ?? 0,
|
||||||
|
id === member.resourceType ? member.maxResource : 100,
|
||||||
|
id === member.resourceType ? profile.regenerationPerSecond : 0,
|
||||||
|
member.lastResourceSpentAt[id as ResourceType] ?? 0,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
const actor = existing?.kind === "companion" ? existing : null;
|
||||||
|
return Object.freeze({
|
||||||
|
id: member.id,
|
||||||
|
threatActorId: member.id,
|
||||||
|
kind: "companion" as const,
|
||||||
|
classId: member.classId,
|
||||||
|
level: member.level,
|
||||||
|
health: actor?.health ?? member.health,
|
||||||
|
maxHealth: actor?.maxHealth ?? member.maxHealth,
|
||||||
|
absorb,
|
||||||
|
resources,
|
||||||
|
cooldowns: actor?.cooldowns ?? member.cooldowns,
|
||||||
|
globalCooldownEndsAt: actor?.globalCooldownEndsAt ?? member.globalCooldownEndsAt,
|
||||||
|
cast: actor?.cast ?? member.activeCast,
|
||||||
|
controlledUntil: actor?.controlledUntil ?? member.controlledUntil,
|
||||||
|
schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil,
|
||||||
|
comboPoints: actor?.comboPoints ?? 0,
|
||||||
|
comboTargetId: actor?.comboTargetId ?? null,
|
||||||
|
runes: actor?.runes ?? member.runes,
|
||||||
|
abilityIds: actor?.abilityIds,
|
||||||
|
alive: (actor?.health ?? member.health) > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Party and scene consumers receive a read-compatible mirror of actor state. */
|
||||||
|
export function memberFromCompanionActor(member: PartyMember, actor?: CombatActorState | null): PartyMember {
|
||||||
|
if (!actor || actor.kind !== "companion") return member;
|
||||||
|
const resourcePools = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.current])) as Partial<Record<ResourceType, number>>;
|
||||||
|
const lastResourceSpentAt = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.lastSpentAt])) as Partial<Record<ResourceType, number>>;
|
||||||
|
const primary = actor.resources[member.resourceType];
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
health: actor.health,
|
||||||
|
maxHealth: actor.maxHealth,
|
||||||
|
resource: primary?.current ?? member.resource,
|
||||||
|
maxResource: primary?.maximum ?? member.maxResource,
|
||||||
|
resourcePools,
|
||||||
|
lastResourceSpentAt,
|
||||||
|
cooldowns: actor.cooldowns,
|
||||||
|
globalCooldownEndsAt: actor.globalCooldownEndsAt,
|
||||||
|
activeCast: actor.cast,
|
||||||
|
activeCastTargetId: actor.cast?.targetId ?? member.activeCastTargetId,
|
||||||
|
schoolLockedUntil: actor.schoolLockedUntil,
|
||||||
|
runes: actor.runes,
|
||||||
|
controlledUntil: actor.controlledUntil,
|
||||||
|
status: actor.health <= 0 ? "down" : member.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function companionActorWithMemberRuntime(
|
||||||
|
actor: CombatActorState,
|
||||||
|
member: PartyMember,
|
||||||
|
): CombatActorState {
|
||||||
|
const profile = resourceProfileForClass(member.classId);
|
||||||
|
const resources = Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id,
|
||||||
|
normalizeActorResourcePool(
|
||||||
|
id,
|
||||||
|
current ?? 0,
|
||||||
|
id === member.resourceType ? member.maxResource : actor.resources[id]?.maximum ?? 100,
|
||||||
|
id === member.resourceType ? profile.regenerationPerSecond : actor.resources[id]?.regenerationPerSecond ?? 0,
|
||||||
|
member.lastResourceSpentAt[id as ResourceType] ?? actor.resources[id]?.lastSpentAt ?? 0,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
return Object.freeze({
|
||||||
|
...actor,
|
||||||
|
health: member.health,
|
||||||
|
maxHealth: member.maxHealth,
|
||||||
|
resources,
|
||||||
|
cooldowns: member.cooldowns,
|
||||||
|
globalCooldownEndsAt: member.globalCooldownEndsAt,
|
||||||
|
cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null,
|
||||||
|
controlledUntil: member.controlledUntil,
|
||||||
|
schoolLockedUntil: member.schoolLockedUntil,
|
||||||
|
runes: member.runes,
|
||||||
|
alive: member.health > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { ClassId } from "../app/characterCatalog";
|
||||||
|
import type { DamageSchool } from "./combatAuras";
|
||||||
|
|
||||||
|
export type CombatActorKind = "player" | "companion" | "enemy" | "pet" | "totem" | "summon";
|
||||||
|
export type CombatResourceId = string;
|
||||||
|
export type DeathKnightRuneType = "blood" | "frost" | "unholy" | "death";
|
||||||
|
export type CombatActorPosition = readonly [number, number, number];
|
||||||
|
|
||||||
|
export interface CombatResourcePoolState {
|
||||||
|
readonly id: CombatResourceId;
|
||||||
|
readonly current: number;
|
||||||
|
readonly maximum: number;
|
||||||
|
readonly regenerationPerSecond: number;
|
||||||
|
/** Mana regeneration observes the five-second rule from this timestamp. */
|
||||||
|
readonly lastSpentAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeathKnightRuneState {
|
||||||
|
readonly slot: number;
|
||||||
|
readonly baseType: Exclude<DeathKnightRuneType, "death">;
|
||||||
|
readonly type: DeathKnightRuneType;
|
||||||
|
readonly readyAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatActorCastState {
|
||||||
|
readonly abilityId: string;
|
||||||
|
readonly school: DamageSchool;
|
||||||
|
readonly mode: "cast" | "channel";
|
||||||
|
readonly startedAt: number;
|
||||||
|
readonly completesAt: number;
|
||||||
|
readonly originalDurationMs: number;
|
||||||
|
readonly pushbackHits: number;
|
||||||
|
/** Simulation-space origin used for movement cancellation and completion validation. */
|
||||||
|
readonly origin?: CombatActorPosition;
|
||||||
|
readonly targetId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderer-free snapshot shared by every combatant. Specialized player, party,
|
||||||
|
* and mob records may retain presentation fields, but combat rules consume
|
||||||
|
* this common contract.
|
||||||
|
*/
|
||||||
|
export interface CombatActorState {
|
||||||
|
readonly id: string;
|
||||||
|
/** Stable id used by threat tables even when a renderer entity is replaced. */
|
||||||
|
readonly threatActorId?: string;
|
||||||
|
readonly kind: CombatActorKind;
|
||||||
|
readonly ownerId?: string;
|
||||||
|
readonly classId?: ClassId;
|
||||||
|
readonly level: number;
|
||||||
|
readonly health: number;
|
||||||
|
readonly maxHealth: number;
|
||||||
|
readonly absorb: number;
|
||||||
|
readonly resources: Readonly<Record<CombatResourceId, CombatResourcePoolState>>;
|
||||||
|
readonly cooldowns: Readonly<Record<string, number>>;
|
||||||
|
readonly globalCooldownEndsAt: number;
|
||||||
|
readonly cast: CombatActorCastState | null;
|
||||||
|
readonly controlledUntil: number;
|
||||||
|
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
||||||
|
readonly comboPoints: number;
|
||||||
|
readonly comboTargetId: string | null;
|
||||||
|
readonly runes: readonly DeathKnightRuneState[];
|
||||||
|
/** Executable catalog abilities owned by pets, summons, and companions. */
|
||||||
|
readonly abilityIds?: readonly string[];
|
||||||
|
readonly alive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeathKnightRunes(now = 0): readonly DeathKnightRuneState[] {
|
||||||
|
const types = ["blood", "blood", "frost", "frost", "unholy", "unholy"] as const;
|
||||||
|
return Object.freeze(types.map((type, slot) => Object.freeze({
|
||||||
|
slot,
|
||||||
|
baseType: type,
|
||||||
|
type,
|
||||||
|
readyAt: Math.max(0, now),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeActorResourcePool(
|
||||||
|
id: CombatResourceId,
|
||||||
|
current: number,
|
||||||
|
maximum: number,
|
||||||
|
regenerationPerSecond = 0,
|
||||||
|
lastSpentAt = 0,
|
||||||
|
): CombatResourcePoolState {
|
||||||
|
const safeMaximum = Math.max(0, Number.isFinite(maximum) ? maximum : 0);
|
||||||
|
return Object.freeze({
|
||||||
|
id,
|
||||||
|
current: Math.max(0, Math.min(safeMaximum, Number.isFinite(current) ? current : 0)),
|
||||||
|
maximum: safeMaximum,
|
||||||
|
regenerationPerSecond: Math.max(0, Number.isFinite(regenerationPerSecond) ? regenerationPerSecond : 0),
|
||||||
|
lastSpentAt: Math.max(0, Number.isFinite(lastSpentAt) ? lastSpentAt : 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spendActorResource(
|
||||||
|
pool: CombatResourcePoolState,
|
||||||
|
amount: number,
|
||||||
|
now: number,
|
||||||
|
): CombatResourcePoolState | null {
|
||||||
|
const requested = Math.max(0, Number.isFinite(amount) ? amount : 0);
|
||||||
|
if (pool.current + Number.EPSILON < requested) return null;
|
||||||
|
return Object.freeze({
|
||||||
|
...pool,
|
||||||
|
current: Math.max(0, pool.current - requested),
|
||||||
|
lastSpentAt: Math.max(pool.lastSpentAt, Number.isFinite(now) ? now : 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreActorResource(
|
||||||
|
pool: CombatResourcePoolState,
|
||||||
|
amount: number,
|
||||||
|
): CombatResourcePoolState {
|
||||||
|
return Object.freeze({
|
||||||
|
...pool,
|
||||||
|
current: Math.min(pool.maximum, pool.current + Math.max(0, Number.isFinite(amount) ? amount : 0)),
|
||||||
|
});
|
||||||
|
}
|
||||||
+25
-4
@@ -143,6 +143,16 @@ export interface AuraDefinition {
|
|||||||
readonly stackBehavior?: AuraStackBehavior;
|
readonly stackBehavior?: AuraStackBehavior;
|
||||||
/** Defaults to true. False makes applications from all sources share one aura. */
|
/** Defaults to true. False makes applications from all sources share one aura. */
|
||||||
readonly uniqueBySource?: boolean;
|
readonly uniqueBySource?: boolean;
|
||||||
|
/** A target can have only one aura from an exclusive stance/form/seal/etc. group. */
|
||||||
|
readonly exclusiveGroup?: string;
|
||||||
|
/** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */
|
||||||
|
readonly utilityTags?: readonly string[];
|
||||||
|
/** Enemy control mechanics ignored while this aura is active. */
|
||||||
|
readonly controlImmunities?: readonly ("fear" | "sleep" | "root" | "stun" | "silence")[];
|
||||||
|
/** Chance from 0 through 1 to reflect an incoming spell. */
|
||||||
|
readonly reflectSpellChance?: number;
|
||||||
|
/** Portion of incoming target damage transferred to the aura source. */
|
||||||
|
readonly redirectDamagePercent?: number;
|
||||||
readonly dispelCategory?: DispelCategory;
|
readonly dispelCategory?: DispelCategory;
|
||||||
readonly dispelPriority?: number;
|
readonly dispelPriority?: number;
|
||||||
readonly modifiers?: readonly AuraModifier[];
|
readonly modifiers?: readonly AuraModifier[];
|
||||||
@@ -309,14 +319,25 @@ export function applyAura(
|
|||||||
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
|
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
|
||||||
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
|
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
|
||||||
const instanceId = auraIdentity(definition, request.sourceId, request.targetId);
|
const instanceId = auraIdentity(definition, request.sourceId, request.targetId);
|
||||||
const existingIndex = current.findIndex((aura) => aura.instanceId === instanceId);
|
const exclusive = definition.exclusiveGroup
|
||||||
|
? current.filter((aura) => (
|
||||||
|
aura.targetId === request.targetId
|
||||||
|
&& aura.definition.exclusiveGroup === definition.exclusiveGroup
|
||||||
|
&& aura.instanceId !== instanceId
|
||||||
|
))
|
||||||
|
: [];
|
||||||
|
const exclusiveIds = new Set(exclusive.map((aura) => aura.instanceId));
|
||||||
|
const base = exclusiveIds.size > 0
|
||||||
|
? current.filter((aura) => !exclusiveIds.has(aura.instanceId))
|
||||||
|
: current;
|
||||||
|
const existingIndex = base.findIndex((aura) => aura.instanceId === instanceId);
|
||||||
|
|
||||||
if (existingIndex < 0) {
|
if (existingIndex < 0) {
|
||||||
const aura = makeAura(request, requestedStacks);
|
const aura = makeAura(request, requestedStacks);
|
||||||
return { auras: [...current, aura], aura };
|
return { auras: [...base, aura], aura, ...(exclusive[0] ? { replaced: exclusive[0] } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = current[existingIndex];
|
const existing = base[existingIndex];
|
||||||
const behavior = definition.stackBehavior ?? "refresh";
|
const behavior = definition.stackBehavior ?? "refresh";
|
||||||
const stacks = behavior === "add"
|
const stacks = behavior === "add"
|
||||||
? Math.min(maxStacks, existing.stacks + requestedStacks)
|
? Math.min(maxStacks, existing.stacks + requestedStacks)
|
||||||
@@ -333,7 +354,7 @@ export function applyAura(
|
|||||||
procStates: existing.procStates,
|
procStates: existing.procStates,
|
||||||
}
|
}
|
||||||
: replacement;
|
: replacement;
|
||||||
const auras = [...current];
|
const auras = [...base];
|
||||||
auras[existingIndex] = aura;
|
auras[existingIndex] = aura;
|
||||||
return { auras, aura, replaced: existing };
|
return { auras, aura, replaced: existing };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { DamageSchool } from "./combatAuras";
|
||||||
|
|
||||||
|
export type StructuredCombatEventKind =
|
||||||
|
| "damage"
|
||||||
|
| "healing"
|
||||||
|
| "absorb"
|
||||||
|
| "resource"
|
||||||
|
| "aura"
|
||||||
|
| "cast"
|
||||||
|
| "interrupt"
|
||||||
|
| "death"
|
||||||
|
| "resurrection";
|
||||||
|
|
||||||
|
export interface StructuredCombatEvent {
|
||||||
|
readonly id: number;
|
||||||
|
readonly occurredAt: number;
|
||||||
|
readonly kind: StructuredCombatEventKind;
|
||||||
|
readonly sourceActorId: string;
|
||||||
|
readonly targetActorId: string;
|
||||||
|
readonly abilityId?: string;
|
||||||
|
readonly school?: DamageSchool;
|
||||||
|
readonly rawAmount: number;
|
||||||
|
readonly effectiveAmount: number;
|
||||||
|
readonly overheal: number;
|
||||||
|
readonly absorbed: number;
|
||||||
|
readonly critical: boolean;
|
||||||
|
readonly outcome?: string;
|
||||||
|
}
|
||||||
|
export type CombatEventInput = Omit<StructuredCombatEvent, "id">;
|
||||||
|
|
||||||
|
export const MAX_COMBAT_EVENT_HISTORY = 200;
|
||||||
|
|
||||||
|
export function appendCombatEvent(
|
||||||
|
events: readonly StructuredCombatEvent[],
|
||||||
|
event: CombatEventInput,
|
||||||
|
id: number,
|
||||||
|
maximum = MAX_COMBAT_EVENT_HISTORY,
|
||||||
|
): readonly StructuredCombatEvent[] {
|
||||||
|
const next = Object.freeze({
|
||||||
|
...event,
|
||||||
|
id,
|
||||||
|
occurredAt: Math.max(0, Number.isFinite(event.occurredAt) ? event.occurredAt : 0),
|
||||||
|
rawAmount: Math.max(0, Number.isFinite(event.rawAmount) ? event.rawAmount : 0),
|
||||||
|
effectiveAmount: Math.max(0, Number.isFinite(event.effectiveAmount) ? event.effectiveAmount : 0),
|
||||||
|
overheal: Math.max(0, Number.isFinite(event.overheal) ? event.overheal : 0),
|
||||||
|
absorbed: Math.max(0, Number.isFinite(event.absorbed) ? event.absorbed : 0),
|
||||||
|
});
|
||||||
|
return Object.freeze([...events, next].slice(-Math.max(1, Math.trunc(maximum))));
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveHealing } from "./combatHealing";
|
||||||
|
|
||||||
|
describe("combat healing resolution", () => {
|
||||||
|
it("reports critical raw healing, effective healing, and overheal independently", () => {
|
||||||
|
expect(resolveHealing(100, 950, 1_000, 0.25, 0.1)).toEqual({
|
||||||
|
rawAmount: 150,
|
||||||
|
effectiveAmount: 50,
|
||||||
|
overheal: 100,
|
||||||
|
critical: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not resurrect a zero-health actor implicitly", () => {
|
||||||
|
const result = resolveHealing(100, 0, 1_000, 0, 1);
|
||||||
|
expect(result).toMatchObject({ rawAmount: 100, effectiveAmount: 100, overheal: 0, critical: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface HealingResolution {
|
||||||
|
readonly rawAmount: number;
|
||||||
|
readonly effectiveAmount: number;
|
||||||
|
readonly overheal: number;
|
||||||
|
readonly critical: boolean;
|
||||||
|
}
|
||||||
|
export function resolveHealing(
|
||||||
|
amount: number,
|
||||||
|
currentHealth: number,
|
||||||
|
maximumHealth: number,
|
||||||
|
criticalChance = 0,
|
||||||
|
roll = 1,
|
||||||
|
criticalMultiplier = 1.5,
|
||||||
|
): HealingResolution {
|
||||||
|
const critical = Number.isFinite(roll) && roll < Math.max(0, Math.min(1, criticalChance));
|
||||||
|
const rawAmount = Math.max(0, Math.round(
|
||||||
|
(Number.isFinite(amount) ? amount : 0) * (critical ? Math.max(1, criticalMultiplier) : 1),
|
||||||
|
));
|
||||||
|
const missing = Math.max(0, (Number.isFinite(maximumHealth) ? maximumHealth : 0)
|
||||||
|
- (Number.isFinite(currentHealth) ? currentHealth : 0));
|
||||||
|
const effectiveAmount = Math.min(rawAmount, missing);
|
||||||
|
return Object.freeze({
|
||||||
|
rawAmount,
|
||||||
|
effectiveAmount,
|
||||||
|
overheal: rawAmount - effectiveAmount,
|
||||||
|
critical,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createDeathKnightRunes, normalizeActorResourcePool } from "./combatActors";
|
||||||
|
import {
|
||||||
|
activateDeathKnightRunes,
|
||||||
|
advanceCombatResourcePool,
|
||||||
|
spendDeathKnightRunes,
|
||||||
|
} from "./combatResources";
|
||||||
|
|
||||||
|
describe("authentic combat resources", () => {
|
||||||
|
it("suppresses spirit mana regeneration for five seconds after spending", () => {
|
||||||
|
const mana = normalizeActorResourcePool("mana", 50, 100, 12, 1_000);
|
||||||
|
const insideRule = advanceCombatResourcePool(mana, {
|
||||||
|
inCombat: true,
|
||||||
|
actorLevel: 80,
|
||||||
|
now: 5_999,
|
||||||
|
deltaSeconds: 1,
|
||||||
|
manaSpiritRegenerationPerSecond: 8,
|
||||||
|
});
|
||||||
|
const outsideRule = advanceCombatResourcePool(mana, {
|
||||||
|
inCombat: true,
|
||||||
|
actorLevel: 80,
|
||||||
|
now: 6_000,
|
||||||
|
deltaSeconds: 1,
|
||||||
|
manaSpiritRegenerationPerSecond: 8,
|
||||||
|
});
|
||||||
|
expect(insideRule.current).toBe(54);
|
||||||
|
expect(outsideRule.current).toBe(62);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decays rage and runic power only out of combat", () => {
|
||||||
|
const rage = normalizeActorResourcePool("rage", 20, 100);
|
||||||
|
const runic = normalizeActorResourcePool("runic-power", 20, 100);
|
||||||
|
expect(advanceCombatResourcePool(rage, { inCombat: false, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(17);
|
||||||
|
expect(advanceCombatResourcePool(runic, { inCombat: false, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(17.5);
|
||||||
|
expect(advanceCombatResourcePool(rage, { inCombat: true, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recharges six Death Knight runes independently", () => {
|
||||||
|
const initial = createDeathKnightRunes();
|
||||||
|
const first = spendDeathKnightRunes(initial, { frost: 1, unholy: 1 }, 1_000)!;
|
||||||
|
expect(first).toHaveLength(6);
|
||||||
|
expect(first.filter((rune) => rune.readyAt === 11_000)).toHaveLength(2);
|
||||||
|
expect(spendDeathKnightRunes(first, { frost: 2 }, 1_001)).toBeNull();
|
||||||
|
const activated = activateDeathKnightRunes(first, 1);
|
||||||
|
expect(activated.filter((rune) => rune.readyAt === 0)).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
createDeathKnightRunes,
|
||||||
|
type CombatResourcePoolState,
|
||||||
|
type DeathKnightRuneState,
|
||||||
|
type DeathKnightRuneType,
|
||||||
|
} from "./combatActors";
|
||||||
|
|
||||||
|
export const MANA_FIVE_SECOND_RULE_MS = 5_000;
|
||||||
|
export const DEATH_KNIGHT_RUNE_RECHARGE_MS = 10_000;
|
||||||
|
|
||||||
|
export interface CombatResourceAdvanceRules {
|
||||||
|
readonly inCombat: boolean;
|
||||||
|
readonly actorLevel: number;
|
||||||
|
readonly now: number;
|
||||||
|
readonly deltaSeconds: number;
|
||||||
|
readonly manaSpiritRegenerationPerSecond?: number;
|
||||||
|
}
|
||||||
|
export function advanceCombatResourcePool(
|
||||||
|
pool: CombatResourcePoolState,
|
||||||
|
rules: CombatResourceAdvanceRules,
|
||||||
|
): CombatResourcePoolState {
|
||||||
|
const delta = Math.max(0, Math.min(1, Number.isFinite(rules.deltaSeconds) ? rules.deltaSeconds : 0));
|
||||||
|
let rate = pool.regenerationPerSecond;
|
||||||
|
if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) {
|
||||||
|
rate = Math.max(0, rate - Math.max(0, rules.manaSpiritRegenerationPerSecond ?? 0));
|
||||||
|
}
|
||||||
|
if ((pool.id === "rage" || pool.id === "runic-power") && !rules.inCombat) {
|
||||||
|
rate = pool.id === "rage" ? -3 : -2.5;
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
...pool,
|
||||||
|
current: Math.max(0, Math.min(pool.maximum, pool.current + rate * delta)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuneCost {
|
||||||
|
readonly blood?: number;
|
||||||
|
readonly frost?: number;
|
||||||
|
readonly unholy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function availableDeathKnightRunes(
|
||||||
|
runes: readonly DeathKnightRuneState[],
|
||||||
|
now: number,
|
||||||
|
): readonly DeathKnightRuneState[] {
|
||||||
|
return runes.filter((rune) => rune.readyAt <= now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runeMatches(rune: DeathKnightRuneState, type: Exclude<DeathKnightRuneType, "death">): boolean {
|
||||||
|
return rune.type === type || rune.type === "death";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spendDeathKnightRunes(
|
||||||
|
runes: readonly DeathKnightRuneState[] | undefined,
|
||||||
|
cost: RuneCost,
|
||||||
|
now: number,
|
||||||
|
rechargeMs = DEATH_KNIGHT_RUNE_RECHARGE_MS,
|
||||||
|
): readonly DeathKnightRuneState[] | null {
|
||||||
|
const current = runes?.length === 6 ? [...runes] : [...createDeathKnightRunes(now)];
|
||||||
|
const spent = new Set<number>();
|
||||||
|
for (const type of ["blood", "frost", "unholy"] as const) {
|
||||||
|
const count = Math.max(0, Math.trunc(cost[type] ?? 0));
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const candidate = current.find((rune) => (
|
||||||
|
!spent.has(rune.slot) && rune.readyAt <= now && runeMatches(rune, type)
|
||||||
|
));
|
||||||
|
if (!candidate) return null;
|
||||||
|
spent.add(candidate.slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.freeze(current.map((rune) => spent.has(rune.slot)
|
||||||
|
? Object.freeze({ ...rune, readyAt: now + Math.max(1, rechargeMs) })
|
||||||
|
: rune));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateDeathKnightRunes(
|
||||||
|
runes: readonly DeathKnightRuneState[],
|
||||||
|
count: number,
|
||||||
|
): readonly DeathKnightRuneState[] {
|
||||||
|
let remaining = Math.max(0, Math.trunc(count));
|
||||||
|
return Object.freeze([...runes]
|
||||||
|
.sort((left, right) => right.readyAt - left.readyAt || left.slot - right.slot)
|
||||||
|
.map((rune) => {
|
||||||
|
if (remaining <= 0 || rune.readyAt <= 0) return rune;
|
||||||
|
remaining -= 1;
|
||||||
|
return Object.freeze({ ...rune, readyAt: 0 });
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.slot - right.slot));
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { CLASSES, type ClassId } from "../app/characterCatalog";
|
import { CLASSES, type ClassId } from "../app/characterCatalog";
|
||||||
import { COA_ABILITIES_BY_CLASS, COA_CLASS_RESOURCES } from "./coaAbilityCatalog";
|
import {
|
||||||
|
COA_ABILITIES_BY_CLASS,
|
||||||
|
COA_CLASS_RESOURCES,
|
||||||
|
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
|
} from "./coaAbilityCatalog";
|
||||||
import {
|
import {
|
||||||
COA_RUNTIME_ABILITIES_BY_CLASS,
|
COA_RUNTIME_ABILITIES_BY_CLASS,
|
||||||
COA_RUNTIME_CLASS_RESOURCES,
|
COA_RUNTIME_CLASS_RESOURCES,
|
||||||
|
COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||||
} from "./coaAbilityRuntimeCatalog";
|
} from "./coaAbilityRuntimeCatalog";
|
||||||
import generatedTalentRuntime from "./generated/talentRuntimeCatalog.json";
|
import generatedTalentRuntime from "./generated/talentRuntimeCatalog.json";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +26,7 @@ describe("compact combat runtime catalogs", () => {
|
|||||||
it("preserves every derived COA ability and resource profile", () => {
|
it("preserves every derived COA ability and resource profile", () => {
|
||||||
expect(COA_RUNTIME_ABILITIES_BY_CLASS).toEqual(COA_ABILITIES_BY_CLASS);
|
expect(COA_RUNTIME_ABILITIES_BY_CLASS).toEqual(COA_ABILITIES_BY_CLASS);
|
||||||
expect(COA_RUNTIME_CLASS_RESOURCES).toEqual(COA_CLASS_RESOURCES);
|
expect(COA_RUNTIME_CLASS_RESOURCES).toEqual(COA_CLASS_RESOURCES);
|
||||||
|
expect(COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID).toEqual(COA_TRIGGERED_ABILITIES_BY_SPELL_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves every talent field used by runtime allocation and modifiers", () => {
|
it("preserves every talent field used by runtime allocation and modifiers", () => {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
combatHasLineOfSight,
|
||||||
|
isPositionInFacingArc,
|
||||||
|
isSourceBehindTarget,
|
||||||
|
projectCombatGroundPoint,
|
||||||
|
registerCombatSpatialProvider,
|
||||||
|
resetCombatSpatialProvider,
|
||||||
|
} from "./combatSpatial";
|
||||||
|
|
||||||
|
describe("renderer-independent combat spatial queries", () => {
|
||||||
|
afterEach(resetCombatSpatialProvider);
|
||||||
|
|
||||||
|
it("validates facing and behind arcs", () => {
|
||||||
|
const actor = { id: "actor", position: [0, 0, 0] as const, yaw: 0 };
|
||||||
|
expect(isPositionInFacingArc(actor, [0, 0, 5])).toBe(true);
|
||||||
|
expect(isPositionInFacingArc(actor, [0, 0, -5])).toBe(false);
|
||||||
|
expect(isSourceBehindTarget([0, 0, -2], actor)).toBe(true);
|
||||||
|
expect(isSourceBehindTarget([0, 0, 2], actor)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates line of sight and collision-safe ground projection", () => {
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: () => null,
|
||||||
|
hasLineOfSight: (_source, target) => target[0] >= 0,
|
||||||
|
projectGroundPoint: (requested) => requested[1] > 5 ? null : [requested[0], 0, requested[2]],
|
||||||
|
});
|
||||||
|
expect(combatHasLineOfSight([0, 0, 0], [2, 0, 0])).toBe(true);
|
||||||
|
expect(combatHasLineOfSight([0, 0, 0], [-2, 0, 0])).toBe(false);
|
||||||
|
expect(projectCombatGroundPoint([2, 3, 4], 10)).toEqual([2, 0, 4]);
|
||||||
|
expect(projectCombatGroundPoint([2, 8, 4], 10)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
|
||||||
|
|
||||||
|
export interface CombatSpatialActorSnapshot {
|
||||||
|
readonly id: string;
|
||||||
|
readonly position: CombatSpatialPosition;
|
||||||
|
/** World-space yaw in radians; +Z is forward. */
|
||||||
|
readonly yaw: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatSpatialProvider {
|
||||||
|
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
|
||||||
|
readonly hasLineOfSight: (
|
||||||
|
source: CombatSpatialPosition,
|
||||||
|
target: CombatSpatialPosition,
|
||||||
|
) => boolean;
|
||||||
|
readonly projectGroundPoint: (
|
||||||
|
requested: CombatSpatialPosition,
|
||||||
|
maximumDistance: number,
|
||||||
|
origin?: CombatSpatialPosition,
|
||||||
|
) => CombatSpatialPosition | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider: CombatSpatialProvider | null = null;
|
||||||
|
|
||||||
|
/** Registers the loaded dungeon's read-only spatial query seam. */
|
||||||
|
export function registerCombatSpatialProvider(next: CombatSpatialProvider): () => void {
|
||||||
|
provider = next;
|
||||||
|
return () => {
|
||||||
|
if (provider === next) provider = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetCombatSpatialProvider(): void {
|
||||||
|
provider = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combatSpatialActor(actorId: string): CombatSpatialActorSnapshot | null {
|
||||||
|
return provider?.actor(actorId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combatHasLineOfSight(
|
||||||
|
source: CombatSpatialPosition,
|
||||||
|
target: CombatSpatialPosition,
|
||||||
|
): boolean {
|
||||||
|
return provider?.hasLineOfSight(source, target) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectCombatGroundPoint(
|
||||||
|
requested: CombatSpatialPosition,
|
||||||
|
maximumDistance: number,
|
||||||
|
origin?: CombatSpatialPosition,
|
||||||
|
): CombatSpatialPosition | null {
|
||||||
|
if (!requested.every(Number.isFinite) || !Number.isFinite(maximumDistance) || maximumDistance < 0) return null;
|
||||||
|
return provider ? provider.projectGroundPoint(requested, maximumDistance, origin) : requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedPlanarDirection(
|
||||||
|
from: CombatSpatialPosition,
|
||||||
|
to: CombatSpatialPosition,
|
||||||
|
): readonly [number, number] | null {
|
||||||
|
const dx = to[0] - from[0];
|
||||||
|
const dz = to[2] - from[2];
|
||||||
|
const length = Math.hypot(dx, dz);
|
||||||
|
return length > 1e-5 ? [dx / length, dz / length] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPositionInFacingArc(
|
||||||
|
source: CombatSpatialActorSnapshot,
|
||||||
|
target: CombatSpatialPosition,
|
||||||
|
arcRadians = Math.PI,
|
||||||
|
): boolean {
|
||||||
|
const direction = normalizedPlanarDirection(source.position, target);
|
||||||
|
if (!direction) return true;
|
||||||
|
const forwardX = Math.sin(source.yaw);
|
||||||
|
const forwardZ = Math.cos(source.yaw);
|
||||||
|
const cosine = forwardX * direction[0] + forwardZ * direction[1];
|
||||||
|
return cosine >= Math.cos(Math.max(0, Math.min(Math.PI * 2, arcRadians)) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSourceBehindTarget(
|
||||||
|
source: CombatSpatialPosition,
|
||||||
|
target: CombatSpatialActorSnapshot,
|
||||||
|
rearArcRadians = Math.PI,
|
||||||
|
): boolean {
|
||||||
|
return !isPositionInFacingArc(target, source, Math.PI * 2 - rearArcRadians);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
EMPTY_CORPSE_DESPAWN_MS,
|
EMPTY_CORPSE_DESPAWN_MS,
|
||||||
MINIMUM_CORPSE_VISIBLE_MS,
|
MINIMUM_CORPSE_VISIBLE_MS,
|
||||||
MOB_WOUND_ANIMATION_COOLDOWN_MS,
|
MOB_WOUND_ANIMATION_COOLDOWN_MS,
|
||||||
|
PLAYER_AURA_ENTITY_ID,
|
||||||
selectedMob,
|
selectedMob,
|
||||||
useCombatStore,
|
useCombatStore,
|
||||||
} from "./combatStore";
|
} from "./combatStore";
|
||||||
@@ -90,7 +91,7 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
|
||||||
useCombatStore.getState().tick(0.1, 2500);
|
useCombatStore.getState().tick(0.1, 2500);
|
||||||
expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100);
|
expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingResource - 14);
|
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.14));
|
||||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||||
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
|
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -950,7 +951,7 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
|
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
|
||||||
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
|
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
|
||||||
useCombatStore.getState().tick(0.1, boltCompletion);
|
useCombatStore.getState().tick(0.1, boltCompletion);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingMana - 9);
|
expect(useCombatStore.getState().resource).toBe(startingMana - Math.round(startingMana * 0.1 * 0.85));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes a cast-time direct heal to the selected living party member", () => {
|
it("routes a cast-time direct heal to the selected living party member", () => {
|
||||||
@@ -972,6 +973,154 @@ describe("combat store", () => {
|
|||||||
expect(useCombatStore.getState().selectedTargetId).toBe("hostile-selection");
|
expect(useCombatStore.getState().selectedTargetId).toBe("hostile-selection");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fully heals the selected ally with Lay on Hands and starts its cooldown", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 10 });
|
||||||
|
usePartyStore.getState().initializeParty("lay-on-hands-test", 10, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [10, 0, 0], 1, true);
|
||||||
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility(
|
||||||
|
"wow335-paladin-lay-on-hands",
|
||||||
|
[0, 0, 0],
|
||||||
|
1_000,
|
||||||
|
)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
healing: memberBefore.maxHealth - 1,
|
||||||
|
});
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)?.health)
|
||||||
|
.toBe(memberBefore.maxHealth);
|
||||||
|
expect(useCombatStore.getState().cooldowns["wow335-paladin-lay-on-hands"])
|
||||||
|
.toBe(1_201_000);
|
||||||
|
expect(useCombatStore.getState().castAbility(
|
||||||
|
"wow335-paladin-lay-on-hands",
|
||||||
|
[0, 0, 0],
|
||||||
|
2_500,
|
||||||
|
)).toMatchObject({ ok: false, reason: "cooldown" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the selected ally immune to physical attacks with Hand of Protection", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 10 });
|
||||||
|
usePartyStore.getState().initializeParty("hand-of-protection-test", 10, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], 1, true);
|
||||||
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility(
|
||||||
|
"wow335-paladin-hand-of-protection",
|
||||||
|
[0, 0, 0],
|
||||||
|
1_000,
|
||||||
|
)).toMatchObject({ ok: true });
|
||||||
|
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||||
|
targetId: memberId,
|
||||||
|
expiresAt: 7_000,
|
||||||
|
definition: expect.objectContaining({ name: expect.stringContaining("Hand of Protection") }),
|
||||||
|
}));
|
||||||
|
expect(useCombatStore.getState().cooldowns["wow335-paladin-hand-of-protection"])
|
||||||
|
.toBe(301_000);
|
||||||
|
|
||||||
|
useCombatStore.getState().registerMob("party-victim", {
|
||||||
|
name: "Party victim",
|
||||||
|
maxHealth: 200,
|
||||||
|
xpReward: 0,
|
||||||
|
});
|
||||||
|
setMobPosition("party-victim", [2, 0, 0]);
|
||||||
|
useCombatStore.getState().damageMob("party-victim", 5, 1_000, {
|
||||||
|
actorId: memberId,
|
||||||
|
role: "tank",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBe(0);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)?.health)
|
||||||
|
.toBe(memberBefore.health);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears roots with Hand of Freedom and replaces mutually exclusive seals", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("paladin-aura-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
||||||
|
usePartyStore.getState().controlMember(memberId, "root", 30_000);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-hand-of-freedom", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)).toMatchObject({
|
||||||
|
controlledUntil: 0,
|
||||||
|
controlMechanic: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
usePartyStore.getState().selectSelf();
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-seal-of-righteousness", [0, 0, 0], 3_000).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-seal-of-wisdom", [0, 0, 0], 5_000).ok).toBe(true);
|
||||||
|
const seals = useCombatStore.getState().auras.filter((aura) => (
|
||||||
|
aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.exclusiveGroup === "paladin:seal"
|
||||||
|
));
|
||||||
|
expect(seals).toHaveLength(1);
|
||||||
|
expect(seals[0].definition.name).toContain("Seal of Wisdom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects Hand of Sacrifice damage to the paladin", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("hand-of-sacrifice-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
||||||
|
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
|
||||||
|
const playerBefore = useCombatStore.getState().health;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-hand-of-sacrifice", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
useCombatStore.getState().registerMob("sacrifice-attacker", { name: "Sacrifice attacker", maxHealth: 200, xpReward: 0 });
|
||||||
|
setMobPosition("sacrifice-attacker", [2, 0, 0]);
|
||||||
|
useCombatStore.getState().damageMob("sacrifice-attacker", 5, 1_000, { actorId: memberId, role: "tank" });
|
||||||
|
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const memberLoss = memberBefore.health - usePartyStore.getState().members.find((member) => member.id === memberId)!.health;
|
||||||
|
const playerLoss = playerBefore - useCombatStore.getState().health;
|
||||||
|
expect(memberLoss).toBeGreaterThan(0);
|
||||||
|
expect(playerLoss).toBeGreaterThan(0);
|
||||||
|
expect(playerLoss).toBeLessThan(memberLoss);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taunts up to three attackers off an ally with Righteous Defense", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("righteous-defense-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
|
||||||
|
for (const id of ["attacker-a", "attacker-b", "attacker-c", "attacker-d"]) {
|
||||||
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100, xpReward: 0 });
|
||||||
|
setMobPosition(id, [2, 0, 0]);
|
||||||
|
}
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
mobs: Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => [id, {
|
||||||
|
...mob,
|
||||||
|
engaged: true,
|
||||||
|
targetActorId: memberId,
|
||||||
|
threatByActor: { [memberId]: 10 },
|
||||||
|
}])),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-paladin-righteous-defense", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
expect(Object.values(useCombatStore.getState().mobs).filter((mob) => mob.forcedTarget?.actorId === PLAYER_AURA_ENTITY_ID)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("channels party-wide classic healing without requiring an enemy target", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("divine-hymn-test", 80, useCombatStore.getState().health);
|
||||||
|
const memberId = preparePartyMember(0, [4, 0, 0], 1);
|
||||||
|
useCombatStore.getState().damagePlayer(500);
|
||||||
|
const playerBefore = useCombatStore.getState().health;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-priest-divine-hymn", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
const completion = useCombatStore.getState().activeCast?.completesAt;
|
||||||
|
if (completion) useCombatStore.getState().tick(1, completion);
|
||||||
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(playerBefore);
|
||||||
|
expect(usePartyStore.getState().members.find((member) => member.id === memberId)!.health).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes intentional classic utility spells instead of rejecting them", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||||
|
useCombatStore.getState().damagePlayer(500);
|
||||||
|
const before = useCombatStore.getState().health;
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-mage-conjure-food", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
const completion = useCombatStore.getState().activeCast?.completesAt;
|
||||||
|
if (completion) useCombatStore.getState().tick(1, completion);
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to the player and snapshots an explicit null friendly target", () => {
|
it("falls back to the player and snapshots an explicit null friendly target", () => {
|
||||||
registerTarget("enemy", [10, 0, 0], 100, 0);
|
registerTarget("enemy", [10, 0, 0], 100, 0);
|
||||||
const memberId = preparePartyMember(0, [8, 0, 0], 1);
|
const memberId = preparePartyMember(0, [8, 0, 0], 1);
|
||||||
|
|||||||
+2800
-195
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ describe("death respawn", () => {
|
|||||||
useCombatStore.getState().selectMob("respawn-pack:a");
|
useCombatStore.getState().selectMob("respawn-pack:a");
|
||||||
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
|
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
|
||||||
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
|
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
|
||||||
expect(useCombatStore.getState().resource).toBe(startingResource - 15);
|
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.15));
|
||||||
|
|
||||||
const revisionBefore = useGameStore.getState().resetRevision;
|
const revisionBefore = useGameStore.getState().resetRevision;
|
||||||
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||||
|
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
|
||||||
|
function registerTarget(id = "target", position: readonly [number, number, number] = [0, 0, 10]) {
|
||||||
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
|
||||||
|
setMobPosition(id, position);
|
||||||
|
useCombatStore.getState().selectMob(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dungeon combat fidelity integration", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetCombatSpatialProvider();
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 10 });
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
usePartyStore.getState().initializeParty("fidelity", 10, useCombatStore.getState().health);
|
||||||
|
});
|
||||||
|
afterEach(resetCombatSpatialProvider);
|
||||||
|
|
||||||
|
it("rejects obstructed and rear-arc hostile casts while friendly healing ignores facing", () => {
|
||||||
|
registerTarget();
|
||||||
|
let blocked = true;
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => id === PLAYER_AGGRO_ID
|
||||||
|
? { id, position: [0, 0, 0], yaw: Math.PI }
|
||||||
|
: { id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 },
|
||||||
|
hasLineOfSight: () => !blocked,
|
||||||
|
projectGroundPoint: (point) => point,
|
||||||
|
});
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 })).toMatchObject({ ok: false, reason: "line-of-sight" });
|
||||||
|
blocked = false;
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 })).toMatchObject({ ok: false, reason: "not-facing" });
|
||||||
|
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
|
||||||
|
const ally = usePartyStore.getState().members[0];
|
||||||
|
registerPartyRuntimePosition(ally.id, [0, 0, 10]);
|
||||||
|
usePartyStore.getState().selectMember(ally.id);
|
||||||
|
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === ally.id ? { ...member, health: 1 } : member) }));
|
||||||
|
const friendlyResult = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", { now: 2_000 });
|
||||||
|
expect(friendlyResult.reason).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies only the first two direct-damage pushbacks and ignores fully absorbed hits", () => {
|
||||||
|
registerTarget();
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 }).ok).toBe(true);
|
||||||
|
const original = useCombatStore.getState().activeCast!.completesAt;
|
||||||
|
useCombatStore.getState().damagePlayer(1);
|
||||||
|
useCombatStore.getState().damagePlayer(1);
|
||||||
|
useCombatStore.getState().damagePlayer(1);
|
||||||
|
expect(useCombatStore.getState().activeCast).toMatchObject({ completesAt: original + 1_000, pushbackHits: 2 });
|
||||||
|
|
||||||
|
useCombatStore.getState().cancelCast();
|
||||||
|
useCombatStore.setState({ globalCooldownEndsAt: 0, shield: 10_000, shieldExpiresAt: Number.MAX_SAFE_INTEGER });
|
||||||
|
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 5_000 }).ok).toBe(true);
|
||||||
|
const absorbedOriginal = useCombatStore.getState().activeCast!.completesAt;
|
||||||
|
useCombatStore.getState().damagePlayer(100);
|
||||||
|
expect(useCombatStore.getState().activeCast!.completesAt).toBe(absorbedOriginal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates persistent collision-safe ground effects and dynamically ticks actors in their radius", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
|
||||||
|
registerTarget("late-target", [20, 0, 0]);
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => ({ id, position: id === PLAYER_AGGRO_ID ? [0, 0, 0] : getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
|
||||||
|
hasLineOfSight: () => true,
|
||||||
|
projectGroundPoint: (point) => [point[0], 0, point[2]],
|
||||||
|
});
|
||||||
|
const groundResult = useCombatStore.getState().castAbility("paladin-consecration", { now: 1_000, groundPosition: [0, 3, 0] });
|
||||||
|
expect(groundResult.reason).toBeUndefined();
|
||||||
|
expect(useCombatStore.getState().areaEffects).toHaveLength(1);
|
||||||
|
setMobPosition("late-target", [2, 0, 0]);
|
||||||
|
const before = useCombatStore.getState().mobs["late-target"].health;
|
||||||
|
useCombatStore.getState().tick(1, 2_000);
|
||||||
|
expect(useCombatStore.getState().mobs["late-target"].health).toBeLessThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spends independent Death Knight runes and exposes every combatant through actor snapshots", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 10 });
|
||||||
|
registerTarget("rune-target", [0, 0, 10]);
|
||||||
|
expect(useCombatStore.getState().castAbility("death-knight-icy-touch", { now: 1_000 }).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().deathKnightRunes.filter((rune) => rune.readyAt > 1_000)).toHaveLength(1);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
const actors = useCombatStore.getState().actors;
|
||||||
|
expect(actors[PLAYER_AGGRO_ID].runes).toHaveLength(6);
|
||||||
|
expect(actors["rune-target"].kind).toBe("enemy");
|
||||||
|
for (const member of usePartyStore.getState().members) expect(actors[member.id].kind).toBe("companion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits structured effective damage and healing records", () => {
|
||||||
|
registerTarget("event-target", [0, 0, 10]);
|
||||||
|
useCombatStore.getState().damageMob("event-target", 25, 1_000);
|
||||||
|
useCombatStore.getState().damagePlayer(10);
|
||||||
|
useCombatStore.getState().healPlayer(100);
|
||||||
|
const events = useCombatStore.getState().combatEvents;
|
||||||
|
expect(events.some((event) => event.kind === "damage" && event.targetActorId === "event-target" && event.effectiveAmount > 0)).toBe(true);
|
||||||
|
expect(events.some((event) => event.kind === "healing" && event.overheal >= 0)).toBe(true);
|
||||||
|
expect(events.every((event) => event.rawAmount >= event.effectiveAmount)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { abilitiesForClass, abilityAtLevel, abilityById, type AbilityDefinition } from "./abilityCatalog";
|
||||||
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||||
|
import { targetingRulesForAbility } from "./abilityRules";
|
||||||
|
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
|
||||||
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
|
||||||
|
function registerMob(id: string, position: readonly [number, number, number], boss = false): void {
|
||||||
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 10_000, xpReward: 0, boss });
|
||||||
|
setMobPosition(id, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("extended dungeon combat fidelity", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetCombatSpatialProvider();
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||||
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||||
|
usePartyStore.getState().initializeParty("actor-authority", 80, useCombatStore.getState().health);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(resetCombatSpatialProvider);
|
||||||
|
|
||||||
|
it("keeps companion vitals authoritative when compatibility party records are stale", () => {
|
||||||
|
const member = usePartyStore.getState().members[0];
|
||||||
|
const actor = useCombatStore.getState().actors[member.id];
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
actors: { ...state.actors, [member.id]: { ...actor, health: 7, alive: true } },
|
||||||
|
}));
|
||||||
|
usePartyStore.setState((state) => ({
|
||||||
|
members: state.members.map((candidate) => candidate.id === member.id
|
||||||
|
? { ...candidate, health: candidate.maxHealth }
|
||||||
|
: candidate),
|
||||||
|
}));
|
||||||
|
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().actors[member.id].health).toBe(7);
|
||||||
|
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds combo points to the enemy that generated them", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
|
||||||
|
registerMob("combo-a", [0, 0, 3]);
|
||||||
|
registerMob("combo-b", [1, 0, 2]);
|
||||||
|
useCombatStore.getState().selectMob("combo-a");
|
||||||
|
expect(useCombatStore.getState().castAbility("rogue-sinister-strike", { now: 1_000 }).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState()).toMatchObject({ comboPoints: 1, comboTargetId: "combo-a" });
|
||||||
|
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
globalCooldownEndsAt: 0,
|
||||||
|
cooldowns: {},
|
||||||
|
resource: state.maxResource,
|
||||||
|
resourcePools: { ...state.resourcePools, energy: state.maxResource },
|
||||||
|
}));
|
||||||
|
useCombatStore.getState().selectMob("combo-b");
|
||||||
|
const secondStrike = useCombatStore.getState().castAbility("rogue-sinister-strike", { now: 2_000 });
|
||||||
|
expect(secondStrike.ok, secondStrike.reason).toBe(true);
|
||||||
|
expect(useCombatStore.getState()).toMatchObject({ comboPoints: 1, comboTargetId: "combo-b" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies boss control and taunt diminishing returns through immunity", () => {
|
||||||
|
registerMob("dr-boss", [0, 0, 3], true);
|
||||||
|
const durations: number[] = [];
|
||||||
|
for (const now of [1_000, 2_000, 3_000]) {
|
||||||
|
expect(useCombatStore.getState().controlMob("dr-boss", `stun-${now}`, "stun", 8_000, now)).toBe(true);
|
||||||
|
const status = useCombatStore.getState().mobs["dr-boss"].statuses.find((entry) => entry.kind === "stun")!;
|
||||||
|
durations.push(status.endsAt - now);
|
||||||
|
}
|
||||||
|
expect(durations).toEqual([3_000, 1_500, 750]);
|
||||||
|
expect(useCombatStore.getState().controlMob("dr-boss", "stun-immune", "stun", 8_000, 4_000)).toBe(false);
|
||||||
|
|
||||||
|
const tauntDurations: number[] = [];
|
||||||
|
for (const now of [5_000, 6_000, 7_000]) {
|
||||||
|
expect(useCombatStore.getState().tauntMob("dr-boss", { actorId: PLAYER_AGGRO_ID, role: "tank" }, 3_000, now)).toBe(true);
|
||||||
|
tauntDurations.push(useCombatStore.getState().mobs["dr-boss"].forcedTarget!.endsAt - now);
|
||||||
|
}
|
||||||
|
expect(tauntDurations).toEqual([3_000, 1_950, 1_268]);
|
||||||
|
expect(useCombatStore.getState().tauntMob("dr-boss", { actorId: PLAYER_AGGRO_ID, role: "tank" }, 3_000, 8_000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves and confirms a collision-projected ground reticle", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||||
|
const groundAbility = abilitiesForClass("mage")
|
||||||
|
.map((ability) => abilityAtLevel(ability, 80))
|
||||||
|
.find((ability) => targetingRulesForAbility(ability).shape === "ground" && ability.range.max > 1)!;
|
||||||
|
expect(groundAbility).toBeDefined();
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => ({ id, position: [0, 0, 0], yaw: 0 }),
|
||||||
|
hasLineOfSight: () => true,
|
||||||
|
projectGroundPoint: (point, maximumDistance, origin = [0, 0, 0]) => {
|
||||||
|
const dx = point[0] - origin[0];
|
||||||
|
const dz = point[2] - origin[2];
|
||||||
|
const distance = Math.hypot(dx, dz);
|
||||||
|
const scale = distance > maximumDistance ? maximumDistance / distance : 1;
|
||||||
|
return [origin[0] + dx * scale, 0, origin[2] + dz * scale];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(useCombatStore.getState().beginGroundTarget(groundAbility.id)).toBe(true);
|
||||||
|
const before = useCombatStore.getState().pendingGroundTarget!;
|
||||||
|
expect(before.valid).toBe(true);
|
||||||
|
useCombatStore.getState().moveGroundTarget(1, 0, 0, 0.1);
|
||||||
|
const moved = useCombatStore.getState().pendingGroundTarget!;
|
||||||
|
expect(moved.position).not.toEqual(before.position);
|
||||||
|
expect(Math.hypot(moved.position[0], moved.position[2])).toBeLessThanOrEqual(moved.maximumRange);
|
||||||
|
expect(useCombatStore.getState().confirmGroundTarget(undefined, 1_000).ok).toBe(true);
|
||||||
|
expect(useCombatStore.getState().pendingGroundTarget).toBeNull();
|
||||||
|
const activeCast = useCombatStore.getState().activeCast;
|
||||||
|
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
||||||
|
expect(useCombatStore.getState().areaEffects).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps summons targetable, credits their owner, replaces totems, and expires them", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rom-warden", raceId: "rom-elf", level: 20 });
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
registerMob("summon-target", [0, 0, 3]);
|
||||||
|
useCombatStore.getState().selectMob("summon-target");
|
||||||
|
expect(useCombatStore.getState().castAbility("rom-warden-primary-1", { now: 1_000 }).ok).toBe(true);
|
||||||
|
useCombatStore.getState().tick(0.1, 5_000);
|
||||||
|
const summon = useCombatStore.getState().summons[0];
|
||||||
|
expect(summon).toBeDefined();
|
||||||
|
expect(useCombatStore.getState().actors[summon.id]).toMatchObject({ kind: "summon", ownerId: PLAYER_AGGRO_ID, alive: true });
|
||||||
|
expect(useCombatStore.getState().selectFriendlyActor(summon.id)).toBe(true);
|
||||||
|
|
||||||
|
const beforeDamage = summon.health;
|
||||||
|
expect(useCombatStore.getState().damageSummon(summon.id, 5)).toBe(5);
|
||||||
|
expect(useCombatStore.getState().actors[summon.id].health).toBe(beforeDamage - 5);
|
||||||
|
expect(useCombatStore.getState().healActor(summon.id, 5, { sourceActorId: PLAYER_AGGRO_ID }).effectiveAmount).toBe(5);
|
||||||
|
|
||||||
|
const baseAbility = abilityById("rom-warden-primary-1")!;
|
||||||
|
const combatSummonAbility: AbilityDefinition = { ...baseAbility, id: "test-guardian", name: "Test Guardian" };
|
||||||
|
useCombatStore.getState().summonActor(
|
||||||
|
PLAYER_AGGRO_ID,
|
||||||
|
combatSummonAbility,
|
||||||
|
{ kind: "summon", creatureId: 2, coefficient: 0.5, durationMs: 10_000 },
|
||||||
|
[0, 0, 0],
|
||||||
|
"summon-target",
|
||||||
|
6_000,
|
||||||
|
);
|
||||||
|
useCombatStore.getState().tick(1, 7_500);
|
||||||
|
expect(useCombatStore.getState().mobs["summon-target"].threatByActor[PLAYER_AGGRO_ID]).toBeGreaterThan(0);
|
||||||
|
expect(useCombatStore.getState().mobs["summon-target"].threatByActor[summon.id] ?? 0).toBe(0);
|
||||||
|
|
||||||
|
const totemAbility: AbilityDefinition = { ...baseAbility, id: "test-totem", name: "Test Totem" };
|
||||||
|
const totemEffect = { kind: "summon" as const, creatureId: 1, coefficient: 0.2, durationMs: 10_000 };
|
||||||
|
useCombatStore.getState().summonActor(PLAYER_AGGRO_ID, totemAbility, totemEffect, [0, 0, 0], null, 8_000);
|
||||||
|
useCombatStore.getState().summonActor(PLAYER_AGGRO_ID, totemAbility, totemEffect, [1, 0, 0], null, 8_100);
|
||||||
|
expect(useCombatStore.getState().summons.filter((candidate) => candidate.exclusiveGroup === `${PLAYER_AGGRO_ID}:totem`)).toHaveLength(1);
|
||||||
|
|
||||||
|
useCombatStore.getState().tick(0.1, 20_001);
|
||||||
|
expect(useCombatStore.getState().summons.some((candidate) => candidate.id === summon.id)).toBe(false);
|
||||||
|
expect(useCombatStore.getState().actors[summon.id]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes damaging abilities from Classic, RuneWaker, and CoA catalogs through one runtime", () => {
|
||||||
|
const cases = [
|
||||||
|
{ classId: "mage" as const, level: 20, source: "classic" },
|
||||||
|
{ classId: "rom-mage" as const, level: 20, source: "runewaker" },
|
||||||
|
{ classId: "stormbringer" as const, level: 60, source: "coa" },
|
||||||
|
];
|
||||||
|
registerCombatSpatialProvider({
|
||||||
|
actor: (id) => ({ id, position: id === PLAYER_AGGRO_ID ? [0, 0, 0] : getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
|
||||||
|
hasLineOfSight: () => true,
|
||||||
|
projectGroundPoint: (point) => point,
|
||||||
|
});
|
||||||
|
for (const entry of cases) {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: entry.classId, level: entry.level });
|
||||||
|
const ability = abilitiesForClass(entry.classId)
|
||||||
|
.map((candidate) => abilityAtLevel(candidate, entry.level))
|
||||||
|
.find((candidate) => (
|
||||||
|
!candidate.passive
|
||||||
|
&& candidate.target === "hostile"
|
||||||
|
&& candidate.talentEntryId === undefined
|
||||||
|
&& candidate.effects.some((effect) => effect.kind === "damage")
|
||||||
|
));
|
||||||
|
expect(ability, `${entry.source} executable damage ability`).toBeDefined();
|
||||||
|
const id = `${entry.source}-target`;
|
||||||
|
const range = Math.max(1, Math.min(10, ability!.range.max));
|
||||||
|
registerMob(id, [0, 0, range]);
|
||||||
|
useCombatStore.getState().selectMob(id);
|
||||||
|
const before = useCombatStore.getState().mobs[id].health;
|
||||||
|
const cast = useCombatStore.getState().castAbility(ability!.id, { now: 1_000 });
|
||||||
|
expect(cast.ok, `${entry.source} cast: ${cast.reason ?? "ok"}`).toBe(true);
|
||||||
|
const activeCast = useCombatStore.getState().activeCast;
|
||||||
|
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
|
||||||
|
expect(useCombatStore.getState().mobs[id].health, `${entry.source} damage`).toBeLessThan(before);
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ export type EnemyDamageSchool =
|
|||||||
| "shadow"
|
| "shadow"
|
||||||
| "holy";
|
| "holy";
|
||||||
|
|
||||||
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun";
|
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
|
||||||
|
|
||||||
export type EnemyAbilityEffect =
|
export type EnemyAbilityEffect =
|
||||||
| { readonly kind: "damage"; readonly multiplier: number }
|
| { readonly kind: "damage"; readonly multiplier: number }
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -48,6 +48,9 @@ function inputActionSpies(blocked = false): InputActions {
|
|||||||
setPartyCommand: vi.fn(),
|
setPartyCommand: vi.fn(),
|
||||||
recallParty: vi.fn(),
|
recallParty: vi.fn(),
|
||||||
clearTarget: vi.fn(),
|
clearTarget: vi.fn(),
|
||||||
|
groundTargetActive: vi.fn(() => false),
|
||||||
|
confirmGroundTarget: vi.fn(),
|
||||||
|
cancelGroundTarget: vi.fn(),
|
||||||
gameplayActionsBlocked: vi.fn(() => blocked),
|
gameplayActionsBlocked: vi.fn(() => blocked),
|
||||||
setCameraLookActive: vi.fn(),
|
setCameraLookActive: vi.fn(),
|
||||||
setInputMode: vi.fn(),
|
setInputMode: vi.fn(),
|
||||||
@@ -90,6 +93,24 @@ describe("gameplay input bindings", () => {
|
|||||||
dispose();
|
dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("confirms and cancels an active ground reticle before face-button casts", () => {
|
||||||
|
stubInputEventTargets();
|
||||||
|
const nextActions = inputActionSpies();
|
||||||
|
(nextActions.groundTargetActive as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||||
|
const dispose = installInput(nextActions);
|
||||||
|
|
||||||
|
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
|
||||||
|
emitControllerToken({ token: "Button0", repeat: false, pressed: false });
|
||||||
|
emitControllerToken({ token: "Button1", repeat: false, pressed: true });
|
||||||
|
|
||||||
|
expect(nextActions.confirmGroundTarget).toHaveBeenCalledTimes(1);
|
||||||
|
expect(nextActions.cancelGroundTarget).toHaveBeenCalledTimes(1);
|
||||||
|
expect(nextActions.lootActiveBoss).not.toHaveBeenCalled();
|
||||||
|
expect(nextActions.castActionBinding).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("maps Thor controls without stealing Select from display routing", () => {
|
it("maps Thor controls without stealing Select from display routing", () => {
|
||||||
expect(controllerCommandForToken("Button9")).toBe("pause");
|
expect(controllerCommandForToken("Button9")).toBe("pause");
|
||||||
expect(controllerCommandForToken("Button11")).toBe("target-next");
|
expect(controllerCommandForToken("Button11")).toBe("target-next");
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export interface InputActions {
|
|||||||
setPartyCommand: (command: PartyCommand) => void;
|
setPartyCommand: (command: PartyCommand) => void;
|
||||||
recallParty: () => void;
|
recallParty: () => void;
|
||||||
clearTarget: () => void;
|
clearTarget: () => void;
|
||||||
|
groundTargetActive: () => boolean;
|
||||||
|
confirmGroundTarget: () => void;
|
||||||
|
cancelGroundTarget: () => void;
|
||||||
gameplayActionsBlocked: () => boolean;
|
gameplayActionsBlocked: () => boolean;
|
||||||
setCameraLookActive: (active: boolean) => void;
|
setCameraLookActive: (active: boolean) => void;
|
||||||
setInputMode: (mode: "keyboard" | "gamepad") => void;
|
setInputMode: (mode: "keyboard" | "gamepad") => void;
|
||||||
@@ -246,6 +249,16 @@ export function installInput(nextActions: InputActions): () => void {
|
|||||||
if (!isPressed) return;
|
if (!isPressed) return;
|
||||||
nextActions.setInputMode("gamepad");
|
nextActions.setInputMode("gamepad");
|
||||||
if (repeat) return;
|
if (repeat) return;
|
||||||
|
if (nextActions.groundTargetActive()) {
|
||||||
|
if (token === "Button0") {
|
||||||
|
nextActions.confirmGroundTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN) {
|
||||||
|
nextActions.cancelGroundTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
token === "Button0"
|
token === "Button0"
|
||||||
&& !nextActions.gameplayActionsBlocked()
|
&& !nextActions.gameplayActionsBlocked()
|
||||||
@@ -292,6 +305,13 @@ export function installInput(nextActions: InputActions): () => void {
|
|||||||
actions?.setInputMode("keyboard");
|
actions?.setInputMode("keyboard");
|
||||||
}
|
}
|
||||||
if (event.repeat) return;
|
if (event.repeat) return;
|
||||||
|
if (nextActions.groundTargetActive() && (event.code === "Escape" || event.code === "Enter")) {
|
||||||
|
event.preventDefault();
|
||||||
|
nextActions.setInputMode("keyboard");
|
||||||
|
if (event.code === "Enter") nextActions.confirmGroundTarget();
|
||||||
|
else nextActions.cancelGroundTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const actionControl = actionControlForKeyboardCode(event.code);
|
const actionControl = actionControlForKeyboardCode(event.code);
|
||||||
if (actionControl) {
|
if (actionControl) {
|
||||||
if (nextActions.gameplayActionsBlocked()) return;
|
if (nextActions.gameplayActionsBlocked()) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { LOOT_CATALOG, rollBossLoot } from "./lootCatalog";
|
|||||||
import { useManastormStore } from "./manastormStore";
|
import { useManastormStore } from "./manastormStore";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import { getManastormCatalog, manastormScaling } from "./manastorm";
|
import { getManastormCatalog, manastormScaling } from "./manastorm";
|
||||||
|
import { requestOnlineInteraction } from "./onlineSessionRuntime";
|
||||||
|
|
||||||
/** Shared keyboard, controller, touch, and HUD interaction for the active corpse. */
|
/** Shared keyboard, controller, touch, and HUD interaction for the active corpse. */
|
||||||
export function lootCurrentManastormBoss(now = Date.now()): boolean {
|
export function lootCurrentManastormBoss(now = Date.now()): boolean {
|
||||||
@@ -11,6 +12,7 @@ export function lootCurrentManastormBoss(now = Date.now()): boolean {
|
|||||||
if (game.gameMode !== "manastorm" || manastorm.status !== "awaiting-loot") return false;
|
if (game.gameMode !== "manastorm" || manastorm.status !== "awaiting-loot") return false;
|
||||||
const bossId = manastorm.currentEncounter?.bossRuntimeId;
|
const bossId = manastorm.currentEncounter?.bossRuntimeId;
|
||||||
if (!bossId) return false;
|
if (!bossId) return false;
|
||||||
|
if (requestOnlineInteraction("loot", bossId)) return true;
|
||||||
return useCombatStore.getState().lootMob(bossId, now);
|
return useCombatStore.getState().lootMob(bossId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { clearMobRuntimeRegistry, registerMobRuntime } from "./mobRuntimeRegistry";
|
||||||
|
import {
|
||||||
|
configureOnlineSessionRuntime,
|
||||||
|
onlinePlayerActorId,
|
||||||
|
} from "./onlineSessionRuntime";
|
||||||
|
import {
|
||||||
|
clearPartyRuntimeRegistry,
|
||||||
|
registerPartyRuntimePosition,
|
||||||
|
} from "./partyRuntimeRegistry";
|
||||||
|
import { usePartyStore } from "./partyStore";
|
||||||
|
|
||||||
|
function remotePlayer(health = 1): OnlineSessionPlayer {
|
||||||
|
return {
|
||||||
|
accountId: "remote",
|
||||||
|
username: "Remote",
|
||||||
|
character: {
|
||||||
|
id: "remote-character",
|
||||||
|
name: "Bulwarka",
|
||||||
|
classId: "warrior",
|
||||||
|
categoryId: "wow",
|
||||||
|
raceId: "human",
|
||||||
|
gender: "female",
|
||||||
|
level: 20,
|
||||||
|
},
|
||||||
|
role: "tank",
|
||||||
|
position: [8, 0, 0],
|
||||||
|
yaw: 0,
|
||||||
|
grounded: true,
|
||||||
|
health,
|
||||||
|
maxHealth: 1_000,
|
||||||
|
resource: 0,
|
||||||
|
maxResource: 100,
|
||||||
|
resourceName: "Rage",
|
||||||
|
selectedTargetId: null,
|
||||||
|
activeCast: null,
|
||||||
|
animationEvent: null,
|
||||||
|
equipment: [],
|
||||||
|
updatedAt: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
|
||||||
|
useCombatStore.getState().resetEncounter();
|
||||||
|
usePartyStore.setState({ members: [], selectedMemberId: null, selfSelected: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||||
|
clearMobRuntimeRegistry();
|
||||||
|
clearPartyRuntimeRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shared online combat actors", () => {
|
||||||
|
it("casts a normal player heal on a remote online player", () => {
|
||||||
|
const player = remotePlayer();
|
||||||
|
const actorId = onlinePlayerActorId(player.accountId);
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: true,
|
||||||
|
localAccountId: "local",
|
||||||
|
players: [player],
|
||||||
|
});
|
||||||
|
registerPartyRuntimePosition(actorId, player.position);
|
||||||
|
useCombatStore.getState().synchronizeActors();
|
||||||
|
expect(useCombatStore.getState().selectFriendlyActor(actorId)).toBe(true);
|
||||||
|
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 1_000).ok).toBe(true);
|
||||||
|
const completion = useCombatStore.getState().activeCast?.completesAt ?? 1_000;
|
||||||
|
useCombatStore.getState().tick(0.1, completion);
|
||||||
|
expect(useCombatStore.getState().actors[actorId]?.health).toBeGreaterThan(1);
|
||||||
|
expect(useCombatStore.getState().combatEvents).toContainEqual(
|
||||||
|
expect.objectContaining({ kind: "healing", targetActorId: actorId }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the leader's enemies acquire a nearby remote player", () => {
|
||||||
|
const player = { ...remotePlayer(1_000), position: [1, 0, 0] as const };
|
||||||
|
const actorId = onlinePlayerActorId(player.accountId);
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: true,
|
||||||
|
localAccountId: "local",
|
||||||
|
players: [player],
|
||||||
|
});
|
||||||
|
registerPartyRuntimePosition(actorId, player.position);
|
||||||
|
registerMobRuntime("shared-enemy", [0, 0, 0]);
|
||||||
|
useCombatStore.getState().setPlayerPosition([100, 0, 100]);
|
||||||
|
useCombatStore.getState().registerMob("shared-enemy", {
|
||||||
|
name: "Shared enemy",
|
||||||
|
maxHealth: 100,
|
||||||
|
aggroRange: 5,
|
||||||
|
leashRange: 20,
|
||||||
|
});
|
||||||
|
expect(useCombatStore.getState().engageNearbyMobs(1_000)).toBe(1);
|
||||||
|
expect(useCombatStore.getState().mobs["shared-enemy"].targetActorId).toBe(actorId);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||||
|
import {
|
||||||
|
configureOnlineSessionRuntime,
|
||||||
|
drainOnlineInteractions,
|
||||||
|
onlinePlayerActorId,
|
||||||
|
onlinePlayerRuntimeActor,
|
||||||
|
onlineSessionIsActive,
|
||||||
|
onlineSessionOwnsWorldSimulation,
|
||||||
|
onlineSynchronizedMobTransform,
|
||||||
|
requestOnlineInteraction,
|
||||||
|
} from "./onlineSessionRuntime";
|
||||||
|
|
||||||
|
const remotePlayer: OnlineSessionPlayer = {
|
||||||
|
accountId: "remote",
|
||||||
|
username: "Remote",
|
||||||
|
character: {
|
||||||
|
id: "remote-character",
|
||||||
|
name: "Mendara",
|
||||||
|
classId: "priest",
|
||||||
|
categoryId: "wow",
|
||||||
|
raceId: "human",
|
||||||
|
gender: "female",
|
||||||
|
level: 20,
|
||||||
|
},
|
||||||
|
role: "healer",
|
||||||
|
position: [4, 0, 6],
|
||||||
|
yaw: 1,
|
||||||
|
grounded: true,
|
||||||
|
health: 800,
|
||||||
|
maxHealth: 1_000,
|
||||||
|
resource: 400,
|
||||||
|
maxResource: 500,
|
||||||
|
resourceName: "Mana",
|
||||||
|
selectedTargetId: null,
|
||||||
|
activeCast: null,
|
||||||
|
animationEvent: null,
|
||||||
|
equipment: [],
|
||||||
|
updatedAt: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => configureOnlineSessionRuntime({
|
||||||
|
active: false,
|
||||||
|
authority: true,
|
||||||
|
localAccountId: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("online session runtime", () => {
|
||||||
|
it("exposes remote players as live combat actors", () => {
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: true,
|
||||||
|
localAccountId: "local",
|
||||||
|
players: [remotePlayer],
|
||||||
|
});
|
||||||
|
const actor = onlinePlayerRuntimeActor(onlinePlayerActorId("remote"));
|
||||||
|
expect(actor).toMatchObject({ health: 800, maxHealth: 1_000, alive: true, kind: "companion" });
|
||||||
|
expect(onlineSessionIsActive()).toBe(true);
|
||||||
|
expect(onlineSessionOwnsWorldSimulation()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses leader transforms and queues follower interactions", () => {
|
||||||
|
configureOnlineSessionRuntime({
|
||||||
|
active: true,
|
||||||
|
authority: false,
|
||||||
|
localAccountId: "local",
|
||||||
|
players: [remotePlayer],
|
||||||
|
mobTransforms: { boss: { position: [7, 0, 9], yaw: 0.75 } },
|
||||||
|
});
|
||||||
|
expect(onlineSessionOwnsWorldSimulation()).toBe(false);
|
||||||
|
expect(onlineSynchronizedMobTransform("boss")?.position).toEqual([7, 0, 9]);
|
||||||
|
expect(requestOnlineInteraction("loot", "boss")).toBe(true);
|
||||||
|
expect(drainOnlineInteractions()).toEqual([
|
||||||
|
expect.objectContaining({ kind: "loot", targetActorId: "boss", sourceActorId: onlinePlayerActorId("local") }),
|
||||||
|
]);
|
||||||
|
expect(drainOnlineInteractions()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { ClassId } from "../app/characterCatalog";
|
||||||
|
import type {
|
||||||
|
OnlineMobTransform,
|
||||||
|
OnlineRelayEventInput,
|
||||||
|
OnlineSessionPlayer,
|
||||||
|
} from "../app/onlineSessionTypes";
|
||||||
|
import {
|
||||||
|
normalizeActorResourcePool,
|
||||||
|
type CombatActorState,
|
||||||
|
} from "./combatActors";
|
||||||
|
|
||||||
|
const ONLINE_PLAYER_PREFIX = "online-player:";
|
||||||
|
let active = false;
|
||||||
|
let authority = true;
|
||||||
|
let localAccountId: string | null = null;
|
||||||
|
let playerActors = new Map<string, CombatActorState>();
|
||||||
|
let players = new Map<string, OnlineSessionPlayer>();
|
||||||
|
let mobTransforms = new Map<string, OnlineMobTransform>();
|
||||||
|
let interactionSequence = 0;
|
||||||
|
let interactions: OnlineRelayEventInput[] = [];
|
||||||
|
|
||||||
|
export function onlinePlayerActorId(accountId: string): string {
|
||||||
|
return `${ONLINE_PLAYER_PREFIX}${accountId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineAccountIdFromActor(actorId: string): string | null {
|
||||||
|
return actorId.startsWith(ONLINE_PLAYER_PREFIX)
|
||||||
|
? actorId.slice(ONLINE_PLAYER_PREFIX.length)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOnlinePlayerActorId(actorId: string): boolean {
|
||||||
|
return actorId.startsWith(ONLINE_PLAYER_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureOnlineSessionRuntime(input: {
|
||||||
|
readonly active: boolean;
|
||||||
|
readonly authority: boolean;
|
||||||
|
readonly localAccountId: string | null;
|
||||||
|
readonly players?: readonly OnlineSessionPlayer[];
|
||||||
|
readonly mobTransforms?: Readonly<Record<string, OnlineMobTransform>>;
|
||||||
|
}): void {
|
||||||
|
active = input.active;
|
||||||
|
authority = !input.active || input.authority;
|
||||||
|
localAccountId = input.localAccountId;
|
||||||
|
players = new Map((input.players ?? []).map((player) => [player.accountId, player]));
|
||||||
|
mobTransforms = new Map(Object.entries(input.mobTransforms ?? {}));
|
||||||
|
const nextActors = new Map<string, CombatActorState>();
|
||||||
|
for (const player of input.players ?? []) {
|
||||||
|
if (player.accountId === input.localAccountId || !player.character) continue;
|
||||||
|
const id = onlinePlayerActorId(player.accountId);
|
||||||
|
const maximum = Math.max(0, player.maxResource);
|
||||||
|
const resourceId = player.resourceName.toLowerCase() || "resource";
|
||||||
|
nextActors.set(id, {
|
||||||
|
id,
|
||||||
|
threatActorId: id,
|
||||||
|
kind: "companion",
|
||||||
|
ownerId: player.accountId,
|
||||||
|
classId: player.character.classId as ClassId,
|
||||||
|
level: player.character.level,
|
||||||
|
health: player.health,
|
||||||
|
maxHealth: player.maxHealth,
|
||||||
|
absorb: 0,
|
||||||
|
resources: maximum > 0
|
||||||
|
? { [resourceId]: normalizeActorResourcePool(resourceId, player.resource, maximum) }
|
||||||
|
: {},
|
||||||
|
cooldowns: {},
|
||||||
|
globalCooldownEndsAt: 0,
|
||||||
|
cast: player.activeCast ? {
|
||||||
|
abilityId: player.activeCast.abilityId,
|
||||||
|
school: player.activeCast.school,
|
||||||
|
mode: player.activeCast.mode,
|
||||||
|
startedAt: player.activeCast.startedAt,
|
||||||
|
completesAt: player.activeCast.completesAt,
|
||||||
|
originalDurationMs: player.activeCast.originalDurationMs,
|
||||||
|
pushbackHits: player.activeCast.pushbackHits,
|
||||||
|
origin: player.activeCast.origin,
|
||||||
|
targetId: player.activeCast.targetId,
|
||||||
|
} : null,
|
||||||
|
controlledUntil: 0,
|
||||||
|
schoolLockedUntil: {},
|
||||||
|
comboPoints: 0,
|
||||||
|
comboTargetId: null,
|
||||||
|
runes: [],
|
||||||
|
alive: player.health > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
playerActors = nextActors;
|
||||||
|
if (!input.active) interactions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineSessionIsActive(): boolean {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineSessionOwnsWorldSimulation(): boolean {
|
||||||
|
return !active || authority;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineSessionLocalAccountId(): string | null {
|
||||||
|
return localAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlinePlayerRuntimeActors(): readonly CombatActorState[] {
|
||||||
|
return [...playerActors.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlinePlayerRuntimeActor(actorId: string): CombatActorState | null {
|
||||||
|
return playerActors.get(actorId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineSessionPlayer(accountId: string): OnlineSessionPlayer | null {
|
||||||
|
return players.get(accountId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onlineSynchronizedMobTransform(id: string): OnlineMobTransform | null {
|
||||||
|
return active && !authority ? mobTransforms.get(id) ?? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestOnlineInteraction(kind: "loot" | "portal", targetActorId = ""): boolean {
|
||||||
|
if (!active || authority) return false;
|
||||||
|
interactionSequence += 1;
|
||||||
|
interactions.push({
|
||||||
|
clientEventId: `interaction:${kind}:${Date.now()}:${interactionSequence}`,
|
||||||
|
kind,
|
||||||
|
sourceActorId: localAccountId ? onlinePlayerActorId(localAccountId) : "",
|
||||||
|
targetActorId,
|
||||||
|
occurredAt: Date.now(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drainOnlineInteractions(): readonly OnlineRelayEventInput[] {
|
||||||
|
const pending = interactions;
|
||||||
|
interactions = [];
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { abilitiesForClass, abilityAtLevel, resourceProfileForClass } from "./abilityCatalog";
|
||||||
|
import { aiHintsForAbility } from "./abilityRules";
|
||||||
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady } from "./partyAbilityAi";
|
||||||
|
import { usePartyStore, type PartyMember } from "./partyStore";
|
||||||
|
|
||||||
|
function memberFor(classId: PartyMember["classId"], role: PartyMember["role"]): PartyMember {
|
||||||
|
const base = usePartyStore.getState().members[0];
|
||||||
|
const profile = resourceProfileForClass(classId);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
classId,
|
||||||
|
role,
|
||||||
|
level: 80,
|
||||||
|
resourceType: profile.type,
|
||||||
|
resourceName: profile.name,
|
||||||
|
resource: profile.maximum,
|
||||||
|
maxResource: profile.maximum,
|
||||||
|
resourcePools: { [profile.type]: profile.maximum },
|
||||||
|
cooldowns: {},
|
||||||
|
globalCooldownEndsAt: 0,
|
||||||
|
nextActionAt: 0,
|
||||||
|
tauntReadyAt: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("catalog-driven party rotations", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||||
|
usePartyStore.getState().initializeParty("party-ai", 80, useCombatStore.getState().health);
|
||||||
|
useCombatStore.getState().registerMob("caster", { name: "Caster", maxHealth: 1_000, xpReward: 0 });
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
mobs: {
|
||||||
|
...state.mobs,
|
||||||
|
caster: {
|
||||||
|
...state.mobs.caster,
|
||||||
|
activeCast: {
|
||||||
|
attackId: "test-cast",
|
||||||
|
name: "Test Cast",
|
||||||
|
school: "shadow",
|
||||||
|
interruptible: true,
|
||||||
|
targetActorId: "__player__",
|
||||||
|
startedAt: 1_000,
|
||||||
|
completesAt: 5_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes an interrupt over ordinary damage deterministically", () => {
|
||||||
|
const member = memberFor("rogue", "damage");
|
||||||
|
const context = { now: 2_000, target: useCombatStore.getState().mobs.caster };
|
||||||
|
const first = choosePartyAbility(member, context);
|
||||||
|
const second = choosePartyAbility(member, context);
|
||||||
|
expect(first?.id).toBe(second?.id);
|
||||||
|
expect(first && aiHintsForAbility(first).categories).toContain("interrupt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a real healer catalog spell and spends its resource/GCD", () => {
|
||||||
|
const member = memberFor("priest", "healer");
|
||||||
|
const ability = choosePartyAbility(member, {
|
||||||
|
now: 2_000,
|
||||||
|
target: null,
|
||||||
|
injuredAllies: 1,
|
||||||
|
lowestHealthFraction: 0.2,
|
||||||
|
});
|
||||||
|
expect(ability && aiHintsForAbility(ability).categories).toContain("heal");
|
||||||
|
const committed = commitPartyAbilityUse(member, ability!, 2_000)!;
|
||||||
|
expect(committed.resource).toBeLessThan(member.resource);
|
||||||
|
expect(committed.globalCooldownEndsAt).toBeGreaterThan(2_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("will not cast a mana spell after mana exhaustion", () => {
|
||||||
|
const member = { ...memberFor("priest", "healer"), resource: 0, resourcePools: { mana: 0 } };
|
||||||
|
const heal = abilitiesForClass("priest")
|
||||||
|
.map((ability) => abilityAtLevel(ability, 80))
|
||||||
|
.find((ability) => aiHintsForAbility(ability).categories.includes("heal") && ability.cost.amount > 0)!;
|
||||||
|
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import {
|
||||||
|
abilitiesForClass,
|
||||||
|
abilityAtLevel,
|
||||||
|
isAbilityUnlocked,
|
||||||
|
type AbilityDefinition,
|
||||||
|
type AbilityEffect,
|
||||||
|
type ResourceType,
|
||||||
|
} from "./abilityCatalog";
|
||||||
|
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
||||||
|
import { spendDeathKnightRunes } from "./combatResources";
|
||||||
|
import type { MobCombatState } from "./combatStore";
|
||||||
|
import type { PartyMember } from "./partyStore";
|
||||||
|
|
||||||
|
export interface PartyAbilityContext {
|
||||||
|
readonly now: number;
|
||||||
|
readonly target: MobCombatState | null;
|
||||||
|
readonly targetIsLoose?: boolean;
|
||||||
|
readonly requiredControl?: boolean;
|
||||||
|
readonly allyNeedsDispel?: boolean;
|
||||||
|
readonly fallenAlly?: boolean;
|
||||||
|
readonly injuredAllies?: number;
|
||||||
|
readonly lowestHealthFraction?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentResource(member: PartyMember, type: ResourceType): number {
|
||||||
|
return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function abilityCosts(ability: AbilityDefinition): readonly { resource: ResourceType; amount: number }[] {
|
||||||
|
return (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ({
|
||||||
|
resource: cost.resource,
|
||||||
|
amount: cost.percentage ? cost.amount / 100 : cost.amount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
|
||||||
|
if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false;
|
||||||
|
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
||||||
|
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||||
|
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
||||||
|
if (abilityCosts(ability).some((cost) => {
|
||||||
|
const amount = cost.amount <= 1 && ability.cost.percentage
|
||||||
|
? (cost.resource === member.resourceType ? member.maxResource : 100) * cost.amount
|
||||||
|
: cost.amount;
|
||||||
|
return currentResource(member, cost.resource) < amount;
|
||||||
|
})) return false;
|
||||||
|
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
|
||||||
|
return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
|
||||||
|
return abilitiesForClass(member.classId)
|
||||||
|
.filter((ability) => partyAbilityIsReady(member, abilityAtLevel(ability, member.level), now))
|
||||||
|
.map((ability) => abilityAtLevel(ability, member.level));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number {
|
||||||
|
const categories = new Set(aiHintsForAbility(ability).categories);
|
||||||
|
let score = ability.ai?.priority ?? 0;
|
||||||
|
if (member.role === "tank") {
|
||||||
|
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
||||||
|
if (context.targetIsLoose && categories.has("taunt")) score += 900;
|
||||||
|
if (categories.has("mitigation") && member.health / member.maxHealth < 0.55) score += 760;
|
||||||
|
if (categories.has("control") && context.requiredControl) score += 700;
|
||||||
|
if (categories.has("debuff")) score += 300;
|
||||||
|
if (categories.has("damage")) score += 180;
|
||||||
|
} else if (member.role === "healer") {
|
||||||
|
if (context.fallenAlly && categories.has("resurrection")) score += 1_200;
|
||||||
|
if (context.allyNeedsDispel && categories.has("dispel")) score += 1_050;
|
||||||
|
if ((context.lowestHealthFraction ?? 1) < 0.3 && categories.has("emergency-heal")) score += 950;
|
||||||
|
if ((context.injuredAllies ?? 0) >= 3 && categories.has("area-heal")) score += 850;
|
||||||
|
if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) score += 600;
|
||||||
|
if (categories.has("mitigation")) score += 350;
|
||||||
|
if (context.target?.activeCast && categories.has("interrupt")) score += 250;
|
||||||
|
if (categories.has("damage")) score += 80;
|
||||||
|
} else {
|
||||||
|
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
|
||||||
|
if (context.requiredControl && categories.has("control")) score += 900;
|
||||||
|
if ((context.target?.health ?? 1) / Math.max(1, context.target?.maxHealth ?? 1) <= 0.2 && categories.has("execute")) score += 800;
|
||||||
|
if (categories.has("debuff")) score += 520;
|
||||||
|
if (categories.has("damage")) score += 300;
|
||||||
|
}
|
||||||
|
if (ability.castTimeMs > 0 && context.target?.activeCast && categories.has("interrupt")) score -= 800;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function choosePartyAbility(
|
||||||
|
member: PartyMember,
|
||||||
|
context: PartyAbilityContext,
|
||||||
|
): AbilityDefinition | null {
|
||||||
|
return candidateAbilities(member, context.now)
|
||||||
|
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
|
||||||
|
.filter((candidate) => candidate.score > 0)
|
||||||
|
.sort((left, right) => right.score - left.score
|
||||||
|
|| left.ability.cooldownMs - right.ability.cooldownMs
|
||||||
|
|| left.ability.id.localeCompare(right.ability.id))[0]?.ability ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commitPartyAbilityUse(
|
||||||
|
member: PartyMember,
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
now: number,
|
||||||
|
): PartyMember | null {
|
||||||
|
if (!partyAbilityIsReady(member, ability, now)) return null;
|
||||||
|
const resourcePools = { ...member.resourcePools };
|
||||||
|
const lastResourceSpentAt = { ...member.lastResourceSpentAt };
|
||||||
|
let resource = member.resource;
|
||||||
|
for (const cost of abilityCosts(ability)) {
|
||||||
|
const maximum = cost.resource === member.resourceType ? member.maxResource : 100;
|
||||||
|
const amount = cost.amount <= 1 && ability.cost.percentage ? maximum * cost.amount : cost.amount;
|
||||||
|
const current = currentResource(member, cost.resource);
|
||||||
|
resourcePools[cost.resource] = Math.max(0, current - amount);
|
||||||
|
if (amount > 0) lastResourceSpentAt[cost.resource] = now;
|
||||||
|
if (cost.resource === member.resourceType) resource = resourcePools[cost.resource] ?? resource;
|
||||||
|
}
|
||||||
|
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
|
||||||
|
const runes = runeCost ? spendDeathKnightRunes(member.runes, runeCost, now) : member.runes;
|
||||||
|
if (!runes) return null;
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
resource,
|
||||||
|
resourcePools,
|
||||||
|
lastResourceSpentAt,
|
||||||
|
runes,
|
||||||
|
cooldowns: ability.cooldownMs > 0
|
||||||
|
? { ...member.cooldowns, [ability.id]: now + ability.cooldownMs }
|
||||||
|
: member.cooldowns,
|
||||||
|
globalCooldownEndsAt: now + ability.gcdMs,
|
||||||
|
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
|
||||||
|
? now + Math.max(8_000, ability.cooldownMs)
|
||||||
|
: member.tauntReadyAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partyAbilityAmount(
|
||||||
|
member: PartyMember,
|
||||||
|
ability: AbilityDefinition,
|
||||||
|
effect: AbilityEffect,
|
||||||
|
): number {
|
||||||
|
const coefficient = "coefficient" in effect
|
||||||
|
? effect.coefficient
|
||||||
|
: effect.kind === "finisher-damage"
|
||||||
|
? effect.baseCoefficient + effect.perComboCoefficient * 5
|
||||||
|
: 0;
|
||||||
|
const power = member.level * 4
|
||||||
|
+ member.gearStats.spellPower
|
||||||
|
+ member.gearStats.attackPower * 0.35
|
||||||
|
+ member.gearStats.rangedAttackPower * 0.35;
|
||||||
|
return Math.max(1, Math.round(Math.max(0.1, coefficient) * Math.max(8, power)));
|
||||||
|
}
|
||||||
+724
-130
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,11 @@ export function updatePartyRuntimePosition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removePartyRuntimePosition(id: string): void {
|
||||||
|
positions.delete(id);
|
||||||
|
targetVisibility.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
|
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
|
||||||
const position = positions.get(id);
|
const position = positions.get(id);
|
||||||
return position ? [...position] as MutablePartyWorldPosition : null;
|
return position ? [...position] as MutablePartyWorldPosition : null;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog";
|
|||||||
import type { EnemyControlMechanic } from "./enemyAttacks";
|
import type { EnemyControlMechanic } from "./enemyAttacks";
|
||||||
import type { ContentCategoryId } from "../app/contentCategories";
|
import type { ContentCategoryId } from "../app/contentCategories";
|
||||||
import type { PartyRole, PartySize } from "./partyRoles";
|
import type { PartyRole, PartySize } from "./partyRoles";
|
||||||
|
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog";
|
||||||
|
import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
classCanFillPartyRole,
|
classCanFillPartyRole,
|
||||||
@@ -56,6 +58,18 @@ export interface PartyMember {
|
|||||||
readonly level: number;
|
readonly level: number;
|
||||||
readonly health: number;
|
readonly health: number;
|
||||||
readonly maxHealth: number;
|
readonly maxHealth: number;
|
||||||
|
readonly resourceType: ResourceType;
|
||||||
|
readonly resourceName: string;
|
||||||
|
readonly resource: number;
|
||||||
|
readonly maxResource: number;
|
||||||
|
readonly resourcePools: Readonly<Partial<Record<ResourceType, number>>>;
|
||||||
|
readonly lastResourceSpentAt: Readonly<Partial<Record<ResourceType, number>>>;
|
||||||
|
readonly cooldowns: Readonly<Record<string, number>>;
|
||||||
|
readonly globalCooldownEndsAt: number;
|
||||||
|
readonly activeCast: CombatActorCastState | null;
|
||||||
|
readonly activeCastTargetId: string | null;
|
||||||
|
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
||||||
|
readonly runes: readonly DeathKnightRuneState[];
|
||||||
readonly gearStats: ItemStats;
|
readonly gearStats: ItemStats;
|
||||||
readonly status: PartyMemberStatus;
|
readonly status: PartyMemberStatus;
|
||||||
readonly nextActionAt: number;
|
readonly nextActionAt: number;
|
||||||
@@ -214,6 +228,9 @@ function createMember(
|
|||||||
const gender: GenderId = genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male";
|
const gender: GenderId = genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male";
|
||||||
const appearance = randomAppearance(race.id, gender, () => nextRandom(identityRandom));
|
const appearance = randomAppearance(race.id, gender, () => nextRandom(identityRandom));
|
||||||
const maxHealth = memberMaxHealth(level, role, EMPTY_ITEM_STATS, template.classId, race.id);
|
const maxHealth = memberMaxHealth(level, role, EMPTY_ITEM_STATS, template.classId, race.id);
|
||||||
|
const resourceProfile = resourceProfileForClass(template.classId);
|
||||||
|
const maxResource = resourceProfile.maximum;
|
||||||
|
const resource = resourceProfile.initial;
|
||||||
return {
|
return {
|
||||||
id: `party-${role}-${index}-${template.classId}`,
|
id: `party-${role}-${index}-${template.classId}`,
|
||||||
name: template.name,
|
name: template.name,
|
||||||
@@ -230,6 +247,18 @@ function createMember(
|
|||||||
level: Math.max(1, Math.trunc(level)),
|
level: Math.max(1, Math.trunc(level)),
|
||||||
health: maxHealth,
|
health: maxHealth,
|
||||||
maxHealth,
|
maxHealth,
|
||||||
|
resourceType: resourceProfile.type,
|
||||||
|
resourceName: resourceProfile.name,
|
||||||
|
resource,
|
||||||
|
maxResource,
|
||||||
|
resourcePools: Object.freeze({ [resourceProfile.type]: resource }),
|
||||||
|
lastResourceSpentAt: Object.freeze({}),
|
||||||
|
cooldowns: Object.freeze({}),
|
||||||
|
globalCooldownEndsAt: 0,
|
||||||
|
activeCast: null,
|
||||||
|
activeCastTargetId: null,
|
||||||
|
schoolLockedUntil: Object.freeze({}),
|
||||||
|
runes: template.classId === "death-knight" ? createDeathKnightRunes() : Object.freeze([]),
|
||||||
gearStats: EMPTY_ITEM_STATS,
|
gearStats: EMPTY_ITEM_STATS,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
nextActionAt: 0,
|
nextActionAt: 0,
|
||||||
@@ -321,6 +350,27 @@ export interface PartyState {
|
|||||||
cycleMember: (direction?: 1 | -1) => string | null;
|
cycleMember: (direction?: 1 | -1) => string | null;
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
healMember: (id: string, amount: number) => number;
|
healMember: (id: string, amount: number) => number;
|
||||||
|
updateMemberCombat: (
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Pick<PartyMember,
|
||||||
|
| "resource"
|
||||||
|
| "maxResource"
|
||||||
|
| "resourcePools"
|
||||||
|
| "lastResourceSpentAt"
|
||||||
|
| "cooldowns"
|
||||||
|
| "globalCooldownEndsAt"
|
||||||
|
| "activeCast"
|
||||||
|
| "activeCastTargetId"
|
||||||
|
| "schoolLockedUntil"
|
||||||
|
| "runes"
|
||||||
|
| "health"
|
||||||
|
| "maxHealth"
|
||||||
|
| "controlledUntil"
|
||||||
|
| "controlMechanic"
|
||||||
|
| "nextActionAt"
|
||||||
|
| "status"
|
||||||
|
>>,
|
||||||
|
) => boolean;
|
||||||
reviveMember: (id: string, percentMaxHealth?: number) => number;
|
reviveMember: (id: string, percentMaxHealth?: number) => number;
|
||||||
damageMember: (id: string, amount: number, school?: DamageSchool, attackerLevel?: number) => number;
|
damageMember: (id: string, amount: number, school?: DamageSchool, attackerLevel?: number) => number;
|
||||||
/** Applies damage that has already passed a discrete combat-result roll. */
|
/** Applies damage that has already passed a discrete combat-result roll. */
|
||||||
@@ -479,6 +529,18 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
|||||||
return healed;
|
return healed;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateMemberCombat: (id, patch) => {
|
||||||
|
let updated = false;
|
||||||
|
set((state) => ({
|
||||||
|
members: state.members.map((member) => {
|
||||||
|
if (member.id !== id) return member;
|
||||||
|
updated = true;
|
||||||
|
return { ...member, ...patch };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
|
||||||
reviveMember: (id, percentMaxHealth = 0.35) => {
|
reviveMember: (id, percentMaxHealth = 0.35) => {
|
||||||
let restored = 0;
|
let restored = 0;
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
+276
-28
@@ -12,6 +12,7 @@ import {
|
|||||||
translateRomAbilitySemantics,
|
translateRomAbilitySemantics,
|
||||||
type RomAbilitySemantic,
|
type RomAbilitySemantic,
|
||||||
type RomGeneratedAbilityLike,
|
type RomGeneratedAbilityLike,
|
||||||
|
type RomSourceComponent,
|
||||||
} from "./romAbilitySemantics";
|
} from "./romAbilitySemantics";
|
||||||
|
|
||||||
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
||||||
@@ -71,9 +72,9 @@ interface GeneratedSkill {
|
|||||||
const generated = rawCatalog.skills as unknown as readonly GeneratedSkill[];
|
const generated = rawCatalog.skills as unknown as readonly GeneratedSkill[];
|
||||||
|
|
||||||
function resource(value: string): ResourceType {
|
function resource(value: string): ResourceType {
|
||||||
return ["mana", "rage", "energy", "runic-power", "focus", "health"].includes(value)
|
return ["mana", "rage", "energy", "runic-power", "focus", "nature-power", "psi", "health"].includes(value)
|
||||||
? value as ResourceType
|
? value as ResourceType
|
||||||
: "mana";
|
: `custom:${slug(value)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function costs(rows: readonly GeneratedCost[]): readonly AbilityCost[] {
|
function costs(rows: readonly GeneratedCost[]): readonly AbilityCost[] {
|
||||||
@@ -109,10 +110,10 @@ function knownResource(value: string): ResourceType | undefined {
|
|||||||
if (value === "mp") return "mana";
|
if (value === "mp") return "mana";
|
||||||
if (value === "nature's power") return "nature-power";
|
if (value === "nature's power") return "nature-power";
|
||||||
if (value === "psi point" || value === "psi points") return "psi";
|
if (value === "psi point" || value === "psi points") return "psi";
|
||||||
if (value === "soul point" || value === "soul points") return "focus";
|
if (value === "soul point" || value === "soul points") return "soul-power";
|
||||||
return ["mana", "rage", "energy", "runic-power", "focus", "nature-power", "psi", "health"].includes(value)
|
return ["mana", "rage", "energy", "runic-power", "focus", "nature-power", "psi", "health"].includes(value)
|
||||||
? value as ResourceType
|
? value as ResourceType
|
||||||
: undefined;
|
: value ? `custom:${slug(value)}` : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function auraRecipient(row: GeneratedSemanticRow, semantic: Extract<RomAbilitySemantic, { kind: "aura" | "absorb" | "proc" }>): "ability-target" | "caster" {
|
function auraRecipient(row: GeneratedSemanticRow, semantic: Extract<RomAbilitySemantic, { kind: "aura" | "absorb" | "proc" }>): "ability-target" | "caster" {
|
||||||
@@ -123,36 +124,119 @@ function auraRecipient(row: GeneratedSemanticRow, semantic: Extract<RomAbilitySe
|
|||||||
return "ability-target";
|
return "ability-target";
|
||||||
}
|
}
|
||||||
|
|
||||||
function auraModifier(semantic: Extract<RomAbilitySemantic, { kind: "aura" }>): AuraModifier | null {
|
const MAGIC_SCHOOLS: readonly DamageSchool[] = ["arcane", "fire", "frost", "holy", "nature", "shadow"];
|
||||||
|
|
||||||
|
function percent(value: number): number {
|
||||||
|
return value / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function magicDamageTaken(value: number): readonly AuraModifier[] {
|
||||||
|
return MAGIC_SCHOOLS.map((school) => ({
|
||||||
|
kind: "damage" as const,
|
||||||
|
direction: "taken" as const,
|
||||||
|
school,
|
||||||
|
operation: "percent" as const,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function auraModifiers(semantic: Extract<RomAbilitySemantic, { kind: "aura" }>): readonly AuraModifier[] {
|
||||||
const value = semantic.value ?? 0;
|
const value = semantic.value ?? 0;
|
||||||
if (semantic.effect === "damage-immunity") {
|
if (semantic.effect === "damage-immunity") {
|
||||||
return { kind: "damage", direction: "taken", operation: "percent", value: -100 };
|
return [{ kind: "damage", direction: "taken", operation: "percent", value: -1 }];
|
||||||
}
|
}
|
||||||
if (semantic.effect === "physical-damage-immunity") {
|
if (semantic.effect === "physical-damage-immunity") {
|
||||||
return { kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -100 };
|
return [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -1 }];
|
||||||
}
|
}
|
||||||
if (semantic.effect === "magic-damage-immunity") {
|
if (semantic.effect === "magic-damage-immunity") {
|
||||||
return { kind: "stat", stat: "magic-damage-immunity", operation: "flat", value: 1 };
|
return magicDamageTaken(-1);
|
||||||
}
|
}
|
||||||
if (semantic.effect === "poison-immunity") {
|
if (semantic.effect === "poison-immunity") {
|
||||||
return { kind: "stat", stat: "poison-immunity", operation: "flat", value: 1 };
|
return [{ kind: "stat", stat: "poison-immunity", operation: "flat", value: 1 }];
|
||||||
}
|
}
|
||||||
if (semantic.effect === "maximum-mana") {
|
if (semantic.effect === "maximum-mana") {
|
||||||
return { kind: "resource", resource: "mana", property: "maximum", operation: "flat", value };
|
return [{ kind: "resource", resource: "mana", property: "maximum", operation: "flat", value }];
|
||||||
}
|
}
|
||||||
if (semantic.effect === "mana-regeneration") {
|
if (semantic.effect === "mana-regeneration") {
|
||||||
return { kind: "resource", resource: "mana", property: "regeneration", operation: "flat", value };
|
return [{ kind: "resource", resource: "mana", property: "regeneration", operation: "flat", value }];
|
||||||
}
|
}
|
||||||
if (semantic.effect === "health-regeneration") {
|
if (semantic.effect === "health-regeneration") {
|
||||||
return { kind: "resource", resource: "health", property: "regeneration", operation: "flat", value };
|
return [{ kind: "stat", stat: "health-regeneration", operation: "flat", value }];
|
||||||
}
|
}
|
||||||
if (["physical-attack", "magical-attack", "weapon-damage"].includes(semantic.effect)) {
|
|
||||||
return { kind: "stat", stat: semantic.effect, operation: "flat", value };
|
const type = semantic.modifierType;
|
||||||
|
if (type === null) return semantic.value === null
|
||||||
|
? []
|
||||||
|
: [{ kind: "stat", stat: semantic.effect, operation: "flat", value }];
|
||||||
|
if (type >= 2 && type <= 22) {
|
||||||
|
return [{ kind: "stat", stat: semantic.effect, operation: "flat", value }];
|
||||||
}
|
}
|
||||||
if (semantic.modifierType !== null || semantic.value !== null) {
|
if (type === 23) return [{ kind: "stat", stat: "attack-speed", operation: "flat", value: -value }];
|
||||||
return { kind: "stat", stat: semantic.effect, operation: "flat", value };
|
if (type === 24) return [{ kind: "stat", stat: "movement-speed", operation: "percent", value: percent(value) }];
|
||||||
|
if (type === 25) return [
|
||||||
|
{ kind: "damage", direction: "dealt", attackKind: "melee", operation: "flat", value },
|
||||||
|
{ kind: "damage", direction: "dealt", attackKind: "ranged", operation: "flat", value },
|
||||||
|
];
|
||||||
|
if (type >= 26 && type <= 32) {
|
||||||
|
const school = ({ 27: "nature", 28: "frost", 29: "fire", 30: "nature", 31: "holy", 32: "shadow" } as Partial<Record<number, DamageSchool>>)[type];
|
||||||
|
return [{ kind: "stat", stat: school ? `resistance:${school}` : "all-resistance", operation: "flat", value }];
|
||||||
}
|
}
|
||||||
return null;
|
if (type === 33) return [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: -percent(value) }];
|
||||||
|
if (type === 37 || (type >= 56 && type <= 67) || type === 173) {
|
||||||
|
return [{ kind: "damage", direction: "dealt", attackKind: "melee", operation: "percent", value: percent(value) }];
|
||||||
|
}
|
||||||
|
if (type >= 38 && type <= 43) return [{ kind: "stat", stat: "armor", operation: "percent", value: percent(value) }];
|
||||||
|
if (type === 44) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "percent", value: percent(value) }];
|
||||||
|
if (type >= 45 && type <= 50) {
|
||||||
|
const school = (["nature", "frost", "fire", "nature", "holy", "shadow"] as const)[type - 45];
|
||||||
|
return [{ kind: "damage", direction: "dealt", school, operation: "percent", value: percent(value) }];
|
||||||
|
}
|
||||||
|
if (type === 51) return [{ kind: "stat", stat: "spell-haste", operation: "flat", value: -value }];
|
||||||
|
if (type >= 52 && type <= 55) return [{ kind: "damage", direction: "dealt", attackKind: "ranged", operation: "percent", value: percent(value) }];
|
||||||
|
if (type >= 68 && type <= 71) return [{ kind: "stat", stat: "ranged-haste", operation: "flat", value: -value }];
|
||||||
|
if (type >= 72 && type <= 83) return [{ kind: "stat", stat: "melee-haste", operation: "flat", value: -value }];
|
||||||
|
if (type === 134) return [{ kind: "stat", stat: "attack-power", operation: "percent", value: percent(value) }];
|
||||||
|
if (type === 135) return [{ kind: "stat", stat: "armor", operation: "percent", value: percent(value) }];
|
||||||
|
if (type === 138) return [{ kind: "stat", stat: "threat", operation: "percent", value: percent(value) }];
|
||||||
|
if (type >= 139 && type <= 141) {
|
||||||
|
const resource = (["rage", "focus", "energy"] as const)[type - 139];
|
||||||
|
return [{ kind: "resource", resource, property: "regeneration", operation: "percent", value: percent(value) }];
|
||||||
|
}
|
||||||
|
if (type === 142) return magicDamageTaken(-percent(value));
|
||||||
|
if (type === 143) return [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -percent(value) }];
|
||||||
|
if (type === 144) return [{ kind: "healing", direction: "taken", operation: "percent", value: -percent(value) }];
|
||||||
|
if (type === 148) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "flat", value }];
|
||||||
|
if (type === 149) return [{ kind: "healing", direction: "done", operation: "percent", value: percent(value) }];
|
||||||
|
if (type === 150) return [{ kind: "healing", direction: "done", operation: "flat", value }];
|
||||||
|
if (type >= 161 && type <= 166) {
|
||||||
|
const stat = (["strength", "stamina", "intellect", "wisdom", "dexterity", "all-primary-attributes"] as const)[type - 161];
|
||||||
|
return [{ kind: "stat", stat, operation: "percent", value: percent(value) }];
|
||||||
|
}
|
||||||
|
if (type === 167) return [{ kind: "stat", stat: "maximum-health", operation: "percent", value: percent(value) }];
|
||||||
|
if (type === 168) return [{ kind: "resource", resource: "mana", property: "maximum", operation: "percent", value: percent(value) }];
|
||||||
|
if (type === 170) return [{ kind: "stat", stat: "magical-defense", operation: "percent", value: percent(value) }];
|
||||||
|
if (type === 171) return [{ kind: "stat", stat: "magical-attack", operation: "percent", value: percent(value) }];
|
||||||
|
if (type === 184 || type === 185 || type === 186 || type === 187 || type === 188 || type === 189) {
|
||||||
|
const school = (["nature", "frost", "fire", "nature", "holy", "shadow"] as const)[type - 184];
|
||||||
|
return [{ kind: "damage", direction: "dealt", school, operation: "flat", value }];
|
||||||
|
}
|
||||||
|
if (type === 191) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "flat", value }];
|
||||||
|
if (type === 192 || type === 208) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "percent", value: percent(value) }];
|
||||||
|
if (type === 195 || type === 199) return [{ kind: "stat", stat: "magical-hit", operation: "flat", value }];
|
||||||
|
if (type === 197) return [{ kind: "stat", stat: "physical-hit", operation: "flat", value }];
|
||||||
|
if (type === 198) return [{ kind: "stat", stat: "dodge", operation: "flat", value }];
|
||||||
|
if (type === 204 || type === 205) return [{ kind: "damage", direction: "dealt", operation: "percent", value: percent(value) }];
|
||||||
|
if (type === 206 || type === 207 || type === 209) return [{ kind: "damage", direction: "taken", operation: "percent", value: percent(value) }];
|
||||||
|
if (type >= 214 && type <= 216) {
|
||||||
|
const resource = (["rage", "energy", "focus"] as const)[type - 214];
|
||||||
|
return [{ kind: "resource", resource, property: "cost", operation: "percent", value: -percent(value) }];
|
||||||
|
}
|
||||||
|
if (type >= 220 && type <= 226) {
|
||||||
|
const school = ({ 221: "nature", 222: "frost", 223: "fire", 224: "nature", 225: "holy", 226: "shadow" } as Partial<Record<number, DamageSchool>>)[type];
|
||||||
|
return [{ kind: "stat", stat: school ? `resistance:${school}` : "all-resistance", operation: "percent", value: percent(value) }];
|
||||||
|
}
|
||||||
|
if (type === 227) return [{ kind: "stat", stat: "parry", operation: "percent", value: percent(value) }];
|
||||||
|
return [{ kind: "stat", stat: semantic.effect, operation: "flat", value }];
|
||||||
}
|
}
|
||||||
|
|
||||||
function controlKind(effect: string): Extract<AbilityEffect, { kind: "apply-aura" }>["control"] {
|
function controlKind(effect: string): Extract<AbilityEffect, { kind: "apply-aura" }>["control"] {
|
||||||
@@ -173,11 +257,30 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
|||||||
const componentKey = semantic.sourceComponentIds.join("-") || "tooltip";
|
const componentKey = semantic.sourceComponentIds.join("-") || "tooltip";
|
||||||
const effectId = `${row.sourceSkillId}-${componentKey}-${index}`;
|
const effectId = `${row.sourceSkillId}-${componentKey}-${index}`;
|
||||||
if (semantic.kind === "damage" || semantic.kind === "heal") {
|
if (semantic.kind === "damage" || semantic.kind === "heal") {
|
||||||
|
const rawPower = Math.max(0, semantic.power);
|
||||||
|
const rawFixed = Math.max(0, semantic.fixedValue);
|
||||||
|
const coefficientCalculation = semantic.calculationType === null
|
||||||
|
? rawPower > 0 && rawPower <= 4
|
||||||
|
: [0, 2, 3, 10].includes(semantic.calculationType);
|
||||||
|
const powerCoefficient = coefficientCalculation ? rawPower : 0;
|
||||||
|
const fixedCoefficient = semantic.fixedType > 0 ? rawFixed : 0;
|
||||||
|
const coefficientBase = powerCoefficient + fixedCoefficient;
|
||||||
|
const coefficientPerLevel = powerCoefficient * Math.max(0, semantic.powerPerSkillLevel) / 100
|
||||||
|
+ fixedCoefficient * Math.max(0, semantic.fixedPerSkillLevel) / 100;
|
||||||
|
const coefficient = coefficientBase + coefficientPerLevel;
|
||||||
|
const sourcePoints = coefficientCalculation ? 0 : rawPower;
|
||||||
|
const pointsPerLevel = sourcePoints * Math.max(0, semantic.powerPerSkillLevel) / 100;
|
||||||
|
const basePoints = sourcePoints + pointsPerLevel;
|
||||||
return {
|
return {
|
||||||
kind: semantic.kind,
|
kind: semantic.kind,
|
||||||
coefficient: Math.max(0, semantic.power),
|
coefficient,
|
||||||
basePoints: Math.max(0, semantic.fixedValue),
|
...(coefficientPerLevel > 0 ? { coefficientPerLevel, sourceLevel: 1 } : {}),
|
||||||
pointsPerLevel: Math.max(0, semantic.fixedPerSkillLevel),
|
...(basePoints > 0 ? {
|
||||||
|
basePoints,
|
||||||
|
pointsPerLevel,
|
||||||
|
sourceLevel: 1,
|
||||||
|
...(coefficient > 0 ? { bonusPowerCoefficient: coefficient } : {}),
|
||||||
|
} : {}),
|
||||||
...(semantic.kind === "heal" && semantic.percentMaxHealth !== undefined
|
...(semantic.kind === "heal" && semantic.percentMaxHealth !== undefined
|
||||||
? { percentMaxHealth: semantic.percentMaxHealth }
|
? { percentMaxHealth: semantic.percentMaxHealth }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -189,8 +292,9 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
|||||||
return {
|
return {
|
||||||
kind: semantic.kind,
|
kind: semantic.kind,
|
||||||
coefficient: 0,
|
coefficient: 0,
|
||||||
basePoints: Math.max(0, semantic.basePerTick),
|
basePoints: Math.max(0, semantic.basePerTick) * (1 + Math.max(0, semantic.perSkillLevel) / 100),
|
||||||
pointsPerLevel: Math.max(0, semantic.perSkillLevel),
|
pointsPerLevel: Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100,
|
||||||
|
sourceLevel: 1,
|
||||||
ticks,
|
ticks,
|
||||||
intervalMs,
|
intervalMs,
|
||||||
...(semantic.kind === "dot" ? { tag: `rom-${effectId}` } : {}),
|
...(semantic.kind === "dot" ? { tag: `rom-${effectId}` } : {}),
|
||||||
@@ -214,24 +318,30 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
|||||||
id: `rom-control-${effectId}-${slug(semantic.effect)}`,
|
id: `rom-control-${effectId}-${slug(semantic.effect)}`,
|
||||||
name: `${row.name}: ${semantic.effect}`,
|
name: `${row.name}: ${semantic.effect}`,
|
||||||
disposition: "debuff",
|
disposition: "debuff",
|
||||||
durationMs: durationMs(semantic.durationSeconds),
|
durationMs: Math.max(1_000, durationMs(semantic.durationSeconds)),
|
||||||
dispelCategory: "magic",
|
dispelCategory: "magic",
|
||||||
};
|
};
|
||||||
return { kind: "apply-aura", aura, control, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0 };
|
return { kind: "apply-aura", aura, control, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0 };
|
||||||
}
|
}
|
||||||
if (semantic.kind === "interrupt") return { kind: "interrupt", lockoutMs: durationMs(semantic.lockoutSeconds || 4) };
|
if (semantic.kind === "interrupt") return { kind: "interrupt", lockoutMs: Math.max(1_000, durationMs(semantic.lockoutSeconds || 4)) };
|
||||||
if (semantic.kind === "aura") {
|
if (semantic.kind === "aura") {
|
||||||
const recipient = auraRecipient(row, semantic);
|
const recipient = auraRecipient(row, semantic);
|
||||||
const modifier = auraModifier(semantic);
|
const modifiers = auraModifiers(semantic);
|
||||||
|
const partyScope = /\b(?:all )?party members?\b/i.test(row.description);
|
||||||
const aura: AuraDefinition = {
|
const aura: AuraDefinition = {
|
||||||
id: `rom-aura-${effectId}-${slug(semantic.effect)}`,
|
id: `rom-aura-${effectId}-${slug(semantic.effect)}`,
|
||||||
name: `${row.name}: ${semantic.effect}`,
|
name: `${row.name}: ${semantic.effect}`,
|
||||||
disposition: recipient === "caster" || row.target !== "hostile" ? "buff" : "debuff",
|
disposition: recipient === "caster" || row.target !== "hostile" ? "buff" : "debuff",
|
||||||
durationMs: semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null,
|
durationMs: semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null,
|
||||||
dispelCategory: "magic",
|
dispelCategory: "magic",
|
||||||
...(modifier ? { modifiers: [modifier] } : {}),
|
...(modifiers.length ? { modifiers } : {}),
|
||||||
|
...(semantic.effect === "poison-immunity"
|
||||||
|
? { utilityTags: ["poison-immunity"] }
|
||||||
|
: semantic.effect === "stealth"
|
||||||
|
? { utilityTags: ["stealth"] }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
return { kind: "apply-aura", aura, recipient, sourceEffectType: -1, sourceAuraType: semantic.modifierType ?? -1, miscValue: semantic.value ?? 0, triggerSpellId: 0 };
|
return { kind: "apply-aura", aura, recipient, ...(partyScope ? { scope: "party" as const } : {}), sourceEffectType: -1, sourceAuraType: semantic.modifierType ?? -1, miscValue: semantic.value ?? 0, triggerSpellId: 0 };
|
||||||
}
|
}
|
||||||
if (semantic.kind === "absorb") {
|
if (semantic.kind === "absorb") {
|
||||||
if (semantic.amount <= 0 && semantic.perSkillLevel <= 0) {
|
if (semantic.amount <= 0 && semantic.perSkillLevel <= 0) {
|
||||||
@@ -260,6 +370,7 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
|||||||
id: `rom-proc-trigger-${effectId}`,
|
id: `rom-proc-trigger-${effectId}`,
|
||||||
triggers: procTrigger(semantic.trigger),
|
triggers: procTrigger(semantic.trigger),
|
||||||
chance,
|
chance,
|
||||||
|
...(semantic.intervalSeconds && semantic.intervalSeconds > 0 ? { internalCooldownMs: durationMs(semantic.intervalSeconds) } : {}),
|
||||||
action: { kind: "custom", id: "rom-trigger-skill", data: { spellId: semantic.triggerSkillId, rank: semantic.rank ?? 0 } },
|
action: { kind: "custom", id: "rom-trigger-skill", data: { spellId: semantic.triggerSkillId, rank: semantic.rank ?? 0 } },
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
@@ -267,6 +378,23 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
|||||||
}
|
}
|
||||||
if (semantic.kind === "resource") {
|
if (semantic.kind === "resource") {
|
||||||
const resourceType = knownResource(semantic.resource);
|
const resourceType = knownResource(semantic.resource);
|
||||||
|
if (semantic.intervalSeconds && semantic.intervalSeconds > 0 && resourceType) {
|
||||||
|
const amountPerSecond = semantic.amount / semantic.intervalSeconds;
|
||||||
|
const aura: AuraDefinition = {
|
||||||
|
id: `rom-resource-over-time-${effectId}`,
|
||||||
|
name: row.name,
|
||||||
|
disposition: semantic.operation === "drain" ? "debuff" : "buff",
|
||||||
|
durationMs: semantic.durationSeconds && semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null,
|
||||||
|
modifiers: [{
|
||||||
|
kind: "resource",
|
||||||
|
resource: resourceType,
|
||||||
|
property: "regeneration",
|
||||||
|
operation: "flat",
|
||||||
|
value: semantic.operation === "drain" ? -amountPerSecond : amountPerSecond,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
return { kind: "apply-aura", aura, recipient: row.target === "hostile" ? "ability-target" : "caster", sourceEffectType: -1, sourceAuraType: -1, miscValue: semantic.amount, triggerSpellId: 0 };
|
||||||
|
}
|
||||||
if (semantic.operation === "drain") return {
|
if (semantic.operation === "drain") return {
|
||||||
kind: "resource-drain",
|
kind: "resource-drain",
|
||||||
amount: semantic.amount,
|
amount: semantic.amount,
|
||||||
@@ -288,13 +416,97 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
|
|||||||
if (semantic.kind === "trigger-spell") return { kind: "trigger-spell", spellId: semantic.spellId };
|
if (semantic.kind === "trigger-spell") return { kind: "trigger-spell", spellId: semantic.spellId };
|
||||||
if (semantic.kind === "pet") return { kind: "pet", action: semantic.action };
|
if (semantic.kind === "pet") return { kind: "pet", action: semantic.action };
|
||||||
if (semantic.kind === "custom") {
|
if (semantic.kind === "custom") {
|
||||||
|
if (semantic.mechanicId === "threat-reset") {
|
||||||
|
return { kind: "threat", mode: "percent", amount: -100 };
|
||||||
|
}
|
||||||
return { kind: "scripted", effectType: -1, auraType: -1, miscValue: 0, triggerSpellId: 0, label: `RoM ${semantic.mechanicId}: ${semantic.summary}` };
|
return { kind: "scripted", effectType: -1, auraType: -1, miscValue: 0, triggerSpellId: 0, label: `RoM ${semantic.mechanicId}: ${semantic.summary}` };
|
||||||
}
|
}
|
||||||
return { kind: "scripted", effectType: -1, auraType: -1, miscValue: 0, triggerSpellId: 0, label: `RoM source mechanic` };
|
return { kind: "scripted", effectType: -1, auraType: -1, miscValue: 0, triggerSpellId: 0, label: `RoM source mechanic` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stateAuraEffect(
|
||||||
|
row: GeneratedSemanticRow,
|
||||||
|
stateId: string,
|
||||||
|
name: string,
|
||||||
|
duration: number | null,
|
||||||
|
options: Pick<AuraDefinition, "maxStacks" | "stackBehavior" | "procs"> = {},
|
||||||
|
): AbilityEffect {
|
||||||
|
return {
|
||||||
|
kind: "apply-aura",
|
||||||
|
recipient: "caster",
|
||||||
|
aura: {
|
||||||
|
id: `rom-state-${stateId}`,
|
||||||
|
name,
|
||||||
|
disposition: "buff",
|
||||||
|
durationMs: duration,
|
||||||
|
utilityTags: [`rom-${stateId}`],
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
sourceEffectType: -1,
|
||||||
|
sourceAuraType: -1,
|
||||||
|
miscValue: 0,
|
||||||
|
triggerSpellId: row.sourceSkillId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null {
|
||||||
|
if (row.sourceSkillId === 491163) {
|
||||||
|
return [stateAuraEffect(row, "frost-arrow", "Frost Arrow", 600_000, {
|
||||||
|
procs: [{
|
||||||
|
id: "rom-frost-arrow-wind-arrow",
|
||||||
|
triggers: "hit",
|
||||||
|
chance: 0.5,
|
||||||
|
action: { kind: "custom", id: "rom-frost-arrow" },
|
||||||
|
}],
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
if (row.sourceSkillId === 491171) {
|
||||||
|
return [stateAuraEffect(row, "static-field-charge", "Static Field Charge", null)];
|
||||||
|
}
|
||||||
|
if (row.sourceSkillId === 494038) {
|
||||||
|
return [stateAuraEffect(row, "entling-offering", "Entling Offering", 900_000, {
|
||||||
|
procs: [{
|
||||||
|
id: "rom-entling-offering-shot",
|
||||||
|
triggers: "hit",
|
||||||
|
chance: 1,
|
||||||
|
action: { kind: "custom", id: "rom-entling-offering" },
|
||||||
|
}],
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
if (row.sourceSkillId === 495296) {
|
||||||
|
return [stateAuraEffect(row, "holy-salvation-candle", "Holy Salvation Candle", 20_000)];
|
||||||
|
}
|
||||||
|
if (row.sourceSkillId === 498571) {
|
||||||
|
return [stateAuraEffect(row, "remodeled-body", "Remodeled Body", 8_000)];
|
||||||
|
}
|
||||||
|
if (row.sourceSkillId === 499542) {
|
||||||
|
return [{
|
||||||
|
kind: "utility",
|
||||||
|
action: "class-script",
|
||||||
|
effectType: -1,
|
||||||
|
auraType: -1,
|
||||||
|
miscValue: 499542,
|
||||||
|
triggerSpellId: 623078,
|
||||||
|
durationMs: 0,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||||
return translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
|
const specialized = specializedEffects(row);
|
||||||
|
if (specialized) return specialized;
|
||||||
|
const translated = translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
|
||||||
|
if (row.sourceSkillId === 491169) {
|
||||||
|
return [...translated, stateAuraEffect(row, "charged", "Charged", 6_000)];
|
||||||
|
}
|
||||||
|
return translated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function areaRadius(row: Pick<GeneratedSkill, "description" | "range">): number | undefined {
|
||||||
|
if (!/\b(?:multiple targets|all targets|surrounding (?:enemies|targets)|party members within|friendly targets within|targets within (?:a )?range)\b/i.test(row.description)) return undefined;
|
||||||
|
const sourceRange = row.description.match(/\b(?:range|radius) of\s+(\d+(?:\.\d+)?)/i)?.[1];
|
||||||
|
return Math.max(3, Math.min(40, sourceRange ? Number(sourceRange) / 10 : row.range || 8));
|
||||||
}
|
}
|
||||||
|
|
||||||
function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition {
|
function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition {
|
||||||
@@ -337,6 +549,7 @@ function ability(row: GeneratedSkill): AbilityDefinition {
|
|||||||
description: row.description,
|
description: row.description,
|
||||||
target: row.target,
|
target: row.target,
|
||||||
range: { min: 0, max: row.range },
|
range: { min: 0, max: row.range },
|
||||||
|
...(areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
|
||||||
castMode: row.castTimeMs > 0 ? "cast" : "instant",
|
castMode: row.castTimeMs > 0 ? "cast" : "instant",
|
||||||
castTimeMs: row.castTimeMs,
|
castTimeMs: row.castTimeMs,
|
||||||
cooldownMs: row.cooldownMs,
|
cooldownMs: row.cooldownMs,
|
||||||
@@ -354,6 +567,41 @@ function ability(row: GeneratedSkill): AbilityDefinition {
|
|||||||
|
|
||||||
export const ROM_ALL_ABILITIES = Object.freeze(generated.map(ability));
|
export const ROM_ALL_ABILITIES = Object.freeze(generated.map(ability));
|
||||||
|
|
||||||
|
const romAbilityByGeneratedId = new Map(ROM_ALL_ABILITIES.map((entry) => [entry.id, entry]));
|
||||||
|
const triggeredComponentOwners = new Map<number, { readonly parent: GeneratedSkill; readonly component: RomSourceComponent }>();
|
||||||
|
for (const parent of generated) {
|
||||||
|
const componentSets = [parent.source?.components ?? [], ...parent.ranks.map((entry) => entry.source?.components ?? [])];
|
||||||
|
for (const components of componentSets) for (const component of components) {
|
||||||
|
if (!triggeredComponentOwners.has(component.id)) triggeredComponentOwners.set(component.id, { parent, component });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve source-triggered MagicObject rows that are not player-facing skills. */
|
||||||
|
export function romTriggeredAbilityBySpellId(spellId: number): AbilityDefinition | null {
|
||||||
|
const owner = triggeredComponentOwners.get(spellId);
|
||||||
|
if (!owner) return null;
|
||||||
|
const parentAbility = romAbilityByGeneratedId.get(owner.parent.id);
|
||||||
|
if (!parentAbility) return null;
|
||||||
|
const triggeredEffects = effects({
|
||||||
|
sourceSkillId: spellId,
|
||||||
|
name: `${owner.parent.name} trigger`,
|
||||||
|
description: "",
|
||||||
|
target: owner.parent.target,
|
||||||
|
passive: false,
|
||||||
|
source: { componentIds: [spellId], components: [owner.component] },
|
||||||
|
}).filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported");
|
||||||
|
if (!triggeredEffects.length) return null;
|
||||||
|
return {
|
||||||
|
...parentAbility,
|
||||||
|
id: `rom-triggered-${spellId}`,
|
||||||
|
dbcSpellId: spellId,
|
||||||
|
name: `${owner.parent.name} trigger`,
|
||||||
|
passive: false,
|
||||||
|
effects: triggeredEffects,
|
||||||
|
ranks: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const ROM_ORDINARY_ABILITIES_BY_CLASS: Readonly<Record<RomClassId, readonly AbilityDefinition[]>> = Object.freeze(
|
export const ROM_ORDINARY_ABILITIES_BY_CLASS: Readonly<Record<RomClassId, readonly AbilityDefinition[]>> = Object.freeze(
|
||||||
Object.fromEntries((rawCatalog.canonicalClasses as readonly { id: RomClassId }[]).map(({ id }) => [
|
Object.fromEntries((rawCatalog.canonicalClasses as readonly { id: RomClassId }[]).map(({ id }) => [
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AbilityEffect } from "./abilityCatalog";
|
||||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||||
import generated from "./romPlayerCatalog.generated.json";
|
import generated from "./romPlayerCatalog.generated.json";
|
||||||
|
|
||||||
@@ -8,6 +9,33 @@ function named(name: string) {
|
|||||||
return ability;
|
return ability;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMeaningfulEffect(effect: AbilityEffect): boolean {
|
||||||
|
if (effect.kind === "scripted" || effect.kind === "unsupported") return false;
|
||||||
|
if (["damage", "heal", "dot", "hot", "shield"].includes(effect.kind)) {
|
||||||
|
const output = effect as Extract<AbilityEffect, { kind: "damage" | "heal" | "dot" | "hot" | "shield" }>;
|
||||||
|
return (
|
||||||
|
output.coefficient > 0
|
||||||
|
|| (output.coefficientPerLevel ?? 0) > 0
|
||||||
|
|| (output.basePoints ?? 0) > 0
|
||||||
|
|| (output.pointsPerLevel ?? 0) > 0
|
||||||
|
|| (output.kind === "heal" && (output.percentMaxHealth ?? 0) > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (effect.kind === "apply-aura") {
|
||||||
|
return Boolean(
|
||||||
|
effect.control
|
||||||
|
|| effect.aura.modifiers?.length
|
||||||
|
|| effect.aura.absorbs?.length
|
||||||
|
|| effect.aura.procs?.length
|
||||||
|
|| effect.aura.utilityTags?.length
|
||||||
|
|| effect.aura.controlImmunities?.length
|
||||||
|
|| effect.aura.reflectSpellChance
|
||||||
|
|| effect.aura.redirectDamagePercent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
describe("executable RuneWaker ability mappings", () => {
|
describe("executable RuneWaker ability mappings", () => {
|
||||||
it("maps Taunt, Shout, and Cleanse to their real combat mechanics", () => {
|
it("maps Taunt, Shout, and Cleanse to their real combat mechanics", () => {
|
||||||
expect(named("Taunt").effects).toEqual(expect.arrayContaining([
|
expect(named("Taunt").effects).toEqual(expect.arrayContaining([
|
||||||
@@ -31,7 +59,7 @@ describe("executable RuneWaker ability mappings", () => {
|
|||||||
expect(holyAura).toMatchObject({
|
expect(holyAura).toMatchObject({
|
||||||
aura: {
|
aura: {
|
||||||
disposition: "buff",
|
disposition: "buff",
|
||||||
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: -100 }],
|
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: -1 }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const electrostatic = named("Electrostatic Charge").effects.find((effect) => (
|
const electrostatic = named("Electrostatic Charge").effects.find((effect) => (
|
||||||
@@ -63,16 +91,61 @@ describe("executable RuneWaker ability mappings", () => {
|
|||||||
expect(named("Spreading Pain").effects.filter((effect) => effect.kind === "trigger-spell")).toHaveLength(3);
|
expect(named("Spreading Pain").effects.filter((effect) => effect.kind === "trigger-spell")).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves only genuinely stateful combo transformations as script-only active skills", () => {
|
it("gives every active base skill and playable rank a concrete runtime effect", () => {
|
||||||
|
const versions = ROM_ALL_ABILITIES.flatMap((ability) => [
|
||||||
|
{ name: ability.name, rank: 0, passive: ability.passive, effects: ability.effects },
|
||||||
|
...(ability.ranks ?? []).map((rank) => ({
|
||||||
|
name: ability.name,
|
||||||
|
rank: rank.rank,
|
||||||
|
passive: ability.passive,
|
||||||
|
effects: rank.effects,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(versions).toHaveLength(1_863);
|
||||||
const scriptOnly = ROM_ALL_ABILITIES
|
const scriptOnly = ROM_ALL_ABILITIES
|
||||||
.filter((ability) => !ability.passive && ability.effects.every((effect) => effect.kind === "scripted"))
|
.filter((ability) => !ability.passive && ability.effects.every((effect) => effect.kind === "scripted"))
|
||||||
.map((ability) => ability.name)
|
.map((ability) => ability.name)
|
||||||
.sort();
|
.sort();
|
||||||
expect(scriptOnly).toEqual([
|
expect(scriptOnly).toEqual([]);
|
||||||
"Electric Compression",
|
for (const version of versions.filter((entry) => !entry.passive)) {
|
||||||
"Entling Offering",
|
expect(
|
||||||
"Frost Arrow",
|
version.effects.some(isMeaningfulEffect),
|
||||||
"Holy Salvation Candle",
|
`${version.name} rank ${version.rank}`,
|
||||||
]);
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("implements source state for RuneWaker's active transformations", () => {
|
||||||
|
expect(named("Frost Arrow").effects).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "apply-aura",
|
||||||
|
aura: expect.objectContaining({
|
||||||
|
utilityTags: ["rom-frost-arrow"],
|
||||||
|
procs: [expect.objectContaining({ action: { kind: "custom", id: "rom-frost-arrow" } })],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
expect(named("Electric Compression").effects).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ utilityTags: ["rom-static-field-charge"] }) }),
|
||||||
|
]));
|
||||||
|
expect(named("Entling Offering").effects).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "apply-aura",
|
||||||
|
aura: expect.objectContaining({
|
||||||
|
utilityTags: ["rom-entling-offering"],
|
||||||
|
procs: [expect.objectContaining({ action: { kind: "custom", id: "rom-entling-offering" } })],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
expect(named("Holy Salvation Candle").effects).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ utilityTags: ["rom-holy-salvation-candle"] }) }),
|
||||||
|
]));
|
||||||
|
expect(named("Remodeled Body").effects).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ utilityTags: ["rom-remodeled-body"] }) }),
|
||||||
|
]));
|
||||||
|
expect(named("Revival").effects).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "utility", action: "class-script" }),
|
||||||
|
]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export interface RomSourceComponent {
|
|||||||
};
|
};
|
||||||
readonly attack?: {
|
readonly attack?: {
|
||||||
readonly type: number;
|
readonly type: number;
|
||||||
|
readonly calculationType?: number;
|
||||||
readonly damagePower: number;
|
readonly damagePower: number;
|
||||||
readonly damagePowerSkillLevelArg: number;
|
readonly damagePowerSkillLevelArg: number;
|
||||||
readonly fixedValue: number;
|
readonly fixedValue: number;
|
||||||
@@ -121,6 +122,7 @@ export type RomAbilitySemantic =
|
|||||||
readonly fixedValue: number;
|
readonly fixedValue: number;
|
||||||
readonly fixedType: number;
|
readonly fixedType: number;
|
||||||
readonly fixedPerSkillLevel: number;
|
readonly fixedPerSkillLevel: number;
|
||||||
|
readonly calculationType: number | null;
|
||||||
readonly percentMaxHealth?: number;
|
readonly percentMaxHealth?: number;
|
||||||
})
|
})
|
||||||
| (RomSemanticBase & {
|
| (RomSemanticBase & {
|
||||||
@@ -170,6 +172,7 @@ export type RomAbilitySemantic =
|
|||||||
readonly operation: "restore" | "drain" | "modify";
|
readonly operation: "restore" | "drain" | "modify";
|
||||||
readonly amount: number;
|
readonly amount: number;
|
||||||
readonly intervalSeconds: number | null;
|
readonly intervalSeconds: number | null;
|
||||||
|
readonly durationSeconds?: number;
|
||||||
readonly percentage?: boolean;
|
readonly percentage?: boolean;
|
||||||
})
|
})
|
||||||
| (RomSemanticBase & {
|
| (RomSemanticBase & {
|
||||||
@@ -225,9 +228,9 @@ const MODIFIER_NAMES: Readonly<Record<number, string>> = Object.freeze({
|
|||||||
15: "magical-attack",
|
15: "magical-attack",
|
||||||
16: "physical-hit",
|
16: "physical-hit",
|
||||||
17: "dodge",
|
17: "dodge",
|
||||||
18: "physical-critical-rate",
|
18: "physical-critical-strike",
|
||||||
19: "physical-critical-power",
|
19: "physical-critical-power",
|
||||||
20: "magical-critical-rate",
|
20: "magical-critical-strike",
|
||||||
21: "magical-critical-power",
|
21: "magical-critical-power",
|
||||||
22: "parry",
|
22: "parry",
|
||||||
23: "attack-speed",
|
23: "attack-speed",
|
||||||
@@ -240,6 +243,66 @@ const MODIFIER_NAMES: Readonly<Record<number, string>> = Object.freeze({
|
|||||||
30: "wind-resistance",
|
30: "wind-resistance",
|
||||||
31: "light-resistance",
|
31: "light-resistance",
|
||||||
32: "dark-resistance",
|
32: "dark-resistance",
|
||||||
|
33: "mana-cost",
|
||||||
|
37: "off-hand-damage",
|
||||||
|
44: "all-magic-damage",
|
||||||
|
45: "earth-damage",
|
||||||
|
46: "water-damage",
|
||||||
|
47: "fire-damage",
|
||||||
|
48: "wind-damage",
|
||||||
|
49: "light-damage",
|
||||||
|
50: "dark-damage",
|
||||||
|
51: "spell-speed",
|
||||||
|
52: "ranged-damage",
|
||||||
|
56: "melee-damage",
|
||||||
|
68: "ranged-attack-speed",
|
||||||
|
72: "melee-attack-speed",
|
||||||
|
134: "physical-attack-percent",
|
||||||
|
135: "physical-defense-percent",
|
||||||
|
138: "threat-percent",
|
||||||
|
139: "rage-regeneration-percent",
|
||||||
|
140: "focus-regeneration-percent",
|
||||||
|
141: "energy-regeneration-percent",
|
||||||
|
142: "magic-damage-absorption-percent",
|
||||||
|
143: "physical-damage-absorption-percent",
|
||||||
|
144: "healing-absorption-percent",
|
||||||
|
148: "magical-damage-points",
|
||||||
|
149: "healing-power-percent",
|
||||||
|
150: "healing-points",
|
||||||
|
161: "strength-percent",
|
||||||
|
162: "stamina-percent",
|
||||||
|
163: "intellect-percent",
|
||||||
|
164: "wisdom-percent",
|
||||||
|
165: "dexterity-percent",
|
||||||
|
166: "all-primary-attributes-percent",
|
||||||
|
167: "maximum-health-percent",
|
||||||
|
168: "maximum-mana-percent",
|
||||||
|
170: "magical-defense-percent",
|
||||||
|
171: "magical-attack-percent",
|
||||||
|
173: "weapon-damage-percent",
|
||||||
|
191: "magical-damage",
|
||||||
|
192: "magical-damage-percent",
|
||||||
|
195: "magical-hit",
|
||||||
|
197: "physical-hit-percent",
|
||||||
|
198: "physical-dodge-percent",
|
||||||
|
199: "magical-hit-percent",
|
||||||
|
204: "player-damage-percent",
|
||||||
|
205: "npc-damage-percent",
|
||||||
|
206: "player-damage-taken-percent",
|
||||||
|
207: "npc-damage-taken-percent",
|
||||||
|
208: "area-magic-damage-percent",
|
||||||
|
209: "area-magic-damage-taken-percent",
|
||||||
|
214: "rage-cost-percent",
|
||||||
|
215: "energy-cost-percent",
|
||||||
|
216: "focus-cost-percent",
|
||||||
|
220: "all-resistance-percent",
|
||||||
|
221: "earth-resistance-percent",
|
||||||
|
222: "water-resistance-percent",
|
||||||
|
223: "fire-resistance-percent",
|
||||||
|
224: "wind-resistance-percent",
|
||||||
|
225: "light-resistance-percent",
|
||||||
|
226: "dark-resistance-percent",
|
||||||
|
227: "parry-percent",
|
||||||
});
|
});
|
||||||
|
|
||||||
const MAGIC_SCHOOLS = ["earth", "water", "fire", "wind", "light", "dark"] as const;
|
const MAGIC_SCHOOLS = ["earth", "water", "fire", "wind", "light", "dark"] as const;
|
||||||
@@ -481,6 +544,7 @@ function componentSemantics(component: RomSourceComponent, target: RomAbilityTar
|
|||||||
operation: dot.base > 0 ? "restore" : "drain",
|
operation: dot.base > 0 ? "restore" : "drain",
|
||||||
amount: Math.abs(dot.base),
|
amount: Math.abs(dot.base),
|
||||||
intervalSeconds: dot.timeSeconds,
|
intervalSeconds: dot.timeSeconds,
|
||||||
|
durationSeconds: positiveDuration(component),
|
||||||
...componentSource(component),
|
...componentSource(component),
|
||||||
}, String(component.id));
|
}, String(component.id));
|
||||||
}
|
}
|
||||||
@@ -498,6 +562,8 @@ function componentSemantics(component: RomSourceComponent, target: RomAbilityTar
|
|||||||
fixedValue: Math.abs(attack.fixedValue),
|
fixedValue: Math.abs(attack.fixedValue),
|
||||||
fixedType: attack.fixedType,
|
fixedType: attack.fixedType,
|
||||||
fixedPerSkillLevel: Math.abs(attack.fixedDamageSkillLevelArg),
|
fixedPerSkillLevel: Math.abs(attack.fixedDamageSkillLevelArg),
|
||||||
|
calculationType: attack.calculationType ?? null,
|
||||||
|
...(healing && attack.calculationType === 8 ? { percentMaxHealth: Math.abs(attack.damagePower) / 100 } : {}),
|
||||||
...componentSource(component),
|
...componentSource(component),
|
||||||
}, String(component.id));
|
}, String(component.id));
|
||||||
} else {
|
} else {
|
||||||
@@ -712,7 +778,7 @@ function tooltipSemantics(ability: RomGeneratedAbilityLike, rows: RomAbilitySema
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (/(?:deals?|inflicts?|causes?)\b[^.]*\b(?:physical|magical|earth|water|fire|wind|light|dark)?\s*damage/.test(lower) && !hasKind(rows, "damage")) {
|
if (/(?:deals?|inflicts?|causes?)\b[^.]*\b(?:physical|magical|earth|water|fire|wind|light|dark)?\s*damage/.test(lower) && !hasKind(rows, "damage")) {
|
||||||
rows.push({ kind: "damage", school: "tooltip-defined", power: 0, powerPerSkillLevel: 0, fixedValue: 0, fixedType: 0, fixedPerSkillLevel: 0, ...tooltipSource() });
|
rows.push({ kind: "damage", school: "tooltip-defined", power: 0, powerPerSkillLevel: 0, fixedValue: 0, fixedType: 0, fixedPerSkillLevel: 0, calculationType: null, ...tooltipSource() });
|
||||||
}
|
}
|
||||||
if (/(?:heals?|restores?|recovers?)\b[^.]*\b(?:hp|health)/.test(lower) && !hasKind(rows, "heal") && !hasKind(rows, "hot")) {
|
if (/(?:heals?|restores?|recovers?)\b[^.]*\b(?:hp|health)/.test(lower) && !hasKind(rows, "heal") && !hasKind(rows, "hot")) {
|
||||||
const percentMaxHealth = firstNumber(lower, /(\d+(?:\.\d+)?)%[^.]*?(?:hp|health)/);
|
const percentMaxHealth = firstNumber(lower, /(\d+(?:\.\d+)?)%[^.]*?(?:hp|health)/);
|
||||||
@@ -724,6 +790,7 @@ function tooltipSemantics(ability: RomGeneratedAbilityLike, rows: RomAbilitySema
|
|||||||
fixedValue: 0,
|
fixedValue: 0,
|
||||||
fixedType: 0,
|
fixedType: 0,
|
||||||
fixedPerSkillLevel: 0,
|
fixedPerSkillLevel: 0,
|
||||||
|
calculationType: null,
|
||||||
...(percentMaxHealth === null ? {} : { percentMaxHealth: percentMaxHealth / 100 }),
|
...(percentMaxHealth === null ? {} : { percentMaxHealth: percentMaxHealth / 100 }),
|
||||||
...tooltipSource(),
|
...tooltipSource(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,36 @@ describe("RuneWaker combat runtime", () => {
|
|||||||
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
|
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses source coefficients for direct damage even without equipment", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 20 });
|
||||||
|
useCombatStore.getState().registerMob("coefficient-dummy", { name: "Coefficient Dummy", maxHealth: 1_000 });
|
||||||
|
setMobPosition("coefficient-dummy", [3, 0, 0]);
|
||||||
|
useCombatStore.getState().selectMob("coefficient-dummy");
|
||||||
|
useCombatStore.setState((state) => ({
|
||||||
|
resource: 100,
|
||||||
|
resourcePools: { ...state.resourcePools, rage: 100 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
expect(useCombatStore.getState().mobs["coefficient-dummy"]!.health).toBeLessThan(1_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finishes a RuneWaker direct heal on the selected party member", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 20 });
|
||||||
|
usePartyStore.getState().initializeParty("rom-urgent-heal", 20, useCombatStore.getState().health, "healer", 2, "rom");
|
||||||
|
const member = usePartyStore.getState().members[0]!;
|
||||||
|
usePartyStore.getState().damageMember(member.id, 500);
|
||||||
|
registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
||||||
|
usePartyStore.getState().selectMember(member.id);
|
||||||
|
const injuredHealth = usePartyStore.getState().members[0]!.health;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("rom-priest-general-0", undefined, 1_000)).toMatchObject({ ok: true });
|
||||||
|
useCombatStore.getState().tick(0.1, 2_100);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||||
|
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses native RoM Rage gains from auto attacks and health damage", () => {
|
it("uses native RoM Rage gains from auto attacks and health damage", () => {
|
||||||
useCombatStore.getState().initializeCharacter({
|
useCombatStore.getState().initializeCharacter({
|
||||||
classId: "rom-warrior",
|
classId: "rom-warrior",
|
||||||
@@ -183,4 +213,59 @@ describe("RuneWaker combat runtime", () => {
|
|||||||
status: "ready",
|
status: "ready",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("chains Plasma Arrow, Electric Compression, and Static Field through their source states", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rom-mage", raceId: "rom-human", level: 20 });
|
||||||
|
useCombatStore.getState().registerMob("static-dummy", { name: "Static Dummy", maxHealth: 10_000 });
|
||||||
|
setMobPosition("static-dummy", [3, 0, 0]);
|
||||||
|
useCombatStore.getState().selectMob("static-dummy");
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-55", undefined, 1_000)).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
reason: "condition-not-met",
|
||||||
|
});
|
||||||
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-52", undefined, 2_000)).toMatchObject({ ok: true });
|
||||||
|
useCombatStore.getState().tick(0.1, 4_100);
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-charged"))).toBe(true);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-55", undefined, 4_200)).toMatchObject({ ok: true });
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-charged"))).toBe(false);
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-static-field-charge"))).toBe(true);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("rom-mage-primary-56", undefined, 5_800)).toMatchObject({ ok: true });
|
||||||
|
useCombatStore.getState().tick(0.1, 6_900);
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-static-field-charge"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets Remodeled Body prevent lethal damage once", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 80 });
|
||||||
|
const now = Date.now();
|
||||||
|
expect(useCombatStore.getState().castAbility("rom-champion-primary-16", undefined, now)).toMatchObject({ ok: true });
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-remodeled-body"))).toBe(true);
|
||||||
|
|
||||||
|
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-remodeled-body"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds and consumes Revival charges from damage taken", () => {
|
||||||
|
useCombatStore.getState().initializeCharacter({
|
||||||
|
classId: "rom-rogue",
|
||||||
|
secondaryClassId: "rom-warden",
|
||||||
|
raceId: "rom-elf",
|
||||||
|
level: 80,
|
||||||
|
});
|
||||||
|
useCombatStore.getState().damagePlayer(100);
|
||||||
|
useCombatStore.getState().damagePlayer(100);
|
||||||
|
useCombatStore.getState().damagePlayer(100);
|
||||||
|
const revival = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("rom-revival-charge"));
|
||||||
|
expect(revival?.stacks).toBe(3);
|
||||||
|
const injuredHealth = useCombatStore.getState().health;
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warden-elite-60", undefined, Date.now())).toMatchObject({ ok: true });
|
||||||
|
|
||||||
|
expect(useCombatStore.getState().health).toBeGreaterThan(injuredHealth);
|
||||||
|
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-revival-charge"))).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -85,6 +85,29 @@ describe("generated RuneWaker player catalog", () => {
|
|||||||
expect(abilityAtLevel(ranked, 80).dbcSpellId).toBe(eligible.spellId);
|
expect(abilityAtLevel(ranked, 80).dbcSpellId).toBe(eligible.spellId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves every source rank and its full flat or percentage cost", () => {
|
||||||
|
const normalizedCosts = (rows: readonly { resource: string; amount: number; percentage?: boolean }[]) => rows.map((cost) => ({
|
||||||
|
resource: cost.resource,
|
||||||
|
amount: cost.amount,
|
||||||
|
...(cost.percentage ? { percentage: true } : {}),
|
||||||
|
}));
|
||||||
|
const generatedRanks = generated.skills.reduce((sum, ability) => sum + ability.ranks.length, 0);
|
||||||
|
expect(generatedRanks).toBe(934);
|
||||||
|
expect(Math.max(...generated.skills.flatMap((ability) => (
|
||||||
|
ability.costs.filter((cost) => !cost.percentage).map((cost) => cost.amount)
|
||||||
|
)))).toBe(400);
|
||||||
|
|
||||||
|
for (const source of generated.skills) {
|
||||||
|
const runtime = ROM_ALL_ABILITIES.find((ability) => ability.id === source.id)!;
|
||||||
|
expect(runtime.costs, `${source.name} base costs`).toEqual(normalizedCosts(source.costs));
|
||||||
|
expect(runtime.ranks).toHaveLength(source.ranks.length);
|
||||||
|
for (const [index, sourceRank] of source.ranks.entries()) {
|
||||||
|
expect(runtime.ranks?.[index]?.spellId, `${source.name} rank ${sourceRank.rank}`).toBe(sourceRank.sourceSkillId);
|
||||||
|
expect(runtime.ranks?.[index]?.costs, `${source.name} rank ${sourceRank.rank} costs`).toEqual(normalizedCosts(sourceRank.costs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("adds exactly ten percent of the secondary class base stats", () => {
|
it("adds exactly ten percent of the secondary class base stats", () => {
|
||||||
const primary = romNativeClassBaseStats("rom-warrior", 40);
|
const primary = romNativeClassBaseStats("rom-warrior", 40);
|
||||||
const secondary = romNativeClassBaseStats("rom-scout", 40);
|
const secondary = romNativeClassBaseStats("rom-scout", 40);
|
||||||
|
|||||||
@@ -20,26 +20,59 @@ function effectsOfKind<K extends AbilityEffect["kind"]>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("WoW 3.3.5 executable effect mapping", () => {
|
describe("WoW 3.3.5 executable effect mapping", () => {
|
||||||
it("never reduces an imported DBC effect to generic utility", () => {
|
it("gives every imported classic rank executable or explicit utility semantics", () => {
|
||||||
const effects = Object.values(WOW335_ABILITIES_BY_CLASS)
|
const effects = Object.values(WOW335_ABILITIES_BY_CLASS)
|
||||||
.flat()
|
.flat()
|
||||||
.flatMap((ability) => ability.ranks?.flatMap((rank) => rank.effects) ?? ability.effects);
|
.flatMap((ability) => ability.ranks?.flatMap((rank) => rank.effects) ?? ability.effects);
|
||||||
|
|
||||||
expect(effects.length).toBeGreaterThan(1_000);
|
expect(effects.length).toBeGreaterThan(1_000);
|
||||||
expect(effects.some((effect) => effect.kind === "utility")).toBe(false);
|
expect(effectsOfKind({ effects }, "scripted")).toEqual([]);
|
||||||
expect([
|
expect(effectsOfKind({ effects }, "unsupported")).toEqual([]);
|
||||||
...new Set(effectsOfKind(
|
for (const effect of effectsOfKind({ effects }, "utility")) {
|
||||||
{ effects },
|
expect(effect.action).toMatch(/^(?:create-item|open-lock|portal|weapon-enchant|pickpocket|farsight|class-script)$/);
|
||||||
"scripted",
|
|
||||||
).map((effect) => effect.effectType)),
|
|
||||||
].sort((left, right) => left - right)).toEqual([3, 77]);
|
|
||||||
for (const effect of effectsOfKind(
|
|
||||||
{ effects },
|
|
||||||
"unsupported",
|
|
||||||
)) {
|
|
||||||
expect(effect.combat).toBe(false);
|
|
||||||
expect(effect.reason).toMatch(/^Noncombat DBC effect \d+$/);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const ability of Object.values(WOW335_ABILITIES_BY_CLASS).flat()) {
|
||||||
|
if (ability.passive) continue;
|
||||||
|
for (const rank of ability.ranks ?? []) {
|
||||||
|
expect(rank.effects.length, `${ability.name} rank ${rank.rank}`).toBeGreaterThan(0);
|
||||||
|
expect(rank.effects.some((effect) => {
|
||||||
|
if (["trigger-spell", "scripted", "unsupported"].includes(effect.kind)) return false;
|
||||||
|
if (effect.kind !== "apply-aura") return true;
|
||||||
|
return Boolean(
|
||||||
|
effect.control
|
||||||
|
|| effect.aura.modifiers?.length
|
||||||
|
|| effect.aura.absorbs?.length
|
||||||
|
|| effect.aura.procs?.length
|
||||||
|
|| effect.aura.utilityTags?.length
|
||||||
|
|| effect.aura.controlImmunities?.length
|
||||||
|
|| effect.aura.reflectSpellChance
|
||||||
|
|| effect.aura.redirectDamagePercent,
|
||||||
|
);
|
||||||
|
}), `${ability.name} rank ${rank.rank}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not promote triggered rank-zero child spells into player ranks", () => {
|
||||||
|
expect(byId("wow335-warrior-execute").ranks?.map((rank) => rank.spellId)).not.toContain(20647);
|
||||||
|
expect(byId("wow335-priest-divine-hymn").ranks?.map((rank) => rank.spellId)).toEqual([64843]);
|
||||||
|
expect(byId("wow335-death-knight-death-coil").ranks?.map((rank) => rank.spellId)).not.toContain(47632);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps percentage costs and form resources source-correct", () => {
|
||||||
|
expect(byId("wow335-mage-frostbolt").cost).toMatchObject({ resource: "mana", amount: 14, percentage: true });
|
||||||
|
expect(byId("wow335-druid-claw").cost).toMatchObject({ resource: "energy", amount: 45 });
|
||||||
|
expect(byId("wow335-druid-maul").cost).toMatchObject({ resource: "rage", amount: 15 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides mechanics for previously silent class scripts and hidden proc spells", () => {
|
||||||
|
expect(effectsOfKind(byId("wow335-warrior-slam"), "damage")).not.toEqual([]);
|
||||||
|
expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "pull")).not.toEqual([]);
|
||||||
|
expect(effectsOfKind(byId("wow335-shaman-windfury-weapon"), "apply-aura")[0]?.aura.procs).not.toEqual([]);
|
||||||
|
expect(effectsOfKind(byId("wow335-shaman-lightning-shield"), "apply-aura")[0]?.aura.procs).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ triggers: "damage-taken", charges: 3 }),
|
||||||
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates source-named stat and avoidance auras", () => {
|
it("creates source-named stat and avoidance auras", () => {
|
||||||
@@ -77,6 +110,28 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
expect(shield.effects.some((effect) => effect.kind === "shield")).toBe(false);
|
expect(shield.effects.some((effect) => effect.kind === "shield")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps Paladin emergency cooldowns to their source-backed mechanics", () => {
|
||||||
|
const layOnHands = byId("wow335-paladin-lay-on-hands");
|
||||||
|
expect(effectsOfKind(layOnHands, "heal")).toContainEqual(expect.objectContaining({
|
||||||
|
coefficient: 0,
|
||||||
|
percentMaxHealth: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handOfProtection = byId("wow335-paladin-hand-of-protection");
|
||||||
|
const immunity = effectsOfKind(handOfProtection, "apply-aura")
|
||||||
|
.find((effect) => effect.sourceAuraType === 39);
|
||||||
|
expect(immunity?.aura).toMatchObject({
|
||||||
|
durationMs: 6_000,
|
||||||
|
modifiers: [{
|
||||||
|
kind: "damage",
|
||||||
|
direction: "taken",
|
||||||
|
school: "physical",
|
||||||
|
operation: "percent",
|
||||||
|
value: -1,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps periodic damage and healing auras out of the generic HUD strip", () => {
|
it("keeps periodic damage and healing auras out of the generic HUD strip", () => {
|
||||||
const pain = byId("wow335-priest-shadow-word-pain");
|
const pain = byId("wow335-priest-shadow-word-pain");
|
||||||
const painAura = effectsOfKind(pain, "apply-aura")
|
const painAura = effectsOfKind(pain, "apply-aura")
|
||||||
@@ -123,11 +178,11 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
.find((effect) => effect.sourceAuraType === 42);
|
.find((effect) => effect.sourceAuraType === 42);
|
||||||
expect(seal?.aura.procs).toEqual([
|
expect(seal?.aura.procs).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
triggers: expect.arrayContaining(["hit"]),
|
triggers: "hit",
|
||||||
action: {
|
action: {
|
||||||
kind: "custom",
|
kind: "custom",
|
||||||
id: "wow335-trigger-spell",
|
id: "classic-status",
|
||||||
data: { spellId: 20170 },
|
data: { status: "stun", durationMs: 2_000 },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ function effectTargetsHostile(effect: Wow335Effect): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function schoolFromMask(mask: number): DamageSchool | undefined {
|
function schoolFromMask(mask: number): DamageSchool | undefined {
|
||||||
|
const matches = schoolsFromMask(mask);
|
||||||
|
return matches.length === 1 ? matches[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schoolsFromMask(mask: number): readonly DamageSchool[] {
|
||||||
const schools: readonly [number, DamageSchool][] = [
|
const schools: readonly [number, DamageSchool][] = [
|
||||||
[1, "physical"],
|
[1, "physical"],
|
||||||
[2, "holy"],
|
[2, "holy"],
|
||||||
@@ -134,8 +139,7 @@ function schoolFromMask(mask: number): DamageSchool | undefined {
|
|||||||
[32, "shadow"],
|
[32, "shadow"],
|
||||||
[64, "arcane"],
|
[64, "arcane"],
|
||||||
];
|
];
|
||||||
const matches = schools.filter(([bit]) => (mask & bit) !== 0);
|
return schools.filter(([bit]) => (mask & bit) !== 0).map(([, school]) => school);
|
||||||
return matches.length === 1 ? matches[0]?.[1] : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function percentValue(effect: Wow335Effect): number {
|
function percentValue(effect: Wow335Effect): number {
|
||||||
@@ -158,6 +162,7 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
|
|||||||
case 221: return percentStat("threat");
|
case 221: return percentStat("threat");
|
||||||
case 13: return [{ kind: "damage", direction: "dealt", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
|
case 13: return [{ kind: "damage", direction: "dealt", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
|
||||||
case 14: return [{ kind: "damage", direction: "taken", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
|
case 14: return [{ kind: "damage", direction: "taken", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
|
||||||
|
case 20: return flatStat("health-regeneration");
|
||||||
case 22:
|
case 22:
|
||||||
case 83:
|
case 83:
|
||||||
case 123:
|
case 123:
|
||||||
@@ -182,6 +187,17 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
|
|||||||
case 34:
|
case 34:
|
||||||
case 250: return flatStat("maximum-health");
|
case 250: return flatStat("maximum-health");
|
||||||
case 35: return [{ kind: "resource", resource: "power", property: "maximum", operation: "flat", value: effect.basePoints }];
|
case 35: return [{ kind: "resource", resource: "power", property: "maximum", operation: "flat", value: effect.basePoints }];
|
||||||
|
// SPELL_AURA_SCHOOL_IMMUNITY. Multi-school masks are common (Divine
|
||||||
|
// Shield, Ice Block, Cyclone); retain every bit instead of dropping the
|
||||||
|
// aura unless exactly one school was selected.
|
||||||
|
case 39:
|
||||||
|
case 267: return schoolsFromMask(effect.miscValue).map((school) => ({
|
||||||
|
kind: "damage" as const,
|
||||||
|
direction: "taken" as const,
|
||||||
|
school,
|
||||||
|
operation: "percent" as const,
|
||||||
|
value: -1,
|
||||||
|
}));
|
||||||
case 47: return percentStat("parry");
|
case 47: return percentStat("parry");
|
||||||
case 49: return percentStat("dodge");
|
case 49: return percentStat("dodge");
|
||||||
case 50: return percentStat("critical-healing");
|
case 50: return percentStat("critical-healing");
|
||||||
@@ -200,6 +216,7 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
|
|||||||
case 71:
|
case 71:
|
||||||
case 179: return percentStat("spell-critical-strike");
|
case 179: return percentStat("spell-critical-strike");
|
||||||
case 59: return [{ kind: "damage", direction: "dealt", operation: "flat", value: effect.basePoints }];
|
case 59: return [{ kind: "damage", direction: "dealt", operation: "flat", value: effect.basePoints }];
|
||||||
|
case 61: return percentStat("haste");
|
||||||
case 65:
|
case 65:
|
||||||
case 138:
|
case 138:
|
||||||
case 140:
|
case 140:
|
||||||
@@ -260,6 +277,9 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
|
|||||||
case 189:
|
case 189:
|
||||||
case 220: return flatStat(`combat-rating:${effect.miscValue}`);
|
case 220: return flatStat(`combat-rating:${effect.miscValue}`);
|
||||||
case 240: return flatStat("expertise");
|
case 240: return flatStat("expertise");
|
||||||
|
case 230: return flatStat("maximum-health");
|
||||||
|
case 271: return [{ kind: "damage", direction: "taken", operation: "percent", value: percentValue(effect) }];
|
||||||
|
case 280: return percentStat("armor-penetration");
|
||||||
case 248: return percentStat("combat-result");
|
case 248: return percentStat("combat-result");
|
||||||
case 251: return percentStat("enemy-dodge");
|
case 251: return percentStat("enemy-dodge");
|
||||||
case 253: return percentStat("block-critical-strike");
|
case 253: return percentStat("block-critical-strike");
|
||||||
@@ -279,11 +299,56 @@ function controlFromAura(auraType: number): AbilityAuraControl | undefined {
|
|||||||
case 27: return { kind: "silence" };
|
case 27: return { kind: "silence" };
|
||||||
case 60: return { kind: "silence" };
|
case 60: return { kind: "silence" };
|
||||||
case 67:
|
case 67:
|
||||||
case 254: return { kind: "disarm" };
|
case 254:
|
||||||
|
case 278: return { kind: "disarm" };
|
||||||
|
case 56: return { kind: "confuse" };
|
||||||
|
case 92: return { kind: "root" };
|
||||||
default: return undefined;
|
default: return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exclusiveGroupForAbility(classId: BaseClassId, abilityName: string): string | undefined {
|
||||||
|
if (/\bStance$/i.test(abilityName)) return `${classId}:stance`;
|
||||||
|
if (/^Seal of\b/i.test(abilityName)) return "paladin:seal";
|
||||||
|
if (/\bAura$/i.test(abilityName)) return `${classId}:aura`;
|
||||||
|
if (/^(?:Greater )?Blessing of\b/i.test(abilityName)) return "paladin:blessing";
|
||||||
|
if (/^Aspect of\b/i.test(abilityName)) return "hunter:aspect";
|
||||||
|
if (/^Track\b|^Sense (?:Undead|Demons)$/i.test(abilityName)) return `${classId}:tracking`;
|
||||||
|
if (/\b(?:Bear|Cat|Travel|Aquatic|Flight|Tree of Life) Form$/i.test(abilityName)) return "druid:form";
|
||||||
|
if (/\bArmor$/i.test(abilityName)) return `${classId}:armor`;
|
||||||
|
if (/\bPresence$/i.test(abilityName)) return "death-knight:presence";
|
||||||
|
if (/ Weapon$/i.test(abilityName)) return "shaman:weapon-enchant";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utilityTagsForAura(auraType: number, abilityName: string): readonly string[] {
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (auraType === 16 || /\bStealth|Prowl\b/i.test(abilityName)) tags.push("stealth");
|
||||||
|
if ([17, 19, 44, 151].includes(auraType)) tags.push("tracking");
|
||||||
|
if (auraType === 36) tags.push("shapeshift");
|
||||||
|
if (auraType === 66) tags.push("feign-death");
|
||||||
|
if (auraType === 78) tags.push("mounted");
|
||||||
|
if (auraType === 82) tags.push("water-breathing");
|
||||||
|
if (auraType === 104) tags.push("water-walking");
|
||||||
|
if (auraType === 105) tags.push("slow-fall");
|
||||||
|
if ([1, 4, 68, 121, 128, 226, 292].includes(auraType)) tags.push(`classic-aura:${auraType}`);
|
||||||
|
if (auraType === 120) tags.push("untrackable");
|
||||||
|
if (auraType === 149) tags.push("pushback-resistance");
|
||||||
|
if (auraType === 25) tags.push("pacified");
|
||||||
|
if (auraType === 41) tags.push("reveal-stealth");
|
||||||
|
if (auraType === 91) tags.push("reduced-aggro-radius");
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlImmunitiesForAura(auraType: number, miscValue: number): AuraDefinition["controlImmunities"] {
|
||||||
|
if (auraType !== 77) return undefined;
|
||||||
|
if (miscValue === 5) return ["fear"];
|
||||||
|
if (miscValue === 7 || miscValue === 11) return ["root"];
|
||||||
|
if (miscValue === 10) return ["sleep"];
|
||||||
|
if (miscValue === 12) return ["stun"];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function inferredProcTriggers(description: string, auraType: number): readonly ProcTrigger[] {
|
function inferredProcTriggers(description: string, auraType: number): readonly ProcTrigger[] {
|
||||||
if (auraType === 23 || auraType === 48 || auraType === 226 || auraType === 227) return ["periodic"];
|
if (auraType === 23 || auraType === 48 || auraType === 226 || auraType === 227) return ["periodic"];
|
||||||
const triggers: ProcTrigger[] = [];
|
const triggers: ProcTrigger[] = [];
|
||||||
@@ -355,16 +420,176 @@ function dispelCategoryForAura(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function namedAuraForEffect(
|
function namedAuraForEffect(
|
||||||
|
classId: BaseClassId,
|
||||||
effect: Wow335Effect,
|
effect: Wow335Effect,
|
||||||
rank: Wow335Rank,
|
rank: Wow335Rank,
|
||||||
abilityName: string,
|
abilityName: string,
|
||||||
effectIndex: number,
|
effectIndex: number,
|
||||||
): AuraDefinition {
|
): AuraDefinition {
|
||||||
const durationMs = rank.durationMs > 0 ? rank.durationMs : null;
|
const durationMs = rank.durationMs > 0 ? rank.durationMs : null;
|
||||||
const proc = procForAura(effect, rank, rank.description);
|
const hiddenProcOverrides = new Set([
|
||||||
|
"Seal of Justice", "Lightning Shield", "Water Shield", "Frost Armor", "Ice Armor", "Molten Armor", "Nature's Grasp",
|
||||||
|
]);
|
||||||
|
const proc = hiddenProcOverrides.has(abilityName) ? undefined : procForAura(effect, rank, rank.description);
|
||||||
const school = schoolFromMask(effect.miscValue);
|
const school = schoolFromMask(effect.miscValue);
|
||||||
|
const auraId = `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`;
|
||||||
|
const modifiers = [...auraModifiers(effect)];
|
||||||
|
if (abilityName === "Greater Blessing of Sanctuary" && effect.auraType === 4) {
|
||||||
|
modifiers.push(
|
||||||
|
{ kind: "damage", direction: "taken", operation: "percent", value: -0.03 },
|
||||||
|
{ kind: "stat", stat: "stamina", operation: "percent", value: 0.1 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (abilityName === "Ice Block" && effect.auraType === 39) {
|
||||||
|
modifiers.push({ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -1 });
|
||||||
|
}
|
||||||
|
if (effect.auraType === 36 && ["Bear Form", "Dire Bear Form"].includes(abilityName)) {
|
||||||
|
modifiers.push(
|
||||||
|
{ kind: "stat", stat: "armor", operation: "percent", value: abilityName === "Dire Bear Form" ? 3.7 : 1.8 },
|
||||||
|
{ kind: "stat", stat: "stamina", operation: "percent", value: abilityName === "Dire Bear Form" ? 0.25 : 0.1 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (effect.auraType === 36 && abilityName === "Cat Form") {
|
||||||
|
modifiers.push(
|
||||||
|
{ kind: "stat", stat: "attack-power", operation: "percent", value: 0.1 },
|
||||||
|
{ kind: "stat", stat: "movement-speed", operation: "percent", value: 0.3 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (effect.auraType === 36 && ["Travel Form", "Ghost Wolf"].includes(abilityName)) {
|
||||||
|
modifiers.push({ kind: "stat", stat: "movement-speed", operation: "percent", value: 0.4 });
|
||||||
|
}
|
||||||
|
if (effect.auraType === 36 && ["Flight Form", "Swift Flight Form"].includes(abilityName)) {
|
||||||
|
modifiers.push({ kind: "stat", stat: "movement-speed", operation: "percent", value: abilityName === "Swift Flight Form" ? 2.8 : 0.6 });
|
||||||
|
}
|
||||||
|
if (effect.auraType === 36 && abilityName === "Tree of Life") {
|
||||||
|
modifiers.push({ kind: "healing", direction: "done", operation: "percent", value: 0.06 });
|
||||||
|
}
|
||||||
|
if (effect.auraType === 36 && abilityName === "Defensive Stance") {
|
||||||
|
modifiers.push(
|
||||||
|
{ kind: "damage", direction: "taken", operation: "percent", value: -0.1 },
|
||||||
|
{ kind: "stat", stat: "threat", operation: "percent", value: 0.3 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (effect.auraType === 36 && abilityName === "Berserker Stance") {
|
||||||
|
modifiers.push(
|
||||||
|
{ kind: "stat", stat: "critical-strike", operation: "percent", value: 0.03 },
|
||||||
|
{ kind: "damage", direction: "taken", operation: "percent", value: 0.05 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (effect.auraType === 21 && abilityName === "Aspect of the Viper") {
|
||||||
|
modifiers.push({
|
||||||
|
kind: "resource",
|
||||||
|
resource: "mana",
|
||||||
|
property: "regeneration",
|
||||||
|
operation: "flat",
|
||||||
|
value: Math.max(1, Math.abs(effect.basePoints)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const specialProcs: AuraProcDefinition[] = [];
|
||||||
|
const procAmount = Math.max(1, Math.round(playerCombatPower(Math.max(1, rank.sourceLevel)) * 0.12));
|
||||||
|
if (["Seal of Righteousness", "Seal of Vengeance", "Seal of Corruption"].includes(abilityName) && effectIndex === 0) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:seal-damage`,
|
||||||
|
triggers: "hit",
|
||||||
|
chance: 1,
|
||||||
|
action: { kind: "damage", amount: procAmount, school: "holy", target: "event-other" },
|
||||||
|
});
|
||||||
|
} else if (abilityName === "Seal of Light" && effectIndex === 0) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:seal-heal`,
|
||||||
|
triggers: "hit",
|
||||||
|
chance: 1,
|
||||||
|
action: { kind: "heal", amount: procAmount, target: "aura-target" },
|
||||||
|
});
|
||||||
|
} else if (abilityName === "Seal of Wisdom" && effectIndex === 0) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:seal-mana`,
|
||||||
|
triggers: "hit",
|
||||||
|
chance: 1,
|
||||||
|
action: { kind: "resource", resource: "mana", amount: Math.max(1, Math.round(procAmount / 3)), target: "aura-target" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (abilityName === "Holy Shield" && effect.auraType === 43) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:holy-shield`,
|
||||||
|
triggers: "block",
|
||||||
|
chance: 1,
|
||||||
|
action: { kind: "damage", amount: procAmount, school: "holy", target: "event-other" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (abilityName === "Retaliation" && effect.auraType === 4) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:retaliation`, triggers: "damage-taken", chance: 1, charges: 30,
|
||||||
|
action: { kind: "damage", amount: Math.max(1, Math.round(procAmount * 1.5)), school: "physical", target: "event-other" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (abilityName === "Judgement of Light" && effect.auraType === 4) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:judgement-light`, triggers: "damage-taken", chance: 1,
|
||||||
|
action: { kind: "heal", amount: procAmount, target: "event-other" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (abilityName === "Judgement of Wisdom" && effect.auraType === 4) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:judgement-wisdom`, triggers: "damage-taken", chance: 1,
|
||||||
|
action: { kind: "resource", resource: "mana", amount: Math.max(1, Math.round(procAmount / 3)), target: "event-other" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (abilityName === "Seal of Justice" && effect.auraType === 42) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:seal-justice`, triggers: "hit", chance: 0.2,
|
||||||
|
action: { kind: "custom", id: "classic-status", data: { status: "stun", durationMs: 2_000 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (abilityName === "Lightning Shield" && effect.auraType === 42) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:lightning-shield`, triggers: "damage-taken", chance: 1, charges: 3,
|
||||||
|
action: { kind: "damage", amount: Math.max(1, effect.basePoints), school: "nature", target: "event-other" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (abilityName === "Water Shield" && effect.auraType === 42) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:water-shield`, triggers: "damage-taken", chance: 1, charges: 3,
|
||||||
|
action: { kind: "resource", resource: "mana", amount: Math.max(1, effect.basePoints), target: "aura-target" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (["Frost Armor", "Ice Armor"].includes(abilityName) && effect.auraType === 42) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:frost-slow`, triggers: "damage-taken", chance: 1,
|
||||||
|
action: { kind: "custom", id: "classic-status", data: { status: "slow", durationMs: 5_000, magnitude: 0.3 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (abilityName === "Molten Armor" && effect.auraType === 42) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:molten-retaliation`, triggers: "damage-taken", chance: 1,
|
||||||
|
action: { kind: "damage", amount: procAmount, school: "fire", target: "event-other" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (abilityName === "Nature's Grasp" && effect.auraType === 42) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:natures-grasp`, triggers: "damage-taken", chance: 1, charges: 1,
|
||||||
|
action: { kind: "custom", id: "classic-status", data: { status: "root", durationMs: 10_000 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (abilityName === "Recklessness" && effect.auraType === 107) {
|
||||||
|
modifiers.push({ kind: "stat", stat: "critical-strike", operation: "percent", value: 1 });
|
||||||
|
}
|
||||||
|
const sacredShield = abilityName === "Sacred Shield" && effect.auraType === 4;
|
||||||
|
if (sacredShield) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:sacred-shield-refresh`,
|
||||||
|
triggers: "damage-taken",
|
||||||
|
chance: 1,
|
||||||
|
internalCooldownMs: 6_000,
|
||||||
|
action: { kind: "apply-aura", auraId, target: "aura-target" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const utilityTags = [
|
||||||
|
...utilityTagsForAura(effect.auraType, abilityName),
|
||||||
|
...(abilityName === "Light's Beacon" ? ["beacon-of-light"] : []),
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`,
|
id: auraId,
|
||||||
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
|
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
|
||||||
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
|
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
|
||||||
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
|
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
|
||||||
@@ -373,21 +598,37 @@ function namedAuraForEffect(
|
|||||||
durationMs,
|
durationMs,
|
||||||
maxStacks: 1,
|
maxStacks: 1,
|
||||||
stackBehavior: "refresh",
|
stackBehavior: "refresh",
|
||||||
|
...(exclusiveGroupForAbility(classId, abilityName) || abilityName === "Light's Beacon"
|
||||||
|
? { exclusiveGroup: abilityName === "Light's Beacon" ? "paladin:beacon" : exclusiveGroupForAbility(classId, abilityName) }
|
||||||
|
: {}),
|
||||||
|
...(utilityTags.length ? { utilityTags } : {}),
|
||||||
|
...(controlImmunitiesForAura(effect.auraType, effect.miscValue)
|
||||||
|
? { controlImmunities: controlImmunitiesForAura(effect.auraType, effect.miscValue) }
|
||||||
|
: {}),
|
||||||
|
...(effect.auraType === 28
|
||||||
|
? { reflectSpellChance: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
|
||||||
|
: {}),
|
||||||
|
...(effect.auraType === 74
|
||||||
|
? { reflectSpellChance: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
|
||||||
|
: {}),
|
||||||
|
...(effect.auraType === 81
|
||||||
|
? { redirectDamagePercent: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
|
||||||
|
: {}),
|
||||||
...(dispelCategoryForAura(abilityName, rank.description, rank.durationMs) !== undefined
|
...(dispelCategoryForAura(abilityName, rank.description, rank.durationMs) !== undefined
|
||||||
? { dispelCategory: dispelCategoryForAura(abilityName, rank.description, rank.durationMs) }
|
? { dispelCategory: dispelCategoryForAura(abilityName, rank.description, rank.durationMs) }
|
||||||
: {}),
|
: {}),
|
||||||
...(auraModifiers(effect).length ? { modifiers: auraModifiers(effect) } : {}),
|
...(modifiers.length ? { modifiers } : {}),
|
||||||
...([69, 97].includes(effect.auraType)
|
...([69, 97].includes(effect.auraType) || sacredShield
|
||||||
? {
|
? {
|
||||||
absorbs: [{
|
absorbs: [{
|
||||||
id: `wow335:${rank.spellId}:absorb:${effectIndex}`,
|
id: `wow335:${rank.spellId}:absorb:${effectIndex}`,
|
||||||
amount: Math.max(0, effect.basePoints),
|
amount: sacredShield ? Math.max(1, Math.round(playerCombatPower(Math.max(1, rank.sourceLevel)) * 0.75)) : Math.max(0, effect.basePoints),
|
||||||
...(school !== undefined ? { school } : {}),
|
...(school !== undefined ? { school } : {}),
|
||||||
perStack: false,
|
perStack: false,
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(proc ? { procs: [proc] } : {}),
|
...(proc || specialProcs.length ? { procs: [...(proc ? [proc] : []), ...specialProcs] } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,16 +678,183 @@ function fallbackCoefficient(effect: Wow335Effect, rank: Wow335Rank): number {
|
|||||||
return Math.max(0.08, Math.min(4, average / playerCombatPower(Math.max(1, rank.sourceLevel || rank.level))));
|
return Math.max(0.08, Math.min(4, average / playerCombatPower(Math.max(1, rank.sourceLevel || rank.level))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectsForRank(rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
|
function utilityAction(effectType: number): Extract<AbilityEffect, { kind: "utility" }>["action"] {
|
||||||
|
if (effectType === 24) return "create-item";
|
||||||
|
if (effectType === 33) return "open-lock";
|
||||||
|
if (effectType === 50) return "portal";
|
||||||
|
if (effectType === 54) return "weapon-enchant";
|
||||||
|
if (effectType === 71) return "pickpocket";
|
||||||
|
if (effectType === 72) return "farsight";
|
||||||
|
return "class-script";
|
||||||
|
}
|
||||||
|
|
||||||
|
function classScriptEffects(
|
||||||
|
classId: BaseClassId,
|
||||||
|
abilityName: string,
|
||||||
|
rank: Wow335Rank,
|
||||||
|
): readonly AbilityEffect[] {
|
||||||
|
const power = playerCombatPower(Math.max(1, rank.sourceLevel || rank.level));
|
||||||
|
const aura = (
|
||||||
|
id: string,
|
||||||
|
modifiers: readonly AuraModifier[],
|
||||||
|
procs: readonly AuraProcDefinition[] = [],
|
||||||
|
): AbilityEffect => ({
|
||||||
|
kind: "apply-aura",
|
||||||
|
aura: {
|
||||||
|
id: `wow335:${rank.spellId}:${id}`,
|
||||||
|
name: abilityName,
|
||||||
|
disposition: "buff",
|
||||||
|
durationMs: rank.durationMs > 0 ? rank.durationMs : null,
|
||||||
|
maxStacks: 1,
|
||||||
|
stackBehavior: "refresh",
|
||||||
|
exclusiveGroup: exclusiveGroupForAbility(classId, abilityName),
|
||||||
|
...(modifiers.length ? { modifiers } : {}),
|
||||||
|
...(procs.length ? { procs } : {}),
|
||||||
|
utilityTags: ["weapon-enchant"],
|
||||||
|
},
|
||||||
|
sourceEffectType: 3,
|
||||||
|
sourceAuraType: 4,
|
||||||
|
miscValue: 0,
|
||||||
|
triggerSpellId: 0,
|
||||||
|
});
|
||||||
|
switch (`${classId}:${abilityName}`) {
|
||||||
|
case "warrior:Slam": return [{ kind: "damage", coefficient: 1.5 }];
|
||||||
|
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
|
||||||
|
case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }];
|
||||||
|
case "warrior:Sunder Armor": return [{
|
||||||
|
kind: "apply-aura",
|
||||||
|
aura: {
|
||||||
|
id: `wow335:${rank.spellId}:sunder-armor`, name: abilityName, disposition: "debuff", durationMs: 30_000,
|
||||||
|
maxStacks: 5, stackBehavior: "add",
|
||||||
|
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.04, perStack: true }],
|
||||||
|
},
|
||||||
|
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
|
||||||
|
}];
|
||||||
|
case "warrior:Shattering Throw": return [
|
||||||
|
{ kind: "damage", coefficient: 1 },
|
||||||
|
{ kind: "status", status: "marked", durationMs: 10_000, magnitude: 0.2 },
|
||||||
|
];
|
||||||
|
case "paladin:Judgement of Justice": return [{ kind: "status", status: "root", durationMs: 10_000 }];
|
||||||
|
case "paladin:Righteous Defense": return [{ kind: "taunt", durationMs: 3_000, maxTargets: 3 }];
|
||||||
|
case "paladin:Hand of Salvation": return [{ kind: "threat", mode: "percent", amount: -20, recipient: "ability-target" }];
|
||||||
|
case "paladin:Divine Intervention": return [{
|
||||||
|
kind: "apply-aura",
|
||||||
|
aura: {
|
||||||
|
id: `wow335:${rank.spellId}:divine-intervention`, name: abilityName, disposition: "buff", durationMs: 180_000,
|
||||||
|
modifiers: schoolsFromMask(127).map((school) => ({
|
||||||
|
kind: "damage" as const, direction: "taken" as const, school, operation: "percent" as const, value: -1,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
sourceEffectType: 64, sourceAuraType: 39, miscValue: 127, triggerSpellId: 0,
|
||||||
|
}, {
|
||||||
|
kind: "utility", action: "class-script", effectType: 77, auraType: 0, miscValue: 0, triggerSpellId: 0, durationMs: 0,
|
||||||
|
}];
|
||||||
|
case "hunter:Disengage": return [{ kind: "movement", movement: "leap", toward: "away" }];
|
||||||
|
case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }];
|
||||||
|
case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }];
|
||||||
|
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }];
|
||||||
|
case "rogue:Garrote": return [{ kind: "status", status: "silence", durationMs: 3_000 }];
|
||||||
|
case "rogue:Cloak of Shadows": return [{
|
||||||
|
kind: "dispel", mode: "cleanse", relationship: "friendly", categories: ["magic"], maxCount: 10,
|
||||||
|
}];
|
||||||
|
case "priest:Shadowfiend": return [{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 }];
|
||||||
|
case "priest:Prayer of Mending": return [{
|
||||||
|
kind: "apply-aura",
|
||||||
|
aura: {
|
||||||
|
id: `wow335:${rank.spellId}:prayer-of-mending`,
|
||||||
|
name: abilityName,
|
||||||
|
disposition: "buff",
|
||||||
|
durationMs: 30_000,
|
||||||
|
maxStacks: 1,
|
||||||
|
stackBehavior: "refresh",
|
||||||
|
procs: [{
|
||||||
|
id: `wow335:${rank.spellId}:prayer-of-mending-proc`,
|
||||||
|
triggers: "damage-taken",
|
||||||
|
chance: 1,
|
||||||
|
charges: 5,
|
||||||
|
action: { kind: "heal", amount: Math.max(1, Math.round(power * 0.8)), target: "aura-target" },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
sourceEffectType: 64,
|
||||||
|
sourceAuraType: 42,
|
||||||
|
miscValue: 0,
|
||||||
|
triggerSpellId: 0,
|
||||||
|
}];
|
||||||
|
case "death-knight:Death Grip": return [{ kind: "pull", toward: "caster" }];
|
||||||
|
case "death-knight:Blood Tap": return [{ kind: "rune", action: "activate", count: 1 }];
|
||||||
|
case "death-knight:Chains of Ice": return [
|
||||||
|
{ kind: "status", status: "root", durationMs: 3_000 },
|
||||||
|
{ kind: "status", status: "slow", durationMs: 10_000, magnitude: 0.95 },
|
||||||
|
];
|
||||||
|
case "death-knight:Icy Touch": return [{ kind: "dot", coefficient: 0.22, ticks: 5, intervalMs: 3_000, tag: "frost-fever" }];
|
||||||
|
case "death-knight:Plague Strike": return [{ kind: "dot", coefficient: 0.22, ticks: 5, intervalMs: 3_000, tag: "blood-plague" }];
|
||||||
|
case "death-knight:Pestilence": return [{ kind: "damage", coefficient: 0.35 }];
|
||||||
|
case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }];
|
||||||
|
case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({
|
||||||
|
kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000,
|
||||||
|
}));
|
||||||
|
case "shaman:Rockbiter Weapon": return [aura("rockbiter", [
|
||||||
|
{ kind: "damage", direction: "dealt", school: "physical", operation: "flat", value: Math.max(1, Math.round(power * 0.12)) },
|
||||||
|
])];
|
||||||
|
case "shaman:Frostbrand Weapon": return [aura("frostbrand", [], [{
|
||||||
|
id: `wow335:${rank.spellId}:frostbrand-proc`, triggers: "hit", chance: 0.2,
|
||||||
|
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.3)), school: "frost", target: "event-other" },
|
||||||
|
}])];
|
||||||
|
case "shaman:Flametongue Weapon": return [aura("flametongue", [], [{
|
||||||
|
id: `wow335:${rank.spellId}:flametongue-proc`, triggers: "hit", chance: 1,
|
||||||
|
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.18)), school: "fire", target: "event-other" },
|
||||||
|
}])];
|
||||||
|
case "shaman:Windfury Weapon": return [aura("windfury", [], [{
|
||||||
|
id: `wow335:${rank.spellId}:windfury-proc`, triggers: "hit", chance: 0.2,
|
||||||
|
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.75)), school: "physical", target: "event-other" },
|
||||||
|
}])];
|
||||||
|
case "warlock:Soulshatter": return [{ kind: "threat", mode: "percent", amount: -90 }];
|
||||||
|
case "warlock:Demonic Circle: Summon": return [{ kind: "summon", creatureId: 19175, coefficient: 0, durationMs: 360_000 }];
|
||||||
|
case "warlock:Demonic Circle: Teleport": return [{ kind: "movement", movement: "teleport", toward: "away" }];
|
||||||
|
case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
||||||
|
case "rogue:Tricks of the Trade": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
||||||
|
case "mage:Blink": return [{ kind: "movement", movement: "leap", toward: "away" }];
|
||||||
|
case "mage:Arcane Blast": return [{
|
||||||
|
kind: "apply-aura",
|
||||||
|
recipient: "caster",
|
||||||
|
aura: {
|
||||||
|
id: "wow335:arcane-blast-stack",
|
||||||
|
name: "Arcane Blast",
|
||||||
|
disposition: "debuff",
|
||||||
|
durationMs: 6_000,
|
||||||
|
maxStacks: 4,
|
||||||
|
stackBehavior: "add",
|
||||||
|
modifiers: [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: 0.15 }],
|
||||||
|
},
|
||||||
|
sourceEffectType: 64,
|
||||||
|
sourceAuraType: 108,
|
||||||
|
miscValue: 0,
|
||||||
|
triggerSpellId: 0,
|
||||||
|
}];
|
||||||
|
case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }];
|
||||||
|
case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }];
|
||||||
|
default: return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMBO_FINISHERS = new Set([
|
||||||
|
"Eviscerate", "Slice and Dice", "Expose Armor", "Rupture", "Kidney Shot", "Envenom", "Deadly Throw",
|
||||||
|
"Rip", "Ferocious Bite", "Maim", "Savage Roar",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DIRECT_COMBO_FINISHERS = new Set(["Eviscerate", "Envenom", "Deadly Throw", "Ferocious Bite"]);
|
||||||
|
|
||||||
|
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
|
||||||
const effects: AbilityEffect[] = [];
|
const effects: AbilityEffect[] = [];
|
||||||
for (const [effectIndex, effect] of rank.effects.entries()) {
|
for (const [effectIndex, effect] of rank.effects.entries()) {
|
||||||
const coefficient = fallbackCoefficient(effect, rank);
|
const coefficient = fallbackCoefficient(effect, rank);
|
||||||
const scaling = amountScaling(effect, rank);
|
const scaling = amountScaling(effect, rank);
|
||||||
if (APPLY_AURA_EFFECTS.has(effect.effectType) && effect.auraType > 0) {
|
if (APPLY_AURA_EFFECTS.has(effect.effectType) && effect.auraType > 0) {
|
||||||
const aura = namedAuraForEffect(effect, rank, abilityName, effectIndex);
|
const aura = namedAuraForEffect(classId, effect, rank, abilityName, effectIndex);
|
||||||
effects.push({
|
effects.push({
|
||||||
kind: "apply-aura",
|
kind: "apply-aura",
|
||||||
aura,
|
aura,
|
||||||
|
...([35, 65].includes(effect.effectType) ? { scope: "party" as const } : {}),
|
||||||
...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}),
|
...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}),
|
||||||
sourceEffectType: effect.effectType,
|
sourceEffectType: effect.effectType,
|
||||||
sourceAuraType: effect.auraType,
|
sourceAuraType: effect.auraType,
|
||||||
@@ -461,6 +869,42 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
|
|||||||
effects.push({ kind: "hot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs });
|
effects.push({ kind: "hot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs });
|
||||||
} else if (effect.auraType === 11) {
|
} else if (effect.auraType === 11) {
|
||||||
effects.push({ kind: "taunt", durationMs: Math.max(3_000, rank.durationMs) });
|
effects.push({ kind: "taunt", durationMs: Math.max(3_000, rank.durationMs) });
|
||||||
|
} else if (effect.auraType === 20 && effect.periodMs > 0) {
|
||||||
|
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
|
||||||
|
effects.push({ kind: "hot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs });
|
||||||
|
} else if (effect.auraType === 53 && effect.periodMs > 0) {
|
||||||
|
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
|
||||||
|
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${rank.spellId}` });
|
||||||
|
effects.push({
|
||||||
|
kind: "hot",
|
||||||
|
coefficient: abilityName === "Devouring Plague" ? coefficient * 0.15 : coefficient,
|
||||||
|
...scaling,
|
||||||
|
ticks,
|
||||||
|
intervalMs: effect.periodMs,
|
||||||
|
});
|
||||||
|
} else if (effect.auraType === 64 && effect.periodMs > 0) {
|
||||||
|
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
|
||||||
|
effects.push({ kind: "resource-drain", amount: Math.abs(effect.basePoints) * ticks, burn: false, resource: "mana", percentage: true });
|
||||||
|
} else if (effect.auraType === 24) {
|
||||||
|
const ticks = effect.periodMs > 0
|
||||||
|
? Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs))
|
||||||
|
: 1;
|
||||||
|
effects.push({
|
||||||
|
kind: "resource",
|
||||||
|
resource: abilityName === "Enrage" ? "rage" : "mana",
|
||||||
|
amount: abilityName === "Innervate" ? 100 : Math.abs(effect.basePoints) * ticks,
|
||||||
|
...(abilityName === "Innervate" ? { percentage: true } : {}),
|
||||||
|
});
|
||||||
|
} else if (effect.auraType === 21 && abilityName === "Divine Plea") {
|
||||||
|
effects.push({ kind: "resource", resource: "mana", amount: 25, percentage: true });
|
||||||
|
} else if (effect.auraType === 226 && abilityName === "Death and Decay" && effect.periodMs > 0) {
|
||||||
|
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
|
||||||
|
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${rank.spellId}` });
|
||||||
|
} else if (effect.auraType === 226 && abilityName === "Frenzied Regeneration" && effect.periodMs > 0) {
|
||||||
|
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
|
||||||
|
effects.push({ kind: "hot", coefficient: 0.3, ticks, intervalMs: effect.periodMs });
|
||||||
|
} else if (effect.auraType === 226 && abilityName === "Mirror Image") {
|
||||||
|
effects.push({ kind: "summon", creatureId: effect.triggerSpellId || 58836, coefficient: 0.25, durationMs: Math.max(1_000, rank.durationMs) });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -492,8 +936,18 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
|
|||||||
effects.push({ kind: "damage", coefficient: weaponCoefficient });
|
effects.push({ kind: "damage", coefficient: weaponCoefficient });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (effect.effectType === 10 || effect.effectType === 67 || effect.effectType === 136) {
|
if (effect.effectType === 67) {
|
||||||
effects.push({ kind: "heal", coefficient, ...scaling });
|
// SPELL_EFFECT_HEAL_MAX_HEALTH is used by Lay on Hands. Its DBC amount
|
||||||
|
// fields are intentionally zero because the target's maximum health is
|
||||||
|
// the amount; treating it as an ordinary coefficient heal makes the
|
||||||
|
// spell appear to do nothing.
|
||||||
|
effects.push({ kind: "heal", coefficient: 0, percentMaxHealth: 1, ...scaling });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (effect.effectType === 10 || effect.effectType === 136) {
|
||||||
|
effects.push(effect.effectType === 136
|
||||||
|
? { kind: "heal", coefficient: 0, percentMaxHealth: Math.max(0, Math.abs(effect.basePoints) / 100), ...scaling }
|
||||||
|
: { kind: "heal", coefficient, ...scaling });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (effect.effectType === 30) {
|
if (effect.effectType === 30) {
|
||||||
@@ -501,7 +955,7 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (effect.effectType === 137) {
|
if (effect.effectType === 137) {
|
||||||
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints) });
|
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints), percentage: true });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (effect.effectType === 63 || effect.effectType === 91 || effect.effectType === 125 || effect.effectType === 130) {
|
if (effect.effectType === 63 || effect.effectType === 91 || effect.effectType === 125 || effect.effectType === 130) {
|
||||||
@@ -634,11 +1088,13 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
|
|||||||
if (effect.effectType === 0 && effect.auraType === 0) continue;
|
if (effect.effectType === 0 && effect.auraType === 0) continue;
|
||||||
if (NONCOMBAT_EFFECTS.has(effect.effectType)) {
|
if (NONCOMBAT_EFFECTS.has(effect.effectType)) {
|
||||||
effects.push({
|
effects.push({
|
||||||
kind: "unsupported",
|
kind: "utility",
|
||||||
combat: false,
|
action: utilityAction(effect.effectType),
|
||||||
effectType: effect.effectType,
|
effectType: effect.effectType,
|
||||||
auraType: effect.auraType,
|
auraType: effect.auraType,
|
||||||
reason: `Noncombat DBC effect ${effect.effectType}`,
|
miscValue: effect.miscValue,
|
||||||
|
triggerSpellId: effect.triggerSpellId,
|
||||||
|
durationMs: rank.durationMs,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -658,15 +1114,40 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
|
|||||||
effects.unshift({ kind: "damage", coefficient: 1 });
|
effects.unshift({ kind: "damage", coefficient: 1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return effects.length
|
const scriptedFallbacks = classScriptEffects(classId, abilityName, rank);
|
||||||
? effects
|
const executable = effects
|
||||||
|
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
|
||||||
|
.map((effect) => effect.kind === "utility" ? effect : effect);
|
||||||
|
const result = scriptedFallbacks.length
|
||||||
|
? [...executable, ...scriptedFallbacks]
|
||||||
|
: executable;
|
||||||
|
let finalized = result;
|
||||||
|
if (DIRECT_COMBO_FINISHERS.has(abilityName)) {
|
||||||
|
finalized = [
|
||||||
|
...result.filter((effect) => effect.kind !== "damage" && effect.kind !== "finisher-damage"),
|
||||||
|
{ kind: "finisher-damage", baseCoefficient: 0.55, perComboCoefficient: 0.45 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (COMBO_FINISHERS.has(abilityName) && !finalized.some((effect) => effect.kind === "finisher-damage")) {
|
||||||
|
finalized = [...finalized, { kind: "combo", amount: -5 }];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
classId === "death-knight"
|
||||||
|
&& rank.powerType === 5
|
||||||
|
&& finalized.some((effect) => ["damage", "dot", "status", "pull", "taunt"].includes(effect.kind))
|
||||||
|
) {
|
||||||
|
finalized = [...finalized, { kind: "resource", resource: "runic-power", amount: 10 }];
|
||||||
|
}
|
||||||
|
return finalized.length
|
||||||
|
? finalized
|
||||||
: [{
|
: [{
|
||||||
kind: "scripted",
|
kind: "utility",
|
||||||
|
action: "class-script",
|
||||||
effectType: 0,
|
effectType: 0,
|
||||||
auraType: 0,
|
auraType: 0,
|
||||||
miscValue: 0,
|
miscValue: 0,
|
||||||
triggerSpellId: 0,
|
triggerSpellId: 0,
|
||||||
label: "Description-backed spell script",
|
durationMs: rank.durationMs,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,6 +1170,20 @@ function costForRank(resource: ResourceType, rank: Wow335Rank): number {
|
|||||||
return rank.powerCost;
|
return rank.powerCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resourceForRank(fallback: ResourceType, rank: Wow335Rank): ResourceType {
|
||||||
|
switch (rank.powerType) {
|
||||||
|
case -2: return "health";
|
||||||
|
case 0: return "mana";
|
||||||
|
case 1: return "rage";
|
||||||
|
case 2: return "focus";
|
||||||
|
case 3: return "energy";
|
||||||
|
case 6: return "runic-power";
|
||||||
|
// Runes (power type 5) are represented by the existing rune effects;
|
||||||
|
// their DBC powerCost is zero, so keep the class pool for display.
|
||||||
|
default: return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function rankDefinition(
|
function rankDefinition(
|
||||||
classId: BaseClassId,
|
classId: BaseClassId,
|
||||||
resource: ResourceType,
|
resource: ResourceType,
|
||||||
@@ -696,8 +1191,9 @@ function rankDefinition(
|
|||||||
index: number,
|
index: number,
|
||||||
abilityName: string,
|
abilityName: string,
|
||||||
): AbilityRankDefinition {
|
): AbilityRankDefinition {
|
||||||
const effects = effectsForRank(rank, abilityName);
|
const effects = effectsForRank(classId, rank, abilityName);
|
||||||
const channeling = rank.channelDurationMs > 0;
|
const channeling = rank.channelDurationMs > 0;
|
||||||
|
const rankResource = resourceForRank(resource, rank);
|
||||||
return {
|
return {
|
||||||
rank: rank.rank || index + 1,
|
rank: rank.rank || index + 1,
|
||||||
spellId: rank.spellId,
|
spellId: rank.spellId,
|
||||||
@@ -707,7 +1203,11 @@ function rankDefinition(
|
|||||||
castTimeMs: channeling ? Math.max(rank.castTimeMs, rank.channelDurationMs) : rank.castTimeMs,
|
castTimeMs: channeling ? Math.max(rank.castTimeMs, rank.channelDurationMs) : rank.castTimeMs,
|
||||||
cooldownMs: rank.cooldownMs,
|
cooldownMs: rank.cooldownMs,
|
||||||
gcdMs: rank.gcdMs,
|
gcdMs: rank.gcdMs,
|
||||||
cost: { resource, amount: costForRank(resource, rank) },
|
cost: {
|
||||||
|
resource: rankResource,
|
||||||
|
amount: costForRank(rankResource, rank),
|
||||||
|
...(rank.powerCostPercentage > 0 ? { percentage: true } : {}),
|
||||||
|
},
|
||||||
effects,
|
effects,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -724,12 +1224,22 @@ function sigil(name: string): string {
|
|||||||
|
|
||||||
function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDefinition | null {
|
function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDefinition | null {
|
||||||
const resource = RESOURCE_BY_CLASS[classId];
|
const resource = RESOURCE_BY_CLASS[classId];
|
||||||
const ranks = chain.ranks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
|
// Some SpellChain rows include rank-zero triggered children or cosmetic
|
||||||
|
// variants after the actual learnable ranks (Execute's damage child,
|
||||||
|
// Divine Hymn's tick, Death Coil's split target spells, polymorph models).
|
||||||
|
// They are executable sub-spells, not a higher player rank.
|
||||||
|
const sourceRanks = chain.ranks.some((rank) => rank.rank > 0)
|
||||||
|
? chain.ranks.filter((rank) => rank.rank > 0)
|
||||||
|
: chain.ranks;
|
||||||
|
const ranks = sourceRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
|
||||||
const first = ranks[0];
|
const first = ranks[0];
|
||||||
const firstRaw = chain.ranks[0];
|
const firstRaw = sourceRanks[0];
|
||||||
if (!first || !firstRaw) return null;
|
if (!first || !firstRaw) return null;
|
||||||
const target = targetForRank(firstRaw, first.effects);
|
const target = abilityNameTarget(chain.name) ?? targetForRank(firstRaw, first.effects);
|
||||||
const radius = Math.max(0, ...firstRaw.effects.map((effect) => effect.radius));
|
const radius = Math.max(
|
||||||
|
abilityNameRadius(chain.name),
|
||||||
|
...firstRaw.effects.map((effect) => effect.radius),
|
||||||
|
);
|
||||||
const range = target === "self"
|
const range = target === "self"
|
||||||
? { min: 0, max: 0 }
|
? { min: 0, max: 0 }
|
||||||
: {
|
: {
|
||||||
@@ -752,6 +1262,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
|||||||
castMode: first.castMode,
|
castMode: first.castMode,
|
||||||
castTimeMs: first.castTimeMs,
|
castTimeMs: first.castTimeMs,
|
||||||
cooldownMs: first.cooldownMs,
|
cooldownMs: first.cooldownMs,
|
||||||
|
...(classicCooldownGroup(classId, chain.name)
|
||||||
|
? { cooldownGroup: classicCooldownGroup(classId, chain.name) }
|
||||||
|
: {}),
|
||||||
gcdMs: first.gcdMs,
|
gcdMs: first.gcdMs,
|
||||||
cost: first.cost,
|
cost: first.cost,
|
||||||
effects: first.effects,
|
effects: first.effects,
|
||||||
@@ -761,6 +1274,26 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function abilityNameRadius(abilityName: string): number {
|
||||||
|
if (abilityName === "Hellfire") return 10;
|
||||||
|
if (abilityName === "Mind Sear") return 10;
|
||||||
|
if (abilityName === "Pestilence") return 10;
|
||||||
|
if (abilityName === "Divine Hymn") return 40;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
||||||
|
if (["Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classicCooldownGroup(classId: BaseClassId, abilityName: string): string | undefined {
|
||||||
|
if (classId === "shaman" && / Shock$/i.test(abilityName)) return "shaman-shocks";
|
||||||
|
if (classId === "hunter" && / Trap$/i.test(abilityName)) return "hunter-traps";
|
||||||
|
if (classId === "mage" && /^(?:Fire|Frost) Ward$/i.test(abilityName)) return "mage-wards";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> =
|
export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> =
|
||||||
Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => [
|
Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => [
|
||||||
classId,
|
classId,
|
||||||
|
|||||||
+10
-4
@@ -20,6 +20,7 @@ import { useGameStore } from "../game/store";
|
|||||||
import { DungeonEnvironment } from "./DungeonEnvironment";
|
import { DungeonEnvironment } from "./DungeonEnvironment";
|
||||||
import { MobPopulation } from "./MobPopulation";
|
import { MobPopulation } from "./MobPopulation";
|
||||||
import { PartyPopulation } from "./PartyPopulation";
|
import { PartyPopulation } from "./PartyPopulation";
|
||||||
|
import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs";
|
||||||
import { PlayerRig } from "./PlayerRig";
|
import { PlayerRig } from "./PlayerRig";
|
||||||
import { useManastormStore } from "../game/manastormStore";
|
import { useManastormStore } from "../game/manastormStore";
|
||||||
import { ManastormPortal } from "./ManastormPortal";
|
import { ManastormPortal } from "./ManastormPortal";
|
||||||
@@ -40,6 +41,9 @@ import { GameGltfLoaderLifecycle } from "./useGameGLTF";
|
|||||||
import { CombatEffects } from "./CombatEffects";
|
import { CombatEffects } from "./CombatEffects";
|
||||||
import { PLAYER_GRAVITY } from "../game/playerMovement";
|
import { PLAYER_GRAVITY } from "../game/playerMovement";
|
||||||
import { GmPlacementMarkers } from "./GmPlacementMarkers";
|
import { GmPlacementMarkers } from "./GmPlacementMarkers";
|
||||||
|
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||||
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||||
|
import { OnlinePlayerPopulation } from "./OnlinePlayerPopulation";
|
||||||
|
|
||||||
export function GameScene() {
|
export function GameScene() {
|
||||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||||
@@ -88,6 +92,8 @@ export function GameScene() {
|
|||||||
const paused = useGameStore((state) => state.paused);
|
const paused = useGameStore((state) => state.paused);
|
||||||
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 sharedOnline = useOnlineGroupStore((state) => state.activeActivity !== null);
|
||||||
|
const onlineAuthority = useOnlineSessionStore((state) => state.snapshot?.authority ?? true);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
|
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
|
||||||
const [safeGraphics, setSafeGraphics] = useState(false);
|
const [safeGraphics, setSafeGraphics] = useState(false);
|
||||||
@@ -96,9 +102,7 @@ export function GameScene() {
|
|||||||
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
|
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
|
||||||
const simulationBlocked = graphicsRecovery !== null
|
const simulationBlocked = graphicsRecovery !== null
|
||||||
|| !worldReady
|
|| !worldReady
|
||||||
|| paused
|
|| (!sharedOnline && (paused || mapOpen || companionOpen));
|
||||||
|| mapOpen
|
|
||||||
|| companionOpen;
|
|
||||||
const allowedRuntimeIds = useMemo(
|
const allowedRuntimeIds = useMemo(
|
||||||
() => gameMode === "manastorm" && manastormEncounter
|
() => gameMode === "manastorm" && manastormEncounter
|
||||||
? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase)
|
? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase)
|
||||||
@@ -236,7 +240,9 @@ export function GameScene() {
|
|||||||
{worldReady && (
|
{worldReady && (
|
||||||
<>
|
<>
|
||||||
<PlayerRig />
|
<PlayerRig />
|
||||||
<PartyPopulation active={!simulationBlocked} />
|
<OnlinePlayerPopulation active={!simulationBlocked} />
|
||||||
|
<PartyPopulation active={!simulationBlocked} simulate={!sharedOnline || onlineAuthority} />
|
||||||
|
<GroundEffectTelegraphs />
|
||||||
<MobPopulation
|
<MobPopulation
|
||||||
active={!simulationBlocked}
|
active={!simulationBlocked}
|
||||||
entities={stagePopulation.entities}
|
entities={stagePopulation.entities}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { DoubleSide } from "three";
|
||||||
|
import { useCombatStore } from "../game/combatStore";
|
||||||
|
|
||||||
|
/** Presentation-only rings; placement and ticking remain in the combat runtime. */
|
||||||
|
export function GroundEffectTelegraphs() {
|
||||||
|
const effects = useCombatStore((state) => state.areaEffects);
|
||||||
|
const pending = useCombatStore((state) => state.pendingGroundTarget);
|
||||||
|
return (
|
||||||
|
<group name="combat-ground-effects">
|
||||||
|
{pending ? (
|
||||||
|
<mesh
|
||||||
|
name="ground-target-reticle"
|
||||||
|
position={[pending.position[0], pending.position[1] + 0.045, pending.position[2]]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
renderOrder={5}
|
||||||
|
>
|
||||||
|
<ringGeometry args={[Math.max(0.1, pending.radius - 0.16), pending.radius, 64]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color={pending.valid ? "#78dfac" : "#e55f57"}
|
||||||
|
transparent
|
||||||
|
opacity={0.78}
|
||||||
|
depthWrite={false}
|
||||||
|
side={DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
{effects.map((effect) => (
|
||||||
|
<mesh
|
||||||
|
key={effect.id}
|
||||||
|
position={[effect.position[0], effect.position[1] + 0.035, effect.position[2]]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
renderOrder={4}
|
||||||
|
>
|
||||||
|
<ringGeometry args={[Math.max(0.1, effect.radius - 0.12), effect.radius, 64]} />
|
||||||
|
<meshBasicMaterial color="#e9c95c" transparent opacity={0.58} depthWrite={false} side={DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { MANASTORM_BUBBLE_DEFAULT_RADIUS } from "../game/manastormClientOverlay"
|
|||||||
import { advanceManastormSession } from "../game/manastormSession";
|
import { advanceManastormSession } from "../game/manastormSession";
|
||||||
import { useManastormStore } from "../game/manastormStore";
|
import { useManastormStore } from "../game/manastormStore";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
|
import { requestOnlineInteraction } from "../game/onlineSessionRuntime";
|
||||||
|
|
||||||
const STORM_PARTICLES = [
|
const STORM_PARTICLES = [
|
||||||
[-0.9, 1.4, 0.4],
|
[-0.9, 1.4, 0.4],
|
||||||
@@ -101,6 +102,10 @@ export function ManastormPortal() {
|
|||||||
useGameStore.getState().playerPosition,
|
useGameStore.getState().playerPosition,
|
||||||
);
|
);
|
||||||
if (inside && !wasInsideRef.current) {
|
if (inside && !wasInsideRef.current) {
|
||||||
|
if (requestOnlineInteraction("portal")) {
|
||||||
|
wasInsideRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
transitionLatchedRef.current = true;
|
transitionLatchedRef.current = true;
|
||||||
transitionStartedAtRef.current = Date.now();
|
transitionStartedAtRef.current = Date.now();
|
||||||
displayedCountdownRef.current = NEXT_WAVE_COUNTDOWN_SECONDS;
|
displayedCountdownRef.current = NEXT_WAVE_COUNTDOWN_SECONDS;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
resolveMobAnimationInterval,
|
resolveMobAnimationInterval,
|
||||||
} from "./mobAnimationCadence";
|
} from "./mobAnimationCadence";
|
||||||
import { useGameGLTF } from "./useGameGLTF";
|
import { useGameGLTF } from "./useGameGLTF";
|
||||||
|
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
|
||||||
|
|
||||||
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
|
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
|
||||||
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
|
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
|
||||||
@@ -916,7 +917,9 @@ function ProxyMob({
|
|||||||
if (event.button !== PRIMARY_MOUSE_BUTTON) return;
|
if (event.button !== PRIMARY_MOUSE_BUTTON) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (dead) {
|
if (dead) {
|
||||||
|
if (!requestOnlineInteraction("loot", instanceId)) {
|
||||||
useCombatStore.getState().lootMob(instanceId);
|
useCombatStore.getState().lootMob(instanceId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
usePartyStore.getState().clearSelection();
|
usePartyStore.getState().clearSelection();
|
||||||
@@ -1188,6 +1191,25 @@ function RoamingPack({
|
|||||||
const forward = scratchForwards[index];
|
const forward = scratchForwards[index];
|
||||||
const member = definition.members[index];
|
const member = definition.members[index];
|
||||||
const instanceId = `${definition.id}:${member.id}`;
|
const instanceId = `${definition.id}:${member.id}`;
|
||||||
|
const synchronizedTransform = onlineSynchronizedMobTransform(instanceId);
|
||||||
|
if (synchronizedTransform) {
|
||||||
|
const object = memberRefs.current[index];
|
||||||
|
if (object) {
|
||||||
|
object.position.set(...synchronizedTransform.position);
|
||||||
|
object.rotation.y = synchronizedTransform.yaw;
|
||||||
|
}
|
||||||
|
chasePositions[index][0] = synchronizedTransform.position[0];
|
||||||
|
chasePositions[index][1] = synchronizedTransform.position[1];
|
||||||
|
chasePositions[index][2] = synchronizedTransform.position[2];
|
||||||
|
updateMobRuntimePosition(
|
||||||
|
instanceId,
|
||||||
|
synchronizedTransform.position[0],
|
||||||
|
synchronizedTransform.position[1],
|
||||||
|
synchronizedTransform.position[2],
|
||||||
|
synchronizedTransform.yaw,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const mob = combat.mobs[instanceId];
|
const mob = combat.mobs[instanceId];
|
||||||
const chasePosition = chasePositions[index];
|
const chasePosition = chasePositions[index];
|
||||||
const engaged = Boolean(mob?.engaged && !mob.dead);
|
const engaged = Boolean(mob?.engaged && !mob.dead);
|
||||||
@@ -1303,6 +1325,24 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
|
|||||||
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
|
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
|
||||||
useEffect(() => registerMobRuntime(spawn.id, spawn.position, spawn.yaw ?? 0), [spawn.id, spawn.position, spawn.yaw]);
|
useEffect(() => registerMobRuntime(spawn.id, spawn.position, spawn.yaw ?? 0), [spawn.id, spawn.position, spawn.yaw]);
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
|
const synchronizedTransform = onlineSynchronizedMobTransform(spawn.id);
|
||||||
|
if (synchronizedTransform) {
|
||||||
|
positionRef.current[0] = synchronizedTransform.position[0];
|
||||||
|
positionRef.current[1] = synchronizedTransform.position[1];
|
||||||
|
positionRef.current[2] = synchronizedTransform.position[2];
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.position.set(...synchronizedTransform.position);
|
||||||
|
groupRef.current.rotation.y = synchronizedTransform.yaw;
|
||||||
|
}
|
||||||
|
updateMobRuntimePosition(
|
||||||
|
spawn.id,
|
||||||
|
synchronizedTransform.position[0],
|
||||||
|
synchronizedTransform.position[1],
|
||||||
|
synchronizedTransform.position[2],
|
||||||
|
synchronizedTransform.yaw,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mob = useCombatStore.getState().mobs[spawn.id];
|
const mob = useCombatStore.getState().mobs[spawn.id];
|
||||||
const position = positionRef.current;
|
const position = positionRef.current;
|
||||||
const player = useGameStore.getState().playerPosition;
|
const player = useGameStore.getState().playerPosition;
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Html } from "@react-three/drei";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useMemo, useRef, type CSSProperties } from "react";
|
||||||
|
import type { Group } from "three";
|
||||||
|
import {
|
||||||
|
classById,
|
||||||
|
clampAppearance,
|
||||||
|
createDefaultAppearance,
|
||||||
|
type ClassId,
|
||||||
|
type GenderId,
|
||||||
|
type RaceId,
|
||||||
|
} from "../app/characterCatalog";
|
||||||
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||||
|
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||||
|
import { CharacterModel, type AvatarIdentity } from "../avatar/CharacterModel";
|
||||||
|
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
||||||
|
import { useCombatStore } from "../game/combatStore";
|
||||||
|
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
||||||
|
import { usePartyStore } from "../game/partyStore";
|
||||||
|
|
||||||
|
const labelStyle: CSSProperties = {
|
||||||
|
color: "#d8f6ff",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
fontSize: "11px",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
pointerEvents: "none",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 1px 2px #000, 0 0 5px #000",
|
||||||
|
userSelect: "none",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
};
|
||||||
|
|
||||||
|
function validAppearance(player: OnlineSessionPlayer): AvatarIdentity {
|
||||||
|
const raceId = (player.character?.raceId ?? "human") as RaceId;
|
||||||
|
const gender = (player.character?.gender ?? "male") as GenderId;
|
||||||
|
const source = player.character?.appearance;
|
||||||
|
const appearance = source && [source.skinColor, source.face, source.hairStyle, source.hairColor, source.feature].every(Number.isFinite)
|
||||||
|
? source
|
||||||
|
: createDefaultAppearance();
|
||||||
|
return {
|
||||||
|
raceId,
|
||||||
|
gender,
|
||||||
|
classId: (player.character?.classId ?? "priest") as ClassId,
|
||||||
|
appearance: clampAppearance(raceId, gender, appearance),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function RemoteOnlinePlayer({ player, active }: {
|
||||||
|
readonly player: OnlineSessionPlayer;
|
||||||
|
readonly active: boolean;
|
||||||
|
}) {
|
||||||
|
const rootRef = useRef<Group>(null);
|
||||||
|
const movingRef = useRef(false);
|
||||||
|
const animationEventRef = useRef(player.animationEvent);
|
||||||
|
animationEventRef.current = player.animationEvent;
|
||||||
|
const previousTargetRef = useRef<[number, number, number]>([...player.position]);
|
||||||
|
const identity = useMemo(() => validAppearance(player), [player.character]);
|
||||||
|
const characterClass = classById(identity.classId);
|
||||||
|
const actorId = onlinePlayerActorId(player.accountId);
|
||||||
|
const selected = useCombatStore((state) => state.selectedFriendlyActorId === actorId);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const target = player.position;
|
||||||
|
const previousTarget = previousTargetRef.current;
|
||||||
|
movingRef.current = Math.hypot(
|
||||||
|
target[0] - previousTarget[0],
|
||||||
|
target[2] - previousTarget[2],
|
||||||
|
) > 0.015;
|
||||||
|
previousTarget[0] = target[0];
|
||||||
|
previousTarget[1] = target[1];
|
||||||
|
previousTarget[2] = target[2];
|
||||||
|
const alpha = 1 - Math.exp(-18 * Math.min(delta, 0.05));
|
||||||
|
root.position.x += (target[0] - root.position.x) * alpha;
|
||||||
|
root.position.y += (target[1] - root.position.y) * alpha;
|
||||||
|
root.position.z += (target[2] - root.position.z) * alpha;
|
||||||
|
const yawDelta = Math.atan2(Math.sin(player.yaw - root.rotation.y), Math.cos(player.yaw - root.rotation.y));
|
||||||
|
root.rotation.y += yawDelta * alpha;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
ref={rootRef}
|
||||||
|
name={`online-player-${player.accountId}`}
|
||||||
|
position={player.position}
|
||||||
|
rotation={[0, player.yaw, 0]}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.nativeEvent.stopPropagation();
|
||||||
|
if (event.button !== 0 || !active || player.health <= 0) return;
|
||||||
|
useCombatStore.getState().clearTarget();
|
||||||
|
usePartyStore.getState().clearSelection();
|
||||||
|
useCombatStore.getState().selectFriendlyActor(actorId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<mesh position={[0, 1.05, 0]}>
|
||||||
|
<capsuleGeometry args={[0.48, 1.2, 6, 10]} />
|
||||||
|
<meshBasicMaterial transparent opacity={0} colorWrite={false} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
<CharacterModel
|
||||||
|
identity={identity}
|
||||||
|
equipment={player.equipment}
|
||||||
|
active={active}
|
||||||
|
movingRef={movingRef}
|
||||||
|
animationEventRef={animationEventRef}
|
||||||
|
fallback={<ProceduralAvatar accent={characterClass.color} />}
|
||||||
|
/>
|
||||||
|
{selected && player.health > 0 ? (
|
||||||
|
<mesh position={[0, 0.035, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<torusGeometry args={[0.62, 0.055, 6, 28]} />
|
||||||
|
<meshBasicMaterial color="#8deaff" transparent opacity={0.95} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
<Html center position={[0, 2.45, 0]} zIndexRange={[5, 0]} style={{
|
||||||
|
...labelStyle,
|
||||||
|
opacity: player.health > 0 ? 1 : 0.55,
|
||||||
|
}}>
|
||||||
|
<div>{player.character?.name ?? player.username}<br /><small>Online player · {player.role ?? "party"}</small></div>
|
||||||
|
</Html>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnlinePlayerPopulation({ active = true }: { readonly active?: boolean }) {
|
||||||
|
const snapshot = useOnlineSessionStore((state) => state.snapshot);
|
||||||
|
if (!snapshot) return null;
|
||||||
|
const remotePlayers = snapshot.players.filter((player) => (
|
||||||
|
player.accountId !== snapshot.localAccountId && player.character
|
||||||
|
));
|
||||||
|
if (!remotePlayers.length) return null;
|
||||||
|
return (
|
||||||
|
<group name="online-player-population">
|
||||||
|
{remotePlayers.map((player) => (
|
||||||
|
<RemoteOnlinePlayer key={player.accountId} player={player} active={active} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ import { partyCombatRangeBand } from "../game/partyRuntime";
|
|||||||
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
|
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
|
||||||
import {
|
import {
|
||||||
clearPartyRuntimeAttackCorridor,
|
clearPartyRuntimeAttackCorridor,
|
||||||
|
getPartyRuntimePosition,
|
||||||
registerPartyRuntimePosition,
|
registerPartyRuntimePosition,
|
||||||
updatePartyRuntimeAttackCorridor,
|
updatePartyRuntimeAttackCorridor,
|
||||||
updatePartyRuntimePosition,
|
updatePartyRuntimePosition,
|
||||||
@@ -75,6 +76,8 @@ import {
|
|||||||
import { usePartyStore } from "../game/partyStore";
|
import { usePartyStore } from "../game/partyStore";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
import { useManastormStore } from "../game/manastormStore";
|
import { useManastormStore } from "../game/manastormStore";
|
||||||
|
import { registerCombatSpatialProvider } from "../game/combatSpatial";
|
||||||
|
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||||
|
|
||||||
const PARTY_MOVE_SPEED = 5.25;
|
const PARTY_MOVE_SPEED = 5.25;
|
||||||
const PARTY_CATCH_UP_SPEED = 7.5;
|
const PARTY_CATCH_UP_SPEED = 7.5;
|
||||||
@@ -266,12 +269,14 @@ function initialMemberPosition(index: number): MutablePartyWorldPosition {
|
|||||||
|
|
||||||
function PartyActors({
|
function PartyActors({
|
||||||
active,
|
active,
|
||||||
|
simulate,
|
||||||
navigationGraph,
|
navigationGraph,
|
||||||
navigationFailed,
|
navigationFailed,
|
||||||
objectives,
|
objectives,
|
||||||
stackAtEntrance,
|
stackAtEntrance,
|
||||||
}: {
|
}: {
|
||||||
readonly active: boolean;
|
readonly active: boolean;
|
||||||
|
readonly simulate: boolean;
|
||||||
readonly navigationGraph: PartyNavigationGraph | null;
|
readonly navigationGraph: PartyNavigationGraph | null;
|
||||||
readonly navigationFailed: boolean;
|
readonly navigationFailed: boolean;
|
||||||
readonly objectives: readonly DungeonBossObjective[];
|
readonly objectives: readonly DungeonBossObjective[];
|
||||||
@@ -442,6 +447,56 @@ function PartyActors({
|
|||||||
const directFollowMovingRef = useRef(new Map<string, boolean>());
|
const directFollowMovingRef = useRef(new Map<string, boolean>());
|
||||||
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
|
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
|
||||||
const memberIdKey = members.map((member) => member.id).join("|");
|
const memberIdKey = members.map((member) => member.id).join("|");
|
||||||
|
useEffect(() => registerCombatSpatialProvider({
|
||||||
|
actor: (actorId) => {
|
||||||
|
const gameState = useGameStore.getState();
|
||||||
|
if (actorId === PLAYER_AGGRO_ID) {
|
||||||
|
return { id: actorId, position: gameState.playerPosition, yaw: gameState.cameraYaw };
|
||||||
|
}
|
||||||
|
const memberIndex = usePartyStore.getState().members.findIndex((member) => member.id === actorId);
|
||||||
|
const memberPosition = positionsRef.current.get(actorId);
|
||||||
|
if (memberPosition) {
|
||||||
|
return {
|
||||||
|
id: actorId,
|
||||||
|
position: memberPosition,
|
||||||
|
yaw: actorRefs.current[memberIndex]?.rotation.y ?? gameState.cameraYaw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const summon = useCombatStore.getState().summons.find((candidate) => candidate.id === actorId);
|
||||||
|
if (summon) return { id: actorId, position: summon.position, yaw: gameState.cameraYaw };
|
||||||
|
const mobPosition = getMobPosition(actorId);
|
||||||
|
if (!mobPosition) return null;
|
||||||
|
const mob = useCombatStore.getState().mobs[actorId];
|
||||||
|
const targetPosition = mob?.targetActorId === PLAYER_AGGRO_ID
|
||||||
|
? gameState.playerPosition
|
||||||
|
: mob?.targetActorId
|
||||||
|
? positionsRef.current.get(mob.targetActorId)
|
||||||
|
?? useCombatStore.getState().summons.find((candidate) => candidate.id === mob.targetActorId)?.position
|
||||||
|
: null;
|
||||||
|
const yaw = targetPosition
|
||||||
|
? Math.atan2(targetPosition[0] - mobPosition[0], targetPosition[2] - mobPosition[2])
|
||||||
|
: 0;
|
||||||
|
return { id: actorId, position: mobPosition, yaw };
|
||||||
|
},
|
||||||
|
hasLineOfSight: hasStaticLineOfSight,
|
||||||
|
projectGroundPoint: (requested, maximumDistance, requestedOrigin) => {
|
||||||
|
const origin = requestedOrigin ?? useGameStore.getState().playerPosition;
|
||||||
|
const dx = requested[0] - origin[0];
|
||||||
|
const dz = requested[2] - origin[2];
|
||||||
|
const planar = Math.hypot(dx, dz);
|
||||||
|
const scale = planar > maximumDistance && planar > 0 ? maximumDistance / planar : 1;
|
||||||
|
const x = origin[0] + dx * scale;
|
||||||
|
const z = origin[2] + dz * scale;
|
||||||
|
const ray = new rapier.Ray({ x, y: requested[1] + 8, z }, { x: 0, y: -1, z: 0 });
|
||||||
|
const hit = world.castRay(
|
||||||
|
ray,
|
||||||
|
20,
|
||||||
|
true,
|
||||||
|
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||||
|
);
|
||||||
|
return hit ? [x, ray.origin.y - hit.timeOfImpact, z] : null;
|
||||||
|
},
|
||||||
|
}), [hasStaticLineOfSight, memberIdKey, rapier, world]);
|
||||||
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
|
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
party.members.forEach((member, index) => {
|
party.members.forEach((member, index) => {
|
||||||
@@ -606,6 +661,21 @@ function PartyActors({
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
if (!party.members.length) return;
|
if (!party.members.length) return;
|
||||||
|
if (!simulate) {
|
||||||
|
party.members.forEach((member, index) => {
|
||||||
|
const synchronized = getPartyRuntimePosition(member.id);
|
||||||
|
if (!synchronized) return;
|
||||||
|
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||||
|
position[0] = synchronized[0];
|
||||||
|
position[1] = synchronized[1];
|
||||||
|
position[2] = synchronized[2];
|
||||||
|
positionsRef.current.set(member.id, position);
|
||||||
|
actorRefs.current[index]?.position.set(position[0], presentationY(position), position[2]);
|
||||||
|
const movingSignal = movingSignalsRef.current.get(member.id);
|
||||||
|
if (movingSignal) movingSignal.current = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
const playerNavigation = game.playerNavigationPosition;
|
const playerNavigation = game.playerNavigationPosition;
|
||||||
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
|
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
|
||||||
@@ -1320,7 +1390,13 @@ function PartyActors({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PartyPopulation({ active = true }: { readonly active?: boolean }) {
|
export function PartyPopulation({
|
||||||
|
active = true,
|
||||||
|
simulate = true,
|
||||||
|
}: {
|
||||||
|
readonly active?: boolean;
|
||||||
|
readonly simulate?: boolean;
|
||||||
|
}) {
|
||||||
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 sessionRevision = useGameStore((state) => state.sessionRevision);
|
const sessionRevision = useGameStore((state) => state.sessionRevision);
|
||||||
@@ -1383,6 +1459,7 @@ export function PartyPopulation({ active = true }: { readonly active?: boolean }
|
|||||||
<PartyActors
|
<PartyActors
|
||||||
key={activeDungeonId}
|
key={activeDungeonId}
|
||||||
active={active}
|
active={active}
|
||||||
|
simulate={simulate}
|
||||||
navigationGraph={navigationGraph}
|
navigationGraph={navigationGraph}
|
||||||
navigationFailed={navigationFailed || !assets.navigation}
|
navigationFailed={navigationFailed || !assets.navigation}
|
||||||
objectives={objectives}
|
objectives={objectives}
|
||||||
|
|||||||
+13
-3
@@ -180,6 +180,15 @@ function PlayerController({
|
|||||||
orbit.yaw = cameraYawAfterHorizontalLook(orbit.yaw, snapshot.lookX * delta * 2.25);
|
orbit.yaw = cameraYawAfterHorizontalLook(orbit.yaw, snapshot.lookX * delta * 2.25);
|
||||||
orbit.pitch = clampCameraPitch(orbit.pitch + snapshot.lookY * delta * 1.55);
|
orbit.pitch = clampCameraPitch(orbit.pitch + snapshot.lookY * delta * 1.55);
|
||||||
}
|
}
|
||||||
|
const groundTargetActive = useCombatStore.getState().pendingGroundTarget !== null;
|
||||||
|
if (groundTargetActive && !paused && !mapOpen && !companionOpen) {
|
||||||
|
useCombatStore.getState().moveGroundTarget(
|
||||||
|
snapshot.moveX,
|
||||||
|
snapshot.moveForward,
|
||||||
|
orbit.yaw,
|
||||||
|
delta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const translation = body.translation();
|
const translation = body.translation();
|
||||||
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
|
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
|
||||||
@@ -222,7 +231,8 @@ function PlayerController({
|
|||||||
body.setNextKinematicTranslation(translation);
|
body.setNextKinematicTranslation(translation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
|
if (requestedAtMs !== null && !groundTargetActive) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
|
||||||
|
if (groundTargetActive) cancelBufferedPlayerJump(jumpTimingRef.current);
|
||||||
|
|
||||||
const shouldJump = updatePlayerJumpTiming(
|
const shouldJump = updatePlayerJumpTiming(
|
||||||
jumpTimingRef.current,
|
jumpTimingRef.current,
|
||||||
@@ -238,8 +248,8 @@ function PlayerController({
|
|||||||
: airbornePlayerVerticalVelocity(verticalVelocityRef.current, movementDeltaSeconds);
|
: airbornePlayerVerticalVelocity(verticalVelocityRef.current, movementDeltaSeconds);
|
||||||
|
|
||||||
const [worldX, worldZ] = cameraRelativeMovement(
|
const [worldX, worldZ] = cameraRelativeMovement(
|
||||||
snapshot.moveX,
|
groundTargetActive ? 0 : snapshot.moveX,
|
||||||
snapshot.moveForward,
|
groundTargetActive ? 0 : snapshot.moveForward,
|
||||||
orbit.yaw,
|
orbit.yaw,
|
||||||
);
|
);
|
||||||
if (body.numColliders() === 0) return;
|
if (body.numColliders() === 0) return;
|
||||||
|
|||||||
@@ -684,6 +684,9 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
|||||||
.party-frame.is-down { filter: grayscale(.75); opacity: .58; }
|
.party-frame.is-down { filter: grayscale(.75); opacity: .58; }
|
||||||
.party-frame--player { opacity: .9; }
|
.party-frame--player { opacity: .9; }
|
||||||
.party-frame--player.is-selected { opacity: 1; }
|
.party-frame--player.is-selected { opacity: 1; }
|
||||||
|
.party-frame--online { border-color: rgba(79,184,154,.3); background: linear-gradient(100deg, rgba(9,35,31,.94), rgba(11,24,21,.9)); }
|
||||||
|
.party-frame__online-status { display: flex; align-items: center; gap: 4px; color: #8fd5bd; font-size: 6px; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.party-frame__online-status i { width: 5px; height: 5px; border-radius: 50%; background: #55d79f; box-shadow: 0 0 5px rgba(85,215,159,.72); }
|
||||||
.party-frame__sigil { display: grid; width: 27px; height: 27px; place-items: center; border: 1px solid color-mix(in srgb, var(--party-class-color, #789b68), white 18%); border-radius: 50%; background: color-mix(in srgb, var(--party-class-color, #789b68) 22%, #06100c); color: #eef4e7; font-size: 7px; font-weight: 800; }
|
.party-frame__sigil { display: grid; width: 27px; height: 27px; place-items: center; border: 1px solid color-mix(in srgb, var(--party-class-color, #789b68), white 18%); border-radius: 50%; background: color-mix(in srgb, var(--party-class-color, #789b68) 22%, #06100c); color: #eef4e7; font-size: 7px; font-weight: 800; }
|
||||||
.party-frame__body { display: grid; min-width: 0; gap: 3px; }
|
.party-frame__body { display: grid; min-width: 0; gap: 3px; }
|
||||||
.party-frame__identity { display: flex; min-width: 0; align-items: baseline; justify-content: space-between; gap: 5px; }
|
.party-frame__identity { display: flex; min-width: 0; align-items: baseline; justify-content: space-between; gap: 5px; }
|
||||||
@@ -691,7 +694,33 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
|||||||
.party-frame__identity small { overflow: hidden; color: #a9b69e; font-size: 6px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
|
.party-frame__identity small { overflow: hidden; color: #a9b69e; font-size: 6px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
|
||||||
.party-frame__health { position: relative; display: block; height: 8px; overflow: hidden; border: 1px solid rgba(255,255,255,.09); border-radius: 2px; background: rgba(0,0,0,.62); }
|
.party-frame__health { position: relative; display: block; height: 8px; overflow: hidden; border: 1px solid rgba(255,255,255,.09); border-radius: 2px; background: rgba(0,0,0,.62); }
|
||||||
.party-frame__health i { display: block; height: 100%; background: linear-gradient(90deg, #3e793e, #79b958); transition: width 150ms ease; }
|
.party-frame__health i { display: block; height: 100%; background: linear-gradient(90deg, #3e793e, #79b958); transition: width 150ms ease; }
|
||||||
|
.party-frame__health em { position: absolute; top: 0; bottom: 0; background: linear-gradient(90deg, rgba(92,176,219,.72), rgba(151,222,244,.94)); box-shadow: 0 0 5px rgba(104,202,238,.55); }
|
||||||
.party-frame__health b { position: absolute; inset: -1px 2px 0; color: rgba(246,252,240,.9); font-size: 6px; line-height: 8px; text-align: right; text-shadow: 0 1px 2px #000; }
|
.party-frame__health b { position: absolute; inset: -1px 2px 0; color: rgba(246,252,240,.9); font-size: 6px; line-height: 8px; text-align: right; text-shadow: 0 1px 2px #000; }
|
||||||
|
.party-frame__resource { display: block; height: 3px; overflow: hidden; border-radius: 2px; background: rgba(0,0,0,.65); }
|
||||||
|
.party-frame__resource i { display: block; height: 100%; background: linear-gradient(90deg, #315f9b, #599fd4); transition: width 150ms ease; }
|
||||||
|
.party-frame__resource--rage i { background: linear-gradient(90deg, #87342c, #c95947); }
|
||||||
|
.party-frame__resource--energy i { background: linear-gradient(90deg, #958321, #e0cb43); }
|
||||||
|
.party-frame__resource--runic-power i { background: linear-gradient(90deg, #327784, #62c5ce); }
|
||||||
|
.party-frame__resource--focus i { background: linear-gradient(90deg, #4b7d6b, #71c8a7); }
|
||||||
|
.party-frame__dispel { justify-self: end; margin-top: -2px; padding: 1px 3px; border: 1px solid rgba(224,132,102,.55); border-radius: 3px; color: #ffd0a9; font-size: 5px; letter-spacing: .08em; }
|
||||||
|
.summon-frames { position: absolute; top: max(101px, calc(env(safe-area-inset-top) + 89px)); left: max(244px, calc(env(safe-area-inset-left) + 232px)); display: grid; width: 150px; gap: 3px; scale: var(--game-ui-scale, 1); transform-origin: top left; }
|
||||||
|
.summon-frame { width: 100%; padding: 4px 6px; border: 1px solid rgba(115,183,205,.28); border-radius: 5px; background: rgba(5,16,19,.88); box-shadow: 0 5px 14px rgba(0,0,0,.3); color: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.summon-frame.is-selected { border-color: rgba(117,225,181,.82); box-shadow: 0 0 0 1px rgba(117,225,181,.28), 0 5px 14px rgba(0,0,0,.3); }
|
||||||
|
.summon-frame header { display: flex; justify-content: space-between; gap: 5px; color: #dff4ec; font-size: 7px; }
|
||||||
|
.summon-frame header span, .summon-frame small { color: #93b4b0; font-size: 5px; text-transform: uppercase; }
|
||||||
|
.summon-frame > div { height: 4px; margin-top: 3px; overflow: hidden; border-radius: 3px; background: rgba(0,0,0,.65); }
|
||||||
|
.summon-frame > div i { display: block; height: 100%; background: linear-gradient(90deg, #417b64, #78c7a2); }
|
||||||
|
.summon-frame--totem { border-color: rgba(220,188,91,.38); }
|
||||||
|
.summon-frame--totem > div i { background: linear-gradient(90deg, #8a6d2b, #d5b755); }
|
||||||
|
.ground-target-reticle { position: absolute; top: 50%; left: 50%; z-index: 20; display: grid; width: 220px; justify-items: center; gap: 5px; color: #f4e8ae; text-align: center; text-shadow: 0 2px 4px #000; transform: translate(-50%, -50%); pointer-events: auto; }
|
||||||
|
.ground-target-reticle > div { position: relative; width: 58px; height: 58px; border: 2px solid rgba(239,210,91,.9); border-radius: 50%; box-shadow: 0 0 18px rgba(238,199,71,.42), inset 0 0 16px rgba(238,199,71,.18); }
|
||||||
|
.ground-target-reticle > div i, .ground-target-reticle > div b { position: absolute; top: 50%; left: 50%; background: rgba(250,222,116,.95); transform: translate(-50%, -50%); }
|
||||||
|
.ground-target-reticle > div i { width: 76px; height: 1px; }
|
||||||
|
.ground-target-reticle > div b { width: 1px; height: 76px; }
|
||||||
|
.ground-target-reticle strong { font: 600 12px Georgia, serif; }
|
||||||
|
.ground-target-reticle small { max-width: 210px; color: #d5ddca; font-size: 7px; }
|
||||||
|
.ground-target-reticle span { display: flex; gap: 5px; }
|
||||||
|
.ground-target-reticle button { padding: 3px 8px; border: 1px solid rgba(232,207,116,.38); border-radius: 4px; background: rgba(7,18,12,.9); color: #edf2d9; font-size: 7px; }
|
||||||
.timed-effect-strip {
|
.timed-effect-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -2129,6 +2158,56 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
|
|||||||
.companion-map-card > div small { color: var(--front-dim); font-size: 7px; line-height: 1.4; }
|
.companion-map-card > div small { color: var(--front-dim); font-size: 7px; line-height: 1.4; }
|
||||||
.companion-actions--game button { min-width: 78px; }
|
.companion-actions--game button { min-width: 78px; }
|
||||||
|
|
||||||
|
/* Optional online groups stay collapsed until a player needs lobby controls. */
|
||||||
|
.online-group-panel { margin: 9px 0; border: 1px solid rgba(111,190,174,.24); border-radius: 8px; background: rgba(3,18,17,.72); color: var(--front-cream); }
|
||||||
|
.online-group-panel > summary { display: grid; min-height: 42px; grid-template-columns: 24px 1fr auto; align-items: center; gap: 7px; padding: 6px 9px; cursor: pointer; list-style: none; }
|
||||||
|
.online-group-panel > summary::-webkit-details-marker { display: none; }
|
||||||
|
.online-group-panel > summary > span { display: grid; width: 22px; height: 22px; place-items: center; border: 1px solid rgba(112,212,190,.42); border-radius: 50%; color: #87dcc7; font-size: 12px; }
|
||||||
|
.online-group-panel > summary strong { font: 12px Georgia, serif; font-weight: 400; }
|
||||||
|
.online-group-panel > summary small { color: #8fb4a9; font-size: 7px; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
|
.online-group-panel[open] > summary { border-bottom: 1px solid rgba(111,190,174,.16); background: rgba(91,155,133,.07); }
|
||||||
|
.online-group-panel__body { display: grid; gap: 8px; padding: 8px; }
|
||||||
|
.online-group-panel__body section > strong, .online-group-roster header strong, .online-group-role > strong { color: #b6dfce; font-size: 7px; letter-spacing: .09em; text-transform: uppercase; }
|
||||||
|
.online-group-roster header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
||||||
|
.online-group-roster header small { color: var(--front-dim); font-size: 7px; }
|
||||||
|
.online-group-member { display: grid; grid-template-columns: 7px minmax(0, 1fr) auto auto; align-items: center; gap: 6px; padding: 5px 0; border-bottom: 1px solid rgba(203,219,180,.08); }
|
||||||
|
.online-group-member > span { width: 6px; height: 6px; border: 1px solid #6e776d; border-radius: 50%; background: #313b36; }
|
||||||
|
.online-group-member > span.is-ready { border-color: #72d0a0; background: #3fa16f; box-shadow: 0 0 5px rgba(92,211,143,.48); }
|
||||||
|
.online-group-member p { min-width: 0; margin: 0; }
|
||||||
|
.online-group-member p strong, .online-group-member p small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.online-group-member p strong { font-size: 8px; }
|
||||||
|
.online-group-member p small { margin-top: 1px; color: var(--front-dim); font-size: 6px; }
|
||||||
|
.online-group-member > b { color: #d2e5da; font-size: 6px; font-weight: 600; text-transform: uppercase; }
|
||||||
|
.online-group-member button, .online-group-invites button, .online-group-role button, .online-group-invite-form button, .online-group-leave { padding: 4px 7px; border: 1px solid rgba(143,201,180,.27); border-radius: 4px; background: rgba(89,139,113,.13); color: #e7f2e9; font-size: 7px; cursor: pointer; }
|
||||||
|
.online-group-member button:disabled, .online-group-invites button:disabled, .online-group-role button:disabled, .online-group-invite-form button:disabled, .online-group-leave:disabled { cursor: not-allowed; opacity: .45; }
|
||||||
|
.online-group-invites { display: grid; gap: 4px; padding: 6px; border: 1px solid rgba(218,188,91,.2); border-radius: 6px; background: rgba(129,104,33,.09); }
|
||||||
|
.online-group-invites > div { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 4px; }
|
||||||
|
.online-group-invites span b, .online-group-invites span small { display: block; }
|
||||||
|
.online-group-invites span b { font-size: 8px; }
|
||||||
|
.online-group-invites span small { color: var(--front-dim); font-size: 6px; }
|
||||||
|
.online-group-role { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.online-group-role > div { display: flex; gap: 4px; }
|
||||||
|
.online-group-role button.is-active { border-color: rgba(116,222,178,.7); background: rgba(62,139,105,.35); color: #dffff0; }
|
||||||
|
.online-group-ai-toggle { display: flex; align-items: center; gap: 7px; padding: 6px; border-radius: 6px; background: rgba(255,255,255,.025); cursor: pointer; }
|
||||||
|
.online-group-ai-toggle input { accent-color: #5fae84; }
|
||||||
|
.online-group-ai-toggle span strong, .online-group-ai-toggle span small { display: block; }
|
||||||
|
.online-group-ai-toggle span strong { font-size: 8px; }
|
||||||
|
.online-group-ai-toggle span small { margin-top: 1px; color: var(--front-dim); font-size: 6px; }
|
||||||
|
.online-group-invite-form { display: grid; gap: 3px; }
|
||||||
|
.online-group-invite-form > label { color: var(--front-dim); font-size: 6px; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.online-group-invite-form > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; }
|
||||||
|
.online-group-invite-form input { min-width: 0; padding: 6px 8px; border: 1px solid rgba(143,201,180,.22); border-radius: 5px; outline: none; background: rgba(0,0,0,.3); color: var(--front-cream); font: 8px inherit; }
|
||||||
|
.online-group-invite-form input:focus { border-color: rgba(111,211,181,.68); box-shadow: 0 0 0 2px rgba(76,165,136,.12); }
|
||||||
|
.online-group-activity { display: flex; align-items: center; justify-content: space-between; margin: 0; padding: 7px; border: 1px solid rgba(221,186,82,.3); border-radius: 6px; background: rgba(145,108,22,.12); }
|
||||||
|
.online-group-activity span strong, .online-group-activity span small { display: block; }
|
||||||
|
.online-group-activity strong { color: #efd98d; font: 10px Georgia, serif; }
|
||||||
|
.online-group-activity small { color: var(--front-dim); font-size: 6px; }
|
||||||
|
.online-group-activity button { padding: 4px 7px; border: 1px solid rgba(221,186,82,.3); border-radius: 4px; background: rgba(131,96,19,.25); color: #efdda0; font-size: 7px; cursor: pointer; }
|
||||||
|
.online-group-leave { justify-self: start; border-color: rgba(192,116,103,.28); background: rgba(126,57,49,.12); color: #e8bbb2; }
|
||||||
|
.online-group-error { margin: 0; color: #f1a99d; font-size: 7px; line-height: 1.35; }
|
||||||
|
.main-menu-modes > .online-group-panel { max-width: 570px; }
|
||||||
|
.dungeon-select-copy > .online-group-panel, .manastorm-role-select > .online-group-panel { margin: 8px 0 10px; }
|
||||||
|
|
||||||
/* Dedicated native WebViews own separate measured viewports. */
|
/* Dedicated native WebViews own separate measured viewports. */
|
||||||
.native-platform, .native-platform body, .native-platform #root { width: 100%; height: 100%; overscroll-behavior: none; }
|
.native-platform, .native-platform body, .native-platform #root { width: 100%; height: 100%; overscroll-behavior: none; }
|
||||||
.native-platform .dedicated-display-surface { padding: 0; }
|
.native-platform .dedicated-display-surface { padding: 0; }
|
||||||
|
|||||||
@@ -82,7 +82,11 @@ function ActionSlot({
|
|||||||
const cooldownLabel = (item?.cooldownRemaining ?? 0) > 0.05
|
const cooldownLabel = (item?.cooldownRemaining ?? 0) > 0.05
|
||||||
? ", cooling down"
|
? ", cooling down"
|
||||||
: "";
|
: "";
|
||||||
const targetLabel = item?.target === "friendly" ? ", targets selected ally or self" : "";
|
const targetLabel = item?.target === "friendly"
|
||||||
|
? ", targets selected ally or self"
|
||||||
|
: item?.target === "any"
|
||||||
|
? ", targets selected enemy or ally"
|
||||||
|
: "";
|
||||||
const resourceLabel = item?.resourceCost
|
const resourceLabel = item?.resourceCost
|
||||||
? `, costs ${item.resourceCost} ${item.resourceLabel ?? "resource"}`
|
? `, costs ${item.resourceCost} ${item.resourceLabel ?? "resource"}`
|
||||||
: "";
|
: "";
|
||||||
@@ -93,7 +97,7 @@ function ActionSlot({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`}
|
className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" || item?.target === "any" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`}
|
||||||
style={style}
|
style={style}
|
||||||
aria-disabled={unavailable}
|
aria-disabled={unavailable}
|
||||||
aria-label={item ? `${item.name}, ${chord}${targetLabel}${resourceLabel}${lockedLabel}${cooldownLabel}` : `Empty binding, ${chord}`}
|
aria-label={item ? `${item.name}, ${chord}${targetLabel}${resourceLabel}${lockedLabel}${cooldownLabel}` : `Empty binding, ${chord}`}
|
||||||
@@ -114,6 +118,7 @@ function ActionSlot({
|
|||||||
<strong>{item?.name ?? "Empty"}</strong>
|
<strong>{item?.name ?? "Empty"}</strong>
|
||||||
{item?.lockedUntilLevel ? <span className="action-slot__unlock">LVL {item.lockedUntilLevel}</span> : null}
|
{item?.lockedUntilLevel ? <span className="action-slot__unlock">LVL {item.lockedUntilLevel}</span> : null}
|
||||||
{item?.target === "friendly" ? <span className="action-slot__target">ALLY</span> : null}
|
{item?.target === "friendly" ? <span className="action-slot__target">ALLY</span> : null}
|
||||||
|
{item?.target === "any" ? <span className="action-slot__target">ANY</span> : null}
|
||||||
{item?.resourceCost ? <small aria-hidden="true">{item.resourceCost}</small> : null}
|
{item?.resourceCost ? <small aria-hidden="true">{item.resourceCost}</small> : null}
|
||||||
{cooldown > 0 && <i className="action-slot__cooldown" aria-hidden="true" />}
|
{cooldown > 0 && <i className="action-slot__cooldown" aria-hidden="true" />}
|
||||||
{(item?.cooldownRemaining ?? 0) > 0.05 && (
|
{(item?.cooldownRemaining ?? 0) > 0.05 && (
|
||||||
@@ -183,7 +188,8 @@ export function ActionBar({
|
|||||||
? `Unlocks at level ${inspectedItem.lockedUntilLevel}`
|
? `Unlocks at level ${inspectedItem.lockedUntilLevel}`
|
||||||
: inspectedItem.resourceCost
|
: inspectedItem.resourceCost
|
||||||
? `${inspectedItem.resourceCost} ${inspectedItem.resourceLabel ?? "resource"}`
|
? `${inspectedItem.resourceCost} ${inspectedItem.resourceLabel ?? "resource"}`
|
||||||
: inspectedItem.target === "friendly" ? "Friendly target" : "Ready"
|
: inspectedItem.target === "friendly" ? "Friendly target"
|
||||||
|
: inspectedItem.target === "any" ? "Enemy or friendly target" : "Ready"
|
||||||
: inspectedControl ? "Empty binding" : "Focus a button for details"}</span>
|
: inspectedControl ? "Empty binding" : "Focus a button for details"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
|||||||
import { ControllerButton } from "./ControllerButton";
|
import { ControllerButton } from "./ControllerButton";
|
||||||
import { DungeonMapGraphic } from "./DungeonMapGraphic";
|
import { DungeonMapGraphic } from "./DungeonMapGraphic";
|
||||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||||
|
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||||
|
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||||
|
|
||||||
installDungeonShellRuntime();
|
installDungeonShellRuntime();
|
||||||
|
|
||||||
@@ -48,6 +50,10 @@ export function DungeonSelectScreen() {
|
|||||||
const enterDungeon = useShellStore((state) => state.enterDungeon);
|
const enterDungeon = useShellStore((state) => state.enterDungeon);
|
||||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||||
const notice = useShellStore((state) => state.notice);
|
const notice = useShellStore((state) => state.notice);
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
|
||||||
|
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
|
||||||
|
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
|
||||||
const character = activeCharacter ?? rosterCharacter;
|
const character = activeCharacter ?? rosterCharacter;
|
||||||
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.available) ?? DUNGEON_CATALOG[0];
|
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.available) ?? DUNGEON_CATALOG[0];
|
||||||
const [selectedCategory, setSelectedCategory] = useState<DungeonCategoryId>(firstAvailable?.category ?? "wow");
|
const [selectedCategory, setSelectedCategory] = useState<DungeonCategoryId>(firstAvailable?.category ?? "wow");
|
||||||
@@ -73,8 +79,22 @@ export function DungeonSelectScreen() {
|
|||||||
setRolePickerOpen(false);
|
setRolePickerOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const enterAsRole = (role: PartyRole) => {
|
const enterAsRole = async (role: PartyRole) => {
|
||||||
if (selectedDungeon?.available) enterDungeon(selectedDungeon.id, role);
|
if (!selectedDungeon?.available || !character) return;
|
||||||
|
if (!onlineGroup || !session || session.kind !== "account") {
|
||||||
|
enterDungeon(selectedDungeon.id, role);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await updateOnlineMember(session, character, role)) return;
|
||||||
|
if (onlineGroup.leaderAccountId !== session.ownerId) {
|
||||||
|
useShellStore.getState().setNotice(`Ready as ${PARTY_ROLE_LABELS[role]}. Waiting for the group leader to start.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await startOnlineActivity(session, {
|
||||||
|
type: "dungeon",
|
||||||
|
contentId: selectedDungeon.id,
|
||||||
|
partySize: 5,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const actions = useMemo<MenuAction[]>(() => [
|
const actions = useMemo<MenuAction[]>(() => [
|
||||||
...DUNGEON_CATEGORIES.map((category, index) => ({
|
...DUNGEON_CATEGORIES.map((category, index) => ({
|
||||||
@@ -220,6 +240,7 @@ export function DungeonSelectScreen() {
|
|||||||
{selectedDungeon.available && !selectedPackInstalled && (
|
{selectedDungeon.available && !selectedPackInstalled && (
|
||||||
<p className="front-notice" role="status">Install this expedition from Main menu / Downloads before playing offline.</p>
|
<p className="front-notice" role="status">Install this expedition from Main menu / Downloads before playing offline.</p>
|
||||||
)}
|
)}
|
||||||
|
<OnlineGroupPanel />
|
||||||
{rolePickerOpen && characterClass ? (
|
{rolePickerOpen && characterClass ? (
|
||||||
<section className="dungeon-role-picker" aria-labelledby="dungeon-role-title">
|
<section className="dungeon-role-picker" aria-labelledby="dungeon-role-title">
|
||||||
<header>
|
<header>
|
||||||
@@ -240,7 +261,7 @@ export function DungeonSelectScreen() {
|
|||||||
>
|
>
|
||||||
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
||||||
<span>
|
<span>
|
||||||
<strong>{PARTY_ROLE_LABELS[role]}</strong>
|
<strong>{onlineGroup && session?.ownerId !== onlineGroup.leaderAccountId ? `Ready as ${PARTY_ROLE_LABELS[role]}` : PARTY_ROLE_LABELS[role]}</strong>
|
||||||
<small>{ROLE_DETAILS[role].summary}</small>
|
<small>{ROLE_DETAILS[role].summary}</small>
|
||||||
</span>
|
</span>
|
||||||
</ControllerButton>
|
</ControllerButton>
|
||||||
|
|||||||
+70
-5
@@ -24,6 +24,9 @@ import { AuraStrip } from "./AuraStrip";
|
|||||||
import { ManastormStatus } from "./ManastormStatus";
|
import { ManastormStatus } from "./ManastormStatus";
|
||||||
import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
import { ThreatMeterWidget } from "./ThreatMeterWidget";
|
||||||
import { BossLootWindow } from "./BossLootWindow";
|
import { BossLootWindow } from "./BossLootWindow";
|
||||||
|
import { SummonFrames } from "./SummonFrames";
|
||||||
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||||
|
import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
||||||
|
|
||||||
function LoadingOverlay() {
|
function LoadingOverlay() {
|
||||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||||
@@ -61,12 +64,14 @@ export function DefeatedPrompt({
|
|||||||
gameMode,
|
gameMode,
|
||||||
resurrectionCharges,
|
resurrectionCharges,
|
||||||
onReturn,
|
onReturn,
|
||||||
|
sharedOnline = false,
|
||||||
}: {
|
}: {
|
||||||
gameMode: "dungeon" | "manastorm";
|
gameMode: "dungeon" | "manastorm";
|
||||||
resurrectionCharges: number;
|
resurrectionCharges: number;
|
||||||
onReturn: () => void;
|
onReturn: () => void;
|
||||||
|
sharedOnline?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const canReturn = gameMode === "dungeon" || resurrectionCharges > 0;
|
const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0);
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="defeated-prompt"
|
className="defeated-prompt"
|
||||||
@@ -86,10 +91,12 @@ export function DefeatedPrompt({
|
|||||||
? `Resurrect now · ${resurrectionCharges} remaining`
|
? `Resurrect now · ${resurrectionCharges} remaining`
|
||||||
: "Respawn at entrance"}
|
: "Respawn at entrance"}
|
||||||
</button>
|
</button>
|
||||||
|
) : sharedOnline ? (
|
||||||
|
<small>Your shared run is still active. A living player can resurrect you.</small>
|
||||||
) : (
|
) : (
|
||||||
<small>No shared resurrection charges remain. A living ally must resurrect you.</small>
|
<small>No shared resurrection charges remain. A living ally must resurrect you.</small>
|
||||||
)}
|
)}
|
||||||
{gameMode === "dungeon" ? (
|
{gameMode === "dungeon" && !sharedOnline ? (
|
||||||
<small>Respawning resets the current encounter and restores the party.</small>
|
<small>Respawning resets the current encounter and restores the party.</small>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -122,6 +129,9 @@ export function Hud() {
|
|||||||
const health = useCombatStore((state) => state.health);
|
const health = useCombatStore((state) => state.health);
|
||||||
const maxHealth = useCombatStore((state) => state.maxHealth);
|
const maxHealth = useCombatStore((state) => state.maxHealth);
|
||||||
const shield = useCombatStore((state) => state.shield);
|
const shield = useCombatStore((state) => state.shield);
|
||||||
|
const namedAbsorb = useCombatStore((state) => state.auras
|
||||||
|
.filter((aura) => aura.targetId === PLAYER_AGGRO_ID)
|
||||||
|
.reduce((total, aura) => total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0), 0));
|
||||||
const resource = useCombatStore((state) => state.resource);
|
const resource = useCombatStore((state) => state.resource);
|
||||||
const maxResource = useCombatStore((state) => state.maxResource);
|
const maxResource = useCombatStore((state) => state.maxResource);
|
||||||
const resourcePools = useCombatStore((state) => state.resourcePools);
|
const resourcePools = useCombatStore((state) => state.resourcePools);
|
||||||
@@ -132,8 +142,10 @@ export function Hud() {
|
|||||||
const cooldowns = useCombatStore((state) => state.cooldowns);
|
const cooldowns = useCombatStore((state) => state.cooldowns);
|
||||||
const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt);
|
const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt);
|
||||||
const activeCast = useCombatStore((state) => state.activeCast);
|
const activeCast = useCombatStore((state) => state.activeCast);
|
||||||
|
const pendingGroundTarget = useCombatStore((state) => state.pendingGroundTarget);
|
||||||
const feedback = useCombatStore((state) => state.feedback);
|
const feedback = useCombatStore((state) => state.feedback);
|
||||||
const target = useCombatStore((state) => state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null);
|
const target = useCombatStore((state) => state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null);
|
||||||
|
const selectedFriendlyActorId = useCombatStore((state) => state.selectedFriendlyActorId);
|
||||||
const friendlyTarget = usePartyStore((state) => state.selectedMemberId
|
const friendlyTarget = usePartyStore((state) => state.selectedMemberId
|
||||||
? state.members.find((member) => member.id === state.selectedMemberId) ?? null
|
? state.members.find((member) => member.id === state.selectedMemberId) ?? null
|
||||||
: null);
|
: null);
|
||||||
@@ -141,6 +153,7 @@ export function Hud() {
|
|||||||
const partyMembers = usePartyStore((state) => state.members);
|
const partyMembers = usePartyStore((state) => state.members);
|
||||||
const playerRole = usePartyStore((state) => state.playerRole);
|
const playerRole = usePartyStore((state) => state.playerRole);
|
||||||
const settings = useCombatStore((state) => state.settings);
|
const settings = useCombatStore((state) => state.settings);
|
||||||
|
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
|
||||||
const [clock, setClock] = useState(() => Date.now());
|
const [clock, setClock] = useState(() => Date.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -148,6 +161,15 @@ export function Hud() {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingGroundTarget) return;
|
||||||
|
const cancel = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") useCombatStore.getState().cancelGroundTarget();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", cancel);
|
||||||
|
return () => window.removeEventListener("keydown", cancel);
|
||||||
|
}, [pendingGroundTarget]);
|
||||||
|
|
||||||
const updateThreatMeterPosition = useCallback((threatMeterPosition: typeof settings.threatMeterPosition) => {
|
const updateThreatMeterPosition = useCallback((threatMeterPosition: typeof settings.threatMeterPosition) => {
|
||||||
useCombatStore.getState().updateSettings({ threatMeterPosition });
|
useCombatStore.getState().updateSettings({ threatMeterPosition });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -211,11 +233,25 @@ export function Hud() {
|
|||||||
role: member.role,
|
role: member.role,
|
||||||
alive: member.health > 0,
|
alive: member.health > 0,
|
||||||
})),
|
})),
|
||||||
|
...(onlineSession?.players ?? [])
|
||||||
|
.filter((player) => player.accountId !== onlineSession?.localAccountId)
|
||||||
|
.map((player) => ({
|
||||||
|
id: onlinePlayerActorId(player.accountId),
|
||||||
|
name: player.character?.name ?? player.username,
|
||||||
|
role: player.role ?? "damage",
|
||||||
|
alive: player.health > 0,
|
||||||
|
})),
|
||||||
],
|
],
|
||||||
target.targetActorId,
|
target.targetActorId,
|
||||||
target.forcedTarget,
|
target.forcedTarget,
|
||||||
clock,
|
clock,
|
||||||
) : [];
|
) : [];
|
||||||
|
const onlineFriendlyAccountId = selectedFriendlyActorId
|
||||||
|
? onlineAccountIdFromActor(selectedFriendlyActorId)
|
||||||
|
: null;
|
||||||
|
const onlineFriendly = onlineFriendlyAccountId
|
||||||
|
? onlineSession?.players.find((player) => player.accountId === onlineFriendlyAccountId) ?? null
|
||||||
|
: null;
|
||||||
const selectedFriendly = selfSelected
|
const selectedFriendly = selfSelected
|
||||||
? {
|
? {
|
||||||
name: character?.name ?? "Adventurer",
|
name: character?.name ?? "Adventurer",
|
||||||
@@ -223,6 +259,7 @@ export function Hud() {
|
|||||||
health,
|
health,
|
||||||
maxHealth,
|
maxHealth,
|
||||||
self: true,
|
self: true,
|
||||||
|
targetId: PLAYER_AGGRO_ID,
|
||||||
}
|
}
|
||||||
: friendlyTarget
|
: friendlyTarget
|
||||||
? {
|
? {
|
||||||
@@ -231,6 +268,16 @@ export function Hud() {
|
|||||||
health: friendlyTarget.health,
|
health: friendlyTarget.health,
|
||||||
maxHealth: friendlyTarget.maxHealth,
|
maxHealth: friendlyTarget.maxHealth,
|
||||||
self: false,
|
self: false,
|
||||||
|
targetId: friendlyTarget.id,
|
||||||
|
}
|
||||||
|
: onlineFriendly
|
||||||
|
? {
|
||||||
|
name: onlineFriendly.character?.name ?? onlineFriendly.username,
|
||||||
|
detail: `Online player · Level ${onlineFriendly.character?.level ?? 1}`,
|
||||||
|
health: onlineFriendly.health,
|
||||||
|
maxHealth: onlineFriendly.maxHealth,
|
||||||
|
self: false,
|
||||||
|
targetId: onlinePlayerActorId(onlineFriendly.accountId),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const friendlyTargetPercent = selectedFriendly
|
const friendlyTargetPercent = selectedFriendly
|
||||||
@@ -275,10 +322,27 @@ export function Hud() {
|
|||||||
character={character ? { ...character, level } : null}
|
character={character ? { ...character, level } : null}
|
||||||
health={{ current: health, maximum: maxHealth, label: "Health", tone: "health" }}
|
health={{ current: health, maximum: maxHealth, label: "Health", tone: "health" }}
|
||||||
resource={{ current: resource, maximum: maxResource, label: resourceName, tone: resourceTone }}
|
resource={{ current: resource, maximum: maxResource, label: resourceName, tone: resourceTone }}
|
||||||
shield={shield}
|
shield={shield + namedAbsorb}
|
||||||
dead={health <= 0}
|
dead={health <= 0}
|
||||||
/>
|
/>
|
||||||
<PartyFrames />
|
<PartyFrames />
|
||||||
|
<SummonFrames now={clock} />
|
||||||
|
|
||||||
|
{pendingGroundTarget ? (
|
||||||
|
<section className="ground-target-reticle" role="status" aria-live="polite">
|
||||||
|
<div aria-hidden="true"><i /><b /></div>
|
||||||
|
<strong>Place ground effect</strong>
|
||||||
|
<small>Move with WASD / left stick. Confirm with Enter / A; Escape / B cancels.</small>
|
||||||
|
<span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!pendingGroundTarget.valid}
|
||||||
|
onClick={() => useCombatStore.getState().confirmGroundTarget()}
|
||||||
|
>Confirm</button>
|
||||||
|
<button type="button" onClick={() => useCombatStore.getState().cancelGroundTarget()}>Cancel</button>
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Minimap
|
<Minimap
|
||||||
position={playerPosition}
|
position={playerPosition}
|
||||||
@@ -325,11 +389,11 @@ export function Hud() {
|
|||||||
</header>
|
</header>
|
||||||
<div className="target-frame__track"><span style={{ width: `${friendlyTargetPercent}%` }} /></div>
|
<div className="target-frame__track"><span style={{ width: `${friendlyTargetPercent}%` }} /></div>
|
||||||
<TimedEffectStrip
|
<TimedEffectStrip
|
||||||
targetId={selectedFriendly.self ? null : friendlyTarget?.id ?? null}
|
targetId={selectedFriendly.self ? null : selectedFriendly.targetId}
|
||||||
className="timed-effect-strip--target"
|
className="timed-effect-strip--target"
|
||||||
/>
|
/>
|
||||||
<AuraStrip
|
<AuraStrip
|
||||||
targetId={selectedFriendly.self ? PLAYER_AGGRO_ID : friendlyTarget?.id ?? PLAYER_AGGRO_ID}
|
targetId={selectedFriendly.targetId}
|
||||||
className="aura-strip--target"
|
className="aura-strip--target"
|
||||||
/>
|
/>
|
||||||
<small>{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}</small>
|
<small>{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}</small>
|
||||||
@@ -384,6 +448,7 @@ export function Hud() {
|
|||||||
gameMode={gameMode}
|
gameMode={gameMode}
|
||||||
resurrectionCharges={resurrectionCharges}
|
resurrectionCharges={resurrectionCharges}
|
||||||
onReturn={returnFromDefeat}
|
onReturn={returnFromDefeat}
|
||||||
|
sharedOnline={onlineSession !== null}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ControllerButton } from "./ControllerButton";
|
|||||||
import { ContentManagerScreen } from "./ContentManagerScreen";
|
import { ContentManagerScreen } from "./ContentManagerScreen";
|
||||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||||
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
import { isGameMasterSession } from "../game/manastormAdminConfig";
|
||||||
|
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||||
|
|
||||||
const GAME_MODES = [
|
const GAME_MODES = [
|
||||||
{
|
{
|
||||||
@@ -156,6 +157,7 @@ export function MainMenuScreen() {
|
|||||||
<h2>Ways to play</h2>
|
<h2>Ways to play</h2>
|
||||||
<p>Select a destination. More adventures will unlock here as they are built.</p>
|
<p>Select a destination. More adventures will unlock here as they are built.</p>
|
||||||
</header>
|
</header>
|
||||||
|
<OnlineGroupPanel />
|
||||||
<nav className="main-menu-mode-grid" aria-label="Game modes">
|
<nav className="main-menu-mode-grid" aria-label="Game modes">
|
||||||
{isGm && (
|
{isGm && (
|
||||||
<ControllerButton
|
<ControllerButton
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { useMenuController, type MenuAction } from "../input/useMenuController";
|
|||||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||||
import { ControllerButton } from "./ControllerButton";
|
import { ControllerButton } from "./ControllerButton";
|
||||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||||
|
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||||
|
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||||
|
|
||||||
installManastormShellRuntime();
|
installManastormShellRuntime();
|
||||||
|
|
||||||
@@ -49,10 +51,19 @@ export function ManastormSelectScreen() {
|
|||||||
const saveSelectedManastormLoadout = useShellStore((state) => state.saveSelectedManastormLoadout);
|
const saveSelectedManastormLoadout = useShellStore((state) => state.saveSelectedManastormLoadout);
|
||||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||||
const notice = useShellStore((state) => state.notice);
|
const notice = useShellStore((state) => state.notice);
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
|
||||||
|
const fillWithAi = useOnlineGroupStore((state) => state.fillWithAi);
|
||||||
|
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
|
||||||
|
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
|
||||||
const adminConfig = useManastormAdminStore((state) => state.config);
|
const adminConfig = useManastormAdminStore((state) => state.config);
|
||||||
const character = activeCharacter ?? rosterCharacter;
|
const character = activeCharacter ?? rosterCharacter;
|
||||||
const availableRoles = character ? partyRolesForClass(character.classId) : [];
|
const availableRoles = character ? partyRolesForClass(character.classId) : [];
|
||||||
const [partySize, setPartySize] = useState<ManastormPartySize>(5);
|
const [partySize, setPartySize] = useState<ManastormPartySize>(5);
|
||||||
|
const groupHumanCount = onlineGroup?.members.length ?? 1;
|
||||||
|
const effectivePartySize = onlineGroup
|
||||||
|
? (fillWithAi ? Math.max(partySize, groupHumanCount) : groupHumanCount) as ManastormPartySize
|
||||||
|
: partySize;
|
||||||
const [resume, setResume] = useState(true);
|
const [resume, setResume] = useState(true);
|
||||||
const progress = character?.manastormProgress ?? createEmptyManastormProgress();
|
const progress = character?.manastormProgress ?? createEmptyManastormProgress();
|
||||||
const effectiveCatalog = useMemo(
|
const effectiveCatalog = useMemo(
|
||||||
@@ -71,7 +82,7 @@ export function ManastormSelectScreen() {
|
|||||||
const canEnter = modeEnabled && hasReadyBosses;
|
const canEnter = modeEnabled && hasReadyBosses;
|
||||||
const modeProgress = manastormModeProgress(progress, modeId);
|
const modeProgress = manastormModeProgress(progress, modeId);
|
||||||
const modeLabel = modeId === 2 ? "End-game" : "Leveling";
|
const modeLabel = modeId === 2 ? "End-game" : "Leveling";
|
||||||
const partyProgress = manastormPartyProgress(progress, partySize, modeId);
|
const partyProgress = manastormPartyProgress(progress, effectivePartySize, modeId);
|
||||||
const checkpoint = Math.max(1, partyProgress.checkpointLevel);
|
const checkpoint = Math.max(1, partyProgress.checkpointLevel);
|
||||||
const startingLevel = resume && checkpoint > 1 ? checkpoint : 1;
|
const startingLevel = resume && checkpoint > 1 ? checkpoint : 1;
|
||||||
const cacheChance = modeId === 2
|
const cacheChance = modeId === 2
|
||||||
@@ -81,7 +92,7 @@ export function ManastormSelectScreen() {
|
|||||||
try {
|
try {
|
||||||
const preview = drawManastormStage(
|
const preview = drawManastormStage(
|
||||||
startingLevel,
|
startingLevel,
|
||||||
partySize,
|
effectivePartySize,
|
||||||
[],
|
[],
|
||||||
null,
|
null,
|
||||||
() => 0.5,
|
() => 0.5,
|
||||||
@@ -92,14 +103,14 @@ export function ManastormSelectScreen() {
|
|||||||
return manastormReward(
|
return manastormReward(
|
||||||
preview.encounter,
|
preview.encounter,
|
||||||
startingLevel,
|
startingLevel,
|
||||||
partySize,
|
effectivePartySize,
|
||||||
effectiveCatalog,
|
effectiveCatalog,
|
||||||
modeId,
|
modeId,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [effectiveCatalog, modeId, partyProgress.highestLevel, partySize, startingLevel]);
|
}, [effectiveCatalog, effectivePartySize, modeId, partyProgress.highestLevel, startingLevel]);
|
||||||
const spellLoadoutModel = useMemo(() => character
|
const spellLoadoutModel = useMemo(() => character
|
||||||
? manastormActiveSpellLoadoutModel(character.classId, character.level, character.talentRanks)
|
? manastormActiveSpellLoadoutModel(character.classId, character.level, character.talentRanks)
|
||||||
: null, [character?.classId, character?.level, character?.talentRanks]);
|
: null, [character?.classId, character?.level, character?.talentRanks]);
|
||||||
@@ -132,7 +143,7 @@ export function ManastormSelectScreen() {
|
|||||||
character.id,
|
character.id,
|
||||||
character.level,
|
character.level,
|
||||||
role,
|
role,
|
||||||
partySize,
|
effectivePartySize,
|
||||||
character.categoryId ?? "wow",
|
character.categoryId ?? "wow",
|
||||||
);
|
);
|
||||||
void preloadAvatarModels([character, ...party]).catch((error) => {
|
void preloadAvatarModels([character, ...party]).catch((error) => {
|
||||||
@@ -140,10 +151,30 @@ export function ManastormSelectScreen() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const enterAsRole = async (role: PartyRole) => {
|
||||||
|
if (!canEnter || !character) return;
|
||||||
|
if (!onlineGroup || !session || session.kind !== "account") {
|
||||||
|
enterManastorm(role, partySize, startingLevel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await updateOnlineMember(session, character, role)) return;
|
||||||
|
if (onlineGroup.leaderAccountId !== session.ownerId) {
|
||||||
|
useShellStore.getState().setNotice(`Ready as ${PARTY_ROLE_LABELS[role]}. Waiting for the group leader to start.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await startOnlineActivity(session, {
|
||||||
|
type: "manastorm",
|
||||||
|
contentId: "manastorm",
|
||||||
|
partySize: effectivePartySize,
|
||||||
|
startingLevel,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const actions = useMemo<MenuAction[]>(() => [
|
const actions = useMemo<MenuAction[]>(() => [
|
||||||
...MANASTORM_PARTY_SIZES.map((size) => ({
|
...MANASTORM_PARTY_SIZES.map((size) => ({
|
||||||
id: `manastorm-party-${size}`,
|
id: `manastorm-party-${size}`,
|
||||||
run: () => setPartySize(size),
|
run: () => setPartySize(size),
|
||||||
|
enabled: !onlineGroup || (size >= groupHumanCount && (fillWithAi || size === groupHumanCount)),
|
||||||
})),
|
})),
|
||||||
{ id: "manastorm-start-fresh", run: () => setResume(false) },
|
{ id: "manastorm-start-fresh", run: () => setResume(false) },
|
||||||
...(checkpoint > 1 ? [{ id: "manastorm-start-resume", run: () => setResume(true) }] : []),
|
...(checkpoint > 1 ? [{ id: "manastorm-start-resume", run: () => setResume(true) }] : []),
|
||||||
@@ -153,7 +184,7 @@ export function ManastormSelectScreen() {
|
|||||||
})) ?? []),
|
})) ?? []),
|
||||||
...availableRoles.map((role) => ({
|
...availableRoles.map((role) => ({
|
||||||
id: `manastorm-role-${role}`,
|
id: `manastorm-role-${role}`,
|
||||||
run: () => canEnter && enterManastorm(role, partySize, startingLevel),
|
run: () => { if (canEnter) void enterAsRole(role); },
|
||||||
enabled: canEnter,
|
enabled: canEnter,
|
||||||
})),
|
})),
|
||||||
{ id: "manastorm-back", run: returnToMainMenu },
|
{ id: "manastorm-back", run: returnToMainMenu },
|
||||||
@@ -161,8 +192,10 @@ export function ManastormSelectScreen() {
|
|||||||
availableRoles,
|
availableRoles,
|
||||||
canEnter,
|
canEnter,
|
||||||
checkpoint,
|
checkpoint,
|
||||||
enterManastorm,
|
groupHumanCount,
|
||||||
partySize,
|
fillWithAi,
|
||||||
|
onlineGroup,
|
||||||
|
effectivePartySize,
|
||||||
returnToMainMenu,
|
returnToMainMenu,
|
||||||
selectedSpellIds,
|
selectedSpellIds,
|
||||||
spellLoadoutModel,
|
spellLoadoutModel,
|
||||||
@@ -235,6 +268,7 @@ export function ManastormSelectScreen() {
|
|||||||
<h2 id="manastorm-role-title">Build your expedition</h2>
|
<h2 id="manastorm-role-title">Build your expedition</h2>
|
||||||
<small>Progress and first clears are tracked separately for every party size.</small>
|
<small>Progress and first clears are tracked separately for every party size.</small>
|
||||||
</header>
|
</header>
|
||||||
|
<OnlineGroupPanel />
|
||||||
|
|
||||||
<div className="manastorm-setup-block">
|
<div className="manastorm-setup-block">
|
||||||
<strong>Party size</strong>
|
<strong>Party size</strong>
|
||||||
@@ -245,8 +279,9 @@ export function ManastormSelectScreen() {
|
|||||||
controlId={`manastorm-party-${size}`}
|
controlId={`manastorm-party-${size}`}
|
||||||
selectedId={controller.selectedId}
|
selectedId={controller.selectedId}
|
||||||
select={controller.select}
|
select={controller.select}
|
||||||
className={`manastorm-size-option ${partySize === size ? "is-active" : ""}`}
|
className={`manastorm-size-option ${effectivePartySize === size ? "is-active" : ""}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={Boolean(onlineGroup && (size < groupHumanCount || (!fillWithAi && size !== groupHumanCount)))}
|
||||||
onClick={() => setPartySize(size)}
|
onClick={() => setPartySize(size)}
|
||||||
>
|
>
|
||||||
{size}
|
{size}
|
||||||
@@ -254,7 +289,11 @@ export function ManastormSelectScreen() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<small>
|
<small>
|
||||||
{partySize === 1 ? "Solo" : `You + ${partySize - 1} companion${partySize === 2 ? "" : "s"}`}
|
{onlineGroup
|
||||||
|
? fillWithAi
|
||||||
|
? `${groupHumanCount} player${groupHumanCount === 1 ? "" : "s"} + ${Math.max(0, effectivePartySize - groupHumanCount)} AI teammate${effectivePartySize - groupHumanCount === 1 ? "" : "s"}`
|
||||||
|
: `${groupHumanCount} grouped player${groupHumanCount === 1 ? "" : "s"} · AI fill off`
|
||||||
|
: partySize === 1 ? "Solo" : `You + ${partySize - 1} companion${partySize === 2 ? "" : "s"}`}
|
||||||
{" · "}Highest clear {partyProgress.highestLevel}
|
{" · "}Highest clear {partyProgress.highestLevel}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,11 +384,11 @@ export function ManastormSelectScreen() {
|
|||||||
disabled={!canEnter}
|
disabled={!canEnter}
|
||||||
onFocus={() => preloadRole(role)}
|
onFocus={() => preloadRole(role)}
|
||||||
onPointerEnter={() => preloadRole(role)}
|
onPointerEnter={() => preloadRole(role)}
|
||||||
onClick={() => canEnter && enterManastorm(role, partySize, startingLevel)}
|
onClick={() => { if (canEnter) void enterAsRole(role); }}
|
||||||
>
|
>
|
||||||
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
||||||
<span>
|
<span>
|
||||||
<strong>Enter as {PARTY_ROLE_LABELS[role]}</strong>
|
<strong>{onlineGroup && session?.ownerId !== onlineGroup.leaderAccountId ? "Ready" : "Enter"} as {PARTY_ROLE_LABELS[role]}</strong>
|
||||||
<small>{ROLE_DETAILS[role].summary}</small>
|
<small>{ROLE_DETAILS[role].summary}</small>
|
||||||
</span>
|
</span>
|
||||||
</ControllerButton>
|
</ControllerButton>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { classById } from "../app/characterCatalog";
|
||||||
|
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||||
|
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||||
|
import {
|
||||||
|
classCanFillPartyRole,
|
||||||
|
partyRolesForClass,
|
||||||
|
PARTY_ROLE_LABELS,
|
||||||
|
type PartyRole,
|
||||||
|
} from "../game/partyRoles";
|
||||||
|
|
||||||
|
export function OnlineGroupPanel() {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
const session = useShellStore((state) => state.session);
|
||||||
|
const character = useShellStore(selectedCharacter);
|
||||||
|
const snapshot = useOnlineGroupStore((state) => state.snapshot);
|
||||||
|
const busy = useOnlineGroupStore((state) => state.busy);
|
||||||
|
const error = useOnlineGroupStore((state) => state.error);
|
||||||
|
const fillWithAi = useOnlineGroupStore((state) => state.fillWithAi);
|
||||||
|
const invite = useOnlineGroupStore((state) => state.invite);
|
||||||
|
const respond = useOnlineGroupStore((state) => state.respond);
|
||||||
|
const leave = useOnlineGroupStore((state) => state.leave);
|
||||||
|
const remove = useOnlineGroupStore((state) => state.remove);
|
||||||
|
const updateMember = useOnlineGroupStore((state) => state.updateMember);
|
||||||
|
const clearActivity = useOnlineGroupStore((state) => state.clearActivity);
|
||||||
|
const setFillWithAi = useOnlineGroupStore((state) => state.setFillWithAi);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (snapshot.invitations.length > 0) setPanelOpen(true);
|
||||||
|
}, [snapshot.invitations.length]);
|
||||||
|
|
||||||
|
if (!session || session.kind !== "account" || !session.accessToken) return null;
|
||||||
|
const group = snapshot.group;
|
||||||
|
const currentMember = group?.members.find((member) => member.accountId === session.ownerId) ?? null;
|
||||||
|
const leader = group?.leaderAccountId === session.ownerId;
|
||||||
|
const roles = character ? partyRolesForClass(character.classId) : [];
|
||||||
|
const readyCount = group?.members.filter((member) => member.character && member.role).length ?? 0;
|
||||||
|
|
||||||
|
const submitInvite = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!username.trim()) return;
|
||||||
|
if (await invite(session, username.trim())) setUsername("");
|
||||||
|
};
|
||||||
|
const chooseRole = (role: PartyRole) => {
|
||||||
|
if (character && classCanFillPartyRole(character.classId, role)) void updateMember(session, character, role);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="online-group-panel" open={panelOpen} onToggle={(event) => setPanelOpen(event.currentTarget.open)}>
|
||||||
|
<summary>
|
||||||
|
<span aria-hidden="true">◎</span>
|
||||||
|
<strong>Online group</strong>
|
||||||
|
<small>{group ? `${group.members.length} / 5 players · ${readyCount} ready` : "Optional co-op"}</small>
|
||||||
|
</summary>
|
||||||
|
<div className="online-group-panel__body">
|
||||||
|
{snapshot.invitations.length > 0 && (
|
||||||
|
<section className="online-group-invites" aria-label="Group invitations">
|
||||||
|
<strong>Invitations</strong>
|
||||||
|
{snapshot.invitations.map((invitation) => (
|
||||||
|
<div key={invitation.id}>
|
||||||
|
<span><b>{invitation.inviterUsername}</b><small>wants you in their group</small></span>
|
||||||
|
<button type="button" disabled={busy} onClick={() => void respond(session, invitation.id, true)}>Accept</button>
|
||||||
|
<button type="button" disabled={busy} onClick={() => void respond(session, invitation.id, false)}>Decline</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{group && (
|
||||||
|
<section className="online-group-roster" aria-label="Online group roster">
|
||||||
|
<header><strong>Party roster</strong><small>{leader ? "You are the leader" : "Waiting for the leader"}</small></header>
|
||||||
|
{group.members.map((member) => {
|
||||||
|
const memberClass = member.character ? classById(member.character.classId as Parameters<typeof classById>[0]) : null;
|
||||||
|
return (
|
||||||
|
<div className="online-group-member" key={member.accountId}>
|
||||||
|
<span className={member.character && member.role ? "is-ready" : ""} aria-hidden="true" />
|
||||||
|
<p>
|
||||||
|
<strong>{member.username}{group.leaderAccountId === member.accountId ? " ★" : ""}</strong>
|
||||||
|
<small>{member.character
|
||||||
|
? `${member.character.name} · Level ${member.character.level} ${memberClass?.name ?? member.character.classId}`
|
||||||
|
: "Choosing a character"}</small>
|
||||||
|
</p>
|
||||||
|
<b>{member.role ? PARTY_ROLE_LABELS[member.role] : "Not ready"}</b>
|
||||||
|
{leader && member.accountId !== session.ownerId && (
|
||||||
|
<button type="button" disabled={busy} onClick={() => void remove(session, member.accountId)}>Remove</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{group && character && currentMember && (
|
||||||
|
<section className="online-group-role" aria-label="Your group role">
|
||||||
|
<strong>Your role</strong>
|
||||||
|
<div>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={role}
|
||||||
|
className={currentMember.role === role ? "is-active" : ""}
|
||||||
|
disabled={busy || Boolean(group.activity)}
|
||||||
|
aria-pressed={currentMember.role === role}
|
||||||
|
onClick={() => chooseRole(role)}
|
||||||
|
>
|
||||||
|
{PARTY_ROLE_LABELS[role]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{group?.activity && (
|
||||||
|
<div className="online-group-activity" role="status">
|
||||||
|
<span>
|
||||||
|
<strong>Launching {group.activity.type === "dungeon" ? "dungeon" : "Manastorm"}</strong>
|
||||||
|
<small>{group.activity.fillWithAi ? `Party filled to ${group.activity.partySize} with AI` : `${group.activity.partySize} players only`}</small>
|
||||||
|
</span>
|
||||||
|
{leader && <button type="button" disabled={busy} onClick={() => void clearActivity(session)}>Reset</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{leader && group && !group.activity && (
|
||||||
|
<label className="online-group-ai-toggle">
|
||||||
|
<input type="checkbox" checked={fillWithAi} onChange={(event) => setFillWithAi(event.target.checked)} />
|
||||||
|
<span><strong>Fill open slots with AI</strong><small>Turn off to enter with only grouped players.</small></span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!group || leader) && (
|
||||||
|
<form className="online-group-invite-form" onSubmit={submitInvite}>
|
||||||
|
<label htmlFor="online-group-username">Invite account name</label>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id="online-group-username"
|
||||||
|
value={username}
|
||||||
|
maxLength={20}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="PlayerName"
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={busy || !username.trim() || (group?.members.length ?? 0) >= 5}>Invite</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{group && (
|
||||||
|
<button className="online-group-leave" type="button" disabled={busy} onClick={() => void leave(session)}>
|
||||||
|
{leader && group.members.length > 1 ? "Leave and transfer leadership" : "Leave group"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{error && <p className="online-group-error" role="alert">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
+117
-3
@@ -11,6 +11,11 @@ import {
|
|||||||
import { TimedEffectStrip } from "./TimedEffectStrip";
|
import { TimedEffectStrip } from "./TimedEffectStrip";
|
||||||
import { AuraStrip } from "./AuraStrip";
|
import { AuraStrip } from "./AuraStrip";
|
||||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||||
|
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||||
|
import type { OnlineGroupMember } from "../app/onlineGroupTypes";
|
||||||
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||||
|
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
|
||||||
|
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
|
||||||
|
|
||||||
const PARTY_COMMANDS: readonly {
|
const PARTY_COMMANDS: readonly {
|
||||||
command: PartyCommand;
|
command: PartyCommand;
|
||||||
@@ -107,6 +112,22 @@ function MemberFrame({ member, selected, onSelect }: {
|
|||||||
const characterClass = classById(member.classId);
|
const characterClass = classById(member.classId);
|
||||||
const race = raceById(member.raceId);
|
const race = raceById(member.raceId);
|
||||||
const percent = healthPercent(member.health, member.maxHealth);
|
const percent = healthPercent(member.health, member.maxHealth);
|
||||||
|
const absorb = useCombatStore((state) => state.auras.reduce((total, aura) => (
|
||||||
|
aura.targetId === member.id
|
||||||
|
? total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0)
|
||||||
|
: total
|
||||||
|
), 0));
|
||||||
|
const absorbPercent = Math.min(100 - percent, healthPercent(absorb, member.maxHealth));
|
||||||
|
const dispellableCount = useCombatStore((state) => state.auras.reduce((total, aura) => (
|
||||||
|
aura.targetId === member.id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory
|
||||||
|
? total + 1
|
||||||
|
: total
|
||||||
|
), 0));
|
||||||
|
const dispellableNames = useCombatStore((state) => state.auras
|
||||||
|
.filter((aura) => aura.targetId === member.id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory)
|
||||||
|
.map((aura) => aura.definition.name)
|
||||||
|
.join(", "));
|
||||||
|
const resourcePercent = healthPercent(member.resource, member.maxResource);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -118,10 +139,15 @@ function MemberFrame({ member, selected, onSelect }: {
|
|||||||
<span className="party-frame__sigil" style={{ "--party-class-color": characterClass.color } as CSSProperties}>{characterClass.sigil}</span>
|
<span className="party-frame__sigil" style={{ "--party-class-color": characterClass.color } as CSSProperties}>{characterClass.sigil}</span>
|
||||||
<span className="party-frame__body">
|
<span className="party-frame__body">
|
||||||
<span className="party-frame__identity"><strong>{member.name}</strong><small>{PARTY_ROLE_LABELS[member.role]} · {race.name} · {member.specialization}</small></span>
|
<span className="party-frame__identity"><strong>{member.name}</strong><small>{PARTY_ROLE_LABELS[member.role]} · {race.name} · {member.specialization}</small></span>
|
||||||
|
{dispellableCount ? <span className="party-frame__dispel" title={dispellableNames}>DISPEL {dispellableCount}</span> : null}
|
||||||
<span className="party-frame__health" role="progressbar" aria-label={`${member.name} health`} aria-valuemin={0} aria-valuemax={member.maxHealth} aria-valuenow={Math.max(0, member.health)}>
|
<span className="party-frame__health" role="progressbar" aria-label={`${member.name} health`} aria-valuemin={0} aria-valuemax={member.maxHealth} aria-valuenow={Math.max(0, member.health)}>
|
||||||
<i style={{ width: `${percent}%` }} />
|
<i style={{ width: `${percent}%` }} />
|
||||||
|
{absorb > 0 ? <em style={{ left: `${percent}%`, width: `${absorbPercent}%` }} /> : null}
|
||||||
<b>{Math.ceil(member.health)} / {member.maxHealth}</b>
|
<b>{Math.ceil(member.health)} / {member.maxHealth}</b>
|
||||||
</span>
|
</span>
|
||||||
|
<span className={`party-frame__resource party-frame__resource--${member.resourceType}`} title={`${Math.floor(member.resource)} / ${member.maxResource} ${member.resourceName}`}>
|
||||||
|
<i style={{ width: `${resourcePercent}%` }} />
|
||||||
|
</span>
|
||||||
<TimedEffectStrip targetId={member.id} className="timed-effect-strip--party" />
|
<TimedEffectStrip targetId={member.id} className="timed-effect-strip--party" />
|
||||||
<AuraStrip targetId={member.id} className="aura-strip--party" />
|
<AuraStrip targetId={member.id} className="aura-strip--party" />
|
||||||
</span>
|
</span>
|
||||||
@@ -129,19 +155,85 @@ function MemberFrame({ member, selected, onSelect }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OnlineMemberFrame({
|
||||||
|
member,
|
||||||
|
player,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
member: OnlineGroupMember;
|
||||||
|
player: OnlineSessionPlayer | null;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const character = member.character;
|
||||||
|
if (!character) return null;
|
||||||
|
const characterClass = classById(character.classId as Parameters<typeof classById>[0]);
|
||||||
|
const percent = player ? healthPercent(player.health, player.maxHealth) : 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`party-frame party-frame--online ${selected ? "is-selected" : ""} ${player && player.health <= 0 ? "is-down" : ""}`}
|
||||||
|
aria-label={`Target ${character.name}, online player`}
|
||||||
|
aria-pressed={selected}
|
||||||
|
disabled={!player}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<span className="party-frame__sigil" style={{ "--party-class-color": characterClass.color } as CSSProperties}>{characterClass.sigil}</span>
|
||||||
|
<span className="party-frame__body">
|
||||||
|
<span className="party-frame__identity"><strong>{character.name}</strong><small>{member.role ? PARTY_ROLE_LABELS[member.role] : "Player"} · {member.username}</small></span>
|
||||||
|
{player ? (
|
||||||
|
<>
|
||||||
|
<span className="party-frame__health" role="progressbar" aria-label={`${character.name} health`} aria-valuemin={0} aria-valuemax={player.maxHealth} aria-valuenow={Math.max(0, player.health)}>
|
||||||
|
<i style={{ width: `${percent}%` }} />
|
||||||
|
<b>{Math.ceil(player.health)} / {player.maxHealth}</b>
|
||||||
|
</span>
|
||||||
|
<span className="party-frame__resource" title={`${Math.floor(player.resource)} / ${player.maxResource} ${player.resourceName}`}>
|
||||||
|
<i style={{ width: `${healthPercent(player.resource, player.maxResource)}%` }} />
|
||||||
|
</span>
|
||||||
|
<span className="party-frame__online-status"><i /> Live</span>
|
||||||
|
</>
|
||||||
|
) : <span className="party-frame__online-status">Connecting…</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Compact roster for the player plus the selected number of targetable AI allies. */
|
/** Compact roster for the player plus the selected number of targetable AI allies. */
|
||||||
export function PartyFrames() {
|
export function PartyFrames() {
|
||||||
const character = useShellStore((state) => state.activeCharacter);
|
const character = useShellStore((state) => state.activeCharacter);
|
||||||
const playerRole = useShellStore((state) => state.activeDungeonRole);
|
const playerRole = useShellStore((state) => state.activeDungeonRole);
|
||||||
const playerHealth = useCombatStore((state) => state.health);
|
const playerHealth = useCombatStore((state) => state.health);
|
||||||
const playerMaxHealth = useCombatStore((state) => state.maxHealth);
|
const playerMaxHealth = useCombatStore((state) => state.maxHealth);
|
||||||
|
const playerResource = useCombatStore((state) => state.resource);
|
||||||
|
const playerMaxResource = useCombatStore((state) => state.maxResource);
|
||||||
|
const playerResourceName = useCombatStore((state) => state.resourceName);
|
||||||
|
const playerAuraAbsorb = useCombatStore((state) => state.auras.reduce((total, aura) => (
|
||||||
|
aura.targetId === PLAYER_AGGRO_ID
|
||||||
|
? total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0)
|
||||||
|
: total
|
||||||
|
), 0));
|
||||||
|
const playerShield = useCombatStore((state) => state.shield);
|
||||||
const members = usePartyStore((state) => state.members);
|
const members = usePartyStore((state) => state.members);
|
||||||
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
|
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
|
||||||
const selfSelected = usePartyStore((state) => state.selfSelected);
|
const selfSelected = usePartyStore((state) => state.selfSelected);
|
||||||
const routeBlocked = usePartyStore((state) => state.routeBlocked);
|
const routeBlocked = usePartyStore((state) => state.routeBlocked);
|
||||||
|
const sessionOwnerId = useShellStore((state) => state.session?.ownerId ?? null);
|
||||||
|
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
|
||||||
|
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||||
|
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
|
||||||
|
const selectedOnlineActorId = useCombatStore((state) => state.selectedFriendlyActorId);
|
||||||
const playerPercent = healthPercent(playerHealth, playerMaxHealth);
|
const playerPercent = healthPercent(playerHealth, playerMaxHealth);
|
||||||
if (!members.length) return null;
|
const playerAbsorb = playerShield + playerAuraAbsorb;
|
||||||
const groupSize = members.length + 1;
|
const playerAbsorbPercent = Math.min(100 - playerPercent, healthPercent(playerAbsorb, playerMaxHealth));
|
||||||
|
const onlineMembers = onlineActivity && onlineGroup
|
||||||
|
? onlineGroup.members.filter((member) => member.accountId !== sessionOwnerId && member.character)
|
||||||
|
: [];
|
||||||
|
const onlinePlayersByAccount = new Map(
|
||||||
|
(onlineSession?.players ?? []).map((player) => [player.accountId, player]),
|
||||||
|
);
|
||||||
|
if (!members.length && !onlineMembers.length) return null;
|
||||||
|
const groupSize = members.length + onlineMembers.length + 1;
|
||||||
|
|
||||||
const selectMember = (id: string) => {
|
const selectMember = (id: string) => {
|
||||||
useCombatStore.getState().clearTarget();
|
useCombatStore.getState().clearTarget();
|
||||||
@@ -153,10 +245,16 @@ export function PartyFrames() {
|
|||||||
usePartyStore.getState().selectSelf();
|
usePartyStore.getState().selectSelf();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectOnlineMember = (accountId: string) => {
|
||||||
|
useCombatStore.getState().clearTarget();
|
||||||
|
usePartyStore.getState().clearSelection();
|
||||||
|
useCombatStore.getState().selectFriendlyActor(onlinePlayerActorId(accountId));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="party-frames" aria-label={`${groupSize} player party`}>
|
<section className="party-frames" aria-label={`${groupSize} player party`}>
|
||||||
<header><span>Party</span><b className={routeBlocked ? "is-warning" : ""}>{routeBlocked ? "Route blocked" : `${groupSize} / ${groupSize}`}</b></header>
|
<header><span>Party</span><b className={routeBlocked ? "is-warning" : ""}>{routeBlocked ? "Route blocked" : `${groupSize} / ${groupSize}`}</b></header>
|
||||||
<PartyCommandBar />
|
{members.length ? <PartyCommandBar /> : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`party-frame party-frame--player ${selfSelected ? "is-selected" : ""} ${playerHealth <= 0 ? "is-down" : ""}`}
|
className={`party-frame party-frame--player ${selfSelected ? "is-selected" : ""} ${playerHealth <= 0 ? "is-down" : ""}`}
|
||||||
@@ -169,12 +267,28 @@ export function PartyFrames() {
|
|||||||
<span className="party-frame__identity"><strong>{character?.name ?? "Adventurer"}</strong><small>You · {PARTY_ROLE_LABELS[playerRole]}</small></span>
|
<span className="party-frame__identity"><strong>{character?.name ?? "Adventurer"}</strong><small>You · {PARTY_ROLE_LABELS[playerRole]}</small></span>
|
||||||
<span className="party-frame__health" role="progressbar" aria-label="Player health" aria-valuemin={0} aria-valuemax={playerMaxHealth} aria-valuenow={Math.max(0, playerHealth)}>
|
<span className="party-frame__health" role="progressbar" aria-label="Player health" aria-valuemin={0} aria-valuemax={playerMaxHealth} aria-valuenow={Math.max(0, playerHealth)}>
|
||||||
<i style={{ width: `${playerPercent}%` }} />
|
<i style={{ width: `${playerPercent}%` }} />
|
||||||
|
{playerAbsorb > 0 ? <em style={{ left: `${playerPercent}%`, width: `${playerAbsorbPercent}%` }} /> : null}
|
||||||
<b>{Math.ceil(playerHealth)} / {playerMaxHealth}</b>
|
<b>{Math.ceil(playerHealth)} / {playerMaxHealth}</b>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="party-frame__resource" title={`${Math.floor(playerResource)} / ${playerMaxResource} ${playerResourceName}`}>
|
||||||
|
<i style={{ width: `${healthPercent(playerResource, playerMaxResource)}%` }} />
|
||||||
|
</span>
|
||||||
<TimedEffectStrip targetId={null} className="timed-effect-strip--party" />
|
<TimedEffectStrip targetId={null} className="timed-effect-strip--party" />
|
||||||
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" />
|
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{onlineMembers.map((member) => {
|
||||||
|
const actorId = onlinePlayerActorId(member.accountId);
|
||||||
|
return (
|
||||||
|
<OnlineMemberFrame
|
||||||
|
key={member.accountId}
|
||||||
|
member={member}
|
||||||
|
player={onlinePlayersByAccount.get(member.accountId) ?? null}
|
||||||
|
selected={selectedOnlineActorId === actorId}
|
||||||
|
onSelect={() => selectOnlineMember(member.accountId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<MemberFrame
|
<MemberFrame
|
||||||
key={member.id}
|
key={member.id}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export interface PlayerFrameMeter {
|
|||||||
current: number;
|
current: number;
|
||||||
maximum: number;
|
maximum: number;
|
||||||
label: string;
|
label: string;
|
||||||
tone: "health" | "mana" | "rage" | "energy" | "runic-power" | "focus" | "nature-power" | "psi";
|
tone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerFrameProps {
|
export interface PlayerFrameProps {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useCombatStore } from "../game/combatStore";
|
||||||
|
|
||||||
|
export function SummonFrames({ now }: { readonly now: number }) {
|
||||||
|
const summons = useCombatStore((state) => state.summons);
|
||||||
|
const selectedFriendlyActorId = useCombatStore((state) => state.selectedFriendlyActorId);
|
||||||
|
const selectFriendlyActor = useCombatStore((state) => state.selectFriendlyActor);
|
||||||
|
if (!summons.length) return null;
|
||||||
|
return (
|
||||||
|
<section className="summon-frames" aria-label="Pets, totems, and summons">
|
||||||
|
{summons.map((summon) => {
|
||||||
|
const healthPercent = summon.maxHealth > 0 ? Math.max(0, Math.min(100, summon.health / summon.maxHealth * 100)) : 0;
|
||||||
|
const remaining = summon.expiresAt >= Number.MAX_SAFE_INTEGER
|
||||||
|
? "∞"
|
||||||
|
: `${Math.max(0, Math.ceil((summon.expiresAt - now) / 1_000))}s`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`summon-frame summon-frame--${summon.kind}${selectedFriendlyActorId === summon.id ? " is-selected" : ""}`}
|
||||||
|
key={summon.id}
|
||||||
|
aria-pressed={selectedFriendlyActorId === summon.id}
|
||||||
|
onClick={() => selectFriendlyActor(selectedFriendlyActorId === summon.id ? null : summon.id)}
|
||||||
|
>
|
||||||
|
<header><strong>{summon.name}</strong><span>{summon.kind} · {remaining}</span></header>
|
||||||
|
<div><i style={{ width: `${healthPercent}%` }} /></div>
|
||||||
|
<small>{Math.ceil(summon.health)} / {summon.maxHealth}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user