diff --git a/inventory-service/database.py b/inventory-service/database.py index d1c0a502..0592f272 100644 --- a/inventory-service/database.py +++ b/inventory-service/database.py @@ -36,7 +36,11 @@ class Item(Base): # Equipment status current_wielded_location = Column(Integer, default=0, index=True) # 0 = not equipped - + + # Container/position tracking + container_id = Column(BigInteger, default=0) # Game container object ID (0 = character) + slot = Column(Integer, default=-1) # Slot position within container (-1 = unknown) + # Item state bonded = Column(Integer, default=0) # 0=Normal, 1=Bonded, 2=Sticky, 4=Destroy on drop attuned = Column(Integer, default=0) # 0=Normal, 1=Attuned diff --git a/inventory-service/main.py b/inventory-service/main.py index 564e31dc..06a4b51b 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -1401,25 +1401,28 @@ async def process_inventory(inventory: InventoryItem): burden=basic['burden'], has_id_data=basic['has_id_data'], last_id_time=item_data.get('LastIdTime', 0), - + # Equipment status current_wielded_location=basic['current_wielded_location'], - + + # Container/position tracking + container_id=item_data.get('ContainerId', 0), + # Item state bonded=basic['bonded'], attuned=basic['attuned'], unique=basic['unique'], - + # Stack/Container properties stack_size=basic['stack_size'], max_stack_size=basic['max_stack_size'], items_capacity=basic['items_capacity'] if basic['items_capacity'] != -1 else None, containers_capacity=basic['containers_capacity'] if basic['containers_capacity'] != -1 else None, - + # Durability structure=basic['structure'] if basic['structure'] != -1 else None, max_structure=basic['max_structure'] if basic['max_structure'] != -1 else None, - + # Special item flags rare_id=basic['rare_id'] if basic['rare_id'] != -1 else None, lifespan=basic['lifespan'] if basic['lifespan'] != -1 else None, @@ -1536,6 +1539,225 @@ async def process_inventory(inventory: InventoryItem): errors=processing_errors if processing_errors else None ) + +@app.post("/inventory/{character_name}/item", + summary="Upsert a single inventory item", + tags=["Data Processing"]) +async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): + """Process and upsert a single item for a character's inventory.""" + + item_game_id = item.get('Id') or item.get('id') + if item_game_id is None: + raise HTTPException(status_code=400, detail="Item must have an 'Id' or 'id' field") + + processed_count = 0 + error_count = 0 + + async with database.transaction(): + # Delete existing item with this character_name + item_id from all related tables + existing = await database.fetch_all( + "SELECT id FROM items WHERE character_name = :character_name AND item_id = :item_id", + {"character_name": character_name, "item_id": item_game_id} + ) + + if existing: + id_list = ','.join(str(row['id']) for row in existing) + await database.execute(f"DELETE FROM item_raw_data WHERE item_id IN ({id_list})") + await database.execute(f"DELETE FROM item_combat_stats WHERE item_id IN ({id_list})") + await database.execute(f"DELETE FROM item_requirements WHERE item_id IN ({id_list})") + await database.execute(f"DELETE FROM item_enhancements WHERE item_id IN ({id_list})") + await database.execute(f"DELETE FROM item_ratings WHERE item_id IN ({id_list})") + await database.execute(f"DELETE FROM item_spells WHERE item_id IN ({id_list})") + await database.execute( + "DELETE FROM items WHERE character_name = :character_name AND item_id = :item_id", + {"character_name": character_name, "item_id": item_game_id} + ) + + # Process and insert the single item using the same logic as process_inventory + try: + properties = extract_item_properties(item) + basic = properties['basic'] + + timestamp = datetime.utcnow() + + item_stmt = sa.insert(Item).values( + character_name=character_name, + item_id=item_game_id, + timestamp=timestamp, + name=basic['name'], + icon=basic['icon'], + object_class=basic['object_class'], + value=basic['value'], + burden=basic['burden'], + has_id_data=basic['has_id_data'], + last_id_time=item.get('LastIdTime', 0), + + # Equipment status + current_wielded_location=basic['current_wielded_location'], + + # Container/position tracking + container_id=item.get('ContainerId', 0), + + # Item state + bonded=basic['bonded'], + attuned=basic['attuned'], + unique=basic['unique'], + + # Stack/Container properties + stack_size=basic['stack_size'], + max_stack_size=basic['max_stack_size'], + items_capacity=basic['items_capacity'] if basic['items_capacity'] != -1 else None, + containers_capacity=basic['containers_capacity'] if basic['containers_capacity'] != -1 else None, + + # Durability + structure=basic['structure'] if basic['structure'] != -1 else None, + max_structure=basic['max_structure'] if basic['max_structure'] != -1 else None, + + # Special item flags + rare_id=basic['rare_id'] if basic['rare_id'] != -1 else None, + lifespan=basic['lifespan'] if basic['lifespan'] != -1 else None, + remaining_lifespan=basic['remaining_lifespan'] if basic['remaining_lifespan'] != -1 else None, + ).returning(Item.id) + + result = await database.fetch_one(item_stmt) + db_item_id = result['id'] + + # Store combat stats if applicable + combat = properties['combat'] + if any(v != -1 and v != -1.0 for v in combat.values()): + combat_stmt = sa.dialects.postgresql.insert(ItemCombatStats).values( + item_id=db_item_id, + **{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v != -1 and v != -1.0 else None for k, v in combat.items()}) + ) + await database.execute(combat_stmt) + + # Store requirements if applicable + requirements = properties['requirements'] + if any(v not in [-1, None, ''] for v in requirements.values()): + req_stmt = sa.dialects.postgresql.insert(ItemRequirements).values( + item_id=db_item_id, + **{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, None, ''] else None for k, v in requirements.items()}) + ) + await database.execute(req_stmt) + + # Store enhancements + enhancements = properties['enhancements'] + enh_stmt = sa.dialects.postgresql.insert(ItemEnhancements).values( + item_id=db_item_id, + **{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, -1.0, None, ''] else None for k, v in enhancements.items()}) + ) + await database.execute(enh_stmt) + + # Store ratings if applicable + ratings = properties['ratings'] + if any(v not in [-1, -1.0, None] for v in ratings.values()): + rat_stmt = sa.dialects.postgresql.insert(ItemRatings).values( + item_id=db_item_id, + **{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()} + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict(**{k: v if v not in [-1, -1.0, None] else None for k, v in ratings.items()}) + ) + await database.execute(rat_stmt) + + # Store spell data if applicable + spells = item.get('Spells', []) + active_spells = item.get('ActiveSpells', []) + all_spells = set(spells + active_spells) + + if all_spells: + await database.execute( + "DELETE FROM item_spells WHERE item_id = :item_id", + {"item_id": db_item_id} + ) + for spell_id in all_spells: + is_active = spell_id in active_spells + spell_stmt = sa.dialects.postgresql.insert(ItemSpells).values( + item_id=db_item_id, + spell_id=spell_id, + is_active=is_active + ).on_conflict_do_nothing() + await database.execute(spell_stmt) + + # Store raw data + raw_stmt = sa.dialects.postgresql.insert(ItemRawData).values( + item_id=db_item_id, + int_values=item.get('IntValues', {}), + double_values=item.get('DoubleValues', {}), + string_values=item.get('StringValues', {}), + bool_values=item.get('BoolValues', {}), + original_json=item + ).on_conflict_do_update( + index_elements=['item_id'], + set_=dict( + int_values=item.get('IntValues', {}), + double_values=item.get('DoubleValues', {}), + string_values=item.get('StringValues', {}), + bool_values=item.get('BoolValues', {}), + original_json=item + ) + ) + await database.execute(raw_stmt) + + processed_count = 1 + + except Exception as e: + error_msg = f"Error processing item {item_game_id}: {e}" + logger.error(error_msg) + error_count = 1 + 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}") + return {"status": "ok", "processed": processed_count} + + +@app.delete("/inventory/{character_name}/item/{item_id}", + summary="Delete a single inventory item", + tags=["Data Processing"]) +async def delete_inventory_item(character_name: str, item_id: int): + """Delete a single item from a character's inventory.""" + + deleted_count = 0 + + async with database.transaction(): + # Find all DB rows for this character + game item_id + existing = await database.fetch_all( + "SELECT id FROM items WHERE character_name = :character_name AND item_id = :item_id", + {"character_name": character_name, "item_id": item_id} + ) + + if existing: + id_list = ','.join(str(row['id']) for row in existing) + + # Delete from all related tables first + await database.execute(f"DELETE FROM item_raw_data WHERE item_id IN ({id_list})") + await database.execute(f"DELETE FROM item_combat_stats WHERE item_id IN ({id_list})") + await database.execute(f"DELETE FROM item_requirements WHERE item_id IN ({id_list})") + await database.execute(f"DELETE FROM item_enhancements WHERE item_id IN ({id_list})") + await database.execute(f"DELETE FROM item_ratings WHERE item_id IN ({id_list})") + await database.execute(f"DELETE FROM item_spells WHERE item_id IN ({id_list})") + + # Delete from main items table + await database.execute( + "DELETE FROM items WHERE character_name = :character_name AND item_id = :item_id", + {"character_name": character_name, "item_id": item_id} + ) + + deleted_count = len(existing) + + logger.info(f"Single item delete for {character_name}: item_id={item_id}, deleted={deleted_count}") + return {"status": "ok", "deleted": deleted_count} + + @app.get("/inventory/{character_name}", summary="Get Character Inventory", description="Retrieve processed inventory data for a specific character with normalized item properties.",