Release Healer Man 0.1.6

This commit is contained in:
phenom
2026-08-20 14:05:50 -04:00
parent 55cfd43d66
commit b12fb84859
96 changed files with 10874 additions and 609 deletions
+10
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
+6
View File
@@ -0,0 +1,6 @@
{
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
"files.trimTrailingWhitespace": true
}
+7 -1
View File
@@ -2,6 +2,13 @@
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
- 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`;
- releasing right mouse stops camera motion;
- no browser mouse-capture prompt appears.
+2 -1
View File
@@ -185,6 +185,7 @@ on the development PC. Stop if any test or build fails:
```powershell
Set-Location F:\Projects\HealerMan
npm run release:check
$VersionName = '0.1.3'
$VersionCode = 1003
@@ -206,8 +207,8 @@ npm run build
npm test -- --run src/avatar
npx vitest run src/game/inputManager.test.ts src/game/inputMath.test.ts
npm run release:check
git status --short
git diff --check
git add -A
git status --short
git diff --cached --stat
+30
View File
@@ -1421,3 +1421,33 @@ Vite's existing large-chunk advisory remains informational.
- Verification: 38 focused party/pathing/jump/animation tests passed, all 28
avatar tests passed, and `npm run build` completed successfully with 954
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
View File
@@ -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
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
`healer-man-release-password.dpapi` file is present, blank signing-password
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:
```powershell
git diff --check
npm run release:check
git status --short
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:
```powershell
@@ -520,7 +538,7 @@ Set-Location F:\Projects\HealerMan
npm run content:test
npm run build
git status --short
git diff --check
npm run release:check
git add -A
git status --short
git diff --cached --stat
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.5",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.5",
"version": "0.1.6",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"scripts": {
"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: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",
"release:check": "python scripts/release_manager.py --check-release",
"release:gui": "python scripts/release_manager.py",
"preview": "vite preview --host 0.0.0.0",
"pretest": "npm run loot:generate",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.5",
"versionCode": 1005
"versionName": "0.1.6",
"versionCode": 1006
}
+194
View File
@@ -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 {
COA_ABILITIES_BY_CLASS,
COA_CLASS_RESOURCES,
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
} from "../src/game/coaAbilityCatalog";
import {
COA_LIVE_SOURCE,
@@ -25,6 +26,7 @@ writeJson("src/game/generated/coaAbilityRuntimeCatalog.json", {
schemaVersion: 1,
abilitiesByClass: COA_ABILITIES_BY_CLASS,
resourcesByClass: COA_CLASS_RESOURCES,
triggeredAbilitiesBySpellId: COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
});
const coaBudgetsByClass = Object.fromEntries(CLASSES
+87 -1
View File
@@ -15,6 +15,7 @@ import re
import shutil
import subprocess
import sys
import tempfile
import threading
import webbrowser
from dataclasses import dataclass
@@ -226,6 +227,67 @@ def current_commit() -> str:
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]:
environment = os.environ.copy()
if Path(powershell).name.lower() == "powershell.exe":
@@ -1003,6 +1065,15 @@ def launch_gui(*, smoke_test: bool = False) -> int:
credentials: dict[str, str],
) -> None:
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")
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
if not npm or not powershell:
@@ -1034,7 +1105,6 @@ def launch_gui(*, smoke_test: bool = False) -> int:
)
for command, label in checks:
self._run_command(command, label)
self._run_command(["git", "diff", "--check"], "Whitespace check")
self._run_command(["git", "add", "-A"], "Stage release")
staged = True
@@ -1371,12 +1441,28 @@ def main(argv: Sequence[str] | None = None) -> int:
action="store_true",
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(
"--smoke-test",
action="store_true",
help=argparse.SUPPRESS,
)
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:
try:
print(json.dumps(check_summary(), indent=2))
+55
View File
@@ -1,4 +1,7 @@
import subprocess
import tempfile
import unittest
from pathlib import Path
import release_manager
@@ -45,5 +48,57 @@ class SafetyTests(unittest.TestCase):
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__":
unittest.main()
@@ -162,6 +162,7 @@ function componentFor(id) {
};
const attack = {
type: int32(buffers.magicObject, offset + 0x194),
calculationType: int32(buffers.magicObject, offset + 0x2d4),
damagePower: cleanFloat(float32(buffers.magicObject, offset + 0x198)),
damagePowerSkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x19c)),
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));
const costs = costRows.map((cost) => ({
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,
}));
const attackDistance = int32(buffers.magicCollect, offset + 0xbc);
+312
View File
@@ -86,6 +86,34 @@ export function openGameDatabase(options = {}) {
updated_by_username TEXT,
updated_at INTEGER
) 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(`
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 {
databasePath,
register,
@@ -264,6 +568,14 @@ export function openGameDatabase(options = {}) {
isGameMaster,
getManastormConfig,
putManastormConfig,
onlineGroupState,
inviteOnlineGroupMember,
respondToOnlineGroupInvite,
leaveOnlineGroup,
removeOnlineGroupMember,
updateOnlineGroupMember,
startOnlineGroupActivity,
clearOnlineGroupActivity,
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
close: () => database.close(),
};
+57
View File
@@ -122,3 +122,60 @@ test("persists revisioned Manastorm GM configuration and rejects stale or unauth
bosses: { "boss:incomplete": { status: "ready" } },
}), (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);
}));
+211
View File
@@ -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(),
};
}
+114
View File
@@ -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
View File
@@ -3,6 +3,7 @@ import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
import { createOnlineSessionManager } from "./online-session.mjs";
const MIME_TYPES = new Map([
[".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 trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
const gameDatabase = options.database ?? openGameDatabase(options);
const onlineSessions = createOnlineSessionManager();
const allowAuthRequest = createAuthLimiter();
gameDatabase.pruneExpiredSessions();
@@ -209,6 +211,58 @@ export function createGameServer(options = {}) {
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
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") {
const current = gameDatabase.getManastormConfig();
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 };
}
+78
View File
@@ -65,6 +65,84 @@ test("serves the game and authenticated cloud-save API", async () => {
});
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`, {
method: "POST",
headers: { "Content-Type": "application/json" },
+12 -14
View File
@@ -3,6 +3,7 @@ import { useShellStore } from "./app/shellStore";
import { useManastormAdminStore } from "./game/manastormAdminStore";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { LoginScreen } from "./ui/LoginScreen";
import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator";
const GameRuntime = lazy(() => import("./GameRuntime"));
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
@@ -28,18 +29,15 @@ export default function App() {
void refreshManastormConfig();
}, [refreshManastormConfig, sessionOwnerId]);
if (phase === "login") return <LoginScreen />;
if (phase === "characters") return <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
if (phase === "create-character") return <Suspense fallback={<ShellTransition />}><CharacterCreateScreen /></Suspense>;
if (phase === "main-menu") return <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
if (phase === "class-hall") return <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
if (phase === "dungeons") return <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
if (phase === "manastorms") return <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
if (phase === "gm-admin") return <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
if (phase !== "game") return null;
return (
<Suspense fallback={<ShellTransition />}>
<GameRuntime />
</Suspense>
);
let screen = null;
if (phase === "login") screen = <LoginScreen />;
else if (phase === "characters") screen = <Suspense fallback={<ShellTransition />}><CharacterSelectScreen /></Suspense>;
else if (phase === "create-character") screen = <Suspense fallback={<ShellTransition />}><CharacterCreateScreen /></Suspense>;
else if (phase === "main-menu") screen = <Suspense fallback={<ShellTransition />}><MainMenuScreen /></Suspense>;
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
else if (phase === "manastorms") screen = <Suspense fallback={<ShellTransition />}><ManastormSelectScreen /></Suspense>;
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
return <><OnlineGroupCoordinator />{screen}</>;
}
+5
View File
@@ -20,6 +20,7 @@ import { MapPanel } from "./ui/MapPanel";
import { PauseMenu } from "./ui/PauseMenu";
import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
import { GmWorldEditor } from "./ui/GmWorldEditor";
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
function InputBridge() {
const overlay = useGameStore((state) => state.overlay);
@@ -51,6 +52,9 @@ function InputBridge() {
useCombatStore.getState().clearTarget();
usePartyStore.getState().clearSelection();
},
groundTargetActive: () => useCombatStore.getState().pendingGroundTarget !== null,
confirmGroundTarget: () => { useCombatStore.getState().confirmGroundTarget(); },
cancelGroundTarget: () => useCombatStore.getState().cancelGroundTarget(),
gameplayActionsBlocked: () => {
const game = useGameStore.getState();
return game.overlay !== null || game.companionOpen;
@@ -81,6 +85,7 @@ export default function GameRuntime() {
top={
<main className="game-shell">
<InputBridge />
<OnlineSessionBridge />
<CombatBridge />
<PartyBridge />
<ManastormBridge />
+85
View File
@@ -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;
}
+76
View File
@@ -6,6 +6,8 @@ import {
type AccountResult,
} from "./accountRepository";
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 SAVE_DEBOUNCE_MS = 500;
@@ -159,3 +161,77 @@ export async function logoutOnlineSession(session: PlayerSession | null): Promis
}
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),
});
}
+35
View File
@@ -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);
});
});
+177
View File
@@ -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;
}
+63
View File
@@ -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;
}
+24
View File
@@ -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: "" }),
}));
+108
View File
@@ -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[];
}
+16 -3
View File
@@ -6,6 +6,10 @@ import { useGameStore } from "./store";
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
import { useManastormStore } from "./manastormStore";
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
import {
onlineSessionIsActive,
onlineSessionOwnsWorldSimulation,
} from "./onlineSessionRuntime";
/**
* Connects the pure combat runtime to the active character and persistent
@@ -21,6 +25,7 @@ export function CombatBridge() {
useEffect(() => {
if (!character) return;
useCombatStore.getState().initializeCharacter(character);
useCombatStore.getState().synchronizeActors();
if (gameMode === "manastorm") {
activateManastormActiveSpellLoadout(
useManastormStore.getState().activeSpellLoadout,
@@ -108,17 +113,25 @@ export function CombatBridge() {
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
previous = now;
const game = useGameStore.getState();
if (!game.paused && !game.companionOpen) {
const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
const epochNow = Date.now();
const combat = useCombatStore.getState();
combat.setPlayerPosition(game.playerPosition);
combat.tick(delta, epochNow);
if (game.gameMode === "dungeon") {
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
}
if (onlineSessionOwnsWorldSimulation()) {
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
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(
useCombatStore.getState().mobs,
epochNow,
+11 -2
View File
@@ -14,6 +14,7 @@ import { getManastormCatalog, manastormEncounterById } from "./manastorm";
import { synchronizeManastormProgress } from "./manastormProgressSync";
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
import { preloadManastormStage } from "./manastormStageLoader";
import { onlineSessionIsActive, onlineSessionOwnsWorldSimulation } from "./onlineSessionRuntime";
/** Connects staged combat, party sizing, and shared resurrections to Manastorm state. */
export function ManastormBridge() {
@@ -30,11 +31,12 @@ export function ManastormBridge() {
useEffect(() => {
if (gameMode !== "manastorm") return;
if (!onlineSessionOwnsWorldSimulation()) return;
synchronizeManastormPartySize();
}, [gameMode, partyMemberCount, partySize]);
useEffect(() => {
if (gameMode !== "manastorm" || status !== "entering" || !encounter) return;
if (gameMode !== "manastorm" || status !== "entering" || !encounter || !onlineSessionOwnsWorldSimulation()) return;
useManastormStore.getState().enterStage();
}, [encounter, gameMode, status]);
@@ -42,12 +44,14 @@ export function ManastormBridge() {
if (
gameMode !== "manastorm"
|| !encounter
|| !onlineSessionOwnsWorldSimulation()
) return;
synchronizeManastormCombat();
}, [encounter, gameMode, mobs, status]);
useEffect(() => {
if (gameMode !== "manastorm") return;
if (!onlineSessionOwnsWorldSimulation()) return;
const timer = window.setInterval(() => synchronizeManastormAffixes(Date.now()), 250);
return () => window.clearInterval(timer);
}, [gameMode]);
@@ -71,6 +75,7 @@ export function ManastormBridge() {
|| previous.health <= 0
|| useGameStore.getState().gameMode !== "manastorm"
|| resolvingDefeatRef.current
|| onlineSessionIsActive()
) return;
resolvingDefeatRef.current = true;
try {
@@ -82,7 +87,11 @@ export function ManastormBridge() {
}), []);
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 downedMemberIds = state.members.filter((member) => (
member.health <= 0 && (previousById.get(member.id)?.health ?? 0) > 0
+319
View File
@@ -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;
}
+9 -2
View File
@@ -5,6 +5,7 @@ import { resetPartyRuntimeClock } from "./partyRuntime";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
export function PartyBridge() {
@@ -12,6 +13,7 @@ export function PartyBridge() {
const playerRole = useShellStore((state) => state.activeDungeonRole);
const gameMode = useGameStore((state) => state.gameMode);
const manastormPartySize = useManastormStore((state) => state.partySize);
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
const level = useCombatStore((state) => state.level);
const resetRevision = useGameStore((state) => state.resetRevision);
const mountedResetRevision = useRef(resetRevision);
@@ -20,17 +22,22 @@ export function PartyBridge() {
if (!character) return;
const combat = useCombatStore.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(
character.id,
combat.level,
combat.health,
playerRole,
gameMode === "manastorm" ? manastormPartySize : 5,
aiPartySize,
character.categoryId ?? "wow",
);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
resetPartyRuntimeClock();
}, [character?.id, gameMode, manastormPartySize, playerRole]);
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
useEffect(() => {
usePartyStore.getState().syncLevel(level);
+14 -1
View File
@@ -38,7 +38,20 @@ describe("baseline ability catalog", () => {
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
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(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.effects.length).toBeGreaterThan(0);
expect(allIds.has(ability.id)).toBe(false);
+86 -6
View File
@@ -6,11 +6,68 @@ import {
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES, ROM_ORDINARY_ABILITIES_BY_CLASS, type RomSkillGroup } from "./romAbilityCatalog";
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 AbilityTarget = "hostile" | "friendly" | "self";
export type ResourceType =
| "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 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 =
| "slow"
| "stun"
@@ -63,6 +120,8 @@ export interface AbilityAmountScaling {
readonly basePoints?: number;
readonly dieSides?: number;
readonly pointsPerLevel?: number;
/** Additional power coefficient gained per source skill level. */
readonly coefficientPerLevel?: number;
readonly bonusPowerCoefficient?: number;
readonly sourceLevel?: 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: "resurrection"; readonly percentMaxHealth: 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: "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 aura: AuraDefinition;
/** Some hostile spells apply their beneficial rider to the 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 sourceEffectType: number;
readonly sourceAuraType: number;
@@ -109,6 +170,7 @@ export type AbilityEffect =
readonly kind: "threat";
readonly mode: "flat" | "percent" | "redirect";
readonly amount: number;
readonly recipient?: "caster" | "ability-target";
}
| {
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: "totem"; readonly action: "destroy-all" }
| { 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 }
| {
/** A known combat-side DBC script hook that requires a spell-specific handler. */
@@ -140,6 +209,7 @@ export type AbilityEffect =
}
| {
readonly kind: "utility";
readonly action?: "create-item" | "open-lock" | "portal" | "weapon-enchant" | "pickpocket" | "farsight" | "class-script";
readonly effectType: number;
readonly auraType: number;
readonly miscValue: number;
@@ -165,6 +235,9 @@ export interface AbilityRankDefinition {
readonly cost: AbilityCost;
readonly costs?: readonly AbilityCost[];
readonly effects: readonly AbilityEffect[];
readonly targeting?: AbilityTargetingRules;
readonly castRules?: AbilityCastRules;
readonly runeCost?: AbilityRuneCost;
}
export interface AbilityDefinition {
@@ -188,6 +261,10 @@ export interface AbilityDefinition {
readonly cost: AbilityCost;
readonly costs?: readonly AbilityCost[];
readonly effects: readonly AbilityEffect[];
readonly targeting?: AbilityTargetingRules;
readonly castRules?: AbilityCastRules;
readonly runeCost?: AbilityRuneCost;
readonly ai?: AbilityAiHints;
readonly passive?: boolean;
readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker";
readonly romSkillGroup?: RomSkillGroup;
@@ -793,6 +870,9 @@ export function abilityAtLevel(
cost: rank.cost,
costs: rank.costs ?? [rank.cost],
effects: rank.effects,
targeting: rank.targeting ?? ability.targeting,
castRules: rank.castRules ?? ability.castRules,
runeCost: rank.runeCost ?? ability.runeCost,
};
}
+104
View File
@@ -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]) });
}
+188
View File
@@ -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);
}
});
});
File diff suppressed because it is too large Load Diff
+5
View File
@@ -6,10 +6,15 @@ interface CoaAbilityRuntimeSnapshot {
readonly schemaVersion: 1;
readonly abilitiesByClass: Readonly<Record<CoaClassId, readonly AbilityDefinition[]>>;
readonly resourcesByClass: Readonly<Record<CoaClassId, ClassResourceProfile>>;
readonly triggeredAbilitiesBySpellId: Readonly<Record<number, AbilityDefinition>>;
}
const snapshot = generated as unknown as CoaAbilityRuntimeSnapshot;
export const COA_RUNTIME_ABILITIES_BY_CLASS = snapshot.abilitiesByClass;
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;
}
+55
View File
@@ -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);
});
});
+105
View File
@@ -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,
});
}
+117
View File
@@ -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
View File
@@ -143,6 +143,16 @@ export interface AuraDefinition {
readonly stackBehavior?: AuraStackBehavior;
/** Defaults to true. False makes applications from all sources share one aura. */
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 dispelPriority?: number;
readonly modifiers?: readonly AuraModifier[];
@@ -309,14 +319,25 @@ export function applyAura(
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
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) {
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 stacks = behavior === "add"
? Math.min(maxStacks, existing.stacks + requestedStacks)
@@ -333,7 +354,7 @@ export function applyAura(
procStates: existing.procStates,
}
: replacement;
const auras = [...current];
const auras = [...base];
auras[existingIndex] = aura;
return { auras, aura, replaced: existing };
}
+49
View File
@@ -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))));
}
+18
View File
@@ -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 });
});
});
+28
View File
@@ -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,
});
}
+47
View File
@@ -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);
});
});
+89
View File
@@ -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));
}
+7 -1
View File
@@ -1,9 +1,14 @@
import { describe, expect, it } from "vitest";
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 {
COA_RUNTIME_ABILITIES_BY_CLASS,
COA_RUNTIME_CLASS_RESOURCES,
COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID,
} from "./coaAbilityRuntimeCatalog";
import generatedTalentRuntime from "./generated/talentRuntimeCatalog.json";
import {
@@ -21,6 +26,7 @@ describe("compact combat runtime catalogs", () => {
it("preserves every derived COA ability and resource profile", () => {
expect(COA_RUNTIME_ABILITIES_BY_CLASS).toEqual(COA_ABILITIES_BY_CLASS);
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", () => {
+33
View File
@@ -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();
});
});
+86
View File
@@ -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);
}
+151 -2
View File
@@ -12,6 +12,7 @@ import {
EMPTY_CORPSE_DESPAWN_MS,
MINIMUM_CORPSE_VISIBLE_MS,
MOB_WOUND_ANIMATION_COOLDOWN_MS,
PLAYER_AURA_ENTITY_ID,
selectedMob,
useCombatStore,
} from "./combatStore";
@@ -90,7 +91,7 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
useCombatStore.getState().tick(0.1, 2500);
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().mobs.ravager.engaged).toBe(true);
});
@@ -950,7 +951,7 @@ describe("combat store", () => {
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
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", () => {
@@ -972,6 +973,154 @@ describe("combat store", () => {
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", () => {
registerTarget("enemy", [10, 0, 0], 100, 0);
const memberId = preparePartyMember(0, [8, 0, 0], 1);
+2800 -195
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -24,7 +24,7 @@ describe("death respawn", () => {
useCombatStore.getState().selectMob("respawn-pack:a");
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().resource).toBe(startingResource - 15);
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.15));
const revisionBefore = useGameStore.getState().resetRevision;
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
+105
View File
@@ -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();
}
});
});
+1 -1
View File
@@ -15,7 +15,7 @@ export type EnemyDamageSchool =
| "shadow"
| "holy";
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun";
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
export type EnemyAbilityEffect =
| { 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
+21
View File
@@ -48,6 +48,9 @@ function inputActionSpies(blocked = false): InputActions {
setPartyCommand: vi.fn(),
recallParty: vi.fn(),
clearTarget: vi.fn(),
groundTargetActive: vi.fn(() => false),
confirmGroundTarget: vi.fn(),
cancelGroundTarget: vi.fn(),
gameplayActionsBlocked: vi.fn(() => blocked),
setCameraLookActive: vi.fn(),
setInputMode: vi.fn(),
@@ -90,6 +93,24 @@ describe("gameplay input bindings", () => {
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", () => {
expect(controllerCommandForToken("Button9")).toBe("pause");
expect(controllerCommandForToken("Button11")).toBe("target-next");
+20
View File
@@ -34,6 +34,9 @@ export interface InputActions {
setPartyCommand: (command: PartyCommand) => void;
recallParty: () => void;
clearTarget: () => void;
groundTargetActive: () => boolean;
confirmGroundTarget: () => void;
cancelGroundTarget: () => void;
gameplayActionsBlocked: () => boolean;
setCameraLookActive: (active: boolean) => void;
setInputMode: (mode: "keyboard" | "gamepad") => void;
@@ -246,6 +249,16 @@ export function installInput(nextActions: InputActions): () => void {
if (!isPressed) return;
nextActions.setInputMode("gamepad");
if (repeat) return;
if (nextActions.groundTargetActive()) {
if (token === "Button0") {
nextActions.confirmGroundTarget();
return;
}
if (token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN) {
nextActions.cancelGroundTarget();
return;
}
}
if (
token === "Button0"
&& !nextActions.gameplayActionsBlocked()
@@ -292,6 +305,13 @@ export function installInput(nextActions: InputActions): () => void {
actions?.setInputMode("keyboard");
}
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);
if (actionControl) {
if (nextActions.gameplayActionsBlocked()) return;
+2
View File
@@ -3,6 +3,7 @@ import { LOOT_CATALOG, rollBossLoot } from "./lootCatalog";
import { useManastormStore } from "./manastormStore";
import { useGameStore } from "./store";
import { getManastormCatalog, manastormScaling } from "./manastorm";
import { requestOnlineInteraction } from "./onlineSessionRuntime";
/** Shared keyboard, controller, touch, and HUD interaction for the active corpse. */
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;
const bossId = manastorm.currentEncounter?.bossRuntimeId;
if (!bossId) return false;
if (requestOnlineInteraction("loot", bossId)) return true;
return useCombatStore.getState().lootMob(bossId, now);
}
+102
View File
@@ -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);
});
});
+78
View File
@@ -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([]);
});
});
+137
View File
@@ -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;
}
+82
View File
@@ -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);
});
});
+150
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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 {
const position = positions.get(id);
return position ? [...position] as MutablePartyWorldPosition : null;
+62
View File
@@ -29,6 +29,8 @@ import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog";
import type { EnemyControlMechanic } from "./enemyAttacks";
import type { ContentCategoryId } from "../app/contentCategories";
import type { PartyRole, PartySize } from "./partyRoles";
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog";
import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors";
export {
classCanFillPartyRole,
@@ -56,6 +58,18 @@ export interface PartyMember {
readonly level: number;
readonly health: 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 status: PartyMemberStatus;
readonly nextActionAt: number;
@@ -214,6 +228,9 @@ function createMember(
const gender: GenderId = genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male";
const appearance = randomAppearance(race.id, gender, () => nextRandom(identityRandom));
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 {
id: `party-${role}-${index}-${template.classId}`,
name: template.name,
@@ -230,6 +247,18 @@ function createMember(
level: Math.max(1, Math.trunc(level)),
health: 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,
status: "ready",
nextActionAt: 0,
@@ -321,6 +350,27 @@ export interface PartyState {
cycleMember: (direction?: 1 | -1) => string | null;
clearSelection: () => void;
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;
damageMember: (id: string, amount: number, school?: DamageSchool, attackerLevel?: number) => number;
/** Applies damage that has already passed a discrete combat-result roll. */
@@ -479,6 +529,18 @@ export const usePartyStore = create<PartyState>((set, get) => ({
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) => {
let restored = 0;
set((state) => ({
+276 -28
View File
@@ -12,6 +12,7 @@ import {
translateRomAbilitySemantics,
type RomAbilitySemantic,
type RomGeneratedAbilityLike,
type RomSourceComponent,
} from "./romAbilitySemantics";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -71,9 +72,9 @@ interface GeneratedSkill {
const generated = rawCatalog.skills as unknown as readonly GeneratedSkill[];
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
: "mana";
: `custom:${slug(value)}`;
}
function costs(rows: readonly GeneratedCost[]): readonly AbilityCost[] {
@@ -109,10 +110,10 @@ function knownResource(value: string): ResourceType | undefined {
if (value === "mp") return "mana";
if (value === "nature's power") return "nature-power";
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)
? value as ResourceType
: undefined;
: value ? `custom:${slug(value)}` : undefined;
}
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";
}
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;
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") {
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") {
return { kind: "stat", stat: "magic-damage-immunity", operation: "flat", value: 1 };
return magicDamageTaken(-1);
}
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") {
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") {
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") {
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) {
return { kind: "stat", stat: semantic.effect, operation: "flat", value };
if (type === 23) return [{ kind: "stat", stat: "attack-speed", operation: "flat", value: -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"] {
@@ -173,11 +257,30 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
const componentKey = semantic.sourceComponentIds.join("-") || "tooltip";
const effectId = `${row.sourceSkillId}-${componentKey}-${index}`;
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 {
kind: semantic.kind,
coefficient: Math.max(0, semantic.power),
basePoints: Math.max(0, semantic.fixedValue),
pointsPerLevel: Math.max(0, semantic.fixedPerSkillLevel),
coefficient,
...(coefficientPerLevel > 0 ? { coefficientPerLevel, sourceLevel: 1 } : {}),
...(basePoints > 0 ? {
basePoints,
pointsPerLevel,
sourceLevel: 1,
...(coefficient > 0 ? { bonusPowerCoefficient: coefficient } : {}),
} : {}),
...(semantic.kind === "heal" && semantic.percentMaxHealth !== undefined
? { percentMaxHealth: semantic.percentMaxHealth }
: {}),
@@ -189,8 +292,9 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
return {
kind: semantic.kind,
coefficient: 0,
basePoints: Math.max(0, semantic.basePerTick),
pointsPerLevel: Math.max(0, semantic.perSkillLevel),
basePoints: Math.max(0, semantic.basePerTick) * (1 + Math.max(0, semantic.perSkillLevel) / 100),
pointsPerLevel: Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100,
sourceLevel: 1,
ticks,
intervalMs,
...(semantic.kind === "dot" ? { tag: `rom-${effectId}` } : {}),
@@ -214,24 +318,30 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
id: `rom-control-${effectId}-${slug(semantic.effect)}`,
name: `${row.name}: ${semantic.effect}`,
disposition: "debuff",
durationMs: durationMs(semantic.durationSeconds),
durationMs: Math.max(1_000, durationMs(semantic.durationSeconds)),
dispelCategory: "magic",
};
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") {
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 = {
id: `rom-aura-${effectId}-${slug(semantic.effect)}`,
name: `${row.name}: ${semantic.effect}`,
disposition: recipient === "caster" || row.target !== "hostile" ? "buff" : "debuff",
durationMs: semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null,
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.amount <= 0 && semantic.perSkillLevel <= 0) {
@@ -260,6 +370,7 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
id: `rom-proc-trigger-${effectId}`,
triggers: procTrigger(semantic.trigger),
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 } },
}],
};
@@ -267,6 +378,23 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
}
if (semantic.kind === "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 {
kind: "resource-drain",
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 === "pet") return { kind: "pet", action: semantic.action };
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 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[] {
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 {
@@ -337,6 +549,7 @@ function ability(row: GeneratedSkill): AbilityDefinition {
description: row.description,
target: row.target,
range: { min: 0, max: row.range },
...(areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
castMode: row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.castTimeMs,
cooldownMs: row.cooldownMs,
@@ -354,6 +567,41 @@ function ability(row: GeneratedSkill): AbilityDefinition {
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(
Object.fromEntries((rawCatalog.canonicalClasses as readonly { id: RomClassId }[]).map(({ id }) => [
id,
+81 -8
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import type { AbilityEffect } from "./abilityCatalog";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import generated from "./romPlayerCatalog.generated.json";
@@ -8,6 +9,33 @@ function named(name: string) {
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", () => {
it("maps Taunt, Shout, and Cleanse to their real combat mechanics", () => {
expect(named("Taunt").effects).toEqual(expect.arrayContaining([
@@ -31,7 +59,7 @@ describe("executable RuneWaker ability mappings", () => {
expect(holyAura).toMatchObject({
aura: {
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) => (
@@ -63,16 +91,61 @@ describe("executable RuneWaker ability mappings", () => {
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
.filter((ability) => !ability.passive && ability.effects.every((effect) => effect.kind === "scripted"))
.map((ability) => ability.name)
.sort();
expect(scriptOnly).toEqual([
"Electric Compression",
"Entling Offering",
"Frost Arrow",
"Holy Salvation Candle",
]);
expect(scriptOnly).toEqual([]);
for (const version of versions.filter((entry) => !entry.passive)) {
expect(
version.effects.some(isMeaningfulEffect),
`${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" }),
]));
});
});
+70 -3
View File
@@ -63,6 +63,7 @@ export interface RomSourceComponent {
};
readonly attack?: {
readonly type: number;
readonly calculationType?: number;
readonly damagePower: number;
readonly damagePowerSkillLevelArg: number;
readonly fixedValue: number;
@@ -121,6 +122,7 @@ export type RomAbilitySemantic =
readonly fixedValue: number;
readonly fixedType: number;
readonly fixedPerSkillLevel: number;
readonly calculationType: number | null;
readonly percentMaxHealth?: number;
})
| (RomSemanticBase & {
@@ -170,6 +172,7 @@ export type RomAbilitySemantic =
readonly operation: "restore" | "drain" | "modify";
readonly amount: number;
readonly intervalSeconds: number | null;
readonly durationSeconds?: number;
readonly percentage?: boolean;
})
| (RomSemanticBase & {
@@ -225,9 +228,9 @@ const MODIFIER_NAMES: Readonly<Record<number, string>> = Object.freeze({
15: "magical-attack",
16: "physical-hit",
17: "dodge",
18: "physical-critical-rate",
18: "physical-critical-strike",
19: "physical-critical-power",
20: "magical-critical-rate",
20: "magical-critical-strike",
21: "magical-critical-power",
22: "parry",
23: "attack-speed",
@@ -240,6 +243,66 @@ const MODIFIER_NAMES: Readonly<Record<number, string>> = Object.freeze({
30: "wind-resistance",
31: "light-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;
@@ -481,6 +544,7 @@ function componentSemantics(component: RomSourceComponent, target: RomAbilityTar
operation: dot.base > 0 ? "restore" : "drain",
amount: Math.abs(dot.base),
intervalSeconds: dot.timeSeconds,
durationSeconds: positiveDuration(component),
...componentSource(component),
}, String(component.id));
}
@@ -498,6 +562,8 @@ function componentSemantics(component: RomSourceComponent, target: RomAbilityTar
fixedValue: Math.abs(attack.fixedValue),
fixedType: attack.fixedType,
fixedPerSkillLevel: Math.abs(attack.fixedDamageSkillLevelArg),
calculationType: attack.calculationType ?? null,
...(healing && attack.calculationType === 8 ? { percentMaxHealth: Math.abs(attack.damagePower) / 100 } : {}),
...componentSource(component),
}, String(component.id));
} 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")) {
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")) {
const percentMaxHealth = firstNumber(lower, /(\d+(?:\.\d+)?)%[^.]*?(?:hp|health)/);
@@ -724,6 +790,7 @@ function tooltipSemantics(ability: RomGeneratedAbilityLike, rows: RomAbilitySema
fixedValue: 0,
fixedType: 0,
fixedPerSkillLevel: 0,
calculationType: null,
...(percentMaxHealth === null ? {} : { percentMaxHealth: percentMaxHealth / 100 }),
...tooltipSource(),
});
+85
View File
@@ -79,6 +79,36 @@ describe("RuneWaker combat runtime", () => {
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", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior",
@@ -183,4 +213,59 @@ describe("RuneWaker combat runtime", () => {
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
+23
View File
@@ -85,6 +85,29 @@ describe("generated RuneWaker player catalog", () => {
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", () => {
const primary = romNativeClassBaseStats("rom-warrior", 40);
const secondary = romNativeClassBaseStats("rom-scout", 40);
+72 -17
View File
@@ -20,26 +20,59 @@ function effectsOfKind<K extends AbilityEffect["kind"]>(
}
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)
.flat()
.flatMap((ability) => ability.ranks?.flatMap((rank) => rank.effects) ?? ability.effects);
expect(effects.length).toBeGreaterThan(1_000);
expect(effects.some((effect) => effect.kind === "utility")).toBe(false);
expect([
...new Set(effectsOfKind(
{ effects },
"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+$/);
expect(effectsOfKind({ effects }, "scripted")).toEqual([]);
expect(effectsOfKind({ effects }, "unsupported")).toEqual([]);
for (const effect of effectsOfKind({ effects }, "utility")) {
expect(effect.action).toMatch(/^(?:create-item|open-lock|portal|weapon-enchant|pickpocket|farsight|class-script)$/);
}
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", () => {
@@ -77,6 +110,28 @@ describe("WoW 3.3.5 executable effect mapping", () => {
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", () => {
const pain = byId("wow335-priest-shadow-word-pain");
const painAura = effectsOfKind(pain, "apply-aura")
@@ -123,11 +178,11 @@ describe("WoW 3.3.5 executable effect mapping", () => {
.find((effect) => effect.sourceAuraType === 42);
expect(seal?.aura.procs).toEqual([
expect.objectContaining({
triggers: expect.arrayContaining(["hit"]),
triggers: "hit",
action: {
kind: "custom",
id: "wow335-trigger-spell",
data: { spellId: 20170 },
id: "classic-status",
data: { status: "stun", durationMs: 2_000 },
},
}),
]);
+560 -27
View File
@@ -125,6 +125,11 @@ function effectTargetsHostile(effect: Wow335Effect): boolean {
}
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][] = [
[1, "physical"],
[2, "holy"],
@@ -134,8 +139,7 @@ function schoolFromMask(mask: number): DamageSchool | undefined {
[32, "shadow"],
[64, "arcane"],
];
const matches = schools.filter(([bit]) => (mask & bit) !== 0);
return matches.length === 1 ? matches[0]?.[1] : undefined;
return schools.filter(([bit]) => (mask & bit) !== 0).map(([, school]) => school);
}
function percentValue(effect: Wow335Effect): number {
@@ -158,6 +162,7 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 221: return percentStat("threat");
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 20: return flatStat("health-regeneration");
case 22:
case 83:
case 123:
@@ -182,6 +187,17 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 34:
case 250: return flatStat("maximum-health");
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 49: return percentStat("dodge");
case 50: return percentStat("critical-healing");
@@ -200,6 +216,7 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 71:
case 179: return percentStat("spell-critical-strike");
case 59: return [{ kind: "damage", direction: "dealt", operation: "flat", value: effect.basePoints }];
case 61: return percentStat("haste");
case 65:
case 138:
case 140:
@@ -260,6 +277,9 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 189:
case 220: return flatStat(`combat-rating:${effect.miscValue}`);
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 251: return percentStat("enemy-dodge");
case 253: return percentStat("block-critical-strike");
@@ -279,11 +299,56 @@ function controlFromAura(auraType: number): AbilityAuraControl | undefined {
case 27: return { kind: "silence" };
case 60: return { kind: "silence" };
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;
}
}
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[] {
if (auraType === 23 || auraType === 48 || auraType === 226 || auraType === 227) return ["periodic"];
const triggers: ProcTrigger[] = [];
@@ -355,16 +420,176 @@ function dispelCategoryForAura(
}
function namedAuraForEffect(
classId: BaseClassId,
effect: Wow335Effect,
rank: Wow335Rank,
abilityName: string,
effectIndex: number,
): AuraDefinition {
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 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 {
id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`,
id: auraId,
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
@@ -373,21 +598,37 @@ function namedAuraForEffect(
durationMs,
maxStacks: 1,
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
? { dispelCategory: dispelCategoryForAura(abilityName, rank.description, rank.durationMs) }
: {}),
...(auraModifiers(effect).length ? { modifiers: auraModifiers(effect) } : {}),
...([69, 97].includes(effect.auraType)
...(modifiers.length ? { modifiers } : {}),
...([69, 97].includes(effect.auraType) || sacredShield
? {
absorbs: [{
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 } : {}),
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))));
}
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[] = [];
for (const [effectIndex, effect] of rank.effects.entries()) {
const coefficient = fallbackCoefficient(effect, rank);
const scaling = amountScaling(effect, rank);
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({
kind: "apply-aura",
aura,
...([35, 65].includes(effect.effectType) ? { scope: "party" as const } : {}),
...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}),
sourceEffectType: effect.effectType,
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 });
} else if (effect.auraType === 11) {
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;
}
@@ -492,8 +936,18 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
effects.push({ kind: "damage", coefficient: weaponCoefficient });
continue;
}
if (effect.effectType === 10 || effect.effectType === 67 || effect.effectType === 136) {
effects.push({ kind: "heal", coefficient, ...scaling });
if (effect.effectType === 67) {
// 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;
}
if (effect.effectType === 30) {
@@ -501,7 +955,7 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
continue;
}
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;
}
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 (NONCOMBAT_EFFECTS.has(effect.effectType)) {
effects.push({
kind: "unsupported",
combat: false,
kind: "utility",
action: utilityAction(effect.effectType),
effectType: effect.effectType,
auraType: effect.auraType,
reason: `Noncombat DBC effect ${effect.effectType}`,
miscValue: effect.miscValue,
triggerSpellId: effect.triggerSpellId,
durationMs: rank.durationMs,
});
continue;
}
@@ -658,15 +1114,40 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
effects.unshift({ kind: "damage", coefficient: 1 });
}
}
return effects.length
? effects
const scriptedFallbacks = classScriptEffects(classId, abilityName, rank);
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,
auraType: 0,
miscValue: 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;
}
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(
classId: BaseClassId,
resource: ResourceType,
@@ -696,8 +1191,9 @@ function rankDefinition(
index: number,
abilityName: string,
): AbilityRankDefinition {
const effects = effectsForRank(rank, abilityName);
const effects = effectsForRank(classId, rank, abilityName);
const channeling = rank.channelDurationMs > 0;
const rankResource = resourceForRank(resource, rank);
return {
rank: rank.rank || index + 1,
spellId: rank.spellId,
@@ -707,7 +1203,11 @@ function rankDefinition(
castTimeMs: channeling ? Math.max(rank.castTimeMs, rank.channelDurationMs) : rank.castTimeMs,
cooldownMs: rank.cooldownMs,
gcdMs: rank.gcdMs,
cost: { resource, amount: costForRank(resource, rank) },
cost: {
resource: rankResource,
amount: costForRank(rankResource, rank),
...(rank.powerCostPercentage > 0 ? { percentage: true } : {}),
},
effects,
};
}
@@ -724,12 +1224,22 @@ function sigil(name: string): string {
function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDefinition | null {
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 firstRaw = chain.ranks[0];
const firstRaw = sourceRanks[0];
if (!first || !firstRaw) return null;
const target = targetForRank(firstRaw, first.effects);
const radius = Math.max(0, ...firstRaw.effects.map((effect) => effect.radius));
const target = abilityNameTarget(chain.name) ?? targetForRank(firstRaw, first.effects);
const radius = Math.max(
abilityNameRadius(chain.name),
...firstRaw.effects.map((effect) => effect.radius),
);
const range = target === "self"
? { min: 0, max: 0 }
: {
@@ -752,6 +1262,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
castMode: first.castMode,
castTimeMs: first.castTimeMs,
cooldownMs: first.cooldownMs,
...(classicCooldownGroup(classId, chain.name)
? { cooldownGroup: classicCooldownGroup(classId, chain.name) }
: {}),
gcdMs: first.gcdMs,
cost: first.cost,
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[]>> =
Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => [
classId,
+10 -4
View File
@@ -20,6 +20,7 @@ import { useGameStore } from "../game/store";
import { DungeonEnvironment } from "./DungeonEnvironment";
import { MobPopulation } from "./MobPopulation";
import { PartyPopulation } from "./PartyPopulation";
import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs";
import { PlayerRig } from "./PlayerRig";
import { useManastormStore } from "../game/manastormStore";
import { ManastormPortal } from "./ManastormPortal";
@@ -40,6 +41,9 @@ import { GameGltfLoaderLifecycle } from "./useGameGLTF";
import { CombatEffects } from "./CombatEffects";
import { PLAYER_GRAVITY } from "../game/playerMovement";
import { GmPlacementMarkers } from "./GmPlacementMarkers";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { OnlinePlayerPopulation } from "./OnlinePlayerPopulation";
export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -88,6 +92,8 @@ export function GameScene() {
const paused = useGameStore((state) => state.paused);
const mapOpen = useGameStore((state) => state.mapOpen);
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 [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
const [safeGraphics, setSafeGraphics] = useState(false);
@@ -96,9 +102,7 @@ export function GameScene() {
const worldReady = encounterWorldIsReady(readySessionRevision, sessionRevision);
const simulationBlocked = graphicsRecovery !== null
|| !worldReady
|| paused
|| mapOpen
|| companionOpen;
|| (!sharedOnline && (paused || mapOpen || companionOpen));
const allowedRuntimeIds = useMemo(
() => gameMode === "manastorm" && manastormEncounter
? manastormChaoticLinkRuntimeIds(manastormEncounter, manastormPhase)
@@ -236,7 +240,9 @@ export function GameScene() {
{worldReady && (
<>
<PlayerRig />
<PartyPopulation active={!simulationBlocked} />
<OnlinePlayerPopulation active={!simulationBlocked} />
<PartyPopulation active={!simulationBlocked} simulate={!sharedOnline || onlineAuthority} />
<GroundEffectTelegraphs />
<MobPopulation
active={!simulationBlocked}
entities={stagePopulation.entities}
+40
View File
@@ -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>
);
}
+5
View File
@@ -6,6 +6,7 @@ import { MANASTORM_BUBBLE_DEFAULT_RADIUS } from "../game/manastormClientOverlay"
import { advanceManastormSession } from "../game/manastormSession";
import { useManastormStore } from "../game/manastormStore";
import { useGameStore } from "../game/store";
import { requestOnlineInteraction } from "../game/onlineSessionRuntime";
const STORM_PARTICLES = [
[-0.9, 1.4, 0.4],
@@ -101,6 +102,10 @@ export function ManastormPortal() {
useGameStore.getState().playerPosition,
);
if (inside && !wasInsideRef.current) {
if (requestOnlineInteraction("portal")) {
wasInsideRef.current = true;
return;
}
transitionLatchedRef.current = true;
transitionStartedAtRef.current = Date.now();
displayedCountdownRef.current = NEXT_WAVE_COUNTDOWN_SECONDS;
+40
View File
@@ -73,6 +73,7 @@ import {
resolveMobAnimationInterval,
} from "./mobAnimationCadence";
import { useGameGLTF } from "./useGameGLTF";
import { onlineSynchronizedMobTransform, requestOnlineInteraction } from "../game/onlineSessionRuntime";
const MOVEMENT_LOCKING_MOB_STATUSES: ReadonlySet<string> = new Set([
"stun", "root", "sleep", "knockdown", "freeze", "charm", "confuse",
@@ -916,7 +917,9 @@ function ProxyMob({
if (event.button !== PRIMARY_MOUSE_BUTTON) return;
event.stopPropagation();
if (dead) {
if (!requestOnlineInteraction("loot", instanceId)) {
useCombatStore.getState().lootMob(instanceId);
}
return;
}
usePartyStore.getState().clearSelection();
@@ -1188,6 +1191,25 @@ function RoamingPack({
const forward = scratchForwards[index];
const member = definition.members[index];
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 chasePosition = chasePositions[index];
const engaged = Boolean(mob?.engaged && !mob.dead);
@@ -1303,6 +1325,24 @@ function StaticPopulationMember({ spawn, entities, active, showLabels, useOrigin
const positionRef = useRef<MutableMobVector3>([...spawn.position]);
useEffect(() => registerMobRuntime(spawn.id, spawn.position, spawn.yaw ?? 0), [spawn.id, spawn.position, spawn.yaw]);
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 position = positionRef.current;
const player = useGameStore.getState().playerPosition;
+140
View File
@@ -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>
);
}
+78 -1
View File
@@ -65,6 +65,7 @@ import { partyCombatRangeBand } from "../game/partyRuntime";
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
import {
clearPartyRuntimeAttackCorridor,
getPartyRuntimePosition,
registerPartyRuntimePosition,
updatePartyRuntimeAttackCorridor,
updatePartyRuntimePosition,
@@ -75,6 +76,8 @@ import {
import { usePartyStore } from "../game/partyStore";
import { useGameStore } from "../game/store";
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_CATCH_UP_SPEED = 7.5;
@@ -266,12 +269,14 @@ function initialMemberPosition(index: number): MutablePartyWorldPosition {
function PartyActors({
active,
simulate,
navigationGraph,
navigationFailed,
objectives,
stackAtEntrance,
}: {
readonly active: boolean;
readonly simulate: boolean;
readonly navigationGraph: PartyNavigationGraph | null;
readonly navigationFailed: boolean;
readonly objectives: readonly DungeonBossObjective[];
@@ -442,6 +447,56 @@ function PartyActors({
const directFollowMovingRef = useRef(new Map<string, boolean>());
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
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 party = usePartyStore.getState();
party.members.forEach((member, index) => {
@@ -606,6 +661,21 @@ function PartyActors({
if (!active) return;
const party = usePartyStore.getState();
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 playerNavigation = game.playerNavigationPosition;
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 gameMode = useGameStore((state) => state.gameMode);
const sessionRevision = useGameStore((state) => state.sessionRevision);
@@ -1383,6 +1459,7 @@ export function PartyPopulation({ active = true }: { readonly active?: boolean }
<PartyActors
key={activeDungeonId}
active={active}
simulate={simulate}
navigationGraph={navigationGraph}
navigationFailed={navigationFailed || !assets.navigation}
objectives={objectives}
+13 -3
View File
@@ -180,6 +180,15 @@ function PlayerController({
orbit.yaw = cameraYawAfterHorizontalLook(orbit.yaw, snapshot.lookX * delta * 2.25);
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();
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
@@ -222,7 +231,8 @@ function PlayerController({
body.setNextKinematicTranslation(translation);
return;
}
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
if (requestedAtMs !== null && !groundTargetActive) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
if (groundTargetActive) cancelBufferedPlayerJump(jumpTimingRef.current);
const shouldJump = updatePlayerJumpTiming(
jumpTimingRef.current,
@@ -238,8 +248,8 @@ function PlayerController({
: airbornePlayerVerticalVelocity(verticalVelocityRef.current, movementDeltaSeconds);
const [worldX, worldZ] = cameraRelativeMovement(
snapshot.moveX,
snapshot.moveForward,
groundTargetActive ? 0 : snapshot.moveX,
groundTargetActive ? 0 : snapshot.moveForward,
orbit.yaw,
);
if (body.numColliders() === 0) return;
+79
View File
@@ -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--player { opacity: .9; }
.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__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; }
@@ -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__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 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__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 {
display: flex;
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-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. */
.native-platform, .native-platform body, .native-platform #root { width: 100%; height: 100%; overscroll-behavior: none; }
.native-platform .dedicated-display-surface { padding: 0; }
+9 -3
View File
@@ -82,7 +82,11 @@ function ActionSlot({
const cooldownLabel = (item?.cooldownRemaining ?? 0) > 0.05
? ", 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
? `, costs ${item.resourceCost} ${item.resourceLabel ?? "resource"}`
: "";
@@ -93,7 +97,7 @@ function ActionSlot({
return (
<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}
aria-disabled={unavailable}
aria-label={item ? `${item.name}, ${chord}${targetLabel}${resourceLabel}${lockedLabel}${cooldownLabel}` : `Empty binding, ${chord}`}
@@ -114,6 +118,7 @@ function ActionSlot({
<strong>{item?.name ?? "Empty"}</strong>
{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 === "any" ? <span className="action-slot__target">ANY</span> : null}
{item?.resourceCost ? <small aria-hidden="true">{item.resourceCost}</small> : null}
{cooldown > 0 && <i className="action-slot__cooldown" aria-hidden="true" />}
{(item?.cooldownRemaining ?? 0) > 0.05 && (
@@ -183,7 +188,8 @@ export function ActionBar({
? `Unlocks at level ${inspectedItem.lockedUntilLevel}`
: inspectedItem.resourceCost
? `${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>
</div>
</div>
+24 -3
View File
@@ -23,6 +23,8 @@ import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton";
import { DungeonMapGraphic } from "./DungeonMapGraphic";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
installDungeonShellRuntime();
@@ -48,6 +50,10 @@ export function DungeonSelectScreen() {
const enterDungeon = useShellStore((state) => state.enterDungeon);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
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 firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.available) ?? DUNGEON_CATALOG[0];
const [selectedCategory, setSelectedCategory] = useState<DungeonCategoryId>(firstAvailable?.category ?? "wow");
@@ -73,8 +79,22 @@ export function DungeonSelectScreen() {
setRolePickerOpen(false);
};
const enterAsRole = (role: PartyRole) => {
if (selectedDungeon?.available) enterDungeon(selectedDungeon.id, role);
const enterAsRole = async (role: PartyRole) => {
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[]>(() => [
...DUNGEON_CATEGORIES.map((category, index) => ({
@@ -220,6 +240,7 @@ export function DungeonSelectScreen() {
{selectedDungeon.available && !selectedPackInstalled && (
<p className="front-notice" role="status">Install this expedition from Main menu / Downloads before playing offline.</p>
)}
<OnlineGroupPanel />
{rolePickerOpen && characterClass ? (
<section className="dungeon-role-picker" aria-labelledby="dungeon-role-title">
<header>
@@ -240,7 +261,7 @@ export function DungeonSelectScreen() {
>
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
<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>
</span>
</ControllerButton>
+70 -5
View File
@@ -24,6 +24,9 @@ import { AuraStrip } from "./AuraStrip";
import { ManastormStatus } from "./ManastormStatus";
import { ThreatMeterWidget } from "./ThreatMeterWidget";
import { BossLootWindow } from "./BossLootWindow";
import { SummonFrames } from "./SummonFrames";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime";
function LoadingOverlay() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -61,12 +64,14 @@ export function DefeatedPrompt({
gameMode,
resurrectionCharges,
onReturn,
sharedOnline = false,
}: {
gameMode: "dungeon" | "manastorm";
resurrectionCharges: number;
onReturn: () => void;
sharedOnline?: boolean;
}) {
const canReturn = gameMode === "dungeon" || resurrectionCharges > 0;
const canReturn = !sharedOnline && (gameMode === "dungeon" || resurrectionCharges > 0);
return (
<section
className="defeated-prompt"
@@ -86,10 +91,12 @@ export function DefeatedPrompt({
? `Resurrect now · ${resurrectionCharges} remaining`
: "Respawn at entrance"}
</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>
)}
{gameMode === "dungeon" ? (
{gameMode === "dungeon" && !sharedOnline ? (
<small>Respawning resets the current encounter and restores the party.</small>
) : null}
</div>
@@ -122,6 +129,9 @@ export function Hud() {
const health = useCombatStore((state) => state.health);
const maxHealth = useCombatStore((state) => state.maxHealth);
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 maxResource = useCombatStore((state) => state.maxResource);
const resourcePools = useCombatStore((state) => state.resourcePools);
@@ -132,8 +142,10 @@ export function Hud() {
const cooldowns = useCombatStore((state) => state.cooldowns);
const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt);
const activeCast = useCombatStore((state) => state.activeCast);
const pendingGroundTarget = useCombatStore((state) => state.pendingGroundTarget);
const feedback = useCombatStore((state) => state.feedback);
const target = useCombatStore((state) => state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null);
const selectedFriendlyActorId = useCombatStore((state) => state.selectedFriendlyActorId);
const friendlyTarget = usePartyStore((state) => state.selectedMemberId
? state.members.find((member) => member.id === state.selectedMemberId) ?? null
: null);
@@ -141,6 +153,7 @@ export function Hud() {
const partyMembers = usePartyStore((state) => state.members);
const playerRole = usePartyStore((state) => state.playerRole);
const settings = useCombatStore((state) => state.settings);
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
const [clock, setClock] = useState(() => Date.now());
useEffect(() => {
@@ -148,6 +161,15 @@ export function Hud() {
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) => {
useCombatStore.getState().updateSettings({ threatMeterPosition });
}, []);
@@ -211,11 +233,25 @@ export function Hud() {
role: member.role,
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.forcedTarget,
clock,
) : [];
const onlineFriendlyAccountId = selectedFriendlyActorId
? onlineAccountIdFromActor(selectedFriendlyActorId)
: null;
const onlineFriendly = onlineFriendlyAccountId
? onlineSession?.players.find((player) => player.accountId === onlineFriendlyAccountId) ?? null
: null;
const selectedFriendly = selfSelected
? {
name: character?.name ?? "Adventurer",
@@ -223,6 +259,7 @@ export function Hud() {
health,
maxHealth,
self: true,
targetId: PLAYER_AGGRO_ID,
}
: friendlyTarget
? {
@@ -231,6 +268,16 @@ export function Hud() {
health: friendlyTarget.health,
maxHealth: friendlyTarget.maxHealth,
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;
const friendlyTargetPercent = selectedFriendly
@@ -275,10 +322,27 @@ export function Hud() {
character={character ? { ...character, level } : null}
health={{ current: health, maximum: maxHealth, label: "Health", tone: "health" }}
resource={{ current: resource, maximum: maxResource, label: resourceName, tone: resourceTone }}
shield={shield}
shield={shield + namedAbsorb}
dead={health <= 0}
/>
<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
position={playerPosition}
@@ -325,11 +389,11 @@ export function Hud() {
</header>
<div className="target-frame__track"><span style={{ width: `${friendlyTargetPercent}%` }} /></div>
<TimedEffectStrip
targetId={selectedFriendly.self ? null : friendlyTarget?.id ?? null}
targetId={selectedFriendly.self ? null : selectedFriendly.targetId}
className="timed-effect-strip--target"
/>
<AuraStrip
targetId={selectedFriendly.self ? PLAYER_AGGRO_ID : friendlyTarget?.id ?? PLAYER_AGGRO_ID}
targetId={selectedFriendly.targetId}
className="aura-strip--target"
/>
<small>{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}</small>
@@ -384,6 +448,7 @@ export function Hud() {
gameMode={gameMode}
resurrectionCharges={resurrectionCharges}
onReturn={returnFromDefeat}
sharedOnline={onlineSession !== null}
/>
) : null}
</div>
+2
View File
@@ -9,6 +9,7 @@ import { ControllerButton } from "./ControllerButton";
import { ContentManagerScreen } from "./ContentManagerScreen";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { isGameMasterSession } from "../game/manastormAdminConfig";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
const GAME_MODES = [
{
@@ -156,6 +157,7 @@ export function MainMenuScreen() {
<h2>Ways to play</h2>
<p>Select a destination. More adventures will unlock here as they are built.</p>
</header>
<OnlineGroupPanel />
<nav className="main-menu-mode-grid" aria-label="Game modes">
{isGm && (
<ControllerButton
+51 -12
View File
@@ -33,6 +33,8 @@ import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
installManastormShellRuntime();
@@ -49,10 +51,19 @@ export function ManastormSelectScreen() {
const saveSelectedManastormLoadout = useShellStore((state) => state.saveSelectedManastormLoadout);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
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 character = activeCharacter ?? rosterCharacter;
const availableRoles = character ? partyRolesForClass(character.classId) : [];
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 progress = character?.manastormProgress ?? createEmptyManastormProgress();
const effectiveCatalog = useMemo(
@@ -71,7 +82,7 @@ export function ManastormSelectScreen() {
const canEnter = modeEnabled && hasReadyBosses;
const modeProgress = manastormModeProgress(progress, modeId);
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 startingLevel = resume && checkpoint > 1 ? checkpoint : 1;
const cacheChance = modeId === 2
@@ -81,7 +92,7 @@ export function ManastormSelectScreen() {
try {
const preview = drawManastormStage(
startingLevel,
partySize,
effectivePartySize,
[],
null,
() => 0.5,
@@ -92,14 +103,14 @@ export function ManastormSelectScreen() {
return manastormReward(
preview.encounter,
startingLevel,
partySize,
effectivePartySize,
effectiveCatalog,
modeId,
);
} catch {
return null;
}
}, [effectiveCatalog, modeId, partyProgress.highestLevel, partySize, startingLevel]);
}, [effectiveCatalog, effectivePartySize, modeId, partyProgress.highestLevel, startingLevel]);
const spellLoadoutModel = useMemo(() => character
? manastormActiveSpellLoadoutModel(character.classId, character.level, character.talentRanks)
: null, [character?.classId, character?.level, character?.talentRanks]);
@@ -132,7 +143,7 @@ export function ManastormSelectScreen() {
character.id,
character.level,
role,
partySize,
effectivePartySize,
character.categoryId ?? "wow",
);
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[]>(() => [
...MANASTORM_PARTY_SIZES.map((size) => ({
id: `manastorm-party-${size}`,
run: () => setPartySize(size),
enabled: !onlineGroup || (size >= groupHumanCount && (fillWithAi || size === groupHumanCount)),
})),
{ id: "manastorm-start-fresh", run: () => setResume(false) },
...(checkpoint > 1 ? [{ id: "manastorm-start-resume", run: () => setResume(true) }] : []),
@@ -153,7 +184,7 @@ export function ManastormSelectScreen() {
})) ?? []),
...availableRoles.map((role) => ({
id: `manastorm-role-${role}`,
run: () => canEnter && enterManastorm(role, partySize, startingLevel),
run: () => { if (canEnter) void enterAsRole(role); },
enabled: canEnter,
})),
{ id: "manastorm-back", run: returnToMainMenu },
@@ -161,8 +192,10 @@ export function ManastormSelectScreen() {
availableRoles,
canEnter,
checkpoint,
enterManastorm,
partySize,
groupHumanCount,
fillWithAi,
onlineGroup,
effectivePartySize,
returnToMainMenu,
selectedSpellIds,
spellLoadoutModel,
@@ -235,6 +268,7 @@ export function ManastormSelectScreen() {
<h2 id="manastorm-role-title">Build your expedition</h2>
<small>Progress and first clears are tracked separately for every party size.</small>
</header>
<OnlineGroupPanel />
<div className="manastorm-setup-block">
<strong>Party size</strong>
@@ -245,8 +279,9 @@ export function ManastormSelectScreen() {
controlId={`manastorm-party-${size}`}
selectedId={controller.selectedId}
select={controller.select}
className={`manastorm-size-option ${partySize === size ? "is-active" : ""}`}
className={`manastorm-size-option ${effectivePartySize === size ? "is-active" : ""}`}
type="button"
disabled={Boolean(onlineGroup && (size < groupHumanCount || (!fillWithAi && size !== groupHumanCount)))}
onClick={() => setPartySize(size)}
>
{size}
@@ -254,7 +289,11 @@ export function ManastormSelectScreen() {
))}
</div>
<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}
</small>
</div>
@@ -345,11 +384,11 @@ export function ManastormSelectScreen() {
disabled={!canEnter}
onFocus={() => 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>
<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>
</span>
</ControllerButton>
+157
View File
@@ -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
View File
@@ -11,6 +11,11 @@ import {
import { TimedEffectStrip } from "./TimedEffectStrip";
import { AuraStrip } from "./AuraStrip";
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 {
command: PartyCommand;
@@ -107,6 +112,22 @@ function MemberFrame({ member, selected, onSelect }: {
const characterClass = classById(member.classId);
const race = raceById(member.raceId);
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 (
<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__body">
<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)}>
<i style={{ width: `${percent}%` }} />
{absorb > 0 ? <em style={{ left: `${percent}%`, width: `${absorbPercent}%` }} /> : null}
<b>{Math.ceil(member.health)} / {member.maxHealth}</b>
</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" />
<AuraStrip targetId={member.id} className="aura-strip--party" />
</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. */
export function PartyFrames() {
const character = useShellStore((state) => state.activeCharacter);
const playerRole = useShellStore((state) => state.activeDungeonRole);
const playerHealth = useCombatStore((state) => state.health);
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 selectedMemberId = usePartyStore((state) => state.selectedMemberId);
const selfSelected = usePartyStore((state) => state.selfSelected);
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);
if (!members.length) return null;
const groupSize = members.length + 1;
const playerAbsorb = playerShield + playerAuraAbsorb;
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) => {
useCombatStore.getState().clearTarget();
@@ -153,10 +245,16 @@ export function PartyFrames() {
usePartyStore.getState().selectSelf();
};
const selectOnlineMember = (accountId: string) => {
useCombatStore.getState().clearTarget();
usePartyStore.getState().clearSelection();
useCombatStore.getState().selectFriendlyActor(onlinePlayerActorId(accountId));
};
return (
<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>
<PartyCommandBar />
{members.length ? <PartyCommandBar /> : null}
<button
type="button"
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__health" role="progressbar" aria-label="Player health" aria-valuemin={0} aria-valuemax={playerMaxHealth} aria-valuenow={Math.max(0, playerHealth)}>
<i style={{ width: `${playerPercent}%` }} />
{playerAbsorb > 0 ? <em style={{ left: `${playerPercent}%`, width: `${playerAbsorbPercent}%` }} /> : null}
<b>{Math.ceil(playerHealth)} / {playerMaxHealth}</b>
</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" />
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" />
</span>
</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) => (
<MemberFrame
key={member.id}
+1 -1
View File
@@ -5,7 +5,7 @@ export interface PlayerFrameMeter {
current: number;
maximum: number;
label: string;
tone: "health" | "mana" | "rage" | "energy" | "runic-power" | "focus" | "nature-power" | "psi";
tone: string;
}
export interface PlayerFrameProps {
+31
View File
@@ -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>
);
}