forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
48a678dcc5
commit
9c5fb4c614
18 files changed
+397
-46
No files matched your search
@@ -3920,3 +3920,15 @@ Vite's existing large-chunk advisory remains informational.
|
||||
the previous weighted selector could drop one of several cast or impact
|
||||
layers. A focused audio test confirms two impact files create two voices.
|
||||
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.
|
||||
@@ -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;
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||
import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
|
||||
|
||||
describe("channel amount expansion", () => {
|
||||
it("spreads a normal channel's full amount across its pulses", () => {
|
||||
@@ -22,4 +22,14 @@ describe("channel amount expansion", () => {
|
||||
: effect) };
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect }
|
||||
|
||||
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. */
|
||||
export function channelAmountEffects(ability: AbilityDefinition, totalTicks: number): readonly {
|
||||
effect: ChannelAmountEffect;
|
||||
@@ -11,7 +15,9 @@ export function channelAmountEffects(ability: AbilityDefinition, totalTicks: num
|
||||
const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = [];
|
||||
for (const effect of ability.effects) {
|
||||
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") {
|
||||
for (const proc of effect.aura.procs ?? []) {
|
||||
const triggers = Array.isArray(proc.triggers) ? proc.triggers : [proc.triggers];
|
||||
|
||||
@@ -139,7 +139,7 @@ import { rollBossLoot } from "./lootCatalog";
|
||||
import { bossCardForSource } from "./bossCardCatalog";
|
||||
import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards";
|
||||
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
|
||||
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||
import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
|
||||
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||
import {
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
@@ -5166,7 +5166,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
|
||||
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(
|
||||
ability,
|
||||
modifiers,
|
||||
@@ -5217,9 +5217,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|| (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")),
|
||||
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
|
||||
groundPosition,
|
||||
totalTicks: ability.castMode === "channel" ? (ability.name === "Divine Hymn" ? 4 : 5) : 0,
|
||||
totalTicks: ability.castMode === "channel" ? channelPulseCount(ability) : 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 };
|
||||
if (activeCast.mode === "channel" && cooldownMs > 0) {
|
||||
|
||||
@@ -65,7 +65,7 @@ import { imminentCooldownMechanic } from "./companionCooldownPolicy";
|
||||
import { normalizeActorResourcePool } from "./combatActors";
|
||||
import { enemyControlPreventsActions } from "./enemyAttacks";
|
||||
import { advanceCombatResourcePool } from "./combatResources";
|
||||
import { channelAmountEffects } from "./combatChannelAmounts";
|
||||
import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
|
||||
import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingRecipientIds } from "./healingAbilityRules";
|
||||
import {
|
||||
combatHasLineOfSight,
|
||||
@@ -1812,7 +1812,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 };
|
||||
continue;
|
||||
}
|
||||
const totalTicks = castingAbility.name === "Divine Hymn" ? 4 : 5;
|
||||
const totalTicks = channelPulseCount(castingAbility);
|
||||
const interval = Math.max(1, cast.originalDurationMs / totalTicks);
|
||||
let completed = cast.channelTicksCompleted ?? 0;
|
||||
while (completed < totalTicks && cast.startedAt + (completed + 1) * interval <= now) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { overrideRomPriestKnightEffects, romPriestKnightAdditionalAbilities } from "./romPriestKnightOverrides";
|
||||
import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides";
|
||||
import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
|
||||
import { romPriestDescription } from "./romPriestTooltips";
|
||||
|
||||
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
||||
|
||||
@@ -691,9 +692,9 @@ function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition
|
||||
rank: row.rank,
|
||||
spellId: row.sourceSkillId,
|
||||
level: row.level,
|
||||
description: row.description,
|
||||
castMode: row.castTimeMs > 0 ? "cast" : "instant",
|
||||
castTimeMs: row.castTimeMs,
|
||||
description: parent.classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description,
|
||||
castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant",
|
||||
castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs,
|
||||
cooldownMs: row.cooldownMs,
|
||||
gcdMs: 1_500,
|
||||
cost: rankCosts[0] ?? { resource: "mana", amount: 0 },
|
||||
@@ -722,15 +723,15 @@ function ability(row: GeneratedSkill): AbilityDefinition {
|
||||
unlockLevel: row.unlockLevel,
|
||||
icon: row.icon,
|
||||
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,
|
||||
range: { min: 0, max: row.range },
|
||||
...(sourceTargeting(row.sourceSkillId)?.radius !== undefined
|
||||
? { radius: sourceTargeting(row.sourceSkillId)!.radius }
|
||||
: areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
|
||||
...(sourceTargeting(row.sourceSkillId) ? { targeting: sourceTargeting(row.sourceSkillId) } : {}),
|
||||
castMode: row.castTimeMs > 0 ? "cast" : "instant",
|
||||
castTimeMs: row.castTimeMs,
|
||||
castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant",
|
||||
castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs,
|
||||
cooldownMs: row.cooldownMs,
|
||||
gcdMs: 1_500,
|
||||
cost: abilityCosts[0] ?? { resource: "mana", amount: 0 },
|
||||
|
||||
@@ -37,6 +37,15 @@ function isMeaningfulEffect(effect: AbilityEffect): boolean {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
expect(named("Taunt").effects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "taunt" }),
|
||||
|
||||
@@ -109,6 +109,26 @@ describe("RuneWaker combat runtime", () => {
|
||||
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", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 50 });
|
||||
usePartyStore.getState().initializeParty("rom-self-drain", 50, useCombatStore.getState().health, "healer", 2, "rom");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { TimedCombatEffect } from "./combatStore";
|
||||
import {
|
||||
formatTimedEffectTimer,
|
||||
groupTimedEffectsForDisplay,
|
||||
timedEffectExpiresAt,
|
||||
timedEffectRemainingMs,
|
||||
} from "./timedEffectDisplay";
|
||||
@@ -37,4 +38,18 @@ describe("timed effect display", () => {
|
||||
expect(formatTimedEffectTimer(9_940)).toBe("9.9");
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,36 @@ export function timedEffectRemainingMs(effect: TimedCombatEffect, now: number):
|
||||
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. */
|
||||
export function formatTimedEffectTimer(remainingMs: number): string {
|
||||
const safeRemaining = Math.max(0, Number.isFinite(remainingMs) ? remainingMs : 0);
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
} from "three";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import {
|
||||
combatEffectStyle,
|
||||
preloadCombatEffectManifest,
|
||||
@@ -51,6 +54,55 @@ const QUALITY_PARTICLE_COUNT: Readonly<Record<Exclude<CombatVfxQuality, "off">,
|
||||
|
||||
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 {
|
||||
if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900));
|
||||
if (event.phase === "tick") return 320;
|
||||
@@ -250,6 +302,10 @@ const CombatEffectPrimitive = memo(function CombatEffectPrimitive({
|
||||
export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) {
|
||||
const settings = useShellStore((state) => state.combatPresentationSettings);
|
||||
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 settingsRef = useRef(settings);
|
||||
const activeRef = useRef(active);
|
||||
@@ -362,6 +418,10 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
if (effectiveQuality === "off") return null;
|
||||
return (
|
||||
<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) => (
|
||||
effect.originalOnly && vfxMode !== "original" ? null :
|
||||
vfxMode === "original" ? (
|
||||
|
||||
@@ -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__fallback { color: #f1ba92; font-size: 12px; font-weight: 900; }
|
||||
.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 {
|
||||
position: absolute;
|
||||
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 .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__stacks { font-size: 6px; line-height: 6px; }
|
||||
.timed-effect-strip--target { justify-content: center; margin-top: 4px; }
|
||||
.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)); }
|
||||
|
||||
@@ -103,4 +103,15 @@ describe("aura strip visibility", () => {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { abilityById } from "../game/abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "../game/aggro";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { formatTimedEffectTimer, timedEffectRemainingMs } from "../game/timedEffectDisplay";
|
||||
import { formatTimedEffectTimer, groupTimedEffectsForDisplay, timedEffectRemainingMs } from "../game/timedEffectDisplay";
|
||||
import { AuraStrip } from "./AuraStrip";
|
||||
import {
|
||||
auraDisplaysInPlayerStatusPanel,
|
||||
@@ -33,19 +33,18 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
|
||||
const activeAuras = groupAurasForDisplay(auras.filter((aura) => (
|
||||
auraDisplaysInPlayerStatusPanel(aura, now)
|
||||
)));
|
||||
const activeEffects = effects.filter((effect) => (
|
||||
const activeEffects = groupTimedEffectsForDisplay(effects.filter((effect) => (
|
||||
PLAYER_EFFECT_TARGETS.includes(effect.targetId)
|
||||
&& timedEffectRemainingMs(effect, now) > 0
|
||||
));
|
||||
)), now);
|
||||
const shieldActive = shield > 0 && (shieldExpiresAt <= 0 || shieldExpiresAt > now);
|
||||
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);
|
||||
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([
|
||||
...activeAuras.map((aura) => aura.instanceId),
|
||||
...activeEffects.map((effect) => effect.id),
|
||||
...activeEffects.map(({ effect }) => effect.id),
|
||||
...(shieldActive ? ["player-absorb-shield"] : []),
|
||||
]);
|
||||
const inspectedIsActive = inspected ? activeInspectionIds.has(inspected.id) : true;
|
||||
@@ -79,8 +78,9 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
|
||||
: {}),
|
||||
} satisfies StatusEffectInspection;
|
||||
}
|
||||
const effect = activeEffects.find((candidate) => candidate.id === inspected.id);
|
||||
if (effect) {
|
||||
const displayedEffect = activeEffects.find(({ effect }) => effect.id === inspected.id);
|
||||
if (displayedEffect) {
|
||||
const { effect, stacks } = displayedEffect;
|
||||
const ability = abilityById(effect.sourceAbilityId);
|
||||
return {
|
||||
...inspected,
|
||||
@@ -88,6 +88,7 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
|
||||
disposition: effect.kind === "hot" ? "buff" : "debuff",
|
||||
description: timedEffectDescription(effect.kind, ability?.description),
|
||||
remainingLabel: `${formatTimedEffectTimer(timedEffectRemainingMs(effect, now))} remaining`,
|
||||
stacks,
|
||||
} satisfies StatusEffectInspection;
|
||||
}
|
||||
return inspected;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { abilityById } from "../game/abilityCatalog";
|
||||
import { useCombatStore, type TimedCombatEffect } from "../game/combatStore";
|
||||
import {
|
||||
formatTimedEffectTimer,
|
||||
timedEffectRemainingMs,
|
||||
groupTimedEffectsForDisplay,
|
||||
} from "../game/timedEffectDisplay";
|
||||
import {
|
||||
timedEffectDescription,
|
||||
@@ -41,20 +41,14 @@ function LiveTimedEffectStrip({
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const visibleEffects = effects
|
||||
.map((effect) => ({
|
||||
effect,
|
||||
ability: abilityById(effect.sourceAbilityId),
|
||||
remainingMs: timedEffectRemainingMs(effect, now),
|
||||
}))
|
||||
.filter(({ remainingMs }) => remainingMs > 0)
|
||||
.sort((left, right) => left.remainingMs - right.remainingMs);
|
||||
const visibleEffects = groupTimedEffectsForDisplay(effects, now);
|
||||
|
||||
if (!visibleEffects.length) return null;
|
||||
|
||||
return (
|
||||
<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 timer = formatTimedEffectTimer(remainingMs);
|
||||
const disposition = effect.kind === "hot" ? "buff" : "debuff";
|
||||
@@ -64,16 +58,16 @@ function LiveTimedEffectStrip({
|
||||
disposition,
|
||||
description: timedEffectDescription(effect.kind, ability?.description),
|
||||
remainingLabel: `${timer} remaining`,
|
||||
stacks: 1,
|
||||
stacks,
|
||||
};
|
||||
return (
|
||||
<span
|
||||
key={effect.id}
|
||||
className={`timed-effect timed-effect--${effect.kind} ${onInspect ? "timed-effect--inspectable" : ""}`.trim()}
|
||||
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}
|
||||
title={onInspect ? undefined : `${label} · ${timer} remaining`}
|
||||
title={onInspect ? undefined : `${label}${stacks > 1 ? ` · ${stacks} stacks` : ""} · ${timer} remaining`}
|
||||
onPointerEnter={onInspect ? () => onInspect(inspection) : undefined}
|
||||
onPointerLeave={onInspect ? (event) => {
|
||||
if (event.currentTarget !== document.activeElement) onInspect(null);
|
||||
@@ -84,6 +78,7 @@ function LiveTimedEffectStrip({
|
||||
{ability?.icon
|
||||
? <img src={ability.icon} alt="" draggable={false} />
|
||||
: <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>
|
||||
</span>
|
||||
);
|
||||
|
||||
+12
-10
@@ -30,24 +30,26 @@ function auraDisplayExpiry(aura: ActiveAura): number {
|
||||
|
||||
/** Collapses a spell's multiple DBC aura effects and repeated sources into one HUD icon. */
|
||||
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) {
|
||||
const key = auraDisplayGroupKey(aura);
|
||||
const existing = grouped.get(key);
|
||||
if (!existing) {
|
||||
grouped.set(key, aura);
|
||||
grouped.set(key, { representative: aura, stacksByDefinition: new Map([[aura.definition.id, aura.stacks]]) });
|
||||
continue;
|
||||
}
|
||||
const representative = auraDisplayExpiry(aura) > auraDisplayExpiry(existing)
|
||||
|| (auraDisplayExpiry(aura) === auraDisplayExpiry(existing) && aura.appliedAt > existing.appliedAt)
|
||||
const representative = auraDisplayExpiry(aura) > auraDisplayExpiry(existing.representative)
|
||||
|| (auraDisplayExpiry(aura) === auraDisplayExpiry(existing.representative) && aura.appliedAt > existing.representative.appliedAt)
|
||||
? aura
|
||||
: existing;
|
||||
const stacks = Math.max(existing.stacks, aura.stacks);
|
||||
grouped.set(key, representative.stacks === stacks
|
||||
? representative
|
||||
: { ...representative, stacks });
|
||||
: existing.representative;
|
||||
existing.stacksByDefinition.set(aura.definition.id,
|
||||
(existing.stacksByDefinition.get(aura.definition.id) ?? 0) + aura.stacks);
|
||||
existing.representative = representative;
|
||||
}
|
||||
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. */
|
||||
|
||||
Reference in new issue
Block a user