Update class mechanics, stat rulesets, and CoA reference catalogs

This commit is contained in:
phenom committed 2026-10-04 12:56:30 -04:00
1 parent b16821e92e
commit 39458d5de6
221 files changed
+2584962 -568

No files matched your search

+79 -6
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { classById, type ClassId } from "../app/characterCatalog";
import { classById, type ClassId, type GenderId, type RaceId } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
import {
EQUIPMENT_SLOTS,
@@ -9,7 +9,11 @@ import {
equipmentSlotForItem,
type EquipmentSlot,
} from "../game/equipment";
import { useCombatStore } from "../game/combatStore";
import {
auraAdjustedCharacterStats,
PLAYER_AURA_ENTITY_ID,
useCombatStore,
} from "../game/combatStore";
import {
itemStatSummary,
weaponDps,
@@ -29,6 +33,7 @@ import {
} from "../game/heirloomEquipment";
import { gearProfileFor } from "../game/specializationCatalog";
import type { PartyRole } from "../game/partyRoles";
import type { CharacterStatRuleset } from "../game/characterStatRuleset";
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = Object.freeze({
head: "Head",
@@ -54,6 +59,9 @@ interface EquipmentOwner {
readonly detail: string;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly raceId: RaceId;
readonly gender: GenderId;
readonly statRuleset: CharacterStatRuleset;
readonly specializationId: string | null;
readonly role: PartyRole;
readonly level: number;
@@ -129,6 +137,41 @@ function itemStatRows(item: InventoryItem): readonly { label: string; value: str
}
function characterRows(stats: CharacterStats, page: StatPage): readonly { label: string; value: string }[] {
const native = stats.romNative;
if (native) {
if (page === "attributes") return [
{ label: "Strength (STR)", value: displayNumber(native.attributes.strength) },
{ label: "Stamina (STA)", value: displayNumber(native.attributes.stamina) },
{ label: "Intellect (INT)", value: displayNumber(native.attributes.intellect) },
{ label: "Wisdom (WIS)", value: displayNumber(native.attributes.wisdom) },
{ label: "Agility (AGI)", value: displayNumber(native.attributes.agility) },
{ label: "Health", value: displayNumber(native.health.max) },
...(native.usesMana ? [{ label: "Mana (MP)", value: displayNumber(native.mana.max) }] : []),
{ label: "Health recovery", value: displayNumber(native.healthRecovery, 1) },
...(native.usesMana ? [{ label: "Mana recovery", value: displayNumber(native.manaRecovery, 1) }] : []),
];
if (page === "offense") return [
{ label: "Physical attack (ATK)", value: displayNumber(native.physicalAttack) },
{ label: "Physical damage (DMG)", value: displayNumber(native.physicalDamage) },
{ label: "Magical attack (MATK)", value: displayNumber(native.magicalAttack) },
{ label: "Magical damage (MDMG)", value: displayNumber(native.magicalDamage) },
{ label: "Magical damage points", value: displayNumber(native.magicalDamagePoint) },
{ label: "Magic power rate", value: `×${native.magicPowerRate.toFixed(3)}` },
{ label: "Physical hit (points)", value: displayNumber(native.physicalHit, 2) },
{ label: "Magical hit (points)", value: displayNumber(native.magicalHit, 2) },
{ label: "Physical critical (points)", value: displayNumber(native.physicalCritical, 2) },
{ label: "Magical critical (points)", value: displayNumber(native.magicalCritical, 2) },
{ label: "Healing power rate", value: `×${native.healing.healPowerRate.toFixed(3)}` },
{ label: "Healing points", value: displayNumber(native.healing.healPoint) },
];
return [
{ label: "Physical defense (DEF)", value: `${displayNumber(native.physicalDefense)} · ${stats.armorReductionPercent.toFixed(2)}% shared armor rule` },
{ label: "Magical defense (MDEF)", value: displayNumber(native.magicalDefense) },
{ label: "Physical dodge (points)", value: displayNumber(native.physicalDodge, 2) },
{ label: "Magical dodge (points)", value: displayNumber(native.magicalDodge, 2) },
{ label: "Parry (points)", value: displayNumber(native.parry, 2) },
];
}
if (page === "attributes") {
return [
{ label: "Strength", value: displayNumber(stats.strength) },
@@ -190,10 +233,14 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
const inventory = useCombatStore((state) => state.inventory);
const equipment = useCombatStore((state) => state.equipment);
const playerClassId = useCombatStore((state) => state.classId);
const playerStatRuleset = useCombatStore((state) => state.statRuleset);
const playerRaceId = useCombatStore((state) => state.raceId);
const playerGender = useCombatStore((state) => state.gender);
const playerSecondaryClassId = useCombatStore((state) => state.secondaryClassId);
const playerLevel = useCombatStore((state) => state.level);
const playerMaxHealth = useCombatStore((state) => state.maxHealth);
const playerGearStats = useCombatStore((state) => state.gearStats);
const activeAuras = useCombatStore((state) => state.auras);
const playerSpecializationId = useCombatStore((state) => state.specializationId);
const playerGearRole = useCombatStore((state) => state.gearRole);
const feedback = useCombatStore((state) => state.feedback);
@@ -207,6 +254,9 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
detail: "Player",
classId: playerClassId,
secondaryClassId: playerSecondaryClassId,
raceId: playerRaceId,
gender: playerGender,
statRuleset: playerStatRuleset,
specializationId: playerSpecializationId,
role: playerGearRole,
level: playerLevel,
@@ -218,12 +268,15 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
detail: `${member.specialization} · ${member.role === "damage" ? "DPS" : member.role}`,
classId: member.classId,
secondaryClassId: member.secondaryClassId,
raceId: member.raceId,
gender: member.gender,
statRuleset: member.statRuleset ?? "wow",
specializationId: member.specializationId,
role: member.role,
level: member.level,
ownerKind: "party" as const,
})),
], [character?.name, members, playerClassId, playerSecondaryClassId, playerGearRole, playerLevel, playerSpecializationId]);
], [character?.name, members, playerClassId, playerSecondaryClassId, playerRaceId, playerGender, playerStatRuleset, playerGearRole, playerLevel, playerSpecializationId]);
const [ownerId, setOwnerId] = useState(PLAYER_EQUIPMENT_OWNER_ID);
const [inspectedId, setInspectedId] = useState(() => inventory.at(-1)?.instanceId ?? "");
const [statPage, setStatPage] = useState<StatPage>("attributes");
@@ -300,8 +353,23 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
const ownerLevel = selectedMember?.level ?? playerLevel;
const ownerMaxHealth = selectedMember?.maxHealth ?? playerMaxHealth;
const ownerGearStats = selectedMember?.gearStats ?? playerGearStats;
const ownerRaceId = selectedMember?.raceId ?? character?.raceId ?? "human";
const ownerStats = deriveCharacterStats(owner.classId, ownerLevel, ownerGearStats, ownerRaceId);
const ownerBaseStats = deriveCharacterStats(
owner.classId,
ownerLevel,
ownerGearStats,
owner.raceId,
{},
owner.secondaryClassId,
owner.gender,
owner.statRuleset,
);
const ownerStats = ownerBaseStats.romNative
? auraAdjustedCharacterStats(
ownerBaseStats,
activeAuras,
owner.ownerKind === "player" ? PLAYER_AURA_ENTITY_ID : owner.id,
)
: ownerBaseStats;
const visibleCharacterRows = characterRows(ownerStats, statPage);
const inspectedStatRows = inspected ? itemStatRows(inspected) : [];
@@ -378,6 +446,9 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
>{page}</ControllerButton>
))}
</div>
{owner.statRuleset === "rom" && (
<p className="equipment-character-stats__note">Native RoM stats · shared dungeon combat rules</p>
)}
<dl className="equipment-character-stats" aria-label={`${owner.name} ${statPage} stats`}>
{visibleCharacterRows.map((row) => (
<div key={row.label}><dt>{row.label}</dt><dd>{row.value}</dd></div>
@@ -385,7 +456,9 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
</dl>
<div className="equipment-character-vitals">
<span>Combat health <strong>{displayNumber(ownerMaxHealth)}</strong></span>
<span>Gear stamina <strong>+{displayNumber(ownerGearStats.stamina)}</strong></span>
<span>Gear stamina <strong>+{displayNumber(
ownerStats.romNative?.sourceInputs.gear.stamina ?? ownerGearStats.stamina,
)}</strong></span>
</div>
<div className="equipment-loadout__slots">
{EQUIPMENT_SLOTS.map((slot) => {
+35 -1
View File
@@ -8,6 +8,7 @@ import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
import { specializationById, specializationIconUrl } from "../game/specializationCatalog";
import { normalizeCharacterStatRuleset } from "../game/characterStatRuleset";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton";
@@ -163,6 +164,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
const openManastorms = useShellStore((state) => state.openManastorms);
const openArenas = useShellStore((state) => state.openArenas);
const openClassHall = useShellStore((state) => state.openClassHall);
const setSelectedCharacterStatRuleset = useShellStore((state) => state.setSelectedCharacterStatRuleset);
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
const notice = useShellStore((state) => state.notice);
const session = useShellStore((state) => state.session);
@@ -260,6 +262,10 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
{ id: "mode-keybinds", run: () => onOpenEditor("keybinds") },
{ id: "mode-bindings", run: () => onOpenEditor("bindings") },
] : []),
...(character && isRom ? [
{ id: "stat-profile-rom", run: () => setSelectedCharacterStatRuleset("rom"), enabled: !onlineGroup?.activity },
{ id: "stat-profile-wow", run: () => setSelectedCharacterStatRuleset("wow"), enabled: !onlineGroup?.activity },
] : []),
{ id: "mode-cards", run: onOpenCards },
{ id: "mode-gm-menu", run: () => setGmMenuOpen(true) },
...(SHELVED_MODES_ENABLED ? [
@@ -268,7 +274,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
] : []),
{ id: "mode-downloads", run: onOpenDownloads },
{ id: "back-to-roster", run: returnToCharacters, neighbors: { down: "mode-dungeons" } },
], [availableRoles, character, closeRandomQueue, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenPartyBuffPolicy, onOpenDungeonQueuePolicy, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenDungeonRace, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, queueing, randomQueueMode, returnToCharacters, selectedRole, startRandomQueue]);
], [availableRoles, character, closeRandomQueue, gmMenuOpen, isGm, isRom, onOpenBalanceLab, onOpenBalanceTuning, onOpenPartyBuffPolicy, onOpenDungeonQueuePolicy, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenDungeonRace, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, onlineGroup?.activity, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, queueing, randomQueueMode, returnToCharacters, selectedRole, setSelectedCharacterStatRuleset, startRandomQueue]);
const controller = useMenuController(actions, {
columns: 2,
initialId: randomQueueMode ? `random-queue-role-${availableRoles[0]}` : gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
@@ -276,6 +282,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
});
const characterClass = character ? classById(character.classId) : null;
const statRuleset = character ? normalizeCharacterStatRuleset(character.statRuleset, character.classId) : "wow";
const specialization = specializationById(character?.specializationId);
const secondaryClass = character?.secondaryClassId ? classById(character.secondaryClassId) : null;
const gameModeAction = (id: string) => {
@@ -348,6 +355,33 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
? secondaryClass ? ` / ${secondaryClass.name}` : " / No secondary class"
: specialization ? ` / ${specialization.displayName}` : ""}
</strong>
{isRom && <fieldset style={{ display: "grid", gap: 5, margin: "8px 0 0", padding: "6px 8px", border: "1px solid rgba(159,217,223,.24)" }}>
<legend style={{ padding: "0 4px", color: "#9fb2b0", fontSize: 8, letterSpacing: ".08em", textTransform: "uppercase" }}>Stat profile</legend>
<div role="group" aria-label="RoM stat profile" style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
{([
["rom", "Native RoM", "stat-profile-rom"],
["wow", "WoW compatibility", "stat-profile-wow"],
] as const).map(([ruleset, label, controlId]) => (
<ControllerButton
key={ruleset}
controlId={controlId}
selectedId={controller.selectedId}
select={controller.select}
className="front-button"
type="button"
aria-pressed={statRuleset === ruleset}
disabled={Boolean(onlineGroup?.activity)}
onClick={() => setSelectedCharacterStatRuleset(ruleset)}
style={{
flex: "1 1 110px", minWidth: 0, padding: "6px 8px", fontSize: 8,
background: statRuleset === ruleset ? "rgba(64,116,121,.5)" : undefined,
borderColor: statRuleset === ruleset ? "#9fd9df" : undefined,
}}
>{label}</ControllerButton>
))}
</div>
{onlineGroup?.activity && <small style={{ color: "#9fb2b0", fontSize: 8 }}>Unavailable during a live online group activity.</small>}
</fieldset>}
</div>
</section>
)}
+6 -6
View File
@@ -232,9 +232,9 @@ function MemberFrame({ member, selected, onSelect, healPopups }: {
<span className="party-frame__health" role="progressbar" aria-label={`${member.name} health`} aria-valuemin={0} aria-valuemax={member.maxHealth} aria-valuenow={Math.max(0, member.health)}>
<i style={{ width: `${percent}%` }} />
{absorb > 0 ? <em style={{ left: `${percent}%`, width: `${absorbPercent}%` }} /> : null}
<b>{Math.ceil(member.health)} / {member.maxHealth}</b>
<b>{Math.ceil(member.health)} / {Math.ceil(member.maxHealth)}</b>
</span>
<span className={`party-frame__resource party-frame__resource--${member.resourceType}`} title={`${Math.floor(member.resource)} / ${member.maxResource} ${member.resourceName}`}>
<span className={`party-frame__resource party-frame__resource--${member.resourceType}`} title={`${Math.floor(member.resource)} / ${Math.floor(member.maxResource)} ${member.resourceName}`}>
<i style={{ width: `${resourcePercent}%` }} />
</span>
<TimedEffectStrip targetId={member.id} className="timed-effect-strip--party" />
@@ -283,9 +283,9 @@ function OnlineMemberFrame({
<>
<span className="party-frame__health" role="progressbar" aria-label={`${character.name} health`} aria-valuemin={0} aria-valuemax={player.maxHealth} aria-valuenow={Math.max(0, player.health)}>
<i style={{ width: `${percent}%` }} />
<b>{Math.ceil(player.health)} / {player.maxHealth}</b>
<b>{Math.ceil(player.health)} / {Math.ceil(player.maxHealth)}</b>
</span>
<span className="party-frame__resource" title={`${Math.floor(player.resource)} / ${player.maxResource} ${player.resourceName}`}>
<span className="party-frame__resource" title={`${Math.floor(player.resource)} / ${Math.floor(player.maxResource)} ${player.resourceName}`}>
<i style={{ width: `${healthPercent(player.resource, player.maxResource)}%` }} />
</span>
<span className="party-frame__online-status"><i /> Live</span>
@@ -383,9 +383,9 @@ export function PartyFrames() {
<span className="party-frame__health" role="progressbar" aria-label="Player health" aria-valuemin={0} aria-valuemax={playerMaxHealth} aria-valuenow={Math.max(0, playerHealth)}>
<i style={{ width: `${playerPercent}%` }} />
{playerAbsorb > 0 ? <em style={{ left: `${playerPercent}%`, width: `${playerAbsorbPercent}%` }} /> : null}
<b>{Math.ceil(playerHealth)} / {playerMaxHealth}</b>
<b>{Math.ceil(playerHealth)} / {Math.ceil(playerMaxHealth)}</b>
</span>
<span className="party-frame__resource" title={`${Math.floor(playerResource)} / ${playerMaxResource} ${playerResourceName}`}>
<span className="party-frame__resource" title={`${Math.floor(playerResource)} / ${Math.floor(playerMaxResource)} ${playerResourceName}`}>
<i style={{ width: `${healthPercent(playerResource, playerMaxResource)}%` }} />
</span>
<TimedEffectStrip
+31 -1
View File
@@ -1,5 +1,10 @@
import { PLAYER_AGGRO_ID } from "../game/aggro";
import { useCombatStore } from "../game/combatStore";
import { useShellStore } from "../app/shellStore";
import {
isWow335HunterPetFamily,
WOW335_HUNTER_PET_FAMILY_CHOICES,
} from "../game/wow335HunterPetFamilySelection";
export function SummonFrames({ now }: { readonly now: number }) {
const summons = useCombatStore((state) => state.summons);
@@ -8,10 +13,21 @@ export function SummonFrames({ now }: { readonly now: number }) {
const selectFriendlyActor = useCombatStore((state) => state.selectFriendlyActor);
const commandHunterPet = useCombatStore((state) => state.commandHunterPet);
const setHunterPetReactMode = useCombatStore((state) => state.setHunterPetReactMode);
const hunterPetFamily = useCombatStore((state) => state.hunterPetFamily);
const setHunterPetFamily = useCombatStore((state) => state.setHunterPetFamily);
const mobs = useCombatStore((state) => state.mobs);
const setHunterPetGrowlAutocast = useCombatStore((state) => state.setHunterPetGrowlAutocast);
const hunterPetFamilyLocked = Object.values(mobs).some((mob) => mob.engaged && !mob.dead && !mob.despawned);
const hasPlayerHunterPet = summons.some((summon) => summon.hunterPet && summon.ownerActorId === PLAYER_AGGRO_ID);
const chooseHunterPetFamily = (value: unknown) => {
if (!isWow335HunterPetFamily(value)) return;
const previousFamily = hunterPetFamily;
if (!setHunterPetFamily(value)) return;
if (!useShellStore.getState().saveActiveHunterPetFamily(value)) setHunterPetFamily(previousFamily);
};
if (!summons.length) return null;
return (
<section className="summon-frames" aria-label="Pets, totems, and summons">
<section className={`summon-frames${hasPlayerHunterPet ? " summon-frames--player-hunter-pet" : ""}`} aria-label="Pets, totems, and summons">
{summons.map((summon) => {
const healthPercent = summon.maxHealth > 0 ? Math.max(0, Math.min(100, summon.health / summon.maxHealth * 100)) : 0;
const remaining = summon.expiresAt >= Number.MAX_SAFE_INTEGER
@@ -48,6 +64,20 @@ export function SummonFrames({ now }: { readonly now: number }) {
>{petCommand}</button>
))}
</div>
<label className="hunter-pet-controls__react">
<span>Family</span>
<select
aria-label="Pet family"
value={hunterPetFamily}
disabled={hunterPetFamilyLocked}
title={hunterPetFamilyLocked ? "End the current encounter before changing pet family." : undefined}
onChange={(event) => chooseHunterPetFamily(event.currentTarget.value)}
>
{WOW335_HUNTER_PET_FAMILY_CHOICES.map((family) => (
<option key={family.id} value={family.id}>{family.name}</option>
))}
</select>
</label>
<label className="hunter-pet-controls__react">
<span>React</span>
<select