updating buff and debuff icons. separated COA classes from WoW classes on the character creation screen

This commit is contained in:
phenom
2026-08-16 11:16:51 -04:00
parent b5c9bce1aa
commit c00f3211ea
39 changed files with 2037 additions and 208 deletions
+17
View File
@@ -124,6 +124,23 @@ describe("local profile repository", () => {
});
});
it("migrates custom-class profiles into the CoA category", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
const character = createCharacter({
ownerId: offline.ownerId,
name: "Conqueror",
categoryId: "wow",
raceId: "human",
classId: "barbarian",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
expect(character.categoryId).toBe("coa");
expect(listCharacters(offline.ownerId, storage)[0]?.categoryId).toBe("coa");
});
it("persists controller ability bindings per character without changing progression", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
+2 -5
View File
@@ -1,6 +1,6 @@
import type { CharacterProfile, PlayerSession } from "./types";
import {
contentCategoryForRace,
contentCategoryForClass,
isRomClassId,
type RomClassId,
} from "./characterCatalog";
@@ -112,7 +112,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
equipment?: unknown;
manastormProgress?: unknown;
settings?: unknown;
categoryId?: unknown;
secondaryClassId?: unknown;
actionLoadouts?: unknown;
};
@@ -132,9 +131,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
.filter(([, abilityId]) => abilityId === null || typeof abilityId === "string")
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
: {};
const categoryId = legacy.categoryId === "rom" || legacy.categoryId === "wow"
? legacy.categoryId
: contentCategoryForRace(value.raceId);
const categoryId = contentCategoryForClass(value.classId);
const secondaryClassId = categoryId === "rom"
&& isRomClassId(value.classId)
&& typeof legacy.secondaryClassId === "string"
+27 -12
View File
@@ -8,12 +8,14 @@ import {
classIconUrl,
classesForRace,
clampAppearance,
contentCategoryForClass,
normalizeCharacterName,
randomAppearance,
supportedGenders,
totalRaceClassCombinations,
validateCharacterName,
} from "./characterCatalog";
import { CONTENT_CATEGORIES } from "./contentCategories";
describe("wow335a character catalog", () => {
it("limits normal play to the live-verified race roster", () => {
@@ -34,9 +36,13 @@ describe("wow335a character catalog", () => {
it("matches the extracted Ascension playable catalog", () => {
const wowRaces = RACES.filter((race) => race.categoryId !== "rom");
const wowClasses = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom");
const azerothClasses = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom");
const wowClasses = CLASSES.filter((characterClass) => contentCategoryForClass(characterClass.id) === "wow");
const coaClasses = CLASSES.filter((characterClass) => contentCategoryForClass(characterClass.id) === "coa");
expect(wowRaces).toHaveLength(27);
expect(wowClasses).toHaveLength(31);
expect(azerothClasses).toHaveLength(31);
expect(wowClasses).toHaveLength(10);
expect(coaClasses).toHaveLength(21);
expect(COA_CLASS_IDS).toHaveLength(21);
expect(wowRaces.reduce((total, race) => total + classesForRace(race.id).length, 0)).toBe(740);
expect(totalRaceClassCombinations()).toBe(758);
@@ -48,7 +54,7 @@ describe("wow335a character catalog", () => {
"Forest Troll", "Taunka", "Northrend Skeleton", "Ice Troll", "Earthen",
"Human Cultist",
]);
expect(COA_CLASS_IDS.map((id) => wowClasses.find((entry) => entry.id === id)?.name)).toEqual([
expect(COA_CLASS_IDS.map((id) => coaClasses.find((entry) => entry.id === id)?.name)).toEqual([
"Barbarian", "Witch Doctor", "Felsworn", "Witch Hunter", "Stormbringer",
"Knight of Xoroth", "Guardian", "Templar", "Bloodmage", "Ranger",
"Chronomancer", "Necromancer", "Pyromancer", "Cultist", "Starcaller",
@@ -56,16 +62,25 @@ describe("wow335a character catalog", () => {
]);
});
it("separates default WoW, custom CoA, and RoM character creation categories", () => {
expect(CONTENT_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom", "coa"]);
expect(classesForRace("human", "wow").map((entry) => entry.id)).toContain("priest");
expect(classesForRace("human", "wow").map((entry) => entry.id)).not.toContain("barbarian");
expect(classesForRace("human", "coa").map((entry) => entry.id)).toContain("barbarian");
expect(classesForRace("human", "coa").map((entry) => entry.id)).not.toContain("priest");
expect(classesForRace("rom-human", "rom").map((entry) => entry.id)).toContain("rom-priest");
});
it("applies the race eligibility rows from CharBaseInfo.dbc", () => {
const wowClassCount = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom").length;
expect(classesForRace("human").map((entry) => entry.id)).toContain("barbarian");
expect(classesForRace("human").map((entry) => entry.id)).not.toContain("felsworn");
expect(classesForRace("draenei").map((entry) => entry.id)).toContain("felsworn");
expect(classesForRace("draenei").map((entry) => entry.id)).toContain("knight-of-xoroth");
expect(classesForRace("troll").map((entry) => entry.id)).toContain("venomancer");
expect(classesForRace("troll").map((entry) => entry.id)).not.toContain("knight-of-xoroth");
expect(classesForRace("goblin")).toHaveLength(wowClassCount);
expect(classesForRace("human-cultist")).toHaveLength(wowClassCount);
expect(classesForRace("human", "coa").map((entry) => entry.id)).toContain("barbarian");
expect(classesForRace("human", "coa").map((entry) => entry.id)).not.toContain("felsworn");
expect(classesForRace("draenei", "coa").map((entry) => entry.id)).toContain("felsworn");
expect(classesForRace("draenei", "coa").map((entry) => entry.id)).toContain("knight-of-xoroth");
expect(classesForRace("troll", "coa").map((entry) => entry.id)).toContain("venomancer");
expect(classesForRace("troll", "coa").map((entry) => entry.id)).not.toContain("knight-of-xoroth");
expect(classesForRace("goblin", "wow")).toHaveLength(10);
expect(classesForRace("goblin", "coa")).toHaveLength(21);
expect(classesForRace("human-cultist")).toHaveLength(31);
expect(supportedGenders("vrykul")).toEqual(["male"]);
expect(supportedGenders("earthen")).toEqual(["male", "female"]);
});
+41 -25
View File
@@ -408,27 +408,27 @@ export const CLASSES: readonly ClassDefinition[] = [
{ id: "warlock", dbcId: 9, name: "Warlock", role: "Damage", sigil: "WL", color: "#9482c9", description: "Fel caster commanding curses and summoned demons.", mode: "classic", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Affliction", "Demonology", "Destruction"], archetype: "warlock" },
{ id: "druid", dbcId: 11, name: "Druid", role: "Tank / Healer / Damage", sigil: "DU", color: "#ff7d0a", description: "Shapeshifter drawing power from the wilds.", mode: "classic", armor: "leather", primaryStat: "agility", resourceSummary: "Mana", specializations: ["Balance", "Feral", "Restoration"], archetype: "druid" },
{ id: "barbarian", dbcId: 12, name: "Barbarian", role: "Damage / Support", sigil: "BA", color: "#8a3303", description: "A brutal melee combatant who cleaves foes and rallies ancestral power.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy", specializations: ["Brutality", "Headhunting", "Ancestry"], archetype: "rogue" },
{ id: "witch-doctor", dbcId: 13, name: "Witch Doctor", role: "Healer / Damage", sigil: "WD", color: "#6eff00", description: "A shadowhunter who mixes hexes, brews, wards, and restorative voodoo.", mode: "conquest", armor: "leather", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Shadowhunting", "Voodoo", "Brewing"], archetype: "shaman" },
{ id: "felsworn", dbcId: 14, name: "Felsworn", role: "Damage / Tank", sigil: "FE", color: "#a330c9", description: "A demon-touched fighter who turns fel fury into relentless offense and defense.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Felfury", specializations: ["Slayer", "Infernal", "Tyrant"], archetype: "rogue" },
{ id: "witch-hunter", dbcId: 15, name: "Witch Hunter", role: "Damage / Tank", sigil: "WH", color: "#abd473", description: "An agile inquisitor wielding blades, firearms, traps, and forbidden shadow.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Boltslinger", "Houndmaster", "Inquisition", "Black Knight"], archetype: "hunter" },
{ id: "stormbringer", dbcId: 16, name: "Stormbringer", role: "Damage / Support", sigil: "ST", color: "#0070de", description: "An elemental conduit who commands lightning, wind, and violent storms.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Static", specializations: ["Maelstrom", "Lightning", "Wind"], archetype: "shaman" },
{ id: "knight-of-xoroth", dbcId: 17, name: "Knight of Xoroth", role: "Tank / Damage", sigil: "KX", color: "#d64747", description: "A demonic plate fighter fueled by rage, deathfire, and hellish summons.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Rage / Deathfire", specializations: ["Hellfire", "Defiance", "War"], archetype: "death-knight" },
{ id: "guardian", dbcId: 18, name: "Guardian", role: "Tank / Damage / Support", sigil: "GU", color: "#c79c6e", description: "A sword-and-board leader who blocks attacks and inspires allies with banners.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Energy", specializations: ["Gladiator", "Inspiration", "Vanguard"], archetype: "warrior" },
{ id: "templar", dbcId: 19, name: "Templar", role: "Damage / Tank", sigil: "TE", color: "#00ff99", description: "A flowing holy martial artist who chains strikes into powerful combos.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy / Holy Runes", specializations: ["Zealot", "Oathkeeper", "Crusader"], archetype: "rogue" },
{ id: "bloodmage", dbcId: 20, name: "Bloodmage", role: "Damage / Healer / Tank", sigil: "BM", color: "#cc9900", description: "A sanguine spellblade who spends vitality and embraces the worgen curse.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Health", specializations: ["Sanguine", "Accursed", "Eternal", "Fleshweaver"], archetype: "druid" },
{ id: "ranger", dbcId: 21, name: "Ranger", role: "Damage / Support", sigil: "RA", color: "#fff569", description: "A mobile skirmisher equally dangerous with bows or paired melee weapons.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Focus / Archery Points", specializations: ["Archery", "Brigand", "Farstrider"], archetype: "hunter" },
{ id: "chronomancer", dbcId: 22, name: "Chronomancer", role: "Healer / Damage", sigil: "CH", color: "#f2e699", description: "A rule-bending caster who reverses wounds and warps order, chaos, and time.", mode: "conquest", armor: "cloth", primaryStat: "spirit", resourceSummary: "Mana", specializations: ["Infinite", "Time", "Artificer"], archetype: "priest" },
{ id: "necromancer", dbcId: 23, name: "Necromancer", role: "Damage", sigil: "NE", color: "#8787ed", description: "A master of frost, plague, and permanent undead minions.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runic Power", specializations: ["Death", "Rime", "Animation"], archetype: "warlock" },
{ id: "pyromancer", dbcId: 24, name: "Pyromancer", role: "Damage / Support", sigil: "PY", color: "#ff300f", description: "A fire specialist who incinerates foes, cauterizes allies, and channels dragons.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Incineration", "Flameweaving", "Draconic"], archetype: "mage" },
{ id: "cultist", dbcId: 25, name: "Cultist", role: "Healer / Damage / Tank", sigil: "CU", color: "#e0c7ff", description: "A versatile servant of the Old Gods wielding eldritch spells and void blades.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Mana / Insanity", specializations: ["Godblade", "Corruption", "Heretic", "Dreadnought"], archetype: "paladin" },
{ id: "starcaller", dbcId: 26, name: "Starcaller", role: "Tank / Damage / Healer", sigil: "SC", color: "#b5ffff", description: "An astral warrior who invokes Elune through blades, bows, and lunar magic.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Moon Guard", "Sentinel", "Moon Priest", "Warden"], archetype: "paladin" },
{ id: "sun-cleric", dbcId: 27, name: "Sun Cleric", role: "Damage / Healer / Tank", sigil: "SU", color: "#f58cba", description: "A solar champion who heals with warmth and burns enemies with holy fire.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana / Rage / Solar Power", specializations: ["Piety", "Blessings", "Valkyrie", "Seraphim"], archetype: "paladin" },
{ id: "tinker", dbcId: 28, name: "Tinker", role: "Damage / Healer", sigil: "TI", color: "#a3a3a3", description: "An inventor armed with guns, ammunition, turrets, gadgets, and mechanical allies.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Energy / Ammunition", specializations: ["Demolition", "Invention", "Mechanics"], archetype: "hunter" },
{ id: "venomancer", dbcId: 29, name: "Venomancer", role: "Tank / Damage / Healer", sigil: "VE", color: "#ff7d0a", description: "A shapeshifting toxin master who spreads venom and hardens into insect forms.", mode: "conquest", armor: "mail", primaryStat: "intellect", resourceSummary: "Mana / Rage", specializations: ["Venom", "Stalking", "Fortitude", "Vizier"], archetype: "shaman" },
{ id: "reaper", dbcId: 30, name: "Reaper", role: "Damage / Tank", sigil: "RE", color: "#c41f3b", description: "A soul-harvesting warrior who moves like a spectre and dominates the dead.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Mana / Souls", specializations: ["Harvest", "Soul", "Domination"], archetype: "death-knight" },
{ id: "primalist", dbcId: 31, name: "Primalist", role: "Damage / Tank / Healer", sigil: "PR", color: "#0d2ed6", description: "A primal shapeshifter who calls beasts, stone, magma, and the Earthmother.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Primal", "Geomancy", "Life", "Mountain King"], archetype: "druid" },
{ id: "runemaster", dbcId: 32, name: "Runemaster", role: "Damage / Tank", sigil: "RU", color: "#40c7eb", description: "A rune-inscribing battlemage who bends elements, portals, and spellblades.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runes", specializations: ["Runic", "Arcane", "Riftblade"], archetype: "mage" },
{ id: "barbarian", categoryId: "coa", dbcId: 12, name: "Barbarian", role: "Damage / Support", sigil: "BA", color: "#8a3303", description: "A brutal melee combatant who cleaves foes and rallies ancestral power.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy", specializations: ["Brutality", "Headhunting", "Ancestry"], archetype: "rogue" },
{ id: "witch-doctor", categoryId: "coa", dbcId: 13, name: "Witch Doctor", role: "Healer / Damage", sigil: "WD", color: "#6eff00", description: "A shadowhunter who mixes hexes, brews, wards, and restorative voodoo.", mode: "conquest", armor: "leather", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Shadowhunting", "Voodoo", "Brewing"], archetype: "shaman" },
{ id: "felsworn", categoryId: "coa", dbcId: 14, name: "Felsworn", role: "Damage / Tank", sigil: "FE", color: "#a330c9", description: "A demon-touched fighter who turns fel fury into relentless offense and defense.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Felfury", specializations: ["Slayer", "Infernal", "Tyrant"], archetype: "rogue" },
{ id: "witch-hunter", categoryId: "coa", dbcId: 15, name: "Witch Hunter", role: "Damage / Tank", sigil: "WH", color: "#abd473", description: "An agile inquisitor wielding blades, firearms, traps, and forbidden shadow.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Boltslinger", "Houndmaster", "Inquisition", "Black Knight"], archetype: "hunter" },
{ id: "stormbringer", categoryId: "coa", dbcId: 16, name: "Stormbringer", role: "Damage / Support", sigil: "ST", color: "#0070de", description: "An elemental conduit who commands lightning, wind, and violent storms.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Static", specializations: ["Maelstrom", "Lightning", "Wind"], archetype: "shaman" },
{ id: "knight-of-xoroth", categoryId: "coa", dbcId: 17, name: "Knight of Xoroth", role: "Tank / Damage", sigil: "KX", color: "#d64747", description: "A demonic plate fighter fueled by rage, deathfire, and hellish summons.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Rage / Deathfire", specializations: ["Hellfire", "Defiance", "War"], archetype: "death-knight" },
{ id: "guardian", categoryId: "coa", dbcId: 18, name: "Guardian", role: "Tank / Damage / Support", sigil: "GU", color: "#c79c6e", description: "A sword-and-board leader who blocks attacks and inspires allies with banners.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Energy", specializations: ["Gladiator", "Inspiration", "Vanguard"], archetype: "warrior" },
{ id: "templar", categoryId: "coa", dbcId: 19, name: "Templar", role: "Damage / Tank", sigil: "TE", color: "#00ff99", description: "A flowing holy martial artist who chains strikes into powerful combos.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy / Holy Runes", specializations: ["Zealot", "Oathkeeper", "Crusader"], archetype: "rogue" },
{ id: "bloodmage", categoryId: "coa", dbcId: 20, name: "Bloodmage", role: "Damage / Healer / Tank", sigil: "BM", color: "#cc9900", description: "A sanguine spellblade who spends vitality and embraces the worgen curse.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Health", specializations: ["Sanguine", "Accursed", "Eternal", "Fleshweaver"], archetype: "druid" },
{ id: "ranger", categoryId: "coa", dbcId: 21, name: "Ranger", role: "Damage / Support", sigil: "RA", color: "#fff569", description: "A mobile skirmisher equally dangerous with bows or paired melee weapons.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Focus / Archery Points", specializations: ["Archery", "Brigand", "Farstrider"], archetype: "hunter" },
{ id: "chronomancer", categoryId: "coa", dbcId: 22, name: "Chronomancer", role: "Healer / Damage", sigil: "CH", color: "#f2e699", description: "A rule-bending caster who reverses wounds and warps order, chaos, and time.", mode: "conquest", armor: "cloth", primaryStat: "spirit", resourceSummary: "Mana", specializations: ["Infinite", "Time", "Artificer"], archetype: "priest" },
{ id: "necromancer", categoryId: "coa", dbcId: 23, name: "Necromancer", role: "Damage", sigil: "NE", color: "#8787ed", description: "A master of frost, plague, and permanent undead minions.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runic Power", specializations: ["Death", "Rime", "Animation"], archetype: "warlock" },
{ id: "pyromancer", categoryId: "coa", dbcId: 24, name: "Pyromancer", role: "Damage / Support", sigil: "PY", color: "#ff300f", description: "A fire specialist who incinerates foes, cauterizes allies, and channels dragons.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Incineration", "Flameweaving", "Draconic"], archetype: "mage" },
{ id: "cultist", categoryId: "coa", dbcId: 25, name: "Cultist", role: "Healer / Damage / Tank", sigil: "CU", color: "#e0c7ff", description: "A versatile servant of the Old Gods wielding eldritch spells and void blades.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Mana / Insanity", specializations: ["Godblade", "Corruption", "Heretic", "Dreadnought"], archetype: "paladin" },
{ id: "starcaller", categoryId: "coa", dbcId: 26, name: "Starcaller", role: "Tank / Damage / Healer", sigil: "SC", color: "#b5ffff", description: "An astral warrior who invokes Elune through blades, bows, and lunar magic.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Moon Guard", "Sentinel", "Moon Priest", "Warden"], archetype: "paladin" },
{ id: "sun-cleric", categoryId: "coa", dbcId: 27, name: "Sun Cleric", role: "Damage / Healer / Tank", sigil: "SU", color: "#f58cba", description: "A solar champion who heals with warmth and burns enemies with holy fire.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana / Rage / Solar Power", specializations: ["Piety", "Blessings", "Valkyrie", "Seraphim"], archetype: "paladin" },
{ id: "tinker", categoryId: "coa", dbcId: 28, name: "Tinker", role: "Damage / Healer", sigil: "TI", color: "#a3a3a3", description: "An inventor armed with guns, ammunition, turrets, gadgets, and mechanical allies.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Energy / Ammunition", specializations: ["Demolition", "Invention", "Mechanics"], archetype: "hunter" },
{ id: "venomancer", categoryId: "coa", dbcId: 29, name: "Venomancer", role: "Tank / Damage / Healer", sigil: "VE", color: "#ff7d0a", description: "A shapeshifting toxin master who spreads venom and hardens into insect forms.", mode: "conquest", armor: "mail", primaryStat: "intellect", resourceSummary: "Mana / Rage", specializations: ["Venom", "Stalking", "Fortitude", "Vizier"], archetype: "shaman" },
{ id: "reaper", categoryId: "coa", dbcId: 30, name: "Reaper", role: "Damage / Tank", sigil: "RE", color: "#c41f3b", description: "A soul-harvesting warrior who moves like a spectre and dominates the dead.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Mana / Souls", specializations: ["Harvest", "Soul", "Domination"], archetype: "death-knight" },
{ id: "primalist", categoryId: "coa", dbcId: 31, name: "Primalist", role: "Damage / Tank / Healer", sigil: "PR", color: "#0d2ed6", description: "A primal shapeshifter who calls beasts, stone, magma, and the Earthmother.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Primal", "Geomancy", "Life", "Mountain King"], archetype: "druid" },
{ id: "runemaster", categoryId: "coa", dbcId: 32, name: "Runemaster", role: "Damage / Tank", sigil: "RU", color: "#40c7eb", description: "A rune-inscribing battlemage who bends elements, portals, and spellblades.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runes", specializations: ["Runic", "Arcane", "Riftblade"], archetype: "mage" },
{ id: "rom-warrior", categoryId: "rom", dbcId: 1, name: "Warrior", role: "Tank / Damage", sigil: "RW", color: "#b86b3f", description: "A hardened weapon fighter who converts incoming pressure into rage.", mode: "rom", armor: "plate", primaryStat: "strength", resourceSummary: "Rage", specializations: [], archetype: "warrior" },
{ id: "rom-scout", categoryId: "rom", dbcId: 2, name: "Scout", role: "Damage", sigil: "SC", color: "#8bae58", description: "A mobile ranged hunter who relies on focus, precision, and battlefield control.", mode: "rom", armor: "leather", primaryStat: "agility", resourceSummary: "Focus", specializations: [], archetype: "hunter" },
@@ -595,21 +595,37 @@ export function isRomClassId(classId: ClassId): classId is RomClassId {
}
export function racesForCategory(categoryId: ContentCategoryId): readonly RaceDefinition[] {
return PLAYABLE_RACES.filter((race) => (race.categoryId ?? "wow") === categoryId);
return PLAYABLE_RACES.filter((race) => {
const raceCategory = race.categoryId ?? "wow";
return raceCategory === categoryId || (categoryId === "coa" && raceCategory === "wow");
});
}
export function classesForRace(raceId: RaceId): readonly ClassDefinition[] {
export function raceSupportsCategory(raceId: RaceId, categoryId: ContentCategoryId): boolean {
const raceCategory = contentCategoryForRace(raceId);
return raceCategory === categoryId || (categoryId === "coa" && raceCategory === "wow");
}
export function classesForRace(
raceId: RaceId,
categoryId?: ContentCategoryId,
): readonly ClassDefinition[] {
const race = raceById(raceId);
if (isRomRaceId(raceId)) {
if (categoryId && categoryId !== "rom") return [];
const allowed = new Set(ROM_CLASSES_BY_RACE[raceId]);
return CLASSES.filter((characterClass) => allowed.has(characterClass.id as RomClassId));
}
if (categoryId === "rom") return [];
const classMatchesCategory = (characterClass: ClassDefinition) => (
categoryId ? contentCategoryForClass(characterClass.id) === categoryId : contentCategoryForClass(characterClass.id) !== "rom"
);
if (race.unrestrictedClasses) return CLASSES.filter((characterClass) => (
(characterClass.categoryId ?? "wow") === "wow"
classMatchesCategory(characterClass)
));
const raceDbcId = race.dbcId;
return CLASSES.filter((characterClass) => (
(characterClass.categoryId ?? "wow") === "wow"
classMatchesCategory(characterClass)
&& RACE_DBC_IDS_BY_CLASS[characterClass.id].includes(raceDbcId)
));
}
+6 -2
View File
@@ -1,4 +1,4 @@
export type ContentCategoryId = "wow" | "rom";
export type ContentCategoryId = "wow" | "rom" | "coa";
export interface ContentCategoryDefinition {
readonly id: ContentCategoryId;
@@ -6,9 +6,13 @@ export interface ContentCategoryDefinition {
readonly name: string;
}
/** Shared by character creation and dungeon selection. */
/** Character-creation categories; other catalogs may expose a supported subset. */
export const CONTENT_CATEGORIES: readonly ContentCategoryDefinition[] = Object.freeze([
{ id: "wow", label: "WoW", name: "World of Warcraft" },
{ id: "rom", label: "RoM", name: "Runes of Magic" },
{ id: "coa", label: "CoA", name: "Conquest of Azeroth" },
]);
export function contentCategoryById(id: ContentCategoryId | null | undefined): ContentCategoryDefinition {
return CONTENT_CATEGORIES.find((category) => category.id === id) ?? CONTENT_CATEGORIES[0];
}
+11 -1
View File
@@ -163,7 +163,7 @@ describe("shell store", () => {
expect(useShellStore.getState().draft.gender).toBe("male");
});
it("resets the creator to a valid draft when switching between WoW and RoM", () => {
it("resets the creator to a valid draft when switching among WoW, RoM, and CoA", () => {
useShellStore.getState().openCreator();
useShellStore.getState().updateDraft({ name: "Category Keeper", categoryId: "rom" });
expect(useShellStore.getState().draft).toMatchObject({
@@ -179,6 +179,16 @@ describe("shell store", () => {
classId: "rom-champion",
gender: "male",
});
useShellStore.getState().updateDraft({ categoryId: "coa" });
expect(useShellStore.getState().draft).toMatchObject({
name: "Categorykeep",
categoryId: "coa",
raceId: "human",
classId: "barbarian",
gender: "female",
});
useShellStore.getState().updateDraft({ classId: "priest" });
expect(useShellStore.getState().draft.classId).toBe("barbarian");
useShellStore.getState().updateDraft({ categoryId: "wow" });
expect(useShellStore.getState().draft).toMatchObject({
name: "Categorykeep",
+6 -6
View File
@@ -19,10 +19,10 @@ import {
clampAppearance,
classById,
classesForRace,
contentCategoryForRace,
createDefaultAppearance,
normalizeCharacterName,
randomAppearance,
raceSupportsCategory,
racesForCategory,
supportedGenders,
validateCharacterName,
@@ -100,7 +100,7 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
name: "",
categoryId,
raceId,
classId: categoryId === "rom" ? "rom-priest" : "priest",
classId: categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest",
gender: "female",
appearance: createDefaultAppearance(),
rotation: -12,
@@ -165,16 +165,16 @@ export const useShellStore = create<ShellState>((set, get) => ({
? { ...defaultDraft(patch.categoryId), name: state.draft.name, rotation: state.draft.rotation }
: { ...state.draft, ...patch };
if (patch.name !== undefined) next.name = normalizeCharacterName(patch.name);
if (contentCategoryForRace(next.raceId) !== next.categoryId) {
if (!raceSupportsCategory(next.raceId, next.categoryId)) {
const targetRace = racesForCategory(next.categoryId)[0];
next.raceId = targetRace.id;
next.classId = classesForRace(targetRace.id)[0].id;
next.classId = classesForRace(targetRace.id, next.categoryId)[0].id;
next.gender = supportedGenders(targetRace.id)[0] ?? "male";
next.appearance = createDefaultAppearance();
}
const availableGenders = supportedGenders(next.raceId);
if (!availableGenders.includes(next.gender)) next.gender = availableGenders[0] ?? "male";
const availableClasses = classesForRace(next.raceId);
const availableClasses = classesForRace(next.raceId, next.categoryId);
if (!availableClasses.some((definition) => definition.id === next.classId)) {
next.classId = availableClasses[0].id;
}
@@ -201,7 +201,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ notice: nameError });
return false;
}
if (!classesForRace(draft.raceId).some((definition) => definition.id === draft.classId)) {
if (!classesForRace(draft.raceId, draft.categoryId).some((definition) => definition.id === draft.classId)) {
set({ notice: "That class is not available to the selected race." });
return false;
}
+34 -13
View File
@@ -39,9 +39,11 @@ import { resolveContentUrl } from "../content/contentManager";
import {
selectCharacterBaseClip,
selectCharacterEventClip,
selectCharacterVerticalClip,
type CharacterCombatAnimationEvent,
type CharacterBaseMotion,
} from "../game/combatAnimation";
import type { CharacterVerticalMotion } from "../game/playerJump";
import { AvatarErrorBoundary } from "./AvatarErrorBoundary";
import {
ATTACK_PRESENTATION_DURATION_SECONDS,
@@ -77,6 +79,7 @@ export interface CharacterModelProps {
identity: AvatarIdentity | null | undefined;
active?: boolean;
movingRef?: MutableRefObject<boolean>;
verticalMotionRef?: MutableRefObject<CharacterVerticalMotion>;
attackPulseRef?: MutableRefObject<number>;
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
onReady?: () => void;
@@ -88,6 +91,8 @@ export interface CharacterModelProps {
const EMPTY_EQUIPMENT: readonly InventoryItem[] = Object.freeze([]);
type CharacterLocomotionMotion = CharacterBaseMotion | Exclude<CharacterVerticalMotion, "grounded">;
type MappedMaterial = Material & { map?: Texture | null; color?: Color };
function configureMaterial(material: Material): void {
@@ -164,6 +169,7 @@ function LoadedCharacterModel({
identity,
manifest,
movingRef,
verticalMotionRef,
attackPulseRef,
animationEventRef,
onReady,
@@ -176,6 +182,7 @@ function LoadedCharacterModel({
identity: AvatarIdentity;
manifest: AvatarManifest;
movingRef?: MutableRefObject<boolean>;
verticalMotionRef?: MutableRefObject<CharacterVerticalMotion>;
attackPulseRef?: MutableRefObject<number>;
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
onReady?: () => void;
@@ -190,7 +197,7 @@ function LoadedCharacterModel({
const overrideAction = useRef<AnimationAction | null>(null);
const overrideTerminal = useRef(false);
const overrideLooping = useRef(false);
const currentMotion = useRef<CharacterBaseMotion | null>(null);
const currentMotion = useRef<CharacterLocomotionMotion | null>(null);
const observedAttackPulse = useRef(attackPulseRef?.current ?? 0);
const equipmentAttackPulseRef = useRef(attackPulseRef?.current ?? 0);
const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0);
@@ -338,19 +345,27 @@ function LoadedCharacterModel({
?? gltf.animations[0]
);
const switchMotion = (motion: CharacterBaseMotion) => {
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
if (overrideAction.current) return;
if (currentMotion.current === motion) return;
const clip = selectClip(motion);
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
? selectCharacterVerticalClip(gltf.animations, motion, moving)
: null;
const fallbackMotion: CharacterBaseMotion = moving ? "run" : "idle";
const clip = verticalSelection?.clip ?? selectClip(
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion,
);
if (!clip) return;
const loop = verticalSelection?.loop ?? true;
const next = mixer.clipAction(clip, scene);
if (currentAction.current !== next) {
next.enabled = true;
next.clampWhenFinished = false;
next.setLoop(LoopRepeat, Number.POSITIVE_INFINITY).reset().fadeIn(0.16).play();
currentAction.current?.fadeOut(0.16);
currentAction.current = next;
}
next.enabled = true;
next.clampWhenFinished = !loop;
next.setLoop(loop ? LoopRepeat : LoopOnce, loop ? Number.POSITIVE_INFINITY : 1)
.reset()
.fadeIn(0.16)
.play();
if (currentAction.current !== next) currentAction.current?.fadeOut(0.16);
currentAction.current = next;
currentMotion.current = motion;
};
@@ -415,7 +430,7 @@ function LoadedCharacterModel({
};
useEffect(() => {
switchMotion("idle");
switchMotion("idle", false);
return () => {
mixer.stopAllAction();
mixer.uncacheRoot(scene);
@@ -439,6 +454,10 @@ function LoadedCharacterModel({
useFrame((_, delta) => {
if (!active) return;
const moving = !preview && Boolean(movingRef?.current);
const verticalMotion = preview ? "grounded" : verticalMotionRef?.current ?? "grounded";
const locomotionMotion: CharacterLocomotionMotion = verticalMotion === "grounded"
? moving ? "run" : "idle"
: verticalMotion;
const nextAnimationEvent = animationEventRef?.current;
if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) {
observedAnimationRevision.current = nextAnimationEvent.revision;
@@ -455,7 +474,7 @@ function LoadedCharacterModel({
playAnimationEvent({ revision: nextAttackPulse, kind: "attack" });
}
}
switchMotion(moving ? "run" : "idle");
switchMotion(locomotionMotion, moving);
const safeDelta = Math.min(delta, 0.05);
mixer.update(safeDelta);
@@ -468,7 +487,7 @@ function LoadedCharacterModel({
&& !overrideLooping.current
&& !overrideAction.current.isRunning()
) clearOverride();
switchMotion(moving ? "run" : "idle");
switchMotion(locomotionMotion, moving);
const swing = attackSwingAt(attackElapsed.current);
attackElapsed.current = Math.min(
ATTACK_PRESENTATION_DURATION_SECONDS,
@@ -514,6 +533,7 @@ export function CharacterModel({
identity,
active = true,
movingRef,
verticalMotionRef,
attackPulseRef,
animationEventRef,
onReady,
@@ -546,6 +566,7 @@ export function CharacterModel({
identity={identity}
manifest={manifest}
movingRef={movingRef}
verticalMotionRef={verticalMotionRef}
attackPulseRef={attackPulseRef}
animationEventRef={animationEventRef}
onReady={onReady}
+27
View File
@@ -5,6 +5,7 @@ import {
parseWoWAnimationName,
selectCharacterBaseClip,
selectCharacterEventClip,
selectCharacterVerticalClip,
selectWoWAnimationVariation,
} from "./combatAnimation";
@@ -20,6 +21,10 @@ const clips = [
{ name: "Death (ID 1 variation 0)" },
{ name: "AttackUnarmed (ID 16 variation 0)" },
{ name: "AttackUnarmed (ID 16 variation 1)" },
{ name: "JumpStart (ID 37 variation 0)" },
{ name: "Fall (ID 40 variation 0)" },
{ name: "JumpEnd (ID 39 variation 0)" },
{ name: "JumpLandRun (ID 133 variation 0)" },
];
describe("WoW character animation selection", () => {
@@ -45,6 +50,19 @@ describe("WoW character animation selection", () => {
expect(clipsForAnimationIds(clips, [57, 17])).toEqual([clips[4], clips[3]]);
});
it("selects native rising, falling, and landing locomotion", () => {
expect(selectCharacterVerticalClip(clips, "rising", false)).toMatchObject({
clip: clips[11],
loop: false,
});
expect(selectCharacterVerticalClip(clips, "falling", false)).toMatchObject({
clip: clips[12],
loop: true,
});
expect(selectCharacterVerticalClip(clips, "landing", false)?.clip).toBe(clips[13]);
expect(selectCharacterVerticalClip(clips, "landing", true)?.clip).toBe(clips[14]);
});
it("maps hard-cast lifecycle events to ready and release clips", () => {
expect(selectCharacterEventClip(clips, "priest", {
revision: 1,
@@ -79,6 +97,9 @@ describe("WoW character animation selection", () => {
{ name: "Action - buff01" },
{ name: "Wound - hurt" },
{ name: "Death - death" },
{ name: "Action - jump_up" },
{ name: "Action - jump_down" },
{ name: "Action - jump_end_run" },
];
expect(selectCharacterEventClip(runewakerClips, "rom-mage", {
@@ -114,5 +135,11 @@ describe("WoW character animation selection", () => {
revision: 6,
kind: "death",
})).toMatchObject({ clip: runewakerClips[9], terminal: true, native: true });
expect(selectCharacterVerticalClip(runewakerClips, "rising", false)?.clip)
.toBe(runewakerClips[10]);
expect(selectCharacterVerticalClip(runewakerClips, "falling", false)?.clip)
.toBe(runewakerClips[11]);
expect(selectCharacterVerticalClip(runewakerClips, "landing", true)?.clip)
.toBe(runewakerClips[12]);
});
});
+30
View File
@@ -1,6 +1,7 @@
import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog";
import type { AbilityDefinition } from "./abilityCatalog";
import { abilityAnimationById } from "./abilityAnimationLookup";
import type { CharacterVerticalMotion } from "./playerJump";
export type CharacterCombatAnimationKind =
| "ability-start"
@@ -112,6 +113,35 @@ export function selectCharacterBaseClip<TClip extends NamedAnimationClip>(
return selectWoWAnimationVariation(clips, ids);
}
export function selectCharacterVerticalClip<TClip extends NamedAnimationClip>(
clips: readonly TClip[],
motion: Exclude<CharacterVerticalMotion, "grounded">,
moving: boolean,
): CharacterClipSelection<TClip> | null {
const ids = motion === "rising"
? [37, 38, 40]
: motion === "falling"
? [40, 38, 37]
: moving
? [133, 39, 38]
: [39, 133, 38];
const runewakerNames = motion === "rising"
? ["Action - jump_up", "Action - jump_loop"]
: motion === "falling"
? ["Action - jump_down", "Action - jump_loop"]
: moving
? ["Action - jump_end_run", "Action - jump_end_back"]
: ["Action - jump_end_back", "Action - jump_end_run"];
const clip = selectWoWAnimationVariation(clips, ids)
?? selectRunewakerClipVariation(clips, runewakerNames);
return clip ? {
clip,
loop: motion === "falling",
terminal: false,
native: true,
} : null;
}
function isMeleeAbility(ability: AbilityDefinition | null, classId: ClassId): boolean {
if (!ability) return MELEE_CLASSES.has(baseClassFor(classId));
if (ability.range.max <= 4) return true;
+12 -1
View File
@@ -38,7 +38,13 @@ describe("named aura combat runtime", () => {
sourceId: PLAYER_AGGRO_ID,
targetId: PLAYER_AGGRO_ID,
expiresAt: now + 1_800_000,
definition: { disposition: "buff", dispelCategory: "magic" },
definition: {
disposition: "buff",
dispelCategory: "magic",
icon: useCombatStore.getState().abilities.find((ability) => (
ability.id === "wow335-priest-power-word-fortitude"
))?.icon,
},
});
useCombatStore.getState().tick(0, now + 1_800_000);
@@ -57,6 +63,11 @@ describe("named aura combat runtime", () => {
.flatMap((aura) => aura.absorbs)
.reduce((total, absorb) => total + absorb.remaining, 0);
expect(beforeAbsorb).toBeGreaterThan(0);
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
useCombatStore.getState().abilities.find((ability) => (
ability.id === "wow335-priest-power-word-shield"
))?.icon,
);
const healthDamage = useCombatStore.getState().damagePlayer(20, "shadow", 80);
const afterAbsorb = useCombatStore.getState().auras
+4
View File
@@ -133,6 +133,10 @@ export interface AuraDefinition {
readonly id: string;
readonly name: string;
readonly disposition: AuraDisposition;
/** Spell artwork used by every generic aura HUD surface. */
readonly icon?: string;
/** Keep a mechanically active aura out of the generic HUD strip when another widget owns its display. */
readonly hideFromAuraStrip?: boolean;
/** Null means the aura persists until explicitly removed. */
readonly durationMs: number | null;
readonly maxStacks?: number;
+19 -8
View File
@@ -83,6 +83,7 @@ import {
resolveResourceValue as resolveAuraResourceValue,
resolveStatValue as resolveAuraStatValue,
type ActiveAura,
type AuraDefinition,
type ProcEvent,
type TriggeredAuraProc,
} from "./combatAuras";
@@ -1464,6 +1465,18 @@ function aurasForEntity(auras: readonly ActiveAura[], entityId: string): ActiveA
return auras.filter((aura) => aura.targetId === entityId);
}
function auraDefinitionForAbility(
definition: AuraDefinition,
ability: AbilityDefinition,
durationMs = definition.durationMs,
): AuraDefinition {
return {
...definition,
icon: definition.icon ?? ability.icon,
durationMs,
};
}
function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): CharacterStats {
const stat = (value: number, ...names: string[]) => names.reduce(
(current, name) => resolveAuraStatValue(current, auras, name),
@@ -1534,7 +1547,7 @@ function syncPassiveAuras(
const ability = abilityAtLevel(catalogAbility, level, learnedRank);
return ability.effects
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
.map((effect) => effect.aura);
.map((effect) => auraDefinitionForAbility(effect.aura, ability));
});
const passiveIds = new Set(definitions.map((definition) => definition.id));
let next = current.filter((aura) => (
@@ -2125,7 +2138,7 @@ function executeTriggeredSpellInWork(
? sourceId
: targetId ?? sourceId;
work.auras = [...applyCombatAura(work.auras, {
definition: effect.aura,
definition: auraDefinitionForAbility(effect.aura, ability),
sourceId,
targetId: recipient,
now,
@@ -3156,12 +3169,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}, ability.id, now);
}
} else if (effect.kind === "apply-aura") {
const auraDefinition = effect.aura.durationMs === null
? effect.aura
: {
...effect.aura,
durationMs: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers),
};
const durationMs = effect.aura.durationMs === null
? null
: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers);
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const recipients = effect.recipient === "caster"
? [PLAYER_AURA_ENTITY_ID]
: ability.target === "hostile"
+7 -7
View File
@@ -1,7 +1,8 @@
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
import { CONTENT_CATEGORIES, type ContentCategoryId } from "../app/contentCategories";
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
export type DungeonCategoryId = ContentCategoryId;
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
export interface DungeonCategory {
readonly id: DungeonCategoryId;
@@ -10,11 +11,10 @@ export interface DungeonCategory {
}
export const DUNGEON_CATEGORIES: readonly DungeonCategory[] = Object.freeze([
...CONTENT_CATEGORIES.map((category) => ({
id: category.id,
label: category.label,
fullName: category.name,
})),
...DUNGEON_CATEGORY_IDS.map((id) => {
const category = contentCategoryById(id);
return { id, label: category.label, fullName: category.name };
}),
]);
export interface DungeonCatalogEntry {
+30 -1
View File
@@ -10,6 +10,8 @@ import {
MAX_MOUSE_DELTA_PER_FRAME,
POINTER_LOCK_MOUSE_GRACE_MS,
beginMouseLookDrag,
clearJumpRequest,
consumeJumpRequest,
consumeMouseLook,
controllerCommandForToken,
createMouseLookBuffer,
@@ -19,6 +21,7 @@ import {
isDesktopCameraLookPointer,
keyboardAxesForKeys,
keyboardCommandForCode,
queueJumpRequest,
sanitizeMouseDelta,
type InputActions,
} from "./inputManager";
@@ -54,6 +57,7 @@ function inputActionSpies(blocked = false): InputActions {
afterEach(() => {
endMouseLookDrag();
clearJumpRequest();
resetControllerState();
vi.unstubAllGlobals();
});
@@ -89,7 +93,7 @@ describe("gameplay input bindings", () => {
it("maps Thor controls without stealing Select from display routing", () => {
expect(controllerCommandForToken("Button9")).toBe("pause");
expect(controllerCommandForToken("Button11")).toBe("target-next");
expect(controllerCommandForToken("Button10")).toBe("target-clear");
expect(controllerCommandForToken("Button10")).toBe("jump");
expect(controllerCommandForToken("Button12")).toBe("party-previous");
expect(controllerCommandForToken("Button13")).toBe("party-next");
expect(controllerCommandForToken("Button4")).toBeNull();
@@ -145,6 +149,31 @@ describe("gameplay input bindings", () => {
expect(keyboardCommandForCode("Digit8")).toBeNull();
});
it("queues Space and L3 jump presses once and blocks them behind menus", () => {
expect(keyboardCommandForCode("Space")).toBe("jump");
queueJumpRequest(25);
expect(consumeJumpRequest()).toBe(25);
expect(consumeJumpRequest()).toBeNull();
stubInputEventTargets();
const nextActions = inputActionSpies();
const blocked = nextActions.gameplayActionsBlocked as ReturnType<typeof vi.fn>;
const dispose = installInput(nextActions);
emitControllerToken({ token: "Button10", repeat: false, pressed: true });
expect(consumeJumpRequest()).not.toBeNull();
emitControllerToken({ token: "Button10", repeat: true, pressed: true });
expect(consumeJumpRequest()).toBeNull();
blocked.mockReturnValue(true);
emitControllerToken({ token: "Button10", repeat: false, pressed: false });
emitControllerToken({ token: "Button10", repeat: false, pressed: true });
expect(consumeJumpRequest()).toBeNull();
dispose();
});
it("uses WASD for movement and arrow keys for camera look", () => {
expect(keyboardAxesForKeys(new Set(["KeyW", "KeyD"]))).toEqual({
moveX: 1,
+21 -1
View File
@@ -42,6 +42,7 @@ export interface InputActions {
const keys = new Set<string>();
let actions: InputActions | null = null;
let jumpRequestAt: number | null = null;
// Pointer-lock implementations can emit a short burst of stale, very large
// movement events while the cursor is being recentered. Some Android WebViews have also been
@@ -69,6 +70,20 @@ function monotonicNow(): number {
return Date.now();
}
export function queueJumpRequest(nowMs = monotonicNow()): void {
if (Number.isFinite(nowMs)) jumpRequestAt = nowMs;
}
export function consumeJumpRequest(): number | null {
const requestedAt = jumpRequestAt;
jumpRequestAt = null;
return requestedAt;
}
export function clearJumpRequest(): void {
jumpRequestAt = null;
}
export function createMouseLookBuffer(now: () => number = monotonicNow) {
let x = 0;
let y = 0;
@@ -161,12 +176,13 @@ export type GameplayInputCommand =
| "party-defend"
| "party-stop"
| "party-recall"
| "jump"
| "target-clear";
export function controllerCommandForToken(token: string): GameplayInputCommand | null {
if (token === "Button9" || token === CONTROLLER_SYSTEM_BACK_TOKEN) return "pause";
if (token === "Button11") return "target-next";
if (token === "Button10") return "target-clear";
if (token === "Button10") return "jump";
if (token === "Button12") return "party-previous";
if (token === "Button13") return "party-next";
return null;
@@ -183,6 +199,7 @@ export function keyboardCommandForCode(code: string, shiftKey = false): Gameplay
if (code === "F2") return "party-defend";
if (code === "F3") return "party-stop";
if (code === "F4") return "party-recall";
if (code === "Space") return "jump";
if (code === "Tab") return shiftKey ? "target-previous" : "target-next";
return null;
}
@@ -202,6 +219,7 @@ function runCommand(command: GameplayInputCommand, nextActions: InputActions): v
else if (command === "party-defend") nextActions.setPartyCommand("defend");
else if (command === "party-stop") nextActions.setPartyCommand("stop");
else if (command === "party-recall") nextActions.recallParty();
else if (command === "jump") queueJumpRequest();
else if (command === "target-clear") nextActions.clearTarget();
}
@@ -303,6 +321,7 @@ export function installInput(nextActions: InputActions): () => void {
return;
}
if (nextActions.gameplayActionsBlocked()) return;
if (command === "jump") event.preventDefault();
if (event.code === "Tab") event.preventDefault();
runCommand(command, nextActions);
};
@@ -339,6 +358,7 @@ export function installInput(nextActions: InputActions): () => void {
keyboardModifiers.clear();
controllerL1 = false;
controllerL2 = false;
clearJumpRequest();
mouseLookDragActive = false;
previousMousePosition = null;
nextActions.setActionLayer("primary");
+98
View File
@@ -8,10 +8,17 @@ import {
partyCombatMovementGoal,
partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile,
partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal,
partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail,
stepPartyPosition,
synchronizePartyBreadcrumbCursor,
} from "./partyMovement";
describe("party world movement", () => {
@@ -38,6 +45,89 @@ describe("party world movement", () => {
expect(out).toEqual([12, 2, 17]);
});
it("builds stable and distinct bounded movement personalities", () => {
const first = partyMovementProfile("member-a", 0);
const repeat = partyMovementProfile("member-a", 0);
const second = partyMovementProfile("member-b", 1);
expect(first).toEqual(repeat);
expect(second).not.toEqual(first);
for (const profile of [first, second]) {
expect(Math.abs(profile.followDistanceJitter)).toBeLessThanOrEqual(0.35);
expect(Math.abs(profile.lateralOffset)).toBeLessThanOrEqual(0.65);
expect(profile.comfortSlack).toBeGreaterThanOrEqual(0.35);
expect(profile.comfortSlack).toBeLessThanOrEqual(0.85);
expect(profile.speedMultiplier).toBeGreaterThanOrEqual(0.94);
expect(profile.speedMultiplier).toBeLessThanOrEqual(1.06);
expect(Math.abs(profile.combatAngleOffset)).toBeLessThanOrEqual(50 * Math.PI / 180);
}
});
it("places natural followers inside personal spacing and movement bands", () => {
const profile = partyMovementProfile("member-c", 2);
const out: [number, number, number] = [0, 0, 0];
partyNaturalFollowPosition([10, 2, 20], 0, 1, 3.75, profile, 5_000, out);
expect(out[1]).toBe(2);
expect(Math.abs(out[0] - 10)).toBeLessThanOrEqual(0.83);
expect(20 - out[2]).toBeGreaterThanOrEqual(3.4);
expect(20 - out[2]).toBeLessThanOrEqual(4.1);
expect(partyShouldMoveToComfortTarget(out, out, false, profile.comfortSlack)).toBe(false);
expect(partyShouldMoveToComfortTarget([0, 0, 0], out, false, profile.comfortSlack)).toBe(true);
});
it("keeps personalized combat bands inside role-safe bounds", () => {
const profiles = [
partyMovementProfile("ranged-a", 1),
partyMovementProfile("ranged-b", 2),
];
for (const profile of profiles) {
const ranged = personalizedPartyCombatRangeBand({
minimum: 10,
preferred: 14.5,
maximum: 18.1,
}, profile);
expect(ranged.minimum).toBeGreaterThanOrEqual(9);
expect(ranged.maximum).toBeLessThanOrEqual(18.1);
expect(ranged.preferred).toBeGreaterThan(ranged.minimum);
expect(ranged.preferred).toBeLessThan(ranged.maximum);
const melee = personalizedPartyCombatRangeBand({
minimum: 0,
preferred: 3.3,
maximum: 3.55,
}, profile);
expect(melee.minimum).toBe(0);
expect(melee.maximum).toBeLessThanOrEqual(3.55);
expect(melee.preferred).toBeLessThan(melee.maximum);
}
});
it("uses a stable combat bearing and separates overlapping allies", () => {
const out: [number, number, number] = [0, 0, 0];
expect(partyPersonalizedCombatMovementGoal(
[20, 0, 0],
[0, 0, 0],
true,
{ minimum: 10, preferred: 14, maximum: 18 },
Math.PI / 2,
false,
out,
)).toBe("approach");
expect(out[0]).toBeCloseTo(14);
expect(out[1]).toBe(0);
expect(out[2]).toBeCloseTo(0);
const separated: [number, number, number] = [0, 0, 0];
expect(partySeparationGoal(
[1, 0, 1],
[2, 0, 2],
[[1, 0, 1]],
Math.PI / 2,
separated,
)).toBe(true);
expect(separated[0]).toBeGreaterThan(2);
expect(separated[2]).toBeCloseTo(2);
});
it("recalls only distant allies that should be moving and stopped making progress", () => {
expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true);
expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false);
@@ -168,6 +258,14 @@ describe("party world movement", () => {
expect(out).toEqual([20, 3, 4]);
});
it("synchronizes direct followers to the matching safe trail point", () => {
const trail = createPartyBreadcrumbTrail();
for (let x = 0; x <= 6; x += 1) appendPartyBreadcrumb(trail, [x, 0, 0], 0.1, 20);
const cursor = createPartyBreadcrumbCursor();
synchronizePartyBreadcrumbCursor(trail, cursor, 2);
expect(cursor.breadcrumbId).toBe(trail.points.find((point) => point.position[0] === 4)?.id);
});
it("lets a distant follower rejoin a trail before it is long enough for formation spacing", () => {
const trail = createPartyBreadcrumbTrail();
const cursor = createPartyBreadcrumbCursor();
+221 -1
View File
@@ -19,7 +19,72 @@ export interface PartyBreadcrumbCursor {
breadcrumbId: number | null;
}
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition";
export interface PartyMovementProfile {
readonly followDistanceJitter: number;
readonly lateralOffset: number;
readonly comfortSlack: number;
readonly speedMultiplier: number;
readonly swayAmplitude: number;
readonly swayPeriodMs: number;
readonly swayPhase: number;
readonly combatAngleOffset: number;
readonly combatRangeBias: number;
}
export interface PartyCombatRangeLike {
readonly minimum: number;
readonly preferred: number;
readonly maximum: number;
}
function stableHash(value: string): number {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function hashUnit(value: string): number {
return stableHash(value) / 0xffff_ffff;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
/** Stable per-character preferences keep motion reproducible without looking cloned. */
export function partyMovementProfile(memberId: string, livingRank: number): PartyMovementProfile {
const rank = Math.max(0, Math.floor(Number.isFinite(livingRank) ? livingRank : 0));
const prefix = `${memberId}|${rank}`;
const lateralMagnitude = rank === 0
? hashUnit(`${prefix}|leader-lateral`) * 0.3
: 0.35 + hashUnit(`${prefix}|lateral`) * 0.3;
const lateralSign = rank === 0
? hashUnit(`${prefix}|leader-side`) < 0.5 ? -1 : 1
: rank % 2 === 1 ? -1 : 1;
const angleSlots = [0, -35, 35, -50, 50];
const angleDegrees = clamp(
(angleSlots[rank % angleSlots.length] ?? 0)
+ (hashUnit(`${prefix}|angle-jitter`) - 0.5) * 10,
-50,
50,
);
return {
followDistanceJitter: (hashUnit(`${prefix}|distance`) - 0.5) * 0.7,
lateralOffset: lateralSign * lateralMagnitude,
comfortSlack: 0.35 + hashUnit(`${prefix}|comfort`) * 0.5,
speedMultiplier: 0.94 + hashUnit(`${prefix}|speed`) * 0.12,
swayAmplitude: 0.08 + hashUnit(`${prefix}|sway-amplitude`) * 0.1,
swayPeriodMs: 6_000 + hashUnit(`${prefix}|sway-period`) * 4_000,
swayPhase: hashUnit(`${prefix}|sway-phase`) * Math.PI * 2,
combatAngleOffset: angleDegrees * Math.PI / 180,
combatRangeBias: hashUnit(`${prefix}|combat-range`) * 2 - 1,
};
}
export const PARTY_STUCK_RECALL_DISTANCE = 12;
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
@@ -66,6 +131,25 @@ export function createPartyBreadcrumbCursor(): PartyBreadcrumbCursor {
return { breadcrumbId: null };
}
export function synchronizePartyBreadcrumbCursor(
trail: PartyBreadcrumbTrail,
cursor: PartyBreadcrumbCursor,
trailingDistance: number,
): void {
const latest = trail.points[trail.points.length - 1];
if (!latest) {
cursor.breadcrumbId = null;
return;
}
const maximumDistance = latest.distance - Math.max(0, trailingDistance);
let target = trail.points[0];
for (const point of trail.points) {
if (point.distance > maximumDistance + EPSILON) break;
target = point;
}
cursor.breadcrumbId = target.id;
}
export function resetPartyBreadcrumbTrail(
trail: PartyBreadcrumbTrail,
position?: PartyWorldPosition,
@@ -271,6 +355,114 @@ export function partyFormationPosition(
out[2] = anchor[2] + rightZ * lateral - normalizedForwardZ * trailing;
}
export function partyNaturalFollowPosition(
anchor: PartyWorldPosition,
forwardX: number,
forwardZ: number,
baseTrailingDistance: number,
profile: PartyMovementProfile,
nowMs: number,
out: MutablePartyWorldPosition,
): void {
const trailing = Math.max(0.9, baseTrailingDistance + profile.followDistanceJitter);
const safeNow = Number.isFinite(nowMs) ? nowMs : 0;
const sway = Math.sin(
safeNow / Math.max(1, profile.swayPeriodMs) * Math.PI * 2 + profile.swayPhase,
) * profile.swayAmplitude;
partyFormationPosition(
anchor,
forwardX,
forwardZ,
profile.lateralOffset + sway,
trailing,
out,
);
}
/** Start outside a personal comfort radius, then finish close enough to avoid jitter. */
export function partyShouldMoveToComfortTarget(
current: PartyWorldPosition,
target: PartyWorldPosition,
wasMoving: boolean,
comfortSlack: number,
): boolean {
const distance = Math.hypot(
target[0] - current[0],
target[1] - current[1],
target[2] - current[2],
);
if (!Number.isFinite(distance)) return false;
const startDistance = Math.max(0.18, Number.isFinite(comfortSlack) ? comfortSlack : 0.35);
return distance > (wasMoving ? 0.18 : startDistance);
}
export function personalizedPartyCombatRangeBand(
base: PartyCombatRangeLike,
profile: PartyMovementProfile,
): PartyCombatRangeLike {
if (base.minimum > 0) {
const minimum = clamp(base.minimum + profile.combatRangeBias * 0.75, 9, 11);
const maximum = clamp(base.maximum + profile.combatRangeBias * 0.35, 17, 18.1);
return {
minimum,
preferred: clamp(
base.preferred + profile.combatRangeBias * 1.25,
minimum + 0.75,
maximum - 0.5,
),
maximum,
};
}
const maximum = Math.max(2.4, base.maximum - Math.abs(profile.combatRangeBias) * 0.2);
return {
minimum: 0,
preferred: clamp(
base.preferred + profile.combatRangeBias * 0.3,
2.35,
maximum - 0.12,
),
maximum,
};
}
/** Adds a bounded local repulsion so allies do not occupy the same point. */
export function partySeparationGoal(
current: PartyWorldPosition,
desired: PartyWorldPosition,
peers: readonly PartyWorldPosition[],
fallbackAngle: number,
out: MutablePartyWorldPosition,
separationDistance = 0.85,
maximumCorrection = 0.35,
): boolean {
let offsetX = 0;
let offsetZ = 0;
const safeSeparation = Math.max(EPSILON, separationDistance);
for (const peer of peers) {
const dx = current[0] - peer[0];
const dz = current[2] - peer[2];
const distance = Math.hypot(dx, dz);
if (!Number.isFinite(distance) || distance >= safeSeparation) continue;
const weight = (safeSeparation - distance) / safeSeparation;
if (distance <= EPSILON) {
offsetX += Math.sin(fallbackAngle) * weight;
offsetZ += Math.cos(fallbackAngle) * weight;
} else {
offsetX += dx / distance * weight;
offsetZ += dz / distance * weight;
}
}
const length = Math.hypot(offsetX, offsetZ);
out[0] = desired[0];
out[1] = desired[1];
out[2] = desired[2];
if (length <= EPSILON) return false;
const correction = Math.min(Math.max(0, maximumCorrection), length);
out[0] += offsetX / length * correction;
out[2] += offsetZ / length * correction;
return true;
}
/**
* Chooses an individual combat destination. Melee only closes distance;
* ranged attackers also back out when a target collapses their preferred band.
@@ -323,3 +515,31 @@ export function partyCombatMovementGoal(
out[2] = target[2] + awayZ / planarLength * preferred;
return "retreat";
}
export function partyPersonalizedCombatMovementGoal(
current: PartyWorldPosition,
target: PartyWorldPosition,
targetVisible: boolean,
rangeBand: PartyCombatRangeLike,
preferredBearing: number,
forceReposition: boolean,
out: MutablePartyWorldPosition,
): PartyCombatMovementIntent {
const intent = partyCombatMovementGoal(
current,
target,
targetVisible,
rangeBand.minimum,
rangeBand.preferred,
rangeBand.maximum,
out,
Math.sin(preferredBearing),
Math.cos(preferredBearing),
);
if (!targetVisible || (intent === "hold" && !forceReposition)) return intent;
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
out[1] = target[1];
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
return intent === "hold" ? "reposition" : intent;
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
PLAYER_GROUND_MIN_NORMAL_Y,
PLAYER_JUMP_BUFFER_MS,
PLAYER_JUMP_COYOTE_MS,
bufferPlayerJump,
cancelBufferedPlayerJump,
characterVerticalMotion,
createPlayerJumpTiming,
isWalkableGroundHit,
updatePlayerJumpTiming,
} from "./playerJump";
describe("player jump timing", () => {
it("accepts only a nearby upward-facing ground hit", () => {
expect(isWalkableGroundHit({
timeOfImpact: 0.9,
normal: { y: PLAYER_GROUND_MIN_NORMAL_Y },
}, 0.95)).toBe(true);
expect(isWalkableGroundHit({ timeOfImpact: 0.96, normal: { y: 1 } }, 0.95)).toBe(false);
expect(isWalkableGroundHit({ timeOfImpact: 0.4, normal: { y: 0.2 } }, 0.95)).toBe(false);
});
it("buffers a press shortly before landing", () => {
const timing = createPlayerJumpTiming();
bufferPlayerJump(timing, 1_000);
expect(updatePlayerJumpTiming(timing, 1_000 + PLAYER_JUMP_BUFFER_MS - 1, true, -2)).toBe(true);
});
it("allows coyote time after leaving a ledge", () => {
const timing = createPlayerJumpTiming(2_000);
bufferPlayerJump(timing, 2_000 + PLAYER_JUMP_COYOTE_MS - 1);
expect(updatePlayerJumpTiming(
timing,
2_000 + PLAYER_JUMP_COYOTE_MS - 1,
false,
-0.2,
)).toBe(true);
});
it("cancels buffered input while gameplay is blocked", () => {
const timing = createPlayerJumpTiming(2_500);
bufferPlayerJump(timing, 2_500);
cancelBufferedPlayerJump(timing);
expect(updatePlayerJumpTiming(timing, 2_500, true, 0)).toBe(false);
});
it("does not double jump until a real landing", () => {
const timing = createPlayerJumpTiming(3_000);
bufferPlayerJump(timing, 3_000);
expect(updatePlayerJumpTiming(timing, 3_000, true, 0)).toBe(true);
bufferPlayerJump(timing, 3_040);
expect(updatePlayerJumpTiming(timing, 3_040, true, 6.2)).toBe(false);
expect(updatePlayerJumpTiming(timing, 3_300, false, -2)).toBe(false);
bufferPlayerJump(timing, 3_550);
expect(updatePlayerJumpTiming(timing, 3_600, true, -1)).toBe(true);
});
it("reports rising, falling, landing, and grounded presentation phases", () => {
expect(characterVerticalMotion(false, 2, 100, 0)).toBe("rising");
expect(characterVerticalMotion(false, -0.1, 100, 0)).toBe("falling");
expect(characterVerticalMotion(true, 0, 100, 150)).toBe("landing");
expect(characterVerticalMotion(true, 0, 150, 150)).toBe("grounded");
});
});
+94
View File
@@ -0,0 +1,94 @@
export const PLAYER_JUMP_VELOCITY = 7.2;
export const PLAYER_JUMP_COYOTE_MS = 100;
export const PLAYER_JUMP_BUFFER_MS = 120;
export const PLAYER_JUMP_LANDING_UNLOCK_MS = 80;
export const PLAYER_LANDING_PRESENTATION_MS = 180;
export const PLAYER_GROUND_PROBE_DISTANCE = 0.14;
export const PLAYER_GROUND_MIN_NORMAL_Y = 0.55;
export type CharacterVerticalMotion = "grounded" | "rising" | "falling" | "landing";
export interface PlayerJumpTiming {
lastGroundedAtMs: number;
bufferedUntilMs: number;
jumpedAtMs: number;
lockedUntilLanding: boolean;
}
export interface GroundProbeHit {
readonly timeOfImpact: number;
readonly normal: { readonly y: number };
}
export function createPlayerJumpTiming(nowMs = Number.NEGATIVE_INFINITY): PlayerJumpTiming {
return {
lastGroundedAtMs: nowMs,
bufferedUntilMs: Number.NEGATIVE_INFINITY,
jumpedAtMs: Number.NEGATIVE_INFINITY,
lockedUntilLanding: false,
};
}
export function bufferPlayerJump(timing: PlayerJumpTiming, requestedAtMs: number): void {
if (!Number.isFinite(requestedAtMs)) return;
timing.bufferedUntilMs = requestedAtMs + PLAYER_JUMP_BUFFER_MS;
}
export function cancelBufferedPlayerJump(timing: PlayerJumpTiming): void {
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
}
export function isWalkableGroundHit(
hit: GroundProbeHit | null | undefined,
maximumTimeOfImpact: number,
): boolean {
return Boolean(
hit
&& Number.isFinite(hit.timeOfImpact)
&& hit.timeOfImpact >= 0
&& hit.timeOfImpact <= maximumTimeOfImpact
&& Number.isFinite(hit.normal.y)
&& hit.normal.y >= PLAYER_GROUND_MIN_NORMAL_Y,
);
}
/**
* Updates the timing latch and consumes a buffered jump exactly once. The
* upward-velocity guard prevents the ground probe from unlocking the latch on
* the first few frames after launch while the capsule is still near the floor.
*/
export function updatePlayerJumpTiming(
timing: PlayerJumpTiming,
nowMs: number,
grounded: boolean,
verticalVelocity: number,
): boolean {
if (!Number.isFinite(nowMs)) return false;
if (grounded && verticalVelocity <= 0.5) {
timing.lastGroundedAtMs = nowMs;
if (
timing.lockedUntilLanding
&& nowMs - timing.jumpedAtMs >= PLAYER_JUMP_LANDING_UNLOCK_MS
) timing.lockedUntilLanding = false;
}
const buffered = timing.bufferedUntilMs >= nowMs;
const insideCoyoteWindow = nowMs - timing.lastGroundedAtMs <= PLAYER_JUMP_COYOTE_MS;
if (timing.lockedUntilLanding || !buffered || !insideCoyoteWindow) return false;
timing.lockedUntilLanding = true;
timing.jumpedAtMs = nowMs;
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
return true;
}
export function characterVerticalMotion(
grounded: boolean,
verticalVelocity: number,
nowMs: number,
landingUntilMs: number,
): CharacterVerticalMotion {
if (grounded) return nowMs < landingUntilMs ? "landing" : "grounded";
return verticalVelocity > 0.15 ? "rising" : "falling";
}
+2 -2
View File
@@ -147,7 +147,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
}
}
}
}, 30_000);
}, 60_000);
it("ships every configured directly-convertible actor as native except documented pose/proxy cases", async () => {
const recipes = await Promise.all(
@@ -305,7 +305,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
poseOnly: 1,
proxy: 0,
});
}, 30_000);
}, 60_000);
it("packages every Pasper actor with a skin and original combat/locomotion clips", async () => {
const pasper = DUNGEON_DEFINITIONS.find((definition) => definition.id === "paspers-shrine");
+31
View File
@@ -57,12 +57,43 @@ describe("game store", () => {
expect(state.areaName).toContain("Entrance");
});
it("keeps a grounded navigation anchor while the physical player is airborne", () => {
useGameStore.getState().updatePlayerSnapshot({
position: [164, -73.66, 132],
yaw: -2,
grounded: true,
});
useGameStore.getState().updatePlayerSnapshot({
position: [166, -72.2, 133],
yaw: -2,
grounded: false,
});
expect(useGameStore.getState()).toMatchObject({
playerPosition: [166, -72.2, 133],
playerNavigationPosition: [164, -73.66, 132],
playerGrounded: false,
});
useGameStore.getState().updatePlayerSnapshot({
position: [167, -73.66, 134],
yaw: -2,
grounded: true,
});
expect(useGameStore.getState()).toMatchObject({
playerNavigationPosition: [167, -73.66, 134],
playerGrounded: true,
});
});
it("reset restores the authoritative transformed entrance", () => {
useGameStore.getState().setActionLayer("tertiary");
useGameStore.getState().updatePlayerSnapshot({ position: [0, 0, 0], yaw: 0, distanceDelta: 8 });
useGameStore.getState().resetAtEntrance();
const state = useGameStore.getState();
expect(state.playerPosition).toEqual(DUNGEON_MANIFEST.entrance.footPosition);
expect(state.playerNavigationPosition).toEqual(DUNGEON_MANIFEST.entrance.footPosition);
expect(state.playerGrounded).toBe(true);
expect(state.cameraYaw).toBe(DUNGEON_MANIFEST.entrance.yaw);
expect(state.distanceTravelled).toBe(0);
expect(state.actionLayer).toBe("primary");
+14 -1
View File
@@ -13,6 +13,7 @@ interface PlayerSnapshot {
position: Vector3Tuple;
yaw: number;
distanceDelta?: number;
grounded?: boolean;
}
export interface GameState {
@@ -31,6 +32,8 @@ export interface GameState {
assetStatus: AssetStatus;
assetMessage: string | null;
playerPosition: Vector3Tuple;
playerNavigationPosition: Vector3Tuple;
playerGrounded: boolean;
cameraYaw: number;
areaName: string;
distanceTravelled: number;
@@ -77,6 +80,8 @@ export const useGameStore = create<GameState>((set) => ({
assetStatus: "checking",
assetMessage: null,
playerPosition: entrance.footPosition,
playerNavigationPosition: entrance.footPosition,
playerGrounded: true,
cameraYaw: entrance.yaw,
areaName: resolveAreaNameForDungeon(initialDungeon, entrance.footPosition),
distanceTravelled: 0,
@@ -106,6 +111,8 @@ export const useGameStore = create<GameState>((set) => ({
assetStatus: "checking",
assetMessage: null,
playerPosition: activeSpawn.footPosition,
playerNavigationPosition: activeSpawn.footPosition,
playerGrounded: true,
cameraYaw: activeSpawn.yaw,
areaName: resolveAreaNameForDungeon(definition, activeSpawn.footPosition),
distanceTravelled: 0,
@@ -175,10 +182,12 @@ export const useGameStore = create<GameState>((set) => ({
if (isStaleLoadingWrite) return state;
return { assetStatus, assetMessage };
}),
updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0 }) => set((state) => {
updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0, grounded = true }) => set((state) => {
const definition = requireDungeonDefinition(state.activeDungeonId);
return {
playerPosition: position,
playerNavigationPosition: grounded ? position : state.playerNavigationPosition,
playerGrounded: grounded,
cameraYaw: yaw,
areaName: resolveAreaNameForDungeon(definition, position),
distanceTravelled: state.distanceTravelled + Math.max(0, distanceDelta),
@@ -193,6 +202,8 @@ export const useGameStore = create<GameState>((set) => ({
mapOpen: false,
cameraLookActive: false,
playerPosition: state.activeSpawn.footPosition,
playerNavigationPosition: state.activeSpawn.footPosition,
playerGrounded: true,
cameraYaw: state.activeSpawn.yaw,
areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition),
distanceTravelled: 0,
@@ -213,6 +224,8 @@ export const useGameStore = create<GameState>((set) => ({
mapOpen: false,
cameraLookActive: false,
playerPosition: activeEntrance.footPosition,
playerNavigationPosition: activeEntrance.footPosition,
playerGrounded: true,
cameraYaw: activeEntrance.yaw,
areaName: resolveAreaNameForDungeon(definition, activeEntrance.footPosition),
distanceTravelled: 0,
+20
View File
@@ -77,6 +77,26 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(shield.effects.some((effect) => effect.kind === "shield")).toBe(false);
});
it("keeps periodic damage and healing auras out of the generic HUD strip", () => {
const pain = byId("wow335-priest-shadow-word-pain");
const painAura = effectsOfKind(pain, "apply-aura")
.find((effect) => effect.sourceAuraType === 3);
const renew = byId("wow335-priest-renew");
const renewAura = effectsOfKind(renew, "apply-aura")
.find((effect) => effect.sourceAuraType === 8);
expect(painAura?.aura).toMatchObject({
disposition: "debuff",
hideFromAuraStrip: true,
});
expect(renewAura?.aura).toMatchObject({
disposition: "buff",
hideFromAuraStrip: true,
});
expect(effectsOfKind(pain, "dot")).toHaveLength(1);
expect(effectsOfKind(renew, "hot")).toHaveLength(1);
});
it("maps cleanse, purge, and dual-purpose dispel categories", () => {
expect(effectsOfKind(byId("wow335-paladin-cleanse"), "dispel")).toEqual(expect.arrayContaining([
expect.objectContaining({ mode: "cleanse", relationship: "friendly", categories: ["magic"] }),
+3
View File
@@ -367,6 +367,9 @@ function namedAuraForEffect(
id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`,
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
? { hideFromAuraStrip: true }
: {}),
durationMs,
maxStacks: 1,
stackBehavior: "refresh",
+268 -77
View File
@@ -31,14 +31,21 @@ import {
createPartyBreadcrumbCursor,
createPartyBreadcrumbTrail,
nextPartyBreadcrumbFollowWaypoint,
partyCombatMovementGoal,
partyFormationPosition,
partyMadeStuckProgress,
partyMovementProfile,
partyNaturalFollowPosition,
partyPersonalizedCombatMovementGoal,
partyPresentationY,
partySeparationGoal,
partyShouldRecallForStuck,
partyShouldMoveToComfortTarget,
personalizedPartyCombatRangeBand,
resetPartyBreadcrumbTrail,
stepPartyPosition,
synchronizePartyBreadcrumbCursor,
type PartyBreadcrumbCursor,
type PartyMovementProfile,
} from "../game/partyMovement";
import {
buildPartyNavigationGraph,
@@ -82,13 +89,7 @@ const PARTY_MAX_FLOOR_DELTA = 0.55;
const PARTY_MAX_GROUND_CORRECTION = 0.85;
const EMPTY_NAVIGATION_LINKS = [] as const;
/** Lateral and trailing offsets behind the current squad anchor. */
const PARTY_FORMATION: readonly (readonly [number, number])[] = [
[0, 1.45],
[0, 2.6],
[0, 3.75],
[0, 4.9],
];
const PARTY_PLAYER_TRAILING_DISTANCE: readonly number[] = [1.45, 2.6, 3.75, 4.9];
const PARTY_ENTRANCE_FORWARD_DISTANCE: readonly number[] = [2.4, 1.8, 1.2, 0.6];
const PARTY_TRAIL_DISTANCE: readonly number[] = [0, 1.15, 2.3, 3.45];
@@ -106,6 +107,11 @@ interface PartyStuckProgress {
lastProgressAt: number;
}
interface PartyCombatAnchor {
readonly targetId: string;
readonly bearing: number;
}
type PartyAvatarMetricKey = `${RaceId}:${GenderId}`;
/** Bind-pose bounds measured from the shipped wow335a rigs, in local meters. */
@@ -418,6 +424,9 @@ function PartyActors({
const lastCommandRef = useRef(usePartyStore.getState().command);
const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision);
const stuckProgressRef = useRef(new Map<string, PartyStuckProgress>());
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
const directFollowMovingRef = useRef(new Map<string, boolean>());
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
const memberIdKey = members.map((member) => member.id).join("|");
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
const party = usePartyStore.getState();
@@ -448,6 +457,8 @@ function PartyActors({
trailLeaderIdRef.current = firstLiving?.id ?? null;
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
followerCursorsRef.current.clear();
lastActiveTargetIdRef.current = party.activeMobId;
lastLeaderWaypointRef.current = null;
@@ -473,6 +484,17 @@ function PartyActors({
for (const id of stuckProgressRef.current.keys()) {
if (!activeIds.has(id)) stuckProgressRef.current.delete(id);
}
for (const id of directFollowMovingRef.current.keys()) {
if (!activeIds.has(id)) directFollowMovingRef.current.delete(id);
}
for (const id of combatAnchorsRef.current.keys()) {
if (!activeIds.has(id)) combatAnchorsRef.current.delete(id);
}
for (const key of movementProfilesRef.current.keys()) {
if (![...activeIds].some((id) => key.startsWith(`${id}|`))) {
movementProfilesRef.current.delete(key);
}
}
const cleanups = members.map((member, index) => {
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
positionsRef.current.set(member.id, position);
@@ -534,6 +556,8 @@ function PartyActors({
trailLeaderIdRef.current = firstLiving?.id ?? null;
followerCursorsRef.current.clear();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
lastActiveTargetIdRef.current = null;
lastLeaderWaypointRef.current = null;
routeRef.current = emptyPartyRouteState();
@@ -561,6 +585,8 @@ function PartyActors({
if (leaderPosition) resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition);
followerCursorsRef.current.clear();
stuckProgressRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
}, [navigationGraph]);
// Time spent in a pause/menu must not count toward the stuck watchdog.
@@ -575,24 +601,39 @@ function PartyActors({
const party = usePartyStore.getState();
if (!party.members.length) return;
const game = useGameStore.getState();
const player = game.playerPosition;
appendPartyBreadcrumb(playerTrailRef.current, player);
const playerNavigation = game.playerNavigationPosition;
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
const routeNow = clock.elapsedTime * 1_000;
const playerTrail = playerTrailRef.current.points;
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
let playerForwardX = Math.sin(game.cameraYaw);
let playerForwardZ = Math.cos(game.cameraYaw);
if (latestPlayerTrailPoint && previousPlayerTrailPoint) {
const trailX = latestPlayerTrailPoint.position[0] - previousPlayerTrailPoint.position[0];
const trailZ = latestPlayerTrailPoint.position[2] - previousPlayerTrailPoint.position[2];
if (Math.hypot(trailX, trailZ) > 0.05) {
playerForwardX = trailX;
playerForwardZ = trailZ;
}
}
if (lastRecallRevisionRef.current !== party.recallRevision) {
lastRecallRevisionRef.current = party.recallRevision;
teleportPartyToPlayer(player, routeNow);
teleportPartyToPlayer(playerNavigation, routeNow);
return;
}
if (lastCommandRef.current !== party.command) {
lastCommandRef.current = party.command;
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
directFollowMovingRef.current.clear();
combatAnchorsRef.current.clear();
followerCursorsRef.current.clear();
const livingLeader = party.members.find((member) => member.health > 0);
const livingLeaderPosition = livingLeader ? positionsRef.current.get(livingLeader.id) : null;
resetPartyBreadcrumbTrail(
leaderTrailRef.current,
livingLeaderPosition ?? player,
livingLeaderPosition ?? playerNavigation,
);
lastLeaderWaypointRef.current = null;
}
@@ -602,9 +643,29 @@ function PartyActors({
const desired: MutablePartyWorldPosition = [0, 0, 0];
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0];
const separatedDesired: MutablePartyWorldPosition = [0, 0, 0];
const livingIndices = party.members
.map((member, index) => member.health > 0 ? index : -1)
.filter((index) => index >= 0);
const livingIds = new Set(livingIndices.map((index) => party.members[index].id));
const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => {
const livingRank = Math.max(0, livingIndices.indexOf(memberIndex));
const key = `${memberId}|${livingRank}`;
let profile = movementProfilesRef.current.get(key);
if (!profile) {
profile = partyMovementProfile(memberId, livingRank);
movementProfilesRef.current.set(key, profile);
}
return profile;
};
const peerPositionsFor = (memberId: string): PartyWorldPosition[] => {
const peers: PartyWorldPosition[] = [];
for (const [id, position] of positionsRef.current) {
if (id !== memberId && livingIds.has(id)) peers.push(position);
}
return peers;
};
let leaderHasWaypoint = false;
let leaderMovementExpected = false;
let leaderFollowingPlayer = false;
@@ -623,7 +684,7 @@ function PartyActors({
routeRef.current = emptyPartyRouteState();
combatRoutesRef.current.clear();
followerCursorsRef.current.clear();
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? player);
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? playerNavigation);
lastLeaderWaypointRef.current = null;
}
if (leaderMember && leaderPosition && trailLeaderIdRef.current !== leaderMember.id) {
@@ -631,6 +692,7 @@ function PartyActors({
trailLeaderIdRef.current = leaderMember.id;
followerCursorsRef.current.clear();
combatRoutesRef.current.clear();
combatAnchorsRef.current.clear();
lastLeaderWaypointRef.current = null;
routeRef.current = emptyPartyRouteState();
}
@@ -640,18 +702,32 @@ function PartyActors({
let goalKey: string;
let hasDestination = true;
if (targetPosition && leaderTargetId) {
const rangeBand = partyCombatRangeBand(leader);
const actor = actorRefs.current[leaderIndex];
const movementIntent = partyCombatMovementGoal(
const profile = movementProfileFor(leader.id, leaderIndex);
const rangeBand = personalizedPartyCombatRangeBand(
partyCombatRangeBand(leader),
profile,
);
let combatAnchor = combatAnchorsRef.current.get(leader.id);
if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) {
combatAnchor = {
targetId: leaderTargetId,
bearing: Math.atan2(
leaderPosition[0] - targetPosition[0],
leaderPosition[2] - targetPosition[2],
) + profile.combatAngleOffset,
};
combatAnchorsRef.current.set(leader.id, combatAnchor);
}
const peers = peerPositionsFor(leader.id);
const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal(
leaderPosition,
targetPosition,
hasStaticLineOfSight(leaderPosition, targetPosition),
rangeBand.minimum,
rangeBand.preferred,
rangeBand.maximum,
rangeBand,
combatAnchor.bearing,
crowded,
leaderDestination,
actor ? Math.sin(actor.rotation.y) : 0,
actor ? Math.cos(actor.rotation.y) : 1,
);
if (movementIntent === "hold") {
hasDestination = false;
@@ -677,10 +753,9 @@ function PartyActors({
goalKey = `leader:${leader.id}|objective:${objective.id}`;
} else {
leaderFollowingPlayer = true;
leaderDestination[0] = player[0];
leaderDestination[1] = player[1];
leaderDestination[2] = player[2];
if (fullDistance(leaderPosition, player) <= PARTY_FORMATION[0][1]) hasDestination = false;
leaderDestination[0] = playerNavigation[0];
leaderDestination[1] = playerNavigation[1];
leaderDestination[2] = playerNavigation[2];
goalKey = `leader:${leader.id}|player`;
}
leaderMovementExpected = hasDestination
@@ -689,28 +764,68 @@ function PartyActors({
if (leaderFollowingPlayer) {
routeRef.current = { ...emptyPartyRouteState(), goalKey };
const cursor = leaderPlayerCursorRef.current;
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId);
leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint(
playerTrailRef.current,
cursor,
leaderPosition,
PARTY_FORMATION[0][1],
BREADCRUMB_REACHED_DISTANCE,
desired,
const profile = movementProfileFor(leader.id, leaderIndex);
const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0];
const personalizedTrailingDistance = Math.max(
0.9,
baseTrailingDistance + profile.followDistanceJitter,
);
if (
leaderHasWaypoint
&& requiresValidatedRejoin
&& !directSegmentAllowed(leaderPosition, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
leaderHasWaypoint = false;
leaderPlayerTrailBlocked = true;
partyNaturalFollowPosition(
playerNavigation,
playerForwardX,
playerForwardZ,
baseTrailingDistance,
profile,
routeNow,
leaderDestination,
);
if (directSegmentAllowed(leaderPosition, leaderDestination)) {
const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false;
leaderHasWaypoint = partyShouldMoveToComfortTarget(
leaderPosition,
leaderDestination,
wasMoving,
profile.comfortSlack,
);
directFollowMovingRef.current.set(leader.id, leaderHasWaypoint);
synchronizePartyBreadcrumbCursor(
playerTrailRef.current,
cursor,
personalizedTrailingDistance,
);
if (leaderHasWaypoint) {
desired[0] = leaderDestination[0];
desired[1] = leaderDestination[1];
desired[2] = leaderDestination[2];
}
leaderMovementExpected = leaderHasWaypoint;
} else {
directFollowMovingRef.current.set(leader.id, false);
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId);
leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint(
playerTrailRef.current,
cursor,
leaderPosition,
personalizedTrailingDistance,
BREADCRUMB_REACHED_DISTANCE,
desired,
profile.comfortSlack,
);
if (
leaderHasWaypoint
&& requiresValidatedRejoin
&& !directSegmentAllowed(leaderPosition, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
leaderHasWaypoint = false;
leaderPlayerTrailBlocked = true;
}
leaderMovementExpected = leaderHasWaypoint
|| planarDistance(leaderPosition, playerNavigation)
> personalizedTrailingDistance + profile.comfortSlack;
}
leaderMovementExpected = leaderHasWaypoint
|| planarDistance(leaderPosition, player) > PARTY_FORMATION[0][1] + 0.75;
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
if (
routeRef.current.goalKey !== goalKey
@@ -860,17 +975,32 @@ function PartyActors({
}
} else {
if (memberTargetPosition && memberTargetId) {
const rangeBand = partyCombatRangeBand(member);
const movementIntent = partyCombatMovementGoal(
const profile = movementProfileFor(member.id, index);
const rangeBand = personalizedPartyCombatRangeBand(
partyCombatRangeBand(member),
profile,
);
let combatAnchor = combatAnchorsRef.current.get(member.id);
if (!combatAnchor || combatAnchor.targetId !== memberTargetId) {
combatAnchor = {
targetId: memberTargetId,
bearing: Math.atan2(
current[0] - memberTargetPosition[0],
current[2] - memberTargetPosition[2],
) + profile.combatAngleOffset,
};
combatAnchorsRef.current.set(member.id, combatAnchor);
}
const peers = peerPositionsFor(member.id);
const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85);
const movementIntent = partyPersonalizedCombatMovementGoal(
current,
memberTargetPosition,
canSeeActiveTarget,
rangeBand.minimum,
rangeBand.preferred,
rangeBand.maximum,
rangeBand,
combatAnchor.bearing,
crowded,
desired,
Math.sin(actor.rotation.y),
Math.cos(actor.rotation.y),
);
movementExpected = movementIntent !== "hold";
if (movementIntent === "hold") {
@@ -933,30 +1063,71 @@ function PartyActors({
cursor = createPartyBreadcrumbCursor();
followerCursorsRef.current.set(member.id, cursor);
}
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
const livingRank = Math.max(1, livingIndices.indexOf(index));
const trailingDistance = PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15;
hasDesired = nextPartyBreadcrumbFollowWaypoint(
leaderTrailRef.current,
cursor,
current,
trailingDistance,
BREADCRUMB_REACHED_DISTANCE,
desired,
const profile = movementProfileFor(member.id, index);
const playerTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[livingRank]
?? 1.45 + livingRank * 1.15;
const leaderTrailingDistance = Math.max(
0.9,
(PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15)
+ profile.followDistanceJitter,
);
if (
hasDesired
&& requiresValidatedRejoin
&& !directSegmentAllowed(current, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
hasDesired = false;
frameRouteBlocked = true;
partyNaturalFollowPosition(
playerNavigation,
playerForwardX,
playerForwardZ,
playerTrailingDistance,
profile,
routeNow,
naturalFollowTarget,
);
if (directSegmentAllowed(current, naturalFollowTarget)) {
const wasMoving = directFollowMovingRef.current.get(member.id) ?? false;
hasDesired = partyShouldMoveToComfortTarget(
current,
naturalFollowTarget,
wasMoving,
profile.comfortSlack,
);
directFollowMovingRef.current.set(member.id, hasDesired);
synchronizePartyBreadcrumbCursor(
leaderTrailRef.current,
cursor,
leaderTrailingDistance,
);
if (hasDesired) {
desired[0] = naturalFollowTarget[0];
desired[1] = naturalFollowTarget[1];
desired[2] = naturalFollowTarget[2];
}
movementExpected = hasDesired;
} else {
directFollowMovingRef.current.set(member.id, false);
const previousBreadcrumbId = cursor.breadcrumbId;
const requiresValidatedRejoin = cursor.breadcrumbId === null
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
hasDesired = nextPartyBreadcrumbFollowWaypoint(
leaderTrailRef.current,
cursor,
current,
leaderTrailingDistance,
BREADCRUMB_REACHED_DISTANCE,
desired,
profile.comfortSlack,
);
if (
hasDesired
&& requiresValidatedRejoin
&& !directSegmentAllowed(current, desired)
) {
cursor.breadcrumbId = previousBreadcrumbId;
hasDesired = false;
frameRouteBlocked = true;
}
movementExpected = hasDesired
|| planarDistance(current, leaderPosition ?? playerNavigation)
> leaderTrailingDistance + profile.comfortSlack;
}
movementExpected = hasDesired
|| planarDistance(current, leaderPosition ?? player) > trailingDistance + 0.75;
}
if (memberTargetPosition) {
faceX = memberTargetPosition[0] - current[0];
@@ -967,9 +1138,29 @@ function PartyActors({
}
}
const currentPlanarDistance = planarDistance(current, player);
const movementProfile = movementProfileFor(member.id, index);
if (hasDesired) {
const peers = peerPositionsFor(member.id);
if (
partySeparationGoal(
current,
desired,
peers,
movementProfile.swayPhase,
separatedDesired,
)
&& directSegmentAllowed(current, separatedDesired)
) {
desired[0] = separatedDesired[0];
desired[1] = separatedDesired[1];
desired[2] = separatedDesired[2];
}
}
const currentPlanarDistance = planarDistance(current, playerNavigation);
if (member.health > 0 && party.command !== "stop" && hasDesired) {
const speed = currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED;
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
* movementProfile.speedMultiplier;
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
// Route progress, not radial player distance, is authoritative. A
// valid path may need to move away briefly around a U-bend.
@@ -1015,7 +1206,7 @@ function PartyActors({
stuckProgress.lastProgressAt = routeNow;
} else if (partyShouldRecallForStuck(
current,
player,
playerNavigation,
movementExpected,
routeNow,
stuckProgress.lastProgressAt,
@@ -1042,7 +1233,7 @@ function PartyActors({
if (autoRecallRequested) {
party.recallParty();
lastRecallRevisionRef.current = usePartyStore.getState().recallRevision;
teleportPartyToPlayer(player, routeNow);
teleportPartyToPlayer(playerNavigation, routeNow);
return;
}
party.setRouteBlocked(frameRouteBlocked);
+90 -9
View File
@@ -13,7 +13,12 @@ import { useShellStore } from "../app/shellStore";
import type { CharacterProfile } from "../app/types";
import { CharacterModel } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
import { consumeMouseLook, readInputSnapshot } from "../game/inputManager";
import {
clearJumpRequest,
consumeJumpRequest,
consumeMouseLook,
readInputSnapshot,
} from "../game/inputManager";
import { useCombatStore } from "../game/combatStore";
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
import {
@@ -33,6 +38,18 @@ import {
isBelowDungeonRecoveryPlane,
isBelowManastormRecoveryPlane,
} from "../game/playerRecovery";
import {
PLAYER_GROUND_PROBE_DISTANCE,
PLAYER_JUMP_VELOCITY,
PLAYER_LANDING_PRESENTATION_MS,
bufferPlayerJump,
cancelBufferedPlayerJump,
characterVerticalMotion,
createPlayerJumpTiming,
isWalkableGroundHit,
updatePlayerJumpTiming,
type CharacterVerticalMotion,
} from "../game/playerJump";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store";
@@ -62,6 +79,7 @@ interface PlayerControllerProps extends PlayerRigRefs {
identity: CharacterProfile | null;
equipmentItems: readonly InventoryItem[];
movingRef: React.MutableRefObject<boolean>;
verticalMotionRef: React.MutableRefObject<CharacterVerticalMotion>;
animationEventRef: React.MutableRefObject<CharacterCombatAnimationEvent | null>;
}
@@ -85,6 +103,7 @@ function PlayerController({
identity,
equipmentItems,
movingRef,
verticalMotionRef,
animationEventRef,
}: PlayerControllerProps) {
const paused = useGameStore((state) => state.paused);
@@ -98,11 +117,24 @@ function PlayerController({
const spawn = useGameStore((state) => state.activeSpawn);
const dungeon = requireDungeonDefinition(activeDungeonId);
const lastReported = useRef<[number, number, number]>([...spawn.footPosition]);
const { rapier, world } = useRapier();
const groundRay = useMemo(
() => new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }),
[rapier],
);
const jumpTimingRef = useRef(createPlayerJumpTiming());
const wasGroundedRef = useRef(true);
const landingUntilRef = useRef(Number.NEGATIVE_INFINITY);
useEffect(() => {
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
lastReported.current = [...spawn.footPosition];
}, [bodyRef, orbitRef, resetRevision, spawn]);
clearJumpRequest();
jumpTimingRef.current = createPlayerJumpTiming(performance.now());
wasGroundedRef.current = true;
landingUntilRef.current = Number.NEGATIVE_INFINITY;
verticalMotionRef.current = "grounded";
}, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]);
useFrame((_, delta) => {
const body = bodyRef.current;
@@ -131,18 +163,68 @@ function PlayerController({
}
const velocity = body.linvel();
if (paused || mapOpen || companionOpen || controlledUntil > Date.now()) {
const translation = body.translation();
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
placeAtActiveSpawn(body, orbit);
return;
}
const nowMs = performance.now();
groundRay.origin.x = translation.x;
groundRay.origin.y = translation.y;
groundRay.origin.z = translation.z;
const groundProbeLength = PLAYER_CENTER_HEIGHT + PLAYER_GROUND_PROBE_DISTANCE;
const groundHit = world.castRayAndGetNormal(
groundRay,
groundProbeLength,
true,
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
undefined,
undefined,
body,
);
let grounded = velocity.y <= 0.5 && isWalkableGroundHit(groundHit, groundProbeLength);
const requestedAtMs = consumeJumpRequest();
const gameplayBlocked = paused || mapOpen || companionOpen || controlledUntil > Date.now();
if (gameplayBlocked) {
cancelBufferedPlayerJump(jumpTimingRef.current);
movingRef.current = false;
verticalMotionRef.current = characterVerticalMotion(
grounded,
velocity.y,
nowMs,
landingUntilRef.current,
);
body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true);
return;
}
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
const shouldJump = updatePlayerJumpTiming(
jumpTimingRef.current,
nowMs,
grounded,
velocity.y,
);
const verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : velocity.y;
if (shouldJump) grounded = false;
if (grounded && !wasGroundedRef.current) {
landingUntilRef.current = nowMs + PLAYER_LANDING_PRESENTATION_MS;
}
wasGroundedRef.current = grounded;
verticalMotionRef.current = characterVerticalMotion(
grounded,
verticalVelocity,
nowMs,
landingUntilRef.current,
);
const [worldX, worldZ] = cameraRelativeMovement(
snapshot.moveX,
snapshot.moveForward,
orbit.yaw,
);
body.setLinvel({ x: worldX * MOVE_SPEED, y: velocity.y, z: worldZ * MOVE_SPEED }, true);
body.setLinvel({ x: worldX * MOVE_SPEED, y: verticalVelocity, z: worldZ * MOVE_SPEED }, true);
const moving = Math.hypot(worldX, worldZ) > 0.05;
movingRef.current = moving;
@@ -152,11 +234,6 @@ function PlayerController({
avatarRef.current.rotation.y += difference * (1 - Math.exp(-14 * delta));
}
const translation = body.translation();
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
placeAtActiveSpawn(body, orbit);
return;
}
const activeManastormPackage = gameMode === "manastorm"
? activeManastormStageAssetPackage()
: null;
@@ -185,6 +262,7 @@ function PlayerController({
position: footPosition,
yaw: orbit.yaw,
distanceDelta,
grounded,
});
lastReported.current = footPosition;
}
@@ -214,6 +292,7 @@ function PlayerController({
equipment={equipmentItems}
active={!paused && !mapOpen && !companionOpen}
movingRef={movingRef}
verticalMotionRef={verticalMotionRef}
animationEventRef={animationEventRef}
fallback={<ProceduralAvatar accent={classById(identity?.classId ?? "priest").color} />}
/>
@@ -331,6 +410,7 @@ export function PlayerRig() {
const bodyRef = useRef<RapierRigidBody>(null);
const avatarRef = useRef<Object3D>(null);
const movingRef = useRef(false);
const verticalMotionRef = useRef<CharacterVerticalMotion>("grounded");
const animationEventRef = useRef<CharacterCombatAnimationEvent | null>(animationEvent);
animationEventRef.current = animationEvent;
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -349,6 +429,7 @@ export function PlayerRig() {
identity={activeCharacter}
equipmentItems={equipmentItems}
movingRef={movingRef}
verticalMotionRef={verticalMotionRef}
animationEventRef={animationEventRef}
/>
<ThirdPersonCamera bodyRef={bodyRef} orbitRef={orbitRef} avatarRef={avatarRef} />
+1
View File
@@ -707,6 +707,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.aura-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; }
.aura { position: relative; display: inline-grid; width: 22px; height: 22px; place-items: center; overflow: hidden; border: 1px solid rgba(255,255,255,.35); border-radius: 3px; color: #fff; background: linear-gradient(145deg, #42669a, #18273e); box-shadow: 0 1px 3px rgba(0,0,0,.7); }
.aura--debuff { border-color: rgba(243,118,105,.75); background: linear-gradient(145deg, #8c3d45, #35191f); }
.aura__icon { width: 100%; height: 100%; object-fit: cover; }
.aura__initial { font: 700 11px/1 var(--font-ui); text-shadow: 0 1px 2px #000; }
.aura__timer { position: absolute; right: 1px; bottom: 0; font: 700 7px/8px var(--font-ui); text-shadow: 0 1px 2px #000; }
.aura__stacks { position: absolute; right: 1px; top: 0; font: 700 8px/8px var(--font-ui); color: #fff4b8; text-shadow: 0 1px 2px #000; }
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import type { AuraDefinition } from "../game/combatAuras";
import { auraDefinitionDisplaysInStrip, auraDefinitionIcon } from "./AuraStrip";
function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
return {
id: "test-aura",
name: "Test Aura",
disposition: "debuff",
durationMs: 10_000,
...overrides,
};
}
describe("aura strip visibility", () => {
it("shows ordinary auras and suppresses periodic shadow auras with dedicated timers", () => {
expect(auraDefinitionDisplaysInStrip(aura())).toBe(true);
expect(auraDefinitionDisplaysInStrip(aura({ hideFromAuraStrip: true }))).toBe(false);
});
it("uses spell artwork while retaining the initial fallback for iconless effects", () => {
expect(auraDefinitionIcon(aura({ icon: " /assets/ui/spells/test.png " }))).toBe(
"/assets/ui/spells/test.png",
);
expect(auraDefinitionIcon(aura())).toBeNull();
});
});
+14 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import type { AuraDisposition } from "../game/combatAuras";
import type { AuraDefinition, AuraDisposition } from "../game/combatAuras";
import { useCombatStore } from "../game/combatStore";
import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
@@ -9,6 +9,14 @@ interface AuraStripProps {
readonly className?: string;
}
export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
return definition.hideFromAuraStrip !== true;
}
export function auraDefinitionIcon(definition: AuraDefinition): string | null {
return definition.icon?.trim() || null;
}
export function AuraStrip({
targetId,
dispositions = ["buff", "debuff"],
@@ -19,6 +27,7 @@ export function AuraStrip({
const auras = allAuras.filter((aura) => (
aura.targetId === targetId
&& dispositions.includes(aura.definition.disposition)
&& auraDefinitionDisplaysInStrip(aura.definition)
&& (aura.expiresAt === null || aura.expiresAt > now)
));
@@ -36,6 +45,7 @@ export function AuraStrip({
const remaining = aura.expiresAt === null ? null : Math.max(0, aura.expiresAt - now);
const timer = remaining === null ? "" : formatTimedEffectTimer(remaining);
const initial = aura.definition.name.trim().charAt(0).toUpperCase() || "*";
const icon = auraDefinitionIcon(aura.definition);
return (
<span
key={aura.instanceId}
@@ -43,7 +53,9 @@ export function AuraStrip({
aria-label={`${aura.definition.name}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
title={`${aura.definition.name}${timer ? ` · ${timer} remaining` : ""}`}
>
<span className="aura__initial" aria-hidden="true">{initial}</span>
{icon
? <img className="aura__icon" src={icon} alt="" draggable={false} />
: <span className="aura__initial" aria-hidden="true">{initial}</span>}
{aura.stacks > 1 ? <b className="aura__stacks">{aura.stacks}</b> : null}
{timer ? <b className="aura__timer" aria-hidden="true">{timer}</b> : null}
</span>
+6 -3
View File
@@ -34,12 +34,15 @@ export function CharacterCreateScreen() {
const race = raceById(draft.raceId);
const characterClass = classById(draft.classId);
const availableRaces = useMemo(() => racesForCategory(draft.categoryId), [draft.categoryId]);
const availableClasses = useMemo(() => classesForRace(draft.raceId), [draft.raceId]);
const availableClasses = useMemo(
() => classesForRace(draft.raceId, draft.categoryId),
[draft.categoryId, draft.raceId],
);
const appearanceLabels = race.labels[draft.gender];
const appearanceCounts = race.counts[draft.gender];
const chooseRace = (raceId: RaceId) => {
const nextClasses = classesForRace(raceId);
const nextClasses = classesForRace(raceId, draft.categoryId);
const nextGenders = supportedGenders(raceId);
const classId = nextClasses.some((definition) => definition.id === draft.classId)
? draft.classId
@@ -154,7 +157,7 @@ export function CharacterCreateScreen() {
</div>
</main>
<section className="creator-classes">
<header><span>02</span><div><strong>Class</strong><small>{availableClasses.length} {draft.categoryId === "rom" ? "RuneWaker" : "WoW / Ascension"} classes available to {race.name}</small></div></header>
<header><span>02</span><div><strong>Class</strong><small>{availableClasses.length} {draft.categoryId === "rom" ? "RuneWaker" : draft.categoryId === "coa" ? "CoA" : "WoW"} classes available to {race.name}</small></div></header>
<div className="class-grid">
{availableClasses.map((definition) => (
<ControllerButton
+5 -3
View File
@@ -1,5 +1,6 @@
import { useMemo, useState, type CSSProperties } from "react";
import { CLASSES, PLAYABLE_RACES, classById, FACTIONS, raceById } from "../app/characterCatalog";
import { CLASSES, PLAYABLE_RACES, classById, contentCategoryForClass, FACTIONS, raceById } from "../app/characterCatalog";
import { contentCategoryById } from "../app/contentCategories";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useMenuController, type MenuAction } from "../input/useMenuController";
@@ -61,6 +62,7 @@ export function CharacterSelectScreen() {
{characters.map((character) => {
const raceDefinition = raceById(character.raceId);
const classDefinition = classById(character.classId);
const categoryDefinition = contentCategoryById(character.categoryId ?? contentCategoryForClass(character.classId));
return (
<ControllerButton
key={character.id}
@@ -72,7 +74,7 @@ export function CharacterSelectScreen() {
onClick={() => select(character.id)}
>
<span style={{ "--portrait-accent": raceDefinition.accent } as CSSProperties}>{raceDefinition.sigil}</span>
<i><strong>{character.name}</strong><small>{character.categoryId === "rom" ? "RoM" : "WoW"} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""}</small></i>
<i><strong>{character.name}</strong><small>{categoryDefinition.label} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""}</small></i>
<em>{formatLastPlayed(character.lastPlayedAt)}</em>
</ControllerButton>
);
@@ -110,7 +112,7 @@ export function CharacterSelectScreen() {
<h2>{selected.location}</h2>
<dl>
<div><dt>Level</dt><dd>{selected.level}</dd></div>
<div><dt>Content</dt><dd>{selected.categoryId === "rom" ? "Runes of Magic" : "World of Warcraft"}</dd></div>
<div><dt>Content</dt><dd>{contentCategoryById(selected.categoryId ?? contentCategoryForClass(selected.classId)).name}</dd></div>
<div><dt>Faction</dt><dd>{FACTIONS[race.faction].name}</dd></div>
<div><dt>Class role</dt><dd>{characterClass.role}</dd></div>
<div><dt>Next step</dt><dd>Choose a game mode</dd></div>
+2 -1
View File
@@ -78,10 +78,11 @@ function PauseDialog() {
</div>
<dl className="controls-list">
<div><dt>Move</dt><dd>WASD / Left stick</dd></div>
<div><dt>Jump</dt><dd>Space / L3</dd></div>
<div><dt>Look</dt><dd>Hold right mouse / Arrow keys / Right stick</dd></div>
<div><dt>Abilities</dt><dd>18 / Face, R1, R2, D-pad left/right</dd></div>
<div><dt>Ability layers</dt><dd>Shift / Alt / Hold L1 / Hold L2</dd></div>
<div><dt>Targets</dt><dd>Tab / R3 enemy · D-pad up/down party/self · L3 clear</dd></div>
<div><dt>Targets</dt><dd>Tab / R3 enemy · D-pad up/down party/self</dd></div>
<div><dt>Party orders</dt><dd>F1 Attack / F2 Defend / F3 Stop / F4 Recall</dd></div>
<div><dt>Spellbook / {skillMenuLabel}</dt><dd>P / N</dd></div>
<div><dt>Inventory / Loot</dt><dd>I / E</dd></div>