diff --git a/ale/azerothai_bridge.lua b/ale/azerothai_bridge.lua index 9056345..2a59298 100644 --- a/ale/azerothai_bridge.lua +++ b/ale/azerothai_bridge.lua @@ -47,6 +47,11 @@ local function json_value(value) if value == nil then return "null" end if t == "boolean" then return value and "true" or "false" end if t == "number" then return tostring(value) end + if t == "table" then + local parts={} + for _,item in ipairs(value) do parts[#parts+1]=json_value(item) end + return "["..table.concat(parts,",").."]" + end return '"' .. escape_json(value) .. '"' end @@ -187,12 +192,16 @@ function AzerothAIEmitSnapshots() local ok,players=pcall(function() return GetPlayersInWorld() end) if not ok or type(players)~="table" then return 0 end local emitted=0 + local guids={} for _,player in pairs(players) do if is_playerbot(player) then emit("player.snapshot",player_payload(player)) + guids[#guids+1]=player:GetGUIDLow() emitted=emitted+1 end end + -- Use snapshot transport so roster reconciliation shares fresh-event priority. + emit("player.snapshot",{roster=true,observedAt=os.time(),characterGuids=guids}) return emitted end diff --git a/core/service.ts b/core/service.ts index 9d0e392..b3308fb 100644 --- a/core/service.ts +++ b/core/service.ts @@ -72,6 +72,24 @@ function observedHardcoreDeath(s:State,b:Bot,observation:Observation){ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,payload:Record){ let s=await store.load(); const revision=s.revision; + // Complete world snapshots own presence once available; historical events + // must never bring disconnected residents back online. + if(kind==='player.snapshot' && payload.roster===true){ + const at=payload.observedAt; + const guids=payload.characterGuids; + if(typeof at!=='number'||!Number.isFinite(at)||!Array.isArray(guids)||!guids.every(g=>Number.isInteger(g)&&g>0))throw new Error('Invalid authoritative roster'); + if(at<=(s.aleRosterAt??0))return {matched:false,stale:true}; + const online=new Set(guids); + for(const bot of s.bots){ + if(bot.status==='dead'||bot.status==='retired')continue; + const present=bot.autonomy?.managed && online.has(bot.autonomy.characterGuid); + bot.status=present?'online':'offline'; + bot.simulationBand=present?'background':'dormant'; + } + s.aleRosterAt=at; + await store.save(s,revision); + return {matched:true,roster:true}; + } if(kind==='bridge.ready'){ let changed=false; for(const bot of s.bots){ @@ -103,7 +121,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa const isZaku=name.toLowerCase()==='zaku'; b={ id:isZaku?'hero-zaku':`live-${crypto.randomUUID()}`, - name,role,race,level,zone,status:'online', + name,role,race,level,zone,status:s.aleRosterAt?'offline':'online', tier:isZaku?'hero':'resident',mortal:true,plotArmorHpFloor:isZaku?1:0, simulationBand:'background', routine:{wakeHour:isZaku?10:7,sleepHour:isZaku?3:23,preferredZones:[zone]}, @@ -137,19 +155,19 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa if(zone)b.zone=zone; if(kind==='player.login'){ - b.status='online'; + if(!s.aleRosterAt)b.status='online'; b.simulationBand='foreground'; event(s,'lifecycle',`${b.name} logged into the authoritative AzerothCore world.`,b.id); }else if(kind==='player.logout'){ - b.status='offline'; + if(!s.aleRosterAt)b.status='offline'; b.simulationBand='dormant'; event(s,'lifecycle',`${b.name} logged out of the authoritative AzerothCore world.`,b.id); }else if(kind==='player.zone'||kind==='player.map'){ - if(b.status==='offline')b.status='online'; + if(!s.aleRosterAt&&b.status==='offline')b.status='online'; b.simulationBand='foreground'; event(s,'lifecycle',`${b.name} is now in ${b.zone} (authoritative ALE observation).`,b.id); }else if(kind==='player.snapshot'){ - if(b.status==='offline')b.status='online'; + if(!s.aleRosterAt&&b.status==='offline')b.status='online'; if(b.simulationBand==='dormant')b.simulationBand='background'; }else if(kind==='player.level'){ const text=`I reached level ${b.level} while living in ${b.zone}.`; diff --git a/core/types.ts b/core/types.ts index 088adcd..d247abb 100644 --- a/core/types.ts +++ b/core/types.ts @@ -18,7 +18,7 @@ export type PopulationSettings = {defaultZoneTarget:number; zoneMax:number; serv export type BrainRouterSettings = {enabled:boolean; residentModel:string; heroModel:string; backgroundUseLLM:boolean; residentMemoryLimit:number; heroMemoryLimit:number; residentMaxTokens:number; heroMaxTokens:number}; export type WorldTrigger = {id:string; kind:'zone-change'|'interaction'|'time'|'manual'; zone:string; at:string; playerName?:string; detail?:string}; export type PopulationPlan = {id:string; trigger:WorldTrigger; target:number; activeBefore:number; requested:number; capacity:number; selectedBotIds:string[]; rejectedReasons:string[]; createdAt:string}; -export type State = {revision:number; bots:Bot[]; homes:Home[]; recipes:Recipe[]; events:RealmEvent[]; sequence:number; settings:{connector:'simulation'|'azerothcore'; provider:'deterministic'|'ollama'|'openai-compatible'; model:string; brains:BrainRouterSettings; hardcore:HardcoreSettings; population:PopulationSettings}; director:{experience:string; zone:string; playerName:string; brief:string}; population:{lastPlan?:PopulationPlan}; operations:Record}; +export type State = {revision:number; aleRosterAt?:number; bots:Bot[]; homes:Home[]; recipes:Recipe[]; events:RealmEvent[]; sequence:number; settings:{connector:'simulation'|'azerothcore'; provider:'deterministic'|'ollama'|'openai-compatible'; model:string; brains:BrainRouterSettings; hardcore:HardcoreSettings; population:PopulationSettings}; director:{experience:string; zone:string; playerName:string; brief:string}; population:{lastPlan?:PopulationPlan}; operations:Record}; export type DeathSignal = {id:string; at:string; cause:string; killer?:string; zone?:string; verified:boolean}; export type Observation = {zone:string; level:number; availableActions:Activity[]; summary:string; alive:boolean; death?:DeathSignal}; export interface GameConnector {id:string; health():Promise<{ok:boolean; detail:string}>; observe(bot:Bot):Promise; lifecycle(bot:Bot,action:'spawn'|'pause'|'resume'|'despawn'|'retire',requestId:string):Promise; decide(bot:Bot,decision:Decision,requestId:string):Promise<{summary:string;zone?:string}>;} diff --git a/tests/hardcore.test.cjs b/tests/hardcore.test.cjs index 7f7c203..7b4ff78 100644 --- a/tests/hardcore.test.cjs +++ b/tests/hardcore.test.cjs @@ -79,3 +79,16 @@ test('unverified bridge death fails safe without erasing identity',async()=>{ assert.equal(bot.death,undefined); }finally{connectors.simulation=old;} }); + + test('complete roster clears stale presence and historical events cannot restore it',async()=>{ + const store=new Store(); + const payload={name:'Rosterbot',isBot:true,characterGuid:123,accountId:2,className:'Death Knight',raceName:'Orc',level:55}; + await applyAuthoritativeAleEvent(store,'player.snapshot',payload); + await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:100,characterGuids:[123]}); + assert.equal(store.state.bots.filter(b=>b.status==='online').length,1); + await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:101,characterGuids:[]}); + for(const kind of ['player.login','player.snapshot','player.zone'])await applyAuthoritativeAleEvent(store,kind,payload); + assert.equal(store.state.bots.filter(b=>b.status==='online').length,0); + await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:100,characterGuids:[123]}); + assert.equal(store.state.bots.filter(b=>b.status==='online').length,0); + });