Compare commits

..

No commits in common. "master" and "feature/async-timescale" have entirely different histories.

8 changed files with 3049 additions and 6771 deletions

154
AGENTS.md
View file

@ -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.

View file

@ -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

1794
main.py

File diff suppressed because it is too large Load diff

View file

@ -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') {

View file

@ -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;
}

View file

@ -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">

View file

@ -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',