From eab176814a0697ee9dc9a38f31920949d45d7a63 Mon Sep 17 00:00:00 2001 From: loothero Date: Sun, 26 Apr 2026 08:39:12 -0700 Subject: [PATCH] add beast appearance filters --- api/AGENTS.md | 2 +- api/README.md | 2 +- api/src/index.ts | 10 +++- client/src/api/summitApi.ts | 4 ++ .../src/components/dialogs/BeastDexModal.tsx | 59 ++++++++++++++++++- 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/api/AGENTS.md b/api/AGENTS.md index bb0ebd5f..c823a560 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -43,7 +43,7 @@ Read [`../AGENTS.md`](../AGENTS.md) first for shared addresses/mechanics and ind - subscribe payload: `{"type":"subscribe","channels":["summit","event"]}` Query/pagination rules agents usually need: -- `/beasts/all`: `limit` default `25`, max `100`; `offset`; filters `prefix`, `suffix`, `beast_id`, `name`, `owner`; `sort` in `summit_held_seconds|level`; `include_total` optional (`false` skips `count(*)`). +- `/beasts/all`: `limit` default `25`, max `100`; `offset`; filters `prefix`, `suffix`, `beast_id`, `name`, `owner`, `shiny`, `animated`; `sort` in `summit_held_seconds|level`; `include_total` optional (`false` skips `count(*)`). - `/logs`: `limit` default `50`, max `100`; `offset`; `category`, `sub_category` (comma-separated), `player`; `include_total` optional (`false` skips `count(*)`). - `/beasts/stats/top`: `limit` default `25`, max `100`; `offset`; `include_total` optional (`false` skips `count(*)`). - `/diplomacy`: `prefix` and `suffix` required; returns HTTP `400` if missing. diff --git a/api/README.md b/api/README.md index cbce0fce..583a4fc2 100644 --- a/api/README.md +++ b/api/README.md @@ -90,7 +90,7 @@ curl http://localhost:3001/health ### Query Parameters and Response Shapes `GET /beasts/all` -- params: `limit` (default `25`, max `100`), `offset`, `prefix`, `suffix`, `beast_id`, `name`, `owner`, `sort` (`summit_held_seconds|level`), `include_total` (`true|false`, default `true`) +- params: `limit` (default `25`, max `100`), `offset`, `prefix`, `suffix`, `beast_id`, `name`, `owner`, `shiny`, `animated`, `sort` (`summit_held_seconds|level`), `include_total` (`true|false`, default `true`) - returns: `{ data: Beast[], pagination: { limit, offset, total, has_more } }` `GET /logs` diff --git a/api/src/index.ts b/api/src/index.ts index 2ef112bd..e91a22ad 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -190,6 +190,8 @@ app.get("/health", async (c) => { * - beast_id: Filter by beast type ID (optional, indexed) * - name: Filter by beast name search (optional, uses beast_id index) * - owner: Filter by owner address (optional, indexed) + * - shiny: Filter by shiny trait, 1 for shiny beasts (optional) + * - animated: Filter by animated trait, 1 for animated beasts (optional) * - sort: Sort by "summit_held_seconds" or "level" (default: summit_held_seconds, both indexed) * - include_total: Set to false to skip count(*) and return pagination.total=null */ @@ -201,16 +203,20 @@ app.get("/beasts/all", async (c) => { const beastId = c.req.query("beast_id"); const name = c.req.query("name"); const ownerRaw = c.req.query("owner"); + const shiny = c.req.query("shiny"); + const animated = c.req.query("animated"); const sort = c.req.query("sort") || "summit_held_seconds"; const includeTotal = parseIncludeTotal(c.req.query("include_total")); const owner = ownerRaw ? normalizeAddress(ownerRaw) : undefined; - // Build where conditions (all filters use indexed columns) + // Build where conditions. const conditions = []; if (prefix) conditions.push(eq(beasts.prefix, parseInt(prefix, 10))); if (suffix) conditions.push(eq(beasts.suffix, parseInt(suffix, 10))); if (beastId) conditions.push(eq(beasts.beast_id, parseInt(beastId, 10))); if (owner) conditions.push(eq(beast_owners.owner, owner)); + if (shiny) conditions.push(eq(beasts.shiny, parseInt(shiny, 10))); + if (animated) conditions.push(eq(beasts.animated, parseInt(animated, 10))); if (name) { // Find beast IDs that match the name search (uses beast_id index) const lowerName = name.toLowerCase(); @@ -892,7 +898,7 @@ app.get("/", (c) => { health: "GET /health", beasts: { by_owner: "GET /beasts/:owner", - all: "GET /beasts/all?limit=25&offset=0&prefix=&suffix=&beast_id=&name=&owner=&sort=summit_held_seconds", + all: "GET /beasts/all?limit=25&offset=0&prefix=&suffix=&beast_id=&name=&owner=&shiny=&animated=&sort=summit_held_seconds", counts: "GET /beasts/stats/counts", top: "GET /beasts/stats/top?limit=25&offset=0", }, diff --git a/client/src/api/summitApi.ts b/client/src/api/summitApi.ts index 02d7dd12..37fb17a8 100644 --- a/client/src/api/summitApi.ts +++ b/client/src/api/summitApi.ts @@ -53,6 +53,8 @@ export interface GetAllBeastsParams { beast_id?: number; name?: string; owner?: string; + shiny?: number; + animated?: number; sort?: 'summit_held_seconds' | 'level'; } @@ -172,6 +174,8 @@ export const useSummitApi = () => { if (params.beast_id) searchParams.set('beast_id', params.beast_id.toString()); if (params.name) searchParams.set('name', params.name); if (params.owner) searchParams.set('owner', params.owner); + if (params.shiny) searchParams.set('shiny', params.shiny.toString()); + if (params.animated) searchParams.set('animated', params.animated.toString()); if (params.sort) searchParams.set('sort', params.sort); const response = await fetch(`${currentNetworkConfig.apiUrl}/beasts/all?${searchParams}`); diff --git a/client/src/components/dialogs/BeastDexModal.tsx b/client/src/components/dialogs/BeastDexModal.tsx index a3fc87ca..7a138925 100644 --- a/client/src/components/dialogs/BeastDexModal.tsx +++ b/client/src/components/dialogs/BeastDexModal.tsx @@ -32,6 +32,28 @@ type MySortKey = 'power' | 'level' | 'health' | 'name'; type AllSortKey = 'summit_held_seconds' | 'level'; type TypeKey = 'all' | 'Brute' | 'Hunter' | 'Magic'; type TabKey = 'mine' | 'all'; +type AppearanceFilterKey = 'all' | 'animated' | 'shiny' | 'animated_shiny'; + +const matchesAppearanceFilter = (beast: Pick, filter: AppearanceFilterKey) => { + const isShiny = Boolean(beast.shiny); + const isAnimated = Boolean(beast.animated); + + switch (filter) { + case 'animated': + return isAnimated; + case 'shiny': + return isShiny; + case 'animated_shiny': + return isAnimated && isShiny; + default: + return true; + } +}; + +const getAppearanceQueryParams = (filter: AppearanceFilterKey): { shiny?: number; animated?: number } => ({ + shiny: filter === 'shiny' || filter === 'animated_shiny' ? 1 : undefined, + animated: filter === 'animated' || filter === 'animated_shiny' ? 1 : undefined, +}); // Transform AllBeast API response to Beast type for display const transformAllBeast = (ab: AllBeast): Beast => { @@ -88,6 +110,7 @@ export default function BeastDexModal(props: BeastDexModalProps) { const [mySortBy, setMySortBy] = useState('power'); const [allSortBy, setAllSortBy] = useState('level'); const [typeFilter, setTypeFilter] = useState('all'); + const [appearanceFilter, setAppearanceFilter] = useState('all'); const [selectedBeast, setSelectedBeast] = useState(null); const [page, setPage] = useState(1); const pageSize = 24; @@ -121,6 +144,9 @@ export default function BeastDexModal(props: BeastDexModalProps) { if (typeFilter !== 'all') { list = list.filter(b => (b.type || '') === typeFilter); } + if (appearanceFilter !== 'all') { + list = list.filter(b => matchesAppearanceFilter(b, appearanceFilter)); + } switch (mySortBy) { case 'power': list.sort((a, b) => b.power - a.power); @@ -149,7 +175,7 @@ export default function BeastDexModal(props: BeastDexModalProps) { } return list; - }, [collection, myPrefixFilter, mySuffixFilter, myNameFilter, mySortBy, typeFilter, summit, filterTokenIds]); + }, [collection, myPrefixFilter, mySuffixFilter, myNameFilter, mySortBy, typeFilter, appearanceFilter, summit, filterTokenIds]); // Autocomplete options const beastNameOptions = useMemo(() => Object.values(BEAST_NAMES), []); @@ -173,7 +199,7 @@ export default function BeastDexModal(props: BeastDexModalProps) { useEffect(() => { setPage(1); - }, [myPrefixFilter, mySuffixFilter, myNameFilter, mySortBy, allSortBy, allNameFilter, allPrefixFilter, allSuffixFilter, typeFilter, collection.length, activeTab]); + }, [myPrefixFilter, mySuffixFilter, myNameFilter, mySortBy, allSortBy, allNameFilter, allPrefixFilter, allSuffixFilter, typeFilter, appearanceFilter, collection.length, activeTab]); // Fetch all beasts when on "All Beasts" tab useEffect(() => { @@ -189,6 +215,7 @@ export default function BeastDexModal(props: BeastDexModalProps) { name: allNameFilter || undefined, prefix: allPrefixFilter || undefined, suffix: allSuffixFilter || undefined, + ...getAppearanceQueryParams(appearanceFilter), sort: allSortBy, }); if (cancelled) return; @@ -215,7 +242,7 @@ export default function BeastDexModal(props: BeastDexModalProps) { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTab, open, page, allNameFilter, allPrefixFilter, allSuffixFilter, allSortBy]); + }, [activeTab, open, page, allNameFilter, allPrefixFilter, allSuffixFilter, allSortBy, appearanceFilter]); const handleSelect = (beast: Beast) => { setSelectedBeast(beast); @@ -416,6 +443,32 @@ export default function BeastDexModal(props: BeastDexModalProps) { )} + + Appearance + +