Release Healer Man 0.1.44
This commit is contained in:
1 parent
ccc01396cd
commit
756ee4f64a
24 files changed
+575
-46
No files matched your search
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { activateCharacterUi } from "./uiLayoutStore";
|
||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||
import { useGameStore } from "./store";
|
||||
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
||||
@@ -41,6 +42,7 @@ export function CombatBridge() {
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole });
|
||||
activateCharacterUi(character);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
if (gameMode === "manastorm") {
|
||||
activateManastormActiveSpellLoadout(
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function runDungeonBossBenchmark(
|
||||
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1);
|
||||
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1, build.source);
|
||||
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
|
||||
const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0];
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
||||
|
||||
@@ -6,6 +6,10 @@ import { useGameStore } from "./store";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { bossTestIsDefeated } from "./BossTestingBridge";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import { dungeonEnemyScaling } from "./dungeonBossScaling";
|
||||
import { creatureRuntimeBaseStats } from "./azerothCoreReference";
|
||||
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
|
||||
import { finalDungeonBoss } from "./dungeonRunStore";
|
||||
|
||||
afterEach(() => {
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
@@ -56,6 +60,39 @@ describe("Boss Testing", () => {
|
||||
expect(useGameStore.getState()).toMatchObject({ gmTestMode: true, bossTest: room.session, overlay: null });
|
||||
});
|
||||
|
||||
it("uses Celebras's native health at the player's scaled level without campaign health floors", () => {
|
||||
const celebras = BOSS_TESTING_CATALOG.find((entry) => entry.name === "Celebras the Cursed")!;
|
||||
const maraudon = requireDungeonDefinition(celebras.dungeonId);
|
||||
const room = createBossTestingRoom(maraudon, celebras);
|
||||
const entity = room.definition.entities[celebras.entityId];
|
||||
const scale = (level: number, difficulty = 1) => dungeonEnemyScaling(room.definition, entity,
|
||||
celebras.spawn.id, level, room.definition.defaultDifficultyId, difficulty);
|
||||
const nativeHealth = creatureRuntimeBaseStats(entity.combat!.serverEntry!, 2)!.health
|
||||
* entity.combat!.healthMultiplier!;
|
||||
expect(scale(1)).toMatchObject({ level: 2, maxHealth: nativeHealth });
|
||||
expect(Math.round(scale(1).maxHealth)).toBe(575);
|
||||
expect(scale(8).maxHealth).toBeGreaterThan(scale(1).maxHealth);
|
||||
expect(scale(20).maxHealth).toBeGreaterThan(scale(8).maxHealth);
|
||||
expect(scale(1, 10).maxHealth).toBe(nativeHealth * bossDifficultyMultipliers(10).health);
|
||||
const campaign = dungeonEnemyScaling(maraudon, maraudon.entities[celebras.entityId], celebras.spawn.id,
|
||||
1, maraudon.defaultDifficultyId, 1);
|
||||
expect(campaign.maxHealth).toBe(900);
|
||||
expect(scale(1).damageMultiplier).toBe(campaign.damageMultiplier);
|
||||
expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks);
|
||||
});
|
||||
|
||||
it("omits the campaign finale bonus even when testing the source dungeon's final boss", () => {
|
||||
const final = finalDungeonBoss(source)!;
|
||||
const finalBoss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === source.id && entry.spawn.id === final.id)!;
|
||||
const room = createBossTestingRoom(source, finalBoss);
|
||||
const entity = room.definition.entities[finalBoss.entityId];
|
||||
const testing = dungeonEnemyScaling(room.definition, entity, finalBoss.spawn.id, 1, source.defaultDifficultyId, 1);
|
||||
const campaign = dungeonEnemyScaling(source, entity, finalBoss.spawn.id, 1, source.defaultDifficultyId, 1);
|
||||
expect(testing.maxHealth).toBe(creatureRuntimeBaseStats(entity.combat!.serverEntry!, 2)!.health
|
||||
* (entity.combat?.healthMultiplier ?? 1));
|
||||
expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 900 * 1.8));
|
||||
});
|
||||
|
||||
it("recognizes actual boss deaths and awards no progression or collectible rewards", () => {
|
||||
const room = createBossTestingRoom(source, boss);
|
||||
registerBossTestingDungeon(room.definition, source.id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { BASE_CLASS_IDS, COA_CLASS_IDS } from "../app/characterCatalog";
|
||||
import { BASE_CLASS_IDS, COA_CLASS_IDS, ROM_CLASS_IDS } from "../app/characterCatalog";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark";
|
||||
import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController";
|
||||
@@ -14,16 +14,16 @@ const samples: unknown[] = [];
|
||||
afterAll(() => {
|
||||
if (process.env.BOSS_BALANCE_REPORT !== "1") return;
|
||||
mkdirSync("artifacts/boss-difficulty", { recursive: true });
|
||||
writeFileSync("artifacts/boss-difficulty/wow-coa-balance.json", `${JSON.stringify({
|
||||
writeFileSync("artifacts/boss-difficulty/wow-rom-coa-balance.json", `${JSON.stringify({
|
||||
difficulty: 1, dungeon: dungeon.id, durationLimitSeconds: 60,
|
||||
targets: { ordinary: 25, final: 45 }, policy: BENCHMARK_POLICY_ID, gear: "resolved starter heirlooms", samples,
|
||||
}, null, 2)}\n`);
|
||||
});
|
||||
|
||||
describe("default boss difficulty with WoW and CoA classes", () => {
|
||||
describe("default boss difficulty across WoW, RoM, and CoA classes", () => {
|
||||
for (const level of [8, 20, 40, 80]) {
|
||||
it(`benchmarks every class-role build at level ${level}`, async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...COA_CLASS_IDS]);
|
||||
const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]);
|
||||
const tooFast: string[] = [];
|
||||
for (const build of builds) {
|
||||
// Let the test worker deliver progress between CPU-heavy simulations.
|
||||
@@ -43,7 +43,7 @@ describe("default boss difficulty with WoW and CoA classes", () => {
|
||||
|
||||
for (const level of [8, 80]) {
|
||||
it(`records healer outcomes with the final boss attacking at level ${level}`, async () => {
|
||||
const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...COA_CLASS_IDS]))
|
||||
const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]))
|
||||
.filter((build) => build.role === "healer");
|
||||
for (const build of builds) {
|
||||
const last = await runDungeonBossBenchmark(build, final.id, "class-balance-2", true);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock
|
||||
import { subscribeCombatEvents } from "./combatEvents";
|
||||
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
|
||||
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
||||
const final = finalDungeonBoss(dungeon)!;
|
||||
@@ -21,15 +22,16 @@ afterAll(() => {
|
||||
writeFileSync("artifacts/boss-difficulty/ragefire-balance.json", `${JSON.stringify({ samples }, null, 2)}\n`);
|
||||
});
|
||||
|
||||
function fight(bossId: string, level: number, difficulty: number, seed: string, mechanics = false, playerDamage = false) {
|
||||
function fight(bossId: string, level: number, difficulty: number, seed: string, mechanics = false, playerDamage = false,
|
||||
characterSource: ContentCategoryId = "rom", encounterDungeon = dungeon) {
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
const entity = dungeon.entities[dungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId];
|
||||
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, level, "normal", difficulty);
|
||||
const entity = encounterDungeon.entities[encounterDungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId];
|
||||
const scaling = dungeonEnemyScaling(encounterDungeon, entity, bossId, level, "normal", difficulty, characterSource);
|
||||
const combat = useCombatStore.getState();
|
||||
const classId = playerDamage ? "rom-mage" : "rom-priest";
|
||||
combat.initializeCharacter({ classId, raceId: "rom-human", level });
|
||||
const classId = characterSource === "wow" ? (playerDamage ? "mage" : "priest") : (playerDamage ? "rom-mage" : "rom-priest");
|
||||
combat.initializeCharacter({ classId, raceId: characterSource === "wow" ? "human" : "rom-human", level });
|
||||
combat.setPlayerPosition([-18, 0, 0]);
|
||||
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, playerDamage ? "damage" : "healer", 5, "rom");
|
||||
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, playerDamage ? "damage" : "healer", 5, characterSource);
|
||||
combat.synchronizeActors();
|
||||
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
|
||||
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
|
||||
@@ -40,7 +42,8 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
|
||||
}
|
||||
let firstDamage: number | undefined;
|
||||
let incomingDamage = 0; let healing = 0;
|
||||
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
|
||||
const heal = abilitiesForClass(classId).find((ability) => ability.target === "friendly"
|
||||
&& ability.effects.some((effect) => effect.kind === "heal") && (ability.unlockLevel ?? 1) <= level)!;
|
||||
const damage = abilitiesForClass(classId).find((ability) => ability.target === "hostile"
|
||||
&& ability.effects.some((effect) => effect.kind === "damage"))!;
|
||||
combat.selectMob(bossId);
|
||||
@@ -59,7 +62,8 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
|
||||
const injured = usePartyStore.getState().members.filter((member) => member.health > 0)
|
||||
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
|
||||
if (injured && injured.health < injured.maxHealth * .8 && !useCombatStore.getState().activeCast) {
|
||||
usePartyStore.getState().selectMember(injured.id); combat.castAbility(heal.id, undefined, now);
|
||||
usePartyStore.getState().selectMember(injured.id);
|
||||
combat.castAbility(heal.id, { targetActorId: injured.id, now });
|
||||
}
|
||||
}
|
||||
const mob = useCombatStore.getState().mobs[bossId];
|
||||
@@ -72,6 +76,39 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
|
||||
}
|
||||
|
||||
describe("dungeon boss durability and difficulty", () => {
|
||||
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||
const stockadesBoss = stockades.bosses[0];
|
||||
const stockadesEntity = stockades.entities[stockades.staticSpawns.find((entry) => entry.id === stockadesBoss.id)!.entityId];
|
||||
|
||||
it("gives each level-three Stockades party its source-calibrated health minimum", () => {
|
||||
const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow");
|
||||
expect(easy).toMatchObject({ level: 4, maxHealth: 1300 });
|
||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth)
|
||||
.toBe(1300 * bossDifficultyMultipliers(10).health);
|
||||
// The dungeon catalog must not choose the companion power curve.
|
||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8);
|
||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766);
|
||||
});
|
||||
|
||||
for (const level of [1, 3, 8, 20, 40, 80]) {
|
||||
it(`preserves WoW boss mechanic windows in Stockades at level ${level}`, () => {
|
||||
for (const seed of ["wow-health-1", "wow-health-2", "wow-health-3"]) {
|
||||
const ordinary = fight(stockadesBoss.id, level, 1, seed, false, false, "wow", stockades);
|
||||
const last = fight(finalDungeonBoss(stockades)!.id, level, 1, seed, false, false, "wow", stockades);
|
||||
samples.push({ level, seed, boss: ordinary, final: last });
|
||||
expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25);
|
||||
expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => {
|
||||
const result = fight(stockadesBoss.id, 3, 1, "wow-health-1", true, false, "wow", stockades);
|
||||
expect(result.seconds, JSON.stringify(result)).not.toBeNull();
|
||||
expect(result.seconds!).toBeLessThanOrEqual(180);
|
||||
expect(result.attacks).toBeGreaterThan(0);
|
||||
expect(result.incomingDamage).toBeGreaterThan(0);
|
||||
});
|
||||
it("increases boss health and damage at every slider step without changing trash", () => {
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === final.id)!;
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
|
||||
@@ -4,11 +4,41 @@ import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLe
|
||||
import { finalDungeonBoss } from "./dungeonRunStore";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import type { PopulationEntityDefinition } from "./mobPopulation";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
|
||||
/** Shared by rendering and deterministic combat benchmarks; preserves authored stats above the floor. */
|
||||
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
|
||||
[2, 900],
|
||||
[4, 1_300],
|
||||
[9, 7_000],
|
||||
[21, 11_000],
|
||||
[41, 23_000],
|
||||
[80, 180_000],
|
||||
];
|
||||
|
||||
function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number {
|
||||
const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel);
|
||||
if (upperIndex <= 0) return anchors[0][1];
|
||||
if (upperIndex < 0) return anchors[anchors.length - 1][1];
|
||||
const [lowerLevel, lowerHealth] = anchors[upperIndex - 1];
|
||||
const [upperLevel, upperHealth] = anchors[upperIndex];
|
||||
const progress = (level - lowerLevel) / (upperLevel - lowerLevel);
|
||||
return lowerHealth + (upperHealth - lowerHealth) * progress;
|
||||
}
|
||||
|
||||
function partyBossHealthFloor(level: number, characterSource: ContentCategoryId): number {
|
||||
if (characterSource === "wow") return interpolatedFloor(level, WOW_BOSS_HEALTH_FLOOR);
|
||||
const baseline = 1_000 + level * 1_200 + level * level * 22;
|
||||
// Each catalog is calibrated independently against its strongest canonical
|
||||
// class-role cohort so every fight retains the 25/45-second mechanic window.
|
||||
return baseline * (characterSource === "rom" ? 1.15 : 1.75);
|
||||
}
|
||||
|
||||
/** Campaign fights retain a durability floor; testing rooms use level-scaled authored stats. */
|
||||
export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: PopulationEntityDefinition,
|
||||
instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number) {
|
||||
instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number,
|
||||
characterSource: ContentCategoryId = "wow") {
|
||||
const boss = entity.kind === "boss";
|
||||
const testing = dungeon.id.startsWith("boss-testing:");
|
||||
const level = scaledDungeonEnemyLevel(characterLevel, boss);
|
||||
const authoredHealth = entity.combat?.healthMultiplier ?? 1;
|
||||
const authoredDamage = entity.combat?.damageMultiplier ?? 1;
|
||||
@@ -16,10 +46,12 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
|
||||
const native = entity.combat?.serverEntry ? creatureRuntimeBaseStats(entity.combat.serverEntry, level) : null;
|
||||
const originalHealth = (baseline ?? native?.health ?? (boss ? 260 + level * 65 : 72 + level * 22)) * authoredHealth;
|
||||
const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 };
|
||||
const finalBoss = boss && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
|
||||
// Calibrated against active RoM, WoW and CoA class-role rotations with heirlooms.
|
||||
// Every class faces the same floor; the margin covers stronger CoA burst builds.
|
||||
const bossFloor = (1_000 + level * 1_200 + level * level * 22) * 1.75 * (finalBoss ? 1.8 : 1);
|
||||
return { level, maxHealth: (boss ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health,
|
||||
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
|
||||
// Match durability to the character's game, including when crossing dungeon catalogs.
|
||||
const partyHealthFloor = partyBossHealthFloor(level, characterSource);
|
||||
const bossFloor = partyHealthFloor * (finalBoss ? 1.8 : 1);
|
||||
// A test room's sole objective must not promote every boss to a campaign finale
|
||||
// or replace low-level native health with the campaign's full-party minimum.
|
||||
return { level, maxHealth: (boss && !testing ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health,
|
||||
healthMultiplier: authoredHealth * multipliers.health, damageMultiplier: authoredDamage * multipliers.damage };
|
||||
}
|
||||
@@ -3,9 +3,64 @@ import {
|
||||
actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay,
|
||||
HUD_ELEMENTS, resolveHudScreen,
|
||||
activateDesktopAbilityLoadout, assignedActionBarAbilityIds,
|
||||
} from "./uiLayout";
|
||||
|
||||
describe("personal UI layouts", () => {
|
||||
it("populates fresh PC bars without changing shared keys, geometry, templates, or mobile controls", () => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
ui.current.pc.wowBars = true;
|
||||
ui.current.pc.barCount = 2;
|
||||
ui.current.pc.bars[1].columns = 3;
|
||||
ui.current.pc = bindDesktopSlot(ui.current.pc, 1, 0, "Ctrl+KeyQ");
|
||||
ui.slots.pc[0] = { name: "Raid", layout: ui.current.pc };
|
||||
const defaults = Array.from({ length: 14 }, (_, i) => `spell-${i}`);
|
||||
const activated = activateDesktopAbilityLoadout(ui, "new:priest:", defaults, defaults);
|
||||
expect(activated.current.pc.bars[0].slots[0]).toEqual({ abilityId: "spell-0", key: "Digit1" });
|
||||
expect(activated.current.pc.bars[1].slots[0]).toEqual({ abilityId: "spell-12", key: "Ctrl+KeyQ" });
|
||||
expect(activated.current.pc.bars[1].columns).toBe(3);
|
||||
expect(activated.current.pc.barCount).toBe(2);
|
||||
expect(activated.current.pc.wowBars).toBe(true);
|
||||
expect(activated.current.mobile).toEqual(ui.current.mobile);
|
||||
expect(activated.slots).toEqual(ui.slots);
|
||||
expect(ui.current.pc.bars[0].slots[0].abilityId).toBeNull();
|
||||
});
|
||||
it("restores separate character and ordered-class loadouts, including intentionally empty bars", () => {
|
||||
let ui = activateDesktopAbilityLoadout(normalizeUiPreferences(null), "one:priest:", ["heal"], ["heal"]);
|
||||
ui.desktopAbilityLoadouts["one:priest:"] = Array(60).fill(null);
|
||||
ui = activateDesktopAbilityLoadout(ui, "two:priest:", ["heal"], ["heal"]);
|
||||
expect(ui.current.pc.bars[0].slots[0].abilityId).toBe("heal");
|
||||
ui = activateDesktopAbilityLoadout(ui, "one:priest:", ["heal"], ["heal"]);
|
||||
expect(ui.current.pc.bars.every((bar) => bar.slots.every((slot) => slot.abilityId === null))).toBe(true);
|
||||
ui = activateDesktopAbilityLoadout(ui, "one:rom-priest:rom-warrior", ["secondary-attack"], ["secondary-attack"]);
|
||||
expect(ui.current.pc.bars[0].slots[0].abilityId).toBe("secondary-attack");
|
||||
expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui);
|
||||
});
|
||||
it("adopts compatible legacy assignments once and repairs bars holding only another class's spells", () => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
ui.current.pc = assignDesktopAbility(ui.current.pc, 0, 2, "custom-heal");
|
||||
const migrated = activateDesktopAbilityLoadout(ui, "one:priest:", ["heal"], ["heal", "custom-heal"]);
|
||||
expect(migrated.current.pc.bars[0].slots[0].abilityId).toBeNull();
|
||||
expect(migrated.current.pc.bars[0].slots[2].abilityId).toBe("custom-heal");
|
||||
const next = activateDesktopAbilityLoadout(migrated, "two:priest:", ["heal"], ["heal", "custom-heal"]);
|
||||
expect(next.current.pc.bars[0].slots[0].abilityId).toBe("heal");
|
||||
expect(next.current.pc.bars[0].slots[2].abilityId).toBeNull();
|
||||
const repaired = activateDesktopAbilityLoadout(ui, "new:mage:", ["fireball"], ["fireball"]);
|
||||
expect(repaired.current.pc.bars[0].slots[0].abilityId).toBe("fireball");
|
||||
const oldExport = JSON.parse(exportUiPreferences(ui));
|
||||
delete oldExport.desktopAbilityLoadouts;
|
||||
expect(importUiPreferences(JSON.stringify(oldExport)).desktopAbilityLoadouts).toEqual({});
|
||||
});
|
||||
it("checks the selected layout across modifier layers and active PC bars", () => {
|
||||
const layout = defaultUiLayout();
|
||||
layout.bars[0].slots[0].abilityId = "pc-heal";
|
||||
layout.bars[1].slots[0].abilityId = "inactive-heal";
|
||||
const bindings = { "primary:face-bottom": "controller-heal", "tertiary:r2": "modifier-heal" };
|
||||
expect([...assignedActionBarAbilityIds(layout, bindings, false)]).toEqual(["controller-heal", "modifier-heal"]);
|
||||
expect([...assignedActionBarAbilityIds(layout, bindings, true)]).toEqual(["pc-heal"]);
|
||||
layout.barCount = 2;
|
||||
expect(assignedActionBarAbilityIds(layout, bindings, true).has("inactive-heal")).toBe(true);
|
||||
});
|
||||
it("assigns every element one display, saves routing, and keeps single-display widgets available", () => {
|
||||
const preferences = normalizeUiPreferences(null);
|
||||
const layout = preferences.current.mobile;
|
||||
@@ -117,6 +172,7 @@ describe("personal UI layouts", () => {
|
||||
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "NotAKey"; }],
|
||||
["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
|
||||
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }],
|
||||
["invalid character bars", (data: any) => { data.desktopAbilityLoadouts = { character: ["heal"] }; }],
|
||||
])("rejects %s without mutating the original preferences", (_, mutate) => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
const original = exportUiPreferences(ui);
|
||||
|
||||
+46
-1
@@ -36,6 +36,8 @@ export interface UiTemplate { name: string; layout: UiLayout }
|
||||
export interface UiPreferences {
|
||||
version: 1;
|
||||
keybinds: InputPreferences;
|
||||
/** Ability assignments belong to a character and ordered class pair; geometry and keys stay shared. */
|
||||
desktopAbilityLoadouts: Record<string, (string | null)[]>;
|
||||
current: Record<UiDevice, UiLayout>;
|
||||
slots: Record<UiDevice, (UiTemplate | null)[]>;
|
||||
}
|
||||
@@ -139,6 +141,12 @@ export function normalizeUiPreferences(value: unknown): UiPreferences {
|
||||
return {
|
||||
version: 1,
|
||||
keybinds: normalizeInputPreferences(source.keybinds),
|
||||
desktopAbilityLoadouts: Object.fromEntries(Object.entries(record(source.desktopAbilityLoadouts))
|
||||
.filter(([key, value]) => key.length <= 300 && Array.isArray(value))
|
||||
.map(([key, value]) => [key, Array.from({ length: 60 }, (_, index) => {
|
||||
const id = (value as unknown[])[index];
|
||||
return typeof id === "string" && id.length <= 200 ? id : null;
|
||||
})])),
|
||||
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
|
||||
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
|
||||
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
|
||||
@@ -162,11 +170,41 @@ export function assignDesktopAbility(layout: UiLayout, barIndex: number, slotInd
|
||||
}
|
||||
|
||||
/** Restore abilities only; key chords and the arrangement belong to the UI template. */
|
||||
export function resetDesktopAbilities(layout: UiLayout, abilityIds: readonly string[], barIndex?: number): UiLayout {
|
||||
export function resetDesktopAbilities(layout: UiLayout, abilityIds: readonly (string | null)[], barIndex?: number): UiLayout {
|
||||
return { ...layout, bars: layout.bars.map((bar, b) => barIndex !== undefined && b !== barIndex ? bar : {
|
||||
...bar, slots: bar.slots.map((slot, s) => ({ ...slot, abilityId: abilityIds[b * 12 + s] ?? null })),
|
||||
}) };
|
||||
}
|
||||
|
||||
/** Use the same class defaults as mobile, while preserving an existing character's empty slots. */
|
||||
export function activateDesktopAbilityLoadout(
|
||||
preferences: UiPreferences, key: string, defaults: readonly string[], availableIds: readonly string[],
|
||||
): UiPreferences {
|
||||
const layout = preferences.current.pc;
|
||||
const available = new Set(availableIds);
|
||||
const legacy = layout.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId));
|
||||
const saved = preferences.desktopAbilityLoadouts[key];
|
||||
// Adopt the old account-wide assignments once, only for a compatible character.
|
||||
const abilities = saved ?? (Object.keys(preferences.desktopAbilityLoadouts).length === 0
|
||||
&& legacy.some((id) => id && available.has(id))
|
||||
? legacy.map((id) => id && available.has(id) ? id : null)
|
||||
: defaults);
|
||||
const pc = resetDesktopAbilities(layout, abilities);
|
||||
return { ...preferences, current: { ...preferences.current, pc },
|
||||
desktopAbilityLoadouts: { ...preferences.desktopAbilityLoadouts,
|
||||
[key]: pc.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId)) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Modifier layers count together; inactive PC bars cannot be used until enabled. */
|
||||
export function assignedActionBarAbilityIds(
|
||||
layout: UiLayout, bindings: Readonly<Record<string, string | null>>, desktop: boolean,
|
||||
): ReadonlySet<string> {
|
||||
const ids = desktop
|
||||
? layout.bars.slice(0, layout.barCount).flatMap((bar) => bar.slots.map((slot) => slot.abilityId))
|
||||
: Object.values(bindings);
|
||||
return new Set(ids.filter((id): id is string => Boolean(id)));
|
||||
}
|
||||
export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null {
|
||||
if (!layout.wowBars || !key) return null;
|
||||
// Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them.
|
||||
@@ -180,6 +218,13 @@ export function importUiPreferences(text: string): UiPreferences {
|
||||
let source: Record<string, unknown>;
|
||||
try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); }
|
||||
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
|
||||
if (source.desktopAbilityLoadouts !== undefined) {
|
||||
if (!source.desktopAbilityLoadouts || typeof source.desktopAbilityLoadouts !== "object" || Array.isArray(source.desktopAbilityLoadouts)) throw new Error("Invalid character action bars.");
|
||||
for (const [key, ids] of Object.entries(record(source.desktopAbilityLoadouts))) {
|
||||
if (key.length > 300 || !Array.isArray(ids) || ids.length !== 60
|
||||
|| ids.some((id) => id !== null && (typeof id !== "string" || id.length > 200))) throw new Error("Invalid character action bars.");
|
||||
}
|
||||
}
|
||||
const validateLayout = (value: unknown) => {
|
||||
const layout = record(value);
|
||||
if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display.");
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDefaultAppearance } from "../app/characterCatalog";
|
||||
import { createCharacter, loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { defaultActionBarForCharacter } from "./abilityCatalog";
|
||||
import { assignDesktopAbility, normalizeUiPreferences } from "./uiLayout";
|
||||
import { activateCharacterUi, useUiLayoutStore } from "./uiLayoutStore";
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); useShellStore.setState({ session: null }); });
|
||||
|
||||
describe("character PC action bars", () => {
|
||||
it("persists custom assignments and empty slots across character switches and sign-in", () => {
|
||||
const values = new Map<string, string>();
|
||||
const storage = { getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { values.set(key, value); },
|
||||
removeItem: (key: string) => { values.delete(key); } };
|
||||
vi.stubGlobal("window", { localStorage: storage });
|
||||
const create = (name: string) => createCharacter({ ownerId: "offline-bars", name,
|
||||
classId: "priest", raceId: "human", gender: "female", appearance: createDefaultAppearance(),
|
||||
}, storage).character!;
|
||||
const first = create("Firstbars");
|
||||
const second = create("Secondbars");
|
||||
const ui = normalizeUiPreferences(null);
|
||||
ui.current.pc.wowBars = true;
|
||||
saveUiPreferences(first.ownerId, ui, storage);
|
||||
const session = { kind: "offline" as const, ownerId: first.ownerId, displayName: "Offline" };
|
||||
useShellStore.setState({ session });
|
||||
activateCharacterUi(first);
|
||||
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[0].abilityId)
|
||||
.toBe(defaultActionBarForCharacter("priest", null, 1)[0]);
|
||||
const state = useUiLayoutStore.getState();
|
||||
const pc = assignDesktopAbility(assignDesktopAbility(state.preferences.current.pc, 0, 0, null), 0, 2, "priest-flash-heal");
|
||||
state.persist({ ...state.preferences, current: { ...state.preferences.current, pc } });
|
||||
activateCharacterUi(second);
|
||||
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[0].abilityId).not.toBeNull();
|
||||
activateCharacterUi(first);
|
||||
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[0].abilityId).toBeNull();
|
||||
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[2].abilityId).toBe("priest-flash-heal");
|
||||
useShellStore.setState({ session: null });
|
||||
useShellStore.setState({ session: { ...session } });
|
||||
activateCharacterUi(first);
|
||||
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[0].abilityId).toBeNull();
|
||||
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[2].abilityId).toBe("priest-flash-heal");
|
||||
expect(loadUiPreferences("another-owner", storage).desktopAbilityLoadouts).toEqual({});
|
||||
activateCharacterUi({ ...second, ownerId: "another-owner" });
|
||||
expect(useUiLayoutStore.getState().desktopLoadoutKey).toContain(first.id);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@ import { create } from "zustand";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { abilitiesForCharacter, defaultActionBarForCharacter } from "./abilityCatalog";
|
||||
import { activateDesktopAbilityLoadout, normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
|
||||
import { publishInputPreferences } from "../input/keybindRuntime";
|
||||
|
||||
export function currentUiDevice(): UiDevice {
|
||||
@@ -13,6 +15,7 @@ export function currentUiDevice(): UiDevice {
|
||||
}
|
||||
interface UiLayoutState {
|
||||
ownerId: string | null;
|
||||
desktopLoadoutKey: string | null;
|
||||
preferences: UiPreferences;
|
||||
draft: UiLayout | null;
|
||||
device: UiDevice;
|
||||
@@ -25,7 +28,7 @@ interface UiLayoutState {
|
||||
finish: (save: boolean) => void;
|
||||
}
|
||||
export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
|
||||
ownerId: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(),
|
||||
ownerId: null, desktopLoadoutKey: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(),
|
||||
mode: "layout", selectedButton: null, selectedElement: null,
|
||||
begin: () => {
|
||||
const device = currentUiDevice();
|
||||
@@ -36,7 +39,11 @@ export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
|
||||
const ownerId = get().ownerId;
|
||||
if (!ownerId) throw new Error("Sign in or choose an offline profile before saving your UI.");
|
||||
// Write first: storage errors leave the editor open with its draft intact.
|
||||
const saved = saveUiPreferences(ownerId, preferences);
|
||||
const key = get().desktopLoadoutKey;
|
||||
const saved = saveUiPreferences(ownerId, key ? { ...preferences,
|
||||
desktopAbilityLoadouts: { ...preferences.desktopAbilityLoadouts,
|
||||
[key]: preferences.current.pc.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId)) },
|
||||
} : preferences);
|
||||
set({ preferences: saved });
|
||||
},
|
||||
finish: (save) => {
|
||||
@@ -48,7 +55,7 @@ export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
|
||||
|
||||
function activateOwner() {
|
||||
const ownerId = useShellStore.getState().session?.ownerId ?? null;
|
||||
useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
|
||||
useUiLayoutStore.setState({ ownerId, desktopLoadoutKey: null, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
|
||||
}
|
||||
activateOwner();
|
||||
publishInputPreferences(useUiLayoutStore.getState().preferences.keybinds);
|
||||
@@ -57,6 +64,18 @@ useShellStore.subscribe((state, previous) => {
|
||||
if (state.session !== previous.session) activateOwner();
|
||||
});
|
||||
|
||||
/** Call after the character's catalogs have loaded, in both menu and gameplay paths. */
|
||||
export function activateCharacterUi(character: CharacterProfile): void {
|
||||
const ui = useUiLayoutStore.getState();
|
||||
if (ui.ownerId !== character.ownerId) return;
|
||||
const key = `${character.id}:${character.classId}:${character.secondaryClassId ?? ""}`;
|
||||
useUiLayoutStore.setState({ desktopLoadoutKey: key, draft: null,
|
||||
preferences: activateDesktopAbilityLoadout(ui.preferences, key,
|
||||
defaultActionBarForCharacter(character.classId, character.secondaryClassId, character.level),
|
||||
abilitiesForCharacter(character.classId, character.secondaryClassId).map((ability) => ability.id)),
|
||||
});
|
||||
}
|
||||
|
||||
export function activeUiLayout(): UiLayout {
|
||||
const state = useUiLayoutStore.getState();
|
||||
return state.draft ?? state.preferences.current[currentUiDevice()];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { activateCharacterUi } from "./uiLayoutStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useArenaStore } from "./arenaStore";
|
||||
@@ -16,6 +17,7 @@ export function useArenaCombatView(character: CharacterProfile | null, actor: Ar
|
||||
const previousParty = usePartyStore.getState();
|
||||
const previousGame = useGameStore.getState();
|
||||
useCombatStore.getState().initializeCharacter(character);
|
||||
activateCharacterUi(character);
|
||||
usePartyStore.setState({ members: [] });
|
||||
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
|
||||
const unsubscribe = useCombatStore.subscribe((state, before) => {
|
||||
|
||||
Reference in new issue
Block a user