Add suitbuilder backend improvements and SSE streaming fix

- Add dedicated streaming proxy endpoint for real-time suitbuilder SSE updates
- Implement stable sorting with character_name and name tiebreakers for deterministic results
- Refactor locked items to locked slots supporting set_id and spell constraints
- Add Mag-SuitBuilder style branch pruning tracking variables
- Enhance search with phase updates and detailed progress logging
- Update design document with SSE streaming proxy fix details

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
erik 2026-02-05 19:14:07 +00:00
parent 8e70f88de1
commit e0265e261c
4 changed files with 655 additions and 222 deletions

View file

@ -222,3 +222,31 @@ No changes needed - ItemPreFilter used at line 969.
| Task 5: Add Armor Level Scoring | ✅ Complete | Added armor_score = total_armor // 100 | | Task 5: Add Armor Level Scoring | ✅ Complete | Added armor_score = total_armor // 100 |
| Task 6: Add Item Pre-Filtering | ✅ Already Working | No changes needed | | Task 6: Add Item Pre-Filtering | ✅ Already Working | No changes needed |
| Task 7: AccessorySearcher | Not Started | Future | | Task 7: AccessorySearcher | Not Started | Future |
| Task 8: Fix SSE Streaming Proxy | ✅ Complete | Added dedicated streaming endpoint in main.py |
---
## Bug Fixes Applied
### SSE Streaming Proxy Fix (2026-01-30)
**Problem:** The generic inventory proxy at `/inv/{path:path}` used `httpx.request()` which buffers the entire response before returning. For SSE streams like suitbuilder search, this caused:
- No progress updates reaching the frontend
- "Gateway Time-out" after buffering the full 5-minute search
**Solution:** Added dedicated streaming proxy endpoint `/inv/suitbuilder/search` in `main.py`:
```python
@app.post("/inv/suitbuilder/search")
async def proxy_suitbuilder_search(request: Request):
async def stream_response():
async with httpx.AsyncClient.stream(...) as response:
async for chunk in response.aiter_bytes():
yield chunk
return StreamingResponse(
stream_response(),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no"} # Disable nginx buffering
)
```
**Result:** Suits now stream to frontend in real-time with scores visible as they're found.

View file

@ -2702,7 +2702,7 @@ async def search_items(
# Handle NULLS for optional fields # Handle NULLS for optional fields
nulls_clause = "NULLS LAST" if sort_direction == "ASC" else "NULLS FIRST" nulls_clause = "NULLS LAST" if sort_direction == "ASC" else "NULLS FIRST"
query_parts.append(f"ORDER BY {sort_field} {sort_direction} {nulls_clause}") query_parts.append(f"ORDER BY {sort_field} {sort_direction} {nulls_clause}, character_name, db_item_id")
# Add pagination # Add pagination
offset = (page - 1) * limit offset = (page - 1) * limit

View file

@ -253,14 +253,20 @@ class ItemBucket:
is_required: bool = False # Some slots might be required by constraints is_required: bool = False # Some slots might be required by constraints
def sort_items(self): def sort_items(self):
"""Sort items by priority based on slot type.""" """Sort items by priority based on slot type.
All sorts include (character_name, name) as stable tiebreakers
to ensure deterministic ordering for reproducible search results.
"""
if self.slot in ['Shirt', 'Pants']: if self.slot in ['Shirt', 'Pants']:
# Underclothes: damage_rating first, ignore armor_level (buffed armor irrelevant) # Underclothes: damage_rating first, ignore armor_level (buffed armor irrelevant)
self.items.sort( self.items.sort(
key=lambda item: ( key=lambda item: (
item.ratings.get('damage_rating', 0), item.ratings.get('damage_rating', 0),
len(item.spell_names), len(item.spell_names),
sum(r for k, r in item.ratings.items() if k != 'damage_rating') sum(r for k, r in item.ratings.items() if k != 'damage_rating'),
item.character_name,
item.name
), ),
reverse=True reverse=True
) )
@ -271,7 +277,9 @@ class ItemBucket:
item.armor_level, item.armor_level,
item.ratings.get('crit_damage_rating', 0), item.ratings.get('crit_damage_rating', 0),
len(item.spell_names), len(item.spell_names),
sum(item.ratings.values()) sum(item.ratings.values()),
item.character_name,
item.name
), ),
reverse=True reverse=True
) )
@ -280,7 +288,9 @@ class ItemBucket:
self.items.sort( self.items.sort(
key=lambda item: ( key=lambda item: (
len(item.spell_names), len(item.spell_names),
sum(item.ratings.values()) sum(item.ratings.values()),
item.character_name,
item.name
), ),
reverse=True reverse=True
) )
@ -407,13 +417,19 @@ class ScoringWeights(BaseModel):
damage_rating_3: int = 30 # DR3 on clothes damage_rating_3: int = 30 # DR3 on clothes
class LockedSlotInfo(BaseModel):
"""Information about a locked slot."""
set_id: Optional[int] = None
spells: List[str] = []
class SearchConstraints(BaseModel): class SearchConstraints(BaseModel):
"""User-defined search constraints.""" """User-defined search constraints."""
characters: List[str] characters: List[str]
primary_set: Optional[int] = None primary_set: Optional[int] = None
secondary_set: Optional[int] = None secondary_set: Optional[int] = None
required_spells: List[str] = field(default_factory=list) required_spells: List[str] = field(default_factory=list)
locked_items: Dict[str, int] = field(default_factory=dict) # slot -> item_id locked_slots: Dict[str, LockedSlotInfo] = field(default_factory=dict) # slot -> lock info
include_equipped: bool = True include_equipped: bool = True
include_inventory: bool = True include_inventory: bool = True
min_armor: Optional[int] = None min_armor: Optional[int] = None
@ -596,6 +612,11 @@ class ConstraintSatisfactionSolver:
self.search_completed = False self.search_completed = False
self.is_cancelled = is_cancelled # Callback to check if search should stop self.is_cancelled = is_cancelled # Callback to check if search should stop
# Branch pruning: track best suit found so far (Mag-SuitBuilder style)
self.best_suit_item_count = 0
self.highest_armor_count_suit_built = 0 # Track highest armor piece count seen
self.total_armor_buckets_with_items = 0 # Will be set during bucket creation
# Pre-compute needed spell bitmap # Pre-compute needed spell bitmap
self.needed_spell_bitmap = self.spell_index.get_bitmap(constraints.required_spells) self.needed_spell_bitmap = self.spell_index.get_bitmap(constraints.required_spells)
logger.info(f"[SPELL_CONSTRAINTS_DEBUG] Required spells: {constraints.required_spells}") logger.info(f"[SPELL_CONSTRAINTS_DEBUG] Required spells: {constraints.required_spells}")
@ -604,45 +625,165 @@ class ConstraintSatisfactionSolver:
async def search(self) -> AsyncGenerator[SearchResult, None]: async def search(self) -> AsyncGenerator[SearchResult, None]:
"""Main search entry point with streaming results.""" """Main search entry point with streaming results."""
try: try:
# Phase 1: Loading items
yield SearchResult(type="phase", data={
"phase": "loading",
"message": "Loading items from database...",
"phase_number": 1,
"total_phases": 5
})
# Load and preprocess items # Load and preprocess items
items = await self.load_items() items = await self.load_items()
logger.info(f"Loaded {len(items)} items for optimization") logger.info(f"Loaded {len(items)} items for optimization")
yield SearchResult(type="phase", data={
"phase": "loaded",
"message": f"Loaded {len(items)} items",
"items_count": len(items),
"phase_number": 1,
"total_phases": 5
})
yield SearchResult(type="log", data={
"level": "info",
"message": f"Loaded {len(items)} items from {len(self.constraints.characters)} characters",
"timestamp": time.time() - self.start_time
})
if not items: if not items:
yield SearchResult(type="error", data={"message": "No items found for specified characters"}) yield SearchResult(type="error", data={"message": "No items found for specified characters"})
return return
# Phase 2: Creating buckets
yield SearchResult(type="phase", data={
"phase": "buckets",
"message": "Creating equipment buckets...",
"phase_number": 2,
"total_phases": 5
})
# Create buckets # Create buckets
buckets = self.create_buckets(items) buckets = self.create_buckets(items)
logger.info(f"Created {len(buckets)} equipment buckets") logger.info(f"Created {len(buckets)} equipment buckets")
# Build bucket summary
bucket_summary = {b.slot: len(b.items) for b in buckets}
yield SearchResult(type="phase", data={
"phase": "buckets_done",
"message": f"Created {len(buckets)} buckets",
"bucket_count": len(buckets),
"bucket_summary": bucket_summary,
"phase_number": 2,
"total_phases": 5
})
# Log bucket details
bucket_details = ", ".join([f"{b.slot}: {len(b.items)}" for b in buckets[:5]])
yield SearchResult(type="log", data={
"level": "info",
"message": f"Buckets created: {bucket_details}{'...' if len(buckets) > 5 else ''}",
"timestamp": time.time() - self.start_time
})
# Phase 3: Applying reduction rules
yield SearchResult(type="phase", data={
"phase": "reducing",
"message": "Applying armor reduction rules...",
"phase_number": 3,
"total_phases": 5
})
# Apply armor reduction rules # Apply armor reduction rules
buckets = self.apply_reduction_options(buckets) buckets = self.apply_reduction_options(buckets)
# Phase 4: Sorting buckets
yield SearchResult(type="phase", data={
"phase": "sorting",
"message": "Optimizing search order...",
"phase_number": 4,
"total_phases": 5
})
# Sort buckets # Sort buckets
buckets = self.sort_buckets(buckets) buckets = self.sort_buckets(buckets)
# Start recursive search # Start recursive search
initial_state = SuitState() initial_state = SuitState()
# Apply locked items # Handle locked slots - filter out locked slots from buckets
for slot, item_id in self.constraints.locked_items.items(): if self.constraints.locked_slots:
# Find the locked item # Debug: log what we received
for bucket in buckets: logger.info(f"[LOCKED_SLOTS] Received locked_slots: {self.constraints.locked_slots}")
if bucket.slot == slot: for slot, lock_info in self.constraints.locked_slots.items():
for item in bucket.items: logger.info(f"[LOCKED_SLOTS] Slot '{slot}': set_id={lock_info.set_id}, spells={lock_info.spells}")
if item.id == item_id:
item.is_locked = True
initial_state.push(item)
break
# Start search locked_slot_names = set(self.constraints.locked_slots.keys())
original_bucket_count = len(buckets)
buckets = [b for b in buckets if b.slot not in locked_slot_names]
logger.info(f"Filtered out {original_bucket_count - len(buckets)} locked slots: {locked_slot_names}")
# Calculate locked set contributions (using numeric set IDs)
self.locked_set_counts = {}
for slot, lock_info in self.constraints.locked_slots.items():
if lock_info.set_id:
# Use numeric set_id for consistency with state.set_counts
self.locked_set_counts[lock_info.set_id] = self.locked_set_counts.get(lock_info.set_id, 0) + 1
logger.info(f"[LOCKED_SLOTS] Added set_id {lock_info.set_id} from slot {slot}")
logger.info(f"Locked set contributions (by ID): {self.locked_set_counts}")
logger.info(f"[LOCKED_SLOTS] Primary set ID: {self.constraints.primary_set}, locked count for it: {self.locked_set_counts.get(self.constraints.primary_set, 0)}")
# Calculate locked spells to exclude from required
self.locked_spells = set()
for lock_info in self.constraints.locked_slots.values():
self.locked_spells.update(lock_info.spells)
logger.info(f"Locked spells (already covered): {self.locked_spells}")
# Log locked slots info
yield SearchResult(type="log", data={
"level": "info",
"message": f"Locked {len(locked_slot_names)} slots: {', '.join(locked_slot_names)}",
"timestamp": time.time() - self.start_time
})
else:
self.locked_set_counts = {}
self.locked_spells = set()
# Calculate effective set requirements (subtract locked pieces)
self.effective_primary_needed = 5 # Default for primary set
self.effective_secondary_needed = 4 # Default for secondary set
if self.constraints.primary_set:
locked_primary = self.locked_set_counts.get(self.constraints.primary_set, 0)
self.effective_primary_needed = max(0, 5 - locked_primary)
if self.constraints.secondary_set:
locked_secondary = self.locked_set_counts.get(self.constraints.secondary_set, 0)
self.effective_secondary_needed = max(0, 4 - locked_secondary)
logger.info(f"Effective requirements: {self.effective_primary_needed} primary, {self.effective_secondary_needed} secondary (after locked)")
# Log effective requirements
if self.locked_set_counts:
yield SearchResult(type="log", data={
"level": "info",
"message": f"Need: {self.effective_primary_needed} primary + {self.effective_secondary_needed} secondary pieces",
"timestamp": time.time() - self.start_time
})
# Phase 5: Searching
logger.info(f"Starting recursive search with {len(buckets)} buckets") logger.info(f"Starting recursive search with {len(buckets)} buckets")
yield SearchResult(type="progress", data={ yield SearchResult(type="phase", data={
"message": "Search started", "phase": "searching",
"buckets": len(buckets), "message": "Searching for optimal suits...",
"evaluated": 0, "total_buckets": len(buckets),
"found": 0, "phase_number": 5,
"elapsed": 0.0 "total_phases": 5
})
# Log search start summary
total_items = sum(len(b.items) for b in buckets)
yield SearchResult(type="log", data={
"level": "info",
"message": f"Starting search: {len(buckets)} buckets, {total_items} total items",
"timestamp": time.time() - self.start_time
}) })
logger.info("Starting async iteration over recursive search") logger.info("Starting async iteration over recursive search")
@ -856,25 +997,31 @@ class ConstraintSatisfactionSolver:
all_jewelry_items = [] all_jewelry_items = []
# Fetch jewelry items (object_class=4) - all jewelry for now # Fetch each jewelry type separately using slot_names filter
logger.info("[DEBUG] Building jewelry API parameters") # This ensures we get all rings, bracelets, etc. instead of just the first page of all jewelry
jewelry_params = build_base_params() jewelry_slot_types = [
jewelry_params.append("object_class=4") # Jewelry ("Ring", "rings"),
# Note: has_spells filter doesn't seem to work, so load all jewelry for now ("Bracelet", "bracelets"),
jewelry_url = f"http://localhost:8000/search/items?{'&'.join(jewelry_params)}" ("Neck", "necklaces/amulets"),
logger.info(f"[DEBUG] Constructed jewelry URL: {jewelry_url}") ("Trinket", "trinkets")
]
logger.info(f"Fetching jewelry items with {equipment_status_log}") for slot_filter, slot_description in jewelry_slot_types:
logger.info(f"Fetching jewelry from: {jewelry_url}") jewelry_params = build_base_params()
jewelry_params.append("jewelry_only=true")
jewelry_params.append(f"slot_names={slot_filter}")
jewelry_url = f"http://localhost:8000/search/items?{'&'.join(jewelry_params)}"
logger.info(f"Fetching {slot_description} with {equipment_status_log}")
try: try:
with urllib.request.urlopen(jewelry_url) as response: with urllib.request.urlopen(jewelry_url) as response:
data = json_module.load(response) data = json_module.load(response)
jewelry_items = data.get('items', []) items = data.get('items', [])
logger.info(f"Jewelry items with {equipment_status_log}: {len(jewelry_items)} items returned") logger.info(f"Fetched {len(items)} {slot_description}")
all_jewelry_items.extend(jewelry_items) all_jewelry_items.extend(items)
except Exception as e: except Exception as e:
logger.error(f"Error fetching jewelry: {e}") logger.error(f"Error fetching {slot_description}: {e}")
logger.info(f"Total jewelry items fetched: {len(all_jewelry_items)}") logger.info(f"Total jewelry items fetched: {len(all_jewelry_items)}")
return all_jewelry_items return all_jewelry_items
@ -895,6 +1042,26 @@ class ConstraintSatisfactionSolver:
logger.info(f"Total items from inventory API: {len(all_api_items)}") logger.info(f"Total items from inventory API: {len(all_api_items)}")
# Helper function to normalize spell_names to list format
def normalize_spell_names(spell_data):
"""Convert spell_names to list format regardless of input type.
The API may return spell_names as:
- A list of spell names (correct format)
- A comma-separated string of spell names or IDs
- None or empty
This ensures we always work with a list.
"""
if spell_data is None:
return []
if isinstance(spell_data, list):
return spell_data
if isinstance(spell_data, str) and spell_data.strip():
# Split comma-separated values and clean up
return [s.strip() for s in spell_data.split(',') if s.strip()]
return []
# Convert to SuitItem objects # Convert to SuitItem objects
items = [] items = []
for api_item in all_api_items: for api_item in all_api_items:
@ -934,7 +1101,7 @@ class ConstraintSatisfactionSolver:
'vitality_rating': api_item.get('vitality_rating') if api_item.get('vitality_rating') is not None else 0 'vitality_rating': api_item.get('vitality_rating') if api_item.get('vitality_rating') is not None else 0
}, },
spell_bitmap=0, # Will calculate if needed spell_bitmap=0, # Will calculate if needed
spell_names=api_item.get('spell_names', []), spell_names=normalize_spell_names(api_item.get('spell_names')),
material=api_item.get('material_name', '') material=api_item.get('material_name', '')
) )
items.append(suit_item) items.append(suit_item)
@ -969,29 +1136,63 @@ class ConstraintSatisfactionSolver:
filtered_items = ItemPreFilter.remove_surpassed_items(items) filtered_items = ItemPreFilter.remove_surpassed_items(items)
# Sort items for optimal search order # Sort items for optimal search order
armor_items = [item for item in filtered_items if item.slot in { # Define slot sets
armor_slot_set = {
"Head", "Chest", "Upper Arms", "Lower Arms", "Hands", "Head", "Chest", "Upper Arms", "Lower Arms", "Hands",
"Abdomen", "Upper Legs", "Lower Legs", "Feet" "Abdomen", "Upper Legs", "Lower Legs", "Feet"
}] }
jewelry_items = [item for item in filtered_items if item.slot in { jewelry_slot_set = {
"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket" "Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"
}] }
clothing_items = [item for item in filtered_items if item.slot in {"Shirt", "Pants"}] # Also match generic jewelry slot names that might come from API
jewelry_fallback_slots = {"Ring", "Bracelet", "Jewelry", "Necklace", "Amulet"}
clothing_slot_set = {"Shirt", "Pants"}
# Helper to check if item matches any slot in a set (handles multi-slot items)
def matches_slot_set(item_slot: str, slot_set: set, fallback_set: set = None) -> bool:
if item_slot in slot_set:
return True
# Handle multi-slot items like "Left Wrist, Right Wrist"
if ', ' in item_slot:
return any(s.strip() in slot_set for s in item_slot.split(', '))
# Check fallback set for generic names like "Ring", "Jewelry"
if fallback_set and item_slot in fallback_set:
return True
return False
armor_items = [item for item in filtered_items if matches_slot_set(item.slot, armor_slot_set)]
jewelry_items = [item for item in filtered_items if matches_slot_set(item.slot, jewelry_slot_set, jewelry_fallback_slots)]
clothing_items = [item for item in filtered_items if matches_slot_set(item.slot, clothing_slot_set)]
# Sort armor by spell count (most spells first) since armor level deprioritized # Sort armor by spell count (most spells first) since armor level deprioritized
armor_items.sort(key=lambda x: len(x.spell_names), reverse=True) # Include (character_name, name) as stable tiebreakers for deterministic ordering
armor_items.sort(key=lambda x: (len(x.spell_names), x.character_name, x.name), reverse=True)
# Sort jewelry by spell count (most spells first) # Sort jewelry by spell count (most spells first)
jewelry_items.sort(key=lambda x: len(x.spell_names), reverse=True) jewelry_items.sort(key=lambda x: (len(x.spell_names), x.character_name, x.name), reverse=True)
# Sort clothing by damage rating (highest first) # Sort clothing by damage rating (highest first)
clothing_items.sort(key=lambda x: x.ratings.get('damage_rating', 0), reverse=True) clothing_items.sort(key=lambda x: (x.ratings.get('damage_rating', 0), x.character_name, x.name), reverse=True)
# Recombine in optimized order # Recombine in optimized order
optimized_items = armor_items + jewelry_items + clothing_items optimized_items = armor_items + jewelry_items + clothing_items
# DETERMINISM CHECK - Log first 5 items of each type to verify consistent ordering
logger.info("DETERMINISM CHECK - First 5 armor items:")
for i, item in enumerate(armor_items[:5]):
logger.info(f" {i}: {item.character_name}/{item.name} spells={len(item.spell_names)}")
logger.info("DETERMINISM CHECK - First 5 jewelry items:")
for i, item in enumerate(jewelry_items[:5]):
logger.info(f" {i}: {item.character_name}/{item.name} spells={len(item.spell_names)}")
logger.info(f"ITEM SORTING: {len(armor_items)} armor, {len(jewelry_items)} jewelry, {len(clothing_items)} clothing") logger.info(f"ITEM SORTING: {len(armor_items)} armor, {len(jewelry_items)} jewelry, {len(clothing_items)} clothing")
# Debug: Log jewelry items with spells
jewelry_with_spells = [item for item in jewelry_items if item.spell_names]
logger.info(f"JEWELRY WITH SPELLS: {len(jewelry_with_spells)} items")
for item in jewelry_with_spells[:5]: # Log first 5
logger.info(f" - {item.name} (slot: {item.slot}): {item.spell_names}")
return optimized_items return optimized_items
except Exception as e: except Exception as e:
@ -1074,11 +1275,39 @@ class ConstraintSatisfactionSolver:
else: else:
logger.warning(f"Multi-slot item {item.name} with slots '{item.slot}' couldn't be mapped to any valid slots") logger.warning(f"Multi-slot item {item.name} with slots '{item.slot}' couldn't be mapped to any valid slots")
else: else:
# Check for complex slot patterns that might not use comma separation # Handle generic jewelry slot names that need expansion
# Handle items with complex slot descriptions that the SQL computed incorrectly generic_jewelry_expansion = {
"Ring": ["Left Ring", "Right Ring"],
"Bracelet": ["Left Wrist", "Right Wrist"],
"Jewelry": ["Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"],
"Necklace": ["Neck"],
"Amulet": ["Neck"],
}
mapped_slots = [] mapped_slots = []
# Check if any of our known slots are mentioned in the slot string # Check for generic jewelry slot names first
if item.slot in generic_jewelry_expansion:
for target_slot in generic_jewelry_expansion[item.slot]:
single_slot_item = SuitItem(
id=item.id,
name=item.name,
character_name=item.character_name,
slot=target_slot,
coverage=item.coverage,
set_id=item.set_id,
armor_level=item.armor_level,
ratings=item.ratings.copy(),
spell_bitmap=item.spell_bitmap,
spell_names=item.spell_names.copy(),
material=item.material
)
slot_items[target_slot].append(single_slot_item)
mapped_slots.append(target_slot)
logger.debug(f"Generic jewelry slot '{item.slot}' expanded to: {mapped_slots} for {item.name}")
else:
# Check for complex slot patterns that might not use comma separation
# Handle items with complex slot descriptions that the SQL computed incorrectly
for known_slot in all_slots: for known_slot in all_slots:
if known_slot.lower() in item.slot.lower(): if known_slot.lower() in item.slot.lower():
# Create a single-slot variant of the item # Create a single-slot variant of the item
@ -1132,6 +1361,20 @@ class ConstraintSatisfactionSolver:
logger.info(f"CREATED {len(buckets)} total buckets (including {len([b for b in buckets if len(b.items) == 0])} empty)") logger.info(f"CREATED {len(buckets)} total buckets (including {len([b for b in buckets if len(b.items) == 0])} empty)")
logger.info(f"BUCKET ORDER: {[f'{b.slot}({len(b.items)})' for b in buckets]}") logger.info(f"BUCKET ORDER: {[f'{b.slot}({len(b.items)})' for b in buckets]}")
# Calculate total armor buckets with items for Mag-SuitBuilder pruning
self.total_armor_buckets_with_items = sum(
1 for b in buckets if b.is_armor and len(b.items) > 0
)
logger.info(f"ARMOR BUCKETS WITH ITEMS: {self.total_armor_buckets_with_items}")
# Log first 3 items of first non-empty bucket for determinism verification
for bucket in buckets:
if len(bucket.items) > 0:
logger.info(f"FIRST BUCKET ({bucket.slot}) - First 3 items:")
for i, item in enumerate(bucket.items[:3]):
logger.info(f" {i}: {item.character_name}/{item.name}")
break
return buckets return buckets
def apply_reduction_options(self, buckets: List[ItemBucket]) -> List[ItemBucket]: def apply_reduction_options(self, buckets: List[ItemBucket]) -> List[ItemBucket]:
@ -1284,59 +1527,107 @@ class ConstraintSatisfactionSolver:
logger.info("Search cancelled by client") logger.info("Search cancelled by client")
return return
# No timeout - search continuously until user stops
# Early success detection - stop when user's set goals are achieved # Early success detection - stop when user's set goals are achieved
primary_count = state.set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_set else 0 primary_count = state.set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_set else 0
secondary_count = state.set_counts.get(self.constraints.secondary_set, 0) if self.constraints.secondary_set else 0 secondary_count = state.set_counts.get(self.constraints.secondary_set, 0) if self.constraints.secondary_set else 0
# REMOVED: Don't stop early - we need to search deeper for complete suits # Branch pruning - Mag-SuitBuilder style aggressive pruning (ArmorSearcher.cs:138)
# Let the search continue to find better combinations # Formula: if (builder.Count + 1 < highestArmorCountSuitBuilt - (totalArmorBucketsWithItems - min(index, totalArmorBucketsWithItems)))
# This prunes branches where the best possible suit can't compete with what we've found
if self.highest_armor_count_suit_built > 0:
current_count = len(state.items)
# Calculate remaining armor slots that could be filled
remaining_armor_potential = self.total_armor_buckets_with_items - min(bucket_idx, self.total_armor_buckets_with_items)
# Minimum required: highest seen minus potential remaining (gives us 1-piece buffer)
min_required = self.highest_armor_count_suit_built - remaining_armor_potential
# If we can't even hit minimum required with 1 more piece, prune
if current_count + 1 < min_required:
return
# REMOVED: Aggressive pruning that prevents finding complete suits # Also keep the simpler max-items pruning as backup
# This pruning was cutting off valid search branches too early remaining_buckets = len(buckets) - bucket_idx
# We need to search deeper to find 9-piece complete suits max_possible_items = len(state.items) + remaining_buckets
if self.best_suit_item_count > 0 and max_possible_items < self.best_suit_item_count:
return
# Base case: all buckets processed # Base case: all buckets processed
if bucket_idx >= len(buckets): if bucket_idx >= len(buckets):
logger.info(f"[DEBUG] BASE CASE: All {len(buckets)} buckets processed, state has {len(state.items)} items") logger.debug(f"[DEBUG] BASE CASE: All {len(buckets)} buckets processed, state has {len(state.items)} items")
suit = self.finalize_suit(state) suit = self.finalize_suit(state)
if suit: if suit:
logger.info(f"[DEBUG] Suit created with score {suit.score}, {len(suit.items)} items") logger.debug(f"[DEBUG] Suit created with score {suit.score}, {len(suit.items)} items")
if self.is_better_than_existing(suit): if self.is_better_than_existing(suit):
logger.info(f"[DEBUG] Suit ACCEPTED: score {suit.score} is better than existing") logger.debug(f"[DEBUG] Suit ACCEPTED: score {suit.score} is better than existing")
logger.info(f"Found suit with score {suit.score}: {len(suit.items)} items") logger.info(f"Found suit with score {suit.score}: {len(suit.items)} items")
self.best_suits.append(suit) self.best_suits.append(suit)
# Update best suit item count for pruning
if len(suit.items) > self.best_suit_item_count:
self.best_suit_item_count = len(suit.items)
# Update armor piece count for Mag-SuitBuilder style pruning
armor_slots = {"Head", "Chest", "Upper Arms", "Lower Arms", "Hands",
"Abdomen", "Upper Legs", "Lower Legs", "Feet"}
armor_piece_count = sum(1 for slot in suit.items.keys() if slot in armor_slots)
if armor_piece_count > self.highest_armor_count_suit_built:
self.highest_armor_count_suit_built = armor_piece_count
logger.info(f"[PRUNING] New highest armor count: {armor_piece_count}")
self.best_suits.sort(key=lambda s: s.score, reverse=True) self.best_suits.sort(key=lambda s: s.score, reverse=True)
self.best_suits = self.best_suits[:self.constraints.max_results] self.best_suits = self.best_suits[:self.constraints.max_results]
# Pass constraint info to to_dict for proper set counts - FIXED: use translated names # Pass constraint info to to_dict for proper set counts
suit_data = suit.to_dict() suit_data = suit.to_dict()
from main import translate_equipment_set_id from main import translate_equipment_set_id
primary_set_name = translate_equipment_set_id(str(self.constraints.primary_set)) if self.constraints.primary_set else None primary_set_name = translate_equipment_set_id(str(self.constraints.primary_set)) if self.constraints.primary_set else None
secondary_set_name = translate_equipment_set_id(str(self.constraints.secondary_set)) if self.constraints.secondary_set else None secondary_set_name = translate_equipment_set_id(str(self.constraints.secondary_set)) if self.constraints.secondary_set else None
suit_data['stats']['primary_set_count'] = suit.set_counts.get(primary_set_name, 0) if primary_set_name else 0 # FIXED: set_counts uses numeric keys, not string names
suit_data['stats']['secondary_set_count'] = suit.set_counts.get(secondary_set_name, 0) if secondary_set_name else 0 primary_count = suit.set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_set else 0
secondary_count = suit.set_counts.get(self.constraints.secondary_set, 0) if self.constraints.secondary_set else 0
# Add locked slot contributions to the counts
if hasattr(self, 'locked_set_counts'):
primary_count += self.locked_set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_set else 0
secondary_count += self.locked_set_counts.get(self.constraints.secondary_set, 0) if self.constraints.secondary_set else 0
# Get locked counts for breakdown
locked_primary = self.locked_set_counts.get(self.constraints.primary_set, 0) if hasattr(self, 'locked_set_counts') and self.constraints.primary_set else 0
locked_secondary = self.locked_set_counts.get(self.constraints.secondary_set, 0) if hasattr(self, 'locked_set_counts') and self.constraints.secondary_set else 0
suit_data['stats']['primary_set_count'] = primary_count # Total (found + locked)
suit_data['stats']['secondary_set_count'] = secondary_count # Total (found + locked)
suit_data['stats']['primary_set'] = primary_set_name suit_data['stats']['primary_set'] = primary_set_name
suit_data['stats']['secondary_set'] = secondary_set_name suit_data['stats']['secondary_set'] = secondary_set_name
suit_data['stats']['locked_slots'] = len(self.constraints.locked_slots) if self.constraints.locked_slots else 0
suit_data['stats']['primary_locked'] = locked_primary
suit_data['stats']['secondary_locked'] = locked_secondary
yield SearchResult(type="suit", data=suit_data) yield SearchResult(type="suit", data=suit_data)
# Send log event for suit found
yield SearchResult(type="log", data={
"level": "success",
"message": f"Found suit #{len(self.best_suits)} with score {suit.score} ({len(suit.items)} items)",
"timestamp": time.time() - self.start_time
})
else: else:
logger.info(f"[DEBUG] Suit REJECTED: score {suit.score} not better than existing") logger.debug(f"[DEBUG] Suit REJECTED: score {suit.score} not better than existing")
else: else:
logger.info(f"[DEBUG] No suit created from current state") logger.debug(f"[DEBUG] No suit created from current state")
return return
# Progress update and debug info # Progress update and debug info
self.suits_evaluated += 1 self.suits_evaluated += 1
if self.suits_evaluated % 10 == 0: # Every 10 evaluations for better granularity if self.suits_evaluated % 100 == 0: # Every 100 evaluations for reduced log spam
# Check for cancellation during progress update # Check for cancellation during progress update
if self.is_cancelled and await self.is_cancelled(): if self.is_cancelled and await self.is_cancelled():
logger.info("Search cancelled during progress update") logger.info("Search cancelled during progress update")
return return
logger.info(f"Search progress: evaluated {self.suits_evaluated}, depth {bucket_idx}/{len(buckets)}, found {len(self.best_suits)} suits, current state: {len(state.items)} items") elapsed = time.time() - self.start_time
rate = round(self.suits_evaluated / elapsed, 1) if elapsed > 0 else 0
best_score = self.best_suits[0].score if self.best_suits else 0
current_bucket_name = buckets[bucket_idx].slot if bucket_idx < len(buckets) else None
logger.info(f"Search progress: evaluated {self.suits_evaluated}, depth {bucket_idx}/{len(buckets)}, found {len(self.best_suits)} suits, best has {self.best_suit_item_count} items")
yield SearchResult( yield SearchResult(
type="progress", type="progress",
data={ data={
@ -1345,61 +1636,57 @@ class ConstraintSatisfactionSolver:
"current_depth": bucket_idx, "current_depth": bucket_idx,
"total_buckets": len(buckets), "total_buckets": len(buckets),
"current_items": len(state.items), "current_items": len(state.items),
"elapsed": time.time() - self.start_time "elapsed": elapsed,
"rate": rate,
"current_bucket": current_bucket_name,
"best_score": best_score
} }
) )
# Send verbose log every 500 evaluations
if self.suits_evaluated % 500 == 0:
yield SearchResult(type="log", data={
"level": "info",
"message": f"Evaluated {self.suits_evaluated:,} combinations | Bucket: {current_bucket_name} ({bucket_idx+1}/{len(buckets)}) | Rate: {rate}/s",
"timestamp": elapsed
})
bucket = buckets[bucket_idx] bucket = buckets[bucket_idx]
# Implement Mag-SuitBuilder parallel processing for first bucket (ArmorSearcher.cs:192-210) # DEBUG: Log bucket processing (reduced verbosity)
if bucket_idx == 0 and len(bucket.items) > 10: # Only parallelize if enough items logger.debug(f"[DEBUG] Processing bucket {bucket_idx}: {bucket.slot} with {len(bucket.items)} items")
# For first bucket, we could use asyncio.gather for parallel processing
# but for now, keep sequential to avoid complexity with async generators
pass
# DEBUG: Log bucket processing
logger.info(f"[DEBUG] Processing bucket {bucket_idx}: {bucket.slot} with {len(bucket.items)} items")
# Continue searching to find more combinations - no aggressive pruning
# Try each item in current bucket # Try each item in current bucket
items_tried = 0 items_tried = 0
items_accepted = 0 items_accepted = 0
for item in bucket.items: for item in bucket.items:
items_tried += 1 items_tried += 1
logger.info(f"[DEBUG] Trying item {items_tried}/{len(bucket.items)} in {bucket.slot}: {item.name}") logger.debug(f"[DEBUG] Trying item {items_tried}/{len(bucket.items)} in {bucket.slot}: {item.name}")
if self.can_add_item(item, state): if self.can_add_item(item, state):
items_accepted += 1 items_accepted += 1
logger.info(f"[DEBUG] Item ACCEPTED: {item.name} (#{items_accepted})") logger.debug(f"[DEBUG] Item ACCEPTED: {item.name} (#{items_accepted})")
# Add item to state # Add item to state
state.push(item) state.push(item)
logger.info(f"[DEBUG] State after push: {len(state.items)} items, going to bucket {bucket_idx + 1}")
# Continue search with next bucket # Continue search with next bucket
recursion_count = 0
async for result in self.recursive_search(buckets, bucket_idx + 1, state): async for result in self.recursive_search(buckets, bucket_idx + 1, state):
recursion_count += 1
logger.info(f"[DEBUG] Received result #{recursion_count} from recursion")
yield result yield result
# Remove item from state (backtrack) # Remove item from state (backtrack)
state.pop(item.slot) state.pop(item.slot)
logger.info(f"[DEBUG] Backtracked from {item.name}, state now: {len(state.items)} items")
else: else:
logger.info(f"[DEBUG] Item REJECTED: {item.name}") logger.debug(f"[DEBUG] Item REJECTED: {item.name}")
logger.info(f"[DEBUG] Bucket {bucket.slot} summary: {items_tried} tried, {items_accepted} accepted") logger.debug(f"[DEBUG] Bucket {bucket.slot} summary: {items_tried} tried, {items_accepted} accepted")
# ALWAYS try skipping buckets to allow incomplete suits # Only skip if no items were accepted (allows incomplete suits when no valid items exist)
logger.info(f"[DEBUG] Trying skip bucket {bucket.slot} (bucket {bucket_idx})") # If items were accepted, we already explored those paths - don't also explore skip
skip_recursion_count = 0 if items_accepted == 0:
logger.debug(f"[DEBUG] No items accepted for {bucket.slot}, trying skip")
async for result in self.recursive_search(buckets, bucket_idx + 1, state): async for result in self.recursive_search(buckets, bucket_idx + 1, state):
skip_recursion_count += 1
logger.info(f"[DEBUG] Skip-bucket result #{skip_recursion_count}")
yield result yield result
logger.info(f"[DEBUG] Skip bucket {bucket.slot} yielded {skip_recursion_count} results")
def can_add_item(self, item: SuitItem, state: SuitState) -> bool: def can_add_item(self, item: SuitItem, state: SuitState) -> bool:
"""Check if item can be added without violating constraints.""" """Check if item can be added without violating constraints."""
@ -1415,16 +1702,16 @@ class ConstraintSatisfactionSolver:
# 1. Slot availability # 1. Slot availability
if item.slot in state.occupied_slots: if item.slot in state.occupied_slots:
logger.info(f"[DEBUG] REJECT {item.name}: slot {item.slot} already occupied") logger.debug(f"[DEBUG] REJECT {item.name}: slot {item.slot} already occupied")
return False return False
# 2. Item uniqueness - same physical item can't be used in multiple slots # 2. Item uniqueness - same physical item can't be used in multiple slots
for existing_item in state.items.values(): for existing_item in state.items.values():
if existing_item.id == item.id: if existing_item.id == item.id:
logger.info(f"[DEBUG] REJECT {item.name}: item already used (duplicate ID)") logger.debug(f"[DEBUG] REJECT {item.name}: item already used (duplicate ID)")
return False return False
# 3. Set piece validation - FIXED: Use numeric IDs consistently # 3. Set piece validation - Use EFFECTIVE limits (account for locked slots)
if item.set_id: if item.set_id:
# Convert item.set_id to numeric for comparison (it might be string or int) # Convert item.set_id to numeric for comparison (it might be string or int)
try: try:
@ -1434,56 +1721,61 @@ class ConstraintSatisfactionSolver:
current_count = state.set_counts.get(item_set_numeric, 0) current_count = state.set_counts.get(item_set_numeric, 0)
# Use effective limits which account for locked slots
eff_primary = getattr(self, 'effective_primary_needed', 5)
eff_secondary = getattr(self, 'effective_secondary_needed', 4)
if item_set_numeric == self.constraints.primary_set: if item_set_numeric == self.constraints.primary_set:
# Primary set: max 5 pieces # Primary set: use effective limit (accounts for locked pieces)
if current_count >= 5: if current_count >= eff_primary:
logger.info(f"[DEBUG] REJECT {item.name}: primary set {item_set_numeric} already has {current_count} pieces (max 5)") logger.info(f"[SET_LIMIT] REJECT {item.name}: primary set {item_set_numeric} already has {current_count} pieces (effective max {eff_primary})")
return False return False
elif item_set_numeric == self.constraints.secondary_set: elif item_set_numeric == self.constraints.secondary_set:
# Secondary set: max 4 pieces # Secondary set: use effective limit (accounts for locked pieces)
if current_count >= 4: if current_count >= eff_secondary:
logger.info(f"[DEBUG] REJECT {item.name}: secondary set {item_set_numeric} already has {current_count} pieces (max 4)") logger.info(f"[SET_LIMIT] REJECT {item.name}: secondary set {item_set_numeric} already has {current_count} pieces (effective max {eff_secondary})")
return False return False
else: else:
# Check if this is a jewelry item - always allow jewelry even if from other sets # Check if this is a jewelry item - only allow if it contributes required spells
jewelry_slots = {"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"} jewelry_slots = {"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"}
if item.slot in jewelry_slots: if item.slot in jewelry_slots:
# Always allow jewelry items regardless of set ID (they provide spells) # Jewelry MUST contribute to required spells to be accepted
logger.info(f"[DEBUG] ACCEPT {item.name}: jewelry item from set '{item_set_numeric}', allowed for spells") if not self._jewelry_contributes_required_spell(item, state):
logger.debug(f"[DEBUG] REJECT {item.name}: jewelry doesn't contribute any required spells")
return False
logger.debug(f"[DEBUG] ACCEPT {item.name}: jewelry from set '{item_set_numeric}' contributes required spells")
else: else:
# STRICT: Reject armor items from other sets # STRICT: Reject armor items from other sets
# Only allow armor from the two user-selected sets # Only allow armor from the two user-selected sets
logger.info(f"[DEBUG] REJECT {item.name}: armor from other set '{item_set_numeric}', only primary '{self.constraints.primary_set}' and secondary '{self.constraints.secondary_set}' allowed") logger.debug(f"[DEBUG] REJECT {item.name}: armor from other set '{item_set_numeric}', only primary '{self.constraints.primary_set}' and secondary '{self.constraints.secondary_set}' allowed")
return False return False
else: else:
# For set optimization, reject items with no set ID unless they're clothing or jewelry # For set optimization, reject items with no set ID unless they're clothing or jewelry
jewelry_slots = {"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"} jewelry_slots = {"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"}
if item.slot in ['Shirt', 'Pants']: if item.slot in ['Shirt', 'Pants']:
# Allow clothing items even without set ID # Allow clothing items even without set ID
logger.info(f"[DEBUG] ACCEPT {item.name}: clothing item without set ID, allowed") logger.debug(f"[DEBUG] ACCEPT {item.name}: clothing item without set ID, allowed")
elif item.slot in jewelry_slots: elif item.slot in jewelry_slots:
# Allow jewelry items even without set ID (they provide cantrips/wards) # Jewelry MUST contribute to required spells to be accepted
logger.info(f"[DEBUG] ACCEPT {item.name}: jewelry item without set ID, allowed") if not self._jewelry_contributes_required_spell(item, state):
logger.debug(f"[DEBUG] REJECT {item.name}: jewelry doesn't contribute any required spells")
return False
logger.debug(f"[DEBUG] ACCEPT {item.name}: jewelry without set ID contributes required spells")
else: else:
# Reject armor items without set ID for set optimization # Reject armor items without set ID for set optimization
logger.info(f"[DEBUG] REJECT {item.name}: no set ID and not clothing/jewelry") logger.debug(f"[DEBUG] REJECT {item.name}: no set ID and not clothing/jewelry")
return False return False
# 4. Spell overlap constraints - FIXED: Only reject if item adds no value # 4. Spell overlap constraints - STRICT: Reject items that don't contribute new spells
if self.constraints.required_spells and item.spell_names: if self.constraints.required_spells and item.spell_names:
# Check if item would add any beneficial spells
if not self._can_get_beneficial_spell_from(item, state): if not self._can_get_beneficial_spell_from(item, state):
# Additional check: item might still be valuable for armor/ratings # STRICT MODE: No fallback for target sets or good stats
if (item.armor_level < 300 and # Low armor # Items with ALL duplicate spells are rejected
item.ratings.get('crit_damage_rating', 0) == 0 and # No crit damage # (Items with SOME new spells + some duplicates are accepted by _can_get_beneficial_spell_from)
item.ratings.get('damage_rating', 0) == 0 and # No damage rating logger.debug(f"[DEBUG] REJECT {item.name}: all spells are duplicates")
item.set_id not in [self.constraints.primary_set, self.constraints.secondary_set]): # Not from target sets
logger.info(f"[DEBUG] REJECT {item.name}: no beneficial spells and low stats")
return False return False
else:
logger.info(f"[DEBUG] ACCEPT {item.name}: overlapping spells but good stats/set")
logger.info(f"[DEBUG] ACCEPT {item.name}: passed all constraints") logger.debug(f"[DEBUG] ACCEPT {item.name}: passed all constraints")
return True return True
def _is_double_spell_acceptable(self, item: SuitItem, overlap: int) -> bool: def _is_double_spell_acceptable(self, item: SuitItem, overlap: int) -> bool:
@ -1521,6 +1813,15 @@ class ConstraintSatisfactionSolver:
fulfilled_spells = self.spell_index.get_spell_names(fulfilled_bitmap) fulfilled_spells = self.spell_index.get_spell_names(fulfilled_bitmap)
missing_spells = self.spell_index.get_spell_names(missing_bitmap) missing_spells = self.spell_index.get_spell_names(missing_bitmap)
# Add locked spells to fulfilled and remove from missing
if hasattr(self, 'locked_spells') and self.locked_spells:
for spell in self.locked_spells:
if spell in missing_spells:
missing_spells.remove(spell)
fulfilled_spells.append(spell)
elif spell not in fulfilled_spells:
fulfilled_spells.append(spell)
return CompletedSuit( return CompletedSuit(
items=state.items.copy(), items=state.items.copy(),
score=score, score=score,
@ -1548,35 +1849,59 @@ class ConstraintSatisfactionSolver:
logger.debug(f"[SCORING] Looking for primary set: {primary_set_name} (ID: {self.constraints.primary_set})") logger.debug(f"[SCORING] Looking for primary set: {primary_set_name} (ID: {self.constraints.primary_set})")
logger.debug(f"[SCORING] Looking for secondary set: {secondary_set_name} (ID: {self.constraints.secondary_set})") logger.debug(f"[SCORING] Looking for secondary set: {secondary_set_name} (ID: {self.constraints.secondary_set})")
# FIXED: set_counts uses numeric IDs, not translated names # Get FOUND counts (items in this suit, not including locked)
primary_count = state.set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_set else 0 found_primary = state.set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_set else 0
secondary_count = state.set_counts.get(self.constraints.secondary_set, 0) if self.constraints.secondary_set else 0 found_secondary = state.set_counts.get(self.constraints.secondary_set, 0) if self.constraints.secondary_set else 0
logger.debug(f"[SCORING] Primary set {self.constraints.primary_set} has {primary_count} pieces") # Get locked counts for display
logger.debug(f"[SCORING] Secondary set {self.constraints.secondary_set} has {secondary_count} pieces") locked_primary = self.locked_set_counts.get(self.constraints.primary_set, 0) if hasattr(self, 'locked_set_counts') and self.constraints.primary_set else 0
locked_secondary = self.locked_set_counts.get(self.constraints.secondary_set, 0) if hasattr(self, 'locked_set_counts') and self.constraints.secondary_set else 0
# Complete set bonuses: +1000 for complete sets only # Total counts (found + locked) for display
if primary_count >= 5: total_primary = found_primary + locked_primary
total_secondary = found_secondary + locked_secondary
# Get effective requirements with fallback
eff_primary = getattr(self, 'effective_primary_needed', 5)
eff_secondary = getattr(self, 'effective_secondary_needed', 4)
logger.debug(f"[SCORING] Primary: {found_primary} found + {locked_primary} locked = {total_primary} total (need {eff_primary} more)")
logger.debug(f"[SCORING] Secondary: {found_secondary} found + {locked_secondary} locked = {total_secondary} total (need {eff_secondary} more)")
# Complete set bonuses: compare FOUND against EFFECTIVE requirements
if found_primary >= eff_primary:
score += weights.armor_set_complete score += weights.armor_set_complete
logger.debug(f"[SCORING] Primary set complete: +{weights.armor_set_complete}") logger.debug(f"[SCORING] Primary set complete: +{weights.armor_set_complete}")
# Penalty for EXCESS primary pieces (should have gone to secondary)
if found_primary > eff_primary:
excess = found_primary - eff_primary
excess_penalty = excess * 500 # STRONG penalty per excess piece
score -= excess_penalty
logger.debug(f"[SCORING] Primary set EXCESS ({excess} extra): -{excess_penalty}")
else: else:
# Missing set penalty: -200 per missing piece # Missing set penalty: -200 per missing piece
if self.constraints.primary_set and primary_count > 0: if self.constraints.primary_set and found_primary > 0:
missing_pieces = 5 - primary_count missing_pieces = eff_primary - found_primary
penalty = missing_pieces * weights.missing_set_penalty penalty = missing_pieces * weights.missing_set_penalty
score += penalty # negative penalty score += penalty # negative penalty
logger.debug(f"[SCORING] Primary set incomplete ({primary_count}/5): {penalty}") logger.debug(f"[SCORING] Primary set incomplete ({found_primary}/{eff_primary}): {penalty}")
if secondary_count >= 4: if found_secondary >= eff_secondary:
score += weights.armor_set_complete score += weights.armor_set_complete
logger.debug(f"[SCORING] Secondary set complete: +{weights.armor_set_complete}") logger.debug(f"[SCORING] Secondary set complete: +{weights.armor_set_complete}")
# Penalty for EXCESS secondary pieces
if found_secondary > eff_secondary:
excess = found_secondary - eff_secondary
excess_penalty = excess * 500 # STRONG penalty
score -= excess_penalty
logger.debug(f"[SCORING] Secondary set EXCESS ({excess} extra): -{excess_penalty}")
else: else:
# Missing set penalty: -200 per missing piece # Missing set penalty: -200 per missing piece
if self.constraints.secondary_set and secondary_count > 0: if self.constraints.secondary_set and found_secondary > 0:
missing_pieces = 4 - secondary_count missing_pieces = eff_secondary - found_secondary
penalty = missing_pieces * weights.missing_set_penalty penalty = missing_pieces * weights.missing_set_penalty
score += penalty # negative penalty score += penalty # negative penalty
logger.debug(f"[SCORING] Secondary set incomplete ({secondary_count}/4): {penalty}") logger.debug(f"[SCORING] Secondary set incomplete ({found_secondary}/{eff_secondary}): {penalty}")
# 2. Crit Damage Rating: CD1 = +10, CD2 = +20 per piece # 2. Crit Damage Rating: CD1 = +10, CD2 = +20 per piece
for item in state.items.values(): for item in state.items.values():
@ -1638,16 +1963,30 @@ class ConstraintSatisfactionSolver:
return result return result
def _has_room_for_armor_set(self, item: SuitItem, state: SuitState) -> bool: def _has_room_for_armor_set(self, item: SuitItem, state: SuitState) -> bool:
"""Check if adding this armor piece violates set limits (Mag-SuitBuilder HasRoomForArmorSet).""" """Check if adding this armor piece violates set limits (Mag-SuitBuilder HasRoomForArmorSet).
Uses effective limits which account for locked slots.
"""
if not item.set_id: if not item.set_id:
return True # Non-set items don't count against limits return True # Non-set items don't count against limits
current_count = state.set_counts.get(item.set_id, 0) current_count = state.set_counts.get(item.set_id, 0)
# Use effective limits (which account for locked slots)
eff_primary = getattr(self, 'effective_primary_needed', 5)
eff_secondary = getattr(self, 'effective_secondary_needed', 4)
if item.set_id == self.constraints.primary_set: if item.set_id == self.constraints.primary_set:
return current_count < 5 # Hard limit: don't add more primary set pieces than needed
has_room = current_count < eff_primary
if not has_room:
logger.debug(f"[SET_LIMIT] Rejecting {item.name} - already have {current_count}/{eff_primary} primary set pieces")
return has_room
elif item.set_id == self.constraints.secondary_set: elif item.set_id == self.constraints.secondary_set:
return current_count < 4 has_room = current_count < eff_secondary
if not has_room:
logger.debug(f"[SET_LIMIT] Rejecting {item.name} - already have {current_count}/{eff_secondary} secondary set pieces")
return has_room
else: else:
# STRICT: Other sets not allowed for armor # STRICT: Other sets not allowed for armor
# Only jewelry can be from other sets # Only jewelry can be from other sets
@ -1682,35 +2021,64 @@ class ConstraintSatisfactionSolver:
return name_to_id.get(set_name) return name_to_id.get(set_name)
def _can_get_beneficial_spell_from(self, item: SuitItem, state: SuitState) -> bool: def _can_get_beneficial_spell_from(self, item: SuitItem, state: SuitState) -> bool:
"""Check if item provides beneficial spells (Mag-SuitBuilder CanGetBeneficialSpellFrom).""" """Check if item provides beneficial spells without duplicates.
if not item.spell_names:
return True # Non-spell items are always beneficial for armor/ratings
# If no spell constraints, any spell item is beneficial STRICT MODE: Reject items that only have duplicate spells, even from target sets.
This prevents wasted spell slots (e.g., Flame Ward on 3 armor pieces).
"""
# Non-spell items are always beneficial (armor/ratings only)
if not item.spell_names:
return True
# If no spell constraints specified, allow any item
if not self.constraints.required_spells: if not self.constraints.required_spells:
return True return True
# FIXED: Items are beneficial for multiple reasons, not just spells # STRICT: Check if item provides ANY new required spell not already covered
# 1. Items from requested armor sets are always beneficial (set completion)
if (item.set_id == self.constraints.primary_set or
item.set_id == self.constraints.secondary_set):
return True
# 2. Items with good ratings are beneficial even if spells overlap
if (item.ratings.get('crit_damage_rating', 0) > 0 or
item.ratings.get('damage_rating', 0) > 0 or
item.armor_level > 500): # High armor items are valuable
return True
# 3. Check if item provides any needed spells not already covered
needed_bitmap = self.needed_spell_bitmap needed_bitmap = self.needed_spell_bitmap
current_bitmap = state.spell_bitmap current_bitmap = state.spell_bitmap
item_bitmap = item.spell_bitmap item_bitmap = item.spell_bitmap
# Item is beneficial if it provides any needed spell we don't have # Item MUST provide at least one new required spell
beneficial_spells = item_bitmap & needed_bitmap & ~current_bitmap new_beneficial_spells = item_bitmap & needed_bitmap & ~current_bitmap
return beneficial_spells != 0
if new_beneficial_spells != 0:
return True # Has new required spells - beneficial
# STRICT REJECTION: No new required spells = reject
# This applies even to primary/secondary set pieces
# Rationale: Duplicate spells waste valuable spell slots
return False
def _jewelry_contributes_required_spell(self, item: SuitItem, state: SuitState) -> bool:
"""Check if jewelry item contributes at least one required spell not already covered.
Jewelry should ONLY be added if it fulfills spell constraints. Empty slots are
preferred over jewelry that doesn't contribute to required spells.
"""
# If no spell constraints, don't add jewelry (nothing to contribute)
if not self.constraints.required_spells:
logger.debug(f"[JEWELRY] REJECT {item.name}: no required spells specified")
return False
# Item must have spells to contribute
if not item.spell_names:
logger.debug(f"[JEWELRY] REJECT {item.name}: item has no spells")
return False
# Check if item has ANY required spell that's not already in the suit
needed_bitmap = self.needed_spell_bitmap
current_bitmap = state.spell_bitmap
for spell in item.spell_names:
spell_bit = self.spell_index.get_bitmap([spell])
# Check if this spell is required AND not already covered
if (spell_bit & needed_bitmap) and not (current_bitmap & spell_bit):
logger.debug(f"[JEWELRY] ACCEPT {item.name}: contributes uncovered spell '{spell}'")
return True
logger.debug(f"[JEWELRY] REJECT {item.name}: no new required spells contributed")
return False
# API Endpoints # API Endpoints

39
main.py
View file

@ -18,7 +18,7 @@ import socket
import struct import struct
from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketDisconnect, Request from fastapi import FastAPI, Header, HTTPException, Query, WebSocket, WebSocketDisconnect, Request
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response, StreamingResponse
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
@ -2268,6 +2268,43 @@ async def test_inventory_route():
"""Test route to verify inventory proxy is working""" """Test route to verify inventory proxy is working"""
return {"message": "Inventory proxy route is working"} return {"message": "Inventory proxy route is working"}
@app.post("/inv/suitbuilder/search")
async def proxy_suitbuilder_search(request: Request):
"""Stream suitbuilder search results - SSE requires streaming proxy."""
inventory_service_url = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-service:8000')
logger.info(f"Streaming proxy to suitbuilder search")
# Read body BEFORE creating generator (request context needed)
body = await request.body()
async def stream_response():
try:
# Use streaming request with long timeout for searches
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
async with client.stream(
method="POST",
url=f"{inventory_service_url}/suitbuilder/search",
content=body,
headers={"Content-Type": "application/json"}
) as response:
async for chunk in response.aiter_bytes():
yield chunk
except httpx.ReadTimeout:
yield b"event: error\ndata: {\"message\": \"Search timeout\"}\n\n"
except Exception as e:
logger.error(f"Streaming proxy error: {e}")
yield f"event: error\ndata: {{\"message\": \"Proxy error: {str(e)}\"}}\n\n".encode()
return StreamingResponse(
stream_response(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # Disable nginx buffering
}
)
@app.api_route("/inv/{path:path}", methods=["GET", "POST"]) @app.api_route("/inv/{path:path}", methods=["GET", "POST"])
async def proxy_inventory_service(path: str, request: Request): async def proxy_inventory_service(path: str, request: Request):
"""Proxy all inventory service requests""" """Proxy all inventory service requests"""