DISCOVERY_SEC now defaults to 2s (the existing hard floor) so the
matchmaker directory is polled 2.5x more often, shrinking the window in
which a room can be created and fill/unlist before we ever list it. One
cheap GET per cycle, no game account; SM_SCAN_DISCOVERY_SEC still overrides.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 days ago
Room scan: drop the visit-run log, add check-ticks to the stays timeline
The visits table (per-run outcome/account/listed/roster) was noise: the stays
table already carries who-was-where-when. Remove it end to end -- scanner stops
building/pushing visit runs, the Space drops the table and all ingest/retention/
stats/read references, and the frontend drops the "Visit log" section. The room's
own visits-count + last_visit_at aggregates stay (they feed t_end and the
"last checked" line). The stays timeline gains a faint "checked" tick row derived
from the stay observation timestamps -- no extra storage. The Space ignores any
visits array an older scanner still sends, so re-copying the scanner to SP7 is
optional.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Room scan history: bind power/gold as REAL so huge values can't abort the delta
Player power/gold in high-tier worlds exceed SQLite's signed 64-bit INTEGER
range (>= ~9.2e18). Binding such a Python int raised OverflowError, which the
/ingest-rooms-history handler turned into a generic 503 and rolled back the
whole delta transaction, so the history DB never filled. Coerce power/gold and
stay pow_from/pow_to to float (REAL columns already), and clamp out-of-int64
level values.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rooms tab: default sort to Highest DPS, drop the min-DPS filter box
The DPS sort is what you want to see on load, so it is now the default and the
dropdown reflects it. The separate min-DPS filter input is removed (sorting by DPS
covers it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rooms tab: per-room DPS (compute, show, sort, filter); drop stale full-room note
- Scanner: capture each roster player's active sword + shield (type + level) in the
live snapshot, so the Rooms tab can work out DPS. Archived rooms keep storing no gear.
- Rooms tab: compute each player's normal DPS (damage per hit × hits per second, the
Formulas-tab definition) from the live roster's sword/shield/power/level, reusing the
page's own combat maps so it matches Account lookup. Shows Room DPS (sum of players)
as its own column, per-player DPS in the room detail, a "Highest DPS" sort and a
min-DPS filter. A player whose gear can't be resolved (archived rooms, or an item not
yet in the catalog) is left out, and a room with no resolvable gear shows "—".
- Manual lookup: remove the "only works while the room is not full" warning — disguised
staff accounts can join full rooms now, so it is no longer true.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Room scanner: space out logins + treat HTTP 429 as transient
Several accounts logged in back-to-back at startup, so the loadbalancer
rate-limited accounts 2+ (HTTP 429). Worse, any non-credential login error
(a 429 included) was turned into a 5-minute lockout, so a transient rate
limit sidelined the account for minutes.
Now logins are serialised across all accounts and spaced >= LOGIN_MIN_GAP_MS
(default 2s) apart, from any call site (startup or a token refresh mid-visit).
A 429 is recognised as transient: it is retried honouring Retry-After (else a
short exponential backoff), and if retries run out it gets only a short
cooldown (LOGIN_429_MAX_MS, default 30s) -- never the 5-minute ban, which now
stays reserved for genuine wrong-credential failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Room scan: SQLite history DB on the Space, delta-only scanner pushes
Space side (app.py): replace the part-based archive store with a normalized
SQLite history DB on /data (players, stays, visit runs, closed rooms). WAL,
a read-only reader pool, trigger-maintained counters, incremental auto_vacuum,
90-day retention keyed on the scanner clock, and a DB-size guard. The live
snapshot is served untouched (gzip passthrough, ETag/304). New endpoints:
/ingest-rooms-history and /room-scan/{archive,search,room,player,db-stats}.
Scanner (room_lookup/room_scanner.js): push only deltas. Identity resolution
(account id > session map > 24h name->id), continuous stays, visit runs,
per-day timelines and a "seen with" graph across all worlds; idempotent,
order-independent upserts with a merge table for replay/name reuse. Local
backup log (history_log/) for --replay, state v2 with one-time v1 migration.
Rooms tab (static/room_scan_tab.js): archive/search/room/player views over
the new endpoints; index.html cache-bust bump.
Co-Authored-By: Claude Opus 4.8 <noreply@anth…
5 days ago
Room scanner: several staff accounts share the visits; slim archive
SM_SCAN_USER_2/SM_SCAN_PASS_2 ... _9 add accounts. Each one logs in,
checks (and if needed sets) its own disguise in its own check room and
visits rooms in its own loop; they share discovery, codes and the
store, and never visit the same room at once. A rate limit pauses all
(same IP), a manual lookup on the Space only pauses account 1. Other
scanner accounts never count as players in a roster.
Closed rooms are slimmed (no visit log or per-player stats) and pushed
separately every 10 min once the Space supports it; the state file is
written at most every 20 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Room scan store: closed-room archive as its own push; per-account status
~2300 rooms close per day, so the 7-day archive reaches ~16k rooms. The
store is now kept in two parts: "main" (active rooms, every push) and
"archive" (?part=archive, slimmed, every ~10 min), merged lazily on
read; the answer carries "parts": true so the scanner knows it may
split. Active rooms are capped separately from the archive and the
archive is ordered newest first, so an old single-list push over 6000
rooms no longer cuts off the newest rooms.
The Rooms tab shows each scanner account (disguised or paused, with
the reason) and every room an account is inside right now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 days ago
Room scanner: no more hangs (leave/HTTP limits, watchdog)
Seen live: the visit loop hung for 17 min inside a room because
room.leave(true) waits forever for a server confirmation (e.g. when the
socket already closed). Now:
- leave waits 5 s, then terminates the socket
- every HTTP call (login, matchmake, codes, staff seat, push) has a timeout
- watchdog: any step running > 5 min exits the process so
start_scanner.bat restarts it (state file keeps all rooms)
- the Space's manual-lookup yield is capped at 60 s (wrong PC clock)
- every 3rd visit failure in a row forces a fresh login (token expiry
with an unrecognised error text)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rooms tab: show the count seen inside (8/8) for hidden rooms; lazy store
Full rooms drop out of the public list at 7/8. When the scanner counted
the players inside after that (staff join), the list now shows that
count with a check mark instead of the list's last 7/8 (also for the
over-capacity counter bug), and the detail view says where it came from.
/ingest-rooms no longer parses and re-writes the whole store on every
45 s push (json.dump of a week of history took >1.5 s per push): it
keeps the gzip body, writes it to /data at most every 5 min, and
/room-scan parses it only when someone looks. /room-scan responses skip
FastAPI's jsonable_encoder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Room scanner: drop the 30-min check room, auto-send /disguise when off
Every real visit already re-reads the own flags, so the extra check room
is only used before the first visit and while paused. If the disguise
flag is explicitly off and the account is staff, the check room sends
"/disguise" through the in-game chat (at most every 30 min; never while
the flag is on or missing, in case the command toggles).
SM_SCAN_AUTO_DISGUISE=0 disables it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Room scanner: disguise guard before any room visit
The scanner checks its own isDisguised/isMod flags (room state only)
before visiting rooms: a probe in a fresh world_1 room at start, every
30 min, and on every visit. If the flag is off or missing (e.g. after a
game update) it stops visiting rooms (fail closed), retries every 5 min,
and reports the reason to /ingest-rooms. The Rooms tab shows the status.
SM_SCAN_REQUIRE_DISGUISE=0 disables the guard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Secure mod access: one-time invite links, per-device sessions, device list
The shared SM_MOD_KEY used to travel in ?mod=<key> links and sat raw in a 30-day
cookie, so one leaked link made anyone a mod. Now:
- The owner creates a one-time invite per mod at /mod/admin. Opening it shows a
confirm page; only the button (POST) redeems it, so link previews can't burn it.
A used link is worthless, unused links expire (1 h / 24 h / 7 days).
- Each device gets a random session cookie (HttpOnly, SameSite=Lax, Secure on the
Space); only its SHA-256 is stored in /data/mod_access.json. Sessions unused for
30 days expire.
- /mod/admin lists every device (name, browser/OS, network, added, last seen), can
remove single devices or all others, cancel unused invites, and shows a security
log (new devices, a login used from a new network, used/invalid links, wrong
owner keys).
- Owner login via SM_OWNER_KEY in a POST form (/mod/admin?login only, rate-limited
per IP and globally, 404 for everyone else).
- No secret in URLs anymore: ?mod=<key> and ?mod_key= grant n…
Scanner: drop rooms the game confirms as closed (archive for search)
A join answers 'room "x" not found' for a room that no longer exists and
'room "x" is locked' for a full one (verified live). Two "not found" answers in a
row while the room is also absent from the public list close it: rooms with a
captured roster move to a 7-day archive (still found by the player search), rooms
without one are dropped. "Locked", timeouts and other errors never close a room.
An archived room that shows up in the list again is reactivated.
/room-scan hides archived rooms by default (?archived=1 / all); the Room List gets
a "Closed (archive)" filter and marks closed rooms in search results.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Room List UI for the room-roster scanner (mod-only, local review)
- static/room_scan_tab.js: W10/W11 room list in the (renamed) "Rooms" tab:
copyable codes, reported vs captured occupancy, status per room, hidden rooms
kept and sorted down by time, filters (world/status/empty), search by player
incl. earlier rosters ("was here"), room detail with roster history + visit log,
player click opens the live Account Info lookup, scanner health line.
- /room-scan: list omits history unless ?history=1, ?room=<id> for one room,
?player=<name> also matches earlier rosters, server_time for clock skew.
- Manual code lookup stays below the list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Serve the colyseus client and room_tab.js to mods only
They are only used by the (mod-only) Room Lookup tab, so non-mods no longer
download the 238 KB colyseus browser client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- POST /ingest-rooms (ingest-key, gzip) + GET /room-scan (mod/ingest-gated)
receive and serve the W10/W11 room store pushed by the SP7 scanner.
- room_lookup/room_scanner.js: the SP7 scanner (directory discovery, immediate
share-code resolution, serialized roster visits with StaffSeatJoin fallback,
backoff, roster history). Runs off-Space; kept here as the canonical source.
- Manual /room-lookup records its join time so the scanner yields the account.
- Room Lookup tab button + panel are now mod-only (MOD region); tab highlighting
uses data-tab instead of button position so it stays correct without the button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 days ago
Restrict room-lookup to moderators + add audit logging
The shared lookup account (joins real game rooms server-side) was driveable by
anyone via the public /room-lookup endpoint — no auth, no rate limit — so a
scripted caller could make the account join a target's room repeatedly. Gate the
endpoint to mods (sm_mod cookie) or the ingest key. A denied request returns the
SAME generic 502 "Room lookup failed." as a full/expired room, so it never
advertises that a moderator tier exists.
New mod-only audit view /room-lookup-log records source IP, code, result, origin
and whether we actually joined (denied attempts tagged "forbidden") — so
endpoint-abuse (a flood here) can be told apart from a credential leak (joins
that bypass this server entirely).
UI hiding of the room tab for non-mods ships separately with the UX batch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The game bundle switched back to obfuscator.io's base64 string-array
encoding: items are now `{'type':0x0,'name':af(0x..),...}` with a rotated,
base64-encoded string table and a decoder `function b(c,d){c=c-OFF;const
e=a();...}`. The old `_build_string_decoder` entry regex didn't match this
shape and silently fell back to the identity decoder, so every item array
still contained undecoded `af(0x..)` calls, hex numbers and arithmetic and
failed to parse -> "Missing official item arrays: boots, chestplates, ...".
- Detect the new decoder shape and reimplement the base64 string decoder
(custom-alphabet base64 -> utf-8), with startup table rotation replicated
from the decoded parseInt checksum; derive the rotation alias from the
checksum expression (the local `ad`), not the global `af`.
- `_parse_object_array`: evaluate hex numbers, arithmetic and JS booleans via
a restricted ast evaluator; transform JS->Python on the still-encoded text
so string decoding can't be corrupted.
Dr…
13 days ago
Power-series: collapse redundant identical points across all sources at read
The exact series is already deduped on record, but the Discord import (src=3)
and the hourly/daily snapshot backfill were NOT — so flat stretches showed many
identical consecutive points on the graph (live: oettaM 83, ZywOo 172 consecutive
duplicates, mostly src=3). Apply _sm_pseries_collapse_identical to the final
merged list: it drops only strictly-inner identical points and keeps each flat
run's first+last (so a flat segment still renders as a line, not a diagonal).
Declutters the graph and shrinks the response (now that the endpoint is public).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 days ago
Power-series history: make public (un-gate from MOD-only)
Storage verified sustainable: per-account points hard-capped at 20,000 with
dedup (<=1/15min, 24h heartbeat); on-disk store is gzipped (live: 12,347
accounts / 149k points / 3.2 MB). Going public does not change what is stored
(same data collected) and the read path is cheap (in-memory series + O(snapshot
files) backfill lookups, no file parse per request).
- /power-series: remove the mod 404 gate -> public (Power/Level/Gold are public
leaderboard values already shown in the lookup). mod_key kept as no-op param.
- index.html: move the power-series pieces out of the MOD:START/END regions so
they are served to everyone (ai-power-panel container, loadPowerSeries/
powerSeriesHide calls, power_series.js include). Other mod features untouched.
Verified as a non-mod locally: script loads, endpoint 200, graph renders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VIP now resolves correctly (comma-separated VIPLeaderboard state, 364/365 names).
Keep the concise "[vip] ok: stored N" line; only dump the raw sample when the
parsed count is far below VIPCount, so steady-state logs don't carry the name list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VIP: retry in ~10min on refresh failure (self-heal transient join errors)
Join can transiently ServerError (4500); previously the next try was 6h away.
Now a failed/empty refresh retries after SM_VIP_RETRY_SECONDS (10min); a success
or held-good-state waits the full 6h.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VIP: broaden name delimiters + log raw sample to diagnose parse
stored=1 vs count=361 means the VIPLeaderboard value is not newline-separated.
Split on newlines/commas/semicolons/pipes/tabs, and log a 600-char raw sample
(+ type) so the actual delimiter/structure is visible if still off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VIP: log stored-names vs count on successful refresh
Surfaces whether the VIPLeaderboard state field carries all names or a top-N
display, and gives ongoing monitoring of the 6h refresh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VIP: read VIPLeaderboard/VIPCount from world_1 state
world_1's room state exposes VIPLeaderboard + VIPCount as schema fields (world_10
does not). The sidecar now reads those state values (message capture kept as a
fallback for worlds that only broadcast), and the listen window drops back to ~12s
since state syncs within seconds of join.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
world_10 (a gameplay shard) never broadcasts VIPLeaderboard; the VIP statue/
leaderboard lives in the main world. Point the VIP lookup at world_1 (still
SM_VIP_WORLD-overridable).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VIP sidecar: widen listen window to catch the broadcast
The VIPLeaderboard/VIPCount broadcast does not arrive in the join burst (only the
friend-list/daily-quest/rivalBoss messages do) — it comes on a longer periodic/
on-change cadence. Listen 75s (SM_VIP_WAIT_MS) with its own subprocess timeout
(SM_VIP_TIMEOUT) instead of the 8s rival window.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
VIP sidecar: read server-pushed room messages, not state
The in-game VIP list is delivered as room messages (onMessage 'VIPLeaderboard'
+ 'VIPCount' in NetworkManager, server-broadcast, no client request), not as
state fields — so the state-reading sidecar found nothing ("no VIPLeaderboard/
names in state"). Rewrite to listen on a wildcard message handler, match the VIP
type case-insensitively (the client assembles the name from obfuscated fragments),
and extract names/count from the payload. Listen ~22s to catch periodic broadcasts.
On miss, report the world's actual message types + state keys for diagnosis.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
forge_api.py:
- Split Nightmare Arena into its own action (separate legendary counts, no rate)
- Dwarf-Multi: /player & /player-detail accept names= to union multiple accounts
- Arena/Nightmare leaderboard: "Most legendarys" sort; luckiest/unluckiest now
legendary-per-attempt ratio (no floor, >=1 always beats 0)
forge_stats.js:
- Sub-tabs restructured to Leaderboard (default) + Totals; Item Tables moved under
Totals as "Dwarf" (first); Combine Accounts moved into the lookup
- Combined-account dwarf items are clickable for level distribution
- Nightmare shows legendarys like arena; sort options gated per drop action
app.py:
- VIP backend + Space-side sidecar (vip_lookup.js reads world_10 VIPLeaderboard via
authenticated lookup account), /vip-status + /ingest-vip
index.html:
- VIP badge (no star), Thorns/Freeze formula cards, forge_stats.js cache bump
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client (static/analytics.js): smTrack('cat:name') sammelt Events und schickt
sie gebuendelt per navigator.sendBeacon('/track') -> feuern-und-vergessen,
blockiert nie den Render-/Interaktionspfad. Nichts Personenbezogenes (kein
Name/IP/UA). Hooks: Tab-Wechsel, Optimize (market/nomarket), Leaderboard-Suche,
Account-/Room-/Clan-/Forge-Lookup, welche Forge-Stat-Details geoeffnet werden.
Server (app.py): /track zaehlt In-Memory pro UTC-Tag + flusht gedrosselt
(<=1x/60s) als winziges JSON nach /data/usage_stats.json; Kategorie-Allowlist
+ Zeichensatz/Laenge + Tages-Kardinalitaetsdeckel + 4KB-Body-Cap als
Flood-Schutz. /usage-admin (MOD-only, wie power-series-admin): stats/raw/csv/
clear. Heisse Pfade (Leaderboard/Stream/Lookups) unberuehrt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
forge_api: periodic forge-DB hot-reload (no restart needed)
The DB connection was cached for the whole process, so a freshly uploaded
forge_stats.db only showed up after a Space restart -> forge/actions could
look stale for a long time. Now _conn() checks every ~30min (FORGE_DB_RELOAD_TTL)
whether the dataset has a new revision and, if so, a background thread downloads
it and atomically swaps the connection + invalidates the DB-derived caches.
No request blocks; the old DB serves until the swap and closes by refcount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lookup: cut live-lookup timeout 5s->3s (kill the ~12s hang)
Measured the lookup latency bimodal: reachable accounts answer GetPlayerInfo
in <0.8s (slowest real responder 2.3s), unreachable ones never answer. With
the old 5s per-phase timeout and 2 room attempts, a dead lookup hung ~12s and
tied up a worker thread the whole time -> also made the leaderboard sluggish.
3s cleanly separates responders; non-responders fall back fast to the cached
leaderboard row, and the 2nd room attempt still covers other world instances.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Badge reads plain "BANNED"; the reason stays available via hover tooltip
and click popover (cursor stays a pointer).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shows WHY a banned account was banned, on top of our own ban detection
(is_banned stays authoritative; the reason only supplements it).
- app.py: /ban-reason endpoint loads ban_reasons.json from the forge HF
dataset (cached, TTL) and matches username + aliases (id-bound, like the
forge/power-series technique), since the public ban-logs channel has no
account ids.
- static/index.html: BANNED badge now shows the reason as a hover tooltip
and a click popover (XSS-escaped), loaded once per username.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
22 days ago
forge stats: bind to account id across renames (aggregate over username+aliases)
Forge/elf/enhance/etc. stats were keyed by player NAME (the compact DB's player_id
is just a name index), so a rename split a player's history. Now the player view
resolves the looked-up name -> the account's full name set (current username +
leaderboard aliases, same resolver the power-series uses) and aggregates
player_action / player_detail / luck across all those local ids. Rename-proof and
id-bound, matching the power/level/gold history. Unknown/not-on-leaderboard names
fall back to single-name (unchanged). app.py injects the resolver into forge_api
(no circular import). Verified on the live compact DB: single lookup unchanged,
two-name aggregation sums exactly, luck+detail OK.
Known follow-up: the /forge/leaderboard ranking tab still ranks per name (a renamed
player can appear twice); grouping the whole board by id is a separate change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The backfill warmer parsed the FULL ~1.6M-account leaderboard snapshot files
(hourly + daily) just to keep the ~10k >=1Qnt accounts, holding the GIL for
seconds each -> the residual hourly/boot freezes. Now each snapshot write also
emits a slim >=1Qnt-only companion (same filename, lb_history_q1 /
lb_history_hourly_q1 dirs); the warmer prefers it and falls back to the full
file only for pre-existing snapshots. ~160x less parse work, full snapshots
untouched for the d1/d7 delta feature.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
power-series: stop timer-rebuilding the name->id index (fixes periodic lookup freezes)
On the 2-vCPU Space, rebuilding the ~1.6M-row name->id index every ~5 min in the
flusher holds the GIL for seconds and froze ALL requests (observed: /health
spiking 0.3s->6-11s, /live-rank timing out at 60s during a rebuild window).
Remove the proactive timer rebuild; the index is now built only lazily on demand
and cached far longer (TTL 5min -> 30min), since name->id changes rarely. Idle
periods no longer trigger freezes and rebuilds during active use are infrequent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Returns exact+discord point counts, power/level span, timespan and sub-1-Qnt
count for one account (by name or player_id). Used to verify the full-history
whitelist landed; mod-key or ingest-key gated like the rest of the endpoint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
power-series: y-axis floor at 0 + full history (incl. <1 Qnt) for whitelisted names
- Chart: clamp the y-domain minimum to 0 (power/level/gold can never be negative;
the 8% bottom padding produced labels like "-612qnt").
- Backend: SM_POWER_SERIES_FULL_NAMES (default "oettaM") bypasses the 1e18 floor
in the recorder and the Discord import, so those accounts keep ALL points incl.
sub-1-Qnt history. On name-normalization collision (a tiny name-twin resolving
to the same id as the real account) the richer series (more points) wins instead
of last-write, so a small twin can't overwrite the real history.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
25 days ago
power-series: keep name->id index warm in the flusher (no request pays the rebuild)
The shared index rebuilds on its 5min TTL; previously whichever request first
found it expired paid the multi-second O(N) build synchronously (the remaining
source of inconsistent load times). Refresh it proactively in the flusher
(runs every 120s < 300s TTL), but only once it has been built at least once, so
an idle Space burns no CPU.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
power-series: periodic (daily) dedup in the flusher to bound idle-account growth
The heartbeat appends 1 identical point/day per idle account; without periodic
cleanup the series would grow unbounded. Collapsing past identical consecutive
points once a day (also once ~25s after each boot) keeps idle accounts at ~2
points, so storage stays flat over years. One-time cleanup already ran live:
141,855 -> 52,951 points (-63%).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
power-series: resolve name->id via shared cached index (kills per-lookup O(N) scan)
_sm_pseries_resolve_key did a full ~1.7M-row leaderboard scan on every id-cache
miss (10min TTL), which is the main cause of the inconsistent/slow history loads.
Route it through the shared name->id index (_sm_pseries_lvlidx_cached, built once
per 5min) so every resolution is an O(1) dict lookup after one build. The hot
recorder path benefits too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
power-series-admin: also accept the ingest key (operator tooling), not only the mod key
Lets the local importer/diagnostics query stats and trigger dedup/clear_discord
with the ingest key it already holds, without exposing the mod key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
power-series: Discord bot-stats history import (src=3), past-dedup + admin/stats endpoint
- /power-series-import-discord (POST, ingest-key gated): bulk-imports historical
player stats parsed from the Discord #bot-commands channel as a NEW uncertain
point source (src=3), bound to account id (name->id via leaderboard). Strict
validation: if ANY historical level for an account exceeds its current level
(name maps to a different/reset account) the ENTIRE account's messages are
discarded. Stored in a separate /data/power_series_discord.json (own lock),
so the hot exact recorder is never touched and re-imports are idempotent.
- /power-series read now merges these (precedence: exact > discord > backfill)
and reports discord_count.
- /power-series-admin (mod-gated): action=stats (sizes/distribution), dedup
(collapse past identical consecutive points in the exact series, keeping run
endpoints), clear_discord (clean re-import).
- Cached the O(N) leaderboard level-index across import chunks (5min TTL).
- Frontend: src=3 rendered as a distinct amber diamond with legen…
Power-series: skip unchanged points, 15-min min gap
- Min gap per account 1 min -> 15 min (SM_POWER_SERIES_MIN_GAP 60 -> 900).
- Skip a point whose level/power/gold/sword/shield are identical to the
account's last point, unless a heartbeat interval (default 1 day,
SM_POWER_SERIES_HEARTBEAT) has passed so flat stretches still extend the
line to ~now. Removes the redundant dense clusters from repeated lookups
of an unchanged account; the series now stores real changes.
Verified: a duplicate push is skipped; power/level/gear changes are kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Power-series: keep tooltip on-screen for top/edge points
The hover tooltip is anchored above the point, so for a point near the
top of the chart it ran off the top edge and clipped the date line. Now
it flips below the point when the point sits in the upper part of the
chart, and its horizontal center is clamped so the wide date line never
runs off the left/right edge of the panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Power-series: fix level dips, gear in snapshots, smaller points
- Level dips: level can never decrease, so a point with a lower level is
stale/wrong (e.g. an hourly snapshot taken right after a restart, before
ingest refreshes A's leaderboard -> old values). The recorder now skips a
point whose level is below the account's last point, and the endpoint
filters any level regression out of the merged series. Removes the dips.
- Snapshots now carry the active sword/shield (type+level): the leaderboard
row stores gear from the scan/lookup, and hourly/daily snapshots append it
as [lvl,pow,gold,sword_type,sword_level,shield_type,shield_level]. Accounts
without gear stay the old 3-element shape (no file bloat); the d1/d7 delta
readers only use indices 0-2, so they're unaffected. Backfill reads the
gear -> coarse snapshot points now show the weapon too.
- Chart data points rendered smaller (r 2.8 -> 1.7).
Verified: stale low-level point dropped (recorder + endpoint); a 7-element
snapshot yields gear in the backfill, a 3-element one yields…