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();
+
+ expect(screen.getByRole("link", { name: "Characters" })).toBeTruthy();
+ expect(screen.getByRole("link", { name: "Artifacts" })).toBeTruthy();
+ expect(screen.queryByRole("link", { name: "Characters (1)" })).toBeNull();
+ expect(screen.queryByRole("link", { name: "Artifacts (1)" })).toBeNull();
+ });
+
+ it("shows catalog hidden counts in local development labels", () => {
+ vi.stubEnv("VITE_NODE_ENV", "development");
+ render();
+
+ expect(screen.getByRole("link", { name: "Characters (1)" })).toBeTruthy();
+ expect(screen.getByRole("link", { name: "Artifacts (1)" })).toBeTruthy();
+ });
+});
diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx
index 20d1bba..61a9d0e 100644
--- a/src/components/navigation.tsx
+++ b/src/components/navigation.tsx
@@ -10,7 +10,11 @@ import {
UserRoundCheck,
} from "lucide-react";
import type { JSX } from "react";
+import { isArtifactVisible } from "@/components/artifacts/utils/artifact-utils";
+import { isCharacterVisible } from "@/components/characters/utils/character-utils";
import { SeparatorText } from "@/components/shared/separator-text";
+import { ARTIFACTS_DATA } from "@/data/artifacts/ARTIFACTS_DATA";
+import { CHARACTERS_DATA } from "@/data/characters/CHARACTERS_DATA";
import { cn } from "@/lib/utils";
import type { FileRoutesByTo } from "@/routeTree.gen";
@@ -149,6 +153,17 @@ const hoverStyle = "hover:text-foreground hover:bg-accent/50";
export const Nav = () => {
const { pathname } = useLocation();
+ const showHiddenCatalog = import.meta.env.VITE_NODE_ENV === "development";
+ const hiddenCharacterCount = showHiddenCatalog
+ ? Object.values(CHARACTERS_DATA).filter(
+ ({ is_hidden }) => is_hidden && isCharacterVisible({ is_hidden }),
+ ).length
+ : 0;
+ const hiddenArtifactCount = showHiddenCatalog
+ ? Object.values(ARTIFACTS_DATA).filter(
+ ({ is_hidden }) => is_hidden && isArtifactVisible({ is_hidden }),
+ ).length
+ : 0;
return (