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
1 change: 1 addition & 0 deletions .abcd/work/DECISIONS.md

Large diffs are not rendered by default.

54 changes: 35 additions & 19 deletions src/components/CardCompactItem/CardCompactItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,30 +27,44 @@ interface CardCompactItemProps {
/**
* Compact view thumbnail card.
*/
export function CardCompactItem({ card, cardNumber, tabIndex = 0, width, height }: CardCompactItemProps) {
export function CardCompactItem({
card,
cardNumber,
tabIndex = 0,
width,
height,
}: CardCompactItemProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [originRect, setOriginRect] = useState<DOMRect | null>(null);
// Mount CardExpanded lazily: each instance registers a window resize listener
// (via useViewportSize) and several store subscriptions, so collapsed items
// must not mount it — the compact layout is not virtualised, so one instance
// per card would multiply into thousands. Once opened it stays mounted so its
// AnimatePresence exit animation can play on close. Mirrors Card.tsx.
const [hasOpenedExpanded, setHasOpenedExpanded] = useState(false);

const handleClick = useCallback((event: React.MouseEvent) => {
const target = event.currentTarget as HTMLElement;
setOriginRect(target.getBoundingClientRect());
setHasOpenedExpanded(true);
setIsModalOpen(true);
}, []);

// Custom style for dynamic sizing (fit view)
const customStyle = width && height ? { width: `${String(width)}px`, height: `${String(height)}px` } : undefined;
const customStyle =
width && height
? { width: `${String(width)}px`, height: `${String(height)}px` }
: undefined;

const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const target = event.currentTarget as HTMLElement;
setOriginRect(target.getBoundingClientRect());
setIsModalOpen(true);
}
},
[]
);
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const target = event.currentTarget as HTMLElement;
setOriginRect(target.getBoundingClientRect());
setHasOpenedExpanded(true);
setIsModalOpen(true);
}
}, []);

const handleCloseModal = useCallback(() => {
setIsModalOpen(false);
Expand Down Expand Up @@ -85,12 +99,14 @@ export function CardCompactItem({ card, cardNumber, tabIndex = 0, width, height
</div>
</motion.article>

<CardExpanded
card={card}
isOpen={isModalOpen}
onClose={handleCloseModal}
originRect={originRect}
/>
{hasOpenedExpanded && (
<CardExpanded
card={card}
isOpen={isModalOpen}
onClose={handleCloseModal}
originRect={originRect}
/>
)}
</>
);
}
Expand Down
51 changes: 30 additions & 21 deletions src/components/CardListItem/CardListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,36 @@ interface CardListItemProps {
/**
* List view card row.
*/
export function CardListItem({ card, cardNumber, tabIndex = 0 }: CardListItemProps) {
export function CardListItem({
card,
cardNumber,
tabIndex = 0,
}: CardListItemProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [originRect, setOriginRect] = useState<DOMRect | null>(null);
// Mount CardExpanded lazily: each instance registers a window resize listener
// (via useViewportSize) and several store subscriptions, so collapsed rows
// must not mount it — the list layout is not virtualised, so one instance per
// card would multiply into thousands. Once opened it stays mounted so its
// AnimatePresence exit animation can play on close. Mirrors Card.tsx.
const [hasOpenedExpanded, setHasOpenedExpanded] = useState(false);

const handleClick = useCallback((event: React.MouseEvent) => {
const target = event.currentTarget as HTMLElement;
setOriginRect(target.getBoundingClientRect());
setHasOpenedExpanded(true);
setIsModalOpen(true);
}, []);

const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const target = event.currentTarget as HTMLElement;
setOriginRect(target.getBoundingClientRect());
setIsModalOpen(true);
}
},
[]
);
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const target = event.currentTarget as HTMLElement;
setOriginRect(target.getBoundingClientRect());
setHasOpenedExpanded(true);
setIsModalOpen(true);
}
}, []);

