Separate looted currency and preserve growing realm state in atomic chunks
Core tests / core (push) Successful in 49s
Core tests / core (push) Successful in 49s
This commit is contained in:
@@ -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<typeof counterKeys[number],number>;
|
||||
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`;}
|
||||
|
||||
@@ -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<text.length;){let end=Math.min(i+120000,text.length);const c=text.charCodeAt(end-1);if(c>=0xd800&&c<=0xdbff)end--;chunks.push(text.slice(i,end));i=end;}return chunks;}
|
||||
+15
-6
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user