Update Healer Man server

This commit is contained in:
phenom committed 2026-09-25 21:37:10 -04:00
1 parent 48a678dcc5
commit 9c5fb4c614
18 files changed
+397 -46

No files matched your search

+12
View File
@@ -3920,3 +3920,15 @@ Vite's existing large-chunk advisory remains informational.
the previous weighted selector could drop one of several cast or impact the previous weighted selector could drop one of several cast or impact
layers. A focused audio test confirms two impact files create two voices. layers. A focused audio test confirms two impact files create two voices.
Listening QA is still needed for the balance of these layers. Listening QA is still needed for the balance of these layers.
## RoM Priest channel and stacked status icons (2026-09-25)
- In a disposable level-30 Priest/Knight Wailing Caverns run,
`scripts/playtest-rom-status-effects.cjs` cast Chain of Light at a nearby
Deviate Ravager. The cast bar showed Channeling and the rendered scene showed
a sustained white-gold beam between the Priest and target. The browser
reported no page errors. Evidence: `artifacts/playtest/rom-status-effects/chain-of-light-and-stacked-dot.png`.
- Three same-spell damage-over-time applications on that target appeared as one
icon with a small `3`; the browser assertion also checked the icon count.
This exercise checked HUD grouping, while focused combat tests checked four
separate channel damage pulses. Source audio was not assessed by listening.
+139
View File
@@ -0,0 +1,139 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const directory = path.resolve('artifacts/playtest/rom-status-effects');
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rom-status-'));
fs.mkdirSync(directory, { recursive: true });
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: runtimeData, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(4195, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.setDefaultTimeout(180_000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const moduleUrls = fs.readdirSync('dist/assets')
.filter((file) => /^(?:index|GameRuntime|combatStore)-.*\.js$/.test(file))
.map((file) => `/assets/${file}`);
await page.addInitScript((urls) => {
window.romStatusStores = async () => {
const exports = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
const stores = exports.filter((value) => value && typeof value.getState === 'function');
return {
game: stores.find((store) => typeof store.getState()?.relocatePlayer === 'function'),
shell: stores.find((store) => typeof store.getState()?.saveActiveProgression === 'function'),
combat: stores.find((store) => typeof store.getState()?.castAbility === 'function'),
party: stores.find((store) => typeof store.getState()?.setCommand === 'function'
&& Array.isArray(store.getState()?.members)),
};
};
}, moduleUrls);
try {
await page.goto('http://127.0.0.1:4195', { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
await page.locator('.class-option').filter({ hasText: 'Priest' }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('RomPriestStatusQA');
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.evaluate(async () => {
const { shell } = await window.romStatusStores();
const state = shell.getState();
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
if (!character) throw new Error('Priest character was not selected.');
shell.setState({ activeCharacter: character });
shell.getState().saveActiveProgression(30, 0, character.talentRanks);
shell.setState({ activeCharacter: null });
shell.getState().openMainMenu();
});
await page.locator('.main-menu-screen').waitFor();
await page.evaluate(async () => {
const { shell } = await window.romStatusStores();
shell.getState().openDungeons();
});
await page.locator('.dungeon-select-screen').waitFor();
await page.locator('.dungeon-select-row').first().waitFor();
const entry = await page.evaluate(async () => {
const { shell } = await window.romStatusStores();
const ok = shell.getState().enterDungeon('wailing-caverns');
return { ok, notice: shell.getState().notice };
});
assert.equal(entry.ok, true, JSON.stringify(entry));
await page.locator('.party-frames').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'detached' });
await page.evaluate(async () => {
const { game } = await window.romStatusStores();
game.getState().closeOverlay();
});
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached' });
await page.waitForFunction(async () => {
const { combat } = await window.romStatusStores();
return combat.getState().level >= 30 && Object.values(combat.getState().mobs)
.some((mob) => !mob.dead && mob.homePosition);
});
const setup = await page.evaluate(async () => {
const { game, combat, party } = await window.romStatusStores();
party?.getState().setCommand('stop');
const mob = Object.values(combat.getState().mobs)
.find((candidate) => !candidate.dead && candidate.homePosition);
if (!mob) throw new Error('No living dungeon mob is available.');
const [x, y, z] = mob.homePosition;
game.getState().relocatePlayer({ footPosition: [x + 3, y, z], yaw: -Math.PI / 2 });
combat.getState().setPlayerPosition(game.getState().playerPosition);
combat.setState((state) => ({ mobs: { ...state.mobs, [mob.id]: {
...state.mobs[mob.id], health: 100_000, maxHealth: 100_000,
} } }));
combat.getState().selectMob(mob.id);
const now = Date.now();
combat.setState((state) => ({ effects: [
...state.effects,
...[1, 2, 3].map((index) => ({
id: `rom-status-poison-${index}`,
kind: 'dot',
sourceAbilityId: 'rom-priest-primary-4',
sourceActorId: `qa-source-${index}`,
targetId: mob.id,
coefficient: 0,
remainingTicks: 6,
intervalMs: 2_000,
nextTickAt: now + 10_000,
})),
] }));
return { mobId: mob.id };
});
await page.waitForTimeout(500);
const castState = await page.evaluate(async () => {
const { combat } = await window.romStatusStores();
const cast = combat.getState().castAbility('rom-priest-primary-9', undefined, Date.now());
return { cast, activeCast: combat.getState().activeCast };
});
assert.equal(castState.cast.ok, true, JSON.stringify({ ...setup, ...castState }));
assert.equal(castState.activeCast.mode, 'channel');
await page.locator('.timed-effect-strip--target .timed-effect').first().waitFor();
assert.equal(await page.locator('.timed-effect-strip--target .timed-effect').count(), 1);
assert.equal(await page.locator('.timed-effect-strip--target .timed-effect__stacks').innerText(), '3');
await page.screenshot({ path: path.join(directory, 'chain-of-light-and-stacked-dot.png') });
assert.deepEqual(errors, []);
console.log(`RoM channel and stacked DoT playtest passed: ${directory}`);
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
const tempRoot = fs.realpathSync(os.tmpdir());
const resolvedData = fs.realpathSync(runtimeData);
if (path.dirname(resolvedData) === tempRoot
&& path.basename(resolvedData).startsWith('healer-man-rom-status-')) {
fs.rmSync(resolvedData, { recursive: true, force: true });
}
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+11 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { abilitiesForClass } from "./abilityCatalog"; import { abilitiesForClass } from "./abilityCatalog";
import { channelAmountEffects } from "./combatChannelAmounts"; import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
describe("channel amount expansion", () => { describe("channel amount expansion", () => {
it("spreads a normal channel's full amount across its pulses", () => { it("spreads a normal channel's full amount across its pulses", () => {
@@ -22,4 +22,14 @@ describe("channel amount expansion", () => {
: effect) }; : effect) };
expect(channelAmountEffects(conditional, 5)).toEqual([]); expect(channelAmountEffects(conditional, 5)).toEqual([]);
}); });
it("deals Chain of Light's source hit on every pulse", () => {
const ability = abilitiesForClass("rom-priest").find((candidate) => candidate.name === "Chain of Light")!;
expect(ability.castMode).toBe("channel");
expect(ability.castTimeMs).toBe(4_000);
expect(channelPulseCount(ability)).toBe(4);
expect(channelAmountEffects(ability, 4)).toEqual([
{ effect: ability.effects.find((effect) => effect.kind === "damage"), multiplier: 1 },
]);
});
}); });
+7 -1
View File
@@ -2,6 +2,10 @@ import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect }
type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>; type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>;
export function channelPulseCount(ability: Pick<AbilityDefinition, "name" | "dbcSpellId">): number {
return ability.name === "Divine Hymn" || ability.dbcSpellId === 491164 ? 4 : 5;
}
/** Direct channel amounts span the cast; periodic trigger payloads fire each pulse. */ /** Direct channel amounts span the cast; periodic trigger payloads fire each pulse. */
export function channelAmountEffects(ability: AbilityDefinition, totalTicks: number): readonly { export function channelAmountEffects(ability: AbilityDefinition, totalTicks: number): readonly {
effect: ChannelAmountEffect; effect: ChannelAmountEffect;
@@ -11,7 +15,9 @@ export function channelAmountEffects(ability: AbilityDefinition, totalTicks: num
const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = []; const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = [];
for (const effect of ability.effects) { for (const effect of ability.effects) {
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "dot" || effect.kind === "hot") { if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "dot" || effect.kind === "hot") {
amounts.push({ effect, multiplier: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks }); amounts.push({ effect, multiplier: ability.dbcSpellId === 491164 && effect.kind === "damage"
? 1
: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks });
} else if (effect.kind === "apply-aura") { } else if (effect.kind === "apply-aura") {
for (const proc of effect.aura.procs ?? []) { for (const proc of effect.aura.procs ?? []) {
const triggers = Array.isArray(proc.triggers) ? proc.triggers : [proc.triggers]; const triggers = Array.isArray(proc.triggers) ? proc.triggers : [proc.triggers];
+4 -4
View File
@@ -139,7 +139,7 @@ import { rollBossLoot } from "./lootCatalog";
import { bossCardForSource } from "./bossCardCatalog"; import { bossCardForSource } from "./bossCardCatalog";
import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards"; import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards";
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula"; import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
import { channelAmountEffects } from "./combatChannelAmounts"; import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
import { normalizeInventory, type InventoryItem } from "./lootTypes"; import { normalizeInventory, type InventoryItem } from "./lootTypes";
import { import {
PLAYER_EQUIPMENT_OWNER_ID, PLAYER_EQUIPMENT_OWNER_ID,
@@ -5166,7 +5166,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} }
const lastPrayerReady = ability.source === "runewaker" && ability.dbcSpellId !== 491648 const lastPrayerReady = ability.source === "runewaker" && ability.dbcSpellId !== 491648
&& ability.castTimeMs > 0 && hasRuneWakerState("rom-last-prayer"); && ability.castMode !== "channel" && ability.castTimeMs > 0 && hasRuneWakerState("rom-last-prayer");
const ordinaryCastTimeMs = talentAdjustedCastTime( const ordinaryCastTimeMs = talentAdjustedCastTime(
ability, ability,
modifiers, modifiers,
@@ -5217,9 +5217,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|| (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")), || (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")),
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]], origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
groundPosition, groundPosition,
totalTicks: ability.castMode === "channel" ? (ability.name === "Divine Hymn" ? 4 : 5) : 0, totalTicks: ability.castMode === "channel" ? channelPulseCount(ability) : 0,
ticksCompleted: 0, ticksCompleted: 0,
nextTickAt: ability.castMode === "channel" ? now + castTimeMs / (ability.name === "Divine Hymn" ? 4 : 5) : 0, nextTickAt: ability.castMode === "channel" ? now + castTimeMs / channelPulseCount(ability) : 0,
}; };
const channelCooldowns: Record<string, number> = { ...state.cooldowns }; const channelCooldowns: Record<string, number> = { ...state.cooldowns };
if (activeCast.mode === "channel" && cooldownMs > 0) { if (activeCast.mode === "channel" && cooldownMs > 0) {
+2 -2
View File
@@ -65,7 +65,7 @@ import { imminentCooldownMechanic } from "./companionCooldownPolicy";
import { normalizeActorResourcePool } from "./combatActors"; import { normalizeActorResourcePool } from "./combatActors";
import { enemyControlPreventsActions } from "./enemyAttacks"; import { enemyControlPreventsActions } from "./enemyAttacks";
import { advanceCombatResourcePool } from "./combatResources"; import { advanceCombatResourcePool } from "./combatResources";
import { channelAmountEffects } from "./combatChannelAmounts"; import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingRecipientIds } from "./healingAbilityRules"; import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingRecipientIds } from "./healingAbilityRules";
import { import {
combatHasLineOfSight, combatHasLineOfSight,
@@ -1812,7 +1812,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 }; nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 };
continue; continue;
} }
const totalTicks = castingAbility.name === "Divine Hymn" ? 4 : 5; const totalTicks = channelPulseCount(castingAbility);
const interval = Math.max(1, cast.originalDurationMs / totalTicks); const interval = Math.max(1, cast.originalDurationMs / totalTicks);
let completed = cast.channelTicksCompleted ?? 0; let completed = cast.channelTicksCompleted ?? 0;
while (completed < totalTicks && cast.startedAt + (completed + 1) * interval <= now) { while (completed < totalTicks && cast.startedAt + (completed + 1) * interval <= now) {
+7 -6
View File
@@ -17,6 +17,7 @@ import {
import { overrideRomPriestKnightEffects, romPriestKnightAdditionalAbilities } from "./romPriestKnightOverrides"; import { overrideRomPriestKnightEffects, romPriestKnightAdditionalAbilities } from "./romPriestKnightOverrides";
import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides"; import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides";
import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides"; import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
import { romPriestDescription } from "./romPriestTooltips";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive"; export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -691,9 +692,9 @@ function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition
rank: row.rank, rank: row.rank,
spellId: row.sourceSkillId, spellId: row.sourceSkillId,
level: row.level, level: row.level,
description: row.description, description: parent.classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description,
castMode: row.castTimeMs > 0 ? "cast" : "instant", castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.castTimeMs, castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs,
cooldownMs: row.cooldownMs, cooldownMs: row.cooldownMs,
gcdMs: 1_500, gcdMs: 1_500,
cost: rankCosts[0] ?? { resource: "mana", amount: 0 }, cost: rankCosts[0] ?? { resource: "mana", amount: 0 },
@@ -722,15 +723,15 @@ function ability(row: GeneratedSkill): AbilityDefinition {
unlockLevel: row.unlockLevel, unlockLevel: row.unlockLevel,
icon: row.icon, icon: row.icon,
sigil: row.name.split(/\s+/).map((part) => part[0]).join("").slice(0, 3).toUpperCase() || "RW", sigil: row.name.split(/\s+/).map((part) => part[0]).join("").slice(0, 3).toUpperCase() || "RW",
description: row.description, description: classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description,
target: row.target, target: row.target,
range: { min: 0, max: row.range }, range: { min: 0, max: row.range },
...(sourceTargeting(row.sourceSkillId)?.radius !== undefined ...(sourceTargeting(row.sourceSkillId)?.radius !== undefined
? { radius: sourceTargeting(row.sourceSkillId)!.radius } ? { radius: sourceTargeting(row.sourceSkillId)!.radius }
: areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}), : areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
...(sourceTargeting(row.sourceSkillId) ? { targeting: sourceTargeting(row.sourceSkillId) } : {}), ...(sourceTargeting(row.sourceSkillId) ? { targeting: sourceTargeting(row.sourceSkillId) } : {}),
castMode: row.castTimeMs > 0 ? "cast" : "instant", castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.castTimeMs, castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs,
cooldownMs: row.cooldownMs, cooldownMs: row.cooldownMs,
gcdMs: 1_500, gcdMs: 1_500,
cost: abilityCosts[0] ?? { resource: "mana", amount: 0 }, cost: abilityCosts[0] ?? { resource: "mana", amount: 0 },
@@ -37,6 +37,15 @@ function isMeaningfulEffect(effect: AbilityEffect): boolean {
} }
describe("executable RuneWaker ability mappings", () => { describe("executable RuneWaker ability mappings", () => {
it("shows readable Priest / Knight spell descriptions without client string tokens", () => {
const priest = ROM_ALL_ABILITIES.filter((ability) => ability.classId === "rom-priest"
&& (!ability.secondaryClassId || ability.secondaryClassId === "rom-knight"));
for (const ability of priest) {
for (const description of [ability.description, ...(ability.ranks ?? []).map((rank) => rank.description)]) {
expect(description, ability.name).not.toMatch(/\([^)]*(?:Buff\d*|DMG\d*)[^)]*\)|\[SC_SKILLTIPS_[^\]]+\]|\[\d{6}\]/i);
}
}
});
it("maps Taunt, Shout, and Cleanse to their real combat mechanics", () => { it("maps Taunt, Shout, and Cleanse to their real combat mechanics", () => {
expect(named("Taunt").effects).toEqual(expect.arrayContaining([ expect(named("Taunt").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "taunt" }), expect.objectContaining({ kind: "taunt" }),
+20
View File
@@ -109,6 +109,26 @@ describe("RuneWaker combat runtime", () => {
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true }); expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
}); });
it("channels Chain of Light into four separate damage pulses", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30 });
useCombatStore.getState().registerMob("light-dummy", { name: "Light Dummy", maxHealth: 100_000 });
setMobPosition("light-dummy", [3, 0, 0]);
useCombatStore.getState().selectMob("light-dummy");
const initialHealth = useCombatStore.getState().mobs["light-dummy"]!.health;
expect(useCombatStore.getState().castAbility("rom-priest-primary-9", undefined, 1_000)).toMatchObject({ ok: true });
const cast = useCombatStore.getState().activeCast!;
expect(cast).toMatchObject({ mode: "channel", totalTicks: 4 });
expect(cast.completesAt).toBeGreaterThan(4_000);
expect(cast.completesAt).toBeLessThanOrEqual(5_000);
expect(useCombatStore.getState().mobs["light-dummy"]!.health).toBe(initialHealth);
useCombatStore.getState().tick(1, cast.nextTickAt);
const firstPulseHealth = useCombatStore.getState().mobs["light-dummy"]!.health;
expect(firstPulseHealth).toBeLessThan(initialHealth);
useCombatStore.getState().tick(3, cast.completesAt);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().mobs["light-dummy"]!.health).toBeLessThan(firstPulseHealth);
});
it("applies companion Holy Candle HP costs to its actor and party record", () => { it("applies companion Holy Candle HP costs to its actor and party record", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 50 }); useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 50 });
usePartyStore.getState().initializeParty("rom-self-drain", 50, useCombatStore.getState().health, "healer", 2, "rom"); usePartyStore.getState().initializeParty("rom-self-drain", 50, useCombatStore.getState().health, "healer", 2, "rom");
+38
View File
@@ -0,0 +1,38 @@
/** Player-facing copy for the active Priest / Knight kit. Source string tokens
* such as Buff0-Time refer to client tables that are not present in the UI. */
const PRIEST_DESCRIPTIONS: Readonly<Record<number, string>> = {
490273: "Shields a friendly target from damage for 60 seconds and reduces physical and magical damage taken while the shield lasts.",
490261: "Restores health to a friendly target.",
490274: "Surrounds the target with icy fog, slowing it for a short time.",
491165: "Chills the target with Water damage every 2 seconds for 12 seconds.",
490297: "Bonds with a friendly target for 30 minutes, allowing it to revive once at the place where it falls.",
490290: "Increases a friendly target's maximum health for 30 minutes.",
490281: "Heals nearby party members. Casting it also applies Exhaustion, increasing the mana cost of group healing.",
491164: "Channels Light into the target for 4 seconds, dealing Light damage once per second.",
490491: "Increases a friendly target's physical attack power for 30 minutes. Does not stack with similar effects.",
490305: "Passive: increases Water resistance and Water spell damage.",
490295: "Passive: increases the healing done by your spells.",
490275: "Surrounds the target with icy fog, slowing it for a short time.",
490294: "Increases healing received by a friendly target for 60 seconds.",
490301: "Temporarily reduces your threat against enemies.",
490276: "Surrounds the target with icy fog, slowing it for a short time.",
490304: "Consumes some of your health each second to increase the healing you do.",
491176: "Revives a dead friendly target outside combat with reduced death debt.",
491177: "Revives a dead friendly target outside combat with greatly reduced death debt.",
491147: "Quickly restores health to a friendly target.",
490256: "Sends a torrent at the target, dealing Water damage.",
490269: "Restores a friendly target's health every 2 seconds for 20 seconds.",
490300: "Makes you immune to damage for 5 seconds.",
491166: "Increases the magical defense of party members for 15 minutes.",
490298: "Increases party members' Water resistance for a limited time.",
491366: "Increases your healing and reduces mana costs while lowering magical attack for 15 minutes.",
492639: "Protects a friendly target from Fear for 10 seconds.",
492934: "Sacrifices some of your health to heal a friendly party member.",
493044: "Increases your Wisdom for 20 seconds or until you cast Heal three times.",
499968: "Passive: Splendor Ripple makes your Devotion Halo heal its target for additional health.",
499957: "Passive: while Devotion Halo is active, increases your healing done.",
};
export function romPriestDescription(sourceSkillId: number, original: string): string {
return PRIEST_DESCRIPTIONS[sourceSkillId] ?? original;
}
+15
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TimedCombatEffect } from "./combatStore"; import type { TimedCombatEffect } from "./combatStore";
import { import {
formatTimedEffectTimer, formatTimedEffectTimer,
groupTimedEffectsForDisplay,
timedEffectExpiresAt, timedEffectExpiresAt,
timedEffectRemainingMs, timedEffectRemainingMs,
} from "./timedEffectDisplay"; } from "./timedEffectDisplay";
@@ -37,4 +38,18 @@ describe("timed effect display", () => {
expect(formatTimedEffectTimer(9_940)).toBe("9.9"); expect(formatTimedEffectTimer(9_940)).toBe("9.9");
expect(formatTimedEffectTimer(-50)).toBe("0.0"); expect(formatTimedEffectTimer(-50)).toBe("0.0");
}); });
it("shows repeated poison applications as one icon with the live stack count", () => {
const first = effect({ id: "poison:1", sourceAbilityId: "rom-rogue-poison", nextTickAt: 4_000 });
const second = effect({ id: "poison:2", sourceAbilityId: "rom-rogue-poison", sourceActorId: "rogue-2", nextTickAt: 5_000 });
const third = effect({ id: "poison:3", sourceAbilityId: "rom-rogue-poison", sourceActorId: "rogue-3", nextTickAt: 6_000 });
expect(groupTimedEffectsForDisplay([first, second, third], 3_000)).toEqual([{
effect: third, stacks: 3, remainingMs: 18_000,
}]);
expect(groupTimedEffectsForDisplay([first, second, third], 20_000)).toEqual([{
effect: third, stacks: 1, remainingMs: 1_000,
}]);
expect(groupTimedEffectsForDisplay([first, effect({ id: "bleed", sourceAbilityId: "rom-rogue-bleed" })], 3_000))
.toHaveLength(2);
});
}); });
+30
View File
@@ -9,6 +9,36 @@ export function timedEffectRemainingMs(effect: TimedCombatEffect, now: number):
return Math.max(0, timedEffectExpiresAt(effect) - now); return Math.max(0, timedEffectExpiresAt(effect) - now);
} }
export interface DisplayedTimedEffect {
readonly effect: TimedCombatEffect;
readonly stacks: number;
readonly remainingMs: number;
}
/** Keep every periodic application active in combat while showing one icon per spell and target. */
export function groupTimedEffectsForDisplay(
effects: readonly TimedCombatEffect[],
now: number,
): DisplayedTimedEffect[] {
const grouped = new Map<string, DisplayedTimedEffect>();
for (const effect of effects) {
const remainingMs = timedEffectRemainingMs(effect, now);
if (remainingMs <= 0) continue;
const key = [effect.targetId ?? "", effect.kind, effect.sourceAbilityId].join("\u0000");
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, { effect, stacks: 1, remainingMs });
continue;
}
grouped.set(key, {
effect: remainingMs > existing.remainingMs ? effect : existing.effect,
stacks: existing.stacks + 1,
remainingMs: Math.max(existing.remainingMs, remainingMs),
});
}
return [...grouped.values()].sort((left, right) => left.remainingMs - right.remainingMs);
}
/** Compact MMO-style countdown: whole seconds, then tenths for the last 10 seconds. */ /** Compact MMO-style countdown: whole seconds, then tenths for the last 10 seconds. */
export function formatTimedEffectTimer(remainingMs: number): string { export function formatTimedEffectTimer(remainingMs: number): string {
const safeRemaining = Math.max(0, Number.isFinite(remainingMs) ? remainingMs : 0); const safeRemaining = Math.max(0, Number.isFinite(remainingMs) ? remainingMs : 0);
+60
View File
@@ -10,6 +10,9 @@ import {
} from "three"; } from "three";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
import { getMobPosition } from "../game/mobRuntimeRegistry";
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
import { usePartyStore } from "../game/partyStore";
import { import {
combatEffectStyle, combatEffectStyle,
preloadCombatEffectManifest, preloadCombatEffectManifest,
@@ -51,6 +54,55 @@ const QUALITY_PARTICLE_COUNT: Readonly<Record<Exclude<CombatVfxQuality, "off">,
const COMBAT_EFFECT_DATASET_KEY = "combatEffectLoad"; const COMBAT_EFFECT_DATASET_KEY = "combatEffectLoad";
/** The imported hand particles do not include Chain of Light's sustained target link. */
function ChainOfLightBeam({ actorId }: { readonly actorId?: string }) {
const beamRef = useRef<Group>(null);
const source = useMemo(() => new Vector3(), []);
const target = useMemo(() => new Vector3(), []);
const direction = useMemo(() => new Vector3(), []);
const up = useMemo(() => new Vector3(0, 1, 0), []);
useFrame(() => {
const beam = beamRef.current;
if (!beam) return;
const state = useCombatStore.getState();
const member = actorId ? usePartyStore.getState().members.find((entry) => entry.id === actorId) : null;
const cast = actorId ? member?.activeCast : state.activeCast;
const targetId = actorId ? member?.activeCastTargetId : state.activeCast?.targetId;
const origin = actorId ? getPartyRuntimePosition(actorId) : state.playerPosition;
const position = targetId ? getMobPosition(targetId) : null;
if (cast?.abilityId !== "rom-priest-primary-9" || !origin || !position) {
beam.visible = false;
return;
}
source.set(origin[0], origin[1] + 1.35, origin[2]);
target.set(position[0], position[1] + 1.15, position[2]);
direction.subVectors(target, source);
const length = direction.length();
if (length < 0.1) {
beam.visible = false;
return;
}
beam.visible = true;
beam.position.copy(source).add(target).multiplyScalar(0.5);
beam.quaternion.setFromUnitVectors(up, direction.multiplyScalar(1 / length));
beam.scale.set(1, length, 1);
});
return (
<group ref={beamRef} visible={false}>
<mesh>
<cylinderGeometry args={[0.14, 0.14, 1, 10]} />
<meshBasicMaterial color="#ffe57c" transparent opacity={0.28} depthWrite={false} blending={AdditiveBlending} />
</mesh>
<mesh>
<cylinderGeometry args={[0.055, 0.055, 1, 8]} />
<meshBasicMaterial color="#fff9d6" transparent opacity={0.85} depthWrite={false} blending={AdditiveBlending} />
</mesh>
</group>
);
}
function effectDurationMs(event: CombatPresentationEvent): number { function effectDurationMs(event: CombatPresentationEvent): number {
if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900)); if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900));
if (event.phase === "tick") return 320; if (event.phase === "tick") return 320;
@@ -250,6 +302,10 @@ const CombatEffectPrimitive = memo(function CombatEffectPrimitive({
export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) { export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) {
const settings = useShellStore((state) => state.combatPresentationSettings); const settings = useShellStore((state) => state.combatPresentationSettings);
const masterVolume = useCombatStore((state) => state.settings.masterVolume); const masterVolume = useCombatStore((state) => state.settings.masterVolume);
const chainOfLightChanneling = useCombatStore((state) => state.activeCast?.abilityId === "rom-priest-primary-9");
const partyChainCasterIds = usePartyStore((state) => state.members
.filter((member) => member.activeCast?.abilityId === "rom-priest-primary-9")
.map((member) => member.id).join("\u0000"));
const [effects, setEffects] = useState<readonly ActiveCombatEffect[]>([]); const [effects, setEffects] = useState<readonly ActiveCombatEffect[]>([]);
const settingsRef = useRef(settings); const settingsRef = useRef(settings);
const activeRef = useRef(active); const activeRef = useRef(active);
@@ -362,6 +418,10 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
if (effectiveQuality === "off") return null; if (effectiveQuality === "off") return null;
return ( return (
<group name="combat-effects"> <group name="combat-effects">
{active && settings.vfxEnabled.player && chainOfLightChanneling ? <ChainOfLightBeam /> : null}
{active && settings.vfxEnabled.party && partyChainCasterIds
? partyChainCasterIds.split("\u0000").map((actorId) => <ChainOfLightBeam key={actorId} actorId={actorId} />)
: null}
{effects.map((effect) => ( {effects.map((effect) => (
effect.originalOnly && vfxMode !== "original" ? null : effect.originalOnly && vfxMode !== "original" ? null :
vfxMode === "original" ? ( vfxMode === "original" ? (
+2
View File
@@ -878,6 +878,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.timed-effect img { width: 100%; height: 100%; object-fit: cover; } .timed-effect img { width: 100%; height: 100%; object-fit: cover; }
.timed-effect__fallback { color: #f1ba92; font-size: 12px; font-weight: 900; } .timed-effect__fallback { color: #f1ba92; font-size: 12px; font-weight: 900; }
.timed-effect--hot .timed-effect__fallback { color: #9ae7ac; } .timed-effect--hot .timed-effect__fallback { color: #9ae7ac; }
.timed-effect__stacks { position: absolute; right: 1px; top: 0; color: #fff4b8; font: 800 8px/8px var(--font-ui); text-shadow: 0 1px 2px #000, 0 0 3px #000; }
.timed-effect__timer { .timed-effect__timer {
position: absolute; position: absolute;
right: 1px; right: 1px;
@@ -894,6 +895,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.timed-effect-strip--party { min-height: 16px; margin-top: 1px; } .timed-effect-strip--party { min-height: 16px; margin-top: 1px; }
.timed-effect-strip--party .timed-effect { width: 16px; height: 16px; } .timed-effect-strip--party .timed-effect { width: 16px; height: 16px; }
.timed-effect-strip--party .timed-effect__timer { font-size: 6px; line-height: 7px; } .timed-effect-strip--party .timed-effect__timer { font-size: 6px; line-height: 7px; }
.timed-effect-strip--party .timed-effect__stacks { font-size: 6px; line-height: 6px; }
.timed-effect-strip--target { justify-content: center; margin-top: 4px; } .timed-effect-strip--target { justify-content: center; margin-top: 4px; }
.timed-effect-strip--target + small { display: block; margin-top: 1px; } .timed-effect-strip--target + small { display: block; margin-top: 1px; }
.timed-effect-strip--world { width: 144px; justify-content: center; filter: drop-shadow(0 2px 2px rgba(0,0,0,.85)); } .timed-effect-strip--world { width: 144px; justify-content: center; filter: drop-shadow(0 2px 2px rgba(0,0,0,.85)); }
+11
View File
@@ -103,4 +103,15 @@ describe("aura strip visibility", () => {
expect(groupAurasForDisplay([first, second])).toEqual([second]); expect(groupAurasForDisplay([first, second])).toEqual([second]);
}); });
it("counts separate applications of the same debuff while collapsing different aura components", () => {
const poison = activeAura(aura({ id: "poison", name: "Poison", icon: "/poison.png" }));
const otherSource = { ...poison, instanceId: "poison-2", sourceId: "rogue-2", appliedAt: 2_000 };
const thirdSource = { ...poison, instanceId: "poison-3", sourceId: "rogue-3", appliedAt: 3_000 };
expect(groupAurasForDisplay([poison, otherSource, thirdSource])).toEqual([
{ ...thirdSource, stacks: 3 },
]);
const secondaryComponent = { ...poison, definition: aura({ id: "poison-secondary", name: "Poison", icon: "/poison.png" }) };
expect(groupAurasForDisplay([poison, secondaryComponent])[0]?.stacks).toBe(1);
});
}); });
+10 -9
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { abilityById } from "../game/abilityCatalog"; import { abilityById } from "../game/abilityCatalog";
import { PLAYER_AGGRO_ID } from "../game/aggro"; import { PLAYER_AGGRO_ID } from "../game/aggro";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
import { formatTimedEffectTimer, timedEffectRemainingMs } from "../game/timedEffectDisplay"; import { formatTimedEffectTimer, groupTimedEffectsForDisplay, timedEffectRemainingMs } from "../game/timedEffectDisplay";
import { AuraStrip } from "./AuraStrip"; import { AuraStrip } from "./AuraStrip";
import { import {
auraDisplaysInPlayerStatusPanel, auraDisplaysInPlayerStatusPanel,
@@ -33,19 +33,18 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
const activeAuras = groupAurasForDisplay(auras.filter((aura) => ( const activeAuras = groupAurasForDisplay(auras.filter((aura) => (
auraDisplaysInPlayerStatusPanel(aura, now) auraDisplaysInPlayerStatusPanel(aura, now)
))); )));
const activeEffects = effects.filter((effect) => ( const activeEffects = groupTimedEffectsForDisplay(effects.filter((effect) => (
PLAYER_EFFECT_TARGETS.includes(effect.targetId) PLAYER_EFFECT_TARGETS.includes(effect.targetId)
&& timedEffectRemainingMs(effect, now) > 0 )), now);
));
const shieldActive = shield > 0 && (shieldExpiresAt <= 0 || shieldExpiresAt > now); const shieldActive = shield > 0 && (shieldExpiresAt <= 0 || shieldExpiresAt > now);
const buffCount = activeAuras.filter((aura) => aura.definition.disposition === "buff").length const buffCount = activeAuras.filter((aura) => aura.definition.disposition === "buff").length
+ activeEffects.filter((effect) => effect.kind === "hot").length + activeEffects.filter(({ effect }) => effect.kind === "hot").length
+ (shieldActive ? 1 : 0); + (shieldActive ? 1 : 0);
const debuffCount = activeAuras.filter((aura) => aura.definition.disposition === "debuff").length const debuffCount = activeAuras.filter((aura) => aura.definition.disposition === "debuff").length
+ activeEffects.filter((effect) => effect.kind === "dot").length; + activeEffects.filter(({ effect }) => effect.kind === "dot").length;
const activeInspectionIds = new Set([ const activeInspectionIds = new Set([
...activeAuras.map((aura) => aura.instanceId), ...activeAuras.map((aura) => aura.instanceId),
...activeEffects.map((effect) => effect.id), ...activeEffects.map(({ effect }) => effect.id),
...(shieldActive ? ["player-absorb-shield"] : []), ...(shieldActive ? ["player-absorb-shield"] : []),
]); ]);
const inspectedIsActive = inspected ? activeInspectionIds.has(inspected.id) : true; const inspectedIsActive = inspected ? activeInspectionIds.has(inspected.id) : true;
@@ -79,8 +78,9 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
: {}), : {}),
} satisfies StatusEffectInspection; } satisfies StatusEffectInspection;
} }
const effect = activeEffects.find((candidate) => candidate.id === inspected.id); const displayedEffect = activeEffects.find(({ effect }) => effect.id === inspected.id);
if (effect) { if (displayedEffect) {
const { effect, stacks } = displayedEffect;
const ability = abilityById(effect.sourceAbilityId); const ability = abilityById(effect.sourceAbilityId);
return { return {
...inspected, ...inspected,
@@ -88,6 +88,7 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
disposition: effect.kind === "hot" ? "buff" : "debuff", disposition: effect.kind === "hot" ? "buff" : "debuff",
description: timedEffectDescription(effect.kind, ability?.description), description: timedEffectDescription(effect.kind, ability?.description),
remainingLabel: `${formatTimedEffectTimer(timedEffectRemainingMs(effect, now))} remaining`, remainingLabel: `${formatTimedEffectTimer(timedEffectRemainingMs(effect, now))} remaining`,
stacks,
} satisfies StatusEffectInspection; } satisfies StatusEffectInspection;
} }
return inspected; return inspected;
+8 -13
View File
@@ -3,7 +3,7 @@ import { abilityById } from "../game/abilityCatalog";
import { useCombatStore, type TimedCombatEffect } from "../game/combatStore"; import { useCombatStore, type TimedCombatEffect } from "../game/combatStore";
import { import {
formatTimedEffectTimer, formatTimedEffectTimer,
timedEffectRemainingMs, groupTimedEffectsForDisplay,
} from "../game/timedEffectDisplay"; } from "../game/timedEffectDisplay";
import { import {
timedEffectDescription, timedEffectDescription,
@@ -41,20 +41,14 @@ function LiveTimedEffectStrip({
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, []); }, []);
const visibleEffects = effects const visibleEffects = groupTimedEffectsForDisplay(effects, now);
.map((effect) => ({
effect,
ability: abilityById(effect.sourceAbilityId),
remainingMs: timedEffectRemainingMs(effect, now),
}))
.filter(({ remainingMs }) => remainingMs > 0)
.sort((left, right) => left.remainingMs - right.remainingMs);
if (!visibleEffects.length) return null; if (!visibleEffects.length) return null;
return ( return (
<span className={`timed-effect-strip ${className}`.trim()} aria-label="Periodic effects"> <span className={`timed-effect-strip ${className}`.trim()} aria-label="Periodic effects">
{visibleEffects.map(({ effect, ability, remainingMs }) => { {visibleEffects.map(({ effect, stacks, remainingMs }) => {
const ability = abilityById(effect.sourceAbilityId);
const label = ability?.name ?? effect.sourceAbilityId; const label = ability?.name ?? effect.sourceAbilityId;
const timer = formatTimedEffectTimer(remainingMs); const timer = formatTimedEffectTimer(remainingMs);
const disposition = effect.kind === "hot" ? "buff" : "debuff"; const disposition = effect.kind === "hot" ? "buff" : "debuff";
@@ -64,16 +58,16 @@ function LiveTimedEffectStrip({
disposition, disposition,
description: timedEffectDescription(effect.kind, ability?.description), description: timedEffectDescription(effect.kind, ability?.description),
remainingLabel: `${timer} remaining`, remainingLabel: `${timer} remaining`,
stacks: 1, stacks,
}; };
return ( return (
<span <span
key={effect.id} key={effect.id}
className={`timed-effect timed-effect--${effect.kind} ${onInspect ? "timed-effect--inspectable" : ""}`.trim()} className={`timed-effect timed-effect--${effect.kind} ${onInspect ? "timed-effect--inspectable" : ""}`.trim()}
tabIndex={onInspect ? 0 : undefined} tabIndex={onInspect ? 0 : undefined}
aria-label={`${label}, ${effect.kind === "hot" ? "healing" : "damage"} over time, ${timer} remaining`} aria-label={`${label}, ${effect.kind === "hot" ? "healing" : "damage"} over time${stacks > 1 ? `, ${stacks} stacks` : ""}, ${timer} remaining`}
aria-describedby={onInspect ? tooltipId : undefined} aria-describedby={onInspect ? tooltipId : undefined}
title={onInspect ? undefined : `${label} · ${timer} remaining`} title={onInspect ? undefined : `${label}${stacks > 1 ? ` · ${stacks} stacks` : ""} · ${timer} remaining`}
onPointerEnter={onInspect ? () => onInspect(inspection) : undefined} onPointerEnter={onInspect ? () => onInspect(inspection) : undefined}
onPointerLeave={onInspect ? (event) => { onPointerLeave={onInspect ? (event) => {
if (event.currentTarget !== document.activeElement) onInspect(null); if (event.currentTarget !== document.activeElement) onInspect(null);
@@ -84,6 +78,7 @@ function LiveTimedEffectStrip({
{ability?.icon {ability?.icon
? <img src={ability.icon} alt="" draggable={false} /> ? <img src={ability.icon} alt="" draggable={false} />
: <span className="timed-effect__fallback" aria-hidden="true">{effect.kind === "hot" ? "+" : "!"}</span>} : <span className="timed-effect__fallback" aria-hidden="true">{effect.kind === "hot" ? "+" : "!"}</span>}
{stacks > 1 ? <b className="timed-effect__stacks" aria-hidden="true">{stacks}</b> : null}
<b className="timed-effect__timer" aria-hidden="true">{timer}</b> <b className="timed-effect__timer" aria-hidden="true">{timer}</b>
</span> </span>
); );
+12 -10
View File
@@ -30,24 +30,26 @@ function auraDisplayExpiry(aura: ActiveAura): number {
/** Collapses a spell's multiple DBC aura effects and repeated sources into one HUD icon. */ /** Collapses a spell's multiple DBC aura effects and repeated sources into one HUD icon. */
export function groupAurasForDisplay(auras: readonly ActiveAura[]): ActiveAura[] { export function groupAurasForDisplay(auras: readonly ActiveAura[]): ActiveAura[] {
const grouped = new Map<string, ActiveAura>(); const grouped = new Map<string, { representative: ActiveAura; stacksByDefinition: Map<string, number> }>();
for (const aura of auras) { for (const aura of auras) {
const key = auraDisplayGroupKey(aura); const key = auraDisplayGroupKey(aura);
const existing = grouped.get(key); const existing = grouped.get(key);
if (!existing) { if (!existing) {
grouped.set(key, aura); grouped.set(key, { representative: aura, stacksByDefinition: new Map([[aura.definition.id, aura.stacks]]) });
continue; continue;
} }
const representative = auraDisplayExpiry(aura) > auraDisplayExpiry(existing) const representative = auraDisplayExpiry(aura) > auraDisplayExpiry(existing.representative)
|| (auraDisplayExpiry(aura) === auraDisplayExpiry(existing) && aura.appliedAt > existing.appliedAt) || (auraDisplayExpiry(aura) === auraDisplayExpiry(existing.representative) && aura.appliedAt > existing.representative.appliedAt)
? aura ? aura
: existing; : existing.representative;
const stacks = Math.max(existing.stacks, aura.stacks); existing.stacksByDefinition.set(aura.definition.id,
grouped.set(key, representative.stacks === stacks (existing.stacksByDefinition.get(aura.definition.id) ?? 0) + aura.stacks);
? representative existing.representative = representative;
: { ...representative, stacks });
} }
return [...grouped.values()]; return [...grouped.values()].map(({ representative, stacksByDefinition }) => {
const stacks = Math.max(...stacksByDefinition.values());
return representative.stacks === stacks ? representative : { ...representative, stacks };
});
} }
/** Permanent and one-minute-or-longer auras are persistent party-frame clutter. */ /** Permanent and one-minute-or-longer auras are persistent party-frame clutter. */