diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index ab8aef45..6d78bd0e 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -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. diff --git a/scripts/playtest-rom-status-effects.cjs b/scripts/playtest-rom-status-effects.cjs new file mode 100644 index 00000000..4571d672 --- /dev/null +++ b/scripts/playtest-rom-status-effects.cjs @@ -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; +}); diff --git a/src/game/combatChannelAmounts.test.ts b/src/game/combatChannelAmounts.test.ts index 9cc2f65e..9c223035 100644 --- a/src/game/combatChannelAmounts.test.ts +++ b/src/game/combatChannelAmounts.test.ts @@ -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 }, + ]); + }); }); diff --git a/src/game/combatChannelAmounts.ts b/src/game/combatChannelAmounts.ts index e6122a0d..16a57ec2 100644 --- a/src/game/combatChannelAmounts.ts +++ b/src/game/combatChannelAmounts.ts @@ -2,6 +2,10 @@ import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect } type ChannelAmountEffect = Extract; +export function channelPulseCount(ability: Pick): 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]; diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 15ede629..dc57aa25 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -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((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((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 = { ...state.cooldowns }; if (activeCast.mode === "channel" && cooldownMs > 0) { diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts index e55f216b..f7ffb638 100644 --- a/src/game/partyRuntime.ts +++ b/src/game/partyRuntime.ts @@ -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) { diff --git a/src/game/romAbilityCatalog.ts b/src/game/romAbilityCatalog.ts index 2e2cc03d..21feced0 100644 --- a/src/game/romAbilityCatalog.ts +++ b/src/game/romAbilityCatalog.ts @@ -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 }, diff --git a/src/game/romAbilityCatalogSemantics.test.ts b/src/game/romAbilityCatalogSemantics.test.ts index 230103c3..e1a1015e 100644 --- a/src/game/romAbilityCatalogSemantics.test.ts +++ b/src/game/romAbilityCatalogSemantics.test.ts @@ -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" }), diff --git a/src/game/romCombatRuntime.test.ts b/src/game/romCombatRuntime.test.ts index f93d245c..d5c49104 100644 --- a/src/game/romCombatRuntime.test.ts +++ b/src/game/romCombatRuntime.test.ts @@ -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"); diff --git a/src/game/romPriestTooltips.ts b/src/game/romPriestTooltips.ts new file mode 100644 index 00000000..f6f82bb8 --- /dev/null +++ b/src/game/romPriestTooltips.ts @@ -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> = { + 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; +} diff --git a/src/game/timedEffectDisplay.test.ts b/src/game/timedEffectDisplay.test.ts index ec88dca3..838a6a5c 100644 --- a/src/game/timedEffectDisplay.test.ts +++ b/src/game/timedEffectDisplay.test.ts @@ -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); + }); }); diff --git a/src/game/timedEffectDisplay.ts b/src/game/timedEffectDisplay.ts index 827bbbde..44475293 100644 --- a/src/game/timedEffectDisplay.ts +++ b/src/game/timedEffectDisplay.ts @@ -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(); + 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); diff --git a/src/scene/CombatEffects.tsx b/src/scene/CombatEffects.tsx index 987a0fdd..f7a9582e 100644 --- a/src/scene/CombatEffects.tsx +++ b/src/scene/CombatEffects.tsx @@ -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, 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(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 ( + + + + + + + + + + + ); +} + 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([]); 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 ( + {active && settings.vfxEnabled.player && chainOfLightChanneling ? : null} + {active && settings.vfxEnabled.party && partyChainCasterIds + ? partyChainCasterIds.split("\u0000").map((actorId) => ) + : null} {effects.map((effect) => ( effect.originalOnly && vfxMode !== "original" ? null : vfxMode === "original" ? ( diff --git a/src/styles.css b/src/styles.css index 96a02e0b..b5d4e23e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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)); } diff --git a/src/ui/AuraStrip.test.ts b/src/ui/AuraStrip.test.ts index eef9c5e9..6a91b09b 100644 --- a/src/ui/AuraStrip.test.ts +++ b/src/ui/AuraStrip.test.ts @@ -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); + }); }); diff --git a/src/ui/PlayerStatusPanel.tsx b/src/ui/PlayerStatusPanel.tsx index 81dc1cfe..beb9f8e9 100644 --- a/src/ui/PlayerStatusPanel.tsx +++ b/src/ui/PlayerStatusPanel.tsx @@ -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; diff --git a/src/ui/TimedEffectStrip.tsx b/src/ui/TimedEffectStrip.tsx index 0a313f87..bcd3ffb2 100644 --- a/src/ui/TimedEffectStrip.tsx +++ b/src/ui/TimedEffectStrip.tsx @@ -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 ( - {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 ( 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 ? : } + {stacks > 1 ? : null} ); diff --git a/src/ui/auraDisplay.ts b/src/ui/auraDisplay.ts index 46763a40..86c3db00 100644 --- a/src/ui/auraDisplay.ts +++ b/src/ui/auraDisplay.ts @@ -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(); + const grouped = new Map }>(); 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. */