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