Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
24 changes: 24 additions & 0 deletions backend/census/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
CharacterOverview,
CharacterSpells,
EquipmentSlot,
GearSet,
GuildData,
GuildMember,
ItemData,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions backend/census/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions backend/census/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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()
15 changes: 15 additions & 0 deletions backend/census/store.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
-- ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 (?, ?, ?, ?);
29 changes: 29 additions & 0 deletions backend/eq2db/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
9 changes: 9 additions & 0 deletions backend/eq2db/items.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 \\'.
Expand Down
2 changes: 2 additions & 0 deletions backend/server/api/character/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand All @@ -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
Expand Down
Loading
Loading