Release Healer Man 0.1.15

This commit is contained in:
phenom committed 2026-09-05 15:33:06 -04:00
1 parent 7b41b102fa
commit f49e54f35f
43 files changed
+1478 -112

No files matched your search

+71 -13
View File
@@ -6,7 +6,6 @@ import {
PLAYER_EQUIPMENT_OWNER_ID,
classCanEquipItem,
equippedOwnerId,
equipmentItemLevel,
equipmentSlotForItem,
type EquipmentSlot,
} from "../game/equipment";
@@ -22,6 +21,14 @@ import { deriveCharacterStats, type CharacterStats } from "../game/wotlkStats";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import {
compareEquipmentUpgrade,
resolvedAverageItemLevel,
resolvedEquipmentLoadout,
type HeirloomContext,
} from "../game/heirloomEquipment";
import { gearProfileFor } from "../game/specializationCatalog";
import type { PartyRole } from "../game/partyRoles";
const SLOT_LABELS: Readonly<Record<EquipmentSlot, string>> = Object.freeze({
head: "Head",
@@ -46,6 +53,10 @@ interface EquipmentOwner {
readonly name: string;
readonly detail: string;
readonly classId: ClassId;
readonly specializationId: string | null;
readonly role: PartyRole;
readonly level: number;
readonly ownerKind: "player" | "party";
}
type StatPage = "attributes" | "offense" | "defense";
@@ -181,6 +192,8 @@ function EquipmentDialog() {
const playerLevel = useCombatStore((state) => state.level);
const playerMaxHealth = useCombatStore((state) => state.maxHealth);
const playerGearStats = useCombatStore((state) => state.gearStats);
const playerSpecializationId = useCombatStore((state) => state.specializationId);
const playerGearRole = useCombatStore((state) => state.gearRole);
const feedback = useCombatStore((state) => state.feedback);
const members = usePartyStore((state) => state.members);
const openOverlay = useGameStore((state) => state.openOverlay);
@@ -191,14 +204,22 @@ function EquipmentDialog() {
name: character?.name ?? "You",
detail: "Player",
classId: playerClassId,
specializationId: playerSpecializationId,
role: playerGearRole,
level: playerLevel,
ownerKind: "player" as const,
},
...members.map((member) => ({
id: member.id,
name: member.name,
detail: `${member.specialization} · ${member.role === "damage" ? "DPS" : member.role}`,
classId: member.classId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
ownerKind: "party" as const,
})),
], [character?.name, members, playerClassId]);
], [character?.name, members, playerClassId, 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");
@@ -214,9 +235,20 @@ function EquipmentDialog() {
const owner = owners.find((candidate) => candidate.id === ownerId) ?? owners[0];
const inspected = inventory.find((item) => item.instanceId === inspectedId) ?? inventory.at(-1) ?? null;
const itemById = useMemo(() => new Map(inventory.map((item) => [item.instanceId, item])), [inventory]);
const ownerById = useMemo(() => new Map(owners.map((candidate) => [candidate.id, candidate])), [owners]);
const loadout = equipment[owner.id] ?? {};
const heirloomContext: HeirloomContext = {
ownerId: owner.id,
ownerKind: owner.ownerKind,
classId: owner.classId,
specializationId: owner.specializationId,
role: owner.role,
level: owner.level,
};
const loadout = useMemo(
() => resolvedEquipmentLoadout(equipment, inventory, heirloomContext),
[equipment, inventory, owner.classId, owner.id, owner.level, owner.ownerKind, owner.role, owner.specializationId],
);
const earnedLoadout = equipment[owner.id] ?? {};
const sortedInventory = useMemo(() => [...inventory].sort((left, right) => (
right.itemLevel - left.itemLevel || right.acquiredAt - left.acquiredAt
)), [inventory]);
@@ -230,7 +262,7 @@ function EquipmentDialog() {
id: `equipment-owner-${candidate.id}`,
run: () => setOwnerId(candidate.id),
})),
...EQUIPMENT_SLOTS.flatMap((slot) => loadout[slot] ? [{
...EQUIPMENT_SLOTS.flatMap((slot) => earnedLoadout[slot] ? [{
id: `equipment-slot-${slot}`,
run: () => useCombatStore.getState().unequipItem(owner.id, slot),
}] : []),
@@ -243,7 +275,7 @@ function EquipmentDialog() {
run: () => equip(item),
})),
{ id: "equipment-back", run: close },
], [loadout, owner.id, owners, sortedInventory]);
], [earnedLoadout, owner.id, owners, sortedInventory]);
const controller = useMenuController(actions, {
columns: 2,
initialId: `equipment-owner-${PLAYER_EQUIPMENT_OWNER_ID}`,
@@ -253,7 +285,11 @@ function EquipmentDialog() {
const inspectedOwnerId = inspected ? equippedOwnerId(equipment, inspected.instanceId) : null;
const inspectedOwner = inspectedOwnerId ? ownerById.get(inspectedOwnerId) : null;
const compatible = inspected ? classCanEquipItem(owner.classId, inspected) : false;
const gearLevel = equipmentItemLevel(equipment, inventory, owner.id);
const gearLevel = resolvedAverageItemLevel(equipment, inventory, heirloomContext);
const gearProfile = gearProfileFor(owner.classId, owner.specializationId, owner.role);
const inspectedComparison = inspected && compatible
? compareEquipmentUpgrade(inspected, loadout, gearProfile, heirloomContext)
: null;
const selectedMember = members.find((member) => member.id === owner.id) ?? null;
const ownerLevel = selectedMember?.level ?? playerLevel;
const ownerMaxHealth = selectedMember?.maxHealth ?? playerMaxHealth;
@@ -274,7 +310,7 @@ function EquipmentDialog() {
<div className="equipment-panel__summary">
<strong>{inventory.length}</strong><span>Items</span>
<i aria-hidden="true" />
<strong>{gearLevel}</strong><span>Gear level</span>
<strong>{gearLevel}</strong><span>Average item level</span>
</div>
<ControllerButton
controlId="equipment-back"
@@ -304,7 +340,14 @@ function EquipmentDialog() {
>
<b>{definition.sigil}</b>
<span><strong>{candidate.name}</strong><small>{candidate.detail} · {definition.name}</small></span>
<em>{equipmentItemLevel(equipment, inventory, candidate.id)}</em>
<em>{resolvedAverageItemLevel(equipment, inventory, {
ownerId: candidate.id,
ownerKind: candidate.ownerKind,
classId: candidate.classId,
specializationId: candidate.specializationId,
role: candidate.role,
level: candidate.level,
})}</em>
</ControllerButton>
);
})}
@@ -339,7 +382,8 @@ function EquipmentDialog() {
</div>
<div className="equipment-loadout__slots">
{EQUIPMENT_SLOTS.map((slot) => {
const item = loadout[slot] ? itemById.get(loadout[slot]!) ?? null : null;
const item = loadout[slot] ?? null;
const earned = Boolean(earnedLoadout[slot]);
return item ? (
<ControllerButton
key={slot}
@@ -349,10 +393,11 @@ function EquipmentDialog() {
type="button"
className={`equipment-slot equipment-quality--${item.quality}`}
onFocus={() => setInspectedId(item.instanceId)}
onClick={() => useCombatStore.getState().unequipItem(owner.id, slot)}
disabled={!earned}
onClick={() => earned && useCombatStore.getState().unequipItem(owner.id, slot)}
>
<span><small>{SLOT_LABELS[slot]}</small><strong>{item.name}</strong></span>
<em>Unequip</em>
<em>{earned ? "Unequip" : `Heirloom ${item.itemLevel}`}</em>
</ControllerButton>
) : (
<div key={slot} className="equipment-slot is-empty">
@@ -370,6 +415,7 @@ function EquipmentDialog() {
const assignedId = equippedOwnerId(equipment, item.instanceId);
const assignedOwner = assignedId ? ownerById.get(assignedId) : null;
const canEquip = classCanEquipItem(owner.classId, item);
const comparison = canEquip ? compareEquipmentUpgrade(item, loadout, gearProfile, heirloomContext) : null;
return (
<ControllerButton
key={item.instanceId}
@@ -388,7 +434,7 @@ function EquipmentDialog() {
<small>Level {item.itemLevel} · {itemType(item)}</small>
<small className="equipment-item__stats">{itemStatSummary(item.stats)}</small>
</span>
<em>{assignedOwner ? assignedOwner.name : canEquip ? "Equip" : "Incompatible"}</em>
<em>{comparison?.isUpgrade ? `Upgrade +${displayNumber(comparison.scoreDelta, 1)}` : assignedOwner ? assignedOwner.name : canEquip ? "Equip" : "Incompatible"}</em>
</ControllerButton>
);
})}
@@ -409,6 +455,11 @@ function EquipmentDialog() {
<p className="eyebrow">{inspected.quality} · Item level {inspected.itemLevel}</p>
<h3>{inspected.name}</h3>
<p>{itemType(inspected)}</p>
{inspectedComparison && (
<p className={inspectedComparison.isUpgrade ? "is-beneficial" : undefined}>
{inspectedComparison.isUpgrade ? "Upgrade" : "Not an upgrade"} · {inspectedComparison.scoreDelta >= 0 ? "+" : ""}{displayNumber(inspectedComparison.scoreDelta, 2)} specialization score
</p>
)}
<dl className="equipment-details__stats">
{inspectedStatRows.map((row) => (
<div key={row.label}>
@@ -417,6 +468,13 @@ function EquipmentDialog() {
</div>
))}
</dl>
{inspectedComparison && Object.keys(inspectedComparison.usefulStatDeltas).length > 0 && (
<dl className="equipment-details__stats">
{Object.entries(inspectedComparison.usefulStatDeltas).map(([key, value]) => (
<div key={key}><dt>{key.replace(/([A-Z])/g, " $1")}</dt><dd className={(value ?? 0) > 0 ? "is-beneficial" : undefined}>{(value ?? 0) > 0 ? "+" : ""}{value}</dd></div>
))}
</dl>
)}
<dl className="equipment-details__provenance">
<div><dt>Source</dt><dd>{inspected.sourceBossName}</dd></div>
<div><dt>Original level</dt><dd>{inspected.sourceItemLevel}</dd></div>