const handleCloseModal = useCallback(() => {
setIsModalOpen(false);
Expand Down Expand Up @@ -92,18 +101,18 @@ export function CardListItem({ card, cardNumber, tabIndex = 0 }: CardListItemPro
</div>
</div>

{summary && (
<p className={styles.summary}>{summary}</p>
)}
{summary && <p className={styles.summary}>{summary}</p>}
</div>
</motion.article>

<CardExpanded
card={card}
isOpen={isModalOpen}
onClose={handleCloseModal}
originRect={originRect}
/>
{hasOpenedExpanded && (
<CardExpanded
card={card}
isOpen={isModalOpen}
onClose={handleCloseModal}
originRect={originRect}
/>
)}
</>
);
}
Expand Down
37 changes: 34 additions & 3 deletions src/lib/clearPersistedData.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/**
* Clear every piece of persisted itemdeck state (the "hard reset").
*
* State is spread across localStorage and three separate IndexedDB databases
* created by different subsystems, so a naive reset leaves data behind. This
* clears all of it to honour the "delete everything" promise.
* State is spread across localStorage, three separate IndexedDB databases
* created by different subsystems, and the service worker's Cache Storage
* buckets, so a naive reset leaves data behind. This clears all of it — and
* unregisters the service worker so it does not re-populate its caches after
* the reload — to honour the "delete everything" promise.
*/

import { deleteDB } from "@/db";
Expand Down Expand Up @@ -42,6 +44,33 @@ function deleteIndexedDb(name: string): Promise<void> {
});
}

/**
* Delete every Cache Storage bucket. The service worker (vite-plugin-pwa)
* caches remote collection JSON, settings.json, entity files and images into
* Cache Storage — a layer entirely separate from the IndexedDB caches above.
* Without this, a hard reset leaves the viewed collection and its imagery on
* disk, contradicting the dialog's "delete all your ... cached data" promise.
*/
async function clearCacheStorage(): Promise<void> {
if (typeof caches === "undefined") return;
const names = await caches.keys();
await Promise.all(names.map((name) => caches.delete(name)));
}

/**
* Unregister every service worker so it stops intercepting and re-populating
* Cache Storage after the reset reload.
*/
async function unregisterServiceWorkers(): Promise<void> {
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
return;
}
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(
registrations.map((registration) => registration.unregister())
);
}

/**
* Remove all persisted itemdeck data: every `itemdeck-` localStorage key,
* the app IndexedDB database, the cached-collection store, and the plugin
Expand All @@ -63,5 +92,7 @@ export async function clearAllPersistedData(): Promise<void> {
deleteDB(), // the app database ("itemdeck")
clearAllCollectionCaches(), // cached collections (idb-keyval store)
...SATELLITE_DATABASES.map((name) => deleteIndexedDb(name)),
clearCacheStorage(), // service-worker Cache Storage buckets
unregisterServiceWorkers(), // stop the SW re-populating them after reload
]);
}
28 changes: 27 additions & 1 deletion src/loaders/fieldPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ interface PathSegment {
filterField?: string;
}

/**
* Upper bound on a field-path expression.
*
* Paths come from untrusted collection.json display config
* (`display.card.front.title` etc.) and from persisted forced `fieldMapping`
* values in settings.json — both `z.string()` with no length cap. parsePath is
* quadratic in the path length for a bracket-free path (it re-scans the
* remaining string for `[` every iteration), so a multi-megabyte dotted path
* freezes the main thread for tens of seconds, once per card at grid render.
* Real paths are a handful of characters ("images[type=cover][0].url"); an
* expression longer than this is malformed, so it resolves to the fallback.
*/
const MAX_FIELD_PATH_LENGTH = 512;

