diff --git a/PLANS.md b/PLANS.md index 75341c6..20332b3 100644 --- a/PLANS.md +++ b/PLANS.md @@ -5,6 +5,7 @@ Keep this checklist aligned with repository behavior. Check off work in the same ## Characters: Available - [x] Add Brisshell as a Tier 5 Earth Assassin with her catalog images. +- [x] Stage Vivian and her signature artifact as hidden catalog records outside local development, with local-development catalog counts, until official metadata is available. - [x] Filter owned and selectable characters by Tier 4 and Tier 5. - [x] Sort owned characters by name or awakening level in either direction. - [x] Show owned/total roster progress and disable additions when every character is owned. diff --git a/assets/images-source/Character_Full/Img_CharacterIllust_Vivian.png b/assets/images-source/Character_Full/Img_CharacterIllust_Vivian.png new file mode 100644 index 0000000..e7ecd9a Binary files /dev/null and b/assets/images-source/Character_Full/Img_CharacterIllust_Vivian.png differ diff --git a/assets/images-source/Character_Portrait/portrait_Bigcat_Vivian_00.png b/assets/images-source/Character_Portrait/portrait_Bigcat_Vivian_00.png new file mode 100644 index 0000000..66232d0 Binary files /dev/null and b/assets/images-source/Character_Portrait/portrait_Bigcat_Vivian_00.png differ diff --git a/assets/images-source/Character_Portrait/portrait_Dimension_Vivian_01.png b/assets/images-source/Character_Portrait/portrait_Dimension_Vivian_01.png new file mode 100644 index 0000000..965bd56 Binary files /dev/null and b/assets/images-source/Character_Portrait/portrait_Dimension_Vivian_01.png differ diff --git a/assets/images-source/Character_Portrait/portrait_Vivian_01.png b/assets/images-source/Character_Portrait/portrait_Vivian_01.png new file mode 100644 index 0000000..53f7a89 Binary files /dev/null and b/assets/images-source/Character_Portrait/portrait_Vivian_01.png differ diff --git a/assets/images-source/Icon_Artifact/ArtifactVivian.png b/assets/images-source/Icon_Artifact/ArtifactVivian.png new file mode 100644 index 0000000..2b88118 Binary files /dev/null and b/assets/images-source/Icon_Artifact/ArtifactVivian.png differ diff --git a/public/images/Character_Full/Img_CharacterIllust_Vivian.webp b/public/images/Character_Full/Img_CharacterIllust_Vivian.webp new file mode 100644 index 0000000..f9a0df9 Binary files /dev/null and b/public/images/Character_Full/Img_CharacterIllust_Vivian.webp differ diff --git a/public/images/Character_Portrait/portrait_Bigcat_Vivian_00.webp b/public/images/Character_Portrait/portrait_Bigcat_Vivian_00.webp new file mode 100644 index 0000000..371df87 Binary files /dev/null and b/public/images/Character_Portrait/portrait_Bigcat_Vivian_00.webp differ diff --git a/public/images/Character_Portrait/portrait_Dimension_Vivian_01.webp b/public/images/Character_Portrait/portrait_Dimension_Vivian_01.webp new file mode 100644 index 0000000..ab47710 Binary files /dev/null and b/public/images/Character_Portrait/portrait_Dimension_Vivian_01.webp differ diff --git a/public/images/Character_Portrait/portrait_Vivian_01.webp b/public/images/Character_Portrait/portrait_Vivian_01.webp new file mode 100644 index 0000000..e224594 Binary files /dev/null and b/public/images/Character_Portrait/portrait_Vivian_01.webp differ diff --git a/public/images/Icon_Artifact/ArtifactVivian.webp b/public/images/Icon_Artifact/ArtifactVivian.webp new file mode 100644 index 0000000..7b939d3 Binary files /dev/null and b/public/images/Icon_Artifact/ArtifactVivian.webp differ diff --git a/src/components/artifacts/artifacts-page.test.tsx b/src/components/artifacts/artifacts-page.test.tsx index 117c6d7..47a423d 100644 --- a/src/components/artifacts/artifacts-page.test.tsx +++ b/src/components/artifacts/artifacts-page.test.tsx @@ -13,9 +13,13 @@ import { ARTIFACTS_DATA } from "@/data/artifacts/ARTIFACTS_DATA"; import { useAppStore } from "@/stores/app-store"; describe("ArtifactsPage", () => { - afterEach(cleanup); + afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + }); beforeEach(() => { + vi.stubEnv("VITE_NODE_ENV", "production"); Element.prototype.scrollIntoView = vi.fn(); useAppStore.setState({ artifactsOwned: { @@ -208,6 +212,21 @@ describe("ArtifactsPage", () => { expect(screen.getByRole("dialog")).toBeTruthy(); }); + it("keeps Vivian's hidden artifact out of the add dialog", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Add Artifact" })); + + expect(screen.queryByText("Vivian's Artifact")).toBeNull(); + }); + + it("shows Vivian's hidden artifact in local development", () => { + vi.stubEnv("VITE_NODE_ENV", "development"); + render(); + fireEvent.click(screen.getByRole("button", { name: "Add Artifact" })); + + expect(screen.getByText("Vivian's Artifact")).toBeTruthy(); + }); + it("allows another owned copy and resets fusion after closing", async () => { render(); const open = () => diff --git a/src/components/artifacts/utils/artifact-utils.ts b/src/components/artifacts/utils/artifact-utils.ts index 00e3135..2cde8b9 100644 --- a/src/components/artifacts/utils/artifact-utils.ts +++ b/src/components/artifacts/utils/artifact-utils.ts @@ -3,6 +3,9 @@ import type { CharacterClassId } from "@/data/character-classes/CHARACTER_CLASS_ import type { ElementId } from "@/data/elements/ELEMENTS_DATA"; import type { TierId } from "@/data/tiers/TIERS_DATA"; +export const isArtifactVisible = ({ is_hidden }: Pick) => + import.meta.env.VITE_NODE_ENV === "development" || !is_hidden; + export const FUSION_LEVELS = [1, 2, 3, 4, 5] as const; export type ArtifactFilters = { @@ -26,6 +29,7 @@ export const filterArtifacts = ( const search = filters.search?.trim().toLowerCase(); return artifacts.filter( (a) => + isArtifactVisible(a) && (!search || a.name.toLowerCase().includes(search)) && (!filters.selectedTiers?.length || filters.selectedTiers.includes(a.tier_id)) && diff --git a/src/components/characters/components/add-character.tsx b/src/components/characters/components/add-character.tsx index 41121c4..d90dbe0 100644 --- a/src/components/characters/components/add-character.tsx +++ b/src/components/characters/components/add-character.tsx @@ -4,7 +4,10 @@ import CharacterCard from "@/components/characters/components/character-card"; import { CharacterOwnedDetailsForm } from "@/components/characters/components/character-details-form"; import { CharacterFilter } from "@/components/characters/components/character-filter"; import { emptyCharacterFilters } from "@/components/characters/store/characters-filter-store"; -import { matchesCharacterFilters } from "@/components/characters/utils/character-utils"; +import { + isCharacterVisible, + matchesCharacterFilters, +} from "@/components/characters/utils/character-utils"; import { TierPortrait } from "@/components/shared/tier-portrait"; import { Button } from "@/components/ui/button"; import { @@ -31,7 +34,7 @@ export function AddCharacter() { const charToAddInfo = hasSelectedChar ? CHARACTERS_DATA[charToAdd] : null; const ownedSet = new Set(Object.values(charactersOwned).map((c) => c.id)); - const characters = Object.values(CHARACTERS_DATA); + const characters = Object.values(CHARACTERS_DATA).filter(isCharacterVisible); const ownedCount = characters.filter((character) => ownedSet.has(character.id), ).length; diff --git a/src/components/characters/components/character-filter.test.tsx b/src/components/characters/components/character-filter.test.tsx index df8a45f..2e13fb1 100644 --- a/src/components/characters/components/character-filter.test.tsx +++ b/src/components/characters/components/character-filter.test.tsx @@ -41,9 +41,13 @@ const ownedCharacterNames = () => .map((element) => element.textContent); describe("character search", () => { - afterEach(cleanup); + afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + }); beforeEach(() => { + vi.stubEnv("VITE_NODE_ENV", "production"); Element.prototype.scrollIntoView = vi.fn(); useAppStore.setState({ charactersOwned: owned }); useCharacterFilter.setState({ @@ -93,7 +97,10 @@ describe("character search", () => { }); it("shows roster progress and disables adding when every character is owned", () => { - const total = Object.keys(CHARACTERS_DATA).length; + const visibleCharacters = Object.values(CHARACTERS_DATA).filter( + ({ is_hidden }) => !is_hidden, + ); + const total = visibleCharacters.length; render(); expect(screen.getByText(`3/${total}`)).toBeTruthy(); @@ -106,10 +113,7 @@ describe("character search", () => { act(() => { useAppStore.setState({ charactersOwned: Object.fromEntries( - Object.values(CHARACTERS_DATA).map(({ id }) => [ - id, - { ...owned[1], id }, - ]), + visibleCharacters.map(({ id }) => [id, { ...owned[1], id }]), ), }); }); @@ -124,6 +128,29 @@ describe("character search", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); + it("keeps hidden Vivian out of the add list and roster total", () => { + render(); + + expect( + screen.getByText(`3/${Object.keys(CHARACTERS_DATA).length - 1}`), + ).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Add Character" })); + + expect(screen.queryByText("Vivian")).toBeNull(); + }); + + it("shows hidden Vivian in local development", () => { + vi.stubEnv("VITE_NODE_ENV", "development"); + render(); + + expect( + screen.getByText(`3/${Object.keys(CHARACTERS_DATA).length}`), + ).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Add Character" })); + + expect(screen.getByText("Vivian")).toBeTruthy(); + }); + it("filters candidates locally and clears before closing Add Character", () => { useAppStore.setState({ charactersOwned: { 1: owned[1] } }); render(); diff --git a/src/components/characters/utils/character-utils.ts b/src/components/characters/utils/character-utils.ts index 6000e10..d821665 100644 --- a/src/components/characters/utils/character-utils.ts +++ b/src/components/characters/utils/character-utils.ts @@ -1,6 +1,11 @@ import type { CharacterFilters } from "@/components/characters/store/characters-filter-store"; import type { Character } from "@/data/characters/CHARACTERS_DATA"; +export const isCharacterVisible = ({ + is_hidden, +}: Pick) => + import.meta.env.VITE_NODE_ENV === "development" || !is_hidden; + export function getAwakeningBonus(awakeningBoost: number) { if (awakeningBoost >= 5) return 4; if (awakeningBoost >= 3) return 2; @@ -12,9 +17,13 @@ export const isMaxSkill = (level: number) => { }; export const matchesCharacterFilters = ( - character: Pick, + character: Pick< + Character, + "class_id" | "element_id" | "is_hidden" | "name" | "tier_id" + >, filters: CharacterFilters, ) => + isCharacterVisible(character) && (!filters.search || character.name.toLowerCase().includes(filters.search.toLowerCase())) && (!filters.selectedCharacterClass.length || diff --git a/src/components/loadouts/components/loadout-card-character-row.tsx b/src/components/loadouts/components/loadout-card-character-row.tsx index c92d924..b3a3311 100644 --- a/src/components/loadouts/components/loadout-card-character-row.tsx +++ b/src/components/loadouts/components/loadout-card-character-row.tsx @@ -1,3 +1,5 @@ +import { isArtifactVisible } from "@/components/artifacts/utils/artifact-utils"; +import { isCharacterVisible } from "@/components/characters/utils/character-utils"; import { TierPortrait } from "@/components/shared/tier-portrait"; import { ARTIFACTS_DATA } from "@/data/artifacts/ARTIFACTS_DATA"; import { CHARACTERS_DATA } from "@/data/characters/CHARACTERS_DATA"; @@ -40,15 +42,23 @@ export const LoadoutCardCharacterRow = ({ onEditMonsterling, onEditArtifact, }: LoadoutCardCharacterRowProps) => { - const character = + const catalogCharacter = slot.characterId !== null ? CHARACTERS_DATA[slot.characterId] : null; + const character = + catalogCharacter && isCharacterVisible(catalogCharacter) + ? catalogCharacter + : null; const characterOwned = slot.characterId !== null ? charactersOwned[slot.characterId] : null; const artifactId = slot.artifactInstanceId; const artifactOwned = artifactId ? artifactsOwned[artifactId] : null; - const artifact = artifactOwned + const catalogArtifact = artifactOwned ? ARTIFACTS_DATA[artifactOwned.artifact_id] : null; + const artifact = + catalogArtifact && isArtifactVisible(catalogArtifact) + ? catalogArtifact + : null; return (
{SLOT_INDEXES.map((index) => { const characterId = draft.characters[index].characterId; - const character = + const catalogCharacter = characterId === null ? null : CHARACTERS_DATA[characterId]; + const character = + catalogCharacter && isCharacterVisible(catalogCharacter) + ? catalogCharacter + : null; return ( {SLOT_INDEXES.map((index) => { const slot = draft.characters[index]; - const character = + const catalogCharacter = slot.characterId === null ? null : CHARACTERS_DATA[slot.characterId]; + const character = + catalogCharacter && isCharacterVisible(catalogCharacter) + ? catalogCharacter + : null; const artifactId = slot.artifactInstanceId; const artifactOwned = artifactId ? artifactsOwned[artifactId] : null; - const artifact = artifactOwned + const catalogArtifact = artifactOwned ? ARTIFACTS_DATA[artifactOwned.artifact_id] : null; + const artifact = + catalogArtifact && isArtifactVisible(catalogArtifact) + ? catalogArtifact + : null; const equipmentIds = slot.equipment_ids ?? [null, null, null, null]; return ( { const item = id ? owned[id] : null; - const artifact = item ? ARTIFACTS_DATA[item.artifact_id] : null; + const catalogArtifact = item ? ARTIFACTS_DATA[item.artifact_id] : null; + const artifact = + catalogArtifact && isArtifactVisible(catalogArtifact) + ? catalogArtifact + : null; if (!item || !artifact || !id) return ; const card = ( diff --git a/src/components/loadouts/components/loadout-preview-row.tsx b/src/components/loadouts/components/loadout-preview-row.tsx index fcb982f..5a2a50b 100644 --- a/src/components/loadouts/components/loadout-preview-row.tsx +++ b/src/components/loadouts/components/loadout-preview-row.tsx @@ -1,3 +1,4 @@ +import { isCharacterVisible } from "@/components/characters/utils/character-utils"; import { MONSTERLING_CARD_WIDTH, MONSTERLING_COMPACT_CARD_WIDTH, @@ -42,8 +43,12 @@ export const LoadoutPreviewRow = ({ onEditMonsterling, onEditArtifact, }: LoadoutPreviewRowProps) => { - const character = + const catalogCharacter = slot.characterId === null ? null : CHARACTERS_DATA[slot.characterId]; + const character = + catalogCharacter && isCharacterVisible(catalogCharacter) + ? catalogCharacter + : null; const monsterlingCardWidth = monsterlingStatsDisplay === "icons" ? MONSTERLING_COMPACT_CARD_WIDTH diff --git a/src/components/loadouts/components/loadouts-list-ui.test.tsx b/src/components/loadouts/components/loadouts-list-ui.test.tsx index b0d8a58..05776cb 100644 --- a/src/components/loadouts/components/loadouts-list-ui.test.tsx +++ b/src/components/loadouts/components/loadouts-list-ui.test.tsx @@ -118,6 +118,7 @@ describe("LoadoutsList", () => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it("renders the shared centered empty-state treatment", () => { @@ -135,6 +136,41 @@ describe("LoadoutsList", () => { ).toBeTruthy(); }); + it("hides persisted hidden catalog assignments outside local development", () => { + vi.stubEnv("VITE_NODE_ENV", "production"); + useAppStore.setState({ + charactersOwned: { + 25: { + id: 25, + awakening: 0, + skills: { basic: 1, switch: 1, special: 1, ultimate: 1 }, + }, + }, + artifactsOwned: { vivian: { artifact_id: 39, fusion_level: 1 } }, + loadouts: { + team: { + ...teamLoadout, + characters: [ + { + ...teamLoadout.characters[0], + characterId: 25, + artifactInstanceId: "vivian", + }, + teamLoadout.characters[1], + teamLoadout.characters[2], + ], + }, + }, + }); + + render(); + + expect(screen.queryByAltText("Vivian portrait")).toBeNull(); + expect(screen.queryByAltText("Vivian's Artifact portrait")).toBeNull(); + expect(screen.getByText("Artifact unavailable")).toBeTruthy(); + expect(screen.getByAltText("Unknown character portrait")).toBeTruthy(); + }); + it("searches loadout and assigned character names while preserving alphabetical order", () => { useAppStore.setState({ charactersOwned, diff --git a/src/components/loadouts/components/loadouts-list.tsx b/src/components/loadouts/components/loadouts-list.tsx index 20e7863..c3ad7f5 100644 --- a/src/components/loadouts/components/loadouts-list.tsx +++ b/src/components/loadouts/components/loadouts-list.tsx @@ -4,6 +4,7 @@ import toast from "react-hot-toast"; import { useGoogleAnalytics } from "tanstack-router-ga4"; import { EditArtifactDetailsDialog } from "@/components/artifacts/components/edit-artifact-details-dialog"; import { EditCharacterDetailsDialog } from "@/components/characters/components/edit-character-details-dialog"; +import { isCharacterVisible } from "@/components/characters/utils/character-utils"; import { CreateLoadoutSnapshotDialog } from "@/components/loadout-snapshots/components/create-loadout-snapshot-dialog"; import { LoadoutCard } from "@/components/loadouts/components/loadout-card"; import { @@ -65,7 +66,11 @@ export const LoadoutsList = () => { loadout.name, ...loadout.characters.flatMap(({ characterId }) => { if (characterId === null) return []; - const character = CHARACTERS_DATA[characterId]; + const catalogCharacter = CHARACTERS_DATA[characterId]; + const character = + catalogCharacter && isCharacterVisible(catalogCharacter) + ? catalogCharacter + : null; return character ? [character.name] : []; }), ].some((searchableName) => diff --git a/src/components/loadouts/hooks/use-loadout-dialog-controller.ts b/src/components/loadouts/hooks/use-loadout-dialog-controller.ts index ecaa1af..8a6aa16 100644 --- a/src/components/loadouts/hooks/use-loadout-dialog-controller.ts +++ b/src/components/loadouts/hooks/use-loadout-dialog-controller.ts @@ -8,7 +8,10 @@ import { filterArtifacts, } from "@/components/artifacts/utils/artifact-utils"; import { emptyCharacterFilters } from "@/components/characters/store/characters-filter-store"; -import { matchesCharacterFilters } from "@/components/characters/utils/character-utils"; +import { + isCharacterVisible, + matchesCharacterFilters, +} from "@/components/characters/utils/character-utils"; import { type EquipmentFilters, emptyEquipmentFilters, @@ -319,9 +322,14 @@ export function useLoadoutDialogController( character_slot: characterIndex, }); const id = draft.characters[characterIndex].characterId; + const catalogCharacter = id === null ? null : CHARACTERS_DATA[id]; + const character = + catalogCharacter && isCharacterVisible(catalogCharacter) + ? catalogCharacter + : null; setCharacterFilters({ ...emptyCharacterFilters(), - search: id === null ? "" : (CHARACTERS_DATA[id]?.name ?? ""), + search: character?.name ?? "", }); setPickerTarget({ type: LOADOUT_TARGET_TYPES.CHARACTER, characterIndex }); }; diff --git a/src/components/navigation.test.tsx b/src/components/navigation.test.tsx new file mode 100644 index 0000000..e3e4182 --- /dev/null +++ b/src/components/navigation.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import type { ComponentProps, ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Nav } from "@/components/navigation"; + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ to, children, ...props }: ComponentProps<"a"> & { to: string }) => ( + + {children as ReactNode} + + ), + useLocation: () => ({ pathname: "/" }), +})); + +describe("Nav", () => { + afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + }); + + it("keeps hidden catalog counts out of production labels", () => { + vi.stubEnv("VITE_NODE_ENV", "production"); + render(