Enable bounded proximity conversations and confirmed social memories
This commit is contained in:
@@ -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.
|
-- 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
|
-- Expose a safe global refresh hook too, so the autonomy worker can explicitly
|
||||||
-- request a fresh authoritative snapshot if the periodic timer ever stalls.
|
-- request a fresh authoritative snapshot if the periodic timer ever stalls.
|
||||||
|
local socialLast={}
|
||||||
function AzerothAIEmitSnapshots()
|
function AzerothAIEmitSnapshots()
|
||||||
local ok,players=pcall(function() return GetPlayersInWorld() end)
|
local ok,players=pcall(function() return GetPlayersInWorld() end)
|
||||||
if not ok or type(players)~="table" then return 0 end
|
if not ok or type(players)~="table" then return 0 end
|
||||||
@@ -201,7 +202,23 @@ function AzerothAIEmitSnapshots()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- Use snapshot transport so roster reconciliation shares fresh-event priority.
|
-- 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
|
return emitted
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { env } from 'cloudflare:workers';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { requireAleBridge } from '@/core/ale-auth';
|
import { requireAleBridge } from '@/core/ale-auth';
|
||||||
import { recordAleEvent } from '@/core/ale-store';
|
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 { D1Store } from '@/core/store';
|
||||||
import { applyAuthoritativeAleEvent,execute } from '@/core/service';
|
import { applyAuthoritativeAleEvent,execute } from '@/core/service';
|
||||||
import type { RuntimeConfig } from '@/core/types';
|
import type { RuntimeConfig } from '@/core/types';
|
||||||
@@ -42,6 +42,8 @@ export async function POST(request:Request){
|
|||||||
await queueConversationEvent(env.DB!,body.kind,body.payload);
|
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();
|
const store=new D1Store();
|
||||||
if(STATEFUL_EVENTS.has(body.kind)){
|
if(STATEFUL_EVENTS.has(body.kind)){
|
||||||
await applyAuthoritativeAleEvent(store,body.kind,body.payload);
|
await applyAuthoritativeAleEvent(store,body.kind,body.payload);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type ConversationChannel='say'|'whisper';
|
export type ConversationChannel='say'|'whisper';
|
||||||
|
|
||||||
export type ConversationRequest={
|
export type ConversationRequest={
|
||||||
|
socialTurn?:0|1;
|
||||||
id:string;
|
id:string;
|
||||||
createdAt:string;
|
createdAt:string;
|
||||||
speakerName:string;
|
speakerName:string;
|
||||||
@@ -79,7 +80,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
|
|||||||
.bind(staleBefore).run();
|
.bind(staleBefore).run();
|
||||||
|
|
||||||
const row=await db.prepare(`SELECT id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts
|
const row=await db.prepare(`SELECT id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts
|
||||||
FROM conversation_requests WHERE status='pending' ORDER BY created_at LIMIT 1`).first<any>();
|
FROM conversation_requests WHERE status='pending' ORDER BY CASE WHEN channel LIKE 'social-%' THEN 1 ELSE 0 END,created_at LIMIT 1`).first<any>();
|
||||||
if(!row)return null;
|
if(!row)return null;
|
||||||
|
|
||||||
const startedAt=new Date().toISOString();
|
const startedAt=new Date().toISOString();
|
||||||
@@ -89,6 +90,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
|
|||||||
if(updated.meta.changes!==1)return null;
|
if(updated.meta.changes!==1)return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
socialTurn:row.channel==='social-start'?0:row.channel==='social-reply'?1:undefined,
|
||||||
id:row.id,
|
id:row.id,
|
||||||
createdAt:row.created_at,
|
createdAt:row.created_at,
|
||||||
speakerName:row.speaker_name,
|
speakerName:row.speaker_name,
|
||||||
@@ -141,3 +143,28 @@ export async function pendingDeliveries(db:D1Database){
|
|||||||
}
|
}
|
||||||
return deliveries;
|
return deliveries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One autonomous exchange per two-minute bucket; human chat keeps priority.
|
||||||
|
export async function queueSocialEncounter(db:D1Database,payload:Record<string,unknown>){
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|||||||
+28
-6
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { queueAleCommand } from './ale-store';
|
import { queueAleCommand } from './ale-store';
|
||||||
import { luaString } from './ale';
|
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';
|
import type { Bot,RealmEvent,RuntimeConfig,State,StateStore } from './types';
|
||||||
|
|
||||||
const replySchema=z.object({reply:z.string().min(1).max(500)}).strict();
|
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;
|
if(!lower)return undefined;
|
||||||
return state.bots.find(bot=>
|
return state.bots.find(bot=>
|
||||||
bot.autonomy?.managed===true&&
|
bot.autonomy?.managed===true&&
|
||||||
bot.status!=='dead'&&bot.status!=='retired'&&
|
bot.status==='online'&&
|
||||||
bot.name.toLowerCase()===lower
|
bot.name.toLowerCase()===lower
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ async function modelReply(bot:Bot,request:ConversationRequest,state:State,config
|
|||||||
content:JSON.stringify({
|
content:JSON.stringify({
|
||||||
speaker:request.speakerName,
|
speaker:request.speakerName,
|
||||||
channel:request.channel,
|
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,
|
currentZone:request.zoneName||bot.zone,
|
||||||
identity:{name:bot.name,race:bot.race,role:bot.role,tier:bot.tier},
|
identity:{name:bot.name,race:bot.race,role:bot.role,tier:bot.tier},
|
||||||
personality:bot.personality,
|
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);
|
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=[
|
const source=[
|
||||||
`local bot=GetPlayerByName(${luaString(botName)})`,
|
`local bot=GetPlayerByName(${luaString(botName)})`,
|
||||||
'if not bot then error("Conversation bot is not online") end'
|
'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'){
|
if(channel==='whisper'){
|
||||||
source.push(`local receiver=GetPlayerByName(${luaString(speakerName)})`);
|
source.push(`local receiver=GetPlayerByName(${luaString(speakerName)})`);
|
||||||
source.push('if not receiver then error("Conversation player is not online") end');
|
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()
|
at:new Date().toISOString()
|
||||||
});
|
});
|
||||||
bot.memories=bot.memories.slice(-100);
|
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';
|
bot.simulationBand='foreground';
|
||||||
realmEvent(state,'memory',`${bot.name} had a live conversation with ${request.speakerName}.`,bot.id);
|
realmEvent(state,'memory',`${bot.name} had a live conversation with ${request.speakerName}.`,bot.id);
|
||||||
try{
|
try{
|
||||||
@@ -150,16 +167,21 @@ async function remember(store:StateStore,botId:string,request:ConversationReques
|
|||||||
export async function runConversationTick(store:StateStore,db:D1Database,config:RuntimeConfig){
|
export async function runConversationTick(store:StateStore,db:D1Database,config:RuntimeConfig){
|
||||||
for(const delivery of await pendingDeliveries(db)){
|
for(const delivery of await pendingDeliveries(db)){
|
||||||
if(delivery.status==='completed'){
|
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}));
|
await completeConversation(db,delivery.request.id,JSON.stringify({commandId:delivery.commandId,delivered:true}));
|
||||||
}else if(delivery.status==='failed'){
|
}else if(delivery.status==='failed'){
|
||||||
await failConversation(db,delivery.request.id,delivery.error||'Game rejected conversation delivery');
|
await failConversation(db,delivery.request.id,delivery.error||'Game rejected conversation delivery');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await cleanupConversations(db);
|
||||||
const request=await leaseConversation(db);
|
const request=await leaseConversation(db);
|
||||||
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
|
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
|
||||||
|
|
||||||
try{
|
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 state=await store.load();
|
||||||
const bot=chooseTarget(state,request);
|
const bot=chooseTarget(state,request);
|
||||||
if(!bot){
|
if(!bot){
|
||||||
@@ -171,7 +193,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
|
|||||||
reply=cleanReply(reply);
|
reply=cleanReply(reply);
|
||||||
if(!reply)throw new Error('Conversation reply was empty');
|
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 awaitDelivery(db,request,bot.id,reply,command.id);
|
||||||
await cleanupConversations(db);
|
await cleanupConversations(db);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
assert.equal(failed,'Bot offline');assert.equal(bot.memories.length,1);
|
||||||
}finally{global.fetch=originalFetch;}
|
}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;}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user