/**
* Parse a field path into segments.
*
Expand Down Expand Up @@ -135,6 +149,13 @@ export function getFieldValue(
entity: Entity | ResolvedEntity,
path: string
): unknown {
// Guard the quadratic parser against an unbounded untrusted path. This is the
// single chokepoint every resolver (getStringValue/getNumberValue/
// getImagesValue and resolveFieldPath's fallback loop) funnels through.
if (typeof path !== "string" || path.length > MAX_FIELD_PATH_LENGTH) {
return undefined;
}

const segments = parsePath(path);
let current: unknown = entity;

Expand Down Expand Up @@ -310,7 +331,12 @@ export function resolveFieldPath(
): unknown {
// Defence in depth: a non-string expression (e.g. from a malformed forced
// fieldMapping) would throw in the split below, crashing every card render.
if (typeof expression !== "string") {
// The length bound also caps the whole fallback chain ("a ?? b ?? …") so a
// pathological expression cannot amplify the per-path work by its split count.
if (
typeof expression !== "string" ||
expression.length > MAX_FIELD_PATH_LENGTH
) {
return undefined;
}

Expand Down
37 changes: 31 additions & 6 deletions src/mechanics/competing/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import type {
} from "./types";
import { DEFAULT_SETTINGS } from "./types";
import { getCardValue, compareValues } from "./utils";
import { getAIStrategy, resetPatternTracker, recordPlayerSelection } from "./ai";
import {
getAIStrategy,
resetPatternTracker,
recordPlayerSelection,
} from "./ai";

/**
* Timeout IDs for cleanup - stored outside store to avoid serialisation issues.
Expand Down Expand Up @@ -121,7 +125,14 @@ export const useCompetingStore = create<CompetingStore>((set, get) => ({

resetGame: () => {
clearPendingTimeouts();
const { numericFields, cardData, difficulty, roundLimit, showCpuThinking, autoAdvance } = get();
const {
numericFields,
cardData,
difficulty,
roundLimit,
showCpuThinking,
autoAdvance,
} = get();

// Reset pattern tracker for new game
resetPatternTracker();
Expand Down Expand Up @@ -195,8 +206,17 @@ export const useCompetingStore = create<CompetingStore>((set, get) => ({
return;
}

// Build card data map, filtering out cards with missing values
const cardData: Record<string, CardData> = {};
// Build card data map, filtering out cards with missing values.
//
// Card ids come from untrusted collection data, so a null-prototype object
// is used: assigning a card object to "__proto__" on a plain object literal
// re-points the map's prototype instead of creating an own key, silently
// dropping that card from the deck (and skewing validCardCount below).
// Mirrors snap-ranking/store.ts.
const cardData: Record<string, CardData> = Object.create(null) as Record<
string,
CardData
>;
for (const card of config.cards) {
const id = card[config.idField];
if (typeof id !== "string") continue;
Expand Down Expand Up @@ -299,7 +319,11 @@ export const useCompetingStore = create<CompetingStore>((set, get) => ({
const gameContext = get().getGameContext();

// CPU selects stat and waits for player confirmation
const selectedStat = ai.selectStat(cpuCardData, state.numericFields, gameContext);
const selectedStat = ai.selectStat(
cpuCardData,
state.numericFields,
gameContext
);

set({
selectedStat,
Expand Down Expand Up @@ -477,7 +501,8 @@ export const useCompetingStore = create<CompetingStore>((set, get) => ({
currentTurn = currentTurn === "player" ? "cpu" : "player";
}

const nextPhase: GamePhase = currentTurn === "player" ? "player_select" : "cpu_select";
const nextPhase: GamePhase =
currentTurn === "player" ? "player_select" : "cpu_select";

set({
playerDeck,
Expand Down
21 changes: 20 additions & 1 deletion src/schemas/v2/collection.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,30 @@ export const cardBackConfigSchema = z.object({
text: z.string().optional(),
});

/**
* Bounds on the untrusted `verdictFields` ordering list. getDisplayableFields
* scans every entry against the entity's fields (O(specs × fields), two
* lowercase allocations per comparison), and CardExpanded runs that scan on
* mount — once per card in the non-virtualised list/compact layouts. An
* unbounded array therefore freezes the tab on collection load. The matched
* result is already capped at MAX_DISPLAYABLE_FIELDS (100), so more specs than
* that can never contribute an extra field; the per-string cap bounds each
* comparison. Truncate rather than reject, matching uiLabels, so one oversized
* value cannot deny the whole collection load.
*/
const MAX_VERDICT_FIELDS = 100;
const MAX_VERDICT_FIELD_LENGTH = 120;

export const cardDisplayConfigSchema = z.object({
front: cardFrontConfigSchema.optional(),
back: cardBackConfigSchema.optional(),
/** Fields to display in the Verdict view, in order. If empty/undefined, shows all in alphabetical order. */
verdictFields: z.array(z.string()).optional(),
verdictFields: z
.array(
z.string().transform((value) => value.slice(0, MAX_VERDICT_FIELD_LENGTH))
)
.transform((specs) => specs.slice(0, MAX_VERDICT_FIELDS))
.optional(),
});

export const displayConfigSchema = z.object({
Expand Down
8 changes: 5 additions & 3 deletions src/utils/entityFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,11 +417,13 @@ export function getDisplayableFields(
const orderedFields: DisplayableField[] = [];

for (const fieldSpec of options.verdictFields) {
// Find field by key or label (case-insensitive)
// Find field by key or label (case-insensitive). Lower-case the spec once
// per iteration rather than twice per candidate inside `find`.
const specLower = fieldSpec.toLowerCase();
const field = fields.find(
(f) =>
f.key.toLowerCase() === fieldSpec.toLowerCase() ||
f.label.toLowerCase() === fieldSpec.toLowerCase()
f.key.toLowerCase() === specLower ||
f.label.toLowerCase() === specLower
);
if (field) {
orderedFields.push(field);
Expand Down
Loading
Loading