Full Go port of suitbuilder.py's ConstraintSatisfactionSolver (the LIVE solver behind the suitbuilder UI; main.py's /optimize/suits is legacy/unused): - suit_model.go: CoverageMask + reductions, SuitItem/ItemBucket/SuitState, SpellBitmapIndex, ScoringWeights, SearchConstraints, CompletedSuit.to_dict, ItemPreFilter, set name<->id maps. Every sort carries (character_name, name) tiebreakers for deterministic results. - suit_solver.go: the 5-phase pipeline — load_items (fed in-process by the Go /search/items), create_buckets (+multi-slot/generic-jewelry expansion), apply_reduction_options, sort_buckets, and the depth-first recursive_search with both Mag-SuitBuilder pruning rules, can_add_item constraints (set limits, jewelry spell contribution, strict spell mode), scoring, and finalize. - suit_http.go: POST /suitbuilder/search (SSE: phase/log/suit/progress/complete), GET /suitbuilder/characters, GET /suitbuilder/sets. - search.go: refactor handleSearchItems -> shared runSearch (the solver reuses it so both see identical rows); emit slot_name (get_sophisticated_slot_options + translate_equipment_slot); fix the trinket slot_names clause to exclude %bracelet% (matches Python). - slotname.go: the EquipMask-based slot translation, loaded from the enum DB. Validation: 9/9 scenarios stream byte-identical suits vs the Python service on production data (no-spell, multi-character, locked slots with/without spells, spell constraints, alternate set pairs, primary-only). ~45x faster than Python. Three subtle bugs found and fixed during validation: - slot_name is load-bearing, not display: jewelry's computed_slot_name is empty, so load_items falls back to slot_name to bucket rings/neck/wrists/trinket. - Python scoring uses floor division (total_armor // 100); total_armor goes negative (non-armor items carry armor_level -1) so Go's truncation was +1 off. - the trinket fetch must exclude bracelets or they duplicate the Wrist buckets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
677 lines
27 KiB
Go
677 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// /search/items — port of inventory-service main.py:2892. This slice implements
|
|
// the search QUERY (the CTE + all SQL filters + sort + pagination + count) and
|
|
// returns each row's direct DB columns plus the computed booleans. The deep
|
|
// per-row translation (material_name, spells, slot_name, ...) from
|
|
// extract_item_properties is layered on in a later slice; the filter/count logic
|
|
// — "which items match" — is validated here against the Python service.
|
|
|
|
// cteSelect is the items_with_slots CTE body (everything up to FROM/JOINs). The
|
|
// rating columns are extracted from the item_raw_data int_values JSONB exactly
|
|
// as Python does (paired ids via GREATEST, singletons via COALESCE).
|
|
const cteSelect = `
|
|
SELECT DISTINCT
|
|
i.id AS db_item_id, i.character_name, i.name, i.icon, i.object_class, i.value, i.burden,
|
|
i.current_wielded_location, i.bonded, i.attuned, i."unique", i.stack_size, i.max_stack_size,
|
|
i.structure, i.max_structure, i.rare_id, i.timestamp AS last_updated,
|
|
COALESCE(cs.max_damage, -1) AS max_damage,
|
|
COALESCE(cs.armor_level, -1) AS armor_level,
|
|
COALESCE(cs.attack_bonus, -1.0) AS attack_bonus,
|
|
COALESCE(cs.melee_defense_bonus, -1.0) AS melee_defense_bonus,
|
|
COALESCE(cs.weapon_time, -1) AS weapon_time,
|
|
COALESCE(cs.base_armor_level, cs.armor_level, -1) AS base_armor_level,
|
|
COALESCE(cs.base_max_damage, cs.max_damage, -1) AS base_max_damage,
|
|
GREATEST(COALESCE((rd.int_values->>'314')::int, -1), COALESCE((rd.int_values->>'374')::int, -1)) AS crit_damage_rating,
|
|
GREATEST(COALESCE((rd.int_values->>'307')::int, -1), COALESCE((rd.int_values->>'370')::int, -1)) AS damage_rating,
|
|
GREATEST(COALESCE((rd.int_values->>'323')::int, -1), COALESCE((rd.int_values->>'376')::int, -1)) AS heal_boost_rating,
|
|
COALESCE((rd.int_values->>'379')::int, -1) AS vitality_rating,
|
|
GREATEST(COALESCE((rd.int_values->>'308')::int, -1), COALESCE((rd.int_values->>'371')::int, -1)) AS damage_resist_rating,
|
|
COALESCE((rd.int_values->>'315')::int, -1) AS crit_resist_rating,
|
|
GREATEST(COALESCE((rd.int_values->>'316')::int, -1), COALESCE((rd.int_values->>'375')::int, -1)) AS crit_damage_resist_rating,
|
|
COALESCE((rd.int_values->>'317')::int, -1) AS healing_resist_rating,
|
|
COALESCE((rd.int_values->>'331')::int, -1) AS nether_resist_rating,
|
|
COALESCE((rd.int_values->>'342')::int, -1) AS healing_rating,
|
|
COALESCE((rd.int_values->>'350')::int, -1) AS dot_resist_rating,
|
|
COALESCE((rd.int_values->>'351')::int, -1) AS life_resist_rating,
|
|
COALESCE((rd.int_values->>'356')::int, -1) AS sneak_attack_rating,
|
|
COALESCE((rd.int_values->>'357')::int, -1) AS recklessness_rating,
|
|
COALESCE((rd.int_values->>'358')::int, -1) AS deception_rating,
|
|
COALESCE((rd.int_values->>'381')::int, -1) AS pk_damage_rating,
|
|
COALESCE((rd.int_values->>'382')::int, -1) AS pk_damage_resist_rating,
|
|
COALESCE((rd.int_values->>'383')::int, -1) AS gear_pk_damage_rating,
|
|
COALESCE((rd.int_values->>'384')::int, -1) AS gear_pk_damage_resist_rating,
|
|
COALESCE(req.wield_level, -1) AS wield_level,
|
|
COALESCE(enh.material, '') AS material,
|
|
COALESCE(enh.workmanship, -1.0) AS workmanship,
|
|
COALESCE(enh.imbue, '') AS imbue,
|
|
COALESCE(enh.tinks, -1) AS tinks,
|
|
COALESCE(enh.item_set, '') AS item_set,
|
|
COALESCE((rd.int_values->>'218103821')::int, 0) AS coverage_mask,
|
|
COALESCE((rd.int_values->>'218103822')::int, 0) AS equippable_slots,
|
|
CASE
|
|
WHEN rd.original_json IS NOT NULL
|
|
AND rd.original_json->'IntValues'->>'218103822' IS NOT NULL
|
|
AND (rd.original_json->'IntValues'->>'218103822')::int > 0
|
|
THEN
|
|
CASE (rd.original_json->'IntValues'->>'218103822')::int
|
|
WHEN 1 THEN 'Head' WHEN 2 THEN 'Neck' WHEN 4 THEN 'Shirt'
|
|
WHEN 16 THEN 'Chest' WHEN 32 THEN 'Hands' WHEN 256 THEN 'Feet'
|
|
WHEN 512 THEN 'Chest' WHEN 1024 THEN 'Abdomen' WHEN 2048 THEN 'Upper Arms'
|
|
WHEN 4096 THEN 'Lower Arms' WHEN 8192 THEN 'Upper Legs' WHEN 16384 THEN 'Lower Legs'
|
|
WHEN 33554432 THEN 'Shield'
|
|
WHEN 15 THEN 'Chest, Abdomen, Upper Arms, Lower Arms'
|
|
WHEN 30 THEN 'Shirt'
|
|
WHEN 14336 THEN 'Chest, Abdomen, Upper Arms, Lower Arms'
|
|
WHEN 25600 THEN 'Abdomen, Upper Legs, Lower Legs'
|
|
ELSE CONCAT_WS(', ',
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 1 = 1 THEN 'Head' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 512 = 512 THEN 'Chest' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 1024 = 1024 THEN 'Abdomen' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 2048 = 2048 THEN 'Upper Arms' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 4096 = 4096 THEN 'Lower Arms' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 32 = 32 THEN 'Hands' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 8192 = 8192 THEN 'Upper Legs' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 16384 = 16384 THEN 'Lower Legs' END,
|
|
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 256 = 256 THEN 'Feet' END)
|
|
END
|
|
WHEN i.object_class = 4 THEN
|
|
CASE
|
|
WHEN i.current_wielded_location = 32768 THEN 'Neck'
|
|
WHEN i.current_wielded_location = 262144 THEN 'Left Ring'
|
|
WHEN i.current_wielded_location = 524288 THEN 'Right Ring'
|
|
WHEN i.current_wielded_location = 786432 THEN 'Left Ring, Right Ring'
|
|
WHEN i.current_wielded_location = 131072 THEN 'Left Wrist'
|
|
WHEN i.current_wielded_location = 1048576 THEN 'Right Wrist'
|
|
WHEN i.current_wielded_location = 1179648 THEN 'Left Wrist, Right Wrist'
|
|
WHEN i.name ILIKE '%amulet%' OR i.name ILIKE '%necklace%' OR i.name ILIKE '%gorget%' THEN 'Neck'
|
|
WHEN i.name ILIKE '%ring%' AND i.name NOT ILIKE '%keyring%' AND i.name NOT ILIKE '%signet%' THEN 'Left Ring, Right Ring'
|
|
WHEN i.name ILIKE '%bracelet%' THEN 'Left Wrist, Right Wrist'
|
|
WHEN i.name ILIKE '%trinket%' THEN 'Trinket'
|
|
ELSE 'Jewelry'
|
|
END
|
|
WHEN i.object_class = 6 THEN 'Melee Weapon'
|
|
WHEN i.object_class = 7 THEN 'Missile Weapon'
|
|
WHEN i.object_class = 8 THEN 'Held'
|
|
WHEN i.current_wielded_location = 67108864 THEN 'Two-Handed'
|
|
WHEN i.name ILIKE '%cloak%' THEN 'Cloak'
|
|
ELSE '-'
|
|
END AS computed_slot_name,
|
|
COALESCE((SELECT STRING_AGG(CAST(sp_inner.spell_id AS VARCHAR), ',' ORDER BY sp_inner.spell_id)
|
|
FROM item_spells sp_inner WHERE sp_inner.item_id = i.id), '') AS computed_spell_names,
|
|
-- Ordered passive Spells from the raw item (matches extract_item_properties:
|
|
-- spell_names = [translate_spell(id) for id in original_json["Spells"]], in
|
|
-- array order, with duplicates preserved). Internal; stripped after enrich.
|
|
(SELECT STRING_AGG(elem, ',' ORDER BY ord)
|
|
FROM jsonb_array_elements_text(
|
|
CASE WHEN jsonb_typeof(rd.original_json->'Spells') = 'array'
|
|
THEN rd.original_json->'Spells' ELSE '[]'::jsonb END)
|
|
WITH ORDINALITY AS t(elem, ord)) AS spell_ids_ordered
|
|
FROM items i
|
|
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
|
LEFT JOIN item_requirements req ON i.id = req.item_id
|
|
LEFT JOIN item_enhancements enh ON i.id = enh.item_id
|
|
LEFT JOIN item_ratings rt ON i.id = rt.item_id
|
|
LEFT JOIN item_raw_data rd ON i.id = rd.item_id`
|
|
|
|
var sortMapping = map[string]string{
|
|
"name": "name", "character_name": "character_name", "value": "value",
|
|
"damage": "max_damage", "armor": "armor_level", "armor_level": "armor_level",
|
|
"workmanship": "workmanship", "level": "wield_level", "damage_rating": "damage_rating",
|
|
"crit_damage_rating": "crit_damage_rating", "heal_boost_rating": "heal_boost_rating",
|
|
"vitality_rating": "vitality_rating", "damage_resist_rating": "damage_resist_rating",
|
|
"crit_damage_resist_rating": "crit_damage_resist_rating", "item_set": "item_set",
|
|
"coverage": "coverage_mask", "item_type_name": "object_class",
|
|
"last_updated": "last_updated", "spell_names": "computed_spell_names",
|
|
}
|
|
|
|
// argBuilder accumulates positional ($N) query args.
|
|
type argBuilder struct{ args []any }
|
|
|
|
func (b *argBuilder) add(v any) string {
|
|
b.args = append(b.args, v)
|
|
return "$" + strconv.Itoa(len(b.args))
|
|
}
|
|
|
|
func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
|
|
res, err := s.runSearch(r.Context(), r.URL.Query())
|
|
if err != nil {
|
|
s.dbErr(w, "search/items", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, res)
|
|
}
|
|
|
|
// runSearch executes /search/items and returns the response object (items +
|
|
// pagination, or an {error,...} object for invalid params). Shared by the HTTP
|
|
// handler and the suitbuilder solver's load_items, so both see identical rows.
|
|
func (s *Server) runSearch(ctx context.Context, q url.Values) (map[string]any, error) {
|
|
ab := &argBuilder{}
|
|
var conds []string
|
|
|
|
// --- character (mutually exclusive cascade) ---
|
|
if c := q.Get("character"); c != "" {
|
|
conds = append(conds, "character_name = "+ab.add(c))
|
|
} else if cs := q.Get("characters"); cs != "" {
|
|
names := splitNonEmpty(cs)
|
|
if len(names) == 0 {
|
|
return map[string]any{"error": "Empty characters list provided", "items": []any{}, "total_count": 0}, nil
|
|
}
|
|
ph := make([]string, len(names))
|
|
for i, n := range names {
|
|
ph[i] = ab.add(n)
|
|
}
|
|
conds = append(conds, "character_name IN ("+strings.Join(ph, ", ")+")")
|
|
} else if !qBool(q, "include_all_characters") {
|
|
return map[string]any{"error": "Must specify character, characters, or set include_all_characters=true", "items": []any{}, "total_count": 0}, nil
|
|
}
|
|
|
|
// --- text ---
|
|
if t := q.Get("text"); t != "" {
|
|
p := ab.add("%" + t + "%")
|
|
conds = append(conds, "(CONCAT(COALESCE(material,''),' ',name) ILIKE "+p+" OR name ILIKE "+p+" OR COALESCE(material,'') ILIKE "+p+")")
|
|
}
|
|
|
|
// --- category (mutually exclusive) ---
|
|
switch {
|
|
case qBool(q, "armor_only"):
|
|
conds = append(conds, "(object_class = 2 AND COALESCE(armor_level,0) > 0)")
|
|
case qBool(q, "jewelry_only"):
|
|
conds = append(conds, "object_class = 4")
|
|
case qBool(q, "weapon_only"):
|
|
conds = append(conds, weaponTypeClause(q.Get("weapon_type")))
|
|
case qBool(q, "clothing_only"):
|
|
conds = append(conds, "(object_class = 3 AND name NOT ILIKE '%cloak%' AND name NOT ILIKE '%robe%' AND name NOT ILIKE '%pallium%' AND name NOT ILIKE '%armet%' AND (name ILIKE '%shirt%' OR name ILIKE '%pants%' OR name ILIKE '%breeches%' OR name ILIKE '%baggy%' OR name ILIKE '%tunic%'))")
|
|
}
|
|
|
|
// --- equipment status / slot ---
|
|
switch q.Get("equipment_status") {
|
|
case "equipped":
|
|
conds = append(conds, "current_wielded_location > 0")
|
|
case "unequipped":
|
|
conds = append(conds, "current_wielded_location = 0")
|
|
}
|
|
if v, ok := qInt(q, "equipment_slot"); ok {
|
|
conds = append(conds, "current_wielded_location = "+ab.add(v))
|
|
}
|
|
|
|
// --- combat + all rating filters (column >= :param) ---
|
|
geFilters := []struct{ param, col string }{
|
|
{"min_damage", "max_damage"}, {"min_armor", "armor_level"},
|
|
{"min_crit_damage_rating", "crit_damage_rating"}, {"min_damage_rating", "damage_rating"},
|
|
{"min_heal_boost_rating", "heal_boost_rating"}, {"min_vitality_rating", "vitality_rating"},
|
|
{"min_damage_resist_rating", "damage_resist_rating"}, {"min_crit_resist_rating", "crit_resist_rating"},
|
|
{"min_crit_damage_resist_rating", "crit_damage_resist_rating"}, {"min_healing_resist_rating", "healing_resist_rating"},
|
|
{"min_nether_resist_rating", "nether_resist_rating"}, {"min_healing_rating", "healing_rating"},
|
|
{"min_dot_resist_rating", "dot_resist_rating"}, {"min_life_resist_rating", "life_resist_rating"},
|
|
{"min_sneak_attack_rating", "sneak_attack_rating"}, {"min_recklessness_rating", "recklessness_rating"},
|
|
{"min_deception_rating", "deception_rating"}, {"min_pk_damage_rating", "pk_damage_rating"},
|
|
{"min_pk_damage_resist_rating", "pk_damage_resist_rating"}, {"min_gear_pk_damage_rating", "gear_pk_damage_rating"},
|
|
{"min_gear_pk_damage_resist_rating", "gear_pk_damage_resist_rating"}, {"min_tinks", "tinks"},
|
|
{"min_value", "value"}, {"min_workmanship", "workmanship"},
|
|
}
|
|
for _, f := range geFilters {
|
|
if v := q.Get(f.param); v != "" {
|
|
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
|
conds = append(conds, f.col+" >= "+ab.add(n))
|
|
}
|
|
}
|
|
}
|
|
leFilters := []struct{ param, col string }{
|
|
{"max_damage", "max_damage"}, {"max_armor", "armor_level"},
|
|
{"max_value", "value"}, {"max_burden", "burden"},
|
|
}
|
|
for _, f := range leFilters {
|
|
if v, ok := qInt(q, f.param); ok {
|
|
conds = append(conds, f.col+" <= "+ab.add(v))
|
|
}
|
|
}
|
|
if v := q.Get("min_attack_bonus"); v != "" {
|
|
if n, err := strconv.ParseFloat(v, 64); err == nil {
|
|
conds = append(conds, "attack_bonus >= "+ab.add(n))
|
|
}
|
|
}
|
|
|
|
// --- requirements (wield level) ---
|
|
if v, ok := qInt(q, "max_level"); ok {
|
|
conds = append(conds, "(wield_level <= "+ab.add(v)+" OR wield_level IS NULL)")
|
|
}
|
|
if v, ok := qInt(q, "min_level"); ok {
|
|
conds = append(conds, "wield_level >= "+ab.add(v))
|
|
}
|
|
|
|
// --- enhancements ---
|
|
if m := q.Get("material"); m != "" {
|
|
conds = append(conds, "material ILIKE "+ab.add("%"+m+"%"))
|
|
}
|
|
if v := q.Get("has_imbue"); v != "" {
|
|
if qBool(q, "has_imbue") {
|
|
conds = append(conds, "(imbue IS NOT NULL AND imbue != '')")
|
|
} else {
|
|
conds = append(conds, "(imbue IS NULL OR imbue = '')")
|
|
}
|
|
}
|
|
|
|
// --- item state ---
|
|
if v := q.Get("bonded"); v != "" {
|
|
conds = append(conds, ternary(qBool(q, "bonded"), "bonded > 0", "bonded = 0"))
|
|
}
|
|
if v := q.Get("attuned"); v != "" {
|
|
conds = append(conds, ternary(qBool(q, "attuned"), "attuned > 0", "attuned = 0"))
|
|
}
|
|
if v := q.Get("unique"); v != "" {
|
|
conds = append(conds, `"unique" = `+ab.add(qBool(q, "unique")))
|
|
}
|
|
if v := q.Get("is_rare"); v != "" {
|
|
conds = append(conds, ternary(qBool(q, "is_rare"), "rare_id IS NOT NULL AND rare_id > 0", "(rare_id IS NULL OR rare_id <= 0)"))
|
|
}
|
|
if v, ok := qInt(q, "min_condition"); ok {
|
|
conds = append(conds, "((structure * 100.0 / NULLIF(max_structure, 0)) >= "+ab.add(v)+" OR max_structure IS NULL)")
|
|
}
|
|
|
|
// --- item_set / item_sets (translate id->name, bug-for-bug) ---
|
|
if v := q.Get("item_set"); v != "" {
|
|
conds = append(conds, "item_set = "+ab.add(s.translateSetID(v)))
|
|
} else if v := q.Get("item_sets"); v != "" {
|
|
ids := splitNonEmpty(v)
|
|
if len(ids) != 1 {
|
|
conds = append(conds, "1 = 0") // 0 or >1 set ids => impossible
|
|
} else {
|
|
conds = append(conds, "item_set = "+ab.add(s.translateSetID(ids[0])))
|
|
}
|
|
}
|
|
|
|
// --- slot_names (OR of per-slot approaches over computed_slot_name) ---
|
|
if v := q.Get("slot_names"); v != "" {
|
|
var slotClauses []string
|
|
for _, name := range splitNonEmpty(v) {
|
|
slotClauses = append(slotClauses, slotNameClause(name, ab))
|
|
}
|
|
if len(slotClauses) > 0 {
|
|
conds = append(conds, "("+strings.Join(slotClauses, " OR ")+")")
|
|
}
|
|
}
|
|
|
|
where := ""
|
|
if len(conds) > 0 {
|
|
where = " WHERE " + strings.Join(conds, " AND ")
|
|
}
|
|
|
|
// --- sort ---
|
|
sortField, ok := sortMapping[q.Get("sort_by")]
|
|
if !ok {
|
|
sortField = "name"
|
|
}
|
|
dir, nulls := "ASC", "NULLS LAST"
|
|
if strings.EqualFold(q.Get("sort_dir"), "desc") {
|
|
dir, nulls = "DESC", "NULLS FIRST"
|
|
}
|
|
orderBy := fmt.Sprintf(" ORDER BY %s %s %s, character_name, db_item_id", sortField, dir, nulls)
|
|
|
|
// --- pagination ---
|
|
page := clampInt(qIntDefault(q, "page", 1), 1, 1<<30)
|
|
limit := clampInt(qIntDefault(q, "limit", 200), 1, 50000)
|
|
offset := (page - 1) * limit
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
// Underwear filters (shirt_only/pants_only/underwear_only) are injected into
|
|
// the CTE body itself (they filter on raw i./rd. columns), mirroring Python's
|
|
// cte_where_clause insertion. Mutually exclusive, shirt > pants > underwear.
|
|
cteBody := cteSelect
|
|
if cw := underwearCTEWhere(q); cw != "" {
|
|
cteBody += "\n" + cw
|
|
}
|
|
cte := "WITH items_with_slots AS (" + cteBody + ")\n"
|
|
mainSQL := cte + "SELECT * FROM items_with_slots" + where + orderBy +
|
|
" LIMIT " + ab.add(limit) + " OFFSET " + ab.add(offset)
|
|
rows, err := queryRowsAsMaps(ctx, s.pool, mainSQL, ab.args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// count uses the SAME CTE (incl. the underwear injection) + conditions, so
|
|
// total_count is always consistent with the returned items. Python builds a
|
|
// SEPARATE count CTE (main.py:3747) that omits the underwear injection and
|
|
// uses a simpler computed_slot_name, so its total_count is inconsistent with
|
|
// its own items for underwear/slot_names filters (e.g. shirt_only reports the
|
|
// whole table). We deliberately do NOT replicate that bug. Normal browse
|
|
// filters apply to both CTEs identically, so those counts match Python.
|
|
// LIMIT/OFFSET args are unused here.
|
|
countSQL := cte + "SELECT COUNT(DISTINCT db_item_id) FROM items_with_slots" + where
|
|
var totalCount int64
|
|
if err := s.pool.QueryRow(ctx, countSQL, ab.args[:len(ab.args)-2]...).Scan(&totalCount); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items := s.enrichRows(rows)
|
|
|
|
return map[string]any{
|
|
"items": items,
|
|
"total_count": totalCount,
|
|
"page": page,
|
|
"limit": limit,
|
|
"has_next": int64(page*limit) < totalCount,
|
|
"has_previous": page > 1,
|
|
}, nil
|
|
}
|
|
|
|
// enrichRows applies the direct-column transforms (computed booleans, condition,
|
|
// timestamp), the material-name prefix, and the item_set name, then strips
|
|
// internal columns. Deeper enrichment (spells, slot_name, weapon damage/mana,
|
|
// rating fallbacks) is a later slice.
|
|
func (s *Server) enrichRows(rows []map[string]any) []map[string]any {
|
|
out := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
row["is_equipped"] = toInt64(row["current_wielded_location"]) > 0
|
|
row["is_bonded"] = toInt64(row["bonded"]) > 0
|
|
row["is_attuned"] = toInt64(row["attuned"]) > 0
|
|
row["is_rare"] = toInt64(row["rare_id"]) > 0
|
|
st, mx := row["structure"], row["max_structure"]
|
|
if st != nil && mx != nil && toFloat(mx) != 0 {
|
|
row["condition_percent"] = roundTo(toFloat(st)*100/toFloat(mx), 1)
|
|
} else {
|
|
row["condition_percent"] = nil
|
|
}
|
|
if t, ok := row["last_updated"].(time.Time); ok {
|
|
row["last_updated"] = pyISO(t)
|
|
}
|
|
|
|
// object_class_name — gem(11) context uses the ORIGINAL item name, so
|
|
// compute before the material prefix below (translate_object_class).
|
|
if oc := int(toInt64(row["object_class"])); oc != 0 {
|
|
row["object_class_name"] = s.translateObjectClass(oc, toStr(row["name"]))
|
|
}
|
|
|
|
// material_name + material prefix on name (material is already a
|
|
// translated string in the DB; enrich_db_item:2371-2602).
|
|
if mat := toStr(row["material"]); mat != "" {
|
|
row["material_name"] = mat
|
|
if name := toStr(row["name"]); name != "" &&
|
|
!strings.HasPrefix(strings.ToLower(name), strings.ToLower(mat)) {
|
|
row["original_name"] = name
|
|
row["name"] = mat + " " + name
|
|
}
|
|
}
|
|
// item_set_name (enrich_db_item:2551-2562).
|
|
if iset := strings.TrimSpace(toStr(row["item_set"])); iset != "" {
|
|
if n, ok := s.attributeSets[iset]; ok {
|
|
row["item_set_name"] = n
|
|
} else {
|
|
row["item_set_name"] = "Set " + iset
|
|
}
|
|
}
|
|
|
|
// spells / spell_names from the ordered passive Spells array
|
|
// (enrich_db_item:3942-3951; only set when the item has spells).
|
|
if raw := toStr(row["spell_ids_ordered"]); raw != "" {
|
|
parts := strings.Split(raw, ",")
|
|
spells := make([]map[string]any, 0, len(parts))
|
|
names := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
id, err := strconv.Atoi(strings.TrimSpace(p))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
sp := s.translateSpell(id)
|
|
spells = append(spells, sp)
|
|
if n, _ := sp["name"].(string); n != "" {
|
|
names = append(names, n)
|
|
}
|
|
}
|
|
if len(spells) > 0 {
|
|
row["spells"] = spells
|
|
row["spell_names"] = names
|
|
}
|
|
}
|
|
delete(row, "spell_ids_ordered")
|
|
|
|
// slot_name — sophisticated equipment-slot translation (main.py:3977-4033).
|
|
// Load-bearing for the suitbuilder: jewelry has an empty computed_slot_name,
|
|
// so load_items falls back to this to bucket rings/neck/wrists/trinket.
|
|
eq := int(toInt64(row["equippable_slots"]))
|
|
hasMat := toStr(row["material"]) != ""
|
|
row["slot_name"] = s.computeSlotName(eq, int(toInt64(row["coverage_mask"])), hasMat)
|
|
delete(row, "equippable_slots")
|
|
|
|
// Gear-total display ratings (main.py:4035-4072): damage_rating,
|
|
// crit_damage_rating, heal_boost_rating only. The CTE already does
|
|
// GREATEST(individual, gear-key 370/374/376), so the gear-positive rescue
|
|
// branch is dead — the net rule is simply -1 -> null. The other three
|
|
// solver ratings (damage_resist/crit_damage_resist/vitality) stay -1.
|
|
for _, f := range []string{"damage_rating", "crit_damage_rating", "heal_boost_rating"} {
|
|
if toInt64(row[f]) == -1 {
|
|
row[f] = nil
|
|
}
|
|
}
|
|
|
|
delete(row, "db_item_id")
|
|
out = append(out, row)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// translateObjectClass mirrors translate_object_class: ObjectClass enum lookup,
|
|
// with the context-aware Gem(11) classification by item name. The aetheria-by-
|
|
// IntValues path (for gem-class items not named crystal/gem/mana stone) is not
|
|
// reproduced here (it needs original_json) — a documented rare edge.
|
|
func (s *Server) translateObjectClass(oc int, name string) string {
|
|
base, ok := s.objectClasses[oc]
|
|
if !ok {
|
|
return fmt.Sprintf("Unknown_ObjectClass_%d", oc)
|
|
}
|
|
if base == "Gem" && oc == 11 {
|
|
n := strings.ToLower(name)
|
|
switch {
|
|
case strings.Contains(n, "mana stone"):
|
|
return "Mana Stone"
|
|
case strings.Contains(n, "crystal"):
|
|
return "Crystal"
|
|
case strings.Contains(n, "gem"):
|
|
return "Gem"
|
|
case strings.Contains(n, "aetheria"):
|
|
return "Aetheria"
|
|
}
|
|
return "Gem"
|
|
}
|
|
return base
|
|
}
|
|
|
|
// translateSetID mirrors translate_equipment_set_id (AttributeSetInfo lookup,
|
|
// ID-string fallback).
|
|
func (s *Server) translateSetID(setID string) string {
|
|
if name, ok := s.attributeSets[setID]; ok {
|
|
return name
|
|
}
|
|
return setID
|
|
}
|
|
|
|
// underwearCTEWhere returns the WHERE clause injected into the search CTE for
|
|
// the shirt_only / pants_only / underwear_only filters (main.py:3220-3251).
|
|
// Coverage bits on key 218103821: UnderwearUpperLegs=2, UnderwearLowerLegs=4,
|
|
// UnderwearChest=8, UnderwearAbdomen=16.
|
|
func underwearCTEWhere(q map[string][]string) string {
|
|
switch {
|
|
case qBool(q, "shirt_only"):
|
|
return `WHERE i.object_class = 3
|
|
AND ((rd.int_values->>'218103821')::int & 8) > 0
|
|
AND NOT ((rd.int_values->>'218103821')::int & 6) = 6
|
|
AND i.name NOT ILIKE '%robe%'
|
|
AND i.name NOT ILIKE '%cloak%'
|
|
AND i.name NOT ILIKE '%pallium%'
|
|
AND i.name NOT ILIKE '%armet%'
|
|
AND i.name NOT ILIKE '%pants%'
|
|
AND i.name NOT ILIKE '%breeches%'`
|
|
case qBool(q, "pants_only"):
|
|
return `WHERE i.object_class = 3
|
|
AND ((rd.int_values->>'218103821')::int & 2) = 2
|
|
AND i.name NOT ILIKE '%robe%'
|
|
AND i.name NOT ILIKE '%cloak%'
|
|
AND i.name NOT ILIKE '%pallium%'
|
|
AND i.name NOT ILIKE '%armet%'`
|
|
case qBool(q, "underwear_only"):
|
|
return `WHERE i.object_class = 3
|
|
AND ((rd.int_values->>'218103821')::int & 30) > 0
|
|
AND i.name NOT ILIKE '%robe%'
|
|
AND i.name NOT ILIKE '%cloak%'
|
|
AND i.name NOT ILIKE '%pallium%'
|
|
AND i.name NOT ILIKE '%armet%'`
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func weaponTypeClause(wt string) string {
|
|
exists := func(skill int) string {
|
|
return fmt.Sprintf("(object_class = 1 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = %d))", skill)
|
|
}
|
|
switch strings.ToLower(wt) {
|
|
case "heavy":
|
|
return exists(44)
|
|
case "light":
|
|
return exists(45)
|
|
case "finesse":
|
|
return exists(46)
|
|
case "two_handed":
|
|
return exists(41)
|
|
case "bow":
|
|
return "(object_class = 9 AND name ILIKE '%bow%' AND name NOT ILIKE '%crossbow%')"
|
|
case "crossbow":
|
|
return "(object_class = 9 AND name ILIKE '%crossbow%')"
|
|
case "thrown":
|
|
return "(object_class = 9 AND (name ILIKE '%atlatl%' OR name ILIKE '%throwing%' OR name ILIKE '%javelin%' OR name ILIKE '%shuriken%' OR name ILIKE '%dart%' OR name ILIKE '%slingshot%'))"
|
|
case "caster":
|
|
return "object_class = 31"
|
|
default:
|
|
return "object_class IN (1, 9, 31)"
|
|
}
|
|
}
|
|
|
|
func slotNameClause(name string, ab *argBuilder) string {
|
|
switch strings.ToLower(name) {
|
|
case "ring":
|
|
return "((computed_slot_name ILIKE '%Ring%') OR (object_class = 4 AND name ILIKE '%ring%' AND name NOT ILIKE '%keyring%' AND name NOT ILIKE '%signet%'))"
|
|
case "bracelet", "wrist":
|
|
return "((computed_slot_name ILIKE '%Wrist%') OR (object_class = 4 AND name ILIKE '%bracelet%'))"
|
|
case "neck":
|
|
return "((computed_slot_name ILIKE " + ab.add("%neck%") + ") OR (object_class = 4 AND (name ILIKE '%amulet%' OR name ILIKE '%necklace%' OR name ILIKE '%gorget%')))"
|
|
case "trinket":
|
|
// Approach 5 (jewelry fallback) MUST exclude %bracelet% — without it the
|
|
// Trinket fetch sweeps in bracelets, which then duplicate the Wrist buckets
|
|
// (also fetched via slot_names=Bracelet) and the DFS re-emits suits.
|
|
return "((computed_slot_name ILIKE " + ab.add("%trinket%") + ") OR (current_wielded_location = 67108864) OR (object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%')) OR (object_class = 11 AND name ILIKE '%trinket%') OR (object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%'))"
|
|
case "cloak":
|
|
return "((computed_slot_name ILIKE " + ab.add("%cloak%") + ") OR (name ILIKE '%cloak%') OR (computed_slot_name = 'Cloak'))"
|
|
default:
|
|
return "(computed_slot_name ILIKE " + ab.add("%"+name+"%") + ")"
|
|
}
|
|
}
|
|
|
|
func splitNonEmpty(s string) []string {
|
|
var out []string
|
|
for _, p := range strings.Split(s, ",") {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func qBool(q map[string][]string, key string) bool {
|
|
v := ""
|
|
if vs, ok := q[key]; ok && len(vs) > 0 {
|
|
v = vs[0]
|
|
}
|
|
switch strings.ToLower(v) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func qInt(q map[string][]string, key string) (int, bool) {
|
|
if vs, ok := q[key]; ok && len(vs) > 0 && vs[0] != "" {
|
|
if n, err := strconv.Atoi(vs[0]); err == nil {
|
|
return n, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func qIntDefault(q map[string][]string, key string, def int) int {
|
|
if n, ok := qInt(q, key); ok {
|
|
return n
|
|
}
|
|
return def
|
|
}
|
|
|
|
func ternary(c bool, a, b string) string {
|
|
if c {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func clampInt(v, lo, hi int) int {
|
|
if v < lo {
|
|
return lo
|
|
}
|
|
if v > hi {
|
|
return hi
|
|
}
|
|
return v
|
|
}
|
|
|
|
func toInt64(v any) int64 {
|
|
switch x := v.(type) {
|
|
case int64:
|
|
return x
|
|
case int32:
|
|
return int64(x)
|
|
case int:
|
|
return int64(x)
|
|
case float64:
|
|
return int64(x)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func toFloat(v any) float64 {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return x
|
|
case float32:
|
|
return float64(x)
|
|
case int64:
|
|
return float64(x)
|
|
case int32:
|
|
return float64(x)
|
|
case int:
|
|
return float64(x)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func roundTo(v float64, places int) float64 {
|
|
p := 1.0
|
|
for i := 0; i < places; i++ {
|
|
p *= 10
|
|
}
|
|
r := v * p
|
|
if r < 0 {
|
|
r -= 0.5
|
|
} else {
|
|
r += 0.5
|
|
}
|
|
return float64(int64(r)) / p
|
|
}
|