feat: update inventory frontend and services to current production state

This commit is contained in:
erik 2026-03-07 08:37:32 +00:00
parent 7050cfb8b7
commit fc557ab1d5
4 changed files with 1321 additions and 307 deletions

View file

@ -1363,7 +1363,7 @@ async def process_inventory(inventory: InventoryItem):
for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', for table in ('item_raw_data', 'item_combat_stats', 'item_requirements',
'item_enhancements', 'item_ratings', 'item_spells'): 'item_enhancements', 'item_ratings', 'item_spells'):
await database.execute( await database.execute(
sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), f"DELETE FROM {table} WHERE item_id = ANY(:ids)",
{"ids": db_ids} {"ids": db_ids}
) )
@ -1577,7 +1577,7 @@ async def upsert_inventory_item(character_name: str, item: Dict[str, Any]):
for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', for table in ('item_raw_data', 'item_combat_stats', 'item_requirements',
'item_enhancements', 'item_ratings', 'item_spells'): 'item_enhancements', 'item_ratings', 'item_spells'):
await database.execute( await database.execute(
sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), f"DELETE FROM {table} WHERE item_id = ANY(:ids)",
{"ids": db_ids} {"ids": db_ids}
) )
await database.execute( await database.execute(
@ -1730,7 +1730,35 @@ async def upsert_inventory_item(character_name: str, item: Dict[str, Any]):
raise HTTPException(status_code=500, detail=error_msg) raise HTTPException(status_code=500, detail=error_msg)
logger.info(f"Single item upsert for {character_name}: item_id={item_game_id}, processed={processed_count}") logger.info(f"Single item upsert for {character_name}: item_id={item_game_id}, processed={processed_count}")
return {"status": "ok", "processed": processed_count}
# Fetch the just-upserted item with all joins and enrich it
enriched_item = None
try:
enrich_query = """
SELECT
i.id, i.item_id, i.name, i.icon, i.object_class, i.value, i.burden,
i.has_id_data, i.timestamp,
i.current_wielded_location, i.container_id, i.items_capacity,
cs.max_damage, cs.armor_level, cs.damage_bonus, cs.attack_bonus,
r.wield_level, r.skill_level, r.equip_skill, r.lore_requirement,
e.material, e.imbue, e.item_set, e.tinks, e.workmanship,
rt.damage_rating, rt.crit_rating, rt.heal_boost_rating,
rd.int_values, rd.double_values, rd.string_values, rd.bool_values, rd.original_json
FROM items i
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
LEFT JOIN item_requirements r ON i.id = r.item_id
LEFT JOIN item_enhancements e ON i.id = e.item_id
LEFT JOIN item_ratings rt ON i.id = rt.item_id
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
WHERE i.id = :db_id
"""
row = await database.fetch_one(enrich_query, {"db_id": db_item_id})
if row:
enriched_item = enrich_db_item(row)
except Exception as e:
logger.warning(f"Failed to enrich item after upsert: {e}")
return {"status": "ok", "processed": processed_count, "item": enriched_item}
@app.delete("/inventory/{character_name}/item/{item_id}", @app.delete("/inventory/{character_name}/item/{item_id}",
@ -1755,7 +1783,7 @@ async def delete_inventory_item(character_name: str, item_id: int):
for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', for table in ('item_raw_data', 'item_combat_stats', 'item_requirements',
'item_enhancements', 'item_ratings', 'item_spells'): 'item_enhancements', 'item_ratings', 'item_spells'):
await database.execute( await database.execute(
sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), f"DELETE FROM {table} WHERE item_id = ANY(:ids)",
{"ids": db_ids} {"ids": db_ids}
) )
@ -1771,49 +1799,14 @@ async def delete_inventory_item(character_name: str, item_id: int):
return {"status": "ok", "deleted": deleted_count} return {"status": "ok", "deleted": deleted_count}
@app.get("/inventory/{character_name}", def enrich_db_item(item) -> dict:
summary="Get Character Inventory", """Enrich a single DB row (from the JOIN query) into the full frontend format.
description="Retrieve processed inventory data for a specific character with normalized item properties.",
tags=["Character Data"])
async def get_character_inventory(
character_name: str,
limit: int = Query(1000, le=5000),
offset: int = Query(0, ge=0)
):
"""Get processed inventory for a character with structured data and comprehensive translations."""
query = """ Takes a mapping (e.g. asyncpg Record or dict) from the items+joins query and returns
SELECT a clean dict with translated material names, spell info, combat stats, ratings,
i.id, i.name, i.icon, i.object_class, i.value, i.burden, workmanship text, mana display, etc. Identical logic to what get_character_inventory
i.has_id_data, i.timestamp, used inline extracted here so upsert_inventory_item can reuse it.
cs.max_damage, cs.armor_level, cs.damage_bonus, cs.attack_bonus,
r.wield_level, r.skill_level, r.equip_skill, r.lore_requirement,
e.material, e.imbue, e.item_set, e.tinks, e.workmanship,
rt.damage_rating, rt.crit_rating, rt.heal_boost_rating,
rd.int_values, rd.double_values, rd.string_values, rd.bool_values, rd.original_json
FROM items i
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
LEFT JOIN item_requirements r ON i.id = r.item_id
LEFT JOIN item_enhancements e ON i.id = e.item_id
LEFT JOIN item_ratings rt ON i.id = rt.item_id
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
WHERE i.character_name = :character_name
ORDER BY i.name
LIMIT :limit OFFSET :offset
""" """
items = await database.fetch_all(query, {
"character_name": character_name,
"limit": limit,
"offset": offset
})
if not items:
raise HTTPException(status_code=404, detail=f"No inventory found for {character_name}")
# Convert to structured format with enhanced translations
processed_items = []
for item in items:
processed_item = dict(item) processed_item = dict(item)
# Get comprehensive translations from original_json # Get comprehensive translations from original_json
@ -2021,7 +2014,52 @@ async def get_character_inventory(
# Remove null values for cleaner response # Remove null values for cleaner response
processed_item = {k: v for k, v in processed_item.items() if v is not None} processed_item = {k: v for k, v in processed_item.items() if v is not None}
processed_items.append(processed_item) return processed_item
@app.get("/inventory/{character_name}",
summary="Get Character Inventory",
description="Retrieve processed inventory data for a specific character with normalized item properties.",
tags=["Character Data"])
async def get_character_inventory(
character_name: str,
limit: int = Query(1000, le=5000),
offset: int = Query(0, ge=0)
):
"""Get processed inventory for a character with structured data and comprehensive translations."""
query = """
SELECT
i.id, i.item_id, i.name, i.icon, i.object_class, i.value, i.burden,
i.has_id_data, i.timestamp,
i.current_wielded_location, i.container_id, i.items_capacity,
cs.max_damage, cs.armor_level, cs.damage_bonus, cs.attack_bonus,
r.wield_level, r.skill_level, r.equip_skill, r.lore_requirement,
e.material, e.imbue, e.item_set, e.tinks, e.workmanship,
rt.damage_rating, rt.crit_rating, rt.heal_boost_rating,
rd.int_values, rd.double_values, rd.string_values, rd.bool_values, rd.original_json
FROM items i
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
LEFT JOIN item_requirements r ON i.id = r.item_id
LEFT JOIN item_enhancements e ON i.id = e.item_id
LEFT JOIN item_ratings rt ON i.id = rt.item_id
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
WHERE i.character_name = :character_name
ORDER BY i.name
LIMIT :limit OFFSET :offset
"""
items = await database.fetch_all(query, {
"character_name": character_name,
"limit": limit,
"offset": offset
})
if not items:
raise HTTPException(status_code=404, detail=f"No inventory found for {character_name}")
# Convert to structured format with enhanced translations
processed_items = [enrich_db_item(item) for item in items]
return { return {
"character_name": character_name, "character_name": character_name,

13
main.py
View file

@ -2002,7 +2002,18 @@ async def ws_receive_snapshots(
f"{INVENTORY_SERVICE_URL}/inventory/{char_name}/item", f"{INVENTORY_SERVICE_URL}/inventory/{char_name}/item",
json=item json=item
) )
if resp.status_code >= 400: if resp.status_code < 400:
# Use enriched item from inventory-service response for broadcast
resp_json = resp.json()
enriched_item = resp_json.get("item")
if enriched_item:
data = {
"type": "inventory_delta",
"action": action,
"character_name": char_name,
"item": enriched_item
}
else:
logger.warning(f"Inventory service returned {resp.status_code} for delta {action}") logger.warning(f"Inventory service returned {resp.status_code} for delta {action}")
# Broadcast delta to all browser clients # Broadcast delta to all browser clients

View file

@ -893,6 +893,131 @@ function updateStatsTimeRange(content, name, timeRange) {
} }
// Show or create an inventory window for a character // 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. * Create a single inventory slot DOM element from item data.
* Used by both initial inventory load and live delta updates. * Used by both initial inventory load and live delta updates.
@ -900,7 +1025,7 @@ function updateStatsTimeRange(content, name, timeRange) {
function createInventorySlot(item) { function createInventorySlot(item) {
const slot = document.createElement('div'); const slot = document.createElement('div');
slot.className = 'inventory-slot'; slot.className = 'inventory-slot';
slot.setAttribute('data-item-id', item.Id || item.id || item.item_id || 0); slot.setAttribute('data-item-id', item.item_id || item.Id || item.id || 0);
// Create layered icon container // Create layered icon container
const iconContainer = document.createElement('div'); const iconContainer = document.createElement('div');
@ -1020,9 +1145,83 @@ function createInventorySlot(item) {
slot.addEventListener('mouseleave', hideInventoryTooltip); slot.addEventListener('mouseleave', hideInventoryTooltip);
slot.appendChild(iconContainer); 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; 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. * Handle live inventory delta updates from WebSocket.
* Updates the inventory grid for a character if their inventory window is open. * Updates the inventory grid for a character if their inventory window is open.
@ -1030,35 +1229,260 @@ function createInventorySlot(item) {
function updateInventoryLive(delta) { function updateInventoryLive(delta) {
const name = delta.character_name; const name = delta.character_name;
const win = inventoryWindows[name]; const win = inventoryWindows[name];
if (!win) return; // No inventory window open for this character if (!win || !win._inventoryState) {
return;
}
const grid = win.querySelector('.inventory-grid'); const state = win._inventoryState;
if (!grid) return; 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);
if (delta.action === 'remove') { if (delta.action === 'remove') {
const itemId = delta.item_id || (delta.item && (delta.item.Id || delta.item.id)); state.items = state.items.filter(i => (i.item_id || i.Id || i.id) !== itemId);
const existing = grid.querySelector(`[data-item-id="${itemId}"]`); } else if (delta.action === 'add' || delta.action === 'update') {
if (existing) existing.remove(); normalizeInventoryItem(delta.item);
} else if (delta.action === 'add') { const existingIdx = state.items.findIndex(i => (i.item_id || i.Id || i.id) === itemId);
const newSlot = createInventorySlot(delta.item); if (existingIdx >= 0) {
grid.appendChild(newSlot); state.items[existingIdx] = delta.item;
} 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 { } else {
const newSlot = createInventorySlot(delta.item); state.items.push(delta.item);
grid.appendChild(newSlot);
} }
} }
// Update item count renderInventoryState(state);
const countEl = win.querySelector('.inventory-count'); }
if (countEl) {
const slotCount = grid.querySelectorAll('.inventory-slot').length; function renderInventoryState(state) {
countEl.textContent = `${slotCount} items`; // 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
let totalBurden = 0;
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 => {
totalBurden += (item.burden || 0);
// 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);
}
});
state.burdenLabel.textContent = 'Burden';
state.burdenFill.style.height = '0%';
// 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/06001BB1.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';
}
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);
} }
} }
@ -1078,19 +1502,120 @@ function showInventoryWindow(name) {
win.dataset.character = name; win.dataset.character = name;
inventoryWindows[name] = win; inventoryWindows[name] = win;
// Loading message
const loading = document.createElement('div'); const loading = document.createElement('div');
loading.className = 'inventory-loading'; loading.className = 'inventory-loading';
loading.textContent = 'Loading inventory...'; loading.textContent = 'Loading inventory...';
content.appendChild(loading); content.appendChild(loading);
// Inventory content container
const invContent = document.createElement('div'); const invContent = document.createElement('div');
invContent.className = 'inventory-content'; invContent.className = 'inventory-content';
invContent.style.display = 'none'; invContent.style.display = 'none';
content.appendChild(invContent); content.appendChild(invContent);
// Fetch inventory data from main app (which will proxy to inventory service) const topSection = document.createElement('div');
topSection.className = 'inv-top-section';
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 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(burdenLabel);
burdenContainer.appendChild(burdenFill);
sidebar.appendChild(burdenContainer);
const packList = document.createElement('div');
packList.className = 'inv-pack-list';
sidebar.appendChild(packList);
topSection.appendChild(equipGrid);
topSection.appendChild(sidebar);
const bottomSection = document.createElement('div');
bottomSection.className = 'inv-bottom-section';
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';
bottomSection.appendChild(contentsHeader);
bottomSection.appendChild(itemGrid);
invContent.appendChild(topSection);
invContent.appendChild(bottomSection);
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 = {
items: [],
activePack: null,
slotMap: slotMap,
equipGrid: equipGrid,
itemGrid: itemGrid,
packList: packList,
burdenFill: burdenFill,
burdenLabel: burdenLabel,
contentsHeader: contentsHeader,
characterName: name
};
fetch(`${API_BASE}/inventory/${encodeURIComponent(name)}?limit=1000`) fetch(`${API_BASE}/inventory/${encodeURIComponent(name)}?limit=1000`)
.then(response => { .then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
@ -1098,24 +1623,12 @@ function showInventoryWindow(name) {
}) })
.then(data => { .then(data => {
loading.style.display = 'none'; loading.style.display = 'none';
invContent.style.display = 'block'; invContent.style.display = 'flex';
// Create inventory grid data.items.forEach(i => normalizeInventoryItem(i));
const grid = document.createElement('div'); win._inventoryState.items = data.items;
grid.className = 'inventory-grid';
// Render each item renderInventoryState(win._inventoryState);
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 => { .catch(err => {
handleError('Inventory', err, true); handleError('Inventory', err, true);

View file

@ -709,13 +709,15 @@ body.noselect, body.noselect * {
border-color: var(--accent); border-color: var(--accent);
} }
/* ---------- inventory window styling ----------------------------- */ /* ---------- inventory window styling (AC Layout) ----------------------------- */
.inventory-content { .inventory-content {
flex: 1; flex: 1;
padding: 15px; display: flex;
background: var(--card); flex-direction: column;
color: var(--text); background: none;
overflow-y: auto; color: var(--ac-text);
overflow: hidden;
padding: 8px;
} }
.inventory-placeholder { .inventory-placeholder {
@ -733,15 +735,18 @@ body.noselect, body.noselect * {
position: fixed; position: fixed;
top: 100px; top: 100px;
left: 400px; left: 400px;
width: 600px; width: 390px;
height: 500px; height: 520px;
background: var(--card); background: rgba(20, 20, 20, 0.92);
border: 1px solid #555; backdrop-filter: blur(2px);
border-radius: 8px; border: 2px solid var(--ac-gold);
border-radius: 4px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); box-shadow: inset 0 0 10px #000, 0 4px 15px rgba(0, 0, 0, 0.8);
z-index: 1000; z-index: 1000;
font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif;
overflow: hidden;
} }
.inventory-loading { .inventory-loading {
@ -750,37 +755,229 @@ body.noselect, body.noselect * {
justify-content: center; justify-content: center;
height: 100%; height: 100%;
font-size: 1.1rem; font-size: 1.1rem;
color: #888; color: var(--ac-text-dim);
} }
/* Inventory grid layout - matches AC original */ .inv-top-section {
.inventory-grid { display: flex;
justify-content: space-between;
height: 264px;
}
.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: 60px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
overflow: visible;
}
.inv-burden-bar {
width: 16px;
height: 40px;
background: #0a0a0a;
border: 1px solid var(--ac-border-light);
position: relative;
display: flex;
flex-direction: column-reverse;
margin-bottom: 2px;
margin-top: 12px;
flex-shrink: 0;
}
.inv-burden-fill {
width: 100%;
background: var(--ac-green);
height: 0%;
transition: height 0.3s ease;
}
.inv-burden-label {
position: absolute;
top: -18px;
width: 60px;
left: -22px;
text-align: center;
font-size: 11px;
color: var(--ac-gold);
}
.inv-pack-list {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
align-items: center;
flex: 1;
min-height: 0;
}
.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;
}
.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-bottom-section {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 10px;
margin-right: 52px;
overflow: hidden;
min-height: 0;
}
.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 {
display: grid; display: grid;
grid-template-columns: repeat(8, 36px); grid-template-columns: repeat(6, 36px);
gap: 0px; grid-auto-rows: 36px;
padding: 8px; gap: 2px;
background: background: var(--ac-black);
linear-gradient(90deg, #333 1px, transparent 1px), padding: 4px;
linear-gradient(180deg, #333 1px, transparent 1px), border: 1px solid var(--ac-border-light);
#111; flex: 1;
background-size: 36px 36px;
max-height: 450px;
overflow-y: auto; overflow-y: auto;
border: 1px solid #444; min-height: 0;
align-content: start;
justify-content: start;
} }
/* Individual inventory slots - no borders like AC original */ .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 */
.inventory-slot { .inventory-slot {
width: 36px; width: 36px;
height: 36px; height: 36px;
background: transparent; background: transparent;
border: none; border: none;
border-radius: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: background 0.1s ease;
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
@ -794,14 +991,11 @@ body.noselect, body.noselect * {
height: 36px; height: 36px;
object-fit: contain; object-fit: contain;
image-rendering: pixelated; image-rendering: pixelated;
/* Improve icon appearance - make background match slot */
border: none; border: none;
outline: none; outline: none;
background: #1a1a1a;
border-radius: 2px;
} }
/* Icon compositing for overlays/underlays - matches AC original */ /* Icon compositing */
.item-icon-composite { .item-icon-composite {
position: relative; position: relative;
width: 36px; width: 36px;
@ -827,24 +1021,13 @@ body.noselect, body.noselect * {
margin: 0; margin: 0;
} }
.icon-underlay { .icon-underlay { z-index: 1; }
z-index: 1; .icon-base { z-index: 2; }
} .icon-overlay { z-index: 3; }
.icon-base { /* Item count (hidden in new AC layout, kept for compatibility) */
z-index: 2;
}
.icon-overlay {
z-index: 3;
}
/* Item count */
.inventory-count { .inventory-count {
text-align: center; display: none;
padding: 10px;
color: #888;
font-size: 0.9rem;
} }
/* Inventory tooltip */ /* Inventory tooltip */
@ -1848,3 +2031,272 @@ table.ts-allegiance td:first-child {
border-color: #af7a30; 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;
}
.inv-sidebar {
width: 52px !important;
align-items: center !important;
overflow: visible !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;
}
.inv-burden-bar {
width: 14px !important;
height: 40px !important;
margin-top: 20px !important;
}
.inv-burden-label {
position: absolute !important;
top: -20px !important;
width: 60px !important;
left: -22px !important;
text-align: center !important;
font-size: 9px !important;
color: #fff !important;
font-weight: normal !important;
line-height: 1.1 !important;
}
.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;
}
/* 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;
}