feat: add mana tracker panel to inventory
Derive equipped item mana state and time-remaining data in inventory-service, then render a Mana panel inside the inventory window with live icon, state, mana, and countdown display.
This commit is contained in:
parent
4e73a5d07d
commit
0e8186b8e5
4 changed files with 3930 additions and 2283 deletions
154
AGENTS.md
Normal file
154
AGENTS.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# AGENTS.md
|
||||
|
||||
Guidance for coding agents working in `MosswartOverlord` (Dereth Tracker).
|
||||
|
||||
Read shared integration rules first: `../AGENTS.md`.
|
||||
|
||||
## Scope and priorities
|
||||
|
||||
- This repo is a Python/FastAPI multi-service project with Docker-first workflows.
|
||||
- Primary services: `main.py` (telemetry API + WS + static frontend), `inventory-service/main.py` (inventory + suitbuilder), `discord-rare-monitor/discord_rare_monitor.py` (Discord bot).
|
||||
- Favor minimal, targeted changes over broad refactors.
|
||||
|
||||
## Local rule sources
|
||||
|
||||
- Additional project guidance exists in `CLAUDE.md`; follow it when relevant.
|
||||
- Cursor/Copilot rule discovery is documented centrally in `../AGENTS.md`.
|
||||
|
||||
## Environment and dependencies
|
||||
|
||||
- Python versions in Dockerfiles: 3.12 (main + bot), 3.11 (inventory-service).
|
||||
- Databases: PostgreSQL/TimescaleDB for telemetry; PostgreSQL for inventory.
|
||||
- Core Python deps: FastAPI, Uvicorn, SQLAlchemy, databases, asyncpg, httpx.
|
||||
- Bot deps: `discord.py`, `websockets`.
|
||||
|
||||
## Build and run commands
|
||||
|
||||
## Docker (recommended)
|
||||
|
||||
- Start all services: `docker compose up -d`
|
||||
- Rebuild app service after source changes (no cache): `docker compose build --no-cache dereth-tracker`
|
||||
- Redeploy app service: `docker compose up -d dereth-tracker`
|
||||
- Rebuild inventory service: `docker compose build --no-cache inventory-service`
|
||||
- Rebuild Discord bot: `docker compose build --no-cache discord-rare-monitor`
|
||||
- Follow logs (app): `docker logs mosswartoverlord-dereth-tracker-1`
|
||||
- Follow logs (telemetry DB): `docker logs dereth-db`
|
||||
|
||||
## Local (without Docker)
|
||||
|
||||
- Main API dev run: `uvicorn main:app --reload --host 0.0.0.0 --port 8765`
|
||||
- Inventory service dev run: `uvicorn main:app --reload --host 0.0.0.0 --port 8000` (from `inventory-service/`)
|
||||
- Data generator: `python generate_data.py`
|
||||
- Discord bot run: `python discord-rare-monitor/discord_rare_monitor.py`
|
||||
|
||||
## Lint/format commands
|
||||
|
||||
- Repo formatter target: `make reformat`
|
||||
- What it does: runs `black *.py` in repo root.
|
||||
- Prefer formatting changed files before finalizing edits.
|
||||
- No repo-level Ruff/Flake8/isort/mypy config files were found.
|
||||
|
||||
## Test commands
|
||||
|
||||
- There is no conventional `tests/` suite configured in this repo.
|
||||
- Existing executable test script: `python discord-rare-monitor/test_websocket.py`
|
||||
- This script validates rare classification and WebSocket handling.
|
||||
- It expects a reachable server at `ws://localhost:8765/ws/position` for connection checks.
|
||||
|
||||
## Single-test guidance (important)
|
||||
|
||||
- For the current codebase, a single targeted test means running the script above.
|
||||
- Practical single-test command:
|
||||
- `python discord-rare-monitor/test_websocket.py`
|
||||
- The script is not pytest-based; use stdout/log output for pass/fail interpretation.
|
||||
- If pytest is introduced later, preferred pattern is:
|
||||
- `python -m pytest path/to/test_file.py::test_name -q`
|
||||
|
||||
## Service-specific quick checks
|
||||
|
||||
- Main health endpoint: `GET /debug`
|
||||
- Live data endpoint: `GET /live`
|
||||
- History endpoint: `GET /history`
|
||||
- Plugin WS endpoint: `/ws/position` (authenticated)
|
||||
- Browser WS endpoint: `/ws/live` (unauthenticated)
|
||||
- Inventory service endpoint family: `/search/*`, `/inventory/*`, `/suitbuilder/*`
|
||||
|
||||
## Repo-specific architecture notes
|
||||
|
||||
- Telemetry DB schema is in `db_async.py` (SQLAlchemy Core tables).
|
||||
- Inventory DB schema is in `inventory-service/database.py` (SQLAlchemy ORM models).
|
||||
- Static frontend is served from `static/` by FastAPI.
|
||||
- Keep inventory-service enum loading paths intact (`comprehensive_enum_database_v2.json`, fallback JSON).
|
||||
|
||||
## Code style conventions observed
|
||||
|
||||
## Imports and module structure
|
||||
|
||||
- Use standard-library imports first, then third-party, then local imports.
|
||||
- Keep import groups separated by one blank line.
|
||||
- Prefer explicit imports over wildcard imports.
|
||||
- In existing files, `typing` imports are common (`Dict`, `List`, `Optional`, `Any`).
|
||||
- Avoid introducing circular imports; shared helpers belong in dedicated modules.
|
||||
|
||||
## Formatting and layout
|
||||
|
||||
- Follow Black-compatible formatting (88-char style assumptions are acceptable).
|
||||
- Use 4 spaces, no tabs.
|
||||
- Keep functions focused; extract helpers for repeated logic.
|
||||
- Maintain existing docstring style (triple double quotes for module/function docs).
|
||||
- Preserve readable logging statements with context-rich messages.
|
||||
|
||||
## Types and data models
|
||||
|
||||
- Add type hints for new functions and non-trivial variables.
|
||||
- Use Pydantic models for request/response payload validation in FastAPI layers.
|
||||
- Keep DB schema changes explicit in SQLAlchemy model/table definitions.
|
||||
- Prefer precise types over `Any` when practical.
|
||||
- For optional values, use `Optional[T]` or `T | None` consistently within a file.
|
||||
|
||||
## Naming conventions
|
||||
|
||||
- Functions/variables: `snake_case`.
|
||||
- Classes: `PascalCase`.
|
||||
- Constants/env names: `UPPER_SNAKE_CASE`.
|
||||
- Endpoint handlers should be action-oriented and descriptive.
|
||||
- Database table/column names should remain stable unless migration is planned.
|
||||
|
||||
## Error handling and resilience
|
||||
|
||||
- Prefer explicit `try/except` around external I/O boundaries:
|
||||
- DB calls, WebSocket send/recv, HTTP calls, file I/O, JSON parsing.
|
||||
- Log actionable errors with enough context to debug production issues.
|
||||
- Fail gracefully for transient network/database errors (retry where already patterned).
|
||||
- Do not swallow exceptions silently; at minimum log at `warning` or `error`.
|
||||
- Keep user-facing APIs predictable (consistent JSON error responses).
|
||||
|
||||
## Logging conventions
|
||||
|
||||
- Use module-level logger: `logger = logging.getLogger(__name__)`.
|
||||
- Respect `LOG_LEVEL` environment variable patterns already present.
|
||||
- Prefer structured, concise messages; avoid noisy logs in hot loops.
|
||||
- Keep emoji-heavy logging style only where already established in file context.
|
||||
|
||||
## Database and migrations guidance
|
||||
|
||||
- Be careful with uniqueness/index assumptions (especially portal coordinate rounding logic).
|
||||
- Validate any schema-affecting changes against Dockerized Postgres services.
|
||||
|
||||
## Frontend/static guidance
|
||||
|
||||
- Preserve existing API base path assumptions used by frontend scripts.
|
||||
- Reverse-proxy prefix behavior (`/api`) is documented in `../AGENTS.md`; keep frontend/backend paths aligned.
|
||||
|
||||
## Secrets and configuration
|
||||
|
||||
- Never hardcode secrets/tokens in commits.
|
||||
- Use env vars (`SHARED_SECRET`, `POSTGRES_PASSWORD`, bot token variables).
|
||||
- Keep defaults safe for local dev, not production credentials.
|
||||
|
||||
## Change management for agents
|
||||
|
||||
- Keep patches small and scoped to the requested task.
|
||||
- Update docs when behavior, endpoints, or run commands change.
|
||||
- If adding new tooling (pytest/ruff/mypy), include config and command docs in this file.
|
||||
- For cross-repo payload changes, follow `../AGENTS.md` checklist and update both sides.
|
||||
File diff suppressed because it is too large
Load diff
127
static/script.js
127
static/script.js
|
|
@ -1484,6 +1484,108 @@ function renderInventoryState(state) {
|
|||
}
|
||||
state.itemGrid.appendChild(cell);
|
||||
}
|
||||
|
||||
renderInventoryManaPanel(state);
|
||||
}
|
||||
|
||||
function getManaTrackedItems(state) {
|
||||
if (!state || !state.items) return [];
|
||||
|
||||
const snapshotMs = Date.now();
|
||||
return state.items
|
||||
.filter(item => (item.current_wielded_location || 0) > 0)
|
||||
.filter(item => item.is_mana_tracked || item.current_mana !== undefined || item.max_mana !== undefined || item.spellcraft !== undefined)
|
||||
.map(item => {
|
||||
const result = { ...item };
|
||||
if (result.mana_time_remaining_seconds !== undefined && result.mana_time_remaining_seconds !== null) {
|
||||
const snapshotUtc = result.mana_snapshot_utc ? Date.parse(result.mana_snapshot_utc) : NaN;
|
||||
if (!Number.isNaN(snapshotUtc)) {
|
||||
const elapsed = Math.max(0, Math.floor((snapshotMs - snapshotUtc) / 1000));
|
||||
result.live_mana_time_remaining_seconds = Math.max((result.mana_time_remaining_seconds || 0) - elapsed, 0);
|
||||
} else {
|
||||
result.live_mana_time_remaining_seconds = result.mana_time_remaining_seconds;
|
||||
}
|
||||
} else {
|
||||
result.live_mana_time_remaining_seconds = null;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aRemaining = a.live_mana_time_remaining_seconds;
|
||||
const bRemaining = b.live_mana_time_remaining_seconds;
|
||||
if (aRemaining === null && bRemaining === null) return (a.name || '').localeCompare(b.name || '');
|
||||
if (aRemaining === null) return 1;
|
||||
if (bRemaining === null) return -1;
|
||||
if (aRemaining !== bRemaining) return aRemaining - bRemaining;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
}
|
||||
|
||||
function formatManaRemaining(totalSeconds) {
|
||||
if (totalSeconds === null || totalSeconds === undefined) return '--';
|
||||
const safeSeconds = Math.max(0, Math.floor(totalSeconds));
|
||||
const hours = Math.floor(safeSeconds / 3600);
|
||||
const minutes = Math.floor((safeSeconds % 3600) / 60);
|
||||
return `${hours}h${String(minutes).padStart(2, '0')}m`;
|
||||
}
|
||||
|
||||
function renderInventoryManaPanel(state) {
|
||||
if (!state || !state.manaListBody || !state.manaSummary) return;
|
||||
|
||||
const items = getManaTrackedItems(state);
|
||||
state.manaListBody.innerHTML = '';
|
||||
|
||||
if (items.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'inv-mana-empty';
|
||||
empty.textContent = 'No equipped mana-bearing items';
|
||||
state.manaListBody.appendChild(empty);
|
||||
state.manaSummary.textContent = 'Mana: 0 tracked';
|
||||
return;
|
||||
}
|
||||
|
||||
const activeCount = items.filter(item => item.mana_state === 'active').length;
|
||||
const lowCount = items.filter(item => (item.live_mana_time_remaining_seconds || 0) > 0 && item.live_mana_time_remaining_seconds <= 7200).length;
|
||||
state.manaSummary.textContent = `Mana: ${items.length} tracked, ${activeCount} active, ${lowCount} low`;
|
||||
|
||||
items.forEach(item => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'inv-mana-row';
|
||||
|
||||
const iconWrap = document.createElement('div');
|
||||
iconWrap.className = 'inv-mana-icon';
|
||||
iconWrap.appendChild(createInventorySlot(item));
|
||||
|
||||
const nameEl = document.createElement('div');
|
||||
nameEl.className = 'inv-mana-name';
|
||||
nameEl.textContent = item.name || item.Name || 'Unknown Item';
|
||||
|
||||
const stateEl = document.createElement('div');
|
||||
const stateName = item.mana_state || 'unknown';
|
||||
stateEl.className = `inv-mana-state mana-state-${stateName}`;
|
||||
stateEl.textContent = stateName.replace(/_/g, ' ');
|
||||
|
||||
const manaEl = document.createElement('div');
|
||||
manaEl.className = 'inv-mana-value';
|
||||
if (item.current_mana !== undefined && item.max_mana !== undefined) {
|
||||
manaEl.textContent = `${item.current_mana} / ${item.max_mana}`;
|
||||
} else if (item.mana_display) {
|
||||
manaEl.textContent = item.mana_display;
|
||||
} else {
|
||||
manaEl.textContent = '--';
|
||||
}
|
||||
|
||||
const timeEl = document.createElement('div');
|
||||
timeEl.className = 'inv-mana-time';
|
||||
timeEl.textContent = formatManaRemaining(item.live_mana_time_remaining_seconds);
|
||||
|
||||
row.appendChild(iconWrap);
|
||||
row.appendChild(nameEl);
|
||||
row.appendChild(stateEl);
|
||||
row.appendChild(manaEl);
|
||||
row.appendChild(timeEl);
|
||||
state.manaListBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function showInventoryWindow(name) {
|
||||
|
|
@ -1559,6 +1661,23 @@ function showInventoryWindow(name) {
|
|||
|
||||
const bottomSection = document.createElement('div');
|
||||
bottomSection.className = 'inv-bottom-section';
|
||||
|
||||
const itemSection = document.createElement('div');
|
||||
itemSection.className = 'inv-item-section';
|
||||
|
||||
const manaPanel = document.createElement('div');
|
||||
manaPanel.className = 'inv-mana-panel';
|
||||
const manaHeader = document.createElement('div');
|
||||
manaHeader.className = 'inv-mana-header';
|
||||
manaHeader.textContent = 'Mana';
|
||||
const manaSummary = document.createElement('div');
|
||||
manaSummary.className = 'inv-mana-summary';
|
||||
manaSummary.textContent = 'Mana: loading';
|
||||
const manaListBody = document.createElement('div');
|
||||
manaListBody.className = 'inv-mana-list';
|
||||
manaPanel.appendChild(manaHeader);
|
||||
manaPanel.appendChild(manaSummary);
|
||||
manaPanel.appendChild(manaListBody);
|
||||
|
||||
const contentsHeader = document.createElement('div');
|
||||
contentsHeader.className = 'inv-contents-header';
|
||||
|
|
@ -1567,8 +1686,10 @@ function showInventoryWindow(name) {
|
|||
const itemGrid = document.createElement('div');
|
||||
itemGrid.className = 'inv-item-grid';
|
||||
|
||||
bottomSection.appendChild(contentsHeader);
|
||||
bottomSection.appendChild(itemGrid);
|
||||
itemSection.appendChild(contentsHeader);
|
||||
itemSection.appendChild(itemGrid);
|
||||
bottomSection.appendChild(itemSection);
|
||||
bottomSection.appendChild(manaPanel);
|
||||
|
||||
invContent.appendChild(topSection);
|
||||
invContent.appendChild(bottomSection);
|
||||
|
|
@ -1613,6 +1734,8 @@ function showInventoryWindow(name) {
|
|||
burdenFill: burdenFill,
|
||||
burdenLabel: burdenLabel,
|
||||
contentsHeader: contentsHeader,
|
||||
manaSummary: manaSummary,
|
||||
manaListBody: manaListBody,
|
||||
characterName: name
|
||||
};
|
||||
|
||||
|
|
|
|||
118
static/style.css
118
static/style.css
|
|
@ -908,11 +908,17 @@ body.noselect, body.noselect * {
|
|||
.inv-bottom-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
margin-top: 10px;
|
||||
margin-right: 52px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inv-bottom-section > :first-child,
|
||||
.inv-bottom-section > :nth-child(2) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inv-contents-header {
|
||||
|
|
@ -924,6 +930,12 @@ body.noselect, body.noselect * {
|
|||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.inv-item-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inv-item-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 36px);
|
||||
|
|
@ -939,6 +951,110 @@ body.noselect, body.noselect * {
|
|||
justify-content: start;
|
||||
}
|
||||
|
||||
.inv-mana-panel {
|
||||
width: 118px;
|
||||
min-width: 118px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(6, 10, 18, 0.92);
|
||||
border: 1px solid var(--ac-border-light);
|
||||
padding: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.inv-mana-header {
|
||||
color: var(--ac-gold);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--ac-border-light);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.inv-mana-summary {
|
||||
color: var(--ac-text-dim);
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.inv-mana-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inv-mana-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 1px 6px;
|
||||
align-items: center;
|
||||
background: rgba(18, 24, 34, 0.9);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.inv-mana-icon {
|
||||
grid-row: 1 / span 3;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.inv-mana-icon .inventory-slot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.inv-mana-name {
|
||||
color: #f2e6c9;
|
||||
font-size: 10px;
|
||||
line-height: 1.15;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.inv-mana-state,
|
||||
.inv-mana-value,
|
||||
.inv-mana-time {
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.inv-mana-value {
|
||||
color: #98d7ff;
|
||||
}
|
||||
|
||||
.inv-mana-time {
|
||||
color: #cfe6a0;
|
||||
}
|
||||
|
||||
.mana-state-active {
|
||||
color: #76d17f;
|
||||
}
|
||||
|
||||
.mana-state-not_active {
|
||||
color: #ff8e6f;
|
||||
}
|
||||
|
||||
.mana-state-unknown {
|
||||
color: #d4c27a;
|
||||
}
|
||||
|
||||
.mana-state-not_activatable {
|
||||
color: #97a1ad;
|
||||
}
|
||||
|
||||
.inv-mana-empty {
|
||||
color: var(--ac-text-dim);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
padding: 12px 6px;
|
||||
}
|
||||
|
||||
.inv-item-grid::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue