nginx's X-Forwarded-For is $proxy_add_x_forwarded_for, which only appends
to whatever the client sends — a forged leftmost hop passed straight
through, letting an attacker rotate spoofed values to dodge the 5s login
cooldown. X-Real-IP is set by nginx to $remote_addr on every proxied
location and can't be forged by the client, so key on that instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Go cutover omitted get_character_inventory; the React InventoryWindow
fetches GET /inventory/{name} and got 404 -> empty. Port the endpoint:
per-character items with placement (current_wielded_location/container_id/
items_capacity), mana (current_mana/max_mana from original_json IntValues),
icon overlays, and join-table combat/req/enh/rating stats; material-prefixed
name. Returns {character_name,item_count,items}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-search filter selecting which crit-damage tiers are allowed on armor.
Default (all allowed) is byte-identical to current behavior; "prefer highest
allowed tier" falls out of existing scoring. Go-only (live solver); Python
copy left frozen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- README: Go-backend architecture, build/run via the compose override stack,
WS/payload/auth/DB contracts, the branch layout (master = Go, python-legacy).
- CLAUDE.md: Project Overview + Components reflect the Go services; a "Go services
— build, deploy, gotchas" section (string coercion, typeless telemetry, the
trinket dedup, rollback); Deploying + Suitbuilder point at the Go paths. The
behavioral contracts (WS/auth/DB/routes) are kept — Go honors them; file refs to
main.py/inventory-service mark the legacy source.
- .gitignore: ignore .env / .env.bak-* (public repo; .env.example stays tracked).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the Go backend so it can fully replace Python in production:
tracker-go website layer (serves the unchanged frontend):
- static file serving + SPA fallback + /icons (website.go)
- login/logout with itsdangerous cookie ISSUING (bcrypt, Python-interop) and the
/me handler (auth.go issueSessionCookie + website.go)
- admin user CRUD (website_admin.go) and the issue-board write side (website_issues.go)
- request-scoped user context + requireAdmin (auth.go)
cutover ingest (gated off during the parallel run, required for a clean cutover):
- inventory forwarding: full_inventory -> /process-inventory, inventory_delta ->
item POST/DELETE, per-character serialized, fire-and-forget (inventory_forward.go)
- death/idle Discord alerts via DISCORD_ACLOG_WEBHOOK (aclog.go)
- SKIP_SCHEMA_INIT so write mode against the prod DBs runs no DDL (tracker-go + inventory-go)
two bugs found live and fixed:
- coerceNum: the plugin sends kills_per_hour/deaths/total_deaths/prismatic_taper_count
as STRINGS; pydantic coerced them, Go's number helpers wrote null/0 (reads.go/ingest.go)
- telemetry is broadcast TYPELESS so the browser ignores it and uses the /live poll;
broadcasting it typed flapped the per-player counters 0<->value (ingest.go stripType)
docker-compose.cutover.yml: reversible override flipping the Go services to write
mode against the production DBs and repointing the Discord bot at the Go /ws/live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /go/ read stack is now fully Go end-to-end: browser -> nginx /go/ -> Go
tracker (dereth-tracker-go) -> Go inventory (inventory-go) -> read-only prod
DBs. Previously the Go tracker proxied inventory calls to the Python service;
inventory-go is validated byte-identical (search, sets, suitbuilder) so the
whole parallel stack is now Go. Production is untouched (additive override).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Adds the remaining search-result enrichment that the suitbuilder solver (and the
item-detail UI) depend on, validated byte-exact against the Python service on
production data:
- Load the SpellTable (spells.values, 6266 entries) from the enum DB and port
translate_spell (id -> {id,name,description,school,difficulty,duration,mana,
family}, Unknown_Spell_<id> fallback, "" defaults).
- Emit `spells` (full dicts) and `spell_names` from the ordered passive Spells
array (original_json->'Spells', array order + duplicates preserved), exactly
as enrich_db_item/extract_item_properties do — NOT from item_spells. Only set
when the item has spells. A jsonb_typeof guard keeps non-array Spells safe.
- Add the shirt_only / pants_only / underwear_only filters as CTE-body WHERE
injections (coverage-bit logic on key 218103821), mirroring main.py.
Validation (char Plant Enjoyer, all chars): spell_names 0 mismatches (8 spell
items), spells[].name 0 mismatches, shirt_only/pants_only item sets identical
(0 only-py / 0 only-go). Normal-search total_count still matches Python.
Note: for shirt/pants/slot filters Python's total_count is inconsistent with its
own items (separate count CTE lacks the injection); Go uses one CTE so the count
is self-consistent. Deliberately not replicating that Python bug.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires the validated item-processor into the ingestion endpoints, writing to an
isolated inventory-go-db (never production):
- schema.go: faithful 7-table replica of inventory-service/database.py.
- ingest.go: /process-inventory (full replace), POST/DELETE single item, with the
exact delete-then-insert flow, dynamic INSERT builder (quotes reserved "unique"),
spell union (is_active), and item_raw_data verbatim. enhancements always inserts.
- compose: isolated inventory-go-db (postgres:14, 127.0.0.1:5435) + read-write
inventory-go-shadow (:8773) that owns it; schema init on boot.
Validated by ingesting a recently-ingested character's items (from production's
original_json) into the shadow DB and diffing vs production: byte-identical —
items 243, combat 243, enhancements 243, ratings 6, requirements 19, spells 52
all match; 0 per-column mismatches across 243 items.
Finding: older production normalized rows can be STALE (predate the code reading
Decal keys 218103832/218103835); Go matches the CURRENT Python code, so validate
ingestion against recently-ingested characters.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports the core item processing: raw item JSON -> normalized columns for all 7
tables, with the exact per-table sentinel->NULL rules, material/item_set string
translation, the Spells/ActiveSpells union (is_active), and compute_base_values
(the spell_effects buff-reversal for base_armor_level/base_max_damage/
base_attack_bonus/etc., with the data embedded and the 167772170-vs-167772172
attack-bonus id discrepancy preserved). loadEnums now also loads MaterialType.
A loopback POST /debug/process returns the normalized columns for validation.
Validated against production's STORED rows (read-only, no writes): 0 mismatches
across 200 items for every sampled column of items, item_enhancements (incl.
translated material + set), item_combat_stats (incl. base_* values), and
item_ratings.
This unlocks ingestion (the processor produces the rows) and the remaining
search-response enrichment (spells/weapon/mana from the same extractor).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loads the ObjectClass enum map and adds object_class_name via translate_object_class,
including the context-aware Gem(11) classification (crystal/mana stone/gem/aetheria
by item name, using the original name before the material prefix). The rare
aetheria-by-IntValues path is documented as not reproduced (needs original_json).
Validated vs Python: 0 mismatches over 600 rows (3 queries incl. a 'crystal' text
search that exercises the gem context) for object_class_name, name, material_name,
item_set_name, value.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
enrichRows now applies the material-name prefix to name (material is already a
translated string in the DB), sets material_name + original_name, and resolves
item_set_name via the AttributeSetInfo enum (fallback "Set {id}").
Validated vs Python position-by-position: 0 mismatches across 60 armor + 60
jewelry rows for name, material_name, item_set_name, original_name, value,
object_class. Sample names match exactly (e.g. "Gold Alduressa Coat").
Remaining enrichment slices: object_class_name (gem context), spells/spell_names
(needs the spells enum map), slot_name (sophisticated), weapon damage/speed/mana,
rating gear-total fallbacks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the full computed_slot_name CASE (EquippableSlots decode, jewelry by
wielded-location, weapons, cloak) and the remaining SQL filters: weapon_type
(skill-id EXISTS), slot_names (per-slot OR clauses), item_set/item_sets
(translate_equipment_set_id, bug-for-bug).
Validated vs Python (total_count EXACT): weapon_type heavy/bow/caster (473/138/
474), slot_names ring/neck/cloak (1286/1428/220), item_set 13 (526). The
computed_slot_name VALUES match exactly (slot distribution identical: Head 721,
Hands 458, Feet 403, Chest 376, ...).
Two documented edge-case discrepancies, both Python main-vs-count CTE
inconsistencies (Python's count query uses a SIMPLIFIED slot CASE where armor ->
'Armor', so its own total_count disagrees with its item list): slot_names with
armor slot names, and sort_by=slot_name empty-string ordering. Our consistent
single-CASE implementation is arguably more correct; reconcile to Python's count
CTE later if strict parity on those is required.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports the search CTE (items_with_slots: combat/req/enh joins + the rating
extractions from item_raw_data.int_values JSONB via GREATEST/COALESCE, coverage
mask, computed_spell_names), the SQL filters, sort mapping, pagination, and the
DISTINCT count query. Returns each row's direct DB columns + computed booleans
(is_equipped/bonded/attuned/rare, condition_percent).
Validated vs the Python service on the production DB: total_count EXACT across
13 filter combinations (armor/jewelry/min_armor/min_damage/text/material/
min_value/is_rare/rating/equipped/character/workmanship), and 50-row alignment
with 0 direct-column mismatches (same SQL sort order, same rows).
Deferred to later slices: deep per-row enrichment (extract_item_properties:
material_name/spells/slot_name/object_class_name/...), and the enum-dependent
filters (has_spell/spell_contains/legendary_cantrips, slot_names, item_set,
weapon_type, underwear).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of the inventory-service port, running in parallel READ-ONLY against
the production inventory_db (never written):
- main.go/store.go: pgx pool (forced read-only), enum-DB loader extracting
AttributeSetInfo for set-name resolution, /health, /sets/list, /characters/list.
- Dockerfile + compose service inventory-go (127.0.0.1:8772, enum JSON mounted).
Validated vs the Python service on the same DB: /characters/list 167 chars exact
counts; /sets/list 76 sets EXACT match (ids, names, counts).
Remaining (large): /search/items (40+ filters + enrich_db_item), inventory
fetch, item-processing ingestion (extract_item_properties), and the suitbuilder
solver.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- share.go: cross-machine vital sharing (share_subscribe/unsubscribe/share_*),
faithful port of the peer-state snapshot + plugin fan-out + /vital-sharing/peers.
The last ingest handler — the Go tracker now handles every plugin event type.
- shadow consumer: drop the outbound keepalive ping (the firehose is never idle)
and tighten the read-deadline watchdog to 12s for faster reconnect after the
upstream's periodic eviction (full-firehose browser clients get evicted ~every
90s; the watchdog recovers it, ~90% duty cycle). Production-bound /ws/position
is unaffected (plugins connect to us; no eviction).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the Go tracker as a cutover-ready drop-in:
- wslive.go: browser broadcast hub with per-client subscribe filters (nil=all),
request_dungeon_map replies, and command routing; auth = internal-trust or
session cookie. The ingestor broadcasts every handled event to it.
- wsposition.go: plugin ingest server with X-Plugin-Secret/SHARED_SECRET auth
(constant-time, fails closed, legacy fallback), register -> plugin_conns, and
dispatch into the shared Ingestor. plugin registry for backend->plugin commands.
- main.go: statusRecorder.Unwrap() so coder/websocket can hijack through the
logging middleware (WS handshakes failed without it); /ws/ bypasses HTTP auth.
Shadow consumer robustness (the harness was being evicted under the full
firehose): decouple socket read from processing — the read loop only copies raw
frames to a queue; a worker unmarshals + dispatches. JSON parsing in the read
loop was slowing it enough that Python's broadcast send errored and evicted us
(Read then blocked forever). Added a 25s read-deadline watchdog to self-heal.
Validated live: shadow /live online = 73 = production; telemetry sustained ~12/s,
0 drops, no eviction; and the shadow's /ws/live re-broadcast stream is IDENTICAL
to production's (TOTAL 2150=2150, every event type exact).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports main.py's _combat_session_delta / _combat_merge_into_lifetime (incl. the
documented "offense/defense use latest, additively" quirk) and the combat_stats
handler (session delta -> DB-backed lifetime merge -> delete-then-insert of
combat_stats + combat_stats_sessions). Read handlers gain the live combat
overlay (union live + DB), like Python.
Validation:
- combat.go `combat-merge` CLI folds snapshots through the accumulator; diffed
against the Python functions on identical input -> byte-IDENTICAL.
- combat_test.go golden test runs in the build (go test now part of the tracker
Dockerfile).
- Live: 40 combat lifetime rows + 40 session snapshots + rare_events flowing in
dereth_go via the shadow consumer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>