diff --git a/CLAUDE.md b/CLAUDE.md index 73ef14e7..ca215fdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ A web companion site for EverQuest 2 (TLE), with a Discord bot for spot checks ( | `backend/server/api/server.py` | `GET /api/server` — bootstraps the frontend with the active server's world, display name, max_level, current_xpac, launch_dt, and the full public server list | | `backend/server/cache.py` | TTLCache with stale-while-revalidate: character_cache, guild_cache, claim_cache. Character and guild read paths serve from `census_store` first and never block on Census. | | `backend/server/api/aa.py` | GET /api/character/{name}/aas — AA profile list with per-tree data | +| `backend/server/api/character/gear_sets.py` | GET /api/character/{name}/gear-sets — saved in-game equipment sets (Census `adventure_sets`), store-first SWR mirroring aa.py; feeds the character-sheet set pills + the compare per-side set picker. Each set carries `stat_deltas` (set − worn, additive stats + active item-set bonuses, from items.db `item_stats`) computed in sibling `stat_deltas.py` — the sheet shows approximated stats when a set is selected | | `backend/server/api/characters.py` | GET /api/characters/search — character name search | | `backend/server/api/guild_officer.py` | Officer claim-review endpoints; imports _officer_chars, _roster_rank_map from guild.py | | `backend/server/api/item_watch.py` | Item watch endpoints; imports _officer_chars, _roster_rank_map from guild.py | diff --git a/backend/census/client.py b/backend/census/client.py index d07990b1..81fe4533 100644 --- a/backend/census/client.py +++ b/backend/census/client.py @@ -18,6 +18,7 @@ CharacterOverview, CharacterSpells, EquipmentSlot, + GearSet, GuildData, GuildMember, ItemData, @@ -428,6 +429,29 @@ async def get_character(self, name: str, world: str) -> CharacterOverview | None spell_ids=spell_ids, ) + async def get_gear_sets(self, character_id: str | int) -> list[GearSet] | None: + """A character's saved in-game equipment sets (adventure_sets + collection), each parsed through the same slot pipeline as live + equipment so items/adorns resolve identically. + + Returns None on Census failure (caller decides how to degrade), + [] when the character simply has no saved sets. + """ + data = await self._census_get("adventure_sets/", {"id": str(character_id), "c:limit": "1"}) + if data is None: + return None + rows = data.get("adventure_sets_list", []) + if not rows: + return [] + sets: list[GearSet] = [] + for raw in rows[0].get("set_list") or []: + if not isinstance(raw, dict): + continue + name = str(raw.get("name") or "").strip() or "Unnamed set" + equipment = await self._parse_equipment(raw.get("equipmentslot_list") or []) + sets.append(GearSet(name=name, equipment=equipment)) + return sets + async def get_character_aas(self, name: str, world: str) -> CharacterAAs | None: params = { "name.first": name, diff --git a/backend/census/models.py b/backend/census/models.py index 90355754..42363c34 100644 --- a/backend/census/models.py +++ b/backend/census/models.py @@ -115,6 +115,15 @@ class EquipmentSlot: adorn_slots: list = field(default_factory=list) # list[AdornSlot] +@dataclass +class GearSet: + """One saved in-game equipment set from the adventure_sets collection. + ``name`` is the player-given label ("DPS", "Tank", ...).""" + + name: str + equipment: list = field(default_factory=list) # list[EquipmentSlot] + + @dataclass class CharacterOverview: id: str diff --git a/backend/census/store.py b/backend/census/store.py index 9645c3a4..cbb0d2c0 100644 --- a/backend/census/store.py +++ b/backend/census/store.py @@ -65,6 +65,7 @@ def _create_schema(self, conn: sqlite3.Connection) -> None: conn.execute(_SQL["schema_characters"]) conn.execute(_SQL["schema_guilds"]) conn.execute(_SQL["schema_character_aas"]) + conn.execute(_SQL["schema_character_gear_sets"]) self._apply_migrations(conn, _MIGRATIONS) # ── Characters ─────────────────────────────────────────────────────────── @@ -180,6 +181,36 @@ def upsert_character_aas( conn.execute(_SQL["upsert_character_aas"], (name.lower(), world, json.dumps(data), now)) conn.commit() + # ── Character gear sets ────────────────────────────────────────────────── + + @staticmethod + def get_character_gear_sets(conn: sqlite3.Connection, name: str, world: str) -> StoreRecord | None: + """The persisted gear-sets response dict (or None) for (name, world).""" + row = conn.execute(_SQL["select_character_gear_sets"], (name.lower(), world)).fetchone() + if row is None: + return None + return {"data": json.loads(row[0]), "last_resolved_at": row[1]} + + @staticmethod + def upsert_character_gear_sets( + conn: sqlite3.Connection, + name: str, + world: str, + data: dict, + *, + now: int | None = None, + ) -> None: + """Insert or update the (name, world) gear-sets record. Always + overwrites — like AAs there is no best-known merge; the Census + response is authoritative.""" + if now is None: + now = int(time.time()) + conn.execute( + _SQL["upsert_character_gear_sets"], + (name.lower(), world, json.dumps(data), now), + ) + conn.commit() + # The shared default instance — every runtime consumer goes through this. store = CensusStore() diff --git a/backend/census/store.sql b/backend/census/store.sql index 5961949e..29d46b45 100644 --- a/backend/census/store.sql +++ b/backend/census/store.sql @@ -38,6 +38,15 @@ CREATE TABLE IF NOT EXISTS character_aas ( PRIMARY KEY (name_lower, world) ); +-- :name schema_character_gear_sets +CREATE TABLE IF NOT EXISTS character_gear_sets ( + name_lower TEXT NOT NULL, + world TEXT NOT NULL, + data_json TEXT NOT NULL, + last_resolved_at INTEGER NOT NULL, + PRIMARY KEY (name_lower, world) +); + -- --------------------------------------------------------------------------- -- DML -- --------------------------------------------------------------------------- @@ -68,3 +77,9 @@ SELECT data_json, last_resolved_at FROM character_aas WHERE name_lower = ? AND w -- :name upsert_character_aas INSERT OR REPLACE INTO character_aas (name_lower, world, data_json, last_resolved_at) VALUES (?, ?, ?, ?); + +-- :name select_character_gear_sets +SELECT data_json, last_resolved_at FROM character_gear_sets WHERE name_lower = ? AND world = ?; + +-- :name upsert_character_gear_sets +INSERT OR REPLACE INTO character_gear_sets (name_lower, world, data_json, last_resolved_at) VALUES (?, ?, ?, ?); diff --git a/backend/eq2db/items.py b/backend/eq2db/items.py index 965c5fc5..dc6a8628 100644 --- a/backend/eq2db/items.py +++ b/backend/eq2db/items.py @@ -559,6 +559,35 @@ def gear_for_ids(self, ids: list[int]) -> dict[int, GearRow]: finally: conn.close() + def stats_for_ids(self, ids: list[int]) -> list[tuple[int, str, float]]: + """All ``(item_id, stat, value)`` rows for the given ids (read-only). + + One row per stat per distinct id — callers holding duplicate ids + (two copies of the same ring) must weight by occurrence themselves. + Returns [] if the DB doesn't exist yet.""" + if not ids or not self.path.exists(): + return [] + conn = sqlite3.connect(f"file:{self.path}?mode=ro", uri=True) + try: + placeholders = ",".join("?" for _ in ids) + rows = conn.execute(_SQL["stats_for_ids"].format(placeholders=placeholders), ids) + return [(row[0], row[1], row[2]) for row in rows] + finally: + conn.close() + + def set_bonus_rows_for_ids(self, ids: list[int]) -> list[tuple[int, str, str | None]]: + """``(item_id, setbonus_name, raw_json)`` for the ids that belong to an + item set (read-only). Returns [] if the DB doesn't exist yet.""" + if not ids or not self.path.exists(): + return [] + conn = sqlite3.connect(f"file:{self.path}?mode=ro", uri=True) + try: + placeholders = ",".join("?" for _ in ids) + rows = conn.execute(_SQL["set_bonus_rows_for_ids"].format(placeholders=placeholders), ids) + return [(row[0], row[1], row[2]) for row in rows] + finally: + conn.close() + async def find_by_name(self, name: str) -> dict | None: """Return raw Census JSON dict for the closest name match, or None.""" if not self.path.exists(): diff --git a/backend/eq2db/items.sql b/backend/eq2db/items.sql index 5cd38d98..1d3fd4ba 100644 --- a/backend/eq2db/items.sql +++ b/backend/eq2db/items.sql @@ -251,6 +251,15 @@ SELECT id, ilvl, wield_style, level_to_use, tier_display FROM items WHERE id IN -- :name find_by_id_raw_json SELECT raw_json FROM items WHERE id = ? LIMIT 1; +-- :name stats_for_ids +-- {placeholders} = comma-joined "?,?,..." +SELECT item_id, stat, value FROM item_stats WHERE item_id IN ({placeholders}); + +-- :name set_bonus_rows_for_ids +-- {placeholders} = comma-joined "?,?,..." +SELECT id, setbonus_name, raw_json FROM items +WHERE id IN ({placeholders}) AND setbonus_name IS NOT NULL; + -- find_by_name composes one of these depending on SERVER_MAX_LEVEL + -- exact-vs-LIKE. {where} is the column condition: 'displayname_lower = ?' -- or 'displayname_lower LIKE ? ESCAPE \\'. diff --git a/backend/server/api/character/__init__.py b/backend/server/api/character/__init__.py index 8627de76..de5f9b80 100644 --- a/backend/server/api/character/__init__.py +++ b/backend/server/api/character/__init__.py @@ -2,6 +2,7 @@ Sub-modules: - views — GET /character/{name}, _build_char_response, equipment helpers + - gear_sets — GET /character/{name}/gear-sets (saved in-game equipment sets) - spells — GET /character/{name}/spells - upgrades — GET /character/{name}/upgrade-materials + /upgrade-recipes """ @@ -12,6 +13,7 @@ router = APIRouter(tags=["character"]) +from backend.server.api.character import gear_sets as _gear_sets # noqa: E402,F401 from backend.server.api.character import spells as _spells # noqa: E402,F401 from backend.server.api.character import upgrades as _upgrades # noqa: E402,F401 from backend.server.api.character import views as _views # noqa: E402,F401 diff --git a/backend/server/api/character/gear_sets.py b/backend/server/api/character/gear_sets.py new file mode 100644 index 00000000..8f2398d9 --- /dev/null +++ b/backend/server/api/character/gear_sets.py @@ -0,0 +1,207 @@ +"""GET /character/{name}/gear-sets — the character's saved in-game +equipment sets (Census ``adventure_sets`` collection). + +Mirrors the AA read path: serve last-known data instantly from +census_store, refresh from Census only in the background, fall through +to one live fetch for never-seen characters. The character's Census id +comes from the (already cached) character record, so a warm character +page costs zero extra Census calls to render the tabs. +""" + +from __future__ import annotations + +import asyncio +import logging +import time + +from fastapi import HTTPException +from pydantic import BaseModel + +from backend.census.store import StoreRecord +from backend.census.store import store as census_store +from backend.core.log_safety import scrub +from backend.eq2db.items import catalogue as _items +from backend.server.api.character import router +from backend.server.api.character.stat_deltas import compute_stat_deltas +from backend.server.api.character.views import ( + AdornSlotResponse, + EquipmentSlotResponse, + _adorn_ilvl_bonus, + _build_char_response, + _equipment_lookup_ids, + _heal_equipment_placeholders, + _ilvl_from_gear, +) +from backend.server.cache import character_cache, gear_sets_cache +from backend.server.constants import CHARACTER_STALE_S +from backend.server.core.cache_keys import char_cache_key, gear_sets_cache_key +from backend.server.core.census_lifecycle import shared_census_client +from backend.server.core.executor import run_sync +from backend.server.server_context import current_world + +_log = logging.getLogger(__name__) + + +class GearSetResponse(BaseModel): + name: str + ilvl: float | None = None # average item level of the set's gear + # Approximate sheet-stat movement of equipping this set instead of the + # current gear: CharacterStats field → (set − worn). See stat_deltas.py. + stat_deltas: dict[str, float] = {} + equipment: list[EquipmentSlotResponse] = [] + + +class CharGearSetsResponse(BaseModel): + character_name: str + sets: list[GearSetResponse] = [] + + +def _sets_response_from_census( + name: str, sets: list, current_equipment: list[EquipmentSlotResponse] +) -> CharGearSetsResponse: + """Census GearSet dataclasses → response models, with per-set average + ilvl computed through the same gear map the character sheet uses and + stat deltas approximated against the currently worn gear.""" + out: list[GearSetResponse] = [] + for gs in sets: + slots = [ + EquipmentSlotResponse( + slot=s.slot_name, + name=s.item_name, + item_id=s.item_id, + icon_id=s.icon_id, + tier=s.tier, + adorn_slots=[ + AdornSlotResponse(color=a.color, adorn_name=a.adorn_name, adorn_id=a.adorn_id) + for a in s.adorn_slots + ], + ) + for s in gs.equipment + ] + gear = _items.gear_for_ids(_equipment_lookup_ids(slots)) + for slot in slots: + for adorn in slot.adorn_slots: + adorn.ilvl_bonus = _adorn_ilvl_bonus(adorn, gear) + out.append( + GearSetResponse( + name=gs.name, + ilvl=_ilvl_from_gear(slots, gear), + stat_deltas=compute_stat_deltas(slots, current_equipment), + equipment=slots, + ) + ) + return CharGearSetsResponse(character_name=name, sets=out) + + +async def _character(name: str): + """The character's response record (id + worn equipment) via the normal + store-first character read. 404s when the character is unknown everywhere.""" + cache_key = char_cache_key(name, current_world()) + cached, _ = character_cache.get_stale(cache_key) + if cached is not None: + return cached + async with shared_census_client() as client: + char = await client.get_character(name, current_world()) + if char is None: + raise HTTPException(status_code=404, detail=f"Character '{name}' not found on {current_world()}") + result = _build_char_response(char) + character_cache.set(cache_key, result) + return result + + +async def _fetch_and_build(name: str) -> CharGearSetsResponse | None: + """One live Census round-trip → response model. None on Census failure.""" + char = await _character(name) + async with shared_census_client() as client: + sets = await client.get_gear_sets(char.id) + if sets is None: + return None + resp = _sets_response_from_census(name, sets, char.equipment) + for gs in resp.sets: + await _heal_equipment_placeholders(gs.equipment) + return resp + + +async def _bg_refresh_gear_sets(name: str, cache_key: str) -> None: + """Background task: silently re-fetch, persist to census_store, update + the in-memory cache.""" + try: + world = current_world() + result = await _fetch_and_build(name) + if result is None: + return + now = int(time.time()) + + def _write() -> None: + conn = census_store.init_db() + try: + census_store.upsert_character_gear_sets(conn, name, world, result.model_dump(), now=now) + finally: + conn.close() + + await run_sync(_write) + gear_sets_cache.set(cache_key, result) + except Exception as exc: + _log.warning("[cache] Background gear-sets refresh failed for %s: %s", scrub(name), exc) + + +@router.get("/character/{name}/gear-sets", response_model=CharGearSetsResponse) +async def get_character_gear_sets(name: str) -> CharGearSetsResponse: + """Serve last-known gear sets instantly; refresh in the background.""" + world = current_world() + cache_key = gear_sets_cache_key(name, world) + now = int(time.time()) + + cached, is_stale = gear_sets_cache.get_stale(cache_key) + if cached is not None: + if is_stale: + asyncio.create_task(_bg_refresh_gear_sets(name, cache_key)) + return cached + + def _read() -> StoreRecord | None: + conn = census_store.init_db() + try: + return census_store.get_character_gear_sets(conn, name, world) + finally: + conn.close() + + rec = await run_sync(_read) + if rec is not None: + stale = (now - rec["last_resolved_at"]) > CHARACTER_STALE_S + if stale: + asyncio.create_task(_bg_refresh_gear_sets(name, cache_key)) + resp = CharGearSetsResponse(**rec["data"]) + gear_sets_cache.set(cache_key, resp) + return resp + + # Never seen — one live fetch. + from backend.server import census_health + + if census_health.is_down(): + _log.debug("[gear-sets] Skipping live fetch — census_health=down (name=%s)", scrub(name)) + raise HTTPException( + status_code=503, + detail=f"'{name}' gear sets not cached yet and Census is unavailable. Try again shortly.", + ) + try: + result = await _fetch_and_build(name) + except HTTPException: + raise + except Exception: + result = None + if result is None: + raise HTTPException( + status_code=503, + detail=f"'{name}' gear sets not cached yet and Census is unavailable. Try again shortly.", + ) + + def _write() -> None: + conn = census_store.init_db() + try: + census_store.upsert_character_gear_sets(conn, name, world, result.model_dump(), now=now) + finally: + conn.close() + + await run_sync(_write) + gear_sets_cache.set(cache_key, result) + return result diff --git a/backend/server/api/character/stat_deltas.py b/backend/server/api/character/stat_deltas.py new file mode 100644 index 00000000..09260339 --- /dev/null +++ b/backend/server/api/character/stat_deltas.py @@ -0,0 +1,150 @@ +"""Approximate character-stat deltas between two equipment configurations. + +Used by the gear-sets endpoint: for each saved set, "what would the sheet +stats roughly look like wearing this instead of the current gear?" — computed +as Σ item stats(set) − Σ item stats(worn), sourced from items.db's +``item_stats`` table (items + adorns) plus active item-set bonuses. + +Deliberate approximations (the sheet marks these values as approximate): + - Only additive stats are mapped. Percent-derived sheet values (armor / + avoidance / mitigations, which Census reports as percentages) and + attribute→pool cascades (stamina→health) are excluded. + - Proc/effect-only set bonuses ("Applies Enhance: X") carry no numbers to + move, so only stat-field set-bonus tiers count. +""" + +from __future__ import annotations + +import json +from collections import Counter +from typing import TYPE_CHECKING + +from backend.census.constants import STAT_MAP +from backend.census.item_parser import _SETBONUS_ATTR_NAMES, _SETBONUS_RESERVED_KEYS +from backend.eq2db.items import catalogue as _items + +if TYPE_CHECKING: + from collections.abc import Iterable + +_ALL_ATTRS = ("str_eff", "sta_eff", "agi_eff", "wis_eff", "int_eff") + +# item_stats display name (lowercased) → CharacterStats field(s) it moves. +# Names absent here (skills, resolve, flat resists/mitigation, tradeskill +# mods, ...) have no additive sheet field and are skipped. +ITEM_STAT_FIELDS: dict[str, tuple[str, ...]] = { + "primary attributes": _ALL_ATTRS, + "all attributes": _ALL_ATTRS, + "strength": ("str_eff",), + "stamina": ("sta_eff",), + "agility": ("agi_eff",), + "wisdom": ("wis_eff",), + "intelligence": ("int_eff",), + "health": ("health_max",), + "max health": ("health_max",), + "mana": ("power_max",), + "max power": ("power_max",), + "combat health regen": ("health_regen",), + "combat hp regen": ("health_regen",), + "combat power regen": ("power_regen",), + "potency": ("potency",), + "crit chance": ("crit_chance",), + "crit bonus": ("crit_bonus",), + "fervor": ("fervor",), + "dps": ("dps",), + "multi attack": ("double_attack",), + "multi attack chance": ("double_attack",), + "ability doublecast": ("ability_doublecast",), + "attack speed": ("attack_speed",), + "haste": ("attack_speed",), + "strikethrough": ("strikethrough",), + "accuracy": ("accuracy",), + "ability mod": ("ability_mod",), + "weapon damage": ("weapon_damage_bonus",), + "flurry": ("flurry",), + "block chance": ("block_chance",), + "parry": ("parry",), + "casting speed": ("casting_speed",), + "reuse speed": ("reuse_speed",), + "spell reuse speed": ("reuse_speed",), + "ability reuse speed": ("reuse_speed",), + "recovery speed": ("recovery_speed",), +} + + +def _bonus_stat_fields(key: str) -> tuple[str, ...]: + """A setbonus_list stat shorthand ('sta', 'blockchance', ...) → sheet + fields, resolved the same way the tooltip renderer names them.""" + kl = key.lower() + if kl in _SETBONUS_ATTR_NAMES: + display = _SETBONUS_ATTR_NAMES[kl] + elif mapped := STAT_MAP.get(kl): + display = mapped[0] + else: + display = kl + return ITEM_STAT_FIELDS.get(display.lower(), ()) + + +def _add(totals: dict[str, float], fields: tuple[str, ...], value: float, weight: int = 1) -> None: + for field in fields: + totals[field] = totals.get(field, 0.0) + value * weight + + +def _set_bonus_totals(totals: dict[str, float], counts: Counter[int]) -> None: + """Add every ACTIVE set-bonus tier for the given equipped-item counts. + A tier is active when the number of equipped pieces of its set reaches + ``requireditems``; only numeric stat fields contribute.""" + piece_count: Counter[str] = Counter() + bonus_source: dict[str, str | None] = {} + for item_id, set_name, raw_json in _items.set_bonus_rows_for_ids(list(counts)): + piece_count[set_name] += counts[item_id] + # Any member's raw_json carries the same setbonus_list — keep one. + if raw_json: + bonus_source.setdefault(set_name, raw_json) + for set_name, count in piece_count.items(): + raw = bonus_source.get(set_name) + if not raw: + continue + try: + bonus_list = json.loads(raw).get("setbonus_list") or [] + except (ValueError, AttributeError): + continue + for bonus in bonus_list: + if not isinstance(bonus, dict) or int(bonus.get("requireditems", 0)) > count: + continue + for key, value in bonus.items(): + kl = key.lower() + if kl in _SETBONUS_RESERVED_KEYS or kl.startswith("descriptiontag_"): + continue + if not isinstance(value, (int, float)) or isinstance(value, bool): + continue + _add(totals, _bonus_stat_fields(key), float(value)) + + +def stat_totals(equipment: Iterable) -> dict[str, float]: + """Total additive sheet-stat contribution of an equipment list (worn items + + their adorns + active set bonuses), keyed by CharacterStats field name. + Duck-typed over EquipmentSlotResponse-shaped slots.""" + counts: Counter[int] = Counter() + for s in equipment: + if s.item_id and str(s.item_id).isdigit(): + counts[int(s.item_id)] += 1 + for a in s.adorn_slots: + if a.adorn_id and str(a.adorn_id).isdigit(): + counts[int(a.adorn_id)] += 1 + totals: dict[str, float] = {} + for item_id, stat, value in _items.stats_for_ids(list(counts)): + _add(totals, ITEM_STAT_FIELDS.get(stat.lower(), ()), value, weight=counts[item_id]) + _set_bonus_totals(totals, counts) + return totals + + +def compute_stat_deltas(set_equipment: Iterable, current_equipment: Iterable) -> dict[str, float]: + """Per-field approximation of ``set − worn``, zero-deltas dropped.""" + worn = stat_totals(current_equipment) + proposed = stat_totals(set_equipment) + deltas: dict[str, float] = {} + for field in worn.keys() | proposed.keys(): + delta = round(proposed.get(field, 0.0) - worn.get(field, 0.0), 2) + if delta: + deltas[field] = delta + return deltas diff --git a/backend/server/cache.py b/backend/server/cache.py index b0eada73..98f16b5c 100644 --- a/backend/server/cache.py +++ b/backend/server/cache.py @@ -15,7 +15,7 @@ _log = logging.getLogger(__name__) -CacheName = Literal["character", "guild", "claim", "aa", "rankings", "favorites"] +CacheName = Literal["character", "guild", "claim", "aa", "gear-sets", "rankings", "favorites"] class TTLCache: @@ -171,4 +171,5 @@ def delete(self, key: str) -> None: guild_cache: TTLCache = TTLCache(name="guild", maxsize=50) claim_cache: TTLCache = TTLCache(name="claim", maxsize=200) aa_cache: TTLCache = TTLCache(name="aa", maxsize=200) +gear_sets_cache: TTLCache = TTLCache(name="gear-sets", maxsize=200) favorite_count_cache: TTLCache = TTLCache(name="favorites", maxsize=1000) diff --git a/backend/server/core/cache_keys.py b/backend/server/core/cache_keys.py index 414ed063..ba9fabcb 100644 --- a/backend/server/core/cache_keys.py +++ b/backend/server/core/cache_keys.py @@ -23,6 +23,11 @@ def aa_cache_key(name: str, world: str) -> str: return f"aas:{name.lower()}:{world.lower()}" +def gear_sets_cache_key(name: str, world: str) -> str: + """Cache key for a character's saved gear sets.""" + return f"gearsets:{name.lower()}:{world.lower()}" + + def guild_roster_key(guild: str, world: str) -> str: """``guild_cache`` key for the full roster fetch.""" return f"roster:{guild.lower()}:{world.lower()}" diff --git a/frontend/src/components/ItemTooltip.tsx b/frontend/src/components/ItemTooltip.tsx index 67f3d3ff..fa2589fe 100644 --- a/frontend/src/components/ItemTooltip.tsx +++ b/frontend/src/components/ItemTooltip.tsx @@ -31,7 +31,7 @@ interface ItemEffect { lines: EffectLine[] } -interface SetBonus { +export interface SetBonus { required_items: number effect: string lines: string[] diff --git a/frontend/src/pages/CharacterPage.gearsets.test.tsx b/frontend/src/pages/CharacterPage.gearsets.test.tsx new file mode 100644 index 00000000..d56d6e50 --- /dev/null +++ b/frontend/src/pages/CharacterPage.gearsets.test.tsx @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import CharacterPage from './CharacterPage' +import type { Character, EquipmentSlot, GearSet } from './characterSheet' + +// ── Hook mocks: no EventSource / server bootstrap in jsdom ─────────────────── + +vi.mock('../hooks/useCensusStream', () => ({ + useCensusStream: () => ({ subscribe: () => () => {} }), +})) +vi.mock('../hooks/useServer', () => ({ + useServer: () => ({ maxLevel: 70 }), +})) + +// ── react-router-dom partial mock (ComparePage.test.tsx pattern) ───────────── +// The throws-test flips `throwOnSet` to simulate the Firefox History-API +// throttle — per CLAUDE.md, actively-clicked URL-mirrored state (the ?set= +// gear-set pills) must survive setSearchParams throwing. +let throwOnSet = false +vi.mock('react-router-dom', async importOriginal => { + const actual = await importOriginal() + const { useCallback } = await import('react') + return { + ...actual, + useSearchParams: (...args: Parameters) => { + const [params, setParams] = actual.useSearchParams(...args) + const guarded = useCallback( + ((...setArgs: Parameters) => { + if (throwOnSet) throw new DOMException('history quota exhausted', 'SecurityError') + return setParams(...setArgs) + }) as typeof setParams, + [setParams], + ) + return [params, guarded] as const + }, + } +}) + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const stats = () => ({ + health_max: 10000, health_regen: null, power_max: 5000, power_regen: null, + run_speed: null, status_points: null, + str_eff: 100, sta_eff: 100, agi_eff: 100, wis_eff: 100, int_eff: 100, + armor: 5000, avoidance: null, block_chance: null, parry: null, + mit_physical: null, mit_elemental: null, mit_noxious: null, mit_arcane: null, + potency: 50, crit_chance: null, crit_bonus: null, fervor: null, dps: null, + double_attack: null, ability_doublecast: null, attack_speed: null, + strikethrough: null, accuracy: null, ability_mod: null, + weapon_damage_bonus: null, flurry: null, lethality: null, toughness: null, + reuse_speed: null, casting_speed: null, recovery_speed: null, + primary_min: null, primary_max: null, primary_delay: null, + secondary_min: null, secondary_max: null, secondary_delay: null, + ranged_min: null, ranged_max: null, ranged_delay: null, +}) + +const slot = (slotName: string, itemName: string, itemId: string | null = null): EquipmentSlot => ({ + slot: slotName, + name: itemName, + item_id: itemId, // null → no tooltip prefetch in jsdom + icon_id: null, + tier: 'FABLED', + adorn_slots: [], +}) + +const mkChar = (name: string, equipment?: EquipmentSlot[]): Character => ({ + id: name, + name, + level: 70, + cls: 'Templar', + race: 'Human', + gender: 'Male', + deity: null, + aa_count: 100, + world: 'Varsoon', + ts_class: null, + ts_level: null, + guild_name: null, + ilvl: 50, + stats: stats(), + equipment: equipment ?? [slot('Head', 'Current Helm')], + stale: false, +}) + +const TANK_SET: GearSet = { + name: 'Tank', + ilvl: 62, + stat_deltas: { potency: -5.5, block_chance: 12.3, health_max: 800 }, + equipment: [slot('Head', 'Tanky Helm')], +} +const DPS_SET: GearSet = { name: 'DPS', ilvl: 58, stat_deltas: {}, equipment: [slot('Head', 'Deeps Helm')] } + +function stubFetch(opts: { char: Character; sets?: GearSet[]; items?: Record }) { + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) + if (url.includes('/gear-sets')) { + return ok({ character_name: opts.char.name, sets: opts.sets ?? [] }) + } + const itemMatch = url.match(/\/api\/item\/(\d+)/) + if (itemMatch) { + const item = opts.items?.[itemMatch[1]] + return item ? ok(item) : { ok: false, status: 404, json: async () => ({}) } + } + if (url.includes('/favorite')) return ok({ count: 0, favorited_by_me: false }) + if (url.includes('/api/claim/me')) return ok({ approved: [], pending: [] }) + if (url.includes('/api/config')) return ok({}) + if (url.includes('/api/classes')) return ok([]) + if (url.match(/\/api\/character\/[^/]+$/)) return ok(opts.char) + return ok({}) + }) as unknown as typeof fetch) +} + +function renderAt(url: string) { + return render( + + + } /> + + , + ) +} + +beforeEach(() => { + vi.restoreAllMocks() + throwOnSet = false +}) + +// NOTE: characterCache is module-level and persists across tests in this file +// — every test uses a unique character name for isolation. + +describe('CharacterPage gear-set pills', () => { + it('renders a pill per saved set plus Current, showing current gear by default', async () => { + stubFetch({ char: mkChar('Pillsa'), sets: [DPS_SET, TANK_SET] }) + renderAt('/character/Pillsa') + expect(await screen.findByRole('button', { name: 'Current' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'DPS' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Tank' })).toBeInTheDocument() + expect(screen.getByText('Current Helm')).toBeInTheDocument() + expect(screen.queryByText('Tanky Helm')).not.toBeInTheDocument() + }) + + it('clicking a set pill swaps the paperdoll to that set and notes the approximation', async () => { + stubFetch({ char: mkChar('Pillsb'), sets: [TANK_SET] }) + renderAt('/character/Pillsb') + fireEvent.click(await screen.findByRole('button', { name: 'Tank' })) + expect(await screen.findByText('Tanky Helm')).toBeInTheDocument() + expect(screen.queryByText('Current Helm')).not.toBeInTheDocument() + expect(screen.getByText(/stats are approximate/)).toBeInTheDocument() + // Back to Current + fireEvent.click(screen.getByRole('button', { name: 'Current' })) + expect(await screen.findByText('Current Helm')).toBeInTheDocument() + }) + + it('a selected set approximates stats: base + delta with a signed chip', async () => { + stubFetch({ char: mkChar('Pillsf'), sets: [TANK_SET] }) + renderAt('/character/Pillsf') + // Live gear: Potency 50 (fixture), no Block Chance row (null), banner Health 10,000. + fireEvent.click(await screen.findByRole('button', { name: 'Tank' })) + // potency 50 − 5.5 → 44.5 with a −5.5 chip + expect(await screen.findByText('44.5')).toBeInTheDocument() + expect(screen.getByText('−5.5')).toBeInTheDocument() + // block_chance had no live value → the delta alone renders the row + expect(screen.getByText('12.3%')).toBeInTheDocument() + // health_max is banner-only → surfaces in the "Health & Power" delta group + expect(screen.getByText('Health & Power')).toBeInTheDocument() + expect(screen.getByText('10,800')).toBeInTheDocument() + // Back to Current → approximation gone + fireEvent.click(screen.getByRole('button', { name: 'Current' })) + expect(await screen.findByText('50.0')).toBeInTheDocument() + expect(screen.queryByText('Health & Power')).not.toBeInTheDocument() + }) + + it('renders no pills for a character without saved sets', async () => { + stubFetch({ char: mkChar('Pillsc'), sets: [] }) + renderAt('/character/Pillsc') + expect(await screen.findByText('Current Helm')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Current' })).not.toBeInTheDocument() + }) + + it('applies a ?set= deep link once the set list arrives', async () => { + stubFetch({ char: mkChar('Pillsd'), sets: [DPS_SET, TANK_SET] }) + renderAt('/character/Pillsd?set=Tank') + expect(await screen.findByText('Tanky Helm')).toBeInTheDocument() + const tankPill = screen.getByRole('button', { name: 'Tank' }) + expect(tankPill).toHaveAttribute('aria-pressed', 'true') + }) + + it('survives setSearchParams throwing (History API throttle) — pills still swap gear', async () => { + throwOnSet = true + stubFetch({ char: mkChar('Pillse'), sets: [TANK_SET] }) + renderAt('/character/Pillse') + fireEvent.click(await screen.findByRole('button', { name: 'Tank' })) + await waitFor(() => expect(screen.getByText('Tanky Helm')).toBeInTheDocument()) + }) +}) + +// NOTE: the tooltip item cache is also module-level — unique item ids per test. + +const itemDetail = (over: Record) => ({ + quality: 'FABLED', stats: [], effects: [], adornment_slots: [], flags: [], + extra_info: [], mitigation: null, set_name: null, set_bonuses: [], + ...over, +}) + +describe('CharacterPage set bonuses', () => { + it('lists worn sets with piece counts and marks active tiers', async () => { + const bonuses = [ + { required_items: 2, effect: '+120 Stamina', lines: [] }, + { required_items: 5, effect: '+5.0 Potency', lines: ['Applies Enhance: Void Bane.'] }, + ] + stubFetch({ + char: mkChar('Setsy', [ + slot('Head', 'Vault Helm', '9101'), + slot('Chest', 'Vault Cuirass', '9102'), + slot('Legs', 'Plain Legs', '9103'), + ]), + items: { + 9101: itemDetail({ id: '9101', name: 'Vault Helm', set_name: 'Vault Raiment', set_bonuses: bonuses }), + 9102: itemDetail({ id: '9102', name: 'Vault Cuirass', set_name: 'Vault Raiment', set_bonuses: bonuses }), + 9103: itemDetail({ id: '9103', name: 'Plain Legs' }), + }, + }) + renderAt('/character/Setsy') + expect(await screen.findByText('Set Bonuses')).toBeInTheDocument() + expect(screen.getByText('Vault Raiment')).toBeInTheDocument() + expect(screen.getByText(/— 2 equipped/)).toBeInTheDocument() + // 2-piece tier active (success-coloured requirement), 5-piece inactive + expect(screen.getByText('(2)').className).toContain('text-success') + expect(screen.getByText('(5)').className).not.toContain('text-success') + expect(screen.getByText(/\+120 Stamina/)).toBeInTheDocument() + expect(screen.getByText(/\+5\.0 Potency/)).toBeInTheDocument() + expect(screen.getByText('Applies Enhance: Void Bane.')).toBeInTheDocument() + }) + + it('renders nothing when no worn item belongs to a set', async () => { + stubFetch({ + char: mkChar('Setless2', [slot('Head', 'Plain Helm', '9201')]), + items: { 9201: itemDetail({ id: '9201', name: 'Plain Helm' }) }, + }) + renderAt('/character/Setless2') + expect(await screen.findByText('Plain Helm')).toBeInTheDocument() + expect(screen.queryByText('Set Bonuses')).not.toBeInTheDocument() + }) + + it('recomputes for the displayed saved set', async () => { + const bonuses = [{ required_items: 2, effect: '+10 Block Chance', lines: [] }] + stubFetch({ + char: mkChar('Setswap', [slot('Head', 'Plain Helm', '9301')]), + sets: [{ + name: 'Tank', + ilvl: 60, + stat_deltas: {}, + equipment: [slot('Head', 'Bulwark Helm', '9302'), slot('Chest', 'Bulwark Chest', '9303')], + }], + items: { + 9301: itemDetail({ id: '9301', name: 'Plain Helm' }), + 9302: itemDetail({ id: '9302', name: 'Bulwark Helm', set_name: 'Bulwark of Stone', set_bonuses: bonuses }), + 9303: itemDetail({ id: '9303', name: 'Bulwark Chest', set_name: 'Bulwark of Stone', set_bonuses: bonuses }), + }, + }) + renderAt('/character/Setswap') + expect(await screen.findByText('Plain Helm')).toBeInTheDocument() + expect(screen.queryByText('Set Bonuses')).not.toBeInTheDocument() + // Switch to the Tank set → its two Bulwark pieces activate the 2-piece bonus + fireEvent.click(screen.getByRole('button', { name: 'Tank' })) + expect(await screen.findByText('Set Bonuses')).toBeInTheDocument() + expect(screen.getByText('Bulwark of Stone')).toBeInTheDocument() + expect(screen.getByText('(2)').className).toContain('text-success') + // Back to current gear → section disappears again + fireEvent.click(screen.getByRole('button', { name: 'Current' })) + await waitFor(() => expect(screen.queryByText('Set Bonuses')).not.toBeInTheDocument()) + }) +}) diff --git a/frontend/src/pages/CharacterPage.tsx b/frontend/src/pages/CharacterPage.tsx index 2e3f1eff..8dab07bd 100644 --- a/frontend/src/pages/CharacterPage.tsx +++ b/frontend/src/pages/CharacterPage.tsx @@ -1,14 +1,16 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useParams, Link, useSearchParams } from 'react-router-dom' import Breadcrumb from '../components/Breadcrumb' import { Card, SectionLabel } from '../components/ui' import { TabButton } from '../components/ui/TabButton' -import { ItemTooltip, useItemTooltip, getCachedItem, prefetchItem } from '../components/ItemTooltip' +import { ItemTooltip, useItemTooltip, getCachedItem, prefetchItem, type SetBonus } from '../components/ItemTooltip' import { FreshnessBadge } from '../components/FreshnessBadge' import FavoriteButton from '../components/FavoriteButton' import { AAsTab } from './CharacterAAsTab' import { SpellsTab } from './CharacterSpellsTab' +import DeltaChip from './compare/DeltaChip' import { useCensusStream } from '../hooks/useCensusStream' +import { useFetch } from '../hooks/useFetch' import { mergeParams, safeSetParams } from '../lib/searchParams' import { fetchCharacter, getCachedCharacter, setCachedCharacter } from '../lib/characterCache' import { useServer } from '../hooks/useServer' @@ -18,7 +20,9 @@ import { useServer } from '../hooks/useServer' // shared with the compare page so the two can never drift. import { + type CharGearSets, type Character, + type CharacterStats, type EquipmentSlot, type Fmt, CONSUMABLE_SLOTS, @@ -382,7 +386,6 @@ type ActiveTab = 'equipment' | 'aas' | 'spells' const TABS: readonly ActiveTab[] = ['equipment', 'aas', 'spells'] function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxLevel: number; ratingConfig: RatingConfig }) { - const bySlot = buildSlotMap(char.equipment) const { tooltip, showTip, hideTip, moveTip } = useItemTooltip() const [hoveredStat, setHoveredStat] = useState(null) const [searchParams, setSearchParams] = useSearchParams() @@ -391,21 +394,49 @@ function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxL const t = searchParams.get('tab') return TABS.includes(t as ActiveTab) ? (t as ActiveTab) : 'equipment' }) - // Mirror the tab to the URL (React state is source of truth; URL best-effort). - // Off the AA tab, drop the AA-only params so links stay clean. + + // Saved in-game gear sets — SWR-backed, so this is a local-store read in + // the common case. `selectedSet === null` means the live (current) gear. + // Errors/503 degrade to "no pills" silently. + const { data: gearSetsData } = useFetch( + `/api/character/${encodeURIComponent(char.name)}/gear-sets`, + ) + const [selectedSet, setSelectedSet] = useState(null) + // ?set= deep link: captured once, applied when the set list arrives. + const [initialSetParam] = useState(() => searchParams.get('set')) + // useFetch keeps the previous character's data while a refetch is in + // flight — only trust sets stamped with this character's name. + const sets = gearSetsData?.character_name.toLowerCase() === char.name.toLowerCase() ? gearSetsData.sets : [] + useEffect(() => { setSelectedSet(null) }, [char.name]) + useEffect(() => { + if (initialSetParam && gearSetsData?.sets.some(s => s.name === initialSetParam)) { + setSelectedSet(initialSetParam) + } + }, [gearSetsData, initialSetParam]) + + const activeSet = selectedSet ? sets.find(s => s.name === selectedSet) ?? null : null + const viewEquipment = activeSet ? activeSet.equipment : char.equipment + const bySlot = buildSlotMap(viewEquipment) + + // Mirror the tab + gear set to the URL (React state is source of truth; + // URL best-effort). Off the AA tab, drop the AA-only params so links stay clean. useEffect(() => { - const updates: Record = { tab: activeTab === 'equipment' ? null : activeTab } + const updates: Record = { + tab: activeTab === 'equipment' ? null : activeTab, + set: activeTab === 'equipment' && selectedSet ? selectedSet : null, + } if (activeTab !== 'aas') { updates.profile = null; updates.tree = null } safeSetParams(setSearchParams as (...a: unknown[]) => void, [mergeParams(updates), { replace: true }]) - }, [activeTab, setSearchParams]) + }, [activeTab, selectedSet, setSearchParams]) // Tracks when background prefetch completes so highlights + gear rating re-evaluate. const [itemsReady, setItemsReady] = useState(false) // Eagerly fetch stats for every equipped item + adorn so highlights work // without the user having to hover each item first. useEffect(() => { + setItemsReady(false) const ids: string[] = [] - for (const slot of char.equipment) { + for (const slot of viewEquipment) { if (slot.item_id) ids.push(slot.item_id) for (const a of slot.adorn_slots) { if (a.adorn_id) ids.push(a.adorn_id) @@ -413,7 +444,7 @@ function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxL } if (ids.length === 0) { setItemsReady(true); return } Promise.allSettled(ids.map(prefetchItem)).then(() => setItemsReady(true)) - }, [char]) + }, [viewEquipment]) /** Returns whether this slot's item or adorns contribute to the hovered stat. */ function getHighlight(item: EquipmentSlot | null): 'direct' | 'adorn' | null { @@ -466,15 +497,45 @@ function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxL
{/* Left: gear rating + detailed stats */}
- + setHoveredStat(null)} />
{/* Right: paperdoll */}
- Equipment +
+ Equipment + {sets.length > 0 && ( +
+ {[null, ...sets.map(s => s.name)].map(name => { + const active = name === selectedSet + return ( + + ) + })} + {activeSet && ( + + saved set — stats are approximate + + )} +
+ )} +
{LEFT_SLOTS.map(([label, key]) => { @@ -490,6 +551,8 @@ function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxL
+ + Consumables
{CONSUMABLE_SLOTS.map(([label, key]) => { @@ -512,6 +575,76 @@ function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxL ) } +// ── Set bonuses (between the paperdoll and consumables) ─────────────────────── + +interface WornSet { + name: string + count: number + tiers: { required: number; effect: string; lines: string[]; active: boolean }[] +} + +/** Group the displayed equipment (items + adorns) by item set and mark which + * bonus tiers are active for the equipped piece count. Reads the tooltip + * prefetch cache, so results are complete once the eager prefetch finishes. */ +function wornSets(equipment: EquipmentSlot[]): WornSet[] { + const counts = new Map() + const tiersBySet = new Map() + const ids: string[] = [] + for (const slot of equipment) { + if (slot.item_id) ids.push(slot.item_id) + for (const a of slot.adorn_slots) { + if (a.adorn_id) ids.push(a.adorn_id) + } + } + for (const id of ids) { + const d = getCachedItem(id) + if (!d?.set_name || !d.set_bonuses?.length) continue + counts.set(d.set_name, (counts.get(d.set_name) ?? 0) + 1) + if (!tiersBySet.has(d.set_name)) tiersBySet.set(d.set_name, d.set_bonuses) + } + return [...counts.entries()] + .map(([name, count]) => ({ + name, + count, + tiers: (tiersBySet.get(name) ?? []).map(t => ({ + required: t.required_items, + effect: t.effect, + lines: t.lines, + active: count >= t.required_items, + })), + })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) +} + +function SetBonusesSection({ equipment, ready }: { equipment: EquipmentSlot[]; ready: boolean }) { + const sets = useMemo(() => (ready ? wornSets(equipment) : []), [equipment, ready]) + if (sets.length === 0) return null + return ( + <> + Set Bonuses + {/* Multi-column (not grid) so short cards pack under each other instead + of row-aligning with the tallest card. */} +
+ {sets.map(s => ( + +
+ {s.name} — {s.count} equipped +
+ {s.tiers.map((t, i) => ( +
+ ({t.required}) {t.effect} + {t.lines.map((ln, j) => ( +
{ln}
+ ))} +
+ ))} +
+ ))} +
+ + ) +} + // ── General banner (full width, above equipment) ────────────────────────────── // Each column holds a top row and an optional bottom row: [label, value] @@ -600,8 +733,20 @@ function BannerStat({ label, value }: { label: string; value: string }) { // ── Stats panel (left of paperdoll, no General group) ───────────────────────── -function StatsPanel({ char, onStatHover, onStatLeave }: { +/** Health/power live in the banner, not STAT_GROUPS — when a saved set moves + * them, surface the approximation in its own group here. */ +const POOL_DELTA_ROWS: { key: keyof CharacterStats; label: string; fmt: Fmt }[] = [ + { key: 'health_max', label: 'Health', fmt: 'int' }, + { key: 'power_max', label: 'Power', fmt: 'int' }, + { key: 'health_regen', label: 'Health Regen', fmt: 'int' }, + { key: 'power_regen', label: 'Power Regen', fmt: 'int' }, +] + +function StatsPanel({ char, deltas, onStatHover, onStatLeave }: { char: Character + /** Saved-set stat deltas (field → set − worn); non-null when a set is + * selected — rows shift to approximate values with a delta chip. */ + deltas?: Record | null onStatHover: (label: string) => void onStatLeave: () => void }) { @@ -609,15 +754,29 @@ function StatsPanel({ char, onStatHover, onStatLeave }: { // Convenience: create hover/leave props for a given label const h = (label: string) => ({ onHover: () => onStatHover(label), onLeave: onStatLeave }) + const row = (r: { key: keyof CharacterStats; label: string; fmt: Fmt }) => { + const delta = deltas?.[r.key] + const base = s[r.key] + // With a delta, show the approximated set value (base treated as 0 when + // the sheet has no current value); without one, the live value as-is. + const value = typeof delta === 'number' ? (typeof base === 'number' ? base : 0) + delta : base + return + } + + const poolRows = deltas ? POOL_DELTA_ROWS.filter(r => typeof deltas[r.key] === 'number') : [] + return (
+ {poolRows.length > 0 && ( + + {poolRows.map(row)} + + )} {/* Data-driven from the shared STAT_GROUPS config (characterSheet.ts) — hover labels are the row labels, so STAT_ALIASES matching is unchanged. */} {STAT_GROUPS.map(group => ( - {group.rows.map(row => ( - - ))} + {group.rows.map(row)} ))} @@ -640,10 +799,12 @@ function StatsPanel({ char, onStatHover, onStatLeave }: { // ── Stat display helpers ────────────────────────────────────────────────────── -export function StatRow({ label, value, fmt: format, onHover, onLeave }: { +export function StatRow({ label, value, fmt: format, delta, onHover, onLeave }: { label: string value: number | string | null | undefined fmt?: Fmt + /** Optional saved-set delta shown as a signed chip after the value. */ + delta?: number | null onHover?: () => void onLeave?: () => void }) { @@ -657,7 +818,12 @@ export function StatRow({ label, value, fmt: format, onHover, onLeave }: { onMouseLeave={onLeave} > {label} - {display} + + {display} + {typeof delta === 'number' && delta !== 0 && ( + + )} +
) } diff --git a/frontend/src/pages/ComparePage.test.tsx b/frontend/src/pages/ComparePage.test.tsx index 77f0e0aa..726f04e9 100644 --- a/frontend/src/pages/ComparePage.test.tsx +++ b/frontend/src/pages/ComparePage.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import ComparePage from './ComparePage' -import type { Character } from './characterSheet' +import type { Character, EquipmentSlot, GearSet } from './characterSheet' // ── react-router-dom partial mock: real router, swappable useSearchParams ──── // Default passthrough; the throws-test flips `throwOnSet` to simulate the @@ -70,11 +70,21 @@ const mkChar = (name: string, cls: string, over: Partial = {}): Chara ...over, }) +const mkSlot = (slotName: string, itemName: string): EquipmentSlot => ({ + slot: slotName, + name: itemName, + item_id: null, // no id → no tooltip fetch in jsdom + icon_id: null, + tier: 'FABLED', + adorn_slots: [], +}) + interface StubOpts { chars?: Record favorites?: unknown[] searchResults?: { name: string; cls: string | null; level: number | null; guild_name: string | null }[] aas?: Record + gearSets?: Record } function stubFetch(opts: StubOpts = {}) { @@ -86,6 +96,11 @@ function stubFetch(opts: StubOpts = {}) { if (url.includes('/api/favorites')) return ok({ favorites: opts.favorites ?? [] }) if (url.includes('/api/characters/search')) return ok({ results: opts.searchResults ?? [], total: 0, source: 'local' }) if (url.includes('/api/aa/config')) return ok({ xpac: 'x', aa_cap: 100, tradeskill_aa_cap: 0, unlocked_tree_types: [] }) + const gsMatch = url.match(/\/api\/character\/([^/]+)\/gear-sets/) + if (gsMatch) { + const name = decodeURIComponent(gsMatch[1]) + return ok({ character_name: name, sets: opts.gearSets?.[name] ?? [] }) + } const aaMatch = url.match(/\/api\/character\/([^/]+)\/aas/) if (aaMatch) { const name = decodeURIComponent(aaMatch[1]) @@ -207,6 +222,27 @@ describe('ComparePage', () => { await waitFor(() => expect(legend().textContent).toContain('Lorin − Mira')) }) + it('gear tab: per-side gear-set dropdown swaps the diffed equipment', async () => { + stubFetch({ + chars: { + Quinra: mkChar('Quinra', 'Templar', { equipment: [mkSlot('Head', 'Quin Current Helm')] }), + Rossik: mkChar('Rossik', 'Templar'), + }, + gearSets: { Quinra: [{ name: 'Tank', ilvl: 62, stat_deltas: {}, equipment: [mkSlot('Head', 'Quin Tank Helm')] }] }, + }) + renderAt('/compare?a=Quinra&b=Rossik&tab=gear') + expect(await screen.findByText('Quin Current Helm')).toBeInTheDocument() + // Only side A has saved sets → exactly one dropdown, defaulting to live gear. + const selects = await screen.findAllByLabelText('Gear set') + expect(selects).toHaveLength(1) + fireEvent.change(selects[0], { target: { value: 'Tank' } }) + expect(await screen.findByText('Quin Tank Helm')).toBeInTheDocument() + expect(screen.queryByText('Quin Current Helm')).not.toBeInTheDocument() + // Headline ilvl reflects the chosen set; column header names the set. + expect(screen.getByText('62')).toBeInTheDocument() + expect(screen.getByText(/— Tank/)).toBeInTheDocument() + }) + it('survives setSearchParams throwing (History API throttle) — UI still responds', async () => { throwOnSet = true stubFetch({ chars: { Nerys: mkChar('Nerys', 'Templar'), Osric: mkChar('Osric', 'Templar') } }) diff --git a/frontend/src/pages/characterSheet.ts b/frontend/src/pages/characterSheet.ts index 0c8223df..e645b913 100644 --- a/frontend/src/pages/characterSheet.ts +++ b/frontend/src/pages/characterSheet.ts @@ -25,6 +25,21 @@ export interface EquipmentSlot { adorn_slots: AdornSlot[] } +/** One saved in-game equipment set (GET /api/character/{name}/gear-sets). */ +export interface GearSet { + name: string + ilvl: number | null + /** Approximate sheet-stat movement of equipping this set instead of the + * current gear: CharacterStats field → (set − worn). Additive stats only. */ + stat_deltas: Record + equipment: EquipmentSlot[] +} + +export interface CharGearSets { + character_name: string + sets: GearSet[] +} + export interface CharacterStats { health_max: number | null health_regen: number | null diff --git a/frontend/src/pages/compare/CompareGear.tsx b/frontend/src/pages/compare/CompareGear.tsx index f275b1cb..d637e845 100644 --- a/frontend/src/pages/compare/CompareGear.tsx +++ b/frontend/src/pages/compare/CompareGear.tsx @@ -1,12 +1,53 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Badge, Card } from '../../components/ui' import { ItemTooltip, useItemTooltip } from '../../components/ItemTooltip' import { useClasses } from '../../useClasses' -import type { Character, EquipmentSlot } from '../characterSheet' +import { useFetch } from '../../hooks/useFetch' +import type { CharGearSets, Character, EquipmentSlot, GearSet } from '../characterSheet' import { tierStyle } from '../characterSheet' import { diffGear, nullableDelta, type GearDiffRow } from './diff' import DeltaChip from './DeltaChip' +/** One side's gear selection: the live equipment or a chosen saved set. + * Fetching lives here (not ComparePage) so the sets load only when the Gear + * tab is actually opened. */ +function useGearSelection(char: Character) { + const { data } = useFetch(`/api/character/${encodeURIComponent(char.name)}/gear-sets`) + const [setName, setSetName] = useState(null) + useEffect(() => { setSetName(null) }, [char.name]) + // useFetch keeps the previous character's payload during a refetch — only + // trust sets stamped with this character's name. + const sets: GearSet[] = data?.character_name.toLowerCase() === char.name.toLowerCase() ? data.sets : [] + const active = setName ? sets.find(s => s.name === setName) ?? null : null + return { + sets, + setName, + setSetName, + equipment: active ? active.equipment : char.equipment, + ilvl: active ? active.ilvl : char.ilvl, + label: active ? active.name : null, + } +} + +/** "Current gear" / saved-set dropdown for one column. Hidden when the + * character has no saved sets. */ +function GearSetSelect({ sel, align }: { sel: ReturnType; align: 'left' | 'right' }) { + if (sel.sets.length === 0) return null + return ( + + ) +} + /** One side of a mirrored slot row: icon + tier-coloured name + adorn fill. */ function GearCell({ item, adorns, align, onShow, onHide }: { item: EquipmentSlot | null @@ -68,12 +109,15 @@ function SlotRowMirror({ row, onShow, onHide }: { ) } -/** Mirrored paperdoll comparison: [A item | slot | B item] rows + ilvl headline. */ +/** Mirrored paperdoll comparison: [A item | slot | B item] rows + ilvl headline. + * Each side can compare its live gear or any saved in-game gear set. */ export default function CompareGear({ charA, charB }: { charA: Character; charB: Character }) { const { tooltip, showTip, hideTip, moveTip } = useItemTooltip() const { colourFor } = useClasses() const [diffOnly, setDiffOnly] = useState(false) - const gear = useMemo(() => diffGear(charA.equipment, charB.equipment), [charA, charB]) + const selA = useGearSelection(charA) + const selB = useGearSelection(charB) + const gear = useMemo(() => diffGear(selA.equipment, selB.equipment), [selA.equipment, selB.equipment]) const filterRows = (rows: GearDiffRow[]) => rows.filter(r => (r.a !== null || r.b !== null) && (!diffOnly || !r.identical)) @@ -92,13 +136,13 @@ export default function CompareGear({ charA, charB }: { charA: Character; charB: Item Level:{' '} - {charA.name} {charA.ilvl != null ? Math.round(charA.ilvl) : '—'} + {charA.name} {selA.ilvl != null ? Math.round(selA.ilvl) : '—'} vs - {charB.name} {charB.ilvl != null ? Math.round(charB.ilvl) : '—'} + {charB.name} {selB.ilvl != null ? Math.round(selB.ilvl) : '—'} {' '} - + {gear.differingCount} of {gear.occupiedCount} slots differ @@ -109,11 +153,21 @@ export default function CompareGear({ charA, charB }: { charA: Character; charB: - {/* Column identity for the mirrored rows */} + {/* Column identity for the mirrored rows + per-side gear-set choice */}
- {charA.name} +
+ + {charA.name}{selA.label && — {selA.label}} + + +
- {charB.name} +
+ + {charB.name}{selB.label && — {selB.label}} + + +
{sections.map(([title, rows]) => diff --git a/tests/census/test_client_gear_sets.py b/tests/census/test_client_gear_sets.py new file mode 100644 index 00000000..b016842d --- /dev/null +++ b/tests/census/test_client_gear_sets.py @@ -0,0 +1,63 @@ +"""Tests for CensusClient.get_gear_sets (the adventure_sets collection). + +Census HTTP is mocked via patch.object(client, "_census_get", ...); item +resolution is mocked at _parse_equipment (its behaviour is covered by the +character-equipment tests — gear sets reuse it verbatim). +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from backend.census.client import CensusClient +from backend.census.models import EquipmentSlot + +_SLOT = EquipmentSlot(slot_name="Head", item_name="Cowl of Fervor", item_id="1001") + + +@pytest.fixture +def client(): + return CensusClient(service_id="test-key") + + +class TestGetGearSets: + @pytest.mark.asyncio + async def test_returns_none_when_census_unavailable(self, client): + with patch.object(client, "_census_get", new=AsyncMock(return_value=None)): + assert await client.get_gear_sets("123") is None + + @pytest.mark.asyncio + async def test_returns_empty_for_character_without_sets(self, client): + with patch.object(client, "_census_get", new=AsyncMock(return_value={"adventure_sets_list": []})): + assert await client.get_gear_sets("123") == [] + + @pytest.mark.asyncio + async def test_parses_sets_and_reuses_equipment_parser(self, client): + census_data = { + "adventure_sets_list": [ + { + "id": 123, + "set_list": [ + {"name": "DPS", "equipmentslot_list": [{"name": "primary"}]}, + {"name": " ", "equipmentslot_list": []}, # blank → fallback label + "not-a-dict", # malformed entry skipped + ], + } + ] + } + with ( + patch.object(client, "_census_get", new=AsyncMock(return_value=census_data)) as mock_get, + patch.object(client, "_parse_equipment", new=AsyncMock(return_value=[_SLOT])) as mock_parse, + ): + sets = await client.get_gear_sets(123) + + assert sets is not None + assert [s.name for s in sets] == ["DPS", "Unnamed set"] + assert sets[0].equipment == [_SLOT] + # The character id must be stringified into the query. + assert mock_get.await_args.args[1]["id"] == "123" + # Both real sets went through the shared equipment parser. + assert mock_parse.await_count == 2 + mock_parse.assert_any_await([{"name": "primary"}]) diff --git a/tests/server/test_gear_sets.py b/tests/server/test_gear_sets.py new file mode 100644 index 00000000..bfce82ca --- /dev/null +++ b/tests/server/test_gear_sets.py @@ -0,0 +1,283 @@ +"""Tests for the gear-sets endpoint's census_store integration. + +Mirrors tests/server/test_aa_census_store.py — the gear-sets read path is +the same store-first SWR shape: + - Cold cache: Census is called + data is persisted to census_store. + - Warm store: census_store is served without calling Census. + - Stale store record: served immediately, background refresh spawned. + - Census down + nothing cached → 503. + - Character with no saved sets → 200 with an empty list. + - Unknown character → 404. + - init_db on a pre-existing census_store DB (without character_gear_sets + table) doesn't crash — simulates an upgrade scenario. +""" + +from __future__ import annotations + +import sqlite3 +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import ASGITransport, AsyncClient + +from backend.census import store as cs +from backend.census.models import AdornSlot, EquipmentSlot, GearSet + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_gear_sets() -> list[GearSet]: + """Two small but realistic saved sets.""" + return [ + GearSet( + name="DPS", + equipment=[ + EquipmentSlot( + slot_name="Head", + item_name="Cowl of Fervor", + item_id="1001", + icon_id="42", + tier="FABLED", + adorn_slots=[AdornSlot(color="White", adorn_name="Mender's Seal", adorn_id="2001")], + ), + EquipmentSlot(slot_name="Chest", item_name="Robe of Woe", item_id="1002"), + ], + ), + GearSet(name="Tank", equipment=[EquipmentSlot(slot_name="Head", item_name="Helm of Stone", item_id="1003")]), + ] + + +def _patch_char_id(char_id: str = "12345"): + """Patch the module's character_cache so _character resolves without a + full CharacterOverview round-trip.""" + mock_cache = MagicMock() + mock_cache.get_stale.return_value = (MagicMock(id=char_id, equipment=[]), False) + return patch("backend.server.api.character.gear_sets.character_cache", mock_cache) + + +# --------------------------------------------------------------------------- +# Cold-cache test: Census called, data persisted to census_store. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_cold_cache_calls_census_and_persists(app, tmp_path): + """On first fetch (no cache, no store), Census is queried and the result + is written to census_store.""" + db_path = tmp_path / "backend.census.db" + + with ( + patch("backend.server.api.character.gear_sets.census_store.path", db_path), + _patch_char_id("777"), + patch("backend.server.core.census_lifecycle._clients", {}), + patch("backend.server.core.census_lifecycle.CensusClient") as MockCC, + ): + mock_client = MagicMock() + mock_client.get_gear_sets = AsyncMock(return_value=_make_gear_sets()) + MockCC.return_value = mock_client + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/character/Coldchar/gear-sets") + + assert resp.status_code == 200 + data = resp.json() + assert data["character_name"] == "Coldchar" + assert [s["name"] for s in data["sets"]] == ["DPS", "Tank"] + assert data["sets"][0]["equipment"][0]["name"] == "Cowl of Fervor" + assert data["sets"][0]["equipment"][0]["adorn_slots"][0]["adorn_name"] == "Mender's Seal" + mock_client.get_gear_sets.assert_awaited_once_with("777") + + # Verify persisted to the store. + conn = cs.CensusStore(db_path).init_db() + try: + rec = cs.CensusStore.get_character_gear_sets(conn, "Coldchar", "Varsoon") + assert rec is not None + assert [s["name"] for s in rec["data"]["sets"]] == ["DPS", "Tank"] + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Warm-store test: census_store served without calling Census. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_warm_store_skips_census(app, tmp_path): + """When census_store has a fresh record, Census is NOT called.""" + db_path = tmp_path / "backend.census.db" + conn = cs.CensusStore(db_path).init_db() + stored_data = {"character_name": "Stored", "sets": [{"name": "Old set", "ilvl": None, "equipment": []}]} + cs.CensusStore.upsert_character_gear_sets(conn, "Stored", "Varsoon", stored_data, now=int(time.time())) + conn.close() + + with ( + patch("backend.server.api.character.gear_sets.census_store.path", db_path), + patch("backend.server.api.character.gear_sets.gear_sets_cache") as mock_cache, + patch("backend.server.core.census_lifecycle._clients", {}), + patch("backend.server.core.census_lifecycle.CensusClient") as MockCC, + ): + mock_cache.get_stale.return_value = (None, False) + mock_cache.set = MagicMock() + + mock_client = MagicMock() + mock_client.get_gear_sets = AsyncMock(return_value=None) + MockCC.return_value = mock_client + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/character/Stored/gear-sets") + + assert resp.status_code == 200 + assert resp.json()["sets"][0]["name"] == "Old set" + mock_client.get_gear_sets.assert_not_called() + + +# --------------------------------------------------------------------------- +# Stale-store test: served immediately, background refresh spawned. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_stale_store_returns_data_and_spawns_refresh(app, tmp_path): + """A store record older than CHARACTER_STALE_S is served immediately but + triggers a background refresh task.""" + from backend.server.constants import CHARACTER_STALE_S + + db_path = tmp_path / "backend.census.db" + old_ts = int(time.time()) - CHARACTER_STALE_S - 60 + conn = cs.CensusStore(db_path).init_db() + stored_data = {"character_name": "Stalechar", "sets": []} + cs.CensusStore.upsert_character_gear_sets(conn, "Stalechar", "Varsoon", stored_data, now=old_ts) + conn.close() + + tasks_created: list = [] + + def _fake_create_task(coro): + tasks_created.append(coro) + coro.close() # never run it — just silence the un-awaited warning + return MagicMock() + + with ( + patch("backend.server.api.character.gear_sets.census_store.path", db_path), + patch("backend.server.api.character.gear_sets.gear_sets_cache") as mock_cache, + patch("backend.server.api.character.gear_sets.asyncio.create_task", side_effect=_fake_create_task), + patch("backend.server.core.census_lifecycle._clients", {}), + ): + mock_cache.get_stale.return_value = (None, False) + mock_cache.set = MagicMock() + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/character/Stalechar/gear-sets") + + assert resp.status_code == 200 + assert resp.json()["character_name"] == "Stalechar" + assert len(tasks_created) == 1 + + +# --------------------------------------------------------------------------- +# Census down + never seen → 503. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_census_down_and_no_store_returns_503(app, tmp_path): + db_path = tmp_path / "backend.census.db" + + with ( + patch("backend.server.api.character.gear_sets.census_store.path", db_path), + patch("backend.server.census_health.is_down", return_value=True), + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/character/Nevercached/gear-sets") + + assert resp.status_code == 503 + + +# --------------------------------------------------------------------------- +# No saved sets → 200 with empty list (valid, cacheable answer). +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_character_with_no_sets_returns_empty_list(app, tmp_path): + db_path = tmp_path / "backend.census.db" + + with ( + patch("backend.server.api.character.gear_sets.census_store.path", db_path), + _patch_char_id(), + patch("backend.server.core.census_lifecycle._clients", {}), + patch("backend.server.core.census_lifecycle.CensusClient") as MockCC, + ): + mock_client = MagicMock() + mock_client.get_gear_sets = AsyncMock(return_value=[]) + MockCC.return_value = mock_client + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/character/Setless/gear-sets") + + assert resp.status_code == 200 + assert resp.json() == {"character_name": "Setless", "sets": []} + + # The empty answer is persisted too — repeat visitors skip Census. + conn = cs.CensusStore(db_path).init_db() + try: + assert cs.CensusStore.get_character_gear_sets(conn, "Setless", "Varsoon") is not None + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Unknown character → 404 from the character-id resolution step. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_unknown_character_returns_404(app, tmp_path): + db_path = tmp_path / "backend.census.db" + mock_char_cache = MagicMock() + mock_char_cache.get_stale.return_value = (None, False) # no cached character + + with ( + patch("backend.server.api.character.gear_sets.census_store.path", db_path), + patch("backend.server.api.character.gear_sets.character_cache", mock_char_cache), + patch("backend.server.core.census_lifecycle._clients", {}), + patch("backend.server.core.census_lifecycle.CensusClient") as MockCC, + ): + mock_client = MagicMock() + mock_client.get_character = AsyncMock(return_value=None) + MockCC.return_value = mock_client + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/api/character/Ghostchar/gear-sets") + + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Migration safety: init_db on a pre-existing DB without character_gear_sets. +# --------------------------------------------------------------------------- + + +def test_init_db_on_old_schema_adds_gear_sets_table(tmp_path): + """init_db on an existing census.db without character_gear_sets should not + crash and should create the new table (simulates an upgrade scenario). + + Memory [test-migrations-against-old-db-shape]. + """ + db_path = tmp_path / "backend.census.db" + conn = sqlite3.connect(db_path) + conn.execute(cs._SQL["schema_characters"]) + conn.execute(cs._SQL["schema_guilds"]) + conn.execute(cs._SQL["schema_character_aas"]) + conn.commit() + conn.close() + + conn = cs.CensusStore(db_path).init_db() + try: + tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert "character_gear_sets" in tables + finally: + conn.close() diff --git a/tests/server/test_stat_deltas.py b/tests/server/test_stat_deltas.py new file mode 100644 index 00000000..2b0d49cd --- /dev/null +++ b/tests/server/test_stat_deltas.py @@ -0,0 +1,150 @@ +"""Tests for the gear-set stat-delta approximation (stat_deltas.py). + +Seeds a temp items.db (items + item_stats) and re-points the shared items +catalogue instance at it — the eq2db test convention. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from backend.eq2db import items as items_mod +from backend.server.api.character import stat_deltas +from backend.server.api.character.stat_deltas import compute_stat_deltas, stat_totals + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _slot(item_id: str | None, adorn_ids: tuple[str, ...] = ()) -> SimpleNamespace: + """Duck-typed EquipmentSlotResponse — stat_deltas only reads item_id + adorn_slots.""" + return SimpleNamespace( + item_id=item_id, + adorn_slots=[SimpleNamespace(adorn_id=a) for a in adorn_ids], + ) + + +@pytest.fixture +def items_db(tmp_path, monkeypatch): + """Temp items.db with a small stat + set-bonus fixture; the shared + catalogue instance is re-pointed at it for the test.""" + db_path = tmp_path / "items.db" + cat = items_mod.ItemCatalogue(db_path) + conn = cat.init_db() + + def add_item(item_id: int, name: str, *, setbonus_name: str | None = None, raw: dict | None = None): + conn.execute( + "INSERT INTO items (id, displayname, displayname_lower, setbonus_name, raw_json) VALUES (?, ?, ?, ?, ?)", + (item_id, name, name.lower(), setbonus_name, json.dumps(raw) if raw else None), + ) + + def add_stat(item_id: int, stat: str, value: float): + conn.execute("INSERT INTO item_stats (item_id, stat, value) VALUES (?, ?, ?)", (item_id, stat, value)) + + # Plain items + add_item(1, "Worn Helm") + add_stat(1, "Potency", 10.0) + add_stat(1, "Stamina", 30.0) + add_item(2, "Set Helm A") + add_stat(2, "Potency", 16.0) + add_item(3, "All-Stats Ring") + add_stat(3, "Primary Attributes", 25.0) + add_item(4, "White Adorn") + add_stat(4, "Crit Bonus", 2.5) + add_item(5, "Skill Item") + add_stat(5, "Resolve", 50.0) # unmapped → ignored + add_stat(5, "Combat Skills", 11.0) # unmapped → ignored + + # A 3-piece armor set: 2-piece tier gives sta, 5-piece tier out of reach, + # plus an effect-only tier that carries no numbers. + set_raw = { + "setbonus_list": [ + {"requireditems": 2, "sta": 120, "blockchance": 10.0}, + {"requireditems": 3, "effect": "Applies Enhance: Void Bane."}, + {"requireditems": 5, "basemodifier": 5.0}, + ] + } + for item_id, name in ((10, "Set Piece One"), (11, "Set Piece Two"), (12, "Set Piece Three")): + add_item(item_id, name, setbonus_name="Vault Raiment", raw=set_raw) + + conn.commit() + conn.close() + monkeypatch.setattr(stat_deltas._items, "path", db_path) + return db_path + + +# --------------------------------------------------------------------------- +# stat_totals +# --------------------------------------------------------------------------- + + +def test_totals_sum_items_and_adorns(items_db): + totals = stat_totals([_slot("1", adorn_ids=("4",))]) + assert totals == {"potency": 10.0, "sta_eff": 30.0, "crit_bonus": 2.5} + + +def test_primary_attributes_fan_out_to_all_five(items_db): + totals = stat_totals([_slot("3")]) + assert totals == {f"{a}_eff": 25.0 for a in ("str", "sta", "agi", "wis", "int")} + + +def test_duplicate_ids_are_weighted(items_db): + totals = stat_totals([_slot("3"), _slot("3")]) # two copies of the same ring + assert totals["sta_eff"] == 50.0 + + +def test_unmapped_stats_are_ignored(items_db): + assert stat_totals([_slot("5")]) == {} + + +def test_non_numeric_and_missing_ids_are_skipped(items_db): + assert stat_totals([_slot(None), _slot("not-a-number"), _slot("99999")]) == {} + + +def test_missing_db_returns_empty(tmp_path, monkeypatch): + monkeypatch.setattr(stat_deltas._items, "path", tmp_path / "absent.db") + assert stat_totals([_slot("1")]) == {} + + +# --------------------------------------------------------------------------- +# Set bonuses +# --------------------------------------------------------------------------- + + +def test_set_bonus_tier_activates_at_required_count(items_db): + totals = stat_totals([_slot("10"), _slot("11")]) # 2 of 3 pieces + # 2-piece tier: +120 sta + 10 block chance. Effect-only and 5-piece tiers: nothing. + assert totals == {"sta_eff": 120.0, "block_chance": 10.0} + + +def test_set_bonus_below_threshold_is_inactive(items_db): + assert stat_totals([_slot("10")]) == {} + + +# --------------------------------------------------------------------------- +# compute_stat_deltas +# --------------------------------------------------------------------------- + + +def test_deltas_are_set_minus_worn_with_zeroes_dropped(items_db): + deltas = compute_stat_deltas( + set_equipment=[_slot("2")], # +16 potency + current_equipment=[_slot("1")], # +10 potency +30 sta + ) + assert deltas == {"potency": 6.0, "sta_eff": -30.0} + + +def test_identical_configs_produce_no_deltas(items_db): + assert compute_stat_deltas([_slot("1")], [_slot("1")]) == {} + + +def test_set_bonus_gain_shows_in_delta(items_db): + deltas = compute_stat_deltas( + set_equipment=[_slot("10"), _slot("11")], + current_equipment=[_slot("1")], + ) + assert deltas == {"sta_eff": 90.0, "block_chance": 10.0, "potency": -10.0} diff --git a/uv.lock b/uv.lock index 4c205b69..5974e903 100644 --- a/uv.lock +++ b/uv.lock @@ -653,60 +653,64 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" +version = "12.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]]