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 6: Add Item Pre-Filtering | ✅ Already Working | No changes needed |
| 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
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
offset = (page - 1) * limit

View file

@ -253,14 +253,20 @@ class ItemBucket:
is_required: bool = False # Some slots might be required by constraints
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']:
# Underclothes: damage_rating first, ignore armor_level (buffed armor irrelevant)
self.items.sort(
key=lambda item: (
item.ratings.get('damage_rating', 0),
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
)
@ -271,7 +277,9 @@ class ItemBucket:
item.armor_level,
item.ratings.get('crit_damage_rating', 0),
len(item.spell_names),
sum(item.ratings.values())
sum(item.ratings.values()),
item.character_name,
item.name
),
reverse=True
)
@ -280,7 +288,9 @@ class ItemBucket:
self.items.sort(
key=lambda item: (
len(item.spell_names),
sum(item.ratings.values())
sum(item.ratings.values()),
item.character_name,
item.name
),
reverse=True
)
@ -407,13 +417,19 @@ class ScoringWeights(BaseModel):
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):
"""User-defined search constraints."""
characters: List[str]
primary_set: Optional[int] = None
secondary_set: Optional[int] = None
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_inventory: bool = True
min_armor: Optional[int] = None
@ -596,6 +612,11 @@ class ConstraintSatisfactionSolver:
self.search_completed = False
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
self.needed_spell_bitmap = self.spell_index.get_bitmap(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]:
"""Main search entry point with streaming results."""
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
items = await self.load_items()
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:
yield SearchResult(type="error", data={"message": "No items found for specified characters"})
return
# Phase 2: Creating buckets
yield SearchResult(type="phase", data={
"phase": "buckets",
"message": "Creating equipment buckets...",
"phase_number": 2,
"total_phases": 5
})
# Create buckets
buckets = self.create_buckets(items)
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
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
buckets = self.sort_buckets(buckets)
# Start recursive search
initial_state = SuitState()
# Apply locked items
for slot, item_id in self.constraints.locked_items.items():
# Find the locked item
for bucket in buckets:
if bucket.slot == slot:
for item in bucket.items:
if item.id == item_id:
item.is_locked = True
initial_state.push(item)
break
# Handle locked slots - filter out locked slots from buckets
if self.constraints.locked_slots:
# Debug: log what we received
logger.info(f"[LOCKED_SLOTS] Received locked_slots: {self.constraints.locked_slots}")
for slot, lock_info in self.constraints.locked_slots.items():
logger.info(f"[LOCKED_SLOTS] Slot '{slot}': set_id={lock_info.set_id}, spells={lock_info.spells}")
# 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")
yield SearchResult(type="progress", data={
"message": "Search started",
"buckets": len(buckets),
"evaluated": 0,
"found": 0,
"elapsed": 0.0
yield SearchResult(type="phase", data={
"phase": "searching",
"message": "Searching for optimal suits...",
"total_buckets": len(buckets),
"phase_number": 5,
"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")
@ -856,25 +997,31 @@ class ConstraintSatisfactionSolver:
all_jewelry_items = []
# Fetch jewelry items (object_class=4) - all jewelry for now
logger.info("[DEBUG] Building jewelry API parameters")
jewelry_params = build_base_params()
jewelry_params.append("object_class=4") # Jewelry
# Note: has_spells filter doesn't seem to work, so load all jewelry for now
jewelry_url = f"http://localhost:8000/search/items?{'&'.join(jewelry_params)}"
logger.info(f"[DEBUG] Constructed jewelry URL: {jewelry_url}")
# Fetch each jewelry type separately using slot_names filter
# This ensures we get all rings, bracelets, etc. instead of just the first page of all jewelry
jewelry_slot_types = [
("Ring", "rings"),
("Bracelet", "bracelets"),
("Neck", "necklaces/amulets"),
("Trinket", "trinkets")
]
logger.info(f"Fetching jewelry items with {equipment_status_log}")
logger.info(f"Fetching jewelry from: {jewelry_url}")
for slot_filter, slot_description in jewelry_slot_types:
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:
with urllib.request.urlopen(jewelry_url) as response:
data = json_module.load(response)
jewelry_items = data.get('items', [])
logger.info(f"Jewelry items with {equipment_status_log}: {len(jewelry_items)} items returned")
all_jewelry_items.extend(jewelry_items)
items = data.get('items', [])
logger.info(f"Fetched {len(items)} {slot_description}")
all_jewelry_items.extend(items)
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)}")
return all_jewelry_items
@ -895,6 +1042,26 @@ class ConstraintSatisfactionSolver:
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
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
},
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', '')
)
items.append(suit_item)
@ -969,29 +1136,63 @@ class ConstraintSatisfactionSolver:
filtered_items = ItemPreFilter.remove_surpassed_items(items)
# 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",
"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"
}]
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
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)
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)
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
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")
# 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
except Exception as e:
@ -1074,11 +1275,39 @@ class ConstraintSatisfactionSolver:
else:
logger.warning(f"Multi-slot item {item.name} with slots '{item.slot}' couldn't be mapped to any valid slots")
else:
# Check for complex slot patterns that might not use comma separation
# Handle items with complex slot descriptions that the SQL computed incorrectly
# Handle generic jewelry slot names that need expansion
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 = []
# 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:
if known_slot.lower() in item.slot.lower():
# 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"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
def apply_reduction_options(self, buckets: List[ItemBucket]) -> List[ItemBucket]:
@ -1284,59 +1527,107 @@ class ConstraintSatisfactionSolver:
logger.info("Search cancelled by client")
return
# No timeout - search continuously until user stops
# 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
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
# Let the search continue to find better combinations
# Branch pruning - Mag-SuitBuilder style aggressive pruning (ArmorSearcher.cs:138)
# 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
# This pruning was cutting off valid search branches too early
# We need to search deeper to find 9-piece complete suits
# Also keep the simpler max-items pruning as backup
remaining_buckets = len(buckets) - bucket_idx
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
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)
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):
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")
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 = 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()
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
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
suit_data['stats']['secondary_set_count'] = suit.set_counts.get(secondary_set_name, 0) if secondary_set_name else 0
# FIXED: set_counts uses numeric keys, not string names
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']['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)
# 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:
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:
logger.info(f"[DEBUG] No suit created from current state")
logger.debug(f"[DEBUG] No suit created from current state")
return
# Progress update and debug info
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
if self.is_cancelled and await self.is_cancelled():
logger.info("Search cancelled during progress update")
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(
type="progress",
data={
@ -1345,61 +1636,57 @@ class ConstraintSatisfactionSolver:
"current_depth": bucket_idx,
"total_buckets": len(buckets),
"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]
# Implement Mag-SuitBuilder parallel processing for first bucket (ArmorSearcher.cs:192-210)
if bucket_idx == 0 and len(bucket.items) > 10: # Only parallelize if enough 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
# DEBUG: Log bucket processing (reduced verbosity)
logger.debug(f"[DEBUG] Processing bucket {bucket_idx}: {bucket.slot} with {len(bucket.items)} items")
# Try each item in current bucket
items_tried = 0
items_accepted = 0
for item in bucket.items:
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):
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
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
recursion_count = 0
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
# Remove item from state (backtrack)
state.pop(item.slot)
logger.info(f"[DEBUG] Backtracked from {item.name}, state now: {len(state.items)} items")
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
logger.info(f"[DEBUG] Trying skip bucket {bucket.slot} (bucket {bucket_idx})")
skip_recursion_count = 0
# Only skip if no items were accepted (allows incomplete suits when no valid items exist)
# If items were accepted, we already explored those paths - don't also explore skip
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):
skip_recursion_count += 1
logger.info(f"[DEBUG] Skip-bucket result #{skip_recursion_count}")
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:
"""Check if item can be added without violating constraints."""
@ -1415,16 +1702,16 @@ class ConstraintSatisfactionSolver:
# 1. Slot availability
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
# 2. Item uniqueness - same physical item can't be used in multiple slots
for existing_item in state.items.values():
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
# 3. Set piece validation - FIXED: Use numeric IDs consistently
# 3. Set piece validation - Use EFFECTIVE limits (account for locked slots)
if item.set_id:
# Convert item.set_id to numeric for comparison (it might be string or int)
try:
@ -1434,56 +1721,61 @@ class ConstraintSatisfactionSolver:
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:
# Primary set: max 5 pieces
if current_count >= 5:
logger.info(f"[DEBUG] REJECT {item.name}: primary set {item_set_numeric} already has {current_count} pieces (max 5)")
# Primary set: use effective limit (accounts for locked pieces)
if current_count >= eff_primary:
logger.info(f"[SET_LIMIT] REJECT {item.name}: primary set {item_set_numeric} already has {current_count} pieces (effective max {eff_primary})")
return False
elif item_set_numeric == self.constraints.secondary_set:
# Secondary set: max 4 pieces
if current_count >= 4:
logger.info(f"[DEBUG] REJECT {item.name}: secondary set {item_set_numeric} already has {current_count} pieces (max 4)")
# Secondary set: use effective limit (accounts for locked pieces)
if current_count >= eff_secondary:
logger.info(f"[SET_LIMIT] REJECT {item.name}: secondary set {item_set_numeric} already has {current_count} pieces (effective max {eff_secondary})")
return False
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"}
if item.slot in jewelry_slots:
# Always allow jewelry items regardless of set ID (they provide spells)
logger.info(f"[DEBUG] ACCEPT {item.name}: jewelry item from set '{item_set_numeric}', allowed for spells")
# Jewelry MUST contribute to required spells to be accepted
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:
# STRICT: Reject armor items from other 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
else:
# 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"}
if item.slot in ['Shirt', 'Pants']:
# 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:
# Allow jewelry items even without set ID (they provide cantrips/wards)
logger.info(f"[DEBUG] ACCEPT {item.name}: jewelry item without set ID, allowed")
# Jewelry MUST contribute to required spells to be accepted
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:
# 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
# 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:
# Check if item would add any beneficial spells
if not self._can_get_beneficial_spell_from(item, state):
# Additional check: item might still be valuable for armor/ratings
if (item.armor_level < 300 and # Low armor
item.ratings.get('crit_damage_rating', 0) == 0 and # No crit damage
item.ratings.get('damage_rating', 0) == 0 and # No damage rating
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")
# STRICT MODE: No fallback for target sets or good stats
# Items with ALL duplicate spells are rejected
# (Items with SOME new spells + some duplicates are accepted by _can_get_beneficial_spell_from)
logger.debug(f"[DEBUG] REJECT {item.name}: all spells are duplicates")
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
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)
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(
items=state.items.copy(),
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 secondary set: {secondary_set_name} (ID: {self.constraints.secondary_set})")
# FIXED: set_counts uses numeric IDs, not translated names
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
# Get FOUND counts (items in this suit, not including locked)
found_primary = state.set_counts.get(self.constraints.primary_set, 0) if self.constraints.primary_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")
logger.debug(f"[SCORING] Secondary set {self.constraints.secondary_set} has {secondary_count} pieces")
# Get locked counts for display
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
if primary_count >= 5:
# Total counts (found + locked) for display
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
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:
# Missing set penalty: -200 per missing piece
if self.constraints.primary_set and primary_count > 0:
missing_pieces = 5 - primary_count
if self.constraints.primary_set and found_primary > 0:
missing_pieces = eff_primary - found_primary
penalty = missing_pieces * weights.missing_set_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
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:
# Missing set penalty: -200 per missing piece
if self.constraints.secondary_set and secondary_count > 0:
missing_pieces = 4 - secondary_count
if self.constraints.secondary_set and found_secondary > 0:
missing_pieces = eff_secondary - found_secondary
penalty = missing_pieces * weights.missing_set_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
for item in state.items.values():
@ -1638,16 +1963,30 @@ class ConstraintSatisfactionSolver:
return result
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:
return True # Non-set items don't count against limits
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:
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:
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:
# STRICT: Other sets not allowed for armor
# Only jewelry can be from other sets
@ -1682,35 +2021,64 @@ class ConstraintSatisfactionSolver:
return name_to_id.get(set_name)
def _can_get_beneficial_spell_from(self, item: SuitItem, state: SuitState) -> bool:
"""Check if item provides beneficial spells (Mag-SuitBuilder CanGetBeneficialSpellFrom)."""
if not item.spell_names:
return True # Non-spell items are always beneficial for armor/ratings
"""Check if item provides beneficial spells without duplicates.
# 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:
return True
# FIXED: Items are beneficial for multiple reasons, not just spells
# 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
# STRICT: Check if item provides ANY new required spell not already covered
needed_bitmap = self.needed_spell_bitmap
current_bitmap = state.spell_bitmap
item_bitmap = item.spell_bitmap
# Item is beneficial if it provides any needed spell we don't have
beneficial_spells = item_bitmap & needed_bitmap & ~current_bitmap
return beneficial_spells != 0
# Item MUST provide at least one new required spell
new_beneficial_spells = item_bitmap & needed_bitmap & ~current_bitmap
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

39
main.py
View file

@ -18,7 +18,7 @@ import socket
import struct
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.staticfiles import StaticFiles
from fastapi.encoders import jsonable_encoder
@ -2268,6 +2268,43 @@ async def test_inventory_route():
"""Test route to verify inventory proxy 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"])
async def proxy_inventory_service(path: str, request: Request):
"""Proxy all inventory service requests"""