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:
parent
8e70f88de1
commit
e0265e261c
4 changed files with 655 additions and 222 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
39
main.py
|
|
@ -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"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue