Release Healer Man 0.1.38

This commit is contained in:
phenom committed 2026-09-12 23:55:24 -04:00
1 parent 361734bc41
commit b417b7c3c3
48 files changed
+1982 -90

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DUNGEON_REPORT_STATUSES, fetchDungeonReports, filterDungeonReports, sessionCanManageDungeonReports,
submitDungeonReport, updateDungeonReport, type DungeonReport,
} from "./dungeonReports";
import { useGameStore } from "../game/store";
function report(id: string, dungeonId: string, status: DungeonReport["status"]): DungeonReport {
return { id, dungeonId, status, comment: "Door does not open", submittedBy: "Player", createdAt: 1, updatedAt: 1, updatedBy: null, revision: 0 };
}
afterEach(() => { vi.unstubAllGlobals(); useGameStore.getState().closeOverlay(); });
describe("dungeon reports", () => {
it("shows all statuses by default, including Closed, and combines status and dungeon filters", () => {
const reports = DUNGEON_REPORT_STATUSES.flatMap((status, index) => [
report(`stock-${index}`, "stormwind-stockade", status), report(`cave-${index}`, "wailing-caverns", status),
]);
expect(filterDungeonReports(reports, "all", "all")).toEqual(reports);
expect(filterDungeonReports(reports, "Closed", "all")).toHaveLength(2);
expect(filterDungeonReports(reports, "all", "wailing-caverns")).toHaveLength(4);
expect(filterDungeonReports(reports, "Testing Fixes", "stormwind-stockade").map((item) => item.id)).toEqual(["stock-2"]);
expect(filterDungeonReports(reports, "Submitted", "missing")).toEqual([]);
});
it("allows GM report management in the ordinary release and rejects offline or local-only authority", () => {
const gm = { kind: "account" as const, ownerId: "gm", displayName: "Phenom", accessToken: "token", roles: ["gm" as const] };
expect(sessionCanManageDungeonReports(gm)).toBe(true);
expect(sessionCanManageDungeonReports({ ...gm, roles: [] })).toBe(false);
expect(sessionCanManageDungeonReports({ ...gm, kind: "offline" })).toBe(false);
expect(sessionCanManageDungeonReports({ ...gm, accessToken: undefined })).toBe(false);
expect(sessionCanManageDungeonReports(null)).toBe(false);
});
it("sends the active dungeon and preserves the expected report revision for GM updates", async () => {
const item = report("report-id", "stormwind-stockade", "Submitted");
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ report: item }) });
vi.stubGlobal("fetch", fetchMock);
const gm = { kind: "account" as const, ownerId: "gm", displayName: "Phenom", accessToken: "token", roles: ["gm" as const] };
await submitDungeonReport(gm, item.dungeonId, "Door does not open");
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ dungeonId: item.dungeonId, comment: "Door does not open" });
expect(fetchMock.mock.calls[0][1].headers.get("Authorization")).toBe("Bearer token");
await updateDungeonReport(gm, { ...item, revision: 3 }, "Closed");
expect(fetchMock.mock.calls[1][1].method).toBe("PATCH");
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ status: "Closed", expectedRevision: 3 });
await fetchDungeonReports();
expect(fetchMock.mock.calls[2][0]).toBe("/api/dungeon-reports");
});
it("keeps the game paused and camera look stopped while reporting and returning to pause", () => {
const game = useGameStore.getState();
game.setCameraLookActive(true);
game.openOverlay("report-issue");
expect(useGameStore.getState()).toMatchObject({ paused: true, overlay: "report-issue", cameraLookActive: false });
game.openOverlay("pause");
expect(useGameStore.getState()).toMatchObject({ paused: true, overlay: "pause", cameraLookActive: false });
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { PlayerSession } from "./types";
import { requestOnlineJson } from "./onlineAccountClient";
import policy from "../../shared/dungeon-report-policy.json";
export type DungeonReportStatus = "Submitted" | "In-Progress" | "Testing Fixes" | "Closed";
export const DUNGEON_REPORT_STATUSES = policy.statuses as readonly DungeonReportStatus[];
export const DUNGEON_REPORT_COMMENT_MAX_LENGTH = policy.commentMaxLength;
export interface DungeonReport {
readonly id: string;
readonly dungeonId: string;
readonly comment: string;
readonly status: DungeonReportStatus;
readonly submittedBy: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly updatedBy: string | null;
readonly revision: number;
}
export interface DungeonReportSnapshot {
readonly reports: readonly DungeonReport[];
readonly openCounts: Readonly<Record<string, number>>;
}
export function sessionCanManageDungeonReports(session: PlayerSession | null): boolean {
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
}
export function filterDungeonReports(
reports: readonly DungeonReport[],
status: DungeonReportStatus | "all",
dungeonId: string,
): readonly DungeonReport[] {
return reports.filter((report) => (status === "all" || report.status === status)
&& (dungeonId === "all" || report.dungeonId === dungeonId));
}
export function fetchDungeonReports(): Promise<DungeonReportSnapshot> {
return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports");
}
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string): Promise<DungeonReport> {
const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", {
method: "POST",
body: JSON.stringify({ dungeonId, comment }),
}, session?.kind === "account" ? session.accessToken : undefined);
return response.report;
}
export async function updateDungeonReport(
session: PlayerSession,
report: DungeonReport,
status: DungeonReportStatus,
): Promise<DungeonReport> {
const response = await requestOnlineJson<{ report: DungeonReport }>(`/api/dungeon-reports/${encodeURIComponent(report.id)}`, {
method: "PATCH",
body: JSON.stringify({ status, expectedRevision: report.revision }),
}, session.accessToken);
return response.report;
}
+10
View File
@@ -14,6 +14,16 @@ import {
} from "./coaLiveCatalog";
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
it("keeps Drakkari Barrier friendly and its retaliation in the damage-taken proc", () => {
const barrier = COA_ABILITIES_BY_CLASS["witch-doctor"].find((ability) => ability.name === "Drakkari Barrier")!;
expect(barrier.target).toBe("friendly");
for (const rank of barrier.ranks ?? []) {
expect(rank.effects.some((effect) => effect.kind === "damage")).toBe(false);
expect(rank.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.procs?.some((proc) => proc.triggers === "damage-taken" && proc.action.kind === "damage"))).toBe(true);
}
});
const ranksFor = (ability: AbilityDefinition): readonly AbilityRankDefinition[] => ability.ranks ?? [{
rank: 1,
spellId: ability.dbcSpellId,
+2 -1
View File
@@ -987,7 +987,8 @@ function effectsForSpell(
}
const hasHealing = effects.some((effect) => ["heal", "hot"].includes(effect.kind));
const hasDamage = effects.some((effect) => ["damage", "dot"].includes(effect.kind));
const hasDamage = effects.some((effect) => ["damage", "dot"].includes(effect.kind)
|| (effect.kind === "apply-aura" && effect.aura.procs?.some((proc) => proc.action.kind === "damage")));
const describesHealing = /\b(?:heal(?:s|ing|ed)?|restores? .*health|mends?|rejuvenat\w*)\b/i.test(description);
const describesDamage = /\b(?:deal(?:s|ing)?|inflict(?:s|ing)?|caus(?:e|es|ing)|damag(?:e|es|ing))\b[^.]*\bdamage\b/i.test(description);
if (!hasHealing && describesHealing) effects.unshift({ kind: "heal", coefficient: 1 });
+1
View File
@@ -33,6 +33,7 @@ export interface CombatActorCastState {
/** Simulation-space origin used for movement cancellation and completion validation. */
readonly origin?: CombatActorPosition;
readonly targetId?: string | null;
readonly channelTicksCompleted?: number;
}
/**
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { abilitiesForClass } from "./abilityCatalog";
import { channelAmountEffects } from "./combatChannelAmounts";
describe("channel amount expansion", () => {
it("spreads a normal channel's full amount across its pulses", () => {
const ability = abilitiesForClass("mage")[0];
const effects = channelAmountEffects({ ...ability, effects: [
{ kind: "damage", coefficient: 1, basePoints: 100 },
{ kind: "dot", coefficient: 1, ticks: 5, intervalMs: 1_000, tag: "channel" },
] }, 5);
expect(effects.map(({ multiplier }) => multiplier)).toEqual([0.2, 1]);
});
it("uses the authored periodic payload for Radiant Flame", () => {
const ability = abilitiesForClass("sun-cleric").find((candidate) => candidate.name === "Radiant Flame")!;
const amounts = channelAmountEffects(ability, 5);
expect(amounts).toHaveLength(1);
expect(amounts[0]).toMatchObject({ effect: { kind: "damage", basePoints: 1 }, multiplier: 1 });
const conditional = { ...ability, effects: ability.effects.map((effect) => effect.kind === "apply-aura"
? { ...effect, aura: { ...effect.aura, procs: effect.aura.procs?.map((proc) => ({ ...proc, chance: 0.5 })) } }
: effect) };
expect(channelAmountEffects(conditional, 5)).toEqual([]);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>;
/** Direct channel amounts span the cast; periodic trigger payloads fire each pulse. */
export function channelAmountEffects(ability: AbilityDefinition, totalTicks: number): readonly {
effect: ChannelAmountEffect;
multiplier: number;
}[] {
const ticks = Math.max(1, totalTicks);
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 });
} else if (effect.kind === "apply-aura") {
for (const proc of effect.aura.procs ?? []) {
const triggers = Array.isArray(proc.triggers) ? proc.triggers : [proc.triggers];
const action = proc.action;
if (!triggers.includes("periodic") || proc.chance !== 1 || proc.internalCooldownMs || proc.charges !== undefined
|| action.kind !== "custom"
|| !["wow335-trigger-spell", "rom-trigger-skill"].includes(action.id)
|| typeof action.data?.spellId !== "number") continue;
const payload = triggeredAbilityBySpellId(action.data.spellId);
for (const payloadEffect of payload?.effects ?? []) {
if (payloadEffect.kind === "damage" || payloadEffect.kind === "heal") {
amounts.push({ effect: payloadEffect, multiplier: 1 });
}
}
}
}
}
return amounts;
}
+60 -16
View File
@@ -129,6 +129,7 @@ import {
} from "./combatAuras";
import { rollBossLoot } from "./lootCatalog";
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
import { channelAmountEffects } from "./combatChannelAmounts";
import { normalizeInventory, type InventoryItem } from "./lootTypes";
import {
PLAYER_EQUIPMENT_OWNER_ID,
@@ -721,6 +722,8 @@ export interface CombatState {
damageSummon: (id: string, amount: number) => number;
healPlayer: (amount: number) => number;
revivePlayer: (percentMaxHealth?: number) => number;
/** Checkpoint recovery of the player only; keeps the party and current pull intact. */
respawnPlayerOnly: (position: CombatPosition, now?: number) => boolean;
cancelCast: (message?: string) => boolean;
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
@@ -1183,7 +1186,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
const physicalKeyword = /\b(?:strike|slash|stab|shot|arrow|mangle|claw|bite|weapon|melee)\b/.test(searchable);
const rangedPhysicalKeyword = /\b(?:shot|arrow|bow|gun|bullet|javelin)\b/.test(searchable);
if (healing) return "spell";
if (ability.range.max > 4 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
if (ability.range.max > 5 && (rangedPhysicalKeyword || baseClassFor(classId) === "hunter")) return "ranged";
if (["warrior", "rogue", "death-knight"].includes(baseClassFor(classId)) || physicalKeyword) return "melee";
return "spell";
}
@@ -1202,7 +1205,7 @@ export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition
return "arcane";
}
function equipmentAdjustedCombatAmount(
export function equipmentAdjustedCombatAmount(
scaling: number | (AbilityAmountScaling & { readonly coefficient: number }),
level: number,
gearStats: ItemStats,
@@ -6106,12 +6109,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const sourceActor = state.actors[sourceActorId];
const sourceMember = usePartyStore.getState().members.find((member) => member.id === sourceActorId);
const amount = sourceMember
? Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * (
sourceMember.level * 4
+ sourceMember.gearStats.spellPower
+ sourceMember.gearStats.attackPower * 0.35
+ sourceMember.gearStats.rangedAttackPower * 0.35
)))
? equipmentAdjustedCombatAmount(effect, sourceMember.level, sourceMember.gearStats, sourceMember.classId, ability)
: Math.max(1, Math.round((effect.basePoints ?? 0) + Math.max(0.1, effect.coefficient) * sourceActor.level * 4));
const next = state.effects.filter((entry) => !(
entry.kind === effect.kind
@@ -6417,6 +6415,51 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return restored;
},
respawnPlayerOnly: (position, now = combatNow()) => {
const state = get();
if (state.health > 0 || state.maxHealth <= 0 || !Number.isFinite(now)
|| position.length !== 3 || !position.every(Number.isFinite)) return false;
const auras = syncPassiveAuras(state.auras.filter((aura) => (
aura.targetId !== PLAYER_AURA_ENTITY_ID || aura.definition.disposition === "buff"
)), state.abilities, state.level, state.talentRanks, now);
const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers,
state.gearStats, state.raceId, state.secondaryClassId, auras);
const patch: Partial<CombatState> = {
health: maxHealth,
maxHealth,
resource: initialResourceForMaximum(state.classId, state.maxResource),
resourcePools: Object.fromEntries(Object.entries(state.maxResourcePools).map(([type, maximum]) => [
type, type === resourceProfileForClass(state.classId).type
? initialResourceForMaximum(state.classId, maximum ?? state.maxResource) : 0,
])),
controlledUntil: 0,
controlMechanic: null,
schoolLockedUntil: {},
shield: 0,
shieldExpiresAt: 0,
activeCast: null,
pendingGroundTarget: null,
autoAttackTargetId: null,
nextAutoAttackAt: 0,
comboPoints: 0,
comboTargetId: null,
playerPosition: [...position],
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
effects: state.effects.filter((effect) => effect.targetId !== null && effect.targetId !== PLAYER_AURA_ENTITY_ID),
auras,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: now, kind: "resurrection", sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "player-checkpoint-resurrection",
rawAmount: maxHealth, effectiveAmount: maxHealth, overheal: 0, absorbed: 0, critical: false,
}),
feedback: nextFeedback("success", "You have revived. Your party is still fighting; use Recall only if you want to regroup."),
};
// Publish only the player snapshot; never replace or mirror companion/enemy actors.
const player = combatActorSnapshots({ ...state, ...patch })[PLAYER_AURA_ENTITY_ID];
set({ ...patch, actors: { ...state.actors, [PLAYER_AURA_ENTITY_ID]: player } });
return true;
},
cancelCast: (message = "Casting interrupted.") => {
const cast = get().activeCast;
if (!cast) return false;
@@ -8151,30 +8194,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: "Channel interrupted: target unavailable.");
return;
}
const damageAmount = ability.effects.reduce(
(total, effect) => total + (effect.kind === "damage" || effect.kind === "dot"
const channelEffects = channelAmountEffects(ability, cast.totalTicks);
const damageAmount = channelEffects.reduce(
(total, { effect, multiplier }) => total + (effect.kind === "damage" || effect.kind === "dot"
? equipmentAdjustedCombatAmount(
effect,
get().level,
get().gearStats,
get().classId,
ability,
) * (effect.kind === "dot" ? effect.ticks : 1)
) * multiplier
: 0),
0,
) / cast.totalTicks;
const healingAmount = ability.effects.reduce(
(total, effect) => total + (effect.kind === "heal" || effect.kind === "hot"
);
const healingAmount = channelEffects.reduce(
(total, { effect, multiplier }) => total + (effect.kind === "heal" || effect.kind === "hot"
? equipmentAdjustedCombatAmount(
effect,
get().level,
get().gearStats,
get().classId,
ability,
) * (effect.kind === "hot" ? effect.ticks : 1)
) * multiplier
: 0),
0,
) / cast.totalTicks;
);
if (damageAmount > 0) {
const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
const channelTargets = ability.radius === undefined
+24 -6
View File
@@ -1,23 +1,41 @@
import { useCombatStore } from "./combatStore";
import { useCombatStore, type MobCombatState } from "./combatStore";
import type { CombatActorState } from "./combatActors";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { useGameStore } from "./store";
import { resolveResurrectionCheckpoint } from "./resurrectionCheckpoints";
import { usePartyStore, type PartyMember } from "./partyStore";
import {
activeDungeonCompletedObjectiveIds,
resetDungeonEncounters,
} from "./dungeonEncounterRuntime";
/**
* Atomically returns a defeated player to the saved run spawn and resets the
* current pull. Defeated enemies and persisted dungeon progress remain clear,
* matching a wipe instead of silently starting a brand-new dungeon.
*/
export function partyFightStillActive(
members: readonly Pick<PartyMember, "id" | "health">[],
mobs: Readonly<Record<string, MobCombatState>>,
actors: Readonly<Record<string, Pick<CombatActorState, "health">>> = {},
): boolean {
return members.some((member) => (actors[member.id]?.health ?? member.health) > 0)
&& Object.values(mobs).some((mob) => mob.engaged && !mob.dead && !mob.despawned);
}
/** Recover only the player during an ongoing party fight; otherwise retain wipe recovery. */
export function respawnDefeatedPlayer(checkpointId?: string): boolean {
const combat = useCombatStore.getState();
if (combat.health > 0) return false;
const game = useGameStore.getState();
if (game.gameMode !== "dungeon") return false;
const checkpoint = checkpointId === undefined ? null : resolveResurrectionCheckpoint(checkpointId);
if (checkpointId !== undefined && !checkpoint) return false;
if (partyFightStillActive(usePartyStore.getState().members, combat.mobs, combat.actors)) {
const spawn = checkpoint?.spawn ?? game.activeSpawn;
if (!combat.respawnPlayerOnly(spawn.footPosition)) return false;
// Relocation does not fire the resetRevision watched by PartyBridge/PartyPopulation.
useGameStore.getState().relocatePlayer(spawn, true);
useGameStore.getState().closeOverlay();
return true;
}
const dungeonId = useGameStore.getState().activeDungeonId;
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
const retrySpawnIds = new Set(
+38 -6
View File
@@ -7,16 +7,22 @@ import {
} from "./dungeonCatalog";
import { DUNGEON_MANIFEST } from "./dungeonManifest";
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
import { compareDungeonReleaseOrder } from "./dungeonReleaseStage";
import availability from "./generated/dungeonAvailability.json";
describe("dungeon catalog", () => {
it("lists the complete generated campaign with authored Wailing Caverns first", () => {
it("lists the complete generated campaign with Stormwind Stockades in Beta first", () => {
expect(DUNGEON_CATALOG).toHaveLength(84);
expect(DUNGEON_CATALOG[0]).toMatchObject({
id: DUNGEON_MANIFEST.id,
id: "stormwind-stockade",
releaseStage: "beta",
available: true,
});
expect(dungeonById(DUNGEON_MANIFEST.id)).toMatchObject({
title: DUNGEON_MANIFEST.title,
mapId: DUNGEON_MANIFEST.mapId,
entranceName: DUNGEON_MANIFEST.entrance.name,
available: true,
releaseStage: "alpha",
});
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "deadmines")).toMatchObject({
id: "deadmines",
@@ -33,10 +39,10 @@ describe("dungeon catalog", () => {
it("separates WoW and RoM dungeons with all RuneWaker imports under RoM", () => {
expect(DUNGEON_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom"]);
expect(dungeonsByCategory("rom").map((dungeon) => dungeon.id)).toEqual([
expect(dungeonsByCategory("rom").map((dungeon) => dungeon.id).sort()).toEqual([
"forsaken-abbey",
...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => definition.id),
]);
].sort());
expect(dungeonsByCategory("wow")).not.toContainEqual(
expect.objectContaining({ id: "forsaken-abbey" }),
);
@@ -46,7 +52,7 @@ describe("dungeon catalog", () => {
it("keeps stable unique ids and resolves only catalogued dungeons", () => {
const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id);
expect(new Set(ids).size).toBe(ids.length);
expect(dungeonById(DUNGEON_MANIFEST.id)).toBe(DUNGEON_CATALOG[0]);
expect(dungeonById(DUNGEON_MANIFEST.id)).toBe(DUNGEON_CATALOG.find((dungeon) => dungeon.id === DUNGEON_MANIFEST.id));
expect(dungeonById("missing-dungeon")).toBeNull();
});
@@ -57,4 +63,30 @@ describe("dungeon catalog", () => {
expect(dungeon.summary.trim()).not.toBe("");
}
});
it("keeps only Stockades in Beta and alphabetizes each release group", () => {
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id))
.toEqual(["stormwind-stockade"]);
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.id !== "stormwind-stockade").every((dungeon) => dungeon.releaseStage === "alpha"))
.toBe(true);
for (const category of DUNGEON_CATEGORIES) {
const dungeons = dungeonsByCategory(category.id);
expect(dungeons).toEqual([...dungeons].sort(compareDungeonReleaseOrder));
}
const stages = [
{ title: "Zeta", releaseStage: "alpha" as const },
{ title: "Beta Z", releaseStage: "beta" as const },
{ title: "Released Z", releaseStage: "full-release" as const },
{ title: "Alpha", releaseStage: "alpha" as const },
{ title: "Beta A", releaseStage: "beta" as const },
{ title: "Released A", releaseStage: "full-release" as const },
];
expect(stages.sort(compareDungeonReleaseOrder).map((entry) => entry.title))
.toEqual(["Released A", "Released Z", "Beta A", "Beta Z", "Alpha", "Zeta"]);
});
it("shares the complete dungeon identity list used to validate reports on the server", () => {
expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort())
.toEqual(["forsaken-abbey", ...Object.keys(availability.enabled)].sort());
});
});
+5 -2
View File
@@ -1,5 +1,6 @@
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage";
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
@@ -27,10 +28,11 @@ export interface DungeonCatalogEntry {
readonly theme: string;
readonly summary: string;
readonly available: boolean;
readonly releaseStage: DungeonReleaseStage;
readonly availabilityReason?: string;
}
/** Stable browser ordering is the registry order; identity is never duplicated. */
/** Release readiness first, then alphabetical title within each release stage. */
export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
id: definition.id as DungeonId,
@@ -42,8 +44,9 @@ export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
theme: definition.presentation.theme,
summary: definition.presentation.summary,
available: dungeonCanEnter(definition.id),
releaseStage: dungeonReleaseStage(definition.id),
availabilityReason: "availabilityReason" in definition ? definition.availabilityReason : undefined,
})),
})).sort(compareDungeonReleaseOrder),
);
export type { DungeonId } from "./dungeonRegistry";
+20
View File
@@ -0,0 +1,20 @@
export const DUNGEON_RELEASE_STAGES = ["full-release", "beta", "alpha"] as const;
export type DungeonReleaseStage = (typeof DUNGEON_RELEASE_STAGES)[number];
export const DUNGEON_RELEASE_STAGE_LABELS: Readonly<Record<DungeonReleaseStage, string>> = {
"full-release": "Full Release",
beta: "Beta",
alpha: "Alpha",
};
export function dungeonReleaseStage(id: string): DungeonReleaseStage {
return id === "stormwind-stockade" ? "beta" : "alpha";
}
export function compareDungeonReleaseOrder(
left: { readonly title: string; readonly releaseStage: DungeonReleaseStage },
right: { readonly title: string; readonly releaseStage: DungeonReleaseStage },
): number {
return DUNGEON_RELEASE_STAGES.indexOf(left.releaseStage) - DUNGEON_RELEASE_STAGES.indexOf(right.releaseStage)
|| left.title.localeCompare(right.title, "en", { sensitivity: "base" });
}
+225
View File
@@ -0,0 +1,225 @@
import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { COA_CLASS_IDS, ROM_CLASS_IDS, PLAYABLE_RACES, classesForRace, type ClassId } from "../app/characterCatalog";
import { createDungeonParty, usePartyStore, type PartyMember } from "./partyStore";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
import { subscribeCombatPresentation } from "./combatPresentation";
import { subscribeCombatEvents } from "./combatEvents";
import { abilitiesForClass, resourceProfileForClass } from "./abilityCatalog";
import { choosePartyAbility } from "./partyAbilityAi";
const auditReport: unknown[] = [];
afterAll(() => {
if (process.env.CLASS_DAMAGE_AUDIT_REPORT !== "1") return;
const directory = resolve("artifacts/class-damage-audit");
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, "report.json"), `${JSON.stringify({ durationSeconds: 30, results: auditReport }, null, 2)}\n`);
});
function initializeAudit(classId: ClassId, level: number, template?: PartyMember): PartyMember {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
const raceId = PLAYABLE_RACES.find((race) => classesForRace(race.id).some((entry) => entry.id === classId))!.id;
useCombatStore.getState().initializeCharacter({ classId, raceId, level });
const combat = useCombatStore.getState();
const profile = resourceProfileForClass(classId);
const base = createDungeonParty("expanded-damage-audit", level)[0];
const member: PartyMember = template ?? { ...base, id: `audit-${classId}`, name: classId, classId, raceId, level,
role: "damage", specialization: "Primary", specializationId: null, talentTreeId: "", talentRanks: {},
health: combat.maxHealth, maxHealth: combat.maxHealth, gearStats: combat.gearStats,
resourceType: profile.type, resourceName: combat.resourceName, resource: combat.resource,
maxResource: combat.maxResource, resourcePools: combat.resourcePools, runes: combat.deathKnightRunes };
usePartyStore.getState().initializeParty("expanded-damage-audit", level, combat.health);
usePartyStore.setState({ members: [member] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(member.id, [2 - partyCombatRangeBand(member).preferred, 0, 0]);
useCombatStore.getState().registerMob("audit", { name: "Training target", maxHealth: 10_000_000, xpReward: 0 });
setMobPosition("audit", [2, 0, 0]);
useCombatStore.getState().damageMob("audit", 1, 100);
updatePartyRuntimeTargetVisibility(member.id, "audit", true);
return member;
}
describe("expanded class damage audit", () => {
for (const control of ["NPC", "player"] as const) {
it(`${control} Radiant Flame deals five pulses during the channel without repeating them on completion`, () => {
const member = initializeAudit("sun-cleric", 20);
const ability = abilitiesForClass(member.classId).find((candidate) => candidate.name === "Radiant Flame")!;
const damageEvents: number[] = [];
if (control === "NPC") {
usePartyStore.setState({ members: [{ ...member, cooldowns: Object.fromEntries(abilitiesForClass(member.classId)
.filter((candidate) => candidate.id !== ability.id).map((candidate) => [candidate.id, 60_000])) }] });
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
} else {
usePartyStore.setState({ members: [], selectedMemberId: null, selfSelected: false });
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().selectMob("audit");
}
const stop = subscribeCombatEvents((event) => {
if (event.kind === "damage" && event.targetActorId === "audit" && event.abilityId === ability.id) damageEvents.push(event.effectiveAmount);
});
const advance = (now: number) => {
useCombatStore.getState().tick(0.1, now);
if (control === "NPC") advanceDungeonPartyCombat(now);
};
if (control === "player") expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000).ok).toBe(true);
advance(1_000);
advance(1_700);
expect(damageEvents).toHaveLength(0);
advance(1_800);
expect(damageEvents).toHaveLength(1);
expect(damageEvents[0]).toBeGreaterThan(0);
for (const now of [2_600, 3_400, 4_200, 5_000]) advance(now);
expect(damageEvents).toHaveLength(5);
usePartyStore.getState().setCommand("stop");
advance(5_100);
advance(6_000);
expect(damageEvents).toHaveLength(5);
stop();
});
}
for (const [mode, classes, levels] of [["CoA", COA_CLASS_IDS, [1, 2, 4, 9, 20, 80]], ["RoM", ROM_CLASS_IDS, [1, 20, 70]]] as const) {
for (const level of levels) {
it(`${mode} NPCs execute damaging abilities at level ${level}`, () => {
const report: unknown[] = [];
const failures: string[] = [];
for (const classId of classes) {
const member = initializeAudit(classId, level);
const releases = new Set<string>();
let damage = 0;
let abilityDamage = 0;
const stopEvents = subscribeCombatEvents((event) => {
const actor = useCombatStore.getState().actors[event.sourceActorId];
if (event.kind === "damage" && event.targetActorId === "audit"
&& (event.sourceActorId === member.id || actor?.ownerId === member.id)) {
damage += event.effectiveAmount;
if (event.abilityId !== "party-auto-attack") abilityDamage += event.effectiveAmount;
}
});
const stopPresentation = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === member.id && message.event.phase === "release") releases.add(message.event.name);
});
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
stopEvents();
stopPresentation();
expect(useCombatStore.getState().mobs.audit.dead, `${classId} level ${level} must sustain the full audit`).toBe(false);
report.push({ classId, damage, abilityDamage, releases: [...releases], resource: usePartyStore.getState().members[0].resource });
auditReport.push({ mode, control: "NPC", classId, level, damage, abilityDamage, abilities: [...releases] });
const hasDamageAbility = abilitiesForClass(classId).some((ability) => !ability.passive
&& ability.talentEntryId === undefined && ability.unlockLevel <= level
&& ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute", "summon"].includes(effect.kind)));
if (damage <= 1 || (hasDamageAbility && abilityDamage <= 0)) failures.push(classId);
}
expect(failures, JSON.stringify(report)).toEqual([]);
});
}
for (const level of levels) {
it(`${mode} players execute damaging abilities at level ${level}`, () => {
const failures: unknown[] = [];
for (const classId of classes) {
const member = initializeAudit(classId, level);
usePartyStore.setState({ members: [], selectedMemberId: null, selfSelected: false });
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
setMobPosition("audit", [partyCombatRangeBand(member).preferred, 0, 0]);
useCombatStore.getState().selectMob("audit");
const abilities = abilitiesForClass(classId);
const harmful = (ability: (typeof abilities)[number]) => !ability.passive && ability.talentEntryId === undefined
&& ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute", "summon"].includes(effect.kind));
const firstAttack = abilities.filter(harmful).sort((left, right) => left.unlockLevel - right.unlockLevel)[0];
let damage = 0;
let abilityDamage = 0;
const casts = new Set<string>();
const reasons = new Set<string>();
const stop = subscribeCombatEvents((event) => {
if (event.kind === "damage" && event.targetActorId === "audit") {
damage += event.effectiveAmount;
if (event.abilityId && !event.abilityId.includes("auto-attack")) abilityDamage += event.effectiveAmount;
}
});
const kickoff = firstAttack ? useCombatStore.getState().castAbility(firstAttack.id, undefined, 1_000) : null;
if (kickoff?.ok && firstAttack) casts.add(firstAttack.name);
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
const combat = useCombatStore.getState();
if (combat.activeCast) continue;
const rejected = new Set<string>();
const playerMember: PartyMember = { ...member, resource: combat.resource, resourcePools: combat.resourcePools,
cooldowns: combat.cooldowns, globalCooldownEndsAt: combat.globalCooldownEndsAt, runes: combat.deathKnightRunes };
for (let attempts = 0; attempts < abilities.length; attempts += 1) {
const ability = choosePartyAbility(playerMember, { now, target: combat.mobs.audit,
canUseAbility: (candidate) => harmful(candidate) && !rejected.has(candidate.id)
&& !combat.effects.some((effect) => effect.sourceAbilityId === candidate.id && effect.remainingTicks > 0)
&& !candidate.effects.some((effect) => effect.kind === "apply-aura"
&& combat.auras.some((aura) => aura.definition.id === effect.aura.id && aura.targetId === "audit")) });
if (!ability) break;
const result = useCombatStore.getState().castAbility(ability.id, undefined, now);
if (result.ok) { casts.add(ability.name); break; }
rejected.add(ability.id);
if (result.reason) reasons.add(result.reason);
}
}
stop();
expect(useCombatStore.getState().mobs.audit.dead, `${classId} level ${level} must sustain the full audit`).toBe(false);
const hasAttack = abilities.some((ability) => harmful(ability) && ability.unlockLevel <= level);
auditReport.push({ mode, control: "player", classId, level, damage, abilityDamage, abilities: [...casts],
firstOffensiveAbilityLevel: firstAttack?.unlockLevel ?? null, failureReasons: [...reasons] });
if (hasAttack && abilityDamage <= 0) failures.push({ classId, damage, abilityDamage, casts: [...casts], reasons: [...reasons], kickoff,
firstAttack: firstAttack?.name, autoAttackTargetId: useCombatStore.getState().autoAttackTargetId });
}
expect(failures).toEqual([]);
});
}
}
for (const level of [1, 20, 70]) {
it(`actual RoM companion templates deal ability damage at level ${level}`, () => {
const templates = new Map<string, PartyMember>();
for (let seed = 0; seed < 100; seed += 1) {
for (const role of ["healer", "damage"] as const) {
for (const member of createDungeonParty(`rom-audit-${seed}`, level, role, 5, "rom")) {
templates.set(`${member.classId}:${member.role}`, member);
}
}
}
expect(new Set([...templates.values()].map((member) => member.classId))).toEqual(new Set(ROM_CLASS_IDS));
for (const template of templates.values()) {
const member = initializeAudit(template.classId, level, template);
let damage = 0;
let abilityDamage = 0;
const abilities = new Set<string>();
const stop = subscribeCombatEvents((event) => {
const actor = useCombatStore.getState().actors[event.sourceActorId];
if (event.kind !== "damage" || event.targetActorId !== "audit"
|| (event.sourceActorId !== member.id && actor?.ownerId !== member.id)) return;
damage += event.effectiveAmount;
if (event.abilityId !== "party-auto-attack") {
abilityDamage += event.effectiveAmount;
if (event.abilityId) abilities.add(event.abilityId);
}
});
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
stop();
auditReport.push({ mode: "RoM", control: "shipped NPC", classId: member.classId, role: member.role,
level, damage, abilityDamage, abilities: [...abilities] });
expect(abilityDamage, `${member.classId} ${member.role} level ${level}`).toBeGreaterThan(0);
const finalMember = usePartyStore.getState().members[0];
expect(finalMember.resource).toBeGreaterThanOrEqual(0);
expect(finalMember.resource).toBeLessThanOrEqual(finalMember.maxResource);
}
});
}
});
@@ -1 +1 @@
{"schemaVersion":1,"classId":"necromancer","abilities":[{"id":"coa-tree-30600","classId":"necromancer","dbcSpellId":805428,"name":"Animate: Frost Wyrm","unlockLevel":1,"icon":"/assets/ui/spells/5_dragoncoldbreath_border.png","sigil":"AF","description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805428,"level":1,"description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}]}],"talentEntryId":30600},{"id":"coa-23-animate-zombies-84-504315","classId":"necromancer","dbcSpellId":504315,"name":"Animate: Zombies","unlockLevel":1,"icon":"/assets/ui/spells/zombiewc3.png","sigil":"AZ","description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":504315,"level":1,"description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":2,"spellId":525378,"level":30,"description":"Animate: ZombiesRank 223% of base mana 3 sec cast15 sec cooldownAnimate 3 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":23,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":3},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":3,"spellId":525381,"level":51,"description":"Animate: ZombiesRank 324% of base mana 3 sec cast15 sec cooldownAnimate 4 Zombies to aid you in combat for 10 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":4},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]}]},{"id":"coa-tree-29774","classId":"necromancer","dbcSpellId":802121,"name":"Bone Tithe","unlockLevel":1,"icon":"/assets/ui/spells/ossuary_bond.png","sigil":"BT","description":"Siphon life from your undead minions, sacrificing 40% of their health to grant yourself an absorb shield equal to half of the health stolen for 20 seconds. Usable while stunned.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":77,"auraType":0,"miscValue":0,"triggerSpellId"Line truncated
{"schemaVersion":1,"classId":"necromancer","abilities":[{"id":"coa-tree-30600","classId":"necromancer","dbcSpellId":805428,"name":"Animate: Frost Wyrm","unlockLevel":1,"icon":"/assets/ui/spells/5_dragoncoldbreath_border.png","sigil":"AF","description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805428,"level":1,"description":"Animate a Frost Wyrm that flies behind the Necromancer that blasts nearby enemies every 2 sec for 30 seconds, dealing 1500 + 14.88% frost SP + 14.88% Intellect Frost damage.","castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":31,"percentage":true},"effects":[{"kind":"summon","creatureId":50177,"coefficient":0.08,"durationMs":30000}]}],"talentEntryId":30600},{"id":"coa-23-animate-zombies-84-504315","classId":"necromancer","dbcSpellId":504315,"name":"Animate: Zombies","unlockLevel":1,"icon":"/assets/ui/spells/zombiewc3.png","sigil":"AZ","description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}],"passive":false,"source":"coa-progression","ranks":[{"rank":1,"spellId":504315,"level":1,"description":"Animate: ZombiesRank 121% of base mana 3 sec cast15 sec cooldownAnimate 2 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":21,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":2},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":2,"spellId":525378,"level":30,"description":"Animate: ZombiesRank 223% of base mana 3 sec cast15 sec cooldownAnimate 3 Zombies to aid you in combat for 15 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":23,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":3},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]},{"rank":3,"spellId":525381,"level":51,"description":"Animate: ZombiesRank 324% of base mana 3 sec cast15 sec cooldownAnimate 4 Zombies to aid you in combat for 10 sec. Every 3 sec, Zombies emit Zombie Plague, dealing 79+1.0 Points Per Level+SP*0.1 Plague Damage to nearby enemies.","castMode":"cast","castTimeMs":3000,"cooldownMs":15000,"gcdMs":1000,"cost":{"resource":"mana","amount":24,"percentage":true},"effects":[{"kind":"trigger-spell","spellId":525379,"withValue":4},{"kind":"damage","coefficient":1},{"kind":"summon","creatureId":503031,"coefficient":0.08,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":524287,"triggerSpellId":500991,"durationMs":100}]}]},{"id":"coa-tree-29774","classId":"necromancer","dbcSpellId":802121,"name":"Bone Tithe","unlockLevel":1,"icon":"/assets/ui/spells/ossuary_bond.png","sigil":"BT","description":"Siphon life from your undead minions, sacrificing 40% of their health to grant yourself an absorb shield equal to half of the health stolen for 20 seconds. Usable while stunned.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":120000,"gcdMs":1500,"cost":{"resource":"mana","amount":25,"percentage":true},"effects":[{"kind":"shield","coefficient":1,"durationMs":15000},{"kind":"utility","action":"class-script","effectType":77,"auraType":0,"miscValue":0,"triggerSpellId"Line truncated
@@ -1 +1 @@
{"schemaVersion":1,"classId":"witch-doctor","abilities":[{"id":"coa-tree-30147","classId":"witch-doctor","dbcSpellId":705921,"name":"Alchemical Enhancement","unlockLevel":1,"icon":"/assets/ui/spells/inv_alchemy_71_potion4.png","sigil":"AE","description":"Increases your haste by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":705921,"level":1,"description":"Increases your haste by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":30147},{"id":"coa-tree-11323","classId":"witch-doctor","dbcSpellId":301297,"name":"Blatant Curse","unlockLevel":1,"icon":"/assets/ui/spells/spell_nature_removecurse.png","sigil":"BC","description":"Reduces the mana cost of your Jinxes by -25%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":301297,"level":1,"description":"Reduces the mana cost of your Jinxes by -25%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}]}],"talentEntryId":11323},{"id":"coa-tree-12048","classId":"witch-doctor","dbcSpellId":572870,"name":"Blessing of Hir'eek","unlockLevel":1,"icon":"/assets/ui/spells/trade_archaeology_trollbatidol.png","sigil":"BO","description":"Your Hexbreak now removes an additional curse effect.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:572870:effect:0:aura:107","name":"Blessing of Hir'eek (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"sLine truncated
{"schemaVersion":1,"classId":"witch-doctor","abilities":[{"id":"coa-tree-30147","classId":"witch-doctor","dbcSpellId":705921,"name":"Alchemical Enhancement","unlockLevel":1,"icon":"/assets/ui/spells/inv_alchemy_71_potion4.png","sigil":"AE","description":"Increases your haste by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":705921,"level":1,"description":"Increases your haste by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":30147},{"id":"coa-tree-11323","classId":"witch-doctor","dbcSpellId":301297,"name":"Blatant Curse","unlockLevel":1,"icon":"/assets/ui/spells/spell_nature_removecurse.png","sigil":"BC","description":"Reduces the mana cost of your Jinxes by -25%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":301297,"level":1,"description":"Reduces the mana cost of your Jinxes by -25%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}]}],"talentEntryId":11323},{"id":"coa-tree-12048","classId":"witch-doctor","dbcSpellId":572870,"name":"Blessing of Hir'eek","unlockLevel":1,"icon":"/assets/ui/spells/trade_archaeology_trollbatidol.png","sigil":"BO","description":"Your Hexbreak now removes an additional curse effect.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:572870:effect:0:aura:107","name":"Blessing of Hir'eek (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"sLine truncated
@@ -1 +1 @@
{"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","descriptionLine truncated
{"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","descriptionLine truncated
+16 -1
View File
@@ -3,7 +3,7 @@ import { abilitiesForClass, abilityAtLevel, resourceProfileForClass } from "./ab
import { aiHintsForAbility } from "./abilityRules";
import { useCombatStore } from "./combatStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady } from "./partyAbilityAi";
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady, partyAbilityAmount } from "./partyAbilityAi";
import { usePartyStore, type PartyMember } from "./partyStore";
import { deriveCharacterStats } from "./wotlkStats";
@@ -87,4 +87,19 @@ describe("catalog-driven party rotations", () => {
.find((ability) => aiHintsForAbility(ability).categories.includes("heal") && ability.cost.amount > 0)!;
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
});
it("preserves spell base damage when its bonus-power coefficient is zero", () => {
const member = { ...memberFor("warrior", "tank"), level: 5, gearStats: EMPTY_ITEM_STATS };
const ability = abilitiesForClass("warrior").find((candidate) => candidate.id === "warrior-heroic-strike")!;
expect(partyAbilityAmount(member, ability, {
kind: "damage", coefficient: 0, basePoints: 17, dieSides: 1, sourceLevel: 5,
})).toBe(17);
});
it("requires a learned CoA tree entry before the NPC can cast it", () => {
const member = { ...memberFor("pyromancer", "damage"), talentRanks: {} };
const ability = abilitiesForClass(member.classId).find((candidate) => candidate.talentEntryId !== undefined && !candidate.passive)!;
expect(partyAbilityIsReady(member, ability, 2_000)).toBe(false);
expect(partyAbilityIsReady({ ...member, talentRanks: { [`coa-entry-${ability.talentEntryId}`]: 1 } }, ability, 2_000)).toBe(true);
});
});
+19 -8
View File
@@ -8,7 +8,7 @@ import {
} from "./abilityCatalog";
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
import { spendDeathKnightRunes } from "./combatResources";
import type { MobCombatState } from "./combatStore";
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
import type { PartyMember } from "./partyStore";
export interface PartyAbilityContext {
@@ -20,6 +20,7 @@ export interface PartyAbilityContext {
readonly fallenAlly?: boolean;
readonly injuredAllies?: number;
readonly lowestHealthFraction?: number;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
}
function currentResource(member: PartyMember, type: ResourceType): number {
@@ -35,6 +36,7 @@ function abilityCosts(ability: AbilityDefinition): readonly { resource: Resource
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false;
if (ability.talentEntryId !== undefined && !(member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0)) return false;
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
@@ -50,12 +52,18 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
return abilitiesForClass(member.classId)
.filter((ability) => partyAbilityIsReady(member, abilityAtLevel(ability, member.level), now))
.map((ability) => abilityAtLevel(ability, member.level));
.map((ability) => abilityAtLevel(ability, member.level, ability.talentEntryId === undefined
? undefined : member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0))
.filter((ability) => partyAbilityIsReady(member, ability, now));
}
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number {
const categories = new Set(aiHintsForAbility(ability).categories);
const damaging = categories.has("damage") || categories.has("execute");
if (categories.has("taunt") && (member.role !== "tank" || !context.targetIsLoose)) return 0;
if (!damaging && categories.has("interrupt") && !context.target?.activeCast) return 0;
const control = categories.has("control") || ability.effects.some((effect) => effect.kind === "apply-aura" && effect.control);
if (!damaging && control && !context.requiredControl) return 0;
let score = ability.ai?.priority ?? 0;
if (member.role === "tank") {
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
@@ -89,6 +97,7 @@ export function choosePartyAbility(
context: PartyAbilityContext,
): AbilityDefinition | null {
return candidateAbilities(member, context.now)
.filter((ability) => context.canUseAbility?.(ability) ?? true)
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
.filter((candidate) => candidate.score > 0)
.sort((left, right) => right.score - left.score
@@ -142,9 +151,11 @@ export function partyAbilityAmount(
: effect.kind === "finisher-damage"
? effect.baseCoefficient + effect.perComboCoefficient * 5
: 0;
const power = member.level * 4
+ member.gearStats.spellPower
+ member.gearStats.attackPower * 0.35
+ member.gearStats.rangedAttackPower * 0.35;
return Math.max(1, Math.round(Math.max(0.1, coefficient) * Math.max(8, power)));
return equipmentAdjustedCombatAmount(
"coefficient" in effect ? effect : coefficient,
member.level,
member.gearStats,
member.classId,
ability,
);
}
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import { createDungeonParty, usePartyStore, type PartyMember } from "./partyStore";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, partyUsesRangedAttacks, resetPartyRuntimeClock } from "./partyRuntime";
import { subscribeCombatPresentation } from "./combatPresentation";
import { abilitiesForClass } from "./abilityCatalog";
function partyTemplates(level: number): PartyMember[] {
const templates = new Map<string, PartyMember>();
for (let seed = 0; seed < 100; seed += 1) {
for (const role of ["healer", "damage"] as const) {
for (const member of createDungeonParty(`damage-audit-${seed}`, level, role)) {
templates.set(`${member.classId}:${member.specialization}`, member);
}
}
}
return [...templates.values()];
}
describe("NPC sustained damage audit", () => {
it("lets Garrick swing immediately with no Rage, and keeps swinging during the GCD", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 3 });
const garrick = partyTemplates(3).find((member) => member.name === "Garrick")!;
usePartyStore.getState().initializeParty("short-pull", 3, useCombatStore.getState().health);
usePartyStore.setState({ members: [{ ...garrick, globalCooldownEndsAt: 10_000 }] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(garrick.id, [0, 0, 0]);
useCombatStore.getState().registerMob("short-pull", { name: "Training target", maxHealth: 1_000, xpReward: 0 });
setMobPosition("short-pull", [2, 0, 0]);
useCombatStore.getState().damageMob("short-pull", 1, 100);
updatePartyRuntimeTargetVisibility(garrick.id, "short-pull", true);
advanceDungeonPartyCombat(1_000);
const swings = () => useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === garrick.id && event.kind === "damage");
expect(swings()).toHaveLength(1);
expect(swings()[0]).toMatchObject({ abilityId: "party-auto-attack" });
expect(swings()[0].effectiveAmount).toBeGreaterThan(1);
expect(usePartyStore.getState().members[0].resource).toBe(6);
advanceDungeonPartyCombat(1_100);
expect(swings()).toHaveLength(1);
advanceDungeonPartyCombat(2_800);
expect(swings()).toHaveLength(2);
expect(usePartyStore.getState().members[0].resource).toBe(12);
usePartyStore.getState().setCommand("stop");
advanceDungeonPartyCombat(5_000);
expect(swings()).toHaveLength(2);
});
it("casts Thunder Clap around the tank rather than rejecting its self range or centering it on the enemy", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 5 });
const garrick = partyTemplates(5).find((member) => member.name === "Garrick")!;
usePartyStore.getState().initializeParty("area-pull", 5, useCombatStore.getState().health);
usePartyStore.setState({ members: [{ ...garrick, resource: 100, resourcePools: { rage: 100 },
cooldowns: Object.fromEntries(abilitiesForClass("warrior").filter((ability) => ability.id !== "warrior-thunder-clap")
.map((ability) => [ability.id, 60_000])) }] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(garrick.id, [0, 0, 0]);
for (const [id, x] of [["area:a", 7], ["area:b", -7], ["area:c", 10]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
setMobPosition(id, [x, 0, 0]);
updatePartyRuntimeTargetVisibility(garrick.id, id, true);
}
useCombatStore.getState().damageMob("area:a", 1, 100);
updatePartyRuntimeTargetVisibility(garrick.id, "area:a", true);
advanceDungeonPartyCombat(1_000);
const damage = useCombatStore.getState().combatEvents.filter((event) => event.sourceActorId === garrick.id && event.kind === "damage");
expect(damage.map((event) => event.targetActorId).sort()).toEqual(["area:a", "area:b"]);
expect(damage.every((event) => event.abilityId === "warrior-thunder-clap" && event.effectiveAmount > 0)).toBe(true);
expect(useCombatStore.getState().mobs["area:c"].health).toBe(1_000);
});
for (const level of [1, 3, 5, 20, 80]) {
it(`executes damaging abilities for every party template at level ${level}`, () => {
const templates = partyTemplates(level);
expect(templates).toHaveLength(26);
for (const member of templates) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level });
usePartyStore.getState().initializeParty("damage-audit", level, useCombatStore.getState().health);
usePartyStore.setState({ members: [member] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(member.id, partyUsesRangedAttacks(member) ? [-12, 0, 0] : [0, 0, 0]);
useCombatStore.getState().registerMob("audit", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("audit", [2, 0, 0]);
useCombatStore.getState().damageMob("audit", 1, 100);
updatePartyRuntimeTargetVisibility(member.id, "audit", true);
const releases: string[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === member.id && message.event.phase === "release") {
releases.push(message.event.name);
}
});
for (let now = 1_000; now <= 31_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
unsubscribe();
const damageEvents = useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage" && event.sourceActorId === member.id);
const damage = damageEvents
.reduce((total, event) => total + event.effectiveAmount, 0);
const description = `${member.name}, ${member.classId}:${member.specialization}, level ${level}`;
expect(damage, description).toBeGreaterThan(1);
expect(releases.filter((name) => name !== "Auto Attack").length, description).toBeGreaterThan(0);
expect(damageEvents.some((event) => event.abilityId !== "party-auto-attack" && event.effectiveAmount > 0), description).toBe(true);
const finalMember = usePartyStore.getState().members[0];
expect(finalMember.resource, description).toBeGreaterThanOrEqual(0);
expect(finalMember.resource, description).toBeLessThanOrEqual(finalMember.maxResource);
}
});
}
});
+114 -15
View File
@@ -28,6 +28,7 @@ import {
updatePartyRuntimePosition,
} from "./partyRuntimeRegistry";
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
import { baseClassFor, classById } from "../app/characterCatalog";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
@@ -54,6 +55,7 @@ import {
} from "./partyAbilityAi";
import { normalizeActorResourcePool } from "./combatActors";
import { advanceCombatResourcePool } from "./combatResources";
import { channelAmountEffects } from "./combatChannelAmounts";
import {
combatHasLineOfSight,
combatSpatialActor,
@@ -69,6 +71,7 @@ const TANK_TAUNT_COOLDOWN_MS = 8_000;
const TANK_TAUNT_DURATION_MS = 3_000;
let lastAdvanceAt = 0;
let nextRegenAt = 0;
const nextWeaponAttackAt = new Map<string, number>();
export interface PartyPresentationIdentity {
readonly spellId: number;
@@ -141,6 +144,7 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
export function resetPartyRuntimeClock(): void {
lastAdvanceAt = 0;
nextRegenAt = 0;
nextWeaponAttackAt.clear();
}
export function activePartyTargetId(
@@ -167,7 +171,7 @@ export function partyAttackDamage(
specialization: member.specialization ?? "",
role: member.role,
})
? classId === "hunter" ? "ranged" : "spell"
? baseClassFor(classId) === "hunter" ? "ranged" : "spell"
: "melee";
const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null;
const equipmentPower = kind === "spell"
@@ -193,15 +197,30 @@ export function partyAttackDamage(
}
export function partyUsesRangedAttacks(
member: Pick<PartyMember, "classId" | "specialization" | "role">,
member: Pick<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
): boolean {
if (member.role === "tank") return false;
if (member.role === "healer") return true;
const definition = classById(member.classId);
if (definition.mode !== "classic") {
return (baseClassFor(member.classId) === "hunter"
|| ["intellect", "wisdom", "spirit"].includes(definition.primaryStat))
&& partyCatalogAttackRange(member) > 5;
}
return ["hunter", "mage", "priest", "warlock"].includes(member.classId)
|| member.specialization === "Elemental"
|| member.specialization === "Balance";
}
function partyCatalogAttackRange(member: Pick<PartyMember, "classId"> & Partial<Pick<PartyMember, "level" | "talentRanks">>): number {
return Math.max(0, ...abilitiesForClass(member.classId)
.filter((ability) => !ability.passive && ability.unlockLevel <= (member.level ?? 80)
&& (ability.talentEntryId === undefined || (member.talentRanks?.[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
.map((ability) => abilityAtLevel(ability, member.level ?? 80))
.filter((ability) => ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute"].includes(effect.kind)))
.map((ability) => ability.range.max));
}
export function partyAttackRange(member: Pick<PartyMember, "classId" | "specialization" | "role">): number {
if (member.role === "tank") return 3.8;
if (partyUsesRangedAttacks(member)) return 19;
@@ -236,9 +255,18 @@ const MELEE_DAMAGE_COMBAT_RANGE_BAND: PartyCombatRangeBand = Object.freeze({
* target instead of treating the tank's position as their own.
*/
export function partyCombatRangeBand(
member: Pick<PartyMember, "classId" | "specialization" | "role">,
member: Pick<PartyMember, "classId" | "specialization" | "role"> & Partial<Pick<PartyMember, "level" | "talentRanks">>,
): PartyCombatRangeBand {
if (partyUsesRangedAttacks(member)) return RANGED_COMBAT_RANGE_BAND;
if (partyUsesRangedAttacks(member)) {
if (classById(member.classId).mode !== "classic") {
const attackRange = partyCatalogAttackRange(member);
if (attackRange <= 5) return RANGED_COMBAT_RANGE_BAND;
const maximum = Math.min(RANGED_COMBAT_RANGE_BAND.maximum, attackRange - 0.1);
const preferred = Math.min(RANGED_COMBAT_RANGE_BAND.preferred, maximum - 0.5);
return { minimum: Math.min(RANGED_COMBAT_RANGE_BAND.minimum, preferred - 0.5), preferred, maximum };
}
return RANGED_COMBAT_RANGE_BAND;
}
return member.role === "tank" ? TANK_COMBAT_RANGE_BAND : MELEE_DAMAGE_COMBAT_RANGE_BAND;
}
@@ -599,7 +627,7 @@ function memberSwingMs(member: PartyMember): number {
ratingPercent(
member.gearStats.hasteRating,
partyUsesRangedAttacks(member)
? member.classId === "hunter" ? "rangedHaste" : "spellHaste"
? baseClassFor(member.classId) === "hunter" ? "rangedHaste" : "spellHaste"
: "meleeHaste",
member.level,
member.classId,
@@ -639,6 +667,33 @@ function emitPartyAbility(
});
}
/** Weapon swings continue during the GCD and supply Rage for paid attacks. */
function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: number): PartyMember {
if (partyUsesRangedAttacks(member) && baseClassFor(member.classId) !== "hunter") return member;
const origin = getPartyRuntimePosition(member.id) ?? useCombatStore.getState().playerPosition;
const targetPosition = getMobPosition(targetId);
const spatial = combatSpatialActor(member.id);
if (!targetPosition || !partyRuntimeTargetVisible(member.id, targetId)
|| distanceSquared(origin, targetPosition) > partyAttackRange(member) ** 2
|| Math.abs(origin[1] - targetPosition[1]) > 8
|| !combatHasLineOfSight(origin, targetPosition)
|| (spatial && !isPositionInFacingArc(spatial, targetPosition, Math.PI))
|| now < (nextWeaponAttackAt.get(member.id) ?? 0)) return member;
nextWeaponAttackAt.set(member.id, now + memberSwingMs(member));
const abilityId = "party-auto-attack";
const result = useCombatStore.getState().damageMob(targetId, partyAttackDamage(member), now,
{ actorId: member.id, role: member.role, abilityId }, "physical");
const identity = partyAttackPresentation(member);
for (const phase of ["release", "impact"] as const) {
emitCombatPresentation({ occurredAt: now, phase, actorGroup: "party", sourceActorId: member.id,
targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical",
delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true });
}
if (member.resourceType !== "rage" || result.amount <= 0) return member;
const resource = Math.min(member.maxResource, member.resource + 6);
return { ...member, resource, resourcePools: { ...member.resourcePools, rage: resource } };
}
function partyFriendlyPosition(actorId: string): MobVector3 | null {
if (actorId === PLAYER_AGGRO_ID) return useCombatStore.getState().playerPosition;
return getPartyRuntimePosition(actorId);
@@ -661,8 +716,11 @@ function partyAbilityTargetIsValid(
if (!hostile && (!targetActor?.alive || targetActor.health <= 0)
&& !ability.effects.some((effect) => effect.kind === "resurrection")) return false;
const distance = Math.sqrt(distanceSquared(origin, targetPosition));
if (distance < ability.range.min || distance > ability.range.max || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
const targeting = targetingRulesForAbility(ability);
const maximumRange = hostile && ability.target === "self"
? targeting.radius ?? ability.radius ?? ability.range.max
: ability.range.max;
if (distance < ability.range.min || distance > maximumRange || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false;
if (hostile && targeting.requiresFacing) {
const sourceSpatial = combatSpatialActor(member.id);
@@ -706,7 +764,7 @@ function partyHostileTargets(
.filter((mob) => {
const position = getMobPosition(mob.id);
return !mob.dead && Boolean(position
&& distanceSquared(origin, position) <= ability.range.max ** 2
&& distanceSquared(origin, position) <= (targeting.radius ?? ability.radius ?? ability.range.max) ** 2
&& combatHasLineOfSight(origin, position)
&& (!memberSpatial || isPositionInFacingArc(memberSpatial, position, targeting.attackArcRadians ?? Math.PI / 2)));
})
@@ -715,12 +773,13 @@ function partyHostileTargets(
}
if (targeting.shape === "circle" || targeting.shape === "ground" || ability.radius !== undefined) {
const radius = targeting.radius ?? ability.radius ?? 0;
const center = ability.target === "self" ? origin : primaryPosition;
return Object.values(combat.mobs)
.filter((mob) => {
const position = getMobPosition(mob.id);
return !mob.dead && Boolean(position
&& distanceSquared(primaryPosition, position) <= radius * radius
&& combatHasLineOfSight(primaryPosition, position));
&& distanceSquared(center, position) <= radius * radius
&& combatHasLineOfSight(center, position));
})
.map((mob) => mob.id)
.sort();
@@ -856,7 +915,7 @@ function executePartyAbility(
? Boolean(useCombatStore.getState().createActorAreaEffect(
member.id,
ability,
getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin,
ability.target === "self" ? origin : getMobPosition(targetId) ?? partyFriendlyPosition(targetId) ?? origin,
now,
))
: false;
@@ -1143,6 +1202,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
member = activeMember;
const currentCombat = useCombatStore.getState();
if (member.activeCast) {
const activeCast = member.activeCast;
const castingAbility = abilityById(member.activeCast.abilityId);
const castTargetId = member.activeCastTargetId;
if (!castingAbility || !castTargetId) {
@@ -1163,7 +1223,31 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
status: "advancing", nextActionAt: now + 250 };
continue;
}
if (now < member.activeCast.completesAt) {
if (member.activeCast.mode === "channel") {
const cast = member.activeCast;
if (!partyAbilityTargetIsValid(member, castingAbility, castTargetId, cast.origin)) {
emitPartyAbility(member, castingAbility, castTargetId, "cast-cancel", now);
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 };
continue;
}
const interval = Math.max(1, cast.originalDurationMs / 5);
let completed = cast.channelTicksCompleted ?? 0;
while (completed < 5 && cast.startedAt + (completed + 1) * interval <= now) {
const effects: AbilityEffect[] = channelAmountEffects(castingAbility, 5).map(({ effect, multiplier }) => ({
kind: effect.kind === "heal" || effect.kind === "hot" ? "heal" : "damage",
coefficient: 0, basePoints: Math.round(partyAbilityAmount(member, castingAbility, effect) * multiplier),
bonusPowerCoefficient: 0, sourceLevel: member.level,
...(effect.kind === "heal" && effect.percentMaxHealth !== undefined
? { percentMaxHealth: effect.percentMaxHealth * multiplier } : {}),
}));
const execution = executePartyAbility(member, { ...castingAbility, effects }, castTargetId,
cast.startedAt + (completed + 1) * interval, nextMembers, 0, true);
member = execution.member;
completed += 1;
}
member = { ...member, activeCast: { ...cast, channelTicksCompleted: completed } };
}
if (now < activeCast.completesAt) {
nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus };
if (hostileCast) memberTargetIds[member.id] = castTargetId;
continue;
@@ -1173,12 +1257,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
completedMember,
castingAbility,
castTargetId,
member.activeCast.origin,
activeCast.origin,
);
if (valid) {
const execution = executePartyAbility(
completedMember,
castingAbility,
activeCast.mode === "channel"
? { ...castingAbility, effects: castingAbility.effects.filter((effect) => !["damage", "dot", "heal", "hot"].includes(effect.kind)) }
: castingAbility,
castTargetId,
now,
nextMembers,
@@ -1284,7 +1370,11 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const targetVisible = Boolean(
targetPosition && partyRuntimeTargetVisible(member.id, memberTargetId)
);
let runtimeMember = member;
let runtimeMember = targetVisible ? advancePartyWeaponAttack(member, memberTargetId, now) : member;
if (useCombatStore.getState().mobs[memberTargetId]?.dead) {
nextMembers[index] = { ...runtimeMember, status: desiredStatus };
continue;
}
const targetMob = currentCombat.mobs[memberTargetId];
if (now < member.nextActionAt) {
if (runtimeMember.status !== desiredStatus) {
@@ -1297,9 +1387,18 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
now,
target: targetMob ?? null,
targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id,
canUseAbility: (ability) => partyAbilityTargetIsValid(runtimeMember, ability, memberTargetId)
// Let maintained effects tick instead of resetting their first tick on every GCD.
&& !currentCombat.effects.some((effect) => effect.sourceActorId === member.id
&& effect.sourceAbilityId === ability.id && effect.targetId === memberTargetId && effect.remainingTicks > 0)
&& !ability.effects.some((effect) => effect.kind === "apply-aura"
&& currentCombat.auras.some((aura) => aura.sourceId === member.id
&& aura.definition.id === effect.aura.id && aura.targetId === memberTargetId)),
});
if (!selectedAbility) {
nextMembers[index] = { ...runtimeMember, status: desiredStatus, nextActionAt: now + 250 };
const inAttackRange = targetVisible && targetPosition
&& distanceSquared(memberPosition, targetPosition) <= partyAttackRange(member) ** 2;
nextMembers[index] = { ...runtimeMember, status: inAttackRange ? desiredStatus : "advancing", nextActionAt: now + 250 };
continue;
}
const inRange = targetVisible && partyAbilityTargetIsValid(runtimeMember, selectedAbility, memberTargetId);
+46
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
APPEARANCE_KEYS,
ROM_CLASS_IDS,
classesForRace,
raceById,
supportedGenders,
@@ -80,6 +81,51 @@ describe("dungeon party composition", () => {
expect(first.every((member) => member.level === 18 && member.health === member.maxHealth)).toBe(true);
});
it("syncs every RoM companion class through entry and level changes without WoW talents", () => {
const seenClasses = new Set<string>();
for (const role of ["tank", "healer", "damage"] as const) {
for (let seed = 0; seed < 20; seed += 1) {
usePartyStore.getState().initializeParty(`rom-level-${seed}`, 1, 100, role, 5, "rom");
const identities = usePartyStore.getState().members.map(({ id, classId, raceId, gender, appearance }) => ({
id, classId, raceId, gender, appearance,
}));
for (const level of [2, 10, 70, 1]) {
usePartyStore.getState().syncLevel(level);
const members = usePartyStore.getState().members;
expect(members.map(({ id, classId, raceId, gender, appearance }) => ({
id, classId, raceId, gender, appearance,
}))).toEqual(identities);
for (const member of members) {
seenClasses.add(member.classId);
expect(member.level).toBe(level);
expect(member.talentRanks).toEqual({});
expect(Object.isFrozen(member.talentRanks)).toBe(true);
expect(member.health).toBe(member.maxHealth);
expect(member.resourcePools[member.resourceType]).toBe(member.resource);
}
}
}
}
expect([...seenClasses].sort()).toEqual([...ROM_CLASS_IDS].sort());
});
it("preserves fallen RoM companions and living health/resource ratios on level sync", () => {
usePartyStore.getState().initializeParty("rom-level-ratios", 10, 100, "damage", 5, "rom");
const [fallen, healer] = usePartyStore.getState().members;
usePartyStore.getState().updateMemberCombat(fallen.id, { health: 0, status: "down" });
usePartyStore.getState().updateMemberCombat(healer.id, {
health: Math.round(healer.maxHealth * 0.5),
resource: Math.round(healer.maxResource * 0.25),
});
const before = usePartyStore.getState().members[1];
usePartyStore.getState().syncLevel(11);
const [syncedFallen, syncedHealer] = usePartyStore.getState().members;
expect(syncedFallen).toMatchObject({ health: 0, status: "down", level: 11, talentRanks: {} });
expect(syncedHealer.health).toBe(Math.round(before.health / before.maxHealth * syncedHealer.maxHealth));
expect(syncedHealer.resource).toBe(Math.round(before.resource / before.maxResource * syncedHealer.maxResource));
expect(syncedHealer.resourcePools.mana).toBe(syncedHealer.resource);
});
it("supports solo through five-person Manastorm party sizes without changing the dungeon default", () => {
expect(createDungeonParty("sizes", 18)).toHaveLength(4);
for (const size of [1, 2, 3, 4, 5] as const) {
+3 -1
View File
@@ -496,7 +496,9 @@ export const usePartyStore = create<PartyState>((set, get) => ({
resource,
maxResource,
resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }),
talentRanks: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
talentRanks: isRomClassId(member.classId)
? Object.freeze({})
: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
};
}),
};
+200
View File
@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { installCombatClock } from "./combatClock";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { partyFightStillActive, respawnDefeatedPlayer } from "./deathRespawn";
import { activateDungeonEncounterDefinitions, advanceDungeonEncounters, resetDungeonEncounters } from "./dungeonEncounterRuntime";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, getPartyRuntimePosition, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
const dungeon = requireDungeonDefinition("stormwind-stockade");
const fightPosition = [2, 0, 0] as const;
function startFight(boss = false): string {
const id = boss ? dungeon.bosses[1].id : "ongoing-pack";
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, boss, xpReward: 0 });
setMobPosition(id, fightPosition);
const tank = usePartyStore.getState().members[0];
useCombatStore.getState().damageMob(id, 1, 100, { actorId: tank.id, role: "tank" });
for (const member of usePartyStore.getState().members) {
updatePartyRuntimeTargetVisibility(member.id, id, true);
}
return id;
}
function killPlayer(): void {
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().health).toBe(0);
}
describe("personal checkpoint resurrection during an ongoing party fight", () => {
let restoreClock: () => void;
beforeEach(() => {
restoreClock = installCombatClock(() => 1_000);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useGameStore.getState().activateDungeon(dungeon.id);
activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? []);
resetDungeonEncounters("dungeon", dungeon.id);
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 16 });
usePartyStore.getState().initializeParty("personal-respawn", 16, useCombatStore.getState().health);
usePartyStore.getState().selectMember(null);
for (const member of usePartyStore.getState().members) {
registerPartyRuntimePosition(member.id, [0, 0, 0]);
}
useCombatStore.getState().synchronizeActors();
});
afterEach(() => restoreClock());
it("revives only the player without restoring companions, resetting the pull, or triggering Recall", () => {
const id = startFight();
advanceDungeonPartyCombat(1_000);
useCombatStore.getState().advanceMobCombat(1_000);
const members = usePartyStore.getState().members;
useCombatStore.getState().commitCompanionActors([
{ ...members[0], health: Math.round(members[0].maxHealth / 2) },
{ ...members[1], health: 0, status: "down" },
{ ...members[2], activeCast: {
abilityId: "ongoing-party-cast", school: "fire", mode: "cast", startedAt: 1_000,
completesAt: 5_000, originalDurationMs: 4_000, pushbackHits: 0, targetId: id,
}, activeCastTargetId: id },
]);
killPlayer();
const combatBefore = useCombatStore.getState();
const partyBefore = usePartyStore.getState();
const gameBefore = useGameStore.getState();
const positions = partyBefore.members.map((member) => getPartyRuntimePosition(member.id));
expect(respawnDefeatedPlayer("entrance")).toBe(true);
const after = useCombatStore.getState();
expect(after.health).toBe(after.maxHealth);
expect(after.resource).toBe(after.maxResource);
expect(after.playerPosition).toEqual(dungeon.entrance.footPosition);
expect(after.actors[PLAYER_AURA_ENTITY_ID]).toMatchObject({ alive: true, health: after.maxHealth });
expect(after.playerAnimationEvent?.kind).toBe("ability-cancel");
expect(after.mobs).toBe(combatBefore.mobs);
expect(usePartyStore.getState()).toBe(partyBefore);
for (const [index, member] of partyBefore.members.entries()) {
expect(after.actors[member.id]).toBe(combatBefore.actors[member.id]);
expect(getPartyRuntimePosition(member.id)).toEqual(positions[index]);
}
expect(useGameStore.getState()).toMatchObject({
resetRevision: gameBefore.resetRevision,
encounterResetRevision: gameBefore.encounterResetRevision,
relocationRevision: gameBefore.relocationRevision + 1,
playerPosition: dungeon.entrance.footPosition,
});
expect(usePartyStore.getState().recallRevision).toBe(partyBefore.recallRevision);
});
it("allows personal resurrection during a boss fight at an already unlocked boss checkpoint", () => {
const completed = dungeon.bosses[0];
advanceDungeonEncounters(dungeon.id, {
kind: "tick", now: 100,
facts: { deadEntityIds: [completed.id], registeredEntityIds: [completed.id] },
});
const currentBoss = startFight(true);
advanceDungeonPartyCombat(1_000);
killPlayer();
const before = useCombatStore.getState().mobs[currentBoss];
const revision = useGameStore.getState().resetRevision;
expect(respawnDefeatedPlayer(`boss:${completed.id}`)).toBe(true);
expect(useGameStore.getState().playerPosition).toEqual(completed.position);
expect(useCombatStore.getState().mobs[currentBoss]).toBe(before);
expect(before.engaged).toBe(true);
expect(useGameStore.getState().resetRevision).toBe(revision);
});
it("continues the actual party and enemy combat ticks after the player leaves for the entrance", () => {
const id = startFight();
advanceDungeonPartyCombat(1_000);
useCombatStore.getState().advanceMobCombat(1_000);
killPlayer();
expect(respawnDefeatedPlayer("entrance")).toBe(true);
const beforeMobHealth = useCombatStore.getState().mobs[id].health;
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
const beforeAttackRevision = useCombatStore.getState().mobs[id].attackRevision;
advanceDungeonPartyCombat(3_000);
const nextAttackTime = Math.max(3_000, ...useCombatStore.getState().mobs[id].statuses.map((status) => status.endsAt)) + 1_000;
useCombatStore.getState().advanceMobCombat(nextAttackTime, dungeon.entrance.footPosition);
expect(useCombatStore.getState().mobs[id].engaged).toBe(true);
expect(useCombatStore.getState().mobs[id].health).toBeLessThan(beforeMobHealth);
expect(useCombatStore.getState().mobs[id].attackRevision).toBeGreaterThan(beforeAttackRevision);
expect(useCombatStore.getState().mobs[id].targetActorId).toBe(tank.id);
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
});
it("keeps companions' periodic healing and enemies' ongoing damage while removing old player HoTs", () => {
const id = startFight();
const renew = useCombatStore.getState().abilities.find((ability) => ability.name === "Renew")!;
expect(useCombatStore.getState().castAbility(renew.id, undefined, 1_000).ok).toBe(true);
const selfHot = useCombatStore.getState().effects.find((effect) => effect.kind === "hot")!;
const allyHot = { ...selfHot, id: "preserved-ally-hot", targetId: usePartyStore.getState().members[0].id };
const enemyDot = { ...selfHot, id: "preserved-enemy-dot", kind: "dot" as const, targetId: id };
const explicitPlayerHot = { ...selfHot, id: "removed-player-hot", targetId: PLAYER_AURA_ENTITY_ID };
useCombatStore.setState({ effects: [selfHot, allyHot, enemyDot, explicitPlayerHot] });
killPlayer();
useCombatStore.setState({ cooldowns: { "ongoing-player-cooldown": 100_000 } });
const cooldowns = useCombatStore.getState().cooldowns;
expect(respawnDefeatedPlayer("entrance")).toBe(true);
expect(useCombatStore.getState().effects).toEqual([allyHot, enemyDot]);
expect(useCombatStore.getState().cooldowns).toBe(cooldowns);
});
it.each(["party wiped", "fight ended"])("retains normal reset recovery when the %s", (reason) => {
const id = startFight();
if (reason === "party wiped") {
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members.map((member) => ({
...member, health: 0, status: "down" as const,
})));
} else {
useCombatStore.getState().damageMob(id, 100_000, 200);
}
killPlayer();
const before = useGameStore.getState().resetRevision;
expect(respawnDefeatedPlayer("entrance")).toBe(true);
expect(useGameStore.getState().resetRevision).toBe(before + 1);
expect(useCombatStore.getState().mobs[id].engaged).toBe(false);
});
it("uses canonical companion health and ignores defeated or despawned enemies", () => {
const id = startFight();
const combat = useCombatStore.getState();
const members = usePartyStore.getState().members;
expect(partyFightStillActive(members, combat.mobs, combat.actors)).toBe(true);
const actors = Object.fromEntries(members.map((member) => [member.id, { health: 0 }]));
expect(partyFightStillActive(members, combat.mobs, actors)).toBe(false);
expect(partyFightStillActive(members, { [id]: { ...combat.mobs[id], despawned: true } })).toBe(false);
expect(partyFightStillActive(members, { [id]: { ...combat.mobs[id], dead: true } })).toBe(false);
});
it("rejects locked checkpoints and leaves Manastorm's shared-charge resurrection separate", () => {
startFight(true);
killPlayer();
const before = useGameStore.getState();
expect(respawnDefeatedPlayer(`boss:${dungeon.bosses[0].id}`)).toBe(false);
expect(useGameStore.getState()).toBe(before);
expect(useCombatStore.getState().health).toBe(0);
useGameStore.setState({ gameMode: "manastorm" });
expect(respawnDefeatedPlayer("entrance")).toBe(false);
expect(useCombatStore.getState().health).toBe(0);
});
it("cannot use player-only recovery as a living-player teleport or with invalid coordinates", () => {
const before = useCombatStore.getState();
expect(before.respawnPlayerOnly([0, 0, 0], 1_000)).toBe(false);
expect(useCombatStore.getState()).toBe(before);
killPlayer();
const dead = useCombatStore.getState();
expect(dead.respawnPlayerOnly([NaN, 0, 0], 1_000)).toBe(false);
expect(dead.respawnPlayerOnly([0, 0, 0], NaN)).toBe(false);
expect(useCombatStore.getState()).toBe(dead);
});
});
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":9,"flatGrowth":3.25,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"wisdom":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":12,"flatGrowth":4.35,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGLine truncated
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":6,"flatGrowth":2.1,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":7,"flatGrowth":2.4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":11,"flatGrowth":4.1,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.2,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.8,"percentGrowth":0.02},"wisdom":{"levelOne":10,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.7,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatLine truncated
+14 -1
View File
@@ -122,7 +122,7 @@ describe("generated RuneWaker player catalog", () => {
103 * MANA_POOL_MULTIPLIER,
);
expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(
86 * MANA_POOL_MULTIPLIER,
98 * MANA_POOL_MULTIPLIER,
);
expect(generated.skills.find((ability) => ability.id === "rom-priest-general-1")?.costs).toEqual([
{ resource: "mana", amount: 12, percentage: false },
@@ -131,4 +131,17 @@ describe("generated RuneWaker player catalog", () => {
{ resource: "mana", amount: 12, percentage: false },
]);
});
it("uses player growth tables for every released RoM class without exponential stat explosions", () => {
for (const entry of generated.canonicalClasses) {
const classId = entry.id as keyof typeof generated.baseStatGrowth;
const growth = generated.baseStatGrowth[classId];
expect(growth.strength.percentGrowth, classId).toBe(0.02);
const stats = romNativeClassBaseStats(classId, 70);
expect(stats.health, classId).toBeLessThan(10_000);
expect(stats.strength, classId).toBeLessThan(2_000);
}
expect(generated.baseStatGrowth["rom-champion"].strength).toEqual({ levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
expect(generated.baseStatGrowth["rom-warlock"].intellect).toEqual({ levelOne: 14, flatGrowth: 5, percentGrowth: 0.02 });
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview";
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
export type InputMode = "keyboard" | "gamepad";
export type GameMode = "dungeon" | "manastorm";
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | null;
export type GameOverlay = "pause" | "report-issue" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | null;
interface PlayerSnapshot {
position: Vector3Tuple;
+1
View File
@@ -6,6 +6,7 @@ import { startControllerInput } from "./input/controller";
import { currentDisplayLayout } from "./platform/displayLayout";
import { initializeContentSystem } from "./content/contentManager";
import "./styles.css";
import "./ui/dungeonReports.css";
const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp })));
+69
View File
@@ -0,0 +1,69 @@
import { useMemo, useRef, useState, type FormEvent } from "react";
import { useShellStore } from "../app/shellStore";
import { DUNGEON_REPORT_COMMENT_MAX_LENGTH, submitDungeonReport } from "../app/dungeonReports";
import { dungeonById } from "../game/dungeonCatalog";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
export function DungeonIssueReportPanel() {
const dungeonId = useGameStore((state) => state.activeDungeonId);
const openOverlay = useGameStore((state) => state.openOverlay);
const session = useShellStore((state) => state.session);
const [comment, setComment] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState("");
const busy = useRef(false);
const form = useRef<HTMLFormElement>(null);
const back = () => { if (!busy.current) openOverlay("pause"); };
const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
const actions = useMemo<MenuAction[]>(() => [
{ id: "report-back", run: back, enabled: !submitting },
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted },
], [canSubmit, openOverlay, submitted, submitting]);
const controller = useMenuController(actions, { initialId: "report-back", onBack: back });
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit || busy.current || submitted) return;
busy.current = true;
setSubmitting(true);
setError("");
try {
await submitDungeonReport(session, dungeonId, comment);
setSubmitted(true);
} catch (failure) {
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
} finally {
busy.current = false;
setSubmitting(false);
}
};
return (
<div className="modal-backdrop" role="presentation">
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
<p className="eyebrow">Dungeon feedback</p>
<h2 id="issue-report-title">Report an issue</h2>
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p>
{submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : (
<form ref={form} onSubmit={(event) => void submit(event)}>
<label htmlFor="dungeon-issue-comment">Describe the issue</label>
<textarea id="dungeon-issue-comment" autoFocus required maxLength={DUNGEON_REPORT_COMMENT_MAX_LENGTH}
value={comment} disabled={submitting} onChange={(event) => setComment(event.target.value)}
aria-describedby="dungeon-issue-count" placeholder="What happened, and what were you doing when it happened?" />
<small id="dungeon-issue-count">{comment.length} / {DUNGEON_REPORT_COMMENT_MAX_LENGTH} characters</small>
{error && <p role="alert" className="dungeon-report-error">{error}</p>}
<ControllerButton controlId="report-submit" selectedId={controller.selectedId} select={controller.select}
className="button button--primary" type="submit" disabled={!canSubmit}>
{submitting ? "Submitting..." : "Submit report"}
</ControllerButton>
</form>
)}
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select}
className="button" type="button" onClick={back} disabled={submitting}>Back to pause menu</ControllerButton>
</section>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useMemo, useRef, useState } from "react";
import {
DUNGEON_REPORT_STATUSES, filterDungeonReports, sessionCanManageDungeonReports, updateDungeonReport,
type DungeonReport, type DungeonReportStatus,
} from "../app/dungeonReports";
import { useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { DUNGEON_CATALOG, dungeonById } from "../game/dungeonCatalog";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { ControllerButton } from "./ControllerButton";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { useDungeonReports } from "./useDungeonReports";
function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }: {
readonly report: DungeonReport;
readonly canManage: boolean;
readonly saving: boolean;
readonly onUpdate: (report: DungeonReport, status: DungeonReportStatus) => void;
readonly selectedId: string;
readonly select: (id: string) => void;
}) {
return (
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}>
<header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header>
<p className="dungeon-report-comment">{report.comment}</p>
<small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small>
{report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>}
{canManage && (
<div className="dungeon-report-management">
<label>Status
<select aria-label={`Status for report ${report.id}`} value={report.status} disabled={saving}
className={selectedId === `report-status-${report.id}` ? "is-controller-selected" : ""}
onPointerEnter={() => select(`report-status-${report.id}`)}
onChange={(event) => onUpdate(report, event.target.value as DungeonReportStatus)}>
{DUNGEON_REPORT_STATUSES.map((status) => <option key={status} value={status}>{status}</option>)}
</select>
</label>
{report.status !== "Closed" && <ControllerButton controlId={`report-close-${report.id}`} selectedId={selectedId} select={select}
type="button" className="front-button" disabled={saving} onClick={() => onUpdate(report, "Closed")}>Close report</ControllerButton>}
</div>
)}
</article>
);
}
export function DungeonReportsScreen({ onClose }: { readonly onClose: () => void }) {
const session = useShellStore((state) => state.session);
const { snapshot, loading, error, refresh } = useDungeonReports();
const [status, setStatus] = useState<DungeonReportStatus | "all">("all");
const [dungeonId, setDungeonId] = useState("all");
const [saving, setSaving] = useState(false);
const [updateError, setUpdateError] = useState("");
const [message, setMessage] = useState("");
const busy = useRef(false);
const canManage = sessionCanManageDungeonReports(session);
const reports = filterDungeonReports(snapshot?.reports ?? [], status, dungeonId);
const dungeonOptions = useMemo(() => [...DUNGEON_CATALOG].sort((a, b) => a.title.localeCompare(b.title)), []);
const actions = useMemo<MenuAction[]>(() => [
{ id: "reports-back", run: onClose, enabled: !saving },
{ id: "reports-filter-status", run: () => {
const options = ["all", ...DUNGEON_REPORT_STATUSES] as const;
setStatus(options[(options.indexOf(status) + 1) % options.length]);
} },
{ id: "reports-filter-dungeon", run: () => {
const options = ["all", ...dungeonOptions.map((dungeon) => dungeon.id)];
setDungeonId(options[(options.indexOf(dungeonId) + 1) % options.length]);
} },
{ id: "reports-clear-filters", run: () => { setStatus("all"); setDungeonId("all"); } },
{ id: "reports-refresh", run: () => void refresh(), enabled: !loading && !saving },
...(canManage ? reports.flatMap((report) => [
{ id: `report-status-${report.id}`, enabled: !saving, run: () => {
const nextStatus = DUNGEON_REPORT_STATUSES[(DUNGEON_REPORT_STATUSES.indexOf(report.status) + 1) % DUNGEON_REPORT_STATUSES.length];
void update(report, nextStatus);
} },
...(report.status === "Closed" ? [] : [{ id: `report-close-${report.id}`, enabled: !saving, run: () => void update(report, "Closed") }]),
]) : []),
], [canManage, dungeonId, dungeonOptions, loading, onClose, refresh, reports, saving, session, status]);
const controller = useMenuController(actions, { initialId: "reports-back", onBack: saving ? undefined : onClose });
const update = async (report: DungeonReport, nextStatus: DungeonReportStatus) => {
if (!session || !canManage || busy.current) return;
busy.current = true;
setSaving(true);
setUpdateError("");
setMessage("");
try {
await updateDungeonReport(session, report, nextStatus);
setMessage(`Report status updated to ${nextStatus}.`);
} catch (failure) {
setUpdateError(failure instanceof Error ? failure.message : "The status could not be updated.");
} finally {
await refresh();
busy.current = false;
setSaving(false);
}
};
return (
<DualDisplayFrame contextLabel="Dungeon reports" top={
<FrontSurface className="dungeon-reports-screen" ariaLabel="Dungeon reports">
<header className="dungeon-reports-header">
<BrandMark compact />
<div><span>Main menu / Dungeon feedback</span><h1>View Reports</h1></div>
<ControllerButton controlId="reports-back" selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" onClick={onClose} disabled={saving}>Back to main menu</ControllerButton>
</header>
<main className="dungeon-reports-main">
<div className="dungeon-report-filters">
<label>Status
<select value={status} onChange={(event) => setStatus(event.target.value as DungeonReportStatus | "all")}
className={controller.selectedId === "reports-filter-status" ? "is-controller-selected" : ""}
onPointerEnter={() => controller.select("reports-filter-status")}>
<option value="all">All statuses</option>
{DUNGEON_REPORT_STATUSES.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label>Dungeon
<select value={dungeonId} onChange={(event) => setDungeonId(event.target.value)}
className={controller.selectedId === "reports-filter-dungeon" ? "is-controller-selected" : ""}
onPointerEnter={() => controller.select("reports-filter-dungeon")}>
<option value="all">All dungeons</option>
{dungeonOptions.map((dungeon) => (
<option key={dungeon.id} value={dungeon.id}>{dungeon.title}</option>
))}
</select>
</label>
<ControllerButton controlId="reports-clear-filters" selectedId={controller.selectedId} select={controller.select}
type="button" className="front-button" onClick={() => { setStatus("all"); setDungeonId("all"); }}>Clear filters</ControllerButton>
<ControllerButton controlId="reports-refresh" selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" disabled={loading || saving} onClick={() => void refresh()}>
{loading ? "Refreshing..." : "Refresh"}
</ControllerButton>
</div>
<p className="dungeon-reports-summary">{reports.length} of {snapshot?.reports.length ?? 0} reports · {canManage ? "GM status controls enabled" : "All players can view reports. GMs manage their status."}</p>
{(error || updateError) && <p role="alert" className="front-notice">{updateError || error}{snapshot && error ? " Showing the last loaded reports." : ""}</p>}
{message && <p role="status" className="front-notice">{message}</p>}
<section className="dungeon-report-list" aria-label="Reports" aria-busy={loading || saving}>
{loading && !snapshot ? <p role="status">Loading reports...</p> : snapshot && reports.length === 0 ? (
<p>{snapshot.reports.length ? "No reports match these filters." : "No dungeon reports have been submitted yet."}</p>
) : reports.map((report) => <ReportCard key={report.id} report={report} canManage={canManage} saving={saving}
onUpdate={(item, nextStatus) => void update(item, nextStatus)} selectedId={controller.selectedId} select={controller.select} />)}
</section>
</main>
<ControllerLegend back />
</FrontSurface>
} bottom={<LazyLocalCompanionPanel />} />
);
}
+11 -2
View File
@@ -26,6 +26,8 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { specializationById } from "../game/specializationCatalog";
import { DUNGEON_RELEASE_STAGE_LABELS } from "../game/dungeonReleaseStage";
import { useDungeonReports } from "./useDungeonReports";
installDungeonShellRuntime();
@@ -50,6 +52,7 @@ interface PendingDungeonDownload {
}
export function DungeonSelectScreen() {
const reports = useDungeonReports();
const contentState = useContentManagerSnapshot();
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
@@ -253,19 +256,24 @@ export function DungeonSelectScreen() {
controlId={`dungeon-${dungeon.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""}`}
className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""} ${(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 ? "has-open-reports" : ""}`}
type="button"
aria-pressed={dungeon.id === selectedDungeon?.id}
onClick={() => selectDungeon(dungeon.id)}
>
<b aria-hidden="true">{String(index + 1).padStart(2, "0")}</b>
<span><strong>{dungeon.title}</strong><small>{dungeon.entranceName}</small></span>
<span><strong>{dungeon.title}</strong><small>{DUNGEON_RELEASE_STAGE_LABELS[dungeon.releaseStage]} · {dungeon.entranceName}</small></span>
{(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 && (
<sup className="dungeon-open-report-count" aria-label={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}
title={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}>{reports.snapshot!.openCounts[dungeon.id]}</sup>
)}
<em>{dungeon.available
? dungeonContentIsUsable(dungeon.id) ? "Ready" : "Download required"
: "Coming soon"}</em>
</ControllerButton>
))}
</div>
{reports.error && <small role="status" className="dungeon-report-load-error">Report counts unavailable. {reports.snapshot ? "Showing the last loaded counts." : ""}</small>}
<ControllerButton
controlId="dungeon-back"
selectedId={controller.selectedId}
@@ -295,6 +303,7 @@ export function DungeonSelectScreen() {
<section className="dungeon-select-copy">
<p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p>
<h2>{selectedDungeon.title}</h2>
<span className="dungeon-release-stage">{DUNGEON_RELEASE_STAGE_LABELS[selectedDungeon.releaseStage]}</span>
<p>{selectedDungeon.summary}</p>
{!canStart && selectedDungeon.availabilityReason && (
<p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p>
+8 -2
View File
@@ -13,7 +13,7 @@ import { useShellStore } from "../app/shellStore";
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
import { respawnDefeatedPlayer } from "../game/deathRespawn";
import { partyFightStillActive, respawnDefeatedPlayer } from "../game/deathRespawn";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { resurrectManastormSession } from "../game/manastormSession";
import { resolveStagePresentation } from "../game/manastormStagePresentation";
@@ -86,12 +86,14 @@ export function DefeatedPrompt({
onReturn,
sharedOnline = false,
checkpoints = [],
partyStillFighting = false,
}: {
gameMode: "dungeon" | "manastorm";
resurrectionCharges: number;
onReturn: (checkpointId?: string) => void;
sharedOnline?: boolean;
checkpoints?: readonly ResurrectionCheckpoint[];
partyStillFighting?: boolean;
}) {
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
@@ -126,7 +128,9 @@ export function DefeatedPrompt({
<small>No shared resurrection charges remain. A living ally must resurrect you.</small>
)}
{gameMode === "dungeon" && !sharedOnline ? (
<small>Respawning resets the current encounter and restores the party.</small>
<small>{partyStillFighting
? "Your party will keep fighting where they are. Use Recall if you want to regroup."
: "Respawning resets the current encounter and restores the party."}</small>
) : null}
</div>
</section>
@@ -204,6 +208,7 @@ export function Hud() {
: null);
const selfSelected = usePartyStore((state) => state.selfSelected);
const partyMembers = usePartyStore((state) => state.members);
const partyStillFighting = useCombatStore((state) => partyFightStillActive(partyMembers, state.mobs, state.actors));
const playerRole = usePartyStore((state) => state.playerRole);
const settings = useCombatStore((state) => state.settings);
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
@@ -516,6 +521,7 @@ export function Hud() {
resurrectionCharges={resurrectionCharges}
onReturn={returnFromDefeat}
checkpoints={resurrectionCheckpoints}
partyStillFighting={partyStillFighting}
sharedOnline={onlineSession !== null}
/>
) : null}
+21 -5
View File
@@ -10,6 +10,7 @@ import { ContentManagerScreen } from "./ContentManagerScreen";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { DungeonReportsScreen } from "./DungeonReportsScreen";
const GAME_MODES = [
{
@@ -64,6 +65,16 @@ const GAME_MODES = [
export function MainMenuScreen() {
const [contentManagerOpen, setContentManagerOpen] = useState(false);
const [reportsOpen, setReportsOpen] = useState(false);
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
}) {
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
const openDungeons = useShellStore((state) => state.openDungeons);
@@ -95,7 +106,7 @@ export function MainMenuScreen() {
}] : []),
{
id: "mode-downloads",
run: () => setContentManagerOpen(true),
run: onOpenDownloads,
neighbors: { left: SHELVED_MODES_ENABLED ? "mode-manastorms" : "mode-dungeons", down: "back-to-roster" },
},
{
@@ -105,7 +116,8 @@ export function MainMenuScreen() {
},
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
], [character, isGm, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
{ id: "mode-reports", run: onOpenReports },
], [character, isGm, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
initialId: "mode-dungeons",
onBack: returnToCharacters,
@@ -114,8 +126,6 @@ export function MainMenuScreen() {
const race = character ? raceById(character.raceId) : null;
const characterClass = character ? classById(character.classId) : null;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
return (
<DualDisplayFrame
contextLabel="Main menu details"
@@ -163,6 +173,12 @@ export function MainMenuScreen() {
</header>
<OnlineGroupPanel />
<nav className="main-menu-mode-grid" aria-label="Game modes">
<ControllerButton controlId="mode-reports" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenReports}>
<b aria-hidden="true">!</b>
<span><strong>View Reports</strong><small>Browse dungeon issues and follow their status.</small></span>
<em>Open</em>
</ControllerButton>
{isGm && (
<ControllerButton
controlId="mode-gm-tools"
@@ -204,7 +220,7 @@ export function MainMenuScreen() {
: mode.id === "arenas"
? openArenas
: mode.id === "downloads"
? () => setContentManagerOpen(true)
? onOpenDownloads
: openDungeons}
>
<b aria-hidden="true">{mode.number}</b>
+6 -2
View File
@@ -6,6 +6,7 @@ import { ControllerButton } from "./ControllerButton";
import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { INPUT_ACTION_BY_ID } from "../input/keybinds";
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
function PauseDialog() {
const bindingLabel = useInputBindingLabels();
@@ -47,6 +48,7 @@ function PauseDialog() {
{ id: "pause-keybinds", run: () => openOverlay("keybinds") },
{ id: "pause-options", run: () => openOverlay("options") },
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []),
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
...(gameMode === "manastorm"
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
@@ -84,6 +86,7 @@ function PauseDialog() {
{menuButton("pause-keybinds", "Keybinds", () => openOverlay("keybinds"))}
{menuButton("pause-options", "Options", () => openOverlay("options"))}
{menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))}
{gameMode === "dungeon" ? menuButton("pause-report-issue", "Report an issue", () => openOverlay("report-issue")) : null}
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
{gameMode === "manastorm"
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
@@ -103,6 +106,7 @@ function PauseDialog() {
}
export function PauseMenu() {
const open = useGameStore((state) => state.overlay === "pause");
return open ? <PauseDialog /> : null;
const overlay = useGameStore((state) => state.overlay);
if (overlay === "report-issue") return <DungeonIssueReportPanel />;
return overlay === "pause" ? <PauseDialog /> : null;
}
+11
View File
@@ -14,6 +14,17 @@ describe("defeated prompt", () => {
expect(markup).toContain("wait for a party member to resurrect you");
expect(markup).toContain("Respawn at entrance");
expect(markup).toContain("Respawning resets the current encounter and restores the party.");
});
it("explains player-only resurrection and optional Recall during an ongoing party fight", () => {
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
gameMode: "dungeon", resurrectionCharges: 0, partyStillFighting: true, onReturn: () => undefined,
}));
expect(markup).toContain("Your party will keep fighting where they are.");
expect(markup).toContain("Use Recall if you want to regroup.");
expect(markup).toContain("Respawn at entrance");
expect(markup).not.toContain("resets the current encounter");
});
it("does not offer a Manastorm resurrection when no shared charges remain", () => {
+89
View File
@@ -0,0 +1,89 @@
:root { --report-orange: #f1a044; }
.dungeon-select-row { position: relative; }
.dungeon-select-row.has-open-reports { border-color: var(--report-orange); }
.dungeon-select-row.has-open-reports.is-controller-selected { box-shadow: 0 0 0 2px rgba(241,160,68,.5) !important; }
.dungeon-select-row.has-open-reports.is-controller-selected, .dungeon-select-row.has-open-reports:focus-visible { outline-color: var(--report-orange) !important; }
.dungeon-open-report-count {
position: absolute;
top: 3px;
right: 4px;
display: grid;
min-width: 19px;
height: 19px;
padding: 0 4px;
place-items: center;
border-radius: 10px;
background: var(--report-orange);
color: #211608;
font: 700 11px/1 sans-serif;
}
.dungeon-release-stage {
display: inline-block;
margin-bottom: 9px;
padding: 3px 7px;
border: 1px solid var(--front-line);
border-radius: 5px;
color: var(--front-gold);
font-size: 10px;
}
.dungeon-report-load-error { margin: 0 0 5px; color: var(--report-orange); font-size: 10px; }
.dungeon-issue-report form { display: grid; gap: 10px; margin-bottom: 12px; }
.dungeon-issue-report textarea {
width: 100%;
min-height: 140px;
max-height: 30vh;
padding: 12px;
resize: vertical;
border: 1px solid var(--edge);
border-radius: 8px;
background: rgba(0,0,0,.25);
color: var(--ink);
font: inherit;
line-height: 1.5;
}
.dungeon-issue-report form > small { color: var(--muted); text-align: right; }
.dungeon-issue-report > .button { width: 100%; }
.dungeon-report-error { margin: 0; color: var(--report-orange); }
.dungeon-reports-screen { display: grid; grid-template-rows: auto minmax(0,1fr) auto; gap: 14px; }
.dungeon-reports-header { display: flex; align-items: center; gap: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--front-line); }
.dungeon-reports-header > div:nth-child(2) { flex: 1; }
.dungeon-reports-header > div > span { color: var(--front-dim); font-size: 10px; }
.dungeon-reports-header h1 { margin: 3px 0 0; font: 26px Georgia, serif; }
.dungeon-reports-main { display: flex; min-height: 0; flex-direction: column; }
.dungeon-report-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
.dungeon-report-filters label, .dungeon-report-management label { display: grid; min-width: 0; gap: 5px; color: var(--front-dim); font-size: 12px; }
.dungeon-report-filters select, .dungeon-report-management select {
width: 100%;
max-width: 360px;
min-height: 40px;
padding: 7px 10px;
border: 1px solid var(--front-line);
border-radius: 7px;
background: #182015;
color: var(--front-cream);
font: inherit;
}
.dungeon-report-filters > label:nth-child(2) { flex: 1; }
.dungeon-reports-summary { color: var(--front-dim); font-size: 12px; }
.dungeon-report-list { display: grid; min-height: 0; align-content: start; gap: 12px; overflow: auto; padding: 2px 4px 10px 0; }
.dungeon-report-card { padding: 16px; border: 1px solid var(--report-orange); border-radius: 9px; background: rgba(255,255,255,.025); }
.dungeon-report-card.is-closed { border-color: var(--front-line); }
.dungeon-report-card header { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.dungeon-report-card h2 { flex: 1; margin: 0; font: 19px Georgia, serif; }
.dungeon-report-status { padding: 4px 8px; border-radius: 5px; background: rgba(241,160,68,.12); color: var(--report-orange); font-size: 12px; }
.is-closed .dungeon-report-status { color: var(--front-dim); background: rgba(255,255,255,.05); }
.dungeon-report-comment { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.5; }
.dungeon-report-card > small { display: block; margin-top: 5px; color: var(--front-dim); font-size: 11px; }
.dungeon-report-management { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-top: 14px; }
.dungeon-reports-screen > .controller-legend { position: static; justify-self: end; }
.dungeon-issue-report textarea:focus-visible, .dungeon-reports-screen select:focus-visible { outline: 2px solid var(--report-orange); outline-offset: 2px; }
.dungeon-reports-screen select.is-controller-selected { outline: 2px solid var(--front-gold); outline-offset: 2px; }
@media (max-width: 600px) {
.dungeon-reports-header { gap: 10px; }
.dungeon-reports-header > .front-brand { display: none; }
.dungeon-reports-header h1 { font-size: 21px; }
.dungeon-report-filters { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); }
.dungeon-report-card { padding: 12px; }
.dungeon-report-card h2 { font-size: 17px; }
.dungeon-reports-screen > .controller-legend { display: none; }
}
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchDungeonReports, type DungeonReportSnapshot } from "../app/dungeonReports";
export function useDungeonReports() {
const [snapshot, setSnapshot] = useState<DungeonReportSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const sequence = useRef(0);
const refresh = useCallback(async () => {
const request = ++sequence.current;
setLoading(true);
try {
const next = await fetchDungeonReports();
if (request !== sequence.current) return;
setSnapshot(next);
setError("");
} catch (failure) {
if (request === sequence.current) setError(failure instanceof Error ? failure.message : "Reports could not be loaded.");
} finally {
if (request === sequence.current) setLoading(false);
}
}, []);
useEffect(() => {
const refreshVisible = () => { if (document.visibilityState === "visible") void refresh(); };
void refresh();
const timer = window.setInterval(refreshVisible, 30_000);
window.addEventListener("focus", refreshVisible);
document.addEventListener("visibilitychange", refreshVisible);
return () => {
sequence.current += 1;
window.clearInterval(timer);
window.removeEventListener("focus", refreshVisible);
document.removeEventListener("visibilitychange", refreshVisible);
};
}, [refresh]);
return { snapshot, loading, error, refresh };
}