From 8e01cdee222c5f54591eb79600f482986a123b82 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 24 Sep 2026 03:16:52 +1200 Subject: [PATCH] Separate looted currency and preserve growing realm state in atomic chunks --- ale/azerothai_outcomes.lua | 11 ++++++++++- app/api/realm-insights/route.ts | 2 +- app/globals.css | 4 +++- app/realm-diagnostics.tsx | 3 ++- core/realm-outcomes.ts | 6 ++++-- core/state-chunks.ts | 2 ++ core/store.ts | 21 +++++++++++++++------ docs/bot-conversations.md | 2 +- docs/realm-insights.md | 2 ++ scripts/realm-backup.py | 7 ++++++- scripts/realm-evidence.py | 5 +++-- scripts/test-core.mjs | 2 +- tests/realm-outcomes.test.cjs | 4 ++++ 13 files changed, 54 insertions(+), 17 deletions(-) create mode 100644 core/state-chunks.ts diff --git a/ale/azerothai_outcomes.lua b/ale/azerothai_outcomes.lua index 2cca248..02bb78f 100644 --- a/ale/azerothai_outcomes.lua +++ b/ale/azerothai_outcomes.lua @@ -4,6 +4,12 @@ AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_outcomes ( abandoned INT UNSIGNED NOT NULL DEFAULT 0,loot_events INT UNSIGNED NOT NULL DEFAULT 0, loot_items INT UNSIGNED NOT NULL DEFAULT 0,party_kills INT UNSIGNED NOT NULL DEFAULT 0, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)]]) +AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_currency ( + guid INT UNSIGNED PRIMARY KEY,looted_copper BIGINT UNSIGNED NOT NULL DEFAULT 0)]]) +local money={} +RegisterPlayerEvent(37,function(_,p,amount) + if p:IsPlayerbot() and amount and amount>0 then local id=p:GetGUIDLow();money[id]=(money[id] or 0)+amount end +end) local pending={} local function add(p,key,amount) if not p:IsPlayerbot() then return end @@ -28,7 +34,10 @@ local function flush() for guid,r in pairs(pending) do AuthDBExecute('INSERT INTO azerothai_outcomes(guid,quests,abandoned,loot_events,loot_items,party_kills) VALUES ('..guid..','..r.quests..','..r.abandoned..','..r.loot_events..','..r.loot_items..','..r.party_kills..') ON DUPLICATE KEY UPDATE quests=quests+VALUES(quests),abandoned=abandoned+VALUES(abandoned),loot_events=loot_events+VALUES(loot_events),loot_items=loot_items+VALUES(loot_items),party_kills=party_kills+VALUES(party_kills),updated_at=NOW()') end - pending={} + for guid,amount in pairs(money) do + AuthDBExecute('INSERT INTO azerothai_currency(guid,looted_copper) VALUES ('..guid..','..amount..') ON DUPLICATE KEY UPDATE looted_copper=looted_copper+VALUES(looted_copper)') + end + money={};pending={} end CreateLuaEvent(flush,60000,0) -- Native profiler JSON is generated every five minutes; enabling it is an operator setting. diff --git a/app/api/realm-insights/route.ts b/app/api/realm-insights/route.ts index af0f064..5da087d 100644 --- a/app/api/realm-insights/route.ts +++ b/app/api/realm-insights/route.ts @@ -6,7 +6,7 @@ import type {RuntimeConfig} from '@/core/types'; import {observeInsights,activityReason,reportWindow,summarize,DAY,type Cursor,type Evidence} from '@/core/realm-insights'; const recovery=z.object({id:z.number().int().positive(),guid:z.number().int().positive(),name:z.string().max(64),at:z.number().finite(),detail:z.string().max(255),status:z.enum(['pending','resumed','no_progress','unverified']).optional(),checkedAt:z.number().nonnegative().optional(),result:z.string().max(255).optional()}); const count=z.number().int().nonnegative(); -const outcome=z.object({guid:count,name:z.string().max(64),quests:count,abandoned:count,lootEvents:count,lootItems:count,partyKills:count,rewarded:count}); +const outcome=z.object({guid:count,name:z.string().max(64),quests:count,abandoned:count,lootEvents:count,lootItems:count,partyKills:count,rewarded:count,lootedCopper:count.default(0)}); const measurement=z.number().finite().nonnegative(); const performance=z.object({at:measurement,enabled:z.boolean(),ticks:measurement,averageTickMs:measurement,rows:z.array(z.object({category:z.string().max(40),name:z.string().max(160),calls:measurement,totalMs:measurement,averageMs:measurement,maxMs:measurement,perTickMs:measurement})).max(20)}); async function ensure(){await env.DB!.batch([ diff --git a/app/globals.css b/app/globals.css index f037c64..7f7ad56 100644 --- a/app/globals.css +++ b/app/globals.css @@ -8,4 +8,6 @@ *,::before,::after{border-color:var(--border)} .readonly-fieldset{border:0;padding:0;margin:0;min-width:0}fieldset[disabled] button,fieldset[disabled] input,fieldset[disabled] textarea{cursor:default} -.diagnostics{--diag-green:#7de3b4;--diag-amber:#f1c47a;--diag-red:#ff9aa9}.diag-hero{display:flex;justify-content:space-between;align-items:center;gap:22px;border:1px solid #35475a;border-radius:14px;padding:28px;background:radial-gradient(ellipse at right top,#244c514d,transparent 70%),linear-gradient(120deg,#1b2a3d,#101b28)}.diag-hero h2{font:28px Georgia,serif;line-height:1.25}.diag-hero p{color:#b0bfd1;font-size:14px;line-height:1.6;margin-top:10px;max-width:640px}.diag-status{display:flex;align-items:center;gap:8px;white-space:nowrap;font-size:12px;border:1px solid currentColor;border-radius:30px;padding:8px 12px}.diag-status>span{height:6px;width:6px;border-radius:100%;background:currentColor}.diagnostics .good{color:var(--diag-green)}.diagnostics .warning{color:var(--diag-amber)}.diagnostics .critical{color:var(--diag-red)}.diag-updated{display:flex;align-items:center;gap:7px;margin:14px 0 24px;font-size:12px;color:#91a5be;line-height:1.6}.diag-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:24px}.diag-metric{padding:20px;background:#111e2d;border:1px solid #2b3d50;border-radius:12px;display:flex;flex-direction:column;gap:12px;min-width:0}.diag-metric-label{display:flex;align-items:center;gap:8px;font-size:13px;color:#a9bcd4}.diag-metric>strong{font-size:30px;font-weight:500;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.diag-metric>small{font-size:11px;line-height:1.6;color:#91a5be}.diag-section-title{display:flex;align-items:center;justify-content:space-between;margin:24px 0 14px;gap:10px}.diag-section-title .fineprint{margin:0}.diag-alerts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.diag-alert{display:flex;align-items:flex-start;gap:14px;text-align:left;padding:21px;background:#121f2d;border:1px solid #3d4652;border-radius:10px;transition:background .15s,border-color .15s}.diag-alert:hover{background:#1a2b3d;border-color:#7991ac}.diag-alert>div{flex:1;min-width:0}.diag-alert h3{color:#e1e9f4;margin:4px 0 8px}.diag-alert p,.diag-recovery p{color:#aebed1;font-size:13px;line-height:1.65}.diag-alert small{display:block;color:#9dc0f4;margin-top:12px}.diag-label{text-transform:uppercase;letter-spacing:1.5px;font-size:10px}.diag-links{display:flex;gap:12px;margin-top:24px;flex-wrap:wrap}.diag-links button{display:flex;gap:20px;align-items:center;padding:12px 16px;background:#17273a;border:1px solid #344a65;border-radius:8px;font-size:13px}.diag-clear{text-align:center;padding:40px 20px;background:#11251f55;border:1px solid #284c40;border-radius:12px}.diag-clear svg{margin:0 auto 15px;color:#7de3b4}.diag-clear p{color:#a9bacd;margin:10px auto 0;font-size:13px;line-height:1.7;max-width:600px}.diag-note{padding:16px 20px;border:1px solid #2d4057;border-radius:9px;background:#142237;color:#a6bad2;font-size:12px;line-height:1.8;margin-bottom:22px}.diag-toolbar{display:flex;gap:12px;margin:22px 0;flex-wrap:wrap}.diag-search{display:flex;align-items:center;gap:10px;flex:1;min-width:200px;background:#0e1826;border:1px solid #34475e;border-radius:8px;padding:11px 14px;color:#8ba4c4}.diag-search input{width:100%;outline:none;background:transparent;color:#dce5f1;font-size:14px}.diag-toolbar select{background:#142237;border:1px solid #34475e;border-radius:8px;padding:10px 14px;font-size:13px;max-width:100%}.diag-bot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));gap:16px}.diag-bot{padding:20px;border:1px solid #293c52;border-radius:12px;background:linear-gradient(135deg,#142334,#101b29)}.diag-bot-head{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.diag-bot-head>div{flex:1}.diag-bot-head small{font-size:11px;display:block;margin-top:5px}.diag-avatar{display:grid;place-items:center;width:40px;height:44px;background:#28394c;border:1px solid #526079;border-radius:8px;font:24px Georgia,serif;color:#e2c48f}.diag-chip{padding:5px 8px;border:1px solid #3a4b5d;border-radius:30px;font-size:10px;color:#9cafc6;background:#0d1825;white-space:nowrap}.diag-bot-values{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:22px 0}.diag-bot-values strong{display:block;font-size:26px;font-weight:500;font-variant-numeric:tabular-nums}.diag-bot-values small{font-size:10px;display:block;margin-top:6px}.diagnostics details{border-top:1px solid #293c52;padding-top:12px;font-size:12px;color:#aebed1}.diagnostics summary{cursor:pointer;color:#a6c9f9}.diagnostics details p{font-size:12px;line-height:1.7;margin-top:10px}.diag-text-link{display:flex;align-items:center;gap:8px;color:#9fc6ff;margin-top:12px}.diag-timeline{margin-top:24px}.diag-timeline>div{padding:16px 0;border-bottom:1px solid #26384d}.diag-timeline strong{display:block;margin-top:7px}.diag-recovery{padding:20px;border:1px solid #314359;border-radius:10px;background:#121f2d;display:grid;grid-template-columns:1fr auto;gap:14px}.diag-recovery>p,.diag-recovery>details{grid-column:1/-1}.diag-recovery small{display:block;margin-top:7px;font-size:11px}.diag-bars{height:150px;display:flex;align-items:flex-end;gap:4px;border-bottom:1px solid #39556b;background:repeating-linear-gradient(to top,transparent 0,transparent 36px,#233449 37px,#233449 38px)}.diag-bars>div{flex:1;max-width:30px;min-width:1px;background:linear-gradient(to top,#377599,#83d7c4);border-radius:3px 3px 0 0}.diag-table-scroll{overflow:auto;max-height:650px}.diag-table{border-collapse:collapse;width:100%;font-size:12px;font-variant-numeric:tabular-nums}.diag-table th{text-align:left;padding:15px 18px;color:#92a9c4;background:#122033;white-space:nowrap}.diag-table td{padding:15px 18px;border-top:1px solid #26384d;white-space:nowrap}.diag-table td:first-child{white-space:normal;min-width:230px;max-width:400px;overflow-wrap:anywhere}.diag-table small{display:block;font-size:10px;margin-top:5px}.diagnostics button:focus-visible,.diagnostics select:focus-visible,.diagnostics summary:focus-visible,.diag-search:focus-within{outline:2px solid #92bdf9;outline-offset:3px}@media(max-width:1100px){.diag-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.diag-alerts{grid-template-columns:1fr}}@media(max-width:767px){.diag-hero{padding:22px;align-items:flex-start;flex-direction:column}.diag-hero h2{font-size:25px}.diag-metric{padding:15px}.diag-metric>strong{font-size:25px}.diag-metric-label{font-size:12px}.diag-alert{padding:17px}.diag-section-title{align-items:flex-start;flex-direction:column}.diag-table td,.diag-table th{padding:12px}.diag-recovery{grid-template-columns:1fr}.diag-recovery .diag-chip{justify-self:start}.diag-updated{align-items:flex-start}} +.diagnostics{--diag-green:#7de3b4;--diag-amber:#f1c47a;--diag-red:#ff9aa9}.diag-hero{display:flex;justify-content:space-between;align-items:center;gap:22px;border:1px solid #35475a;border-radius:14px;padding:28px;background:radial-gradient(ellipse at right top,#244c514d,transparent 70%),linear-gradient(120deg,#1b2a3d,#101b28)}.diag-hero h2{font:28px Georgia,serif;line-height:1.25}.diag-hero p{color:#b0bfd1;font-size:14px;line-height:1.6;margin-top:10px;max-width:640px}.diag-status{display:flex;align-items:center;gap:8px;white-space:nowrap;font-size:12px;border:1px solid currentColor;border-radius:30px;padding:8px 12px}.diag-status>span{height:6px;width:6px;border-radius:100%;background:currentColor}.diagnostics .good{color:var(--diag-green)}.diagnostics .warning{color:var(--diag-amber)}.diagnostics .critical{color:var(--diag-red)}.diag-updated{display:flex;align-items:center;gap:7px;margin:14px 0 24px;font-size:12px;color:#91a5be;line-height:1.6}.diag-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:24px}.diag-metric{padding:20px;background:#111e2d;border:1px solid #2b3d50;border-radius:12px;display:flex;flex-direction:column;gap:12px;min-width:0}.diag-metric-label{display:flex;align-items:center;gap:8px;font-size:13px;color:#a9bcd4}.diag-metric>strong{font-size:30px;font-weight:500;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.diag-metric>small{font-size:11px;line-height:1.6;color:#91a5be}.diag-section-title{display:flex;align-items:center;justify-content:space-between;margin:24px 0 14px;gap:10px}.diag-section-title .fineprint{margin:0}.diag-alerts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.diag-alert{display:flex;align-items:flex-start;gap:14px;text-align:left;padding:21px;background:#121f2d;border:1px solid #3d4652;border-radius:10px;transition:background .15s,border-color .15s}.diag-alert:hover{background:#1a2b3d;border-color:#7991ac}.diag-alert>div{flex:1;min-width:0}.diag-alert h3{color:#e1e9f4;margin:4px 0 8px}.diag-alert p,.diag-recovery p{color:#aebed1;font-size:13px;line-height:1.65}.diag-alert small{display:block;color:#9dc0f4;margin-top:12px}.diag-label{text-transform:uppercase;letter-spacing:1.5px;font-size:10px}.diag-links{display:flex;gap:12px;margin-top:24px;flex-wrap:wrap}.diag-links button{display:flex;gap:20px;align-items:center;padding:12px 16px;background:#17273a;border:1px solid #344a65;border-radius:8px;font-size:13px}.diag-clear{text-align:center;padding:40px 20px;background:#11251f55;border:1px solid #284c40;border-radius:12px}.diag-clear svg{margin:0 auto 15px;color:#7de3b4}.diag-clear p{color:#a9bacd;margin:10px auto 0;font-size:13px;line-height:1.7;max-width:600px}.diag-note{padding:16px 20px;border:1px solid #2d4057;border-radius:9px;background:#142237;color:#a6bad2;font-size:12px;line-height:1.8;margin-bottom:22px}.diag-toolbar{display:flex;gap:12px;margin:22px 0;flex-wrap:wrap}.diag-search{display:flex;align-items:center;gap:10px;flex:1;min-width:200px;background:#0e1826;border:1px solid #34475e;border-radius:8px;padding:11px 14px;color:#8ba4c4}.diag-search input{width:100%;outline:none;background:transparent;color:#dce5f1;font-size:14px}.diag-toolbar select{background:#142237;border:1px solid #34475e;border-radius:8px;padding:10px 14px;font-size:13px;max-width:100%}.diag-bot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));gap:16px}.diag-bot{padding:20px;border:1px solid #293c52;border-radius:12px;background:linear-gradient(135deg,#142334,#101b29)}.diag-bot-head{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.diag-bot-head>div{flex:1}.diag-bot-head small{font-size:11px;display:block;margin-top:5px}.diag-avatar{display:grid;place-items:center;width:40px;height:44px;background:#28394c;border:1px solid #526079;border-radius:8px;font:24px Georgia,serif;color:#e2c48f}.diag-chip{padding:5px 8px;border:1px solid #3a4b5d;border-radius:30px;font-size:10px;color:#9cafc6;background:#0d1825;white-space:nowrap}.diag-bot-values{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:22px 0}.diag-bot-values strong{display:block;font-size:26px;font-weight:500;font-variant-numeric:tabular-nums}.diag-bot-values small{font-size:10px;display:block;margin-top:6px}.diagnostics details{border-top:1px solid #293c52;padding-top:12px;font-size:12px;color:#aebed1}.diagnostics summary{cursor:pointer;color:#a6c9f9}.diagnostics details p{font-size:12px;line-height:1.7;margin-top:10px}.diag-text-link{display:flex;align-items:center;gap:8px;color:#9fc6ff;margin-top:12px}.diag-timeline{margin-top:24px}.diag-timeline>div{padding:16px 0;border-bottom:1px solid #26384d}.diag-timeline strong{display:block;margin-top:7px}.diag-recovery{padding:20px;border:1px solid #314359;border-radius:10px;background:#121f2d;display:grid;grid-template-columns:1fr auto;gap:14px}.diag-recovery>p,.diag-recovery>details{grid-column:1/-1}.diag-recovery small{display:block;margin-top:7px;font-size:11px}.diag-bars{height:150px;display:flex;align-items:flex-end;gap:4px;border-bottom:1px solid #39556b;background:repeating-linear-gradient(to top,transparent 0,transparent 36px,#233449 37px,#233449 38px)}.diag-bars>div{flex:1;max-width:30px;min-width:1px;background:linear-gradient(to top,#377599,#83d7c4);border-radius:3px 3px 0 0}.diag-table-scroll{overflow:auto;max-height:650px}.diag-table{border-collapse:collapse;width:100%;font-size:12px;font-variant-numeric:tabular-nums}.diag-table th{text-align:left;padding:15px 18px;color:#92a9c4;background:#122033;white-space:nowrap}.diag-table td{padding:15px 18px;border-top:1px solid #26384d;white-space:nowrap}.diag-table td:first-child{white-space:normal;min-width:230px;max-width:400px;overflow-wrap:anywhere}.diag-table small{display:block;font-size:10px;margin-top:5px}.diagnostics button:focus-visible,.diagnostics select:focus-visible,.diagnostics summary:focus-visible,.diag-search:focus-within{outline:2px solid #92bdf9;outline-offset:3px}@media(max-width:1100px){.diag-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.diag-alerts{grid-template-columns:1fr}}@media(max-width:767px){.diag-hero{padding:22px;align-items:flex-start;flex-direction:column}.diag-hero h2{font-size:25px}.diag-metric{padding:15px}.diag-metric>strong{font-size:25px}.diag-metric-label{font-size:12px}.diag-alert{padding:17px}.diag-section-title{align-items:flex-start;flex-direction:column}.diag-table td,.diag-table th{padding:12px}.diag-recovery{grid-template-columns:1fr}.diag-recovery .diag-chip{justify-self:start}.diag-updated{align-items:flex-start}} + +.diag-bot-values{grid-template-columns:repeat(2,minmax(0,1fr))}.diag-bot-values strong{overflow-wrap:anywhere;font-variant-numeric:tabular-nums} diff --git a/app/realm-diagnostics.tsx b/app/realm-diagnostics.tsx index d4d7a3c..da29808 100644 --- a/app/realm-diagnostics.tsx +++ b/app/realm-diagnostics.tsx @@ -1,6 +1,7 @@ 'use client'; import {useEffect,useState} from 'react'; import {Activity,CheckCircle2,AlertTriangle,ArrowRight,Clock3,Search,ShieldCheck,Package,ScrollText,Users,Timer} from 'lucide-react'; +import {formatCopper} from '@/core/realm-outcomes'; import type {Attention,Perf} from '@/core/realm-outcomes'; import type {Evidence} from '@/core/realm-insights'; type Recovery={name:string;at:number;status:string;result:string;detail:string;checkedAt:number|null}; @@ -21,7 +22,7 @@ export function RealmDiagnostics({page,onNavigate,initialQuery=''}:{page:string; const attempts=recoveries.filter(r=>r.name.toLowerCase().includes(query.toLowerCase())&&(filter==='all'||r.status===filter)); return
EVIDENCE FROM YOUR REALM

{page==='Needs attention'?'A clear view of what needs you.':page==='Gameplay Results'?'Plans become progress.':page==='Recovery Results'?'Did the recovery help?':'Inside the bot engine.'}

{page==='Needs attention'?'Prioritized signals, with a direct path to the details.':page==='Gameplay Results'?'Native quest, loot, and group observations from the last 24 hours.':page==='Recovery Results'?'Native follow-up separates an attempted fix from observed progress.':'Measured execution time, sampled every five minutes.'}

{stale?'Collection delayed':'Collector active'}

Last collection {stamp(data.collectedAt)} · View refreshes every 15 seconds

{error&&
{error}
} {page==='Needs attention'&&<>

Follow-up queue

Critical signals first
{data.attention.length?
{data.attention.map(a=>)}
:

No current attention signals

The monitored checks are clear. This is not proof that every gameplay path is bug-free.

}
{['Gameplay Results','Recovery Results','Bot Performance'].map(p=>)}
} - {page==='Gameplay Results'&&<>
Evidence since {stamp(o.startedAt)}. Native counters last collected {stamp(o.at)}. Quest rewards count newly recorded quest IDs; repeated rewards for an already recorded quest may not increase this count. Quest objectives completed: {metrics.quests??0}. Party actions rejected: {metrics.partyFailed??0}. Gaps or a server reload can miss unflushed events.
{visibleBots.map(b=>
{b.name[0]}

{b.name}

{b.zone}
{b.grouped?'Grouped':'Solo / unknown'}
{b.totals.rewarded}Quest rewards
{b.totals.lootItems}Items looted
{b.totals.partyKills}Shared kills
Evidence details

Objectives completed: {b.totals.quests} · Abandoned: {b.totals.abandoned} · Loot collections: {b.totals.lootEvents}

Observed bot companions: {b.groupBots.filter(n=>n!==b.name).join(', ')||'None reported'}

)}
{!visibleBots.length&&
No residents match your filter.
}
Recent evidence ({o.recent.length}){o.recent.map(e=>
{stamp(e.at)}{e.name}

{e.detail} · +{e.amount}

)}
} + {page==='Gameplay Results'&&<>
Evidence since {stamp(o.startedAt)}. Native counters last collected {stamp(o.at)}. Quest rewards count newly recorded quest IDs; repeated rewards for an already recorded quest may not increase this count. Quest objectives completed: {metrics.quests??0}. Party actions rejected: {metrics.partyFailed??0}. Currency tracking starts with this update; historical coin loot is unavailable. Gaps or a server reload can miss unflushed events.
{visibleBots.map(b=>
{b.name[0]}

{b.name}

{b.zone}
{b.grouped?'Grouped':'Solo / unknown'}
{b.totals.rewarded}Quest rewards
{b.totals.lootItems}Items looted
{formatCopper(b.totals.lootedCopper??0)}Currency looted
{b.totals.partyKills}Shared kills
Evidence details

Objectives completed: {b.totals.quests} · Abandoned: {b.totals.abandoned} · Loot collections: {b.totals.lootEvents}

Observed bot companions: {b.groupBots.filter(n=>n!==b.name).join(', ')||'None reported'}

)}
{!visibleBots.length&&
No residents match your filter.
}
Recent evidence ({o.recent.length}){o.recent.map(e=>
{stamp(e.at)}{e.name}

{e.detail} · +{e.kind==='lootedCopper'?formatCopper(e.amount):e.amount}

)}
} {page==='Recovery Results'&&<>
{['resumed','no_progress','pending','unverified'].map(status=>r.status===status).length} note="Among the latest 100 retained attempts" tone={status==='resumed'?'good':status==='no_progress'?'warning':''} icon={status==='resumed'?CheckCircle2:Clock3}/>)}

Progress resumed means native movement over five yards, a map change, or a level change was observed within the follow-up window. It does not prove lasting recovery or that the intervention caused it. Three minutes without that evidence is flagged for review. Restarts or unavailable residents may prevent verification.

{attempts.map((r,i)=>

{r.name}

{stamp(r.at)}
{labels[r.status]}

{r.result}

Attempt details

{r.detail}

Checked: {stamp(r.checkedAt)}
)}
{!attempts.length&&

No matching recovery attempts

Results appear after the native guard attempts a recovery. No intervention is forced to populate this view.

}} {page==='Bot Performance'&&<>
{(!perf||Date.now()/1000-perf.at>720)&&
Profiler data is missing or stale. Measurements below are not current.
}

Average full-tick history

Milliseconds · cumulative average
{data.performance.history.slice(-48).map(p=>
x.averageTickMs)))+'%'}}/>)}

{stamp(data.performance.history[0]?.at??null)} — {stamp(perf?.at??null)}. Timers can overlap; they must not be added together or interpreted as CPU utilization.

Most expensive measured operations

Sorted by accumulated time
{perf?.rows.map((r,i)=>)}
OperationCallsMeanMaximumPer full tick
{r.name}{r.category}{Math.round(r.calls).toLocaleString()}{r.averageMs.toFixed(3)} ms{r.maxMs.toFixed(3)} ms{r.perTickMs.toFixed(3)} ms
{!perf?.rows.length&&

Waiting for the first native profiler export.

}
}
; diff --git a/core/realm-outcomes.ts b/core/realm-outcomes.ts index e5c6591..406ea0a 100644 --- a/core/realm-outcomes.ts +++ b/core/realm-outcomes.ts @@ -1,12 +1,12 @@ import type {State} from './types'; import {activityReason,type Evidence} from './realm-insights'; -export const counterKeys=['quests','abandoned','lootEvents','lootItems','partyKills','rewarded'] as const; +export const counterKeys=['quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper'] as const; export type NativeOutcome={guid:number;name:string}&Record; export type OutcomeBaseline={at:number;startedAt:number;rows:NativeOutcome[]}; export function outcomeChanges(rows:NativeOutcome[],previous:OutcomeBaseline|undefined,now:number){ const events:Evidence[]=[]; for(const r of rows){const old=previous?.rows.find(b=>b.guid===r.guid);if(!old)continue; - for(const key of counterKeys){const amount=r[key]-old[key];if(amount>0)events.push({id:`outcome:${r.guid}:${key}:${now}:${r[key]}`,at:now,kind:key,name:r.name,amount,detail:({quests:'Quest objectives completed',abandoned:'Quests abandoned',lootEvents:'Loot collections',lootItems:'Items collected',partyKills:'Kills with a nearby bot attacking the same target',rewarded:'New rewarded quest records'})[key]});} + for(const key of counterKeys){const amount=r[key]-old[key];if(amount>0)events.push({id:`outcome:${r.guid}:${key}:${now}:${r[key]}`,at:now,kind:key,name:r.name,amount,detail:({lootedCopper:'Currency looted',quests:'Quest objectives completed',abandoned:'Quests abandoned',lootEvents:'Loot collections',lootItems:'Items collected',partyKills:'Kills with a nearby bot attacking the same target',rewarded:'New rewarded quest records'})[key]});} } return {baseline:{at:now,startedAt:previous?.startedAt??now,rows},events}; } @@ -35,3 +35,5 @@ export function attentionItems(s:State,now:number,collectedAt:number|null,outcom for(const r of latest.values())if(r.status==='no_progress'&&now-r.at<3600&&online.some(b=>b.autonomy?.characterGuid===r.guid&&activityReason(b,now).label==='Possible stall'))add('recovery:'+r.id,'warning',r.name+' · recovery needs follow-up','No native movement or level progress was observed within three minutes.','Recovery Results',r.name); return items.sort((a,b)=>Number(b.severity==='critical')-Number(a.severity==='critical')); } + +export function formatCopper(amount:number){const n=Math.max(0,Math.floor(Number.isFinite(amount)?amount:0));return `${Math.floor(n/10000)}g ${Math.floor(n%10000/100)}s ${n%100}c`;} diff --git a/core/state-chunks.ts b/core/state-chunks.ts new file mode 100644 index 0000000..f32cc74 --- /dev/null +++ b/core/state-chunks.ts @@ -0,0 +1,2 @@ +// Bound each SQL value to at most 512 KiB of UTF-8; preserve all resident history. +export function splitState(state:unknown){const text=JSON.stringify(state),chunks:string[]=[];for(let i=0;i=0xd800&&c<=0xdbff)end--;chunks.push(text.slice(i,end));i=end;}return chunks;} diff --git a/core/store.ts b/core/store.ts index 6eedb58..cc349eb 100644 --- a/core/store.ts +++ b/core/store.ts @@ -1,4 +1,5 @@ import { env } from 'cloudflare:workers'; +import { splitState } from './state-chunks'; import { seed } from './seed'; import type { State,StateStore,Bot,Recipe,Home } from './types'; @@ -68,13 +69,21 @@ export class D1Store implements StateStore { if(!env.DB) throw new Error('Realm database unavailable'); const initial=seed(); await env.DB.prepare('INSERT OR IGNORE INTO realm_state (id,revision,payload) VALUES (?,?,?)').bind('realm',0,JSON.stringify(initial)).run(); - const row=await env.DB.prepare('SELECT payload FROM realm_state WHERE id=?').bind('realm').first<{payload:string}>(); - if(!row)throw new Error('Realm state unavailable'); - return migrate(JSON.parse(row.payload)); + await this.chunks(); + const result=await env.DB.prepare("SELECT s.payload AS manifest,c.payload AS chunk FROM realm_state s LEFT JOIN realm_state_chunks c ON c.token=json_extract(s.payload,'$.chunkToken') WHERE s.id=? ORDER BY c.part").bind('realm').all<{manifest:string;chunk:string|null}>(); + const rows=result.results;if(!rows.length)throw new Error('Realm state unavailable'); + const manifest=JSON.parse(rows[0].manifest); + if(manifest.chunkToken){if(rows.length!==manifest.parts||rows.some(r=>r.chunk===null))throw new Error('Realm state chunks incomplete');return migrate(JSON.parse(rows.map(r=>r.chunk).join('')));} + return migrate(manifest); } + private async chunks(){await env.DB!.prepare('CREATE TABLE IF NOT EXISTS realm_state_chunks (token TEXT,part INTEGER,payload TEXT,PRIMARY KEY(token,part))').run();} async save(s:State,expected:number){ - s.revision=expected+1; - const result=await env.DB!.prepare('UPDATE realm_state SET revision=?,payload=? WHERE id=? AND revision=?').bind(s.revision,JSON.stringify(s),'realm',expected).run(); - if(result.meta.changes!==1)throw new Error('State changed in another request. Refresh and try again.'); + await this.chunks();s.revision=expected+1; + const token=crypto.randomUUID(),parts=splitState(s),manifest=JSON.stringify({chunkToken:token,parts:parts.length}); + const statements=[env.DB!.prepare('UPDATE realm_state SET revision=?,payload=? WHERE id=? AND revision=?').bind(s.revision,manifest,'realm',expected), + ...parts.map((part,i)=>env.DB!.prepare('INSERT INTO realm_state_chunks(token,part,payload) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM realm_state WHERE id=? AND payload=?)').bind(token,i,part,'realm',manifest)), + env.DB!.prepare('DELETE FROM realm_state_chunks WHERE token<>? AND EXISTS (SELECT 1 FROM realm_state WHERE id=? AND payload=?)').bind(token,'realm',manifest)]; + const result=await env.DB!.batch(statements); + if(result[0].meta.changes!==1)throw new Error('State changed in another request. Refresh and try again.'); } } diff --git a/docs/bot-conversations.md b/docs/bot-conversations.md index ce88054..214a7be 100644 --- a/docs/bot-conversations.md +++ b/docs/bot-conversations.md @@ -18,7 +18,7 @@ at most 100 memories and 100 relationship records on this path. Completed and failed conversation requests are pruned after 24 hours; character memorials and other game/event history have separate retention policies. -Memories and relationships are stored in `realm_state.payload` in the application's +Memories and relationships are stored in `realm_state_chunks` (with an atomic revision manifest in `realm_state.payload`) in the application's D1 SQLite database under `.wrangler/state/v3/d1` in the Ubuntu deployment. This uses the VM's disk, backed by the host PC's storage. The per-resident memory limit is not a total disk quota: additional identities and other logs can still grow. diff --git a/docs/realm-insights.md b/docs/realm-insights.md index 39b37d0..11c669a 100644 --- a/docs/realm-insights.md +++ b/docs/realm-insights.md @@ -21,3 +21,5 @@ Recovery Results retains up to 100 attempts. Native follow-up checks position, m Bot Performance reads the native `playerbots pmon tick` JSON export every five minutes. Enable `AiPlayerbot.PerfMonEnabled = 1` in the installed module configuration; on an already-running server, toggle the native profiler once only if it is currently disabled. Deploy `ale/azerothai_outcomes.lua` alongside the existing bridge and recovery scripts. The collector reads only fixed container paths. Timings are converted from microseconds to milliseconds. Operation timings overlap; they are not CPU percentages. Cumulative means are explicitly labeled, and only the top 20 operations and 144 snapshots are retained in D1. No new chat/model calls are made by diagnostics. Visitor access remains read-only. The same components render both monitors; diagnostics expose aggregate evidence and managed character names, excluding raw database identities, credentials, command sources and private chat. Native counter/result tables are included in the existing full auth database backup, and all D1 diagnostic tables are covered by the existing D1 backup. + +Currency looted is tracked independently through native player event 37, in copper, and displayed as gold/silver/copper. Item event 32 remains item quantities only. Coin totals exclude quest rewards, sales, trades, and existing wallet balances; no historical currency is inferred. The first sample establishes a baseline, then the last-24-hour view reports observed gains. Both admin and visitor use the same read-only measurements. diff --git a/scripts/realm-backup.py b/scripts/realm-backup.py index b3d7e4c..73e5878 100644 --- a/scripts/realm-backup.py +++ b/scripts/realm-backup.py @@ -109,7 +109,12 @@ def backup(): assert db.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'").fetchone()[0]>0 if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='realm_state'").fetchone(): row=db.execute("SELECT payload FROM realm_state WHERE id='realm'").fetchone() - if row:restored_state=json.loads(row[0]) + if row: + restored_state=json.loads(row[0]) + if restored_state.get('chunkToken'): + chunks=db.execute('SELECT payload FROM realm_state_chunks WHERE token=? ORDER BY part',(restored_state['chunkToken'],)).fetchall() + assert len(chunks)==restored_state['parts'], 'Incomplete restored state' + restored_state=json.loads(''.join(r[0] for r in chunks)) restored_file.unlink() if not restored_state or not restored_state.get('bots'):raise RuntimeError('Restored app realm state missing') manifest['restoredMemories']=sum(len(b.get('memories',[])) for b in restored_state['bots']) diff --git a/scripts/realm-evidence.py b/scripts/realm-evidence.py index 7e7be98..06ae0c8 100644 --- a/scripts/realm-evidence.py +++ b/scripts/realm-evidence.py @@ -1,6 +1,6 @@ """Read native counters and profiler output; return bounded public measurements.""" import json,subprocess,time,math -COUNTERS=('quests','abandoned','lootEvents','lootItems','partyKills','rewarded') +COUNTERS=('quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper') def normalize_performance(raw): def number(v): try: @@ -27,9 +27,10 @@ def collect(sql,state): guids=','.join(map(str,allowed)) for line in sql('SELECT guid,COUNT(*) FROM acore_characters.character_queststatus_rewarded WHERE guid IN ('+guids+') GROUP BY guid').splitlines(): guid,count=map(int,line.split('\t'));rewarded[guid]=count + money={int(g):int(c) for g,c in (line.split('\t') for line in sql('SELECT guid,looted_copper FROM acore_auth.azerothai_currency').splitlines())} result=[] for guid,b in allowed.items(): - counters=rows.get(guid,dict.fromkeys(COUNTERS,0));counters['rewarded']=rewarded.get(guid,0) + counters=rows.get(guid,dict.fromkeys(COUNTERS,0));counters['rewarded']=rewarded.get(guid,0);counters['lootedCopper']=money.get(guid,0) result.append({'guid':guid,'name':b['name'],**counters}) return result diff --git a/scripts/test-core.mjs b/scripts/test-core.mjs index c639b53..82e7697 100644 --- a/scripts/test-core.mjs +++ b/scripts/test-core.mjs @@ -1,6 +1,6 @@ import {spawnSync} from 'node:child_process'; import {writeFileSync,mkdirSync} from 'node:fs'; -const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/realm-outcomes.ts','core/realm-insights.ts','core/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','core/autonomy.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'}); +const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/state-chunks.ts','core/realm-outcomes.ts','core/realm-insights.ts','core/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','core/autonomy.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'}); if(result.status)process.exit(result.status); mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}'); process.exit(spawnSync(process.execPath,['--test','tests/realm-outcomes.test.cjs','tests/realm-insights.test.cjs','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs','tests/community.test.cjs'],{stdio:'inherit'}).status??1); diff --git a/tests/realm-outcomes.test.cjs b/tests/realm-outcomes.test.cjs index d602210..12ad799 100644 --- a/tests/realm-outcomes.test.cjs +++ b/tests/realm-outcomes.test.cjs @@ -4,3 +4,7 @@ const row={guid:1,name:'A',quests:10,abandoned:1,lootEvents:4,lootItems:10,party test('outcome baseline excludes history, reports native deltas, handles resets without invented gains',()=>{const first=outcomeChanges([row],undefined,100);assert.equal(first.events.length,0);const next=outcomeChanges([{...row,lootItems:14,rewarded:9}],first.baseline,160);assert.deepEqual(next.events.map(e=>[e.kind,e.amount]),[['lootItems',4],['rewarded',1]]);assert.equal(outcomeChanges(next.baseline.rows,next.baseline,220).events.length,0);assert.equal(outcomeChanges([{...row,lootItems:0}],next.baseline,280).events.length,0);}); test('attention skips false population alarms when roster is stale and prioritizes resource pressure',()=>{const s={bots:[],aleRosterAt:0};const h=[{host:'ubuntu',at:1000,memoryAvailableMB:100,memoryTotalMB:8000,diskFreeGB:20,cpuPercent:10},{host:'windows',at:1000,memoryAvailableMB:2000,memoryTotalMB:16000,diskFreeGB:20,cpuPercent:10}];const a=attentionItems(s,1000,1000,1000,{at:1000,enabled:true},[],h);assert.equal(a[0].key,'memory:ubuntu');assert.equal(a.some(x=>x.key==='population'),false);}); test('resolved old recovery failures do not remain actionable',()=>{const b={id:'a',name:'A',status:'online',autonomy:{managed:true,characterGuid:1},world:{at:1000,stationarySince:990},lastDecision:{action:'quest'}};const a=attentionItems({bots:[b]},1000,1000,1000,null,[{id:1,guid:1,name:'A',at:900,status:'no_progress'}],[]);assert.equal(a.some(x=>x.key.startsWith('recovery:')),false);}); + +test('currency formats copper and remains independent of item counts and historical baselines',()=>{const {formatCopper}=require('../.test-build/realm-outcomes.js');assert.equal(formatCopper(12345),'1g 23s 45c');assert.equal(formatCopper(0),'0g 0s 0c');const first=outcomeChanges([{...row,lootedCopper:100}],{at:1,startedAt:1,rows:[row]},2);assert.equal(first.events.length,0);const next=outcomeChanges([{...row,lootedCopper:250}],first.baseline,3);assert.deepEqual(next.events.map(e=>[e.kind,e.amount]),[['lootedCopper',150]]);}); + +test('large state chunks preserve Unicode and all history within per-value limits',()=>{const {splitState}=require('../.test-build/state-chunks.js');const input={history:'🎮'.repeat(600000)};const chunks=splitState(input);assert.ok(chunks.length>1);assert.deepEqual(JSON.parse(chunks.join('')),input);for(const c of chunks)assert.ok(Buffer.byteLength(c)<512*1024);});