forked from phenom/healer-man
Release Healer Man 0.1.15
This commit is contained in:
1 parent
7b41b102fa
commit
f49e54f35f
43 files changed
+1478
-112
No files matched your search
@@ -1,5 +1,11 @@
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { classCanEquipItem, PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
|
||||
import {
|
||||
compareEquipmentUpgrade,
|
||||
resolvedEquipmentLoadout,
|
||||
} from "../game/heirloomEquipment";
|
||||
import { gearProfileFor } from "../game/specializationCatalog";
|
||||
|
||||
function itemType(inventorySlot: string): string {
|
||||
return inventorySlot
|
||||
@@ -11,8 +17,25 @@ function itemType(inventorySlot: string): string {
|
||||
|
||||
export function BossLootWindow() {
|
||||
const reward = useCombatStore((state) => state.bossLootRewards[0] ?? null);
|
||||
const classId = useCombatStore((state) => state.classId);
|
||||
const specializationId = useCombatStore((state) => state.specializationId);
|
||||
const role = useCombatStore((state) => state.gearRole);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const equipment = useCombatStore((state) => state.equipment);
|
||||
const inventory = useCombatStore((state) => state.inventory);
|
||||
if (!reward) return null;
|
||||
|
||||
const profile = gearProfileFor(classId, specializationId, role);
|
||||
const heirloomContext = {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player" as const,
|
||||
classId,
|
||||
specializationId,
|
||||
role,
|
||||
level,
|
||||
};
|
||||
const loadout = resolvedEquipmentLoadout(equipment, inventory, heirloomContext);
|
||||
|
||||
const dismiss = () => useCombatStore.getState().dismissBossLootReward(reward.id);
|
||||
const viewInventory = () => {
|
||||
dismiss();
|
||||
@@ -39,8 +62,11 @@ export function BossLootWindow() {
|
||||
<div className="boss-loot-window__body">
|
||||
<p className="boss-loot-window__eyebrow">Loot acquired automatically</p>
|
||||
<div className="boss-loot-window__items">
|
||||
{reward.items.map((item) => (
|
||||
<article
|
||||
{reward.items.map((item) => {
|
||||
const comparison = classCanEquipItem(classId, item)
|
||||
? compareEquipmentUpgrade(item, loadout, profile, heirloomContext)
|
||||
: null;
|
||||
return <article
|
||||
key={item.instanceId}
|
||||
className={`boss-loot-window__item equipment-quality--${item.quality}`}
|
||||
>
|
||||
@@ -48,10 +74,11 @@ export function BossLootWindow() {
|
||||
<p>
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.quality} · Item level {item.itemLevel} · {itemType(item.inventorySlot)}</small>
|
||||
{comparison && <small>{comparison.isUpgrade ? `Upgrade · +${comparison.scoreDelta.toFixed(1)} score` : `${comparison.scoreDelta.toFixed(1)} score`}</small>}
|
||||
</p>
|
||||
{item.quantity > 1 ? <em>×{item.quantity}</em> : null}
|
||||
</article>
|
||||
))}
|
||||
</article>;
|
||||
})}
|
||||
{!reward.items.length ? (
|
||||
<p className="boss-loot-window__empty">No items dropped this time.</p>
|
||||
) : null}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { CharacterPreview } from "./CharacterPreview";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { PARTY_ROLE_LABELS } from "../game/partyRoles";
|
||||
import { specializationsForClass } from "../game/specializationCatalog";
|
||||
|
||||
export function CharacterCreateScreen() {
|
||||
const draft = useShellStore((state) => state.draft);
|
||||
@@ -40,6 +42,7 @@ export function CharacterCreateScreen() {
|
||||
);
|
||||
const appearanceLabels = race.labels[draft.gender];
|
||||
const appearanceCounts = race.counts[draft.gender];
|
||||
const specializationOptions = useMemo(() => specializationsForClass(draft.classId), [draft.classId]);
|
||||
|
||||
const chooseRace = (raceId: RaceId) => {
|
||||
const nextClasses = classesForRace(raceId, draft.categoryId);
|
||||
@@ -69,6 +72,7 @@ export function CharacterCreateScreen() {
|
||||
run: () => chooseGender(gender),
|
||||
})),
|
||||
...availableClasses.map((definition) => ({ id: `class-${definition.id}`, run: () => updateDraft({ classId: definition.id }) })),
|
||||
...specializationOptions.map((definition) => ({ id: `spec-${definition.id}`, run: () => updateDraft({ specializationId: definition.id }) })),
|
||||
{ id: "rotate-left", run: () => updateDraft({ rotation: draft.rotation - 12 }) },
|
||||
{ id: "rotate-right", run: () => updateDraft({ rotation: draft.rotation + 12 }) },
|
||||
{ id: "creator-back", run: cancel },
|
||||
@@ -82,7 +86,7 @@ export function CharacterCreateScreen() {
|
||||
{ id: "randomize", run: randomize },
|
||||
{ id: "appearance-back", run: () => setStep("identity") },
|
||||
{ id: "create-character", run: createCharacter },
|
||||
], [draft, appearanceCounts, availableClasses, availableRaces, cancel, createCharacter, randomize, setStep, updateDraft]);
|
||||
], [draft, appearanceCounts, availableClasses, availableRaces, cancel, createCharacter, randomize, setStep, specializationOptions, updateDraft]);
|
||||
const controller = useMenuController(actions, {
|
||||
columns: draft.step === "identity" ? 5 : 2,
|
||||
initialId: draft.step === "identity" ? `race-${draft.raceId}` : "character-name",
|
||||
@@ -180,6 +184,25 @@ export function CharacterCreateScreen() {
|
||||
<span>{characterClass.description}</span>
|
||||
<small>{characterClass.armor} / {characterClass.resourceSummary} / {characterClass.specializations.length ? characterClass.specializations.join(" · ") : "Automatic skill ranks"}</small>
|
||||
</div>
|
||||
{specializationOptions.length > 0 && (
|
||||
<div className="creator-specializations" aria-label="Specialization">
|
||||
<strong>Specialization</strong>
|
||||
{specializationOptions.map((definition) => (
|
||||
<ControllerButton
|
||||
key={definition.id}
|
||||
controlId={`spec-${definition.id}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className={draft.specializationId === definition.id ? "is-active" : ""}
|
||||
type="button"
|
||||
onClick={() => updateDraft({ specializationId: definition.id })}
|
||||
>
|
||||
<span>{definition.displayName}</span>
|
||||
<small>{PARTY_ROLE_LABELS[definition.role]} / {definition.talentTree}</small>
|
||||
</ControllerButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<footer className="creator-footer">
|
||||
<ControllerButton controlId="creator-back" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={cancel}>Back to roster</ControllerButton>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { classById, raceById, type RomClassId } from "../app/characterCatalog";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import {
|
||||
ROM_ELITE_SKILL_LEVELS,
|
||||
ROM_SECONDARY_UNLOCK_LEVEL,
|
||||
@@ -13,9 +14,100 @@ import { CharacterPreview } from "./CharacterPreview";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { PARTY_ROLE_LABELS } from "../game/partyRoles";
|
||||
import {
|
||||
specializationById,
|
||||
specializationsForClass,
|
||||
} from "../game/specializationCatalog";
|
||||
|
||||
export function ClassHallScreen() {
|
||||
const character = useShellStore(selectedCharacter);
|
||||
if (!character) return null;
|
||||
return character.categoryId === "rom"
|
||||
? <RomClassHall character={character} />
|
||||
: <SpecializationClassHall character={character} />;
|
||||
}
|
||||
|
||||
function SpecializationClassHall({ character }: { readonly character: CharacterProfile }) {
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
|
||||
const changeSpecialization = useShellStore((state) => state.changeSelectedSpecialization);
|
||||
const [pendingSpecializationId, setPendingSpecializationId] = useState<string | null>(null);
|
||||
const options = useMemo(() => specializationsForClass(character.classId), [character.classId]);
|
||||
const current = specializationById(character.specializationId);
|
||||
const forced = current === null;
|
||||
const goBack = forced ? returnToCharacters : returnToMainMenu;
|
||||
const apply = (resetTalents: boolean) => {
|
||||
if (!pendingSpecializationId || !changeSpecialization(pendingSpecializationId, resetTalents)) return;
|
||||
setPendingSpecializationId(null);
|
||||
if (forced) returnToMainMenu();
|
||||
};
|
||||
const actions = useMemo<MenuAction[]>(() => pendingSpecializationId ? [
|
||||
{ id: "specialization-cancel", run: () => setPendingSpecializationId(null) },
|
||||
{ id: "specialization-keep", run: () => apply(false) },
|
||||
{ id: "specialization-reset", run: () => apply(true) },
|
||||
] : [
|
||||
...options.map((definition) => ({ id: `specialization-${definition.id}`, run: () => setPendingSpecializationId(definition.id) })),
|
||||
{ id: "class-hall-back", run: goBack },
|
||||
], [goBack, options, pendingSpecializationId]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: current ? `specialization-${current.id}` : options[0] ? `specialization-${options[0].id}` : "class-hall-back",
|
||||
onBack: pendingSpecializationId ? () => setPendingSpecializationId(null) : goBack,
|
||||
});
|
||||
const race = raceById(character.raceId);
|
||||
const characterClass = classById(character.classId);
|
||||
const pending = specializationById(pendingSpecializationId);
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
contextLabel="Specialization details"
|
||||
top={
|
||||
<FrontSurface className="class-hall-screen" ariaLabel="Class Hall specialization selection">
|
||||
<header className="class-hall-header">
|
||||
<BrandMark compact />
|
||||
<div><span>{characterClass.name} / Class Hall</span><strong>{forced ? "Choose your specialization to continue." : "Change your combat path."}</strong></div>
|
||||
<ControllerButton controlId="class-hall-back" selectedId={controller.selectedId} select={controller.select} type="button" onClick={goBack}>{forced ? "Roster" : "Back"}</ControllerButton>
|
||||
</header>
|
||||
<aside className="class-hall-character">
|
||||
<CharacterPreview character={character} />
|
||||
<div><p className="front-kicker">{race.name} / Level {character.level}</p><h1>{character.name}</h1><strong style={{ color: characterClass.color }}>{characterClass.name}</strong>{current && <small>{current.displayName} / {PARTY_ROLE_LABELS[current.role]}</small>}</div>
|
||||
</aside>
|
||||
<main className="class-hall-content">
|
||||
<section className="secondary-class-choice">
|
||||
<p className="front-kicker">Specialization and heirloom profile</p>
|
||||
<h2>{forced ? "A specialization is required" : `Current: ${current.displayName}`}</h2>
|
||||
<p>Your saved specialization locks your activity role and tunes every heirloom stat and weapon slot. Talent points do not change specialization automatically.</p>
|
||||
<div>{options.map((definition) => (
|
||||
<ControllerButton key={definition.id} controlId={`specialization-${definition.id}`} selectedId={controller.selectedId} select={controller.select} className={`front-button secondary-class-option ${current?.id === definition.id ? "is-active" : ""}`} type="button" onClick={() => setPendingSpecializationId(definition.id)}>
|
||||
<b style={{ color: characterClass.color }}>{characterClass.sigil}</b>
|
||||
<span><strong>{definition.displayName}</strong><small>{PARTY_ROLE_LABELS[definition.role]} / {definition.gearArchetype.replaceAll("-", " ")} / {definition.weaponLayout.replaceAll("-", " ")}</small></span>
|
||||
</ControllerButton>
|
||||
))}</div>
|
||||
</section>
|
||||
{notice && <p className="front-notice" role="status">{notice}</p>}
|
||||
</main>
|
||||
{pending && (
|
||||
<div className="front-modal-backdrop"><section className="front-confirm" role="alertdialog" aria-modal="true" aria-labelledby="specialization-confirm-title">
|
||||
<p className="front-kicker">Specialization change</p>
|
||||
<h2 id="specialization-confirm-title">Become {pending.displayName}?</h2>
|
||||
<p>Heirloom stats and your allowed activity role change immediately. Choose whether to preserve the current talent build and action bindings.</p>
|
||||
<div>
|
||||
<ControllerButton controlId="specialization-cancel" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => setPendingSpecializationId(null)}>Cancel</ControllerButton>
|
||||
<ControllerButton controlId="specialization-keep" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => apply(false)}>Keep talents</ControllerButton>
|
||||
<ControllerButton controlId="specialization-reset" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="button" onClick={() => apply(true)}>Reset talents</ControllerButton>
|
||||
</div>
|
||||
</section></div>
|
||||
)}
|
||||
<ControllerLegend back />
|
||||
</FrontSurface>
|
||||
}
|
||||
bottom={<LazyLocalCompanionPanel />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RomClassHall({ character }: { readonly character: CharacterProfile }) {
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||
const chooseSecondary = useShellStore((state) => state.chooseRomSecondaryClass);
|
||||
@@ -38,7 +130,6 @@ export function ClassHallScreen() {
|
||||
onBack: pendingClassId ? () => setPendingClassId(null) : returnToMainMenu,
|
||||
});
|
||||
|
||||
if (!character) return null;
|
||||
const race = raceById(character.raceId);
|
||||
const primary = classById(character.classId);
|
||||
const secondary = character.secondaryClassId ? classById(character.secondaryClassId) : null;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { DungeonMapGraphic } from "./DungeonMapGraphic";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
@@ -66,7 +67,10 @@ export function DungeonSelectScreen() {
|
||||
?? null;
|
||||
const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : null;
|
||||
|
||||
const availableRoles = character ? partyRolesForClass(character.classId) : [];
|
||||
const savedSpecialization = specializationById(character?.specializationId);
|
||||
const availableRoles = character
|
||||
? character.categoryId === "rom" ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : []
|
||||
: [];
|
||||
const selectDungeon = (dungeonId: DungeonId) => {
|
||||
setSelectedDungeonId(dungeonId);
|
||||
setRolePickerOpen(false);
|
||||
|
||||
+71
-13
@@ -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>
|
||||
|
||||
@@ -99,9 +99,9 @@ export function MainMenuScreen() {
|
||||
run: returnToCharacters,
|
||||
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
|
||||
},
|
||||
...(isRom ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
|
||||
], [isGm, isRom, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
], [character, isGm, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
@@ -173,7 +173,7 @@ export function MainMenuScreen() {
|
||||
<em>Authorized</em>
|
||||
</ControllerButton>
|
||||
)}
|
||||
{isRom && (
|
||||
{character && (
|
||||
<ControllerButton
|
||||
controlId="mode-class-hall"
|
||||
selectedId={controller.selectedId}
|
||||
@@ -183,8 +183,8 @@ export function MainMenuScreen() {
|
||||
onClick={openClassHall}
|
||||
>
|
||||
<b aria-hidden="true">CH</b>
|
||||
<span><strong>Class Hall</strong><small>Choose a permanent partner class and swap your active ordering.</small></span>
|
||||
<em>{character && character.level >= 10 ? "Open" : "Unlocks at 10"}</em>
|
||||
<span><strong>Class Hall</strong><small>{isRom ? "Choose a permanent partner class and swap your active ordering." : "Change specialization and retune your heirlooms."}</small></span>
|
||||
<em>{isRom && character.level < 10 ? "Unlocks at 10" : "Open"}</em>
|
||||
</ControllerButton>
|
||||
)}
|
||||
{GAME_MODES.map((mode) => mode.available ? (
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ControllerButton } from "./ControllerButton";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
|
||||
installManastormShellRuntime();
|
||||
|
||||
@@ -58,7 +59,10 @@ export function ManastormSelectScreen() {
|
||||
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
|
||||
const adminConfig = useManastormAdminStore((state) => state.config);
|
||||
const character = activeCharacter ?? rosterCharacter;
|
||||
const availableRoles = character ? partyRolesForClass(character.classId) : [];
|
||||
const savedSpecialization = specializationById(character?.specializationId);
|
||||
const availableRoles = character
|
||||
? character.categoryId === "rom" ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : []
|
||||
: [];
|
||||
const [partySize, setPartySize] = useState<ManastormPartySize>(5);
|
||||
const groupHumanCount = onlineGroup?.members.length ?? 1;
|
||||
const effectivePartySize = onlineGroup
|
||||
|
||||
Reference in new issue
Block a user