Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion PLANS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Keep this checklist aligned with repository behavior. Check off work in the same
- [x] Show owned/total roster progress and disable additions when every character is owned.
- [x] Highlight max-level skills in green so awakening styling never obscures a stored level 12.
- [x] Place Characters first in the Inventory navigation section.
- [x] Add portrait-selectable Mina, Jiwon, and Ophelia costumes with hidden-catalog development navigation details.

## Monsterlings: Available

Expand Down Expand Up @@ -128,7 +129,11 @@ Implementation is intentionally deferred. Keep every item unchecked until the co

## Chunk 2: Artifacts Inventory

- [x] Add Brisshell's Tier 5 Earth Assassin artifact, Monstrous Longing.
<<<<<<< HEAD

- [x] # Add Brisshell's Tier 5 Earth Assassin artifact, Monstrous Longing.
- [x] Add Brisshell's Tier 5 Earth Assassin artifact with a temporary title pending its official name.
> > > > > > > 59afb26 (Prepare Brishell assets)
- [x] Define typed artifact data, owned-artifact fields, images, and validation from the game source.
- [x] Add owned-artifact create, edit, delete, reset, local persistence, and mutation timestamps.
- [x] Build the Artifacts page with cards, search, filters, empty states, and accessible forms.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion src/components/artifacts/artifacts-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ import { useAppStore } from "@/stores/app-store";

export const ArtifactsPage = () => {
const owned = useAppStore((s) => s.artifactsOwned);
const charactersOwned = useAppStore((s) => s.charactersOwned);
const loadouts = useAppStore((s) => s.loadouts);
const equippedCharacterUsage = getEquippedCharacterUsage(
loadouts,
undefined,
{ artifactInstanceIds: Object.keys(owned) },
{ artifactInstanceIds: Object.keys(owned), charactersOwned },
);
const [filters, setFilters] = useState(emptyArtifactFilters);
const [instanceIdToEdit, setInstanceIdToEdit] = useState<string | null>(null);
Expand Down
6 changes: 5 additions & 1 deletion src/components/characters/characters-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CharacterFilter } from "@/components/characters/components/character-fi
import { CharacterOwnedList } from "@/components/characters/components/character-owned-list";
import { CharacterSkillLevel } from "@/components/characters/components/character-skill-level";
import { useCharacterFilter } from "@/components/characters/store/characters-filter-store";
import { resolveCharacterPortrait } from "@/components/characters/utils/character-costume";
import { matchesCharacterFilters } from "@/components/characters/utils/character-utils";
import { CollectionExportMenu } from "@/components/shared/collection-export-menu";
import { PageTitle } from "@/components/shared/page-title";
Expand Down Expand Up @@ -58,7 +59,10 @@ export const CharactersPage = () => {
<CharacterCard
portraitSize={130}
iconSize={30}
portraitImage={character.info.portraitImage}
portraitImage={resolveCharacterPortrait(
character.info,
character,
)}
name={character.info.name}
element_id={character.info.element_id}
class_id={character.info.class_id}
Expand Down
47 changes: 46 additions & 1 deletion src/components/characters/components/character-details-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, useWatch } from "react-hook-form";
import { useGoogleAnalytics } from "tanstack-router-ga4";
import { z } from "zod";
import { getVisibleCostumes } from "@/components/characters/utils/character-costume";
import { CHARACTER_SKILLS } from "@/components/characters/utils/character-domain-values";
import { NumberControlInput } from "@/components/forms/number-control-input";
import { SeparatorText } from "@/components/shared/separator-text";
Expand Down Expand Up @@ -31,6 +32,7 @@ const characterOwnedDetailsSchema = z.object({
[CHARACTER_SKILLS.SPECIAL]: z.number().min(1).max(12),
[CHARACTER_SKILLS.ULTIMATE]: z.number().min(1).max(12),
}),
costume_id: z.number().nullable().optional(),
});

export type CharacterOwned = z.infer<typeof characterOwnedDetailsSchema>;
Expand Down Expand Up @@ -58,12 +60,13 @@ export const CharacterOwnedDetailsForm = ({
[CHARACTER_SKILLS.SPECIAL]: editCharacterData?.skills.special ?? 1,
[CHARACTER_SKILLS.ULTIMATE]: editCharacterData?.skills.ultimate ?? 1,
},
costume_id: editCharacterData?.costume_id ?? null,
},
mode: "onChange",
});

const onSubmit = (data: CharacterOwned) => {
const { awakening, skills } = data;
const { awakening, skills, costume_id } = data;
const object = {
id,
awakening: Number(awakening),
Expand All @@ -73,6 +76,7 @@ export const CharacterOwnedDetailsForm = ({
[CHARACTER_SKILLS.SPECIAL]: Number(skills[CHARACTER_SKILLS.SPECIAL]),
[CHARACTER_SKILLS.ULTIMATE]: Number(skills[CHARACTER_SKILLS.ULTIMATE]),
},
costume_id: costume_id ?? null,
};
setCharacterOwned(object);

Expand All @@ -91,6 +95,9 @@ export const CharacterOwnedDetailsForm = ({
control: form.control,
name: "awakening",
});
const costumeId = useWatch({ control: form.control, name: "costume_id" });
const character = CHARACTERS_DATA[id];
const costumes = getVisibleCostumes(character);

return (
<div className="flex flex-col gap-5">
Expand All @@ -101,6 +108,44 @@ export const CharacterOwnedDetailsForm = ({
className="gap-y-2 flex flex-col"
id={FORM_ID}
>
<FieldGroup>
{costumes.length > 0 && (
<fieldset className="grid gap-2">
<legend className="text-sm font-medium">Costume</legend>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => form.setValue("costume_id", null)}
aria-pressed={costumeId == null}
className="rounded-md border p-1 aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<img
src={character.portraitImage}
alt="Default"
className="size-16 object-contain"
/>
</button>
{costumes.map((costume) => (
<button
key={costume.id}
type="button"
onClick={() => form.setValue("costume_id", costume.id)}
aria-pressed={costumeId === costume.id}
aria-label={costume.name}
className="rounded-md border p-1 aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<img
src={costume.portraitImage}
alt={costume.name}
className="size-16 object-contain"
/>
</button>
))}
</div>
</fieldset>
)}
</FieldGroup>

<FieldGroup>
<NumberControlInput<CharacterOwned>
name="awakening"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CHARACTER_SORTS,
type CharacterFilters,
} from "@/components/characters/store/characters-filter-store";
import { resolveCharacterPortrait } from "@/components/characters/utils/character-costume";
import { matchesCharacterFilters } from "@/components/characters/utils/character-utils";
import { CollectionEmptyState } from "@/components/shared/collection-empty-state";
import { CHARACTERS_DATA } from "@/data/characters/CHARACTERS_DATA";
Expand Down Expand Up @@ -78,7 +79,10 @@ export const CharacterOwnedList = ({ filters }: CharacterOwnedListProps) => {
key={charOwned.id}
portraitSize={130}
iconSize={30}
portraitImage={charOwned.info.portraitImage}
portraitImage={resolveCharacterPortrait(
charOwned.info,
charOwned,
)}
name={charOwned.info.name}
element_id={charOwned.info.element_id}
class_id={charOwned.info.class_id}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useGoogleAnalytics } from "tanstack-router-ga4";
import { CharacterOwnedDetailsForm } from "@/components/characters/components/character-details-form";
import { resolveCharacterPortrait } from "@/components/characters/utils/character-costume";
import { ConfirmDeleteButton } from "@/components/shared/confirm-delete-button";
import { TierPortrait } from "@/components/shared/tier-portrait";
import {
Expand Down Expand Up @@ -59,7 +60,10 @@ export const EditCharacterDetailsDialog = (
aria-hidden="true"
>
<TierPortrait
portraitImg={charInfo.portraitImage}
portraitImg={resolveCharacterPortrait(
charInfo,
charToEditInfo,
)}
portraitSize={50}
tier={charInfo.tier_id}
name={charInfo.name}
Expand Down
28 changes: 28 additions & 0 deletions src/components/characters/utils/character-costume.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveCharacterPortrait } from "@/components/characters/utils/character-costume";
import { CHARACTERS_DATA } from "@/data/characters/CHARACTERS_DATA";

describe("resolveCharacterPortrait", () => {
afterEach(() => vi.unstubAllEnvs());

it("shows released costumes and safely falls back for unknown IDs", () => {
vi.stubEnv("VITE_NODE_ENV", "production");
expect(
resolveCharacterPortrait(CHARACTERS_DATA[9], { costume_id: 1 }),
).toBe(CHARACTERS_DATA[9].costumes?.[0]?.portraitImage);
expect(
resolveCharacterPortrait(CHARACTERS_DATA[9], { costume_id: 99 }),
).toBe(CHARACTERS_DATA[9].portraitImage);
});

it("preserves hidden selections while rendering them only in development", () => {
vi.stubEnv("VITE_NODE_ENV", "production");
expect(
resolveCharacterPortrait(CHARACTERS_DATA[3], { costume_id: 1 }),
).toBe(CHARACTERS_DATA[3].portraitImage);
vi.stubEnv("VITE_NODE_ENV", "development");
expect(
resolveCharacterPortrait(CHARACTERS_DATA[3], { costume_id: 1 }),
).toBe(CHARACTERS_DATA[3].costumes?.[0]?.portraitImage);
});
});
20 changes: 20 additions & 0 deletions src/components/characters/utils/character-costume.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Character } from "@/data/characters/CHARACTERS_DATA";

export type CharacterOwnedCostume = { costume_id?: number | null };

export const getVisibleCostumes = (character: Character) =>
(character.costumes ?? []).filter(
(costume) =>
import.meta.env.VITE_NODE_ENV === "development" || !costume.is_hidden,
);

export const resolveCharacterPortrait = (
character: Character,
owned?: CharacterOwnedCostume,
) =>
getVisibleCostumes(character).find(
(costume) => costume.id === owned?.costume_id,
)?.portraitImage ?? character.portraitImage;

export const getCatalogCostume = (character: Character, id?: number | null) =>
(character.costumes ?? []).find((costume) => costume.id === id);
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isArtifactVisible } from "@/components/artifacts/utils/artifact-utils";
import { resolveCharacterPortrait } from "@/components/characters/utils/character-costume";
import { isCharacterVisible } from "@/components/characters/utils/character-utils";
import { TierPortrait } from "@/components/shared/tier-portrait";
import { ARTIFACTS_DATA } from "@/data/artifacts/ARTIFACTS_DATA";
Expand Down Expand Up @@ -50,6 +51,13 @@ export const LoadoutCardCharacterRow = ({
: null;
const characterOwned =
slot.characterId !== null ? charactersOwned[slot.characterId] : null;
const resolvedCharacter =
character && characterOwned
? {
...character,
portraitImage: resolveCharacterPortrait(character, characterOwned),
}
: character;
const artifactId = slot.artifactInstanceId;
const artifactOwned = artifactId ? artifactsOwned[artifactId] : null;
const catalogArtifact = artifactOwned
Expand All @@ -62,7 +70,7 @@ export const LoadoutCardCharacterRow = ({
return (
<div className="grid grid-cols-5 gap-1 rounded-md border bg-muted/20 p-2">
<LoadoutCardCharacterTile
character={character}
character={resolvedCharacter}
owned={characterOwned}
onEdit={onEditCharacter}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { CharacterOwned } from "@/components/characters/components/characte
import { CharacterFilter } from "@/components/characters/components/character-filter";
import { CharacterSkillLevel } from "@/components/characters/components/character-skill-level";
import type { CharacterFilters } from "@/components/characters/store/characters-filter-store";
import { resolveCharacterPortrait } from "@/components/characters/utils/character-costume";
import type { Character } from "@/data/characters/CHARACTERS_DATA";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -48,7 +49,10 @@ export const LoadoutCharacterPicker = ({
<CharacterCard
portraitSize={130}
iconSize={30}
portraitImage={character.info.portraitImage}
portraitImage={resolveCharacterPortrait(
character.info,
character,
)}
name={character.info.name}
element_id={character.info.element_id}
class_id={character.info.class_id}
Expand Down
11 changes: 10 additions & 1 deletion src/components/loadouts/components/loadout-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isArtifactVisible } from "@/components/artifacts/utils/artifact-utils";
import { resolveCharacterPortrait } from "@/components/characters/utils/character-costume";
import { isCharacterVisible } from "@/components/characters/utils/character-utils";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
Expand Down Expand Up @@ -28,6 +29,7 @@ type LoadoutEditorProps = {
draft: Omit<LoadoutOwned, "id">;
activeTab: string;
monsterlingsOwned: StoreState["monsterlingsOwned"];
charactersOwned: StoreState["charactersOwned"];
artifactsOwned: StoreState["artifactsOwned"];
onNameChange: (name: string) => void;
onNotesChange: (notes: string) => void;
Expand All @@ -53,6 +55,7 @@ export const LoadoutEditor = ({
draft,
activeTab,
monsterlingsOwned,
charactersOwned,
artifactsOwned,
onNameChange,
onNotesChange,
Expand Down Expand Up @@ -103,7 +106,13 @@ export const LoadoutEditor = ({
>
<img
src={
character?.portraitImage ?? UNKNOWN_CHARACTER_PORTRAIT_IMAGE
(character && charactersOwned[character.id]
? resolveCharacterPortrait(
character,
charactersOwned[character.id],
)
: character?.portraitImage) ??
UNKNOWN_CHARACTER_PORTRAIT_IMAGE
}
alt=""
className="size-10 shrink-0 rounded-sm object-cover"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type PreviewCharacter = Character;
type CharacterOwned = {
awakening: number;
skills: Record<CharacterSkill, number>;
costume_id?: number | null;
};
type LoadoutPreviewCharacterProps = {
character: PreviewCharacter;
Expand Down
11 changes: 10 additions & 1 deletion src/components/loadouts/components/loadout-preview-row.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolveCharacterPortrait } from "@/components/characters/utils/character-costume";
import { isCharacterVisible } from "@/components/characters/utils/character-utils";
import {
MONSTERLING_CARD_WIDTH,
Expand Down Expand Up @@ -90,7 +91,15 @@ export const LoadoutPreviewRow = ({
<div className="grid items-center gap-3" style={{ gridTemplateColumns }}>
{character && characterOwned ? (
<LoadoutPreviewCharacter
character={character}
character={
character && {
...character,
portraitImage: resolveCharacterPortrait(
character,
characterOwned,
),
}
}
owned={characterOwned}
statValues={slot.stat_values ?? {}}
pinnedStatIds={slot.pinned_stat_ids ?? []}
Expand Down
3 changes: 3 additions & 0 deletions src/components/loadouts/components/loadouts-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "@/components/ui/dialog";
import { preventSearchInputDismissOnEscape } from "@/components/ui/search-input";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/stores/app-store";

type LoadoutsDialogProps = {
open: boolean;
Expand All @@ -38,6 +39,7 @@ export const LoadoutsDialog = ({
setOpen,
onClose,
);
const charactersOwned = useAppStore((state) => state.charactersOwned);
const title =
controller.pickerTarget?.type === LOADOUT_TARGET_TYPES.CHARACTER
? "Select Character"
Expand Down Expand Up @@ -157,6 +159,7 @@ export const LoadoutsDialog = ({
draft={controller.draft}
activeTab={controller.activeTab}
monsterlingsOwned={controller.monsterlingsOwned}
charactersOwned={charactersOwned}
artifactsOwned={controller.artifactsOwned}
onNameChange={controller.setName}
onNotesChange={controller.setNotes}
Expand Down
Loading
Loading