Compare commits
No commits in common. "master" and "feature/async-timescale" have entirely different histories.
master
...
feature/as
8 changed files with 3049 additions and 6771 deletions
154
AGENTS.md
154
AGENTS.md
|
|
@ -1,154 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
# Suitbuilder Algorithm
|
||||
|
||||
The suitbuilder finds optimal equipment loadouts across multiple characters' inventories. It fills 17 equipment slots (9 armor, 6 jewelry, 2 clothing) using a constraint satisfaction solver with depth-first search and branch pruning.
|
||||
|
||||
## Search Pipeline
|
||||
|
||||
The search runs in 5 phases, streamed to the browser via SSE:
|
||||
|
||||
1. **Load items** - Fetch from inventory API (armor by set, jewelry by slot type, clothing DR3-only)
|
||||
2. **Create buckets** - Group items into 17 slot buckets, expand multi-slot items
|
||||
3. **Apply reductions** - Generate tailored variants of multi-coverage armor pieces
|
||||
4. **Sort buckets** - Order buckets and items within them for optimal pruning
|
||||
5. **Recursive search** - Depth-first search with backtracking, streaming top 10 results
|
||||
|
||||
## Item Loading
|
||||
|
||||
Items are fetched from the internal inventory API (`localhost:8000/search/items`) in four batches:
|
||||
|
||||
| Batch | Filter | Notes |
|
||||
|-------|--------|-------|
|
||||
| Primary set armor | `item_set={name}` | All armor in user's primary set |
|
||||
| Secondary set armor | `item_set={name}` | All armor in user's secondary set |
|
||||
| Clothing | `shirt_only` / `pants_only` | Only DR3+ shirts and pants |
|
||||
| Jewelry | `jewelry_only` + `slot_names={type}` | Rings, bracelets, necklaces, trinkets separately |
|
||||
|
||||
After loading, a **domination pre-filter** removes items that are strictly worse than another item in the same slot with the same set. Item A is "surpassed" by item B when B has equal-or-better spells (Legendary > Epic > Major), equal-or-better ratings, equal-or-better armor, and is strictly better in at least one category.
|
||||
|
||||
## Bucket Creation
|
||||
|
||||
Each of the 17 slots gets a bucket. Items are assigned to buckets with special handling:
|
||||
|
||||
- **Multi-slot items** (e.g., "Left Wrist, Right Wrist") are cloned into each applicable slot bucket
|
||||
- **Generic jewelry** ("Ring" -> Left Ring + Right Ring, "Bracelet" -> Left Wrist + Right Wrist)
|
||||
- **Robes** (6+ coverage areas) are excluded entirely - they can't be reduced to single slots
|
||||
|
||||
All 17 buckets are created even if empty, allowing the search to produce incomplete suits when no valid item exists for a slot.
|
||||
|
||||
## Armor Reduction (Tailoring)
|
||||
|
||||
Multi-coverage armor can be tailored to fit a single slot. Only loot-generated items (those with a `material`) are eligible. Reduction patterns follow Mag-SuitBuilder logic:
|
||||
|
||||
| Original Coverage | Reduces To |
|
||||
|---|---|
|
||||
| Upper Arms + Lower Arms | Upper Arms **or** Lower Arms |
|
||||
| Upper Legs + Lower Legs | Upper Legs **or** Lower Legs |
|
||||
| Lower Legs + Feet | Feet |
|
||||
| Chest + Abdomen | Chest |
|
||||
| Chest + Abdomen + Upper Arms | Chest |
|
||||
| Chest + Upper Arms + Lower Arms | Chest |
|
||||
| Chest + Upper Arms | Chest |
|
||||
| Abdomen + Upper Legs + Lower Legs | Abdomen **or** Upper Legs **or** Lower Legs |
|
||||
| Chest + Abdomen + Upper Arms + Lower Arms (hauberks) | Chest |
|
||||
| Abdomen + Upper Legs | Abdomen |
|
||||
|
||||
Reduced items are added to the target slot's bucket as `"Item Name (tailored to Slot)"`.
|
||||
|
||||
## Bucket Sort Order
|
||||
|
||||
### Bucket ordering (which slot to fill first)
|
||||
|
||||
Buckets are searched in this priority:
|
||||
|
||||
1. **Core armor** - Chest, Head, Hands, Feet, Upper Arms, Lower Arms, Abdomen, Upper Legs, Lower Legs
|
||||
2. **Jewelry** - Neck, Left Ring, Right Ring, Left Wrist, Right Wrist, Trinket
|
||||
3. **Clothing** - Shirt, Pants
|
||||
|
||||
Within each category, buckets are further sorted by their position in the priority list (not by item count). This means armor slots are always filled before jewelry, and jewelry before clothing.
|
||||
|
||||
### Item ordering within each bucket
|
||||
|
||||
Items within a bucket are sorted to try the best candidates first. The sort depends on slot type:
|
||||
|
||||
| Slot Type | Sort Priority (highest first) |
|
||||
|-----------|-------------------------------|
|
||||
| **Armor** | User's primary set > secondary set > others, then crit damage rating desc, then damage rating desc, then armor level desc |
|
||||
| **Jewelry** | Spell count desc, then total ratings desc |
|
||||
| **Clothing** (Shirt/Pants) | Damage rating desc, then spell count desc, then other ratings desc |
|
||||
|
||||
All sorts include `(character_name, name)` as final tiebreakers for deterministic results.
|
||||
|
||||
## Recursive Search
|
||||
|
||||
The solver uses depth-first search with backtracking across the ordered buckets:
|
||||
|
||||
```
|
||||
for each bucket (slot) in order:
|
||||
for each item in bucket:
|
||||
if item passes constraints:
|
||||
add item to suit state
|
||||
recurse to next bucket
|
||||
remove item (backtrack)
|
||||
if no items were accepted:
|
||||
skip this slot (allow incomplete suits)
|
||||
recurse to next bucket
|
||||
```
|
||||
|
||||
When all buckets are processed, the suit is scored and kept if it ranks in the top N (default 10).
|
||||
|
||||
### Branch Pruning
|
||||
|
||||
Two pruning strategies cut off hopeless branches early:
|
||||
|
||||
1. **Mag-SuitBuilder style**: If `current_items + 1 < highest_armor_count_seen - remaining_armor_buckets`, prune. This ensures we don't explore branches that can't produce suits with enough armor pieces.
|
||||
|
||||
2. **Max-items pruning**: If `current_items + remaining_buckets < best_suit_item_count`, prune. The branch can't produce a suit with more items than the best found so far.
|
||||
|
||||
### Item Acceptance Rules (`can_add_item`)
|
||||
|
||||
An item must pass all of these checks:
|
||||
|
||||
1. **Slot available** - The slot must not already be occupied in the current suit state
|
||||
2. **Item uniqueness** - The same physical item (by ID) can't appear in multiple slots
|
||||
3. **Set membership** (armor only):
|
||||
- Primary set items: accepted up to effective limit (5 minus locked primary pieces)
|
||||
- Secondary set items: accepted up to effective limit (4 minus locked secondary pieces)
|
||||
- Other set items: **rejected** for armor slots, allowed for jewelry only if they contribute required spells
|
||||
- No-set items: **rejected** for armor, allowed for clothing always, allowed for jewelry only if they contribute required spells
|
||||
4. **Spell contribution** (when required spells are specified):
|
||||
- Items with spells must contribute at least one **new** required spell not already covered by the current suit
|
||||
- Items where all spells are duplicates of already-covered spells are **rejected**, even from the target sets
|
||||
- Jewelry has an additional gate: it must contribute an uncovered required spell or it's rejected (empty slot preferred over useless jewelry)
|
||||
|
||||
### Locked Slots
|
||||
|
||||
Users can lock specific slots with a predetermined set and/or spells. Locked slots are:
|
||||
- Removed from the bucket list (not searched)
|
||||
- Their set contributions are subtracted from set requirements (e.g., 2 locked primary pieces means only 3 more needed)
|
||||
- Their spells are counted as already fulfilled
|
||||
|
||||
## Scoring
|
||||
|
||||
The scoring system determines suit ranking. Points are awarded in this priority order:
|
||||
|
||||
### 1. Set Completion (highest weight)
|
||||
|
||||
| Condition | Points |
|
||||
|-----------|--------|
|
||||
| Primary set complete (found pieces >= effective need) | **+1000** |
|
||||
| Secondary set complete | **+1000** |
|
||||
| Missing primary piece | **-200** per missing piece |
|
||||
| Missing secondary piece | **-200** per missing piece |
|
||||
| Excess primary pieces (beyond 5) | **-500** per excess piece |
|
||||
| Excess secondary pieces (beyond 4) | **-500** per excess piece |
|
||||
|
||||
### 2. Crit Damage Rating (armor pieces)
|
||||
|
||||
| Rating | Points |
|
||||
|--------|--------|
|
||||
| CD1 (crit_damage_rating = 1) | **+10** per piece |
|
||||
| CD2 (crit_damage_rating = 2) | **+20** per piece |
|
||||
|
||||
### 3. Damage Rating (clothing only - Shirt/Pants)
|
||||
|
||||
| Rating | Points |
|
||||
|--------|--------|
|
||||
| DR1 | **+10** per piece |
|
||||
| DR2 | **+20** per piece |
|
||||
| DR3 | **+30** per piece |
|
||||
|
||||
### 4. Spell Coverage
|
||||
|
||||
| Condition | Points |
|
||||
|-----------|--------|
|
||||
| Each fulfilled required spell | **+100** |
|
||||
|
||||
### 5. Base Item Score
|
||||
|
||||
| Condition | Points |
|
||||
|-----------|--------|
|
||||
| Each item in the suit | **+5** |
|
||||
|
||||
### 6. Armor Level (tiebreaker only)
|
||||
|
||||
| Condition | Points |
|
||||
|-----------|--------|
|
||||
| Total armor level | **+1 per 100 AL** (e.g., 4500 AL = +45) |
|
||||
|
||||
Score is floored at 0 (never negative).
|
||||
|
||||
### Practical Effect of Scoring Weights
|
||||
|
||||
The weights create this effective priority:
|
||||
|
||||
1. **Complete sets matter most** - A suit with both sets complete (+2000) always beats one with a missing piece, regardless of other stats
|
||||
2. **Spells matter second** - Each required cantrip/ward is worth +100, so 10 spells = +1000 (equivalent to one complete set)
|
||||
3. **Crit damage and damage rating are tiebreakers** - CD2 on all 9 armor pieces = +180, DR3 on both clothes = +60
|
||||
4. **Armor level barely matters** - Only ~45 points for a full suit of 4500 AL; it only breaks ties between otherwise-equal suits
|
||||
|
||||
## Frontend Display
|
||||
|
||||
Results stream in as SSE events. The frontend maintains a sorted list of top 10 suits:
|
||||
|
||||
- New suits are inserted in score-ordered position (highest first)
|
||||
- If the list is full (10 suits) and the new suit scores lower than all existing ones, it's discarded
|
||||
- Medals are assigned by position: gold/silver/bronze for top 3
|
||||
|
||||
### Score Display Classes
|
||||
|
||||
| Score Range | CSS Class |
|
||||
|-------------|-----------|
|
||||
| >= 90 | `excellent` |
|
||||
| >= 75 | `good` |
|
||||
| >= 60 | `fair` |
|
||||
| < 60 | `poor` |
|
||||
|
||||
### Item Display
|
||||
|
||||
Each suit shows a table with all 17 slots. Per item:
|
||||
- **Armor pieces**: Show CD (crit damage) and CDR (crit damage resist) ratings
|
||||
- **Clothing pieces**: Show DR (damage rating) and DRR (damage resist rating)
|
||||
- **Spells**: Show up to 2 Legendary/Epic spells, then "+N more"
|
||||
- **Multi-slot items** that need tailoring are marked with an asterisk (*)
|
||||
|
||||
### Suit Selection
|
||||
|
||||
Clicking a suit populates the right-panel equipment slots visual. Users can then:
|
||||
- Lock slots (preserving set/spell info for re-searches)
|
||||
- Copy suit summary to clipboard
|
||||
- Clear individual slots
|
||||
File diff suppressed because it is too large
Load diff
797
static/script.js
797
static/script.js
|
|
@ -309,7 +309,6 @@ const chatWindows = {};
|
|||
const statsWindows = {};
|
||||
// Keep track of open inventory windows: character_name -> DOM element
|
||||
const inventoryWindows = {};
|
||||
const equipmentCantripStates = {};
|
||||
|
||||
/**
|
||||
* ---------- Application Constants -----------------------------
|
||||
|
|
@ -894,131 +893,6 @@ function updateStatsTimeRange(content, name, timeRange) {
|
|||
}
|
||||
|
||||
// Show or create an inventory window for a character
|
||||
/**
|
||||
* Normalize raw plugin MyWorldObject format to flat fields expected by createInventorySlot.
|
||||
* Plugin sends PascalCase computed properties: { Id, Icon, Name, Value, Burden, ArmorLevel, Material, ... }
|
||||
* Also has raw dictionaries: { IntValues: {19: value, 5: burden, ...}, StringValues: {1: name, ...} }
|
||||
* Inventory service sends flat lowercase: { item_id, icon, name, value, burden, armor_level, ... }
|
||||
*
|
||||
* MyWorldObject uses -1 as sentinel for "not set" on int/double properties.
|
||||
*/
|
||||
function normalizeInventoryItem(item) {
|
||||
if (!item) return item;
|
||||
if (item.name && item.item_id) return item;
|
||||
|
||||
// MyWorldObject uses -1 as "not set" sentinel — filter those out
|
||||
const v = (val) => (val !== undefined && val !== null && val !== -1) ? val : undefined;
|
||||
|
||||
if (!item.item_id) item.item_id = item.Id;
|
||||
if (!item.icon) item.icon = item.Icon;
|
||||
if (!item.object_class) item.object_class = item.ObjectClass;
|
||||
if (item.HasIdData !== undefined) item.has_id_data = item.HasIdData;
|
||||
|
||||
const baseName = item.Name || (item.StringValues && item.StringValues['1']) || null;
|
||||
const material = item.Material || null;
|
||||
if (material) {
|
||||
item.material = material;
|
||||
item.material_name = material;
|
||||
}
|
||||
|
||||
// Prepend material to name (e.g. "Pants" → "Satin Pants") matching inventory-service
|
||||
if (baseName) {
|
||||
if (material && !baseName.toLowerCase().startsWith(material.toLowerCase())) {
|
||||
item.name = material + ' ' + baseName;
|
||||
} else {
|
||||
item.name = baseName;
|
||||
}
|
||||
}
|
||||
|
||||
const iv = item.IntValues || {};
|
||||
if (item.value === undefined) item.value = v(item.Value) ?? v(iv['19']);
|
||||
if (item.burden === undefined) item.burden = v(item.Burden) ?? v(iv['5']);
|
||||
|
||||
// Container/equipment tracking
|
||||
if (item.container_id === undefined) item.container_id = item.ContainerId || 0;
|
||||
if (item.current_wielded_location === undefined) {
|
||||
item.current_wielded_location = v(item.CurrentWieldedLocation) ?? v(iv['10']) ?? 0;
|
||||
}
|
||||
if (item.items_capacity === undefined) item.items_capacity = v(item.ItemsCapacity) ?? v(iv['6']);
|
||||
|
||||
const armor = v(item.ArmorLevel);
|
||||
if (armor !== undefined) item.armor_level = armor;
|
||||
|
||||
const maxDmg = v(item.MaxDamage);
|
||||
if (maxDmg !== undefined) item.max_damage = maxDmg;
|
||||
|
||||
const dmgBonus = v(item.DamageBonus);
|
||||
if (dmgBonus !== undefined) item.damage_bonus = dmgBonus;
|
||||
|
||||
const atkBonus = v(item.AttackBonus);
|
||||
if (atkBonus !== undefined) item.attack_bonus = atkBonus;
|
||||
|
||||
const elemDmg = v(item.ElementalDmgBonus);
|
||||
if (elemDmg !== undefined) item.elemental_damage_vs_monsters = elemDmg;
|
||||
|
||||
const meleeD = v(item.MeleeDefenseBonus);
|
||||
if (meleeD !== undefined) item.melee_defense_bonus = meleeD;
|
||||
|
||||
const magicD = v(item.MagicDBonus);
|
||||
if (magicD !== undefined) item.magic_defense_bonus = magicD;
|
||||
|
||||
const missileD = v(item.MissileDBonus);
|
||||
if (missileD !== undefined) item.missile_defense_bonus = missileD;
|
||||
|
||||
const manaC = v(item.ManaCBonus);
|
||||
if (manaC !== undefined) item.mana_conversion_bonus = manaC;
|
||||
|
||||
const wieldLvl = v(item.WieldLevel);
|
||||
if (wieldLvl !== undefined) item.wield_level = wieldLvl;
|
||||
|
||||
const skillLvl = v(item.SkillLevel);
|
||||
if (skillLvl !== undefined) item.skill_level = skillLvl;
|
||||
|
||||
const loreLvl = v(item.LoreRequirement);
|
||||
if (loreLvl !== undefined) item.lore_requirement = loreLvl;
|
||||
|
||||
if (item.EquipSkill) item.equip_skill = item.EquipSkill;
|
||||
if (item.Mastery) item.mastery = item.Mastery;
|
||||
if (item.ItemSet) item.item_set = item.ItemSet;
|
||||
if (item.Imbue) item.imbue = item.Imbue;
|
||||
|
||||
const tinks = v(item.Tinks);
|
||||
if (tinks !== undefined) item.tinks = tinks;
|
||||
|
||||
const work = v(item.Workmanship);
|
||||
if (work !== undefined) item.workmanship = work;
|
||||
|
||||
const damR = v(item.DamRating);
|
||||
if (damR !== undefined) item.damage_rating = damR;
|
||||
|
||||
const critR = v(item.CritRating);
|
||||
if (critR !== undefined) item.crit_rating = critR;
|
||||
|
||||
const healR = v(item.HealBoostRating);
|
||||
if (healR !== undefined) item.heal_boost_rating = healR;
|
||||
|
||||
const vitalR = v(item.VitalityRating);
|
||||
if (vitalR !== undefined) item.vitality_rating = vitalR;
|
||||
|
||||
const critDmgR = v(item.CritDamRating);
|
||||
if (critDmgR !== undefined) item.crit_damage_rating = critDmgR;
|
||||
|
||||
const damResR = v(item.DamResistRating);
|
||||
if (damResR !== undefined) item.damage_resist_rating = damResR;
|
||||
|
||||
const critResR = v(item.CritResistRating);
|
||||
if (critResR !== undefined) item.crit_resist_rating = critResR;
|
||||
|
||||
const critDmgResR = v(item.CritDamResistRating);
|
||||
if (critDmgResR !== undefined) item.crit_damage_resist_rating = critDmgResR;
|
||||
|
||||
if (item.Spells && Array.isArray(item.Spells) && item.Spells.length > 0 && !item.spells) {
|
||||
item.spells = item.Spells;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single inventory slot DOM element from item data.
|
||||
* Used by both initial inventory load and live delta updates.
|
||||
|
|
@ -1026,7 +900,7 @@ function normalizeInventoryItem(item) {
|
|||
function createInventorySlot(item) {
|
||||
const slot = document.createElement('div');
|
||||
slot.className = 'inventory-slot';
|
||||
slot.setAttribute('data-item-id', item.item_id || item.Id || item.id || 0);
|
||||
slot.setAttribute('data-item-id', item.Id || item.id || item.item_id || 0);
|
||||
|
||||
// Create layered icon container
|
||||
const iconContainer = document.createElement('div');
|
||||
|
|
@ -1146,83 +1020,9 @@ function createInventorySlot(item) {
|
|||
slot.addEventListener('mouseleave', hideInventoryTooltip);
|
||||
|
||||
slot.appendChild(iconContainer);
|
||||
|
||||
// Add stack count if > 1
|
||||
const stackCount = item.count || item.Count || item.stack_count || item.StackCount || 1;
|
||||
if (stackCount > 1) {
|
||||
const countEl = document.createElement('div');
|
||||
countEl.className = 'inventory-count';
|
||||
countEl.textContent = stackCount;
|
||||
slot.appendChild(countEl);
|
||||
}
|
||||
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equipment slots mapping for the AC inventory layout.
|
||||
* Grid matches the real AC "Equipment Slots Enabled" paperdoll view.
|
||||
*
|
||||
* Layout (6 cols × 6 rows):
|
||||
* Col: 1 2 3 4 5 6
|
||||
* Row 1: Neck — Head Sigil(Blue) Sigil(Yellow) Sigil(Red)
|
||||
* Row 2: Trinket — ChestArmor — — Cloak
|
||||
* Row 3: Bracelet(L) UpperArmArmor AbdomenArmor — Bracelet(R) ChestWear(Shirt)
|
||||
* Row 4: Ring(L) LowerArmArmor UpperLegArmor — Ring(R) AbdomenWear(Pants)
|
||||
* Row 5: — Hands — LowerLegArmor — —
|
||||
* Row 6: Shield — — Feet Weapon Ammo
|
||||
*/
|
||||
const EQUIP_SLOTS = {
|
||||
// Row 1: Necklace, Head, 3× Aetheria/Sigil
|
||||
32768: { name: 'Neck', row: 1, col: 1 }, // EquipMask.NeckWear
|
||||
1: { name: 'Head', row: 1, col: 3 }, // EquipMask.HeadWear
|
||||
268435456: { name: 'Sigil (Blue)', row: 1, col: 5 }, // EquipMask.SigilOne
|
||||
536870912: { name: 'Sigil (Yellow)', row: 1, col: 6 }, // EquipMask.SigilTwo
|
||||
1073741824: { name: 'Sigil (Red)', row: 1, col: 7 }, // EquipMask.SigilThree
|
||||
|
||||
// Row 2: Trinket, Chest Armor, Cloak
|
||||
67108864: { name: 'Trinket', row: 2, col: 1 }, // EquipMask.TrinketOne
|
||||
2048: { name: 'Upper Arm Armor',row: 2, col: 2 }, // EquipMask.UpperArmArmor
|
||||
512: { name: 'Chest Armor', row: 2, col: 3 }, // EquipMask.ChestArmor
|
||||
134217728: { name: 'Cloak', row: 2, col: 7 }, // EquipMask.Cloak
|
||||
|
||||
// Row 3: Bracelet(L), Lower Arm Armor, Abdomen Armor, Upper Leg Armor, Bracelet(R), Shirt
|
||||
65536: { name: 'Bracelet (L)', row: 3, col: 1 }, // EquipMask.WristWearLeft
|
||||
4096: { name: 'Lower Arm Armor',row: 3, col: 2 }, // EquipMask.LowerArmArmor
|
||||
1024: { name: 'Abdomen Armor', row: 3, col: 3 }, // EquipMask.AbdomenArmor
|
||||
8192: { name: 'Upper Leg Armor',row: 3, col: 4 }, // EquipMask.UpperLegArmor
|
||||
131072: { name: 'Bracelet (R)', row: 3, col: 5 }, // EquipMask.WristWearRight
|
||||
2: { name: 'Shirt', row: 3, col: 7 }, // EquipMask.ChestWear
|
||||
|
||||
// Row 4: Ring(L), Hands, Lower Leg Armor, Ring(R), Pants
|
||||
262144: { name: 'Ring (L)', row: 4, col: 1 }, // EquipMask.FingerWearLeft
|
||||
32: { name: 'Hands', row: 4, col: 2 }, // EquipMask.HandWear
|
||||
16384: { name: 'Lower Leg Armor',row: 4, col: 4 }, // EquipMask.LowerLegArmor
|
||||
524288: { name: 'Ring (R)', row: 4, col: 5 }, // EquipMask.FingerWearRight
|
||||
4: { name: 'Pants', row: 4, col: 7 }, // EquipMask.AbdomenWear
|
||||
|
||||
// Row 5: Feet
|
||||
256: { name: 'Feet', row: 5, col: 4 }, // EquipMask.FootWear
|
||||
|
||||
// Row 6: Shield, Weapon, Ammo
|
||||
2097152: { name: 'Shield', row: 6, col: 1 }, // EquipMask.Shield
|
||||
1048576: { name: 'Melee Weapon', row: 6, col: 3 }, // EquipMask.MeleeWeapon
|
||||
4194304: { name: 'Missile Weapon', row: 6, col: 3 }, // EquipMask.MissileWeapon
|
||||
16777216: { name: 'Held', row: 6, col: 3 }, // EquipMask.Held
|
||||
33554432: { name: 'Two Handed', row: 6, col: 3 }, // EquipMask.TwoHanded
|
||||
8388608: { name: 'Ammo', row: 6, col: 7 }, // EquipMask.Ammunition
|
||||
};
|
||||
|
||||
const SLOT_COLORS = {};
|
||||
// Purple: jewelry
|
||||
[32768, 67108864, 65536, 131072, 262144, 524288].forEach(m => SLOT_COLORS[m] = 'slot-purple');
|
||||
// Blue: armor
|
||||
[1, 512, 2048, 1024, 4096, 8192, 16384, 32, 256].forEach(m => SLOT_COLORS[m] = 'slot-blue');
|
||||
// Teal: clothing/misc
|
||||
[2, 4, 134217728, 268435456, 536870912, 1073741824].forEach(m => SLOT_COLORS[m] = 'slot-teal');
|
||||
// Dark blue: weapons/combat
|
||||
[2097152, 1048576, 4194304, 16777216, 33554432, 8388608].forEach(m => SLOT_COLORS[m] = 'slot-darkblue');
|
||||
|
||||
/**
|
||||
* Handle live inventory delta updates from WebSocket.
|
||||
* Updates the inventory grid for a character if their inventory window is open.
|
||||
|
|
@ -1230,403 +1030,36 @@ const SLOT_COLORS = {};
|
|||
function updateInventoryLive(delta) {
|
||||
const name = delta.character_name;
|
||||
const win = inventoryWindows[name];
|
||||
if (!win || !win._inventoryState) {
|
||||
return;
|
||||
}
|
||||
if (!win) return; // No inventory window open for this character
|
||||
|
||||
const state = win._inventoryState;
|
||||
const getItemId = (d) => {
|
||||
if (d.item) return d.item.item_id || d.item.Id || d.item.id;
|
||||
return d.item_id;
|
||||
};
|
||||
|
||||
const itemId = getItemId(delta);
|
||||
const grid = win.querySelector('.inventory-grid');
|
||||
if (!grid) return;
|
||||
|
||||
if (delta.action === 'remove') {
|
||||
state.items = state.items.filter(i => (i.item_id || i.Id || i.id) !== itemId);
|
||||
} else if (delta.action === 'add' || delta.action === 'update') {
|
||||
normalizeInventoryItem(delta.item);
|
||||
const existingIdx = state.items.findIndex(i => (i.item_id || i.Id || i.id) === itemId);
|
||||
if (existingIdx >= 0) {
|
||||
state.items[existingIdx] = delta.item;
|
||||
const itemId = delta.item_id || (delta.item && (delta.item.Id || delta.item.id));
|
||||
const existing = grid.querySelector(`[data-item-id="${itemId}"]`);
|
||||
if (existing) existing.remove();
|
||||
} else if (delta.action === 'add') {
|
||||
const newSlot = createInventorySlot(delta.item);
|
||||
grid.appendChild(newSlot);
|
||||
} else if (delta.action === 'update') {
|
||||
const itemId = delta.item.Id || delta.item.id || delta.item.item_id;
|
||||
const existing = grid.querySelector(`[data-item-id="${itemId}"]`);
|
||||
if (existing) {
|
||||
const newSlot = createInventorySlot(delta.item);
|
||||
existing.replaceWith(newSlot);
|
||||
} else {
|
||||
state.items.push(delta.item);
|
||||
const newSlot = createInventorySlot(delta.item);
|
||||
grid.appendChild(newSlot);
|
||||
}
|
||||
}
|
||||
|
||||
renderInventoryState(state);
|
||||
}
|
||||
|
||||
function renderInventoryState(state) {
|
||||
// 1. Clear equipment slots
|
||||
state.slotMap.forEach((slotEl) => {
|
||||
slotEl.innerHTML = '';
|
||||
const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
|
||||
slotEl.className = `inv-equip-slot empty ${colorClass}`;
|
||||
delete slotEl.dataset.itemId;
|
||||
});
|
||||
|
||||
// 2. Identify containers (object_class === 10) by item_id for sidebar
|
||||
// These are packs/sacks/pouches/foci that appear in inventory as items
|
||||
// but should ONLY show in the pack sidebar, not in the item grid.
|
||||
const containers = []; // container objects (object_class=10)
|
||||
const containerItemIds = new Set(); // item_ids of containers (to exclude from grid)
|
||||
|
||||
state.items.forEach(item => {
|
||||
if (item.object_class === 10) {
|
||||
containers.push(item);
|
||||
containerItemIds.add(item.item_id);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Separate equipped items from pack items, excluding containers from grid
|
||||
const packItems = new Map(); // container_id → [items] (non-container items only)
|
||||
|
||||
// Determine the character body container_id: items with wielded_location > 0
|
||||
// share a container_id that is NOT 0 and NOT a pack's item_id.
|
||||
// We treat non-wielded items from the body container as "main backpack" items.
|
||||
let bodyContainerId = null;
|
||||
state.items.forEach(item => {
|
||||
if (item.current_wielded_location && item.current_wielded_location > 0) {
|
||||
const cid = item.container_id;
|
||||
if (cid && cid !== 0 && !containerItemIds.has(cid)) {
|
||||
bodyContainerId = cid;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
state.items.forEach(item => {
|
||||
// Skip container objects — they go in sidebar only
|
||||
if (containerItemIds.has(item.item_id)) return;
|
||||
|
||||
if (item.current_wielded_location && item.current_wielded_location > 0) {
|
||||
const mask = item.current_wielded_location;
|
||||
const isArmor = item.object_class === 2;
|
||||
|
||||
// For armor (object_class=2): render in ALL matching slots (multi-slot display)
|
||||
// For everything else (clothing, jewelry, weapons): place in first matching slot only
|
||||
if (isArmor) {
|
||||
Object.keys(EQUIP_SLOTS).forEach(m => {
|
||||
const slotMask = parseInt(m);
|
||||
if ((mask & slotMask) === slotMask) {
|
||||
const slotDef = EQUIP_SLOTS[slotMask];
|
||||
const key = `${slotDef.row}-${slotDef.col}`;
|
||||
if (state.slotMap.has(key)) {
|
||||
const slotEl = state.slotMap.get(key);
|
||||
if (!slotEl.dataset.itemId) {
|
||||
slotEl.innerHTML = '';
|
||||
const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
|
||||
slotEl.className = `inv-equip-slot equipped ${colorClass}`;
|
||||
slotEl.dataset.itemId = item.item_id;
|
||||
slotEl.appendChild(createInventorySlot(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Non-armor: find the first matching slot by exact mask key, then by bit overlap
|
||||
let placed = false;
|
||||
// Try exact mask match first (e.g. necklace mask=32768 matches key 32768 directly)
|
||||
if (EQUIP_SLOTS[mask]) {
|
||||
const slotDef = EQUIP_SLOTS[mask];
|
||||
const key = `${slotDef.row}-${slotDef.col}`;
|
||||
if (state.slotMap.has(key)) {
|
||||
const slotEl = state.slotMap.get(key);
|
||||
if (!slotEl.dataset.itemId) {
|
||||
slotEl.innerHTML = '';
|
||||
const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
|
||||
slotEl.className = `inv-equip-slot equipped ${colorClass}`;
|
||||
slotEl.dataset.itemId = item.item_id;
|
||||
slotEl.appendChild(createInventorySlot(item));
|
||||
placed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no exact match, find first matching bit in EQUIP_SLOTS
|
||||
if (!placed) {
|
||||
for (const m of Object.keys(EQUIP_SLOTS)) {
|
||||
const slotMask = parseInt(m);
|
||||
if ((mask & slotMask) === slotMask) {
|
||||
const slotDef = EQUIP_SLOTS[slotMask];
|
||||
const key = `${slotDef.row}-${slotDef.col}`;
|
||||
if (state.slotMap.has(key)) {
|
||||
const slotEl = state.slotMap.get(key);
|
||||
if (!slotEl.dataset.itemId) {
|
||||
slotEl.innerHTML = '';
|
||||
const colorClass = slotEl.className.match(/slot-\w+/)?.[0] || '';
|
||||
slotEl.className = `inv-equip-slot equipped ${colorClass}`;
|
||||
slotEl.dataset.itemId = item.item_id;
|
||||
slotEl.appendChild(createInventorySlot(item));
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-equipped, non-container → pack item. Group by container_id.
|
||||
let cid = item.container_id || 0;
|
||||
// Items on the character body (not wielded) → treat as main backpack (cid=0)
|
||||
if (bodyContainerId !== null && cid === bodyContainerId) cid = 0;
|
||||
if (!packItems.has(cid)) packItems.set(cid, []);
|
||||
packItems.get(cid).push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const stats = characterStats[state.characterName] || {};
|
||||
const burdenUnits = Number(stats.burden_units || 0);
|
||||
const encumbranceCapacity = Number(stats.encumbrance_capacity || 0);
|
||||
const burdenPct = encumbranceCapacity > 0
|
||||
? Math.max(0, Math.min(200, (burdenUnits / encumbranceCapacity) * 100))
|
||||
: 0;
|
||||
const burdenDisplay = Math.floor(burdenPct);
|
||||
state.burdenLabel.textContent = `${burdenDisplay}%`;
|
||||
state.burdenLabel.title = burdenUnits > 0 && encumbranceCapacity > 0
|
||||
? `${burdenUnits.toLocaleString()} / ${encumbranceCapacity.toLocaleString()}`
|
||||
: '';
|
||||
// Fill height: map 0-200% burden onto 0-100% bar height
|
||||
state.burdenFill.style.height = `${burdenPct / 2}%`;
|
||||
// Color by threshold
|
||||
state.burdenFill.style.backgroundColor = burdenPct > 150
|
||||
? '#b7432c'
|
||||
: burdenPct > 100
|
||||
? '#d8a431'
|
||||
: '#2e8b57';
|
||||
|
||||
// 4. Sort containers for stable sidebar order (by unsigned item_id)
|
||||
containers.sort((a, b) => {
|
||||
const ua = a.item_id >>> 0;
|
||||
const ub = b.item_id >>> 0;
|
||||
return ua - ub;
|
||||
});
|
||||
|
||||
// 5. Render packs in sidebar
|
||||
state.packList.innerHTML = '';
|
||||
|
||||
// Helper: compute icon URL from raw icon id
|
||||
const iconUrl = (iconRaw) => {
|
||||
if (!iconRaw) return '/icons/06001080.png';
|
||||
const hex = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
||||
return `/icons/${hex}.png`;
|
||||
};
|
||||
|
||||
// --- Main backpack (container_id === 0, non-containers) ---
|
||||
const mainPackEl = document.createElement('div');
|
||||
mainPackEl.className = `inv-pack-icon ${state.activePack === null ? 'active' : ''}`;
|
||||
const mainPackImg = document.createElement('img');
|
||||
mainPackImg.src = '/icons/0600127E.png';
|
||||
mainPackImg.onerror = function() { this.src = '/icons/06000133.png'; };
|
||||
|
||||
const mainFillCont = document.createElement('div');
|
||||
mainFillCont.className = 'inv-pack-fill-container';
|
||||
const mainFill = document.createElement('div');
|
||||
mainFill.className = 'inv-pack-fill';
|
||||
|
||||
// Main backpack items = container_id 0, excluding container objects
|
||||
const mainPackItems = packItems.get(0) || [];
|
||||
const mainPct = Math.min(100, (mainPackItems.length / 102) * 100);
|
||||
mainFill.style.height = `${mainPct}%`;
|
||||
|
||||
mainFillCont.appendChild(mainFill);
|
||||
mainPackEl.appendChild(mainPackImg);
|
||||
mainPackEl.appendChild(mainFillCont);
|
||||
|
||||
mainPackEl.onclick = () => {
|
||||
state.activePack = null;
|
||||
renderInventoryState(state);
|
||||
};
|
||||
state.packList.appendChild(mainPackEl);
|
||||
|
||||
// --- Sub-packs: each container object (object_class=10) ---
|
||||
containers.forEach(container => {
|
||||
const cid = container.item_id; // items inside this pack have container_id = this item_id
|
||||
const packEl = document.createElement('div');
|
||||
packEl.className = `inv-pack-icon ${state.activePack === cid ? 'active' : ''}`;
|
||||
const packImg = document.createElement('img');
|
||||
// Use the container's actual icon from the API
|
||||
packImg.src = iconUrl(container.icon);
|
||||
packImg.onerror = function() { this.src = '/icons/06001080.png'; };
|
||||
|
||||
const fillCont = document.createElement('div');
|
||||
fillCont.className = 'inv-pack-fill-container';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'inv-pack-fill';
|
||||
|
||||
const pItems = packItems.get(cid) || [];
|
||||
const capacity = container.items_capacity || 24; // default pack capacity in AC
|
||||
const pPct = Math.min(100, (pItems.length / capacity) * 100);
|
||||
fill.style.height = `${pPct}%`;
|
||||
|
||||
fillCont.appendChild(fill);
|
||||
packEl.appendChild(packImg);
|
||||
packEl.appendChild(fillCont);
|
||||
|
||||
packEl.onclick = () => {
|
||||
state.activePack = cid;
|
||||
renderInventoryState(state);
|
||||
};
|
||||
state.packList.appendChild(packEl);
|
||||
});
|
||||
|
||||
// 6. Render item grid
|
||||
state.itemGrid.innerHTML = '';
|
||||
let itemsToShow = [];
|
||||
if (state.activePack === null) {
|
||||
// Main backpack: non-container items with container_id === 0
|
||||
itemsToShow = mainPackItems;
|
||||
state.contentsHeader.textContent = 'Contents of Backpack';
|
||||
} else {
|
||||
// Sub-pack: items with matching container_id
|
||||
itemsToShow = packItems.get(state.activePack) || [];
|
||||
// Use the container's name for the header
|
||||
const activeContainer = containers.find(c => c.item_id === state.activePack);
|
||||
state.contentsHeader.textContent = activeContainer
|
||||
? `Contents of ${activeContainer.name}`
|
||||
: 'Contents of Pack';
|
||||
// Update item count
|
||||
const countEl = win.querySelector('.inventory-count');
|
||||
if (countEl) {
|
||||
const slotCount = grid.querySelectorAll('.inventory-slot').length;
|
||||
countEl.textContent = `${slotCount} items`;
|
||||
}
|
||||
|
||||
const numCells = Math.max(24, Math.ceil(itemsToShow.length / 6) * 6);
|
||||
for (let i = 0; i < numCells; i++) {
|
||||
const cell = document.createElement('div');
|
||||
if (i < itemsToShow.length) {
|
||||
cell.className = 'inv-item-slot occupied';
|
||||
const itemNode = createInventorySlot(itemsToShow[i]);
|
||||
cell.appendChild(itemNode);
|
||||
} else {
|
||||
cell.className = 'inv-item-slot';
|
||||
}
|
||||
state.itemGrid.appendChild(cell);
|
||||
}
|
||||
|
||||
renderInventoryManaPanel(state);
|
||||
}
|
||||
|
||||
function getManaTrackedItems(state) {
|
||||
if (!state || !state.items) return [];
|
||||
|
||||
const overlayItems = equipmentCantripStates[state.characterName]?.items;
|
||||
const overlayMap = new Map();
|
||||
if (Array.isArray(overlayItems)) {
|
||||
overlayItems.forEach(item => {
|
||||
if (item && item.item_id != null) {
|
||||
overlayMap.set(Number(item.item_id), item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const snapshotMs = Date.now();
|
||||
return state.items
|
||||
.filter(item => (item.current_wielded_location || 0) > 0)
|
||||
.filter(item => overlayMap.has(Number(item.item_id)))
|
||||
.map(item => {
|
||||
const result = { ...item };
|
||||
const overlay = overlayMap.get(Number(item.item_id)) || {};
|
||||
result.mana_state = overlay.state || 'unknown';
|
||||
if (overlay.current_mana !== undefined && overlay.current_mana !== null) {
|
||||
result.current_mana = overlay.current_mana;
|
||||
}
|
||||
if (overlay.max_mana !== undefined && overlay.max_mana !== null) {
|
||||
result.max_mana = overlay.max_mana;
|
||||
}
|
||||
if (overlay.mana_time_remaining_seconds !== undefined && overlay.mana_time_remaining_seconds !== null) {
|
||||
result.mana_time_remaining_seconds = overlay.mana_time_remaining_seconds;
|
||||
result.mana_snapshot_utc = equipmentCantripStates[state.characterName]?.timestamp || null;
|
||||
}
|
||||
|
||||
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';
|
||||
const iconSlot = createInventorySlot(item);
|
||||
iconSlot.classList.add('mana-slot');
|
||||
iconWrap.appendChild(iconSlot);
|
||||
|
||||
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-dot mana-state-${stateName}`;
|
||||
stateEl.title = 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) {
|
||||
|
|
@ -1645,137 +1078,19 @@ function showInventoryWindow(name) {
|
|||
win.dataset.character = name;
|
||||
inventoryWindows[name] = win;
|
||||
|
||||
// Loading message
|
||||
const loading = document.createElement('div');
|
||||
loading.className = 'inventory-loading';
|
||||
loading.textContent = 'Loading inventory...';
|
||||
content.appendChild(loading);
|
||||
|
||||
win.style.width = '572px';
|
||||
win.style.height = '720px';
|
||||
|
||||
// Inventory content container
|
||||
const invContent = document.createElement('div');
|
||||
invContent.className = 'inventory-content';
|
||||
invContent.style.display = 'none';
|
||||
content.appendChild(invContent);
|
||||
|
||||
const equipGrid = document.createElement('div');
|
||||
equipGrid.className = 'inv-equipment-grid';
|
||||
|
||||
const slotMap = new Map();
|
||||
const createdSlots = new Set();
|
||||
|
||||
Object.entries(EQUIP_SLOTS).forEach(([mask, slotDef]) => {
|
||||
const key = `${slotDef.row}-${slotDef.col}`;
|
||||
if (!createdSlots.has(key)) {
|
||||
createdSlots.add(key);
|
||||
const slotEl = document.createElement('div');
|
||||
const colorClass = SLOT_COLORS[parseInt(mask)] || 'slot-darkblue';
|
||||
slotEl.className = `inv-equip-slot empty ${colorClass}`;
|
||||
slotEl.style.left = `${(slotDef.col - 1) * 44}px`;
|
||||
slotEl.style.top = `${(slotDef.row - 1) * 44}px`;
|
||||
slotEl.dataset.pos = key;
|
||||
equipGrid.appendChild(slotEl);
|
||||
slotMap.set(key, slotEl);
|
||||
}
|
||||
});
|
||||
|
||||
const sidebar = document.createElement('div');
|
||||
sidebar.className = 'inv-sidebar';
|
||||
|
||||
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 burdenContainer = document.createElement('div');
|
||||
burdenContainer.className = 'inv-burden-bar';
|
||||
const burdenFill = document.createElement('div');
|
||||
burdenFill.className = 'inv-burden-fill';
|
||||
const burdenLabel = document.createElement('div');
|
||||
burdenLabel.className = 'inv-burden-label';
|
||||
burdenLabel.textContent = 'Burden';
|
||||
burdenContainer.appendChild(burdenFill);
|
||||
sidebar.appendChild(burdenLabel);
|
||||
sidebar.appendChild(burdenContainer);
|
||||
|
||||
const packList = document.createElement('div');
|
||||
packList.className = 'inv-pack-list';
|
||||
sidebar.appendChild(packList);
|
||||
|
||||
const leftColumn = document.createElement('div');
|
||||
leftColumn.className = 'inv-left-column';
|
||||
|
||||
const contentsHeader = document.createElement('div');
|
||||
contentsHeader.className = 'inv-contents-header';
|
||||
contentsHeader.textContent = 'Contents of Backpack';
|
||||
|
||||
const itemGrid = document.createElement('div');
|
||||
itemGrid.className = 'inv-item-grid';
|
||||
|
||||
leftColumn.appendChild(equipGrid);
|
||||
leftColumn.appendChild(contentsHeader);
|
||||
leftColumn.appendChild(itemGrid);
|
||||
|
||||
invContent.appendChild(leftColumn);
|
||||
invContent.appendChild(sidebar);
|
||||
invContent.appendChild(manaPanel);
|
||||
|
||||
const resizeGrip = document.createElement('div');
|
||||
resizeGrip.className = 'inv-resize-grip';
|
||||
win.appendChild(resizeGrip);
|
||||
|
||||
let resizing = false;
|
||||
let startY, startH;
|
||||
|
||||
resizeGrip.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resizing = true;
|
||||
startY = e.clientY;
|
||||
startH = win.offsetHeight;
|
||||
document.body.style.cursor = 'ns-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!resizing) return;
|
||||
const newH = Math.max(400, startH + (e.clientY - startY));
|
||||
win.style.height = newH + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (!resizing) return;
|
||||
resizing = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
});
|
||||
|
||||
win._inventoryState = {
|
||||
windowEl: win,
|
||||
items: [],
|
||||
activePack: null,
|
||||
slotMap: slotMap,
|
||||
equipGrid: equipGrid,
|
||||
itemGrid: itemGrid,
|
||||
packList: packList,
|
||||
burdenFill: burdenFill,
|
||||
burdenLabel: burdenLabel,
|
||||
contentsHeader: contentsHeader,
|
||||
manaPanel: manaPanel,
|
||||
manaSummary: manaSummary,
|
||||
manaListBody: manaListBody,
|
||||
characterName: name
|
||||
};
|
||||
|
||||
// Fetch inventory data from main app (which will proxy to inventory service)
|
||||
fetch(`${API_BASE}/inventory/${encodeURIComponent(name)}?limit=1000`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
|
@ -1783,46 +1098,30 @@ function showInventoryWindow(name) {
|
|||
})
|
||||
.then(data => {
|
||||
loading.style.display = 'none';
|
||||
invContent.style.display = 'flex';
|
||||
|
||||
data.items.forEach(i => normalizeInventoryItem(i));
|
||||
win._inventoryState.items = data.items;
|
||||
|
||||
renderInventoryState(win._inventoryState);
|
||||
invContent.style.display = 'block';
|
||||
|
||||
// Create inventory grid
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'inventory-grid';
|
||||
|
||||
// Render each item
|
||||
data.items.forEach(item => {
|
||||
grid.appendChild(createInventorySlot(item));
|
||||
});
|
||||
|
||||
invContent.appendChild(grid);
|
||||
|
||||
// Add item count
|
||||
const count = document.createElement('div');
|
||||
count.className = 'inventory-count';
|
||||
count.textContent = `${data.item_count} items`;
|
||||
invContent.appendChild(count);
|
||||
})
|
||||
.catch(err => {
|
||||
handleError('Inventory', err, true);
|
||||
loading.textContent = `Failed to load inventory: ${err.message}`;
|
||||
});
|
||||
|
||||
if (!characterStats[name]) {
|
||||
fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data && !data.error) {
|
||||
characterStats[name] = data;
|
||||
if (win._inventoryState) {
|
||||
renderInventoryState(win._inventoryState);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (!equipmentCantripStates[name]) {
|
||||
fetch(`${API_BASE}/equipment-cantrip-state/${encodeURIComponent(name)}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data && !data.error) {
|
||||
equipmentCantripStates[name] = data;
|
||||
if (win._inventoryState) {
|
||||
renderInventoryState(win._inventoryState);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
debugLog('Inventory window created for:', name);
|
||||
}
|
||||
|
||||
|
|
@ -3039,14 +2338,6 @@ function initWebSocket() {
|
|||
} else if (msg.type === 'character_stats') {
|
||||
characterStats[msg.character_name] = msg;
|
||||
updateCharacterWindow(msg.character_name, msg);
|
||||
if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) {
|
||||
renderInventoryState(inventoryWindows[msg.character_name]._inventoryState);
|
||||
}
|
||||
} else if (msg.type === 'equipment_cantrip_state') {
|
||||
equipmentCantripStates[msg.character_name] = msg;
|
||||
if (inventoryWindows[msg.character_name] && inventoryWindows[msg.character_name]._inventoryState) {
|
||||
renderInventoryState(inventoryWindows[msg.character_name]._inventoryState);
|
||||
}
|
||||
} else if (msg.type === 'inventory_delta') {
|
||||
updateInventoryLive(msg);
|
||||
} else if (msg.type === 'server_status') {
|
||||
|
|
|
|||
752
static/style.css
752
static/style.css
|
|
@ -709,25 +709,13 @@ body.noselect, body.noselect * {
|
|||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- inventory window styling (AC Layout) ----------------------------- */
|
||||
/* ---------- inventory window styling ----------------------------- */
|
||||
.inventory-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: none;
|
||||
color: var(--ac-text);
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.inv-left-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 316px;
|
||||
flex: none;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 15px;
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.inventory-placeholder {
|
||||
|
|
@ -745,18 +733,15 @@ body.noselect, body.noselect * {
|
|||
position: fixed;
|
||||
top: 100px;
|
||||
left: 400px;
|
||||
width: 572px;
|
||||
height: 720px;
|
||||
background: rgba(20, 20, 20, 0.92);
|
||||
backdrop-filter: blur(2px);
|
||||
border: 2px solid var(--ac-gold);
|
||||
border-radius: 4px;
|
||||
width: 600px;
|
||||
height: 500px;
|
||||
background: var(--card);
|
||||
border: 1px solid #555;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: inset 0 0 10px #000, 0 4px 15px rgba(0, 0, 0, 0.8);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.inventory-loading {
|
||||
|
|
@ -765,361 +750,37 @@ body.noselect, body.noselect * {
|
|||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 1.1rem;
|
||||
color: var(--ac-text-dim);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.inv-equipment-grid {
|
||||
position: relative;
|
||||
width: 308px;
|
||||
height: 264px;
|
||||
}
|
||||
|
||||
.inv-equip-slot {
|
||||
position: absolute;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--ac-medium-stone);
|
||||
border-top: 2px solid #3d4b5f;
|
||||
border-left: 2px solid #3d4b5f;
|
||||
border-bottom: 2px solid #12181a;
|
||||
border-right: 2px solid #12181a;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.inv-equip-slot.equipped {
|
||||
border: 2px solid var(--ac-cyan);
|
||||
box-shadow: 0 0 5px var(--ac-cyan), inset 0 0 5px var(--ac-cyan);
|
||||
}
|
||||
|
||||
.inv-equip-slot.empty::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-image: url('/icons/06000133.png');
|
||||
background-size: contain;
|
||||
opacity: 0.15;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.inv-equip-slot .inventory-slot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.inv-sidebar {
|
||||
width: 38px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inv-burden-bar {
|
||||
width: 14px;
|
||||
height: 40px;
|
||||
background: #111;
|
||||
border: 1px solid var(--ac-border-light);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inv-burden-fill {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 0%;
|
||||
transition: height 0.3s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.inv-burden-label {
|
||||
text-align: center;
|
||||
font-size: 9px;
|
||||
color: #ccc;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.inv-pack-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.inv-pack-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.inv-pack-icon.active {
|
||||
border: 1px solid var(--ac-green);
|
||||
box-shadow: 0 0 4px var(--ac-green);
|
||||
}
|
||||
|
||||
.inv-pack-icon.active::before {
|
||||
content: "▶";
|
||||
position: absolute;
|
||||
left: -14px;
|
||||
top: 10px;
|
||||
color: var(--ac-gold);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inv-pack-fill-container {
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: -1px;
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: #000;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.inv-pack-fill {
|
||||
height: 100%;
|
||||
background: var(--ac-green);
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.inv-pack-icon img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.inv-contents-header {
|
||||
color: var(--ac-gold);
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--ac-border-light);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.inv-item-grid {
|
||||
/* Inventory grid layout - matches AC original */
|
||||
.inventory-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 36px);
|
||||
grid-auto-rows: 36px;
|
||||
gap: 2px;
|
||||
background: var(--ac-black);
|
||||
padding: 4px;
|
||||
border: 1px solid var(--ac-border-light);
|
||||
flex: 1;
|
||||
grid-template-columns: repeat(8, 36px);
|
||||
gap: 0px;
|
||||
padding: 8px;
|
||||
background:
|
||||
linear-gradient(90deg, #333 1px, transparent 1px),
|
||||
linear-gradient(180deg, #333 1px, transparent 1px),
|
||||
#111;
|
||||
background-size: 36px 36px;
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
justify-content: start;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.inv-mana-panel {
|
||||
width: 162px;
|
||||
min-width: 162px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(6, 10, 18, 0.92);
|
||||
border: 1px solid var(--ac-border-light);
|
||||
padding: 3px;
|
||||
min-height: 0;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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: 9px;
|
||||
line-height: 1.2;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.inv-mana-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.inv-mana-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr 14px;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 1px 4px;
|
||||
align-items: center;
|
||||
background: rgba(18, 24, 34, 0.9);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
padding: 1px 2px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.inv-mana-icon {
|
||||
grid-row: 1 / span 2;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.inv-mana-icon .inventory-slot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.inv-mana-icon .inventory-slot.mana-slot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.inv-mana-icon .inventory-slot.mana-slot .item-icon-composite {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.inv-mana-icon .inventory-slot.mana-slot .icon-underlay,
|
||||
.inv-mana-icon .inventory-slot.mana-slot .icon-base,
|
||||
.inv-mana-icon .inventory-slot.mana-slot .icon-overlay {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.inv-mana-name {
|
||||
color: #f2e6c9;
|
||||
font-size: 9px;
|
||||
line-height: 1.05;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.inv-mana-value,
|
||||
.inv-mana-time {
|
||||
font-size: 9px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.inv-mana-value {
|
||||
color: #98d7ff;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.inv-mana-time {
|
||||
color: #cfe6a0;
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
text-align: right;
|
||||
min-width: 34px;
|
||||
}
|
||||
|
||||
.inv-mana-state-dot {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
background: #97a1ad;
|
||||
border: 1px solid rgba(0,0,0,0.65);
|
||||
box-shadow: inset 0 0 1px rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.mana-state-active {
|
||||
background: #76d17f;
|
||||
}
|
||||
|
||||
.mana-state-not_active {
|
||||
background: #ff8e6f;
|
||||
}
|
||||
|
||||
.mana-state-unknown {
|
||||
background: #d4c27a;
|
||||
}
|
||||
|
||||
.mana-state-not_activatable {
|
||||
background: #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;
|
||||
}
|
||||
.inv-item-grid::-webkit-scrollbar-track {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
.inv-item-grid::-webkit-scrollbar-thumb {
|
||||
background: #0022cc;
|
||||
border-top: 2px solid var(--ac-gold);
|
||||
border-bottom: 2px solid var(--ac-gold);
|
||||
}
|
||||
|
||||
.inv-item-slot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #222;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.inv-item-slot.occupied {
|
||||
background: linear-gradient(135deg, #3d007a 0%, #1a0033 100%);
|
||||
border: 1px solid #4a148c;
|
||||
}
|
||||
|
||||
/* Base slot styling used by createInventorySlot */
|
||||
/* Individual inventory slots - no borders like AC original */
|
||||
.inventory-slot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -1133,11 +794,14 @@ body.noselect, body.noselect * {
|
|||
height: 36px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
/* Improve icon appearance - make background match slot */
|
||||
border: none;
|
||||
outline: none;
|
||||
background: #1a1a1a;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Icon compositing */
|
||||
/* Icon compositing for overlays/underlays - matches AC original */
|
||||
.item-icon-composite {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
|
|
@ -1163,13 +827,24 @@ body.noselect, body.noselect * {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-underlay { z-index: 1; }
|
||||
.icon-base { z-index: 2; }
|
||||
.icon-overlay { z-index: 3; }
|
||||
.icon-underlay {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Item count (hidden in new AC layout, kept for compatibility) */
|
||||
.icon-base {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.icon-overlay {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Item count */
|
||||
.inventory-count {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Inventory tooltip */
|
||||
|
|
@ -1965,7 +1640,7 @@ body.noselect, body.noselect * {
|
|||
/* -- Tab containers (two side-by-side) -- */
|
||||
.ts-tabrow {
|
||||
display: flex;
|
||||
gap: 13px;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ts-tabcontainer {
|
||||
|
|
@ -2085,7 +1760,7 @@ table.ts-props tr.ts-colnames td {
|
|||
padding: 6px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
border-bottom: 2px solid #af7a30;
|
||||
}
|
||||
.ts-vital {
|
||||
|
|
@ -2173,332 +1848,3 @@ table.ts-allegiance td:first-child {
|
|||
border-color: #af7a30;
|
||||
}
|
||||
|
||||
|
||||
/* ==============================================
|
||||
Inventory Window Visual Fixes - AC Game Match
|
||||
============================================== */
|
||||
|
||||
.inventory-window,
|
||||
.inventory-window * {
|
||||
font-family: "Times New Roman", Times, serif !important;
|
||||
text-shadow: 1px 1px 0 #000 !important;
|
||||
}
|
||||
|
||||
.inventory-window .chat-header {
|
||||
background: #0e0c08 !important;
|
||||
border-bottom: 1px solid #8a7a44 !important;
|
||||
color: #d4af37 !important;
|
||||
padding: 4px 6px !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: bold !important;
|
||||
height: 22px !important;
|
||||
box-sizing: border-box !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.inventory-window .window-content {
|
||||
background: linear-gradient(180deg, #1a1814 0%, #0e0c0a 100%) !important;
|
||||
border: 2px solid #8a7a44 !important;
|
||||
padding: 4px !important;
|
||||
}
|
||||
|
||||
.inv-equipment-grid {
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(30, 28, 25, 0.6) 0%, transparent 70%),
|
||||
radial-gradient(ellipse at 80% 30%, rgba(25, 23, 20, 0.4) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 50% 80%, rgba(35, 30, 25, 0.5) 0%, transparent 50%),
|
||||
linear-gradient(180deg, #0e0c0a 0%, #141210 50%, #0c0a08 100%) !important;
|
||||
}
|
||||
|
||||
.inv-equip-slot {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
border-top: 1px solid #2a2a30 !important;
|
||||
border-left: 1px solid #2a2a30 !important;
|
||||
border-bottom: 1px solid #0a0a0e !important;
|
||||
border-right: 1px solid #0a0a0e !important;
|
||||
background: #14141a !important;
|
||||
}
|
||||
|
||||
.inv-equip-slot.equipped {
|
||||
border: 1px solid #222 !important;
|
||||
background: #14141a !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Equipment slot color categories - matching real AC
|
||||
Real AC uses clearly visible colored borders AND tinted backgrounds per slot type */
|
||||
.inv-equip-slot.slot-purple {
|
||||
border: 1px solid #8040a8 !important;
|
||||
background: #2a1538 !important;
|
||||
}
|
||||
.inv-equip-slot.slot-blue {
|
||||
border: 1px solid #3060b0 !important;
|
||||
background: #141e38 !important;
|
||||
}
|
||||
.inv-equip-slot.slot-teal {
|
||||
border: 1px solid #309898 !important;
|
||||
background: #0e2828 !important;
|
||||
}
|
||||
.inv-equip-slot.slot-darkblue {
|
||||
border: 1px solid #1e3060 !important;
|
||||
background: #0e1428 !important;
|
||||
}
|
||||
/* Brighter tint when equipped (item present) */
|
||||
.inv-equip-slot.equipped.slot-purple {
|
||||
border: 1px solid #9050b8 !important;
|
||||
background: #341a44 !important;
|
||||
}
|
||||
.inv-equip-slot.equipped.slot-blue {
|
||||
border: 1px solid #4070c0 !important;
|
||||
background: #1a2844 !important;
|
||||
}
|
||||
.inv-equip-slot.equipped.slot-teal {
|
||||
border: 1px solid #40a8a8 !important;
|
||||
background: #143030 !important;
|
||||
}
|
||||
.inv-equip-slot.equipped.slot-darkblue {
|
||||
border: 1px solid #283870 !important;
|
||||
background: #141a30 !important;
|
||||
}
|
||||
|
||||
.inv-equip-slot.empty::before {
|
||||
opacity: 0.15 !important;
|
||||
filter: grayscale(100%) !important;
|
||||
}
|
||||
|
||||
.inv-item-grid {
|
||||
background: #1a1208 !important;
|
||||
gap: 2px !important;
|
||||
}
|
||||
|
||||
.inv-item-slot.occupied {
|
||||
background: #442c1e !important;
|
||||
border: 1px solid #5a3c28 !important;
|
||||
}
|
||||
|
||||
.inv-item-slot {
|
||||
background: #2a1c14 !important;
|
||||
border: 1px solid #3a2818 !important;
|
||||
}
|
||||
|
||||
.inv-contents-header {
|
||||
font-size: 10px !important;
|
||||
font-family: "Times New Roman", Times, serif !important;
|
||||
color: #ffffff !important;
|
||||
border-bottom: none !important;
|
||||
text-align: center !important;
|
||||
padding-bottom: 2px !important;
|
||||
margin-bottom: 2px !important;
|
||||
text-transform: none !important;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
.inventory-content {
|
||||
gap: 13px !important;
|
||||
}
|
||||
|
||||
.inv-left-column {
|
||||
width: 316px !important;
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
.inv-sidebar {
|
||||
width: 38px !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.inv-pack-icon {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
border: 1px solid #1a1a1a !important;
|
||||
margin-bottom: 2px !important;
|
||||
overflow: visible !important;
|
||||
margin-right: 8px !important;
|
||||
}
|
||||
|
||||
.inv-pack-icon img {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
.inv-pack-icon.active {
|
||||
border: 1px solid #8a7a44 !important;
|
||||
position: relative !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.inv-pack-icon.active::before {
|
||||
content: '' !important;
|
||||
position: absolute !important;
|
||||
left: -8px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
border-top: 6px solid transparent !important;
|
||||
border-bottom: 6px solid transparent !important;
|
||||
border-left: 7px solid #d4af37 !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.inv-pack-fill-container {
|
||||
position: absolute !important;
|
||||
right: -6px !important;
|
||||
top: 0 !important;
|
||||
bottom: auto !important;
|
||||
left: auto !important;
|
||||
width: 4px !important;
|
||||
height: 32px !important;
|
||||
background: #000 !important;
|
||||
border: 1px solid #333 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column-reverse !important;
|
||||
}
|
||||
|
||||
.inv-pack-fill {
|
||||
width: 100% !important;
|
||||
background: #00ff00 !important;
|
||||
transition: height 0.3s ease !important;
|
||||
}
|
||||
|
||||
.inv-item-grid::-webkit-scrollbar {
|
||||
width: 14px;
|
||||
}
|
||||
.inv-item-grid::-webkit-scrollbar-track {
|
||||
background: #0e0a04;
|
||||
border: 1px solid #8a7a44;
|
||||
}
|
||||
.inv-item-grid::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #2244aa 0%, #1a3399 50%, #2244aa 100%);
|
||||
border: 1px solid #8a7a44;
|
||||
}
|
||||
.inv-item-grid::-webkit-scrollbar-button:vertical:start:decrement,
|
||||
.inv-item-grid::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: #8a2020;
|
||||
border: 1px solid #b89a30;
|
||||
height: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.inventory-count {
|
||||
display: block !important;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 1px;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
font-size: 8px !important;
|
||||
color: #fff !important;
|
||||
background: #1a3399 !important;
|
||||
padding: 0 2px !important;
|
||||
line-height: 12px !important;
|
||||
min-width: 8px !important;
|
||||
text-align: center !important;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
.inventory-window {
|
||||
border: 2px solid #8a7a44 !important;
|
||||
background: #0e0c08 !important;
|
||||
resize: none !important;
|
||||
width: 572px !important;
|
||||
min-height: 720px !important;
|
||||
}
|
||||
|
||||
.inv-mana-panel {
|
||||
width: 162px !important;
|
||||
min-width: 162px !important;
|
||||
background: #111014 !important;
|
||||
border: 1px solid #5a4a24 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.inv-mana-header {
|
||||
font-size: 10px !important;
|
||||
color: #ffffff !important;
|
||||
border-bottom: none !important;
|
||||
padding-bottom: 2px !important;
|
||||
}
|
||||
|
||||
.inv-mana-summary {
|
||||
font-size: 9px !important;
|
||||
color: #d4af37 !important;
|
||||
}
|
||||
|
||||
.inv-mana-row {
|
||||
grid-template-columns: 18px 1fr 14px !important;
|
||||
grid-template-rows: auto auto !important;
|
||||
gap: 1px 4px !important;
|
||||
padding: 1px 2px !important;
|
||||
background: #1a1208 !important;
|
||||
border: 1px solid #3a2818 !important;
|
||||
}
|
||||
|
||||
.inv-mana-icon {
|
||||
grid-row: 1 / span 2 !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
|
||||
.inv-mana-icon .inventory-slot {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
|
||||
.inv-mana-icon .inventory-slot.mana-slot .item-icon-composite,
|
||||
.inv-mana-icon .inventory-slot.mana-slot .icon-underlay,
|
||||
.inv-mana-icon .inventory-slot.mana-slot .icon-base,
|
||||
.inv-mana-icon .inventory-slot.mana-slot .icon-overlay {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
|
||||
.inv-mana-name {
|
||||
font-size: 9px !important;
|
||||
line-height: 1.05 !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
|
||||
.inv-mana-value,
|
||||
.inv-mana-time {
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
.inv-mana-state-dot {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
}
|
||||
|
||||
/* Custom resize grip for inventory window */
|
||||
.inv-resize-grip {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
cursor: ns-resize;
|
||||
z-index: 100;
|
||||
background: transparent;
|
||||
border-top: 1px solid #8a7a44;
|
||||
}
|
||||
|
||||
.inv-resize-grip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 30px;
|
||||
height: 2px;
|
||||
border-top: 1px solid #5a4a24;
|
||||
border-bottom: 1px solid #5a4a24;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,14 +206,6 @@
|
|||
<input type="checkbox" id="cantrip_legendary_life" value="Legendary Life Magic Aptitude">
|
||||
<label for="cantrip_legendary_life">Life Magic</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_healing" value="Legendary Healing Prowess">
|
||||
<label for="cantrip_legendary_healing">Healing</label>
|
||||
</div>
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_summoning" value="Legendary Summoning Prowess">
|
||||
<label for="cantrip_legendary_summoning">Summoning</label>
|
||||
</div>
|
||||
<!-- Legendary Defense -->
|
||||
<div class="checkbox-item">
|
||||
<input type="checkbox" id="cantrip_legendary_magic_defense" value="Legendary Magic Resistance">
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ const COMMON_CANTRIPS = [
|
|||
'Legendary Creature Enchantment Aptitude',
|
||||
'Legendary Item Enchantment Aptitude',
|
||||
'Legendary Life Magic Aptitude',
|
||||
'Legendary Healing Prowess',
|
||||
'Legendary Summoning Prowess',
|
||||
// Defense
|
||||
'Legendary Magic Resistance',
|
||||
'Legendary Invulnerability',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue