forked from phenom/healer-man
Release Healer Man 0.1.5
This commit is contained in:
1 parent
f4c9cf356c
commit
55cfd43d66
62 files changed
+3030
-153
No files matched your search
@@ -1,12 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||
import { useGameStore } from "./store";
|
||||
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { resolveManastormActorDown } from "./manastormSession";
|
||||
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||
|
||||
/**
|
||||
@@ -126,16 +124,6 @@ export function CombatBridge() {
|
||||
epochNow,
|
||||
);
|
||||
}
|
||||
if (useCombatStore.getState().health <= 0 && game.gameMode === "manastorm") {
|
||||
const resolution = resolveManastormActorDown({ kind: "player" });
|
||||
if (resolution === "failed") useShellStore.getState().failActiveManastorm();
|
||||
return;
|
||||
}
|
||||
if (respawnDefeatedPlayer()) {
|
||||
// resetAtEntrance increments this revision. Mark it consumed because
|
||||
// respawnDefeatedPlayer already reset combat atomically.
|
||||
mountedResetRevision.current = useGameStore.getState().resetRevision;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { preloadManastormStage } from "./manastormStageLoader";
|
||||
/** Connects staged combat, party sizing, and shared resurrections to Manastorm state. */
|
||||
export function ManastormBridge() {
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const gmTestMode = useGameStore((state) => state.gmTestMode);
|
||||
const status = useManastormStore((state) => state.status);
|
||||
const partySize = useManastormStore((state) => state.partySize);
|
||||
const encounter = useManastormStore((state) => state.currentEncounter);
|
||||
@@ -103,7 +104,7 @@ export function ManastormBridge() {
|
||||
useEffect(() => {
|
||||
if (!characterId) return;
|
||||
return useManastormStore.subscribe((state, previous) => {
|
||||
if (useGameStore.getState().gameMode !== "manastorm") return;
|
||||
if (useGameStore.getState().gameMode !== "manastorm" || useGameStore.getState().gmTestMode) return;
|
||||
const character = useShellStore.getState().activeCharacter;
|
||||
if (!character || character.id !== characterId) return;
|
||||
const result = synchronizeManastormProgress(
|
||||
@@ -116,7 +117,7 @@ export function ManastormBridge() {
|
||||
useShellStore.getState().saveActiveManastormProgress(result.progress);
|
||||
}
|
||||
});
|
||||
}, [characterId]);
|
||||
}, [characterId, gmTestMode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -124,6 +124,20 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", abilityId)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an explicit resurrection action to restore a defeated player", () => {
|
||||
const maxHealth = useCombatStore.getState().maxHealth;
|
||||
useCombatStore.getState().damagePlayer(maxHealth * 10);
|
||||
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().healPlayer(maxHealth)).toBe(0);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
|
||||
const restored = useCombatStore.getState().revivePlayer(0.35);
|
||||
expect(restored).toBe(Math.max(1, Math.round(maxHealth * 0.35)));
|
||||
expect(useCombatStore.getState().health).toBe(restored);
|
||||
expect(useCombatStore.getState().revivePlayer(1)).toBe(0);
|
||||
});
|
||||
|
||||
it("starts with attribute-derived pools including racial and automatic class passives", () => {
|
||||
const gnomeMage = deriveCharacterStats("mage", 1, EMPTY_ITEM_STATS, "gnome");
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "gnome", level: 1 });
|
||||
|
||||
@@ -520,6 +520,7 @@ export interface CombatState {
|
||||
tauntMob: (id: string, source: ThreatSource, durationMs: number, now?: number) => boolean;
|
||||
damagePlayer: (amount: number, school?: DamageSchool, attackerLevel?: number) => number;
|
||||
healPlayer: (amount: number) => number;
|
||||
revivePlayer: (percentMaxHealth?: number) => number;
|
||||
cancelCast: (message?: string) => boolean;
|
||||
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
@@ -3851,6 +3852,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
healPlayer: (amount) => {
|
||||
const state = get();
|
||||
// Ordinary healing and lingering HoTs must not raise a defeated player.
|
||||
// Resurrection is an explicit combat action handled by revivePlayer.
|
||||
if (state.health <= 0) return 0;
|
||||
const modified = resolveAuraHealingValue(
|
||||
amount,
|
||||
aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID),
|
||||
@@ -3867,6 +3871,22 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return healed;
|
||||
},
|
||||
|
||||
revivePlayer: (percentMaxHealth = 0.35) => {
|
||||
const state = get();
|
||||
if (state.health > 0 || state.maxHealth <= 0) return 0;
|
||||
const restored = Math.max(1, Math.round(
|
||||
state.maxHealth * Math.max(0.01, Math.min(1, percentMaxHealth)),
|
||||
));
|
||||
set({
|
||||
health: restored,
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||
feedback: nextFeedback("heal", `Resurrected with ${restored} health.`, { amount: restored }),
|
||||
});
|
||||
return restored;
|
||||
},
|
||||
|
||||
cancelCast: (message = "Casting interrupted.") => {
|
||||
const cast = get().activeCast;
|
||||
if (!cast) return false;
|
||||
|
||||
@@ -223,6 +223,23 @@ describe("generated dungeon campaign catalog", () => {
|
||||
.toEqual([-819.8267, 39.4232, -87.3708]);
|
||||
});
|
||||
|
||||
it("keeps Stockades creatures in the package's native world coordinates", () => {
|
||||
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||
const firstSpawn = stockades.staticSpawns.find(
|
||||
(spawn) => spawn.id === "creature-79035",
|
||||
);
|
||||
const targorr = stockades.staticSpawns.find(
|
||||
(spawn) => spawn.id === "creature-84027",
|
||||
);
|
||||
|
||||
expect(stockades.environmentMode).toBe("global-wmo");
|
||||
expect(stockades.provenance.coordinateTransform)
|
||||
.toBe("three(x,y,z)=draft(x,y,z)");
|
||||
expect(stockades.entrance.footPosition).toEqual([-54.23, -18.34, 0.28]);
|
||||
expect(firstSpawn?.position).toEqual([-82.3245, -26.4396, -10.6106]);
|
||||
expect(targorr?.position).toEqual([-159.582, -25.6062, 1.2531]);
|
||||
});
|
||||
|
||||
it("starts Razorfen Kraul inside the portal and maps its first pack into the WMO", () => {
|
||||
const razorfenKraul = requireDungeonDefinition("razorfen-kraul");
|
||||
const firstPackSpawn = razorfenKraul.staticSpawns.find(
|
||||
|
||||
@@ -8,10 +8,12 @@ import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore, type GameMode } from "./store";
|
||||
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
||||
import type { WorldSpawn } from "./dungeonTypes";
|
||||
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||
|
||||
export interface DungeonSessionOptions {
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
readonly gmTestMode?: boolean;
|
||||
/** Used by in-game encounter transitions so character progression and inventory survive. */
|
||||
readonly preserveCharacter?: boolean;
|
||||
}
|
||||
@@ -72,7 +74,8 @@ export function activateDungeonSession(
|
||||
}
|
||||
useGameStore.getState().activateDungeon(dungeonId as DungeonId, difficultyId, {
|
||||
gameMode: options.gameMode,
|
||||
spawn: options.spawn,
|
||||
spawn: options.spawn ?? useManastormAdminStore.getState().config.dungeonSpawns[dungeonId],
|
||||
gmTestMode: options.gmTestMode,
|
||||
});
|
||||
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
|
||||
return true;
|
||||
@@ -87,6 +90,7 @@ export function restartActiveDungeonSession(): boolean {
|
||||
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
|
||||
gameMode: game.gameMode,
|
||||
spawn: game.activeSpawn,
|
||||
gmTestMode: game.gmTestMode,
|
||||
preserveCharacter: true,
|
||||
});
|
||||
}
|
||||
@@ -5,10 +5,13 @@ import { dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||
|
||||
describe("dungeon session asset eviction", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useManastormAdminStore.setState({ config: DEFAULT_MANASTORM_ADMIN_CONFIG, draft: DEFAULT_MANASTORM_ADMIN_CONFIG, dirty: false });
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
});
|
||||
|
||||
@@ -44,4 +47,13 @@ describe("dungeon session asset eviction", () => {
|
||||
expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
|
||||
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
|
||||
});
|
||||
|
||||
it("applies the published dungeon group anchor at the session boundary", () => {
|
||||
const spawn = { footPosition: [12, 3, -8] as const, yaw: 1.25 };
|
||||
useManastormAdminStore.setState({
|
||||
config: { ...DEFAULT_MANASTORM_ADMIN_CONFIG, dungeonSpawns: { "wailing-caverns": spawn } },
|
||||
});
|
||||
expect(activateDungeonSession("wailing-caverns")).toBe(true);
|
||||
expect(useGameStore.getState().activeSpawn).toEqual(spawn);
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,2 @@
|
||||
// Generated by npm run dungeon:catalog. Do not edit.
|
||||
export default "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
Line truncated
|
||||
export default "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
Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":326,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":169,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":145,"roamingPacks":8,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
||||
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":326,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":169,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":145,"roamingPacks":8,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
manastormCheckpointForLevel,
|
||||
manastormModeForCharacterLevel,
|
||||
manastormExperienceReward,
|
||||
manastormAffixes,
|
||||
manastormScaling,
|
||||
setManastormCatalog,
|
||||
shuffleManastormEncounterIds,
|
||||
@@ -155,6 +156,32 @@ describe("Manastorm rules", () => {
|
||||
.toBe(fullGroup.encounter.id);
|
||||
});
|
||||
|
||||
it("does not restore fallback content when a published Ready pool is empty", () => {
|
||||
const emptyPublishedCatalog: ManastormCatalogLike = { stages: [], fallbackPolicy: "none" };
|
||||
expect(() => drawManastormStage(1, 1, [], null, () => 0, emptyPublishedCatalog))
|
||||
.toThrow("no unlocked encounter");
|
||||
});
|
||||
|
||||
it("uses the standard pool at milestone levels when forced milestones are disabled", () => {
|
||||
const catalog: ManastormCatalogLike = {
|
||||
stages: [
|
||||
stage("standard", { unlockLevel: 1 }),
|
||||
stage("milestone", { unlockLevel: 5, stageKind: "milestone", milestoneLevel: 5 }),
|
||||
],
|
||||
runtimeOptions: { milestoneEncountersEnabled: false },
|
||||
};
|
||||
expect(drawManastormStage(5, 1, [], null, () => 0, catalog).encounter.id).toBe("standard");
|
||||
});
|
||||
|
||||
it("disables both explicit and modifier-derived affixes", () => {
|
||||
const catalog: ManastormCatalogLike = {
|
||||
affixes: [{ id: "explicit", name: "Explicit", description: "Fixture", unlockLevel: 1 }],
|
||||
modifiers: [{ id: "fallback", modeId: 0, level: 1, rawFlag: 7, tuning: [] }],
|
||||
runtimeOptions: { affixesEnabled: false },
|
||||
};
|
||||
expect(manastormAffixes(1, catalog, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps leveling and end-game encounter pools separate by character state", () => {
|
||||
const catalog: ManastormCatalogLike = {
|
||||
id: "split-mode-fixture",
|
||||
|
||||
+22
-4
@@ -32,6 +32,7 @@ export type ManastormPhase =
|
||||
|
||||
export interface ManastormEncounterDefinition {
|
||||
readonly id: string;
|
||||
readonly encounterId?: number;
|
||||
readonly dungeonId: DungeonId;
|
||||
/** Authoritative package map identity; dungeonId may only select fallback scene geometry. */
|
||||
readonly mapId?: number;
|
||||
@@ -42,9 +43,12 @@ export interface ManastormEncounterDefinition {
|
||||
readonly bossEntityId: string;
|
||||
readonly bossName: string;
|
||||
readonly bossPosition: MobVector3;
|
||||
readonly bossYaw?: number;
|
||||
readonly playerSpawn: WorldSpawn;
|
||||
readonly portalPosition: MobVector3;
|
||||
readonly trashRuntimeIds?: readonly string[];
|
||||
readonly requiredGuardianKills?: number;
|
||||
readonly chaoticLinkEnabled?: boolean;
|
||||
readonly modeIds?: readonly number[];
|
||||
readonly stageKind?: ManastormStageKind;
|
||||
readonly unlockLevel?: number;
|
||||
@@ -186,9 +190,12 @@ export interface ManastormCatalogEncounterLike {
|
||||
readonly bossEntityId?: string;
|
||||
readonly bossName?: string;
|
||||
readonly bossPosition?: MobVector3;
|
||||
readonly bossYaw?: number;
|
||||
readonly playerSpawn?: WorldSpawn;
|
||||
readonly portalPosition?: MobVector3;
|
||||
readonly trashRuntimeIds?: readonly string[];
|
||||
readonly requiredGuardianKills?: number;
|
||||
readonly chaoticLinkEnabled?: boolean;
|
||||
readonly stageKind?: ManastormStageKind;
|
||||
readonly unlockLevel?: number;
|
||||
readonly milestoneLevel?: ManastormSpecialLevel;
|
||||
@@ -299,6 +306,15 @@ export interface ManastormCatalogLike {
|
||||
readonly rewards?: readonly ManastormCatalogRewardLike[];
|
||||
readonly loadouts?: readonly ManastormCatalogLoadoutLike[];
|
||||
readonly defaultLoadoutId?: string;
|
||||
readonly fallbackPolicy?: "legacy" | "none";
|
||||
readonly runtimeOptions?: {
|
||||
readonly enabled?: boolean;
|
||||
readonly levelingEnabled?: boolean;
|
||||
readonly endgameEnabled?: boolean;
|
||||
readonly affixesEnabled?: boolean;
|
||||
readonly chaoticLinkEnabled?: boolean;
|
||||
readonly milestoneEncountersEnabled?: boolean;
|
||||
};
|
||||
readonly scaling?: {
|
||||
readonly perLevel?: number;
|
||||
readonly bonusLootPerLevel?: number;
|
||||
@@ -583,7 +599,7 @@ export function runtimeStages(catalog: ManastormCatalogLike = getManastormCatalo
|
||||
minimumPartySize: normalizeManastormPartySize(stage.minimumPartySize ?? 1),
|
||||
maximumPartySize: normalizeManastormPartySize(stage.maximumPartySize ?? 5),
|
||||
}));
|
||||
return stages.length ? stages : MANASTORM_ENCOUNTERS;
|
||||
return stages.length || catalog.fallbackPolicy === "none" ? stages : MANASTORM_ENCOUNTERS;
|
||||
}
|
||||
|
||||
export function drawManastormStage(
|
||||
@@ -609,7 +625,8 @@ export function drawManastormStage(
|
||||
&& (stage.minimumPartySize ?? 1) <= safePartySize
|
||||
&& (stage.maximumPartySize ?? 5) >= safePartySize
|
||||
));
|
||||
const special = isManastormSpecialLevel(safeLevel);
|
||||
const special = catalog.runtimeOptions?.milestoneEncountersEnabled !== false
|
||||
&& isManastormSpecialLevel(safeLevel);
|
||||
const milestoneStages = unlocked.filter((stage) => stage.milestoneLevel === safeLevel);
|
||||
const milestonePool = unlocked.filter((stage) => (
|
||||
stage.stageKind === "milestone" || stage.milestoneLevel !== undefined
|
||||
@@ -621,7 +638,7 @@ export function drawManastormStage(
|
||||
let eligible = special
|
||||
? (milestoneStages.length ? milestoneStages : milestonePool)
|
||||
: standardStages;
|
||||
if (!eligible.length) {
|
||||
if (!eligible.length && catalog.fallbackPolicy !== "none") {
|
||||
eligible = MANASTORM_ENCOUNTERS.filter((stage) => (
|
||||
(stage.unlockLevel ?? 1) <= safeLevel
|
||||
&& (special
|
||||
@@ -629,7 +646,7 @@ export function drawManastormStage(
|
||||
: stage.stageKind !== "milestone" && !stage.milestoneLevel)
|
||||
));
|
||||
}
|
||||
if (!eligible.length) eligible = [MANASTORM_ENCOUNTERS[0]];
|
||||
if (!eligible.length && catalog.fallbackPolicy !== "none") eligible = [MANASTORM_ENCOUNTERS[0]];
|
||||
|
||||
const byId = new Map(eligible.map((stage) => [stage.id, stage]));
|
||||
let available = bag.filter((id) => byId.has(id));
|
||||
@@ -839,6 +856,7 @@ export function manastormAffixes(
|
||||
modeId = manastormModeId(catalog),
|
||||
unlockedThroughLevel = level,
|
||||
): readonly ManastormAffixState[] {
|
||||
if (catalog.runtimeOptions?.affixesEnabled === false) return [];
|
||||
const safeLevel = Math.max(1, Math.trunc(Number.isFinite(level) ? level : 1));
|
||||
const safeUnlockedThroughLevel = Math.max(
|
||||
safeLevel,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runtimeStages } from "./manastorm";
|
||||
import {
|
||||
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
UNIFIED_MANASTORM_BOSS_CATALOG,
|
||||
authoredStageConfigForBoss,
|
||||
bossAdminRecordForStatus,
|
||||
derivePartySpawnSlots,
|
||||
effectiveManastormCatalog,
|
||||
isGameMasterSession,
|
||||
resolveBossAdminEntry,
|
||||
resolvedBossAdminCatalog,
|
||||
type ManastormAdminConfig,
|
||||
} from "./manastormAdminConfig";
|
||||
|
||||
function configWithBoss(key: string, record: ManastormAdminConfig["bosses"][string]): ManastormAdminConfig {
|
||||
return {
|
||||
...DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
bosses: { [key]: record },
|
||||
};
|
||||
}
|
||||
|
||||
describe("Manastorm GM catalog", () => {
|
||||
it("deduplicates map/name records and defaults every boss to Needs tested", () => {
|
||||
const identities = UNIFIED_MANASTORM_BOSS_CATALOG.map((entry) => `${entry.mapId}:${entry.name.toLowerCase()}`);
|
||||
expect(new Set(identities).size).toBe(identities.length);
|
||||
expect(UNIFIED_MANASTORM_BOSS_CATALOG.length).toBeGreaterThan(0);
|
||||
expect(resolvedBossAdminCatalog(DEFAULT_MANASTORM_ADMIN_CONFIG).every((entry) => (
|
||||
entry.status === "needs-tested" && !entry.inPublicPool
|
||||
))).toBe(true);
|
||||
const crossMode = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => entry.name === "Burning Felguard");
|
||||
expect(crossMode?.baseStage?.modeIds).toEqual(expect.arrayContaining([0, 2]));
|
||||
});
|
||||
|
||||
it("publishes only runnable Ready bosses and never restores the legacy pool", () => {
|
||||
const candidate = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => (
|
||||
resolveBossAdminEntry(entry, configWithBoss(entry.key, { status: "ready" })).runnable
|
||||
));
|
||||
expect(candidate).toBeTruthy();
|
||||
const config = configWithBoss(candidate!.key, { status: "ready" });
|
||||
const effective = effectiveManastormCatalog(config);
|
||||
expect(effective.fallbackPolicy).toBe("none");
|
||||
expect(runtimeStages(effective)).toHaveLength(1);
|
||||
expect(runtimeStages(effective)[0].bossName).toBe(candidate!.name);
|
||||
|
||||
const empty = effectiveManastormCatalog(DEFAULT_MANASTORM_ADMIN_CONFIG);
|
||||
expect(empty.fallbackPolicy).toBe("none");
|
||||
expect(runtimeStages(empty)).toEqual([]);
|
||||
});
|
||||
|
||||
it("materializes an inherited imported stage when a runnable boss becomes Ready", () => {
|
||||
const candidate = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => entry.baseStage);
|
||||
expect(candidate).toBeTruthy();
|
||||
const record = bossAdminRecordForStatus(candidate!, DEFAULT_MANASTORM_ADMIN_CONFIG, "ready");
|
||||
expect(record.status).toBe("ready");
|
||||
expect(record.stage).toMatchObject({
|
||||
dungeonId: candidate!.baseStage!.dungeonId,
|
||||
bossRuntimeId: candidate!.baseStage!.bossRuntimeId,
|
||||
bossPosition: candidate!.baseStage!.bossPosition,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects stale exact-mob IDs and bounds required kills", () => {
|
||||
const candidate = UNIFIED_MANASTORM_BOSS_CATALOG.find((entry) => authoredStageConfigForBoss(entry, DEFAULT_MANASTORM_ADMIN_CONFIG));
|
||||
expect(candidate).toBeTruthy();
|
||||
const stage = authoredStageConfigForBoss(candidate!, DEFAULT_MANASTORM_ADMIN_CONFIG)!;
|
||||
const stale = configWithBoss(candidate!.key, {
|
||||
status: "ready",
|
||||
stage: {
|
||||
...stage,
|
||||
linkedMobRuntimeIds: ["missing:runtime-id"],
|
||||
requiredKillCount: 1,
|
||||
},
|
||||
});
|
||||
const resolved = resolveBossAdminEntry(candidate!, stale);
|
||||
expect(resolved.runnable).toBe(false);
|
||||
expect(resolved.validationIssues.join(" ")).toContain("missing:runtime-id");
|
||||
});
|
||||
|
||||
it("derives a stable five-person formation from one anchor and yaw", () => {
|
||||
const slots = derivePartySpawnSlots({ footPosition: [10, 2, 20], yaw: Math.PI / 2 }, 5);
|
||||
expect(slots).toHaveLength(5);
|
||||
expect(slots[0]).toEqual({ footPosition: [10, 2, 20], yaw: Math.PI / 2 });
|
||||
expect(new Set(slots.map((slot) => slot.footPosition.join(","))).size).toBe(5);
|
||||
expect(slots.every((slot) => slot.yaw === Math.PI / 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("never grants GM UI authority to offline or local-only sessions", () => {
|
||||
expect(isGameMasterSession({ kind: "offline", roles: ["gm"] })).toBe(false);
|
||||
expect(isGameMasterSession({ kind: "account", roles: ["gm"] })).toBe(false);
|
||||
expect(isGameMasterSession({ kind: "account", accessToken: "server-token", roles: ["gm"] })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
import { DUNGEON_DEFINITIONS, dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import type { DungeonBossObjective, Vector3Tuple, WorldSpawn } from "./dungeonTypes";
|
||||
import {
|
||||
runtimeStages,
|
||||
type ManastormCatalogLike,
|
||||
type ManastormEncounterDefinition,
|
||||
} from "./manastorm";
|
||||
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "./manastormInstalledCatalog";
|
||||
|
||||
export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 1;
|
||||
export const MANASTORM_ADMIN_CACHE_KEY = "healer-man.manastorm-admin-config.v1";
|
||||
|
||||
export type ManastormBossStatus = "ready" | "needs-tested" | "not-ready";
|
||||
|
||||
export interface ManastormGlobalOptions {
|
||||
readonly enabled: boolean;
|
||||
readonly levelingEnabled: boolean;
|
||||
readonly endgameEnabled: boolean;
|
||||
readonly affixesEnabled: boolean;
|
||||
readonly chaoticLinkEnabled: boolean;
|
||||
readonly milestoneEncountersEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ManastormAdminStageConfig {
|
||||
readonly dungeonId: string;
|
||||
readonly mapId: number;
|
||||
readonly baseStageId?: string;
|
||||
readonly assetPackageId?: string;
|
||||
readonly bossRuntimeId: string;
|
||||
readonly bossEntityId: string;
|
||||
readonly bossName: string;
|
||||
readonly bossPosition: Vector3Tuple;
|
||||
readonly bossYaw: number;
|
||||
readonly groupSpawn: WorldSpawn;
|
||||
readonly portalPosition: Vector3Tuple;
|
||||
readonly linkedMobRuntimeIds: readonly string[];
|
||||
readonly requiredKillCount: number;
|
||||
}
|
||||
|
||||
export interface ManastormBossAdminRecord {
|
||||
readonly status: ManastormBossStatus;
|
||||
readonly stage?: ManastormAdminStageConfig;
|
||||
}
|
||||
|
||||
export interface ManastormAdminConfig {
|
||||
readonly schemaVersion: typeof MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION;
|
||||
readonly global: ManastormGlobalOptions;
|
||||
readonly dungeonSpawns: Readonly<Record<string, WorldSpawn>>;
|
||||
readonly bosses: Readonly<Record<string, ManastormBossAdminRecord>>;
|
||||
}
|
||||
|
||||
export interface PublishedManastormAdminConfig {
|
||||
readonly revision: number;
|
||||
readonly updatedAt: number | null;
|
||||
readonly config: ManastormAdminConfig;
|
||||
}
|
||||
|
||||
export interface UnifiedBossCatalogEntry {
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
readonly dungeonId: string | null;
|
||||
readonly dungeonTitle: string;
|
||||
readonly mapId: number;
|
||||
readonly bossRuntimeId: string;
|
||||
readonly bossEntityId: string;
|
||||
readonly source: readonly ("dungeon" | "installed-manastorm")[];
|
||||
readonly sourceIds: readonly string[];
|
||||
readonly baseStage: ManastormEncounterDefinition | null;
|
||||
readonly objective: DungeonBossObjective | null;
|
||||
}
|
||||
|
||||
export interface ResolvedBossAdminEntry extends UnifiedBossCatalogEntry {
|
||||
readonly status: ManastormBossStatus;
|
||||
readonly stage: ManastormEncounterDefinition | null;
|
||||
readonly runnable: boolean;
|
||||
readonly validationIssues: readonly string[];
|
||||
readonly inPublicPool: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_MANASTORM_ADMIN_CONFIG: ManastormAdminConfig = Object.freeze({
|
||||
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||
global: Object.freeze({
|
||||
enabled: true,
|
||||
levelingEnabled: true,
|
||||
endgameEnabled: true,
|
||||
affixesEnabled: true,
|
||||
chaoticLinkEnabled: true,
|
||||
milestoneEncountersEnabled: true,
|
||||
}),
|
||||
dungeonSpawns: Object.freeze({}),
|
||||
bosses: Object.freeze({}),
|
||||
});
|
||||
|
||||
function normalizedName(value: string): string {
|
||||
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function finiteVector(value: unknown): value is Vector3Tuple {
|
||||
return Array.isArray(value) && value.length === 3 && value.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate));
|
||||
}
|
||||
|
||||
function normalizedSpawn(value: unknown): WorldSpawn | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const input = value as Partial<WorldSpawn>;
|
||||
if (!finiteVector(input.footPosition) || typeof input.yaw !== "number" || !Number.isFinite(input.yaw)) return null;
|
||||
return { footPosition: [...input.footPosition] as Vector3Tuple, yaw: input.yaw };
|
||||
}
|
||||
|
||||
function normalizedStage(value: unknown): ManastormAdminStageConfig | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const input = value as Partial<ManastormAdminStageConfig>;
|
||||
if (
|
||||
typeof input.dungeonId !== "string"
|
||||
|| !Number.isInteger(input.mapId)
|
||||
|| typeof input.bossRuntimeId !== "string"
|
||||
|| typeof input.bossEntityId !== "string"
|
||||
|| typeof input.bossName !== "string"
|
||||
|| !finiteVector(input.bossPosition)
|
||||
|| typeof input.bossYaw !== "number"
|
||||
|| !Number.isFinite(input.bossYaw)
|
||||
|| !finiteVector(input.portalPosition)
|
||||
|| !Array.isArray(input.linkedMobRuntimeIds)
|
||||
|| !input.linkedMobRuntimeIds.every((id) => typeof id === "string" && Boolean(id))
|
||||
|| !Number.isInteger(input.requiredKillCount)
|
||||
) return null;
|
||||
const groupSpawn = normalizedSpawn(input.groupSpawn);
|
||||
const linkedMobRuntimeIds = [...new Set(input.linkedMobRuntimeIds)];
|
||||
if (!groupSpawn || input.requiredKillCount! < 0 || input.requiredKillCount! > linkedMobRuntimeIds.length) return null;
|
||||
return {
|
||||
dungeonId: input.dungeonId,
|
||||
mapId: input.mapId!,
|
||||
...(typeof input.baseStageId === "string" && input.baseStageId ? { baseStageId: input.baseStageId } : {}),
|
||||
...(typeof input.assetPackageId === "string" && input.assetPackageId ? { assetPackageId: input.assetPackageId } : {}),
|
||||
bossRuntimeId: input.bossRuntimeId,
|
||||
bossEntityId: input.bossEntityId,
|
||||
bossName: input.bossName,
|
||||
bossPosition: [...input.bossPosition] as Vector3Tuple,
|
||||
bossYaw: input.bossYaw,
|
||||
groupSpawn,
|
||||
portalPosition: [...input.portalPosition] as Vector3Tuple,
|
||||
linkedMobRuntimeIds,
|
||||
requiredKillCount: input.requiredKillCount!,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeManastormAdminConfig(value: unknown): ManastormAdminConfig {
|
||||
if (!value || typeof value !== "object") return DEFAULT_MANASTORM_ADMIN_CONFIG;
|
||||
const input = value as Partial<ManastormAdminConfig>;
|
||||
if (input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) return DEFAULT_MANASTORM_ADMIN_CONFIG;
|
||||
const globalInput = input.global as Partial<ManastormGlobalOptions> | undefined;
|
||||
const global: ManastormGlobalOptions = {
|
||||
enabled: globalInput?.enabled !== false,
|
||||
levelingEnabled: globalInput?.levelingEnabled !== false,
|
||||
endgameEnabled: globalInput?.endgameEnabled !== false,
|
||||
affixesEnabled: globalInput?.affixesEnabled !== false,
|
||||
chaoticLinkEnabled: globalInput?.chaoticLinkEnabled !== false,
|
||||
milestoneEncountersEnabled: globalInput?.milestoneEncountersEnabled !== false,
|
||||
};
|
||||
const dungeonSpawns = Object.fromEntries(Object.entries(input.dungeonSpawns ?? {}).flatMap(([id, value]) => {
|
||||
const spawn = normalizedSpawn(value);
|
||||
return id && spawn ? [[id, spawn]] : [];
|
||||
}));
|
||||
const bosses = Object.fromEntries(Object.entries(input.bosses ?? {}).flatMap(([key, value]) => {
|
||||
if (!key || !value || typeof value !== "object") return [];
|
||||
const candidate = value as Partial<ManastormBossAdminRecord>;
|
||||
const status: ManastormBossStatus = candidate.status === "ready" || candidate.status === "not-ready" ? candidate.status : "needs-tested";
|
||||
const stage = normalizedStage(candidate.stage);
|
||||
return [[key, { status, ...(stage ? { stage } : {}) }]];
|
||||
}));
|
||||
return { schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION, global, dungeonSpawns, bosses };
|
||||
}
|
||||
|
||||
function dungeonBossEntityId(dungeonId: string, objectiveId: string): string {
|
||||
const dungeon = dungeonDefinitionById(dungeonId);
|
||||
const staticSpawn = dungeon?.staticSpawns.find((spawn) => spawn.id === objectiveId);
|
||||
if (staticSpawn) return staticSpawn.entityId;
|
||||
for (const pack of dungeon?.roamingPacks ?? []) {
|
||||
const member = pack.members.find((candidate) => `${pack.id}:${candidate.id}` === objectiveId);
|
||||
if (member) return member.entityId;
|
||||
}
|
||||
return objectiveId;
|
||||
}
|
||||
|
||||
function entryMergeKey(mapId: number, name: string): string {
|
||||
return `${mapId}:${normalizedName(name)}`;
|
||||
}
|
||||
|
||||
export function buildUnifiedBossCatalog(): readonly UnifiedBossCatalogEntry[] {
|
||||
const entries = new Map<string, UnifiedBossCatalogEntry>();
|
||||
for (const dungeon of DUNGEON_DEFINITIONS) {
|
||||
for (const objective of dungeon.bosses) {
|
||||
const mergeKey = entryMergeKey(dungeon.mapId, objective.name);
|
||||
entries.set(mergeKey, {
|
||||
key: `dungeon:${dungeon.id}:boss:${objective.id}`,
|
||||
name: objective.name,
|
||||
dungeonId: dungeon.id,
|
||||
dungeonTitle: dungeon.title,
|
||||
mapId: dungeon.mapId,
|
||||
bossRuntimeId: objective.id,
|
||||
bossEntityId: dungeonBossEntityId(dungeon.id, objective.id),
|
||||
source: ["dungeon"],
|
||||
sourceIds: [objective.id],
|
||||
baseStage: null,
|
||||
objective,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const stage of runtimeStages(INSTALLED_MANASTORM_RUNTIME_CATALOG)) {
|
||||
const mapId = stage.mapId ?? dungeonDefinitionById(stage.dungeonId)?.mapId ?? 0;
|
||||
const mergeKey = entryMergeKey(mapId, stage.bossName);
|
||||
const matchingDungeon = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.mapId === mapId) ?? null;
|
||||
const existing = entries.get(mergeKey);
|
||||
const mergedStage = existing?.baseStage
|
||||
? {
|
||||
...stage,
|
||||
modeIds: [...new Set([...(existing.baseStage.modeIds ?? []), ...(stage.modeIds ?? [])])],
|
||||
}
|
||||
: stage;
|
||||
const key = stage.encounterId !== undefined
|
||||
? `map:${mapId}:encounter:${stage.encounterId}`
|
||||
: existing?.key ?? `manastorm:${stage.id}`;
|
||||
entries.set(mergeKey, {
|
||||
key,
|
||||
name: stage.bossName,
|
||||
dungeonId: matchingDungeon?.id ?? existing?.dungeonId ?? null,
|
||||
dungeonTitle: matchingDungeon?.title ?? stage.location ?? existing?.dungeonTitle ?? `Map ${mapId}`,
|
||||
mapId,
|
||||
bossRuntimeId: existing?.bossRuntimeId ?? stage.bossRuntimeId,
|
||||
bossEntityId: existing?.bossEntityId ?? stage.bossEntityId,
|
||||
source: [...new Set([...(existing?.source ?? []), "installed-manastorm" as const])],
|
||||
sourceIds: [...new Set([...(existing?.sourceIds ?? []), stage.id])],
|
||||
baseStage: mergedStage,
|
||||
objective: existing?.objective ?? null,
|
||||
});
|
||||
}
|
||||
return Object.freeze([...entries.values()].sort((left, right) => (
|
||||
left.dungeonTitle.localeCompare(right.dungeonTitle) || left.name.localeCompare(right.name)
|
||||
)));
|
||||
}
|
||||
|
||||
export const UNIFIED_MANASTORM_BOSS_CATALOG = buildUnifiedBossCatalog();
|
||||
|
||||
function knownMobRuntimeIds(entry: UnifiedBossCatalogEntry): ReadonlySet<string> {
|
||||
const ids = new Set<string>(entry.baseStage?.trashRuntimeIds ?? []);
|
||||
const dungeon = entry.dungeonId ? dungeonDefinitionById(entry.dungeonId) : null;
|
||||
for (const spawn of dungeon?.staticSpawns ?? []) ids.add(spawn.id);
|
||||
for (const pack of dungeon?.roamingPacks ?? []) {
|
||||
for (const member of pack.members) ids.add(`${pack.id}:${member.id}`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function configuredStageForBoss(
|
||||
entry: UnifiedBossCatalogEntry,
|
||||
config: ManastormAdminConfig,
|
||||
): ManastormEncounterDefinition | null {
|
||||
const override = config.bosses[entry.key]?.stage;
|
||||
const base = entry.baseStage;
|
||||
if (!override && !base) return null;
|
||||
const dungeonId = override?.dungeonId ?? entry.dungeonId ?? base?.dungeonId;
|
||||
if (!dungeonId) return null;
|
||||
return {
|
||||
...(base ?? {}),
|
||||
id: base?.id ?? `gm:stage:${entry.key}`,
|
||||
dungeonId,
|
||||
mapId: override?.mapId ?? base?.mapId ?? entry.mapId,
|
||||
location: base?.location ?? entry.dungeonTitle,
|
||||
...(override?.assetPackageId || base?.assetPackageId
|
||||
? { assetPackageId: override?.assetPackageId ?? base?.assetPackageId }
|
||||
: {}),
|
||||
bossRuntimeId: override?.bossRuntimeId ?? base?.bossRuntimeId ?? entry.bossRuntimeId,
|
||||
bossEntityId: override?.bossEntityId ?? base?.bossEntityId ?? entry.bossEntityId,
|
||||
bossName: override?.bossName ?? base?.bossName ?? entry.name,
|
||||
bossPosition: override?.bossPosition ?? base?.bossPosition ?? entry.objective?.position ?? [0, 0, 0],
|
||||
bossYaw: override?.bossYaw ?? base?.bossYaw ?? 0,
|
||||
playerSpawn: override?.groupSpawn ?? base?.playerSpawn ?? config.dungeonSpawns[dungeonId] ?? dungeonDefinitionById(dungeonId)?.entrance ?? { footPosition: [0, 0, 0], yaw: 0 },
|
||||
portalPosition: override?.portalPosition ?? base?.portalPosition ?? config.dungeonSpawns[dungeonId]?.footPosition ?? dungeonDefinitionById(dungeonId)?.entrance.footPosition ?? [0, 0, 0],
|
||||
trashRuntimeIds: override?.linkedMobRuntimeIds ?? base?.trashRuntimeIds ?? [],
|
||||
requiredGuardianKills: override?.requiredKillCount ?? base?.requiredGuardianKills,
|
||||
modeIds: base?.modeIds ?? [0, 2],
|
||||
stageKind: base?.stageKind ?? "standard",
|
||||
unlockLevel: base?.unlockLevel ?? 1,
|
||||
minimumPartySize: base?.minimumPartySize ?? 1,
|
||||
maximumPartySize: base?.maximumPartySize ?? 5,
|
||||
};
|
||||
}
|
||||
|
||||
/** Produces the complete persisted override edited by GM tooling. */
|
||||
export function authoredStageConfigForBoss(
|
||||
entry: UnifiedBossCatalogEntry,
|
||||
config: ManastormAdminConfig,
|
||||
): ManastormAdminStageConfig | null {
|
||||
const stage = configuredStageForBoss(entry, config);
|
||||
if (!stage) {
|
||||
if (!entry.dungeonId || !entry.objective) return null;
|
||||
const dungeon = dungeonDefinitionById(entry.dungeonId);
|
||||
if (!dungeon) return null;
|
||||
const groupSpawn = config.dungeonSpawns[entry.dungeonId] ?? dungeon.entrance;
|
||||
const staticBoss = dungeon.staticSpawns.find((spawn) => spawn.id === entry.bossRuntimeId);
|
||||
return {
|
||||
dungeonId: entry.dungeonId,
|
||||
mapId: entry.mapId,
|
||||
bossRuntimeId: entry.bossRuntimeId,
|
||||
bossEntityId: entry.bossEntityId,
|
||||
bossName: entry.name,
|
||||
bossPosition: [...entry.objective.position] as Vector3Tuple,
|
||||
bossYaw: staticBoss?.yaw ?? 0,
|
||||
groupSpawn: { footPosition: [...groupSpawn.footPosition] as Vector3Tuple, yaw: groupSpawn.yaw },
|
||||
portalPosition: [...groupSpawn.footPosition] as Vector3Tuple,
|
||||
linkedMobRuntimeIds: [],
|
||||
requiredKillCount: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
dungeonId: stage.dungeonId,
|
||||
mapId: stage.mapId ?? entry.mapId,
|
||||
baseStageId: stage.id,
|
||||
...(stage.assetPackageId ? { assetPackageId: stage.assetPackageId } : {}),
|
||||
bossRuntimeId: stage.bossRuntimeId,
|
||||
bossEntityId: stage.bossEntityId,
|
||||
bossName: stage.bossName,
|
||||
bossPosition: [...stage.bossPosition] as Vector3Tuple,
|
||||
bossYaw: stage.bossYaw ?? 0,
|
||||
groupSpawn: {
|
||||
footPosition: [...stage.playerSpawn.footPosition] as Vector3Tuple,
|
||||
yaw: stage.playerSpawn.yaw,
|
||||
},
|
||||
portalPosition: [...stage.portalPosition] as Vector3Tuple,
|
||||
linkedMobRuntimeIds: [...(stage.trashRuntimeIds ?? [])],
|
||||
requiredKillCount: Math.max(
|
||||
0,
|
||||
Math.min(stage.trashRuntimeIds?.length ?? 0, stage.requiredGuardianKills ?? Math.ceil((stage.trashRuntimeIds?.length ?? 0) / 2)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Materializes inherited stage data when a catalog boss is promoted to Ready. */
|
||||
export function bossAdminRecordForStatus(
|
||||
entry: UnifiedBossCatalogEntry,
|
||||
config: ManastormAdminConfig,
|
||||
status: ManastormBossStatus,
|
||||
): ManastormBossAdminRecord {
|
||||
const existing = config.bosses[entry.key];
|
||||
const stage = status === "ready"
|
||||
? (existing?.stage ?? authoredStageConfigForBoss(entry, config) ?? undefined)
|
||||
: existing?.stage;
|
||||
return { ...existing, status, ...(stage ? { stage } : {}) };
|
||||
}
|
||||
|
||||
export function resolveBossAdminEntry(
|
||||
entry: UnifiedBossCatalogEntry,
|
||||
config: ManastormAdminConfig,
|
||||
): ResolvedBossAdminEntry {
|
||||
const status = config.bosses[entry.key]?.status ?? "needs-tested";
|
||||
const stage = configuredStageForBoss(entry, config);
|
||||
const issues: string[] = [];
|
||||
if (!stage) issues.push("No runnable Manastorm stage has been configured.");
|
||||
if (stage && !dungeonDefinitionById(stage.dungeonId)) issues.push("The configured dungeon is not installed.");
|
||||
if (stage) {
|
||||
const dungeon = dungeonDefinitionById(stage.dungeonId);
|
||||
if (entry.dungeonId && stage.dungeonId !== entry.dungeonId) issues.push("The stage references a different dungeon than its boss record.");
|
||||
if (dungeon && stage.mapId !== dungeon.mapId) issues.push("The stage map does not match its dungeon.");
|
||||
const knownIds = knownMobRuntimeIds(entry);
|
||||
const missingIds = (stage.trashRuntimeIds ?? []).filter((id) => !knownIds.has(id));
|
||||
if (missingIds.length) issues.push(`Missing linked mobs: ${missingIds.join(", ")}.`);
|
||||
const required = stage.requiredGuardianKills ?? Math.ceil((stage.trashRuntimeIds?.length ?? 0) / 2);
|
||||
if (required < 0 || required > (stage.trashRuntimeIds?.length ?? 0)) issues.push("The required kill count is outside the linked mob set.");
|
||||
}
|
||||
const runnable = Boolean(stage && issues.length === 0);
|
||||
return {
|
||||
...entry,
|
||||
status,
|
||||
stage,
|
||||
runnable,
|
||||
validationIssues: Object.freeze(issues),
|
||||
inPublicPool: status === "ready" && runnable,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvedBossAdminCatalog(config: ManastormAdminConfig): readonly ResolvedBossAdminEntry[] {
|
||||
return UNIFIED_MANASTORM_BOSS_CATALOG.map((entry) => resolveBossAdminEntry(entry, config));
|
||||
}
|
||||
|
||||
export function effectiveManastormCatalog(
|
||||
config: ManastormAdminConfig,
|
||||
base: ManastormCatalogLike = INSTALLED_MANASTORM_RUNTIME_CATALOG,
|
||||
): ManastormCatalogLike {
|
||||
const stages = resolvedBossAdminCatalog(config)
|
||||
.filter((entry) => entry.inPublicPool)
|
||||
.flatMap((entry) => entry.stage ? [entry.stage] : []);
|
||||
return Object.freeze({
|
||||
...base,
|
||||
stages: Object.freeze(stages.map((stage) => Object.freeze({
|
||||
...stage,
|
||||
chaoticLinkEnabled: config.global.chaoticLinkEnabled,
|
||||
}))),
|
||||
fallbackPolicy: "none" as const,
|
||||
runtimeOptions: Object.freeze({ ...config.global }),
|
||||
});
|
||||
}
|
||||
|
||||
export function isGameMasterSession(session: { readonly kind: string; readonly accessToken?: string; readonly roles?: readonly string[] } | null): boolean {
|
||||
return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm"));
|
||||
}
|
||||
|
||||
export function derivePartySpawnSlots(spawn: WorldSpawn, partySize = 5): readonly WorldSpawn[] {
|
||||
const size = Math.max(1, Math.min(5, Math.trunc(partySize)));
|
||||
const offsets = [
|
||||
[0, 0],
|
||||
[-1.65, -1.8],
|
||||
[1.65, -1.8],
|
||||
[-3.05, -3.5],
|
||||
[3.05, -3.5],
|
||||
] as const;
|
||||
const sin = Math.sin(spawn.yaw);
|
||||
const cos = Math.cos(spawn.yaw);
|
||||
return Object.freeze(offsets.slice(0, size).map(([lateral, forward]) => ({
|
||||
footPosition: [
|
||||
spawn.footPosition[0] + lateral * cos + forward * sin,
|
||||
spawn.footPosition[1],
|
||||
spawn.footPosition[2] - lateral * sin + forward * cos,
|
||||
] as const,
|
||||
yaw: spawn.yaw,
|
||||
})));
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { create } from "zustand";
|
||||
import type { PlayerSession } from "../app/types";
|
||||
import { requestOnlineJson } from "../app/onlineAccountClient";
|
||||
import {
|
||||
DEFAULT_MANASTORM_ADMIN_CONFIG,
|
||||
MANASTORM_ADMIN_CACHE_KEY,
|
||||
normalizeManastormAdminConfig,
|
||||
resolvedBossAdminCatalog,
|
||||
type ManastormAdminConfig,
|
||||
type PublishedManastormAdminConfig,
|
||||
} from "./manastormAdminConfig";
|
||||
|
||||
interface CachedPayload extends PublishedManastormAdminConfig {}
|
||||
|
||||
function readCache(): CachedPayload {
|
||||
try {
|
||||
const raw = typeof window !== "undefined" ? window.localStorage.getItem(MANASTORM_ADMIN_CACHE_KEY) : null;
|
||||
const parsed = raw ? JSON.parse(raw) as Partial<CachedPayload> : null;
|
||||
if (!parsed || !Number.isInteger(parsed.revision) || (parsed.revision ?? -1) < 0) throw new Error("missing cache");
|
||||
return {
|
||||
revision: parsed.revision!,
|
||||
updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : null,
|
||||
config: normalizeManastormAdminConfig(parsed.config),
|
||||
};
|
||||
} catch {
|
||||
return { revision: 0, updatedAt: null, config: DEFAULT_MANASTORM_ADMIN_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(payload: PublishedManastormAdminConfig): void {
|
||||
try {
|
||||
window.localStorage.setItem(MANASTORM_ADMIN_CACHE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
// Private browsing and storage quotas must not prevent play.
|
||||
}
|
||||
}
|
||||
|
||||
export interface ManastormAdminState extends PublishedManastormAdminConfig {
|
||||
readonly draft: ManastormAdminConfig;
|
||||
readonly loading: boolean;
|
||||
readonly saving: boolean;
|
||||
readonly error: string | null;
|
||||
readonly editingBossKey: string | null;
|
||||
readonly dirty: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
replaceDraft: (config: ManastormAdminConfig) => void;
|
||||
resetDraft: () => void;
|
||||
publish: (session: PlayerSession | null) => Promise<boolean>;
|
||||
setEditingBossKey: (key: string | null) => void;
|
||||
}
|
||||
|
||||
const cached = readCache();
|
||||
|
||||
export const useManastormAdminStore = create<ManastormAdminState>((set, get) => ({
|
||||
...cached,
|
||||
draft: cached.config,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
editingBossKey: null,
|
||||
dirty: false,
|
||||
refresh: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const payload = await requestOnlineJson<PublishedManastormAdminConfig>("/api/manastorm-config");
|
||||
const normalized = { ...payload, config: normalizeManastormAdminConfig(payload.config) };
|
||||
writeCache(normalized);
|
||||
set({ ...normalized, draft: normalized.config, dirty: false, loading: false });
|
||||
} catch (error) {
|
||||
set({ loading: false, error: error instanceof Error ? error.message : "Could not refresh Manastorm configuration." });
|
||||
}
|
||||
},
|
||||
replaceDraft: (draft) => set({ draft: normalizeManastormAdminConfig(draft), dirty: true, error: null }),
|
||||
resetDraft: () => set((state) => ({ draft: state.config, dirty: false, error: null })),
|
||||
publish: async (session) => {
|
||||
if (!session?.accessToken || !session.roles?.includes("gm")) {
|
||||
set({ error: "Game Master access is required." });
|
||||
return false;
|
||||
}
|
||||
const invalidReadyBoss = resolvedBossAdminCatalog(get().draft).find((entry) => entry.status === "ready" && !entry.runnable);
|
||||
if (invalidReadyBoss) {
|
||||
set({ error: `${invalidReadyBoss.name} cannot be published as Ready: ${invalidReadyBoss.validationIssues.join(" ")}` });
|
||||
return false;
|
||||
}
|
||||
set({ saving: true, error: null });
|
||||
try {
|
||||
const payload = await requestOnlineJson<PublishedManastormAdminConfig>("/api/gm/manastorm-config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ expectedRevision: get().revision, config: get().draft }),
|
||||
}, session.accessToken);
|
||||
const normalized = { ...payload, config: normalizeManastormAdminConfig(payload.config) };
|
||||
writeCache(normalized);
|
||||
set({ ...normalized, draft: normalized.config, dirty: false, saving: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
set({ saving: false, error: error instanceof Error ? error.message : "Could not publish Manastorm configuration." });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
setEditingBossKey: (editingBossKey) => set({ editingBossKey }),
|
||||
}));
|
||||
@@ -77,4 +77,20 @@ describe("Manastorm Chaotic Link", () => {
|
||||
expect(manastormChaoticLinkRuntimeIds(encounter, "boss")).toEqual(expected);
|
||||
expect(manastormChaoticLinkRuntimeIds(encounter, "next")).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses an authored exact requirement and disables empowerment with the global switch", () => {
|
||||
expect(manastormChaoticLinkState({ ...encounter, requiredGuardianKills: 1 }, {})).toMatchObject({
|
||||
requiredGuardianKills: 1,
|
||||
activeStacks: 1,
|
||||
broken: false,
|
||||
});
|
||||
expect(manastormChaoticLinkState({ ...encounter, requiredGuardianKills: 5, chaoticLinkEnabled: false }, {})).toMatchObject({
|
||||
guardianCount: 5,
|
||||
requiredGuardianKills: 0,
|
||||
activeStacks: 0,
|
||||
broken: true,
|
||||
bossHealthMultiplier: 1,
|
||||
bossDamageMultiplier: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,15 @@ export function manastormChaoticLinkState(
|
||||
const defeatedGuardians = guardianIds.filter(
|
||||
(runtimeId) => mobs[runtimeId]?.dead === true,
|
||||
).length;
|
||||
const requiredGuardianKills = Math.ceil(guardianCount / 2);
|
||||
const requiredGuardianKills = encounter.chaoticLinkEnabled === false
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
guardianCount,
|
||||
Math.trunc(encounter.requiredGuardianKills ?? Math.ceil(guardianCount / 2)),
|
||||
),
|
||||
);
|
||||
const activeStacks = Math.max(0, requiredGuardianKills - defeatedGuardians);
|
||||
return Object.freeze({
|
||||
guardianCount,
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("Manastorm encounter sessions", () => {
|
||||
expect(useManastormStore.getState().status).toBe("failed");
|
||||
});
|
||||
|
||||
it("spends a remaining charge instead of failing a full-party down event", () => {
|
||||
it("waits for an explicit shared resurrection when the full party is down", () => {
|
||||
useCombatStore.setState({ health: 0 });
|
||||
const companions = usePartyStore.getState().members;
|
||||
for (const companion of companions) {
|
||||
@@ -184,13 +184,17 @@ describe("Manastorm encounter sessions", () => {
|
||||
expect(resolveManastormActorDown({
|
||||
kind: "companion",
|
||||
memberId: companion.id,
|
||||
})).toBe("revived");
|
||||
expect(usePartyStore.getState().members.at(-1)!.health).toBe(companion.maxHealth);
|
||||
})).toBe("downed");
|
||||
expect(usePartyStore.getState().members.at(-1)!.health).toBe(0);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useManastormStore.getState()).toMatchObject({
|
||||
status: "entering",
|
||||
resurrectionCharges: 4,
|
||||
resurrectionCharges: 5,
|
||||
});
|
||||
|
||||
expect(resurrectManastormSession({ kind: "player" })).toBe(true);
|
||||
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
|
||||
expect(useManastormStore.getState().resurrectionCharges).toBe(4);
|
||||
});
|
||||
|
||||
it("drives real trash, boss, loot, portal, and next-stage state end to end", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCombatStore } from "./combatStore";
|
||||
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
manastormChaoticLinkState,
|
||||
type ManastormChaoticLinkState,
|
||||
@@ -153,7 +154,12 @@ export function synchronizeManastormPartySize(): void {
|
||||
function activateCurrentStage(preserveCharacter: boolean): boolean {
|
||||
const encounter = useManastormStore.getState().currentEncounter;
|
||||
if (!encounter) return false;
|
||||
const result = activateManastormStage(encounter, undefined, preserveCharacter);
|
||||
const result = activateManastormStage(
|
||||
encounter,
|
||||
undefined,
|
||||
preserveCharacter,
|
||||
useGameStore.getState().gmTestMode,
|
||||
);
|
||||
if (result.activated) synchronizeManastormPartySize();
|
||||
return result.activated;
|
||||
}
|
||||
@@ -234,7 +240,7 @@ function reviveTarget(target: ManastormResurrectionTarget): boolean {
|
||||
if (target.kind === "player") {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0 || combat.maxHealth <= 0) return false;
|
||||
combat.healPlayer(combat.maxHealth);
|
||||
combat.revivePlayer(1);
|
||||
return useCombatStore.getState().health > 0;
|
||||
}
|
||||
|
||||
@@ -277,16 +283,16 @@ export function manastormPartyIsDefeated(): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a newly downed actor without resetting the floor. An actor remains
|
||||
* down when the shared pool is empty while an ally is still standing. Only a
|
||||
* full-party defeat with no valid charge-backed revive fails the run.
|
||||
* Resolves a newly downed actor without spending a shared resurrection on the
|
||||
* player's behalf. A charge can be used explicitly from the defeated prompt;
|
||||
* only a full-party defeat with no remaining charge fails the run.
|
||||
*/
|
||||
export function resolveManastormActorDown(
|
||||
target: ManastormResurrectionTarget,
|
||||
): ManastormDownResolution {
|
||||
if (!targetIsFallen(target)) return "ignored";
|
||||
if (resurrectManastormSession(target)) return "revived";
|
||||
if (!manastormPartyIsDefeated()) return "downed";
|
||||
if (useManastormStore.getState().resurrectionCharges > 0) return "downed";
|
||||
useManastormStore.getState().failRun();
|
||||
return "failed";
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export function activateManastormStage(
|
||||
stage: ManastormLoadableStage,
|
||||
loader: ManastormStageLoader = defaultLoader,
|
||||
preserveCharacter = true,
|
||||
gmTestMode = false,
|
||||
): ManastormStageLoadResult {
|
||||
const nextPackage = stage.assetPackageId
|
||||
? manastormStageAssetPackageById(stage.assetPackageId)
|
||||
@@ -76,6 +77,7 @@ export function activateManastormStage(
|
||||
gameMode: "manastorm",
|
||||
spawn: stage.playerSpawn,
|
||||
preserveCharacter,
|
||||
gmTestMode,
|
||||
});
|
||||
if (!activated) {
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "./manastormInstalledStages";
|
||||
import {
|
||||
manastormStagePopulation,
|
||||
manastormStageRoamingPacks,
|
||||
registerManastormStageBindingResolver,
|
||||
} from "./manastormStagePopulation";
|
||||
|
||||
@@ -82,4 +83,23 @@ describe("installed Manastorm stage population", () => {
|
||||
.toBeGreaterThan(1);
|
||||
expect(presentations.every((visual) => visual && !visual.model)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes a configured roaming boss from its patrol so its fixed spawn is authoritative", () => {
|
||||
const packs = [{
|
||||
id: "boss-patrol",
|
||||
name: "Boss patrol",
|
||||
speed: 1,
|
||||
formationSource: "database-solo" as const,
|
||||
waypoints: [[0, 0, 0], [2, 0, 0]] as const,
|
||||
members: [
|
||||
{ id: "boss", entityId: "boss-entity", role: "leader" as const, formationOffset: [0, 0, 0] as const },
|
||||
{ id: "guard", entityId: "guard-entity", role: "follower" as const, formationOffset: [1, 0, 1] as const },
|
||||
],
|
||||
}];
|
||||
const filtered = manastormStageRoamingPacks(packs, {
|
||||
...stages[0]!,
|
||||
bossRuntimeId: "boss-patrol:boss",
|
||||
});
|
||||
expect(filtered[0].members.map((member) => member.id)).toEqual(["guard"]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { ManastormEncounterDefinition } from "./manastorm";
|
||||
import type {
|
||||
BossDefinition,
|
||||
MobDefinition,
|
||||
MobPackDefinition,
|
||||
PopulationDefinitionMap,
|
||||
StaticMobSpawnDefinition,
|
||||
} from "./mobPopulation";
|
||||
@@ -12,6 +13,19 @@ export interface ManastormStagePopulation {
|
||||
readonly staticSpawns: readonly StaticMobSpawnDefinition[];
|
||||
}
|
||||
|
||||
/** Removes a dungeon-native roaming boss from its patrol when a fixed authored spawn replaces it. */
|
||||
export function manastormStageRoamingPacks(
|
||||
packs: readonly MobPackDefinition[],
|
||||
encounter: ManastormEncounterDefinition | null,
|
||||
): readonly MobPackDefinition[] {
|
||||
if (!encounter) return packs;
|
||||
return packs.flatMap((pack) => {
|
||||
const members = pack.members.filter((member) => `${pack.id}:${member.id}` !== encounter.bossRuntimeId);
|
||||
if (!members.length) return [];
|
||||
return members.length === pack.members.length ? [pack] : [{ ...pack, members }];
|
||||
});
|
||||
}
|
||||
|
||||
type ManastormStageBindingResolver = (
|
||||
stageId: string,
|
||||
) => ManastormInstalledStageBinding | null;
|
||||
@@ -111,7 +125,16 @@ export function manastormStagePopulation(
|
||||
const existingEntity = baseEntities[encounter.bossEntityId];
|
||||
const existingSpawn = baseStaticSpawns.find((spawn) => spawn.id === encounter.bossRuntimeId);
|
||||
if (existingEntity && existingSpawn) {
|
||||
return { entities: baseEntities, staticSpawns: baseStaticSpawns };
|
||||
return {
|
||||
entities: baseEntities,
|
||||
staticSpawns: baseStaticSpawns.map((spawn) => spawn.id === encounter.bossRuntimeId
|
||||
? {
|
||||
...spawn,
|
||||
position: encounter.bossPosition,
|
||||
...(Number.isFinite(encounter.bossYaw) ? { yaw: encounter.bossYaw } : {}),
|
||||
}
|
||||
: spawn),
|
||||
};
|
||||
}
|
||||
|
||||
const exactNameVisual = Object.values(baseEntities).find(
|
||||
@@ -145,6 +168,7 @@ export function manastormStagePopulation(
|
||||
id: encounter.bossRuntimeId,
|
||||
entityId: encounter.bossEntityId,
|
||||
position: encounter.bossPosition,
|
||||
...(Number.isFinite(encounter.bossYaw) ? { yaw: encounter.bossYaw } : {}),
|
||||
spawnMask: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearMobRuntimeRegistry,
|
||||
getMobPosition,
|
||||
getMobRuntimeTransform,
|
||||
nearbyMobRuntimeIds,
|
||||
readMobRuntimePosition,
|
||||
registerMobRuntime,
|
||||
@@ -14,12 +15,13 @@ describe("mob runtime position registry", () => {
|
||||
beforeEach(clearMobRuntimeRegistry);
|
||||
|
||||
it("supports allocation-free frame updates and snapshot reads", () => {
|
||||
const unregister = registerMobRuntime("mob-a", [1, 2, 3]);
|
||||
const unregister = registerMobRuntime("mob-a", [1, 2, 3], 0.4);
|
||||
const out: [number, number, number] = [0, 0, 0];
|
||||
expect(readMobRuntimePosition("mob-a", out)).toBe(true);
|
||||
expect(out).toEqual([1, 2, 3]);
|
||||
updateMobRuntimePosition("mob-a", 4, 5, 6);
|
||||
updateMobRuntimePosition("mob-a", 4, 5, 6, 1.2);
|
||||
expect(getMobPosition("mob-a")).toEqual([4, 5, 6]);
|
||||
expect(getMobRuntimeTransform("mob-a")).toEqual({ position: [4, 5, 6], yaw: 1.2 });
|
||||
unregister();
|
||||
expect(getMobPosition("mob-a")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ export type MutableCombatPosition = [x: number, y: number, z: number];
|
||||
|
||||
interface RuntimeMobPosition {
|
||||
readonly value: MutableCombatPosition;
|
||||
yaw: number;
|
||||
mounted: boolean;
|
||||
}
|
||||
|
||||
@@ -12,9 +13,10 @@ function finite(value: number): number {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export function registerMobRuntime(id: string, initial: CombatPosition = [0, 0, 0]): () => void {
|
||||
export function registerMobRuntime(id: string, initial: CombatPosition = [0, 0, 0], yaw = 0): () => void {
|
||||
const record: RuntimeMobPosition = {
|
||||
value: [finite(initial[0]), finite(initial[1]), finite(initial[2])],
|
||||
yaw: finite(yaw),
|
||||
mounted: true,
|
||||
};
|
||||
runtimePositions.set(id, record);
|
||||
@@ -24,16 +26,17 @@ export function registerMobRuntime(id: string, initial: CombatPosition = [0, 0,
|
||||
}
|
||||
|
||||
/** Allocation-free update intended for roaming-pack useFrame callbacks. */
|
||||
export function updateMobRuntimePosition(id: string, x: number, y: number, z: number): void {
|
||||
export function updateMobRuntimePosition(id: string, x: number, y: number, z: number, yaw?: number): void {
|
||||
const record = runtimePositions.get(id);
|
||||
if (record) {
|
||||
record.value[0] = finite(x);
|
||||
record.value[1] = finite(y);
|
||||
record.value[2] = finite(z);
|
||||
if (yaw !== undefined) record.yaw = finite(yaw);
|
||||
record.mounted = true;
|
||||
return;
|
||||
}
|
||||
runtimePositions.set(id, { value: [finite(x), finite(y), finite(z)], mounted: true });
|
||||
runtimePositions.set(id, { value: [finite(x), finite(y), finite(z)], yaw: finite(yaw ?? 0), mounted: true });
|
||||
}
|
||||
|
||||
export function unregisterMobRuntime(id: string): void {
|
||||
@@ -56,6 +59,11 @@ export function getMobRuntimePosition(id: string): CombatPosition | null {
|
||||
return record?.mounted ? [...record.value] as MutableCombatPosition : null;
|
||||
}
|
||||
|
||||
export function getMobRuntimeTransform(id: string): { readonly position: CombatPosition; readonly yaw: number } | null {
|
||||
const record = runtimePositions.get(id);
|
||||
return record?.mounted ? { position: [...record.value] as MutableCombatPosition, yaw: record.yaw } : null;
|
||||
}
|
||||
|
||||
export function mobRuntimeDistanceSquared(id: string, origin: CombatPosition): number {
|
||||
const record = runtimePositions.get(id);
|
||||
if (!record?.mounted) return Number.POSITIVE_INFINITY;
|
||||
|
||||
@@ -75,6 +75,20 @@ describe("party combat runtime", () => {
|
||||
expect(usePartyStore.getState().members.map((member) => member.nextActionAt)).toEqual(actionRevisions);
|
||||
});
|
||||
|
||||
it("keeps living companions in the fight while the player waits for resurrection", () => {
|
||||
registerEngagedMob();
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
}
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
const beforeMobHealth = useCombatStore.getState().mobs["pack:a"].health;
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().mobs["pack:a"].health).toBeLessThan(beforeMobHealth);
|
||||
});
|
||||
|
||||
it("assigns the tank to a loose mob attacking the healer before one attacking DPS", () => {
|
||||
const party = usePartyStore.getState();
|
||||
const tank = party.members.find((member) => member.role === "tank")!;
|
||||
@@ -177,6 +191,31 @@ describe("party combat runtime", () => {
|
||||
expect(coolingDown.targetId).toBeNull();
|
||||
});
|
||||
|
||||
it("uses an unlocked party resurrection instead of treating a defeated player as a heal target", () => {
|
||||
usePartyStore.getState().initializeParty("resurrection-runtime", 80, 100, "damage");
|
||||
const support = applyHealerSupport(usePartyStore.getState().members, 0, 100, 1_000);
|
||||
|
||||
expect(support).toMatchObject({
|
||||
action: "resurrection",
|
||||
targetId: "__player__",
|
||||
playerHealing: 0,
|
||||
});
|
||||
expect(support.playerResurrectionPercent).toBeGreaterThan(0);
|
||||
expect(support.presentation?.name.toLowerCase()).toMatch(/rebirth|redemption|resurrect|ancestral|revive/);
|
||||
});
|
||||
|
||||
it("lets the party raise the player in place while combat remains active", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
|
||||
usePartyStore.getState().initializeParty("resurrection-runtime", 80, useCombatStore.getState().health, "damage");
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().feedback?.message).toContain("Resurrected");
|
||||
});
|
||||
|
||||
it("classifies ranged classes and elemental shaman into a stand-off range band", () => {
|
||||
const ranged = [
|
||||
{ classId: "hunter", specialization: "Marksmanship", role: "damage" },
|
||||
|
||||
+144
-21
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
abilitiesForClass,
|
||||
abilityAtLevel,
|
||||
isAbilityUnlocked,
|
||||
type AbilityDefinition,
|
||||
} from "./abilityCatalog";
|
||||
import type { MobCombatState } from "./combatStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import type { MobVector3 } from "./mobPopulation";
|
||||
@@ -35,7 +41,7 @@ const TANK_TAUNT_DURATION_MS = 3_000;
|
||||
let lastAdvanceAt = 0;
|
||||
let nextRegenAt = 0;
|
||||
|
||||
interface PartyPresentationIdentity {
|
||||
export interface PartyPresentationIdentity {
|
||||
readonly spellId: number;
|
||||
readonly name: string;
|
||||
readonly school: CombatPresentationSchool;
|
||||
@@ -265,18 +271,59 @@ export function partyCommandTargetId(
|
||||
export interface HealerSupportResult {
|
||||
readonly members: readonly PartyMember[];
|
||||
readonly playerHealing: number;
|
||||
readonly playerResurrectionPercent: number;
|
||||
readonly targetId: string | null;
|
||||
readonly effectiveHealing: number;
|
||||
readonly sourceMemberId: string | null;
|
||||
readonly action: "heal" | "resurrection" | null;
|
||||
readonly presentation: PartyPresentationIdentity | null;
|
||||
}
|
||||
|
||||
function partyHealAmount(level: number, spellPower = 0): number {
|
||||
return Math.max(10, Math.round(12 + Math.max(1, level) * 2.6 + Math.max(0, spellPower) * 1.25));
|
||||
}
|
||||
|
||||
function partyResurrectionAbility(member: PartyMember): AbilityDefinition | null {
|
||||
const definition = abilitiesForClass(member.classId)
|
||||
.filter((ability) => !ability.passive && ability.target === "friendly")
|
||||
.filter((ability) => isAbilityUnlocked(ability, member.level))
|
||||
.find((ability) => ability.effects.some((effect) => effect.kind === "resurrection"));
|
||||
return definition ? abilityAtLevel(definition, member.level) : null;
|
||||
}
|
||||
|
||||
function partyResurrectionPresentation(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
): PartyPresentationIdentity {
|
||||
const nature = member.classId === "druid"
|
||||
|| member.classId === "shaman"
|
||||
|| member.classId === "rom-druid";
|
||||
return {
|
||||
spellId: ability.dbcSpellId,
|
||||
name: ability.name,
|
||||
school: nature ? "nature" : "holy",
|
||||
delivery: "projectile",
|
||||
};
|
||||
}
|
||||
|
||||
function noSupport(members: readonly PartyMember[]): HealerSupportResult {
|
||||
return {
|
||||
members,
|
||||
playerHealing: 0,
|
||||
playerResurrectionPercent: 0,
|
||||
targetId: null,
|
||||
effectiveHealing: 0,
|
||||
sourceMemberId: null,
|
||||
action: null,
|
||||
presentation: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the dedicated NPC healer one legal, level-scaled heal at a time. The
|
||||
* runtime caller applies `playerHealing` to the combat store after this pure
|
||||
* party-state update.
|
||||
* Gives the party one legal support action at a time. Any living class with an
|
||||
* unlocked resurrection can raise a fallen target; ordinary healing remains
|
||||
* the dedicated healer's job. The runtime caller applies player-side changes
|
||||
* to the combat store after this pure party-state update.
|
||||
*/
|
||||
export function applyHealerSupport(
|
||||
members: readonly PartyMember[],
|
||||
@@ -284,10 +331,74 @@ export function applyHealerSupport(
|
||||
playerMaxHealth: number,
|
||||
now: number,
|
||||
): HealerSupportResult {
|
||||
const resurrector = members
|
||||
.map((member, index) => ({ member, index, ability: partyResurrectionAbility(member) }))
|
||||
.filter((candidate): candidate is typeof candidate & { ability: AbilityDefinition } => (
|
||||
candidate.member.health > 0
|
||||
&& candidate.member.controlledUntil <= now
|
||||
&& candidate.member.nextActionAt <= now
|
||||
&& candidate.ability !== null
|
||||
))
|
||||
.sort((left, right) => (
|
||||
Number(right.member.role === "healer") - Number(left.member.role === "healer")
|
||||
|| left.member.id.localeCompare(right.member.id)
|
||||
))[0];
|
||||
const fallen = [
|
||||
...(playerHealth <= 0 && playerMaxHealth > 0
|
||||
? [{ id: "__player__", index: -1, maxHealth: playerMaxHealth, rolePriority: -1 }]
|
||||
: []),
|
||||
...members
|
||||
.map((member, index) => ({
|
||||
id: member.id,
|
||||
index,
|
||||
maxHealth: member.maxHealth,
|
||||
rolePriority: member.role === "healer" ? 0 : member.role === "tank" ? 1 : 2,
|
||||
}))
|
||||
.filter((candidate) => members[candidate.index].health <= 0),
|
||||
].sort((left, right) => left.rolePriority - right.rolePriority || left.id.localeCompare(right.id));
|
||||
const fallenTarget = fallen[0];
|
||||
if (resurrector && fallenTarget) {
|
||||
const resurrection = resurrector.ability.effects.find((effect) => effect.kind === "resurrection");
|
||||
if (resurrection?.kind === "resurrection") {
|
||||
const restored = Math.max(1, Math.round(fallenTarget.maxHealth * resurrection.percentMaxHealth));
|
||||
const next = [...members];
|
||||
if (fallenTarget.index >= 0) {
|
||||
const target = next[fallenTarget.index];
|
||||
next[fallenTarget.index] = {
|
||||
...target,
|
||||
health: restored,
|
||||
status: "ready",
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
};
|
||||
}
|
||||
const caster = next[resurrector.index];
|
||||
next[resurrector.index] = {
|
||||
...caster,
|
||||
status: "healing",
|
||||
nextActionAt: now + Math.max(
|
||||
HEALER_ACTION_INTERVAL_MS,
|
||||
resurrector.ability.castTimeMs,
|
||||
resurrector.ability.gcdMs,
|
||||
),
|
||||
};
|
||||
return {
|
||||
members: next,
|
||||
playerHealing: 0,
|
||||
playerResurrectionPercent: fallenTarget.index < 0 ? resurrection.percentMaxHealth : 0,
|
||||
targetId: fallenTarget.id,
|
||||
effectiveHealing: restored,
|
||||
sourceMemberId: resurrector.member.id,
|
||||
action: "resurrection",
|
||||
presentation: partyResurrectionPresentation(resurrector.member, resurrector.ability),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const healerIndex = members.findIndex((member) => member.role === "healer" && member.health > 0);
|
||||
if (healerIndex < 0) return { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
||||
if (healerIndex < 0) return noSupport(members);
|
||||
const healer = members[healerIndex];
|
||||
if (now < healer.nextActionAt) return { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
||||
if (now < healer.nextActionAt) return noSupport(members);
|
||||
|
||||
const candidates = members
|
||||
.map((member, index) => ({
|
||||
@@ -308,11 +419,11 @@ export function applyHealerSupport(
|
||||
const target = candidates[0];
|
||||
if (!target) {
|
||||
if (healer.status !== "healing" && healer.nextActionAt === 0) {
|
||||
return { members, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
||||
return noSupport(members);
|
||||
}
|
||||
const next = [...members];
|
||||
next[healerIndex] = { ...healer, status: "ready", nextActionAt: 0 };
|
||||
return { members: next, playerHealing: 0, targetId: null, effectiveHealing: 0 };
|
||||
return noSupport(next);
|
||||
}
|
||||
|
||||
const requested = partyHealAmount(healer.level, healer.gearStats.spellPower);
|
||||
@@ -331,8 +442,12 @@ export function applyHealerSupport(
|
||||
return {
|
||||
members: next,
|
||||
playerHealing: target.index < 0 ? healed : 0,
|
||||
playerResurrectionPercent: 0,
|
||||
targetId: target.id,
|
||||
effectiveHealing: healed,
|
||||
sourceMemberId: healer.id,
|
||||
action: "heal",
|
||||
presentation: partyHealPresentation(healer),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -428,7 +543,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const objectiveBossId = nextPartyBossObjective(combat.mobs)?.id ?? null;
|
||||
let memberTargetIds: Record<string, string> = {};
|
||||
|
||||
if (targetId && useCombatStore.getState().health > 0) {
|
||||
// A defeated player can watch the encounter continue while waiting for a
|
||||
// resurrection. Living companions remain active until the whole party falls.
|
||||
if (targetId && members.some((member) => member.health > 0)) {
|
||||
const nextMembers = [...members];
|
||||
for (let index = 0; index < nextMembers.length; index += 1) {
|
||||
let member = nextMembers[index];
|
||||
@@ -596,18 +713,24 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
|
||||
const currentPlayer = useCombatStore.getState();
|
||||
const support = applyHealerSupport(members, currentPlayer.health, currentPlayer.maxHealth, now);
|
||||
const healer = members.find((member) => member.role === "healer" && member.health > 0) ?? null;
|
||||
const supporter = support.sourceMemberId
|
||||
? members.find((member) => member.id === support.sourceMemberId && member.health > 0) ?? null
|
||||
: null;
|
||||
members = support.members;
|
||||
if (support.playerHealing > 0) {
|
||||
useCombatStore.getState().healPlayer(support.playerHealing);
|
||||
let effectiveSupport = support.effectiveHealing;
|
||||
if (support.playerResurrectionPercent > 0) {
|
||||
effectiveSupport = useCombatStore.getState().revivePlayer(support.playerResurrectionPercent);
|
||||
}
|
||||
if (healer && support.effectiveHealing > 0) {
|
||||
if (support.playerHealing > 0) {
|
||||
effectiveSupport = useCombatStore.getState().healPlayer(support.playerHealing);
|
||||
}
|
||||
if (supporter && support.presentation && effectiveSupport > 0) {
|
||||
useCombatStore.getState().addHealingThreat(
|
||||
{ actorId: healer.id, role: healer.role },
|
||||
support.effectiveHealing,
|
||||
{ actorId: supporter.id, role: supporter.role },
|
||||
effectiveSupport,
|
||||
);
|
||||
const presentation = partyHealPresentation(healer);
|
||||
const healerPosition = getPartyRuntimePosition(healer.id) ?? currentPlayer.playerPosition;
|
||||
const presentation = support.presentation;
|
||||
const supporterPosition = getPartyRuntimePosition(supporter.id) ?? currentPlayer.playerPosition;
|
||||
const presentationTargetId = support.targetId === "__player__" ? PLAYER_AGGRO_ID : support.targetId;
|
||||
const targetPosition = presentationTargetId === PLAYER_AGGRO_ID
|
||||
? currentPlayer.playerPosition
|
||||
@@ -617,16 +740,16 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const healPresentation = {
|
||||
occurredAt: now,
|
||||
actorGroup: "party",
|
||||
sourceActorId: healer.id,
|
||||
sourceActorId: supporter.id,
|
||||
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
|
||||
abilityId: `party-heal:${healer.id}`,
|
||||
abilityId: `party-${support.action}:${supporter.id}`,
|
||||
sourceSpellId: presentation.spellId,
|
||||
name: presentation.name,
|
||||
source: "wow335",
|
||||
school: presentation.school,
|
||||
delivery: presentation.delivery,
|
||||
origin: healerPosition,
|
||||
targetPosition: targetPosition ?? healerPosition,
|
||||
origin: supporterPosition,
|
||||
targetPosition: targetPosition ?? supporterPosition,
|
||||
} as const;
|
||||
emitCombatPresentation({ ...healPresentation, phase: "release" });
|
||||
emitCombatPresentation({ ...healPresentation, phase: "impact" });
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { EXTMeshoptCompression, KHRMeshQuantization } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
PLAYER_GROUND_MIN_NORMAL_Y,
|
||||
PLAYER_GROUND_PROBE_DISTANCE,
|
||||
} from "./playerJump";
|
||||
import {
|
||||
PLAYER_CHARACTER_CONTROLLER_OFFSET,
|
||||
PLAYER_GROUND_SNAP_DISTANCE,
|
||||
PLAYER_GROUND_STICK_VELOCITY,
|
||||
PLAYER_STEP_HEIGHT,
|
||||
PLAYER_STEP_MIN_WIDTH,
|
||||
airbornePlayerVerticalVelocity,
|
||||
configurePlayerCharacterController,
|
||||
playerFrameDeltaSeconds,
|
||||
} from "./playerMovement";
|
||||
|
||||
const PLAYER_RADIUS = 0.36;
|
||||
const PLAYER_CAPSULE_HALF_HEIGHT = 0.48;
|
||||
const PLAYER_CENTER_HEIGHT = PLAYER_RADIUS + PLAYER_CAPSULE_HALF_HEIGHT;
|
||||
const MOVE_SPEED = 4.8;
|
||||
const FRAME_SECONDS = 1 / 60;
|
||||
const STOCKADES_SPAWN = {
|
||||
x: -126.4629,
|
||||
y: -29.1512,
|
||||
z: 21.2946,
|
||||
forwardX: -0.2756962614591509,
|
||||
forwardZ: 0.9612448030639477,
|
||||
};
|
||||
|
||||
let stockadesCollision: { vertices: Float32Array; indices: Uint32Array };
|
||||
|
||||
beforeAll(async () => {
|
||||
await RAPIER.init();
|
||||
const io = new NodeIO()
|
||||
.registerExtensions([EXTMeshoptCompression, KHRMeshQuantization])
|
||||
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||||
const document = await io.read("public/assets/game/manastorm/34-stormwindjail/collision.glb");
|
||||
const node = document.getRoot().listNodes().find((candidate) => candidate.getMesh());
|
||||
const primitive = node?.getMesh()?.listPrimitives()[0];
|
||||
const positions = primitive?.getAttribute("POSITION");
|
||||
const sourceVertices = positions?.getArray();
|
||||
const sourceIndices = primitive?.getIndices()?.getArray();
|
||||
if (!node || !positions || !sourceVertices || !sourceIndices) {
|
||||
throw new Error("Stormwind Stockade collision GLB is missing its trimesh primitive.");
|
||||
}
|
||||
|
||||
const translation = node.getWorldTranslation();
|
||||
const scale = node.getWorldScale();
|
||||
const normalization = positions.getNormalized() ? 32_767 : 1;
|
||||
const vertices = new Float32Array(sourceVertices.length);
|
||||
for (let offset = 0; offset < sourceVertices.length; offset += 3) {
|
||||
vertices[offset] = sourceVertices[offset] / normalization * scale[0] + translation[0];
|
||||
vertices[offset + 1] = sourceVertices[offset + 1] / normalization * scale[1] + translation[1];
|
||||
vertices[offset + 2] = sourceVertices[offset + 2] / normalization * scale[2] + translation[2];
|
||||
}
|
||||
stockadesCollision = {
|
||||
vertices,
|
||||
indices: Uint32Array.from(sourceIndices),
|
||||
};
|
||||
});
|
||||
|
||||
function walkIntoStep(stepHeight: number): { x: number; y: number; z: number } {
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
const floor = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
||||
world.createCollider(
|
||||
RAPIER.ColliderDesc.cuboid(5, 0.1, 8).setTranslation(0, -0.1, 3),
|
||||
floor,
|
||||
);
|
||||
const step = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
||||
world.createCollider(
|
||||
RAPIER.ColliderDesc.cuboid(2, stepHeight / 2, 5)
|
||||
.setTranslation(0, stepHeight / 2, 4.75),
|
||||
step,
|
||||
);
|
||||
const body = world.createRigidBody(
|
||||
RAPIER.RigidBodyDesc.kinematicPositionBased()
|
||||
.setTranslation(0, PLAYER_CENTER_HEIGHT, -0.8),
|
||||
);
|
||||
const collider = world.createCollider(
|
||||
RAPIER.ColliderDesc.capsule(PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS),
|
||||
body,
|
||||
);
|
||||
world.step();
|
||||
|
||||
const controller = configurePlayerCharacterController(
|
||||
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
||||
);
|
||||
for (let frame = 0; frame < 50; frame += 1) {
|
||||
controller.computeColliderMovement(
|
||||
collider,
|
||||
{
|
||||
x: 0,
|
||||
y: PLAYER_GROUND_STICK_VELOCITY * FRAME_SECONDS,
|
||||
z: MOVE_SPEED * FRAME_SECONDS,
|
||||
},
|
||||
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
);
|
||||
const translation = body.translation();
|
||||
const movement = controller.computedMovement();
|
||||
body.setNextKinematicTranslation({
|
||||
x: translation.x + movement.x,
|
||||
y: translation.y + movement.y,
|
||||
z: translation.z + movement.z,
|
||||
});
|
||||
world.step();
|
||||
}
|
||||
|
||||
const translation = body.translation();
|
||||
const result = { x: translation.x, y: translation.y, z: translation.z };
|
||||
world.removeCharacterController(controller);
|
||||
world.free();
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("player movement", () => {
|
||||
it("configures bounded autostep and ground snapping", () => {
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
const controller = configurePlayerCharacterController(
|
||||
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
||||
);
|
||||
|
||||
expect(controller.autostepMaxHeight()).toBeCloseTo(PLAYER_STEP_HEIGHT);
|
||||
expect(controller.autostepMinWidth()).toBeCloseTo(PLAYER_STEP_MIN_WIDTH);
|
||||
expect(controller.autostepIncludesDynamicBodies()).toBe(false);
|
||||
expect(controller.snapToGroundDistance()).toBeCloseTo(PLAYER_GROUND_SNAP_DISTANCE);
|
||||
expect(controller.slideEnabled()).toBe(true);
|
||||
|
||||
world.removeCharacterController(controller);
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("walks up an ordinary half-unit stair without jumping", () => {
|
||||
const translation = walkIntoStep(0.5);
|
||||
expect(translation.z).toBeGreaterThan(2.5);
|
||||
expect(translation.y).toBeGreaterThan(PLAYER_CENTER_HEIGHT + 0.45);
|
||||
});
|
||||
|
||||
it("does not autostep over a wall taller than the traversal limit", () => {
|
||||
const translation = walkIntoStep(0.8);
|
||||
expect(translation.z).toBeLessThan(-0.5);
|
||||
expect(translation.y).toBeLessThan(PLAYER_CENTER_HEIGHT + 0.1);
|
||||
});
|
||||
|
||||
it("walks back up the real Stockades entrance stairs without a jump impulse", () => {
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
const dungeonBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed());
|
||||
world.createCollider(
|
||||
RAPIER.ColliderDesc.trimesh(
|
||||
stockadesCollision.vertices,
|
||||
stockadesCollision.indices,
|
||||
),
|
||||
dungeonBody,
|
||||
);
|
||||
const body = world.createRigidBody(
|
||||
RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(
|
||||
STOCKADES_SPAWN.x,
|
||||
STOCKADES_SPAWN.y + PLAYER_CENTER_HEIGHT,
|
||||
STOCKADES_SPAWN.z,
|
||||
),
|
||||
);
|
||||
const collider = world.createCollider(
|
||||
RAPIER.ColliderDesc.capsule(PLAYER_CAPSULE_HALF_HEIGHT, PLAYER_RADIUS),
|
||||
body,
|
||||
);
|
||||
world.step();
|
||||
const controller = configurePlayerCharacterController(
|
||||
world.createCharacterController(PLAYER_CHARACTER_CONTROLLER_OFFSET),
|
||||
);
|
||||
const groundRay = new RAPIER.Ray(
|
||||
{ x: 0, y: 0, z: 0 },
|
||||
{ x: 0, y: -1, z: 0 },
|
||||
);
|
||||
const groundProbeLength = PLAYER_CENTER_HEIGHT + PLAYER_GROUND_PROBE_DISTANCE;
|
||||
let verticalVelocity = 0;
|
||||
|
||||
const walkFrame = (direction: 1 | -1) => {
|
||||
const translation = body.translation();
|
||||
groundRay.origin.x = translation.x;
|
||||
groundRay.origin.y = translation.y;
|
||||
groundRay.origin.z = translation.z;
|
||||
const groundHit = world.castRayAndGetNormal(
|
||||
groundRay,
|
||||
groundProbeLength,
|
||||
true,
|
||||
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
undefined,
|
||||
undefined,
|
||||
body,
|
||||
);
|
||||
const groundedBeforeMove = verticalVelocity <= 0.5
|
||||
&& Boolean(
|
||||
groundHit
|
||||
&& groundHit.timeOfImpact <= groundProbeLength
|
||||
&& groundHit.normal.y >= PLAYER_GROUND_MIN_NORMAL_Y,
|
||||
);
|
||||
verticalVelocity = groundedBeforeMove
|
||||
? PLAYER_GROUND_STICK_VELOCITY
|
||||
: airbornePlayerVerticalVelocity(verticalVelocity, FRAME_SECONDS);
|
||||
controller.computeColliderMovement(
|
||||
collider,
|
||||
{
|
||||
x: direction * STOCKADES_SPAWN.forwardX * MOVE_SPEED * FRAME_SECONDS,
|
||||
y: verticalVelocity * FRAME_SECONDS,
|
||||
z: direction * STOCKADES_SPAWN.forwardZ * MOVE_SPEED * FRAME_SECONDS,
|
||||
},
|
||||
RAPIER.QueryFilterFlags.EXCLUDE_SENSORS | RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
);
|
||||
const movement = controller.computedMovement();
|
||||
body.setNextKinematicTranslation({
|
||||
x: translation.x + movement.x,
|
||||
y: translation.y + movement.y,
|
||||
z: translation.z + movement.z,
|
||||
});
|
||||
if (controller.computedGrounded() && verticalVelocity <= 0) verticalVelocity = 0;
|
||||
world.step();
|
||||
};
|
||||
|
||||
for (let frame = 0; frame < 190; frame += 1) walkFrame(1);
|
||||
const lowerFootHeight = body.translation().y - PLAYER_CENTER_HEIGHT;
|
||||
for (let frame = 0; frame < 240; frame += 1) walkFrame(-1);
|
||||
const returned = body.translation();
|
||||
const returnedFootHeight = returned.y - PLAYER_CENTER_HEIGHT;
|
||||
|
||||
expect(lowerFootHeight).toBeLessThan(-33);
|
||||
expect(returnedFootHeight).toBeGreaterThan(-30);
|
||||
expect(Math.hypot(
|
||||
returned.x - STOCKADES_SPAWN.x,
|
||||
returned.z - STOCKADES_SPAWN.z,
|
||||
)).toBeLessThan(3);
|
||||
|
||||
world.removeCharacterController(controller);
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("clamps frame hitches and applies bounded airborne gravity", () => {
|
||||
expect(playerFrameDeltaSeconds(Number.NaN)).toBe(0);
|
||||
expect(playerFrameDeltaSeconds(1)).toBe(0.05);
|
||||
expect(airbornePlayerVerticalVelocity(0, 1)).toBeCloseTo(-0.9);
|
||||
expect(airbornePlayerVerticalVelocity(-100, FRAME_SECONDS)).toBe(-32);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { KinematicCharacterController } from "@dimforge/rapier3d-compat";
|
||||
import { PLAYER_GROUND_MIN_NORMAL_Y } from "./playerJump";
|
||||
|
||||
export const PLAYER_GRAVITY = -18;
|
||||
export const PLAYER_CHARACTER_CONTROLLER_OFFSET = 0.02;
|
||||
export const PLAYER_STEP_HEIGHT = 0.55;
|
||||
export const PLAYER_STEP_MIN_WIDTH = 0.15;
|
||||
export const PLAYER_GROUND_SNAP_DISTANCE = PLAYER_STEP_HEIGHT;
|
||||
export const PLAYER_GROUND_STICK_VELOCITY = -0.5;
|
||||
export const PLAYER_MAX_FRAME_DELTA_SECONDS = 0.05;
|
||||
export const PLAYER_TERMINAL_FALL_VELOCITY = -32;
|
||||
|
||||
/**
|
||||
* Configures Rapier's kinematic controller for MMO-style traversal. Ordinary
|
||||
* stairs and curb-sized seams are walkable, while taller obstacles remain
|
||||
* blocking geometry and still require a real jump or another route.
|
||||
*/
|
||||
export function configurePlayerCharacterController(
|
||||
controller: KinematicCharacterController,
|
||||
): KinematicCharacterController {
|
||||
controller.setUp({ x: 0, y: 1, z: 0 });
|
||||
controller.setSlideEnabled(true);
|
||||
controller.enableAutostep(PLAYER_STEP_HEIGHT, PLAYER_STEP_MIN_WIDTH, false);
|
||||
controller.enableSnapToGround(PLAYER_GROUND_SNAP_DISTANCE);
|
||||
controller.setMaxSlopeClimbAngle(Math.acos(PLAYER_GROUND_MIN_NORMAL_Y));
|
||||
return controller;
|
||||
}
|
||||
|
||||
export function playerFrameDeltaSeconds(deltaSeconds: number): number {
|
||||
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
|
||||
return Math.min(deltaSeconds, PLAYER_MAX_FRAME_DELTA_SECONDS);
|
||||
}
|
||||
|
||||
export function airbornePlayerVerticalVelocity(
|
||||
currentVelocity: number,
|
||||
deltaSeconds: number,
|
||||
): number {
|
||||
const velocity = Number.isFinite(currentVelocity) ? currentVelocity : 0;
|
||||
const delta = playerFrameDeltaSeconds(deltaSeconds);
|
||||
return Math.max(
|
||||
PLAYER_TERMINAL_FALL_VELOCITY,
|
||||
velocity + PLAYER_GRAVITY * delta,
|
||||
);
|
||||
}
|
||||
+6
-2
@@ -7,7 +7,7 @@ import type { ActionBindingLayer } from "./actionBindings";
|
||||
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" | "spellbook" | "talents" | "bindings" | "options" | null;
|
||||
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "spellbook" | "talents" | "bindings" | "options" | "gm" | null;
|
||||
|
||||
interface PlayerSnapshot {
|
||||
position: Vector3Tuple;
|
||||
@@ -20,6 +20,7 @@ export interface GameState {
|
||||
activeDungeonId: DungeonId;
|
||||
activeDifficultyId: string;
|
||||
gameMode: GameMode;
|
||||
gmTestMode: boolean;
|
||||
activeSpawn: WorldSpawn;
|
||||
sessionRevision: number;
|
||||
overlay: GameOverlay;
|
||||
@@ -42,7 +43,7 @@ export interface GameState {
|
||||
activateDungeon: (
|
||||
dungeonId: DungeonId,
|
||||
difficultyId?: string,
|
||||
options?: { readonly gameMode?: GameMode; readonly spawn?: WorldSpawn },
|
||||
options?: { readonly gameMode?: GameMode; readonly spawn?: WorldSpawn; readonly gmTestMode?: boolean },
|
||||
) => void;
|
||||
openOverlay: (overlay: Exclude<GameOverlay, null>) => void;
|
||||
toggleOverlay: (overlay: Exclude<GameOverlay, null>) => void;
|
||||
@@ -68,6 +69,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
activeDungeonId: DEFAULT_DUNGEON_ID,
|
||||
activeDifficultyId: initialDungeon.defaultDifficultyId,
|
||||
gameMode: "dungeon",
|
||||
gmTestMode: false,
|
||||
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
|
||||
sessionRevision: 0,
|
||||
overlay: null,
|
||||
@@ -100,6 +102,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
activeDungeonId,
|
||||
activeDifficultyId,
|
||||
gameMode: options?.gameMode ?? "dungeon",
|
||||
gmTestMode: options?.gmTestMode ?? false,
|
||||
activeSpawn,
|
||||
sessionRevision: state.sessionRevision + 1,
|
||||
overlay: null,
|
||||
@@ -218,6 +221,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw };
|
||||
return {
|
||||
gameMode: "dungeon",
|
||||
gmTestMode: false,
|
||||
activeSpawn,
|
||||
overlay: null,
|
||||
paused: false,
|
||||
|
||||
Reference in new issue
Block a user