From ee8161eca25362ceabc9359ed20ea78e9e0aefb7 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 22 Sep 2026 17:10:15 -0400 Subject: [PATCH] Enable bounded proximity conversations and confirmed social memories --- ale/azerothai_bridge.lua | 19 ++++++++++++++++- app/api/bridge/ale/event/route.ts | 4 +++- core/conversation-store.ts | 29 +++++++++++++++++++++++++- core/conversation.ts | 34 +++++++++++++++++++++++++------ tests/conversation.test.cjs | 34 +++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 9 deletions(-) diff --git a/ale/azerothai_bridge.lua b/ale/azerothai_bridge.lua index 2a59298..d3789da 100644 --- a/ale/azerothai_bridge.lua +++ b/ale/azerothai_bridge.lua @@ -188,6 +188,7 @@ CreateLuaEvent(function() poll() end, POLL_MS, 0) -- Keep AzerothAI grounded in real online Playerbots even when no human is logged in. -- Expose a safe global refresh hook too, so the autonomy worker can explicitly -- request a fresh authoritative snapshot if the periodic timer ever stalls. +local socialLast={} function AzerothAIEmitSnapshots() local ok,players=pcall(function() return GetPlayersInWorld() end) if not ok or type(players)~="table" then return 0 end @@ -201,7 +202,23 @@ function AzerothAIEmitSnapshots() end end -- Use snapshot transport so roster reconciliation shares fresh-event priority. - emit("player.snapshot",{roster=true,observedAt=os.time(),characterGuids=guids}) + local roster={roster=true,observedAt=os.time(),characterGuids=guids} + local ordered={} + for _,p in pairs(players) do if is_playerbot(p) then ordered[#ordered+1]=p end end + table.sort(ordered,function(a,b) return (socialLast[a:GetGUIDLow()] or 0)<(socialLast[b:GetGUIDLow()] or 0) end) + for _,p in ipairs(ordered) do + if not p:IsDead() and not p:IsInCombat() and os.time()-(socialLast[p:GetGUIDLow()] or 0)>600 then + for _,q in pairs(p:GetPlayersInRange(25) or {}) do + if q:GetGUIDLow()~=p:GetGUIDLow() and is_playerbot(q) and not q:IsDead() and not q:IsInCombat() and p:GetTeam()==q:GetTeam() and p:GetPhaseMask()==q:GetPhaseMask() and os.time()-(socialLast[q:GetGUIDLow()] or 0)>600 then + roster.socialA=p:GetName();roster.socialB=q:GetName();roster.zoneName=area_name(p:GetZoneId()) + socialLast[p:GetGUIDLow()]=os.time();socialLast[q:GetGUIDLow()]=os.time() + break + end + end + end + if roster.socialA then break end + end + emit("player.snapshot",roster) return emitted end diff --git a/app/api/bridge/ale/event/route.ts b/app/api/bridge/ale/event/route.ts index 6437f5d..48847ca 100644 --- a/app/api/bridge/ale/event/route.ts +++ b/app/api/bridge/ale/event/route.ts @@ -2,7 +2,7 @@ import { env } from 'cloudflare:workers'; import { z } from 'zod'; import { requireAleBridge } from '@/core/ale-auth'; import { recordAleEvent } from '@/core/ale-store'; -import { queueConversationEvent } from '@/core/conversation-store'; +import { queueSocialEncounter,queueConversationEvent } from '@/core/conversation-store'; import { D1Store } from '@/core/store'; import { applyAuthoritativeAleEvent,execute } from '@/core/service'; import type { RuntimeConfig } from '@/core/types'; @@ -42,6 +42,8 @@ export async function POST(request:Request){ await queueConversationEvent(env.DB!,body.kind,body.payload); } + if(body.kind==='player.snapshot'&&body.payload.roster===true)await queueSocialEncounter(env.DB!,body.payload); + const store=new D1Store(); if(STATEFUL_EVENTS.has(body.kind)){ await applyAuthoritativeAleEvent(store,body.kind,body.payload); diff --git a/core/conversation-store.ts b/core/conversation-store.ts index 746bcc9..4a2cb8a 100644 --- a/core/conversation-store.ts +++ b/core/conversation-store.ts @@ -1,6 +1,7 @@ export type ConversationChannel='say'|'whisper'; export type ConversationRequest={ + socialTurn?:0|1; id:string; createdAt:string; speakerName:string; @@ -79,7 +80,7 @@ export async function leaseConversation(db:D1Database):Promise(); + FROM conversation_requests WHERE status='pending' ORDER BY CASE WHEN channel LIKE 'social-%' THEN 1 ELSE 0 END,created_at LIMIT 1`).first(); if(!row)return null; const startedAt=new Date().toISOString(); @@ -89,6 +90,7 @@ export async function leaseConversation(db:D1Database):Promise){ + const a=text(payload,'socialA',40),b=text(payload,'socialB',40); + const at=Number(payload.observedAt); + if(!a||!b||a===b||!Number.isFinite(at)||Math.abs(Date.now()/1000-at)>60)return; + await ensure(db); + const now=new Date().toISOString(); + const cutoff=new Date(Date.now()-10*60_000).toISOString(); + const id='social-'+Math.floor(Date.now()/120_000); + await db.prepare(`INSERT OR IGNORE INTO conversation_requests + (id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts) + SELECT ?,?,?,?,'social-start',?,?,?,?,'pending',0 + WHERE NOT EXISTS (SELECT 1 FROM conversation_requests WHERE status IN ('pending','processing','awaiting_delivery')) + AND NOT EXISTS (SELECT 1 FROM conversation_requests WHERE channel LIKE 'social-%' AND created_at>? AND (speaker_name IN (?,?) OR selected_player IN (?,?)))`) + .bind(id,now,b,'Initiate a brief friendly in-world conversation with your nearby companion.',text(payload,'zoneName',120),a,a,b,cutoff,a,b,a,b).run(); +} + +export async function queueSocialReply(db:D1Database,request:ConversationRequest,botName:string,reply:string){ + if(request.socialTurn!==0)return; + await db.prepare(`INSERT OR IGNORE INTO conversation_requests + (id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts) + VALUES (?,?,?,?,'social-reply',?,?,?,?,'pending',0)`) + .bind(request.id+':reply',new Date().toISOString(),botName,reply,request.zoneName,request.speakerName,request.speakerName,botName).run(); +} diff --git a/core/conversation.ts b/core/conversation.ts index 8d59f70..5539772 100644 --- a/core/conversation.ts +++ b/core/conversation.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { queueAleCommand } from './ale-store'; import { luaString } from './ale'; -import { cleanupConversations,completeConversation,failConversation,leaseConversation,awaitDelivery,pendingDeliveries,type ConversationRequest } from './conversation-store'; +import { queueSocialReply,cleanupConversations,completeConversation,failConversation,leaseConversation,awaitDelivery,pendingDeliveries,type ConversationRequest } from './conversation-store'; import type { Bot,RealmEvent,RuntimeConfig,State,StateStore } from './types'; const replySchema=z.object({reply:z.string().min(1).max(500)}).strict(); @@ -20,7 +20,7 @@ function byName(state:State,name:string){ if(!lower)return undefined; return state.bots.find(bot=> bot.autonomy?.managed===true&& - bot.status!=='dead'&&bot.status!=='retired'&& + bot.status==='online'&& bot.name.toLowerCase()===lower ); } @@ -73,7 +73,7 @@ async function modelReply(bot:Bot,request:ConversationRequest,state:State,config content:JSON.stringify({ speaker:request.speakerName, channel:request.channel, - message:request.message, + message:request.socialTurn===0?'Start a conversation with '+request.speakerName+' about your current surroundings or goals. They have not spoken yet.':request.message, currentZone:request.zoneName||bot.zone, identity:{name:bot.name,race:bot.race,role:bot.role,tier:bot.tier}, personality:bot.personality, @@ -105,11 +105,15 @@ async function modelReply(bot:Bot,request:ConversationRequest,state:State,config return cleanReply(replySchema.parse(JSON.parse(raw)).reply); } -function compileReply(botName:string,speakerName:string,reply:string,channel:'say'|'whisper'){ +function compileReply(botName:string,speakerName:string,reply:string,channel:'say'|'whisper',social=false){ const source=[ `local bot=GetPlayerByName(${luaString(botName)})`, 'if not bot then error("Conversation bot is not online") end' ]; + if(social){ + source.push(`local peer=GetPlayerByName(${luaString(speakerName)})`); + source.push('if not peer or bot:IsDead() or peer:IsDead() or bot:IsInCombat() or peer:IsInCombat() or bot:GetMapId()~=peer:GetMapId() or bot:GetPhaseMask()~=peer:GetPhaseMask() or bot:GetTeam()~=peer:GetTeam() or bot:GetDistance(peer)>25 then error("Social pair is no longer available nearby") end'); + } if(channel==='whisper'){ source.push(`local receiver=GetPlayerByName(${luaString(speakerName)})`); source.push('if not receiver then error("Conversation player is not online") end'); @@ -136,6 +140,19 @@ async function remember(store:StateStore,botId:string,request:ConversationReques at:new Date().toISOString() }); bot.memories=bot.memories.slice(-100); + if(request.socialTurn===1){ + const peer=byName(state,request.speakerName); + if(peer){ + peer.memories.push({id:request.id,text:`I said to ${bot.name}: "${request.message.slice(0,180)}" They replied: "${reply.slice(0,180)}"`,importance:6,source:'observation',at:new Date().toISOString()}); + peer.memories=peer.memories.slice(-100); + for(const [from,to] of [[bot,peer],[peer,bot]]){ + const existing=from.relationships.find(r=>r.targetId===to.id); + if(existing)existing.note='We have spoken together in person.'; + else from.relationships.push({targetId:to.id,affinity:0,note:'We have spoken together in person.'}); + from.relationships=from.relationships.slice(-100); + } + } + } bot.simulationBand='foreground'; realmEvent(state,'memory',`${bot.name} had a live conversation with ${request.speakerName}.`,bot.id); try{ @@ -150,16 +167,21 @@ async function remember(store:StateStore,botId:string,request:ConversationReques export async function runConversationTick(store:StateStore,db:D1Database,config:RuntimeConfig){ for(const delivery of await pendingDeliveries(db)){ if(delivery.status==='completed'){ - await remember(store,delivery.botId,delivery.request,delivery.reply); + if(delivery.request.socialTurn!==0)await remember(store,delivery.botId,delivery.request,delivery.reply); + const state=await store.load(); + const speaker=state.bots.find(b=>b.id===delivery.botId); + if(speaker)await queueSocialReply(db,delivery.request,speaker.name,delivery.reply); await completeConversation(db,delivery.request.id,JSON.stringify({commandId:delivery.commandId,delivered:true})); }else if(delivery.status==='failed'){ await failConversation(db,delivery.request.id,delivery.error||'Game rejected conversation delivery'); } } + await cleanupConversations(db); const request=await leaseConversation(db); if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'}; try{ + if(request.socialTurn!==undefined&&Date.now()-Date.parse(request.createdAt)>120_000)throw new Error('Social encounter expired'); const state=await store.load(); const bot=chooseTarget(state,request); if(!bot){ @@ -171,7 +193,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config: reply=cleanReply(reply); if(!reply)throw new Error('Conversation reply was empty'); - const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel)); + const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined)); await awaitDelivery(db,request,bot.id,reply,command.id); await cleanupConversations(db); diff --git a/tests/conversation.test.cjs b/tests/conversation.test.cjs index b5e3bce..389976e 100644 --- a/tests/conversation.test.cjs +++ b/tests/conversation.test.cjs @@ -39,3 +39,37 @@ test('conversation failure, queued delivery, confirmed memory and replay safety' assert.equal(failed,'Bot offline');assert.equal(bot.memories.length,1); }finally{global.fetch=originalFetch;} }); + +test('social exchanges remember both residents only after confirmed reply and replays are harmless',async()=>{ + const a={id:'a',name:'A',status:'online',autonomy:{managed:true},memories:[],relationships:[],personality:{},race:'Orc',role:'Warrior'}; + const b={...a,id:'b',name:'B',memories:[],relationships:[]}; + const state={revision:0,sequence:0,events:[],bots:[a,b],settings:{provider:'ollama',model:'test',brains:{enabled:false,residentMemoryLimit:5,residentMaxTokens:100}}}; + const store={load:async()=>state,save:async()=>{}}; + const request={id:'social-test',createdAt:new Date().toISOString(),socialTurn:0,speakerName:'B',selectedPlayer:'A',message:'Initiate',channel:'say',nearbyBots:['B']}; + let pending=request,deliveries=[],followups=0,source=''; + queue.pendingDeliveries=async()=>deliveries; + queue.leaseConversation=async()=>{const r=pending;pending=null;return r;}; + queue.cleanupConversations=async()=>{}; + queue.completeConversation=async()=>{}; + queue.failConversation=async()=>{}; + queue.awaitDelivery=async(_db,r,botId,reply,commandId)=>{deliveries=[{request:r,botId,reply,commandId,status:'pending'}];}; + queue.queueSocialReply=async(_db,r,name,reply)=>{if(r.socialTurn===0){followups++;pending={...r,id:r.id+':reply',socialTurn:1,speakerName:name,selectedPlayer:'B',message:reply};}}; + ale.queueAleCommand=async(_db,s)=>{source=s;return {id:'cmd'};}; + const originalFetch=global.fetch; + global.fetch=async()=>({ok:true,json:async()=>({message:{content:'{"reply":"How is the hunt going?"}'}})}); + try{ + await runConversationTick(store,{}, {LLM_URL:'http://model'}); + assert.match(source,/GetDistance\(peer\)>25/); + assert.match(source,/GetTeam/); + assert.equal(a.memories.length,0); + deliveries[0].status='completed'; + await runConversationTick(store,{}, {LLM_URL:'http://model'}); + assert.equal(followups,1);assert.equal(a.memories.length,0); + deliveries[0].status='completed'; + await runConversationTick(store,{},{}); + assert.equal(a.memories.length,1);assert.equal(b.memories.length,1); + assert.equal(a.relationships[0].targetId,'b');assert.equal(b.relationships[0].targetId,'a'); + await runConversationTick(store,{},{}); + assert.equal(a.memories.length,1);assert.equal(b.memories.length,1);assert.equal(followups,1); + }finally{global.fetch=originalFetch;} +});