137 lines
16 KiB
TypeScript
137 lines
16 KiB
TypeScript
import type {Bot,State,StateStore,SocialEntry} from './types';
|
|
import {luaString} from './ale';
|
|
import {queueAleCommand} from './ale-store';
|
|
|
|
export function community(s:State){return s.community??(s.community={feed:[],evidence:[],guilds:[],partiesEnabled:true,guildsEnabled:true});}
|
|
export function socialEntry(s:State,e:SocialEntry){const c=community(s);if(!c.feed.some(x=>x.id===e.id))c.feed.push(e);c.feed=c.feed.slice(-250);}
|
|
export function bondEvidence(s:State,id:string,a:Bot,b:Bot,reason:string,delta:number){
|
|
const c=community(s);if(c.evidence.includes(id)||a.id===b.id)return false;
|
|
c.evidence.push(id);c.evidence=c.evidence.slice(-1000);
|
|
for(const [from,to] of [[a,b],[b,a]]){
|
|
let r=from.relationships.find(x=>x.targetId===to.id);
|
|
if(!r){r={targetId:to.id,affinity:0,note:reason};from.relationships.push(r);}
|
|
r.affinity=Math.max(-100,Math.min(100,r.affinity+delta));r.note=reason;
|
|
from.relationships=from.relationships.slice(-100);
|
|
}
|
|
socialEntry(s,{id,at:new Date().toISOString(),kind:'relationship',names:[a.name,b.name],text:reason,affinityDelta:delta,status:'confirmed'});
|
|
return true;
|
|
}
|
|
export async function changeCommunity(store:StateStore,fn:(s:State)=>void){
|
|
for(let n=0;n<4;n++){const s=await store.load(),r=s.revision;fn(s);try{await store.save(s,r);return;}catch(e){if(n===3||!(e instanceof Error)||!e.message.includes('State changed'))throw e;}}
|
|
}
|
|
export function observePresence(s:State,b:Bot,p:Record<string,unknown>){
|
|
const at=Number(p.observedAt);if(!Number.isFinite(at)||at<=(b.world?.at??0)||Math.abs(Date.now()/1000-at)>120)return;
|
|
if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return;
|
|
const old=b.world;
|
|
const stationary=old&&old.mapId===p.mapId&&Math.hypot(old.x-p.x,old.y-p.y)<3;
|
|
b.world={trainingPending:typeof p.trainingPending==='number'?p.trainingPending:undefined,trainingLevel:typeof p.trainingLevel==='number'?p.trainingLevel:undefined,trainingTrainer:typeof p.trainingTrainer==='string'?p.trainingTrainer:undefined,trainingStatus:typeof p.trainingStatus==='string'?p.trainingStatus:undefined,trainingAt:typeof p.trainingAt==='number'?p.trainingAt:undefined,trainingLearned:typeof p.trainingLearned==='number'?p.trainingLearned:undefined,lastTrainingAt:typeof p.lastTrainingAt==='number'?p.lastTrainingAt:undefined,minDurabilityPct:typeof p.minDurabilityPct==='number'?p.minDurabilityPct:undefined,survivalSince:typeof p.survivalSince==='number'?p.survivalSince:undefined,isDead:typeof p.isDead==='boolean'?p.isDead:undefined,isGhost:typeof p.isGhost==='boolean'?p.isGhost:undefined,manaPct:typeof p.manaPct==='number'?p.manaPct:undefined,leaderName:typeof p.leaderName==='string'?p.leaderName:undefined,brokenItems:typeof p.brokenItems==='number'?p.brokenItems:undefined,freeTalents:typeof p.freeTalents==='number'?p.freeTalents:undefined,foodCount:typeof p.foodCount==='number'?p.foodCount:undefined,drinkCount:typeof p.drinkCount==='number'?p.drinkCount:undefined,readinessAt:typeof p.readinessAt==='number'?p.readinessAt:undefined,deathMemoryId:typeof p.deathMemoryId==='number'?p.deathMemoryId:undefined,deathMemoryAt:typeof p.deathMemoryAt==='number'?p.deathMemoryAt:undefined,deathMemoryKiller:typeof p.deathMemoryKiller==='string'?p.deathMemoryKiller:undefined,deathMemoryMap:typeof p.deathMemoryMap==='number'?p.deathMemoryMap:undefined,deathMemoryX:typeof p.deathMemoryX==='number'?p.deathMemoryX:undefined,deathMemoryY:typeof p.deathMemoryY==='number'?p.deathMemoryY:undefined,deathMemoryLevel:typeof p.deathMemoryLevel==='number'?p.deathMemoryLevel:undefined,quests:Array.isArray(p.quests)?p.quests.filter((q):q is number=>typeof q==='number'&&q>0).slice(0,25):undefined,dangerAt:typeof p.dangerAt==='number'?p.dangerAt:old?.dangerAt,survivalAction:typeof p.survivalAction==='string'?p.survivalAction:undefined,survivalAt:typeof p.survivalAt==='number'?p.survivalAt:undefined,combatRole:typeof p.combatRole==='string'?p.combatRole:undefined,casting:p.casting===true,taxi:p.taxi===true,humanControlled:p.humanControlled===true,movementType:typeof p.movementType==='number'?p.movementType:undefined,healthPct:typeof p.healthPct==='number'?p.healthPct:undefined,at,team:p.team,guildId:Number(p.guildId)||0,guildName:String(p.guildName||''),guildRank:Number(p.guildRank)||0,groupId:String(p.groupId||''),groupNames:String(p.groupNames||'').split(',').filter(Boolean),nearby:String(p.nearbyBots||'').split(',').filter(Boolean),inCombat:p.inCombat===true,x:p.x,y:p.y,mapId:Number(p.mapId)||0,stationarySince:stationary?(old.stationarySince??old.at):at};
|
|
if(b.world.deathMemoryId && b.world.deathMemoryId>(b.deathMemorySeenId??0)){
|
|
b.deathMemorySeenId=b.world.deathMemoryId;
|
|
b.memories.push({id:`native-death:${b.world.deathMemoryId}`,text:`I died to ${b.world.deathMemoryKiller??'an unknown enemy'} at level ${b.world.deathMemoryLevel??b.level}, map ${b.world.deathMemoryMap}, near (${Math.round(b.world.deathMemoryX??0)}, ${Math.round(b.world.deathMemoryY??0)}). Approach similar enemies there cautiously; recover before fighting again.`,importance:10,source:'observation',at:new Date((b.world.deathMemoryAt??at)*1000).toISOString()});
|
|
b.memories=b.memories.slice(-100);
|
|
}
|
|
const c=community(s);
|
|
if(b.world.guildId&&!c.guilds.some(g=>g.id===b.world!.guildId))c.guilds.push({id:b.world.guildId,name:b.world.guildName,goal:['quest','support','explore'][b.world.guildId%3] as 'quest'|'support'|'explore',progress:0,target:20});
|
|
if(old&&old.mapId!==b.world.mapId&&b.world.guildId){
|
|
const g=c.guilds.find(g=>g.id===b.world!.guildId),key=`explore:${b.id}:${b.world.mapId}:${Math.floor(at/86400)}`;
|
|
if(g?.goal==='explore'&&!c.evidence.includes(key)){g.progress++;c.evidence.push(key);c.evidence=c.evidence.slice(-1000);socialEntry(s,{id:key,at:new Date().toISOString(),kind:'guild',names:[b.name,g.name],text:'Reached another map for the guild exploration goal.',status:'confirmed'});}
|
|
}
|
|
|
|
}
|
|
export function applyCooperation(s:State,p:Record<string,unknown>){
|
|
const at=Number(p.observedAt);if(!Number.isFinite(at)||Math.abs(Date.now()/1000-at)>120)return;
|
|
const id=String(p.evidenceId||'');if(!id||community(s).evidence.includes(id))return;
|
|
const a=s.bots.find(b=>b.autonomy?.characterGuid===p.characterGuid),b=s.bots.find(b=>b.autonomy?.characterGuid===p.peerGuid);
|
|
if(!a||!b||a.status==='dead'||b.status==='dead')return;
|
|
const kind=p.cooperation;
|
|
if(kind!=='quest'&&kind!=='assist')return;
|
|
if(bondEvidence(s,id,a,b,kind==='quest'?'Completed the same quest while adventuring together.':'Helped defeat an enemy together.',kind==='quest'?3:1)){
|
|
if(kind==='quest')socialEntry(s,{id:id+':quest',at:new Date(at*1000).toISOString(),kind:'sharedQuest',names:[a.name,b.name],text:`Completed quest ${String(p.questId)} together.`,status:'confirmed'});
|
|
for(const bot of [a,b]){bot.memories.push({id,text:kind==='quest'?`Completed quest ${String(p.questId)} with ${bot.id===a.id?b.name:a.name}.`:`Fought alongside ${bot.id===a.id?b.name:a.name}.`,importance:6,source:'observation',at:new Date().toISOString()});bot.memories=bot.memories.slice(-100);}
|
|
if(a.world?.guildId&&a.world.guildId===b.world?.guildId){const g=community(s).guilds.find(g=>g.id===a.world!.guildId);if(g&&((g.goal==='quest'&&kind==='quest')||(g.goal==='support'&&kind==='assist')))g.progress++;}
|
|
}
|
|
}
|
|
const affinity=(a:Bot,b:Bot)=>a.relationships.find(r=>r.targetId===b.id)?.affinity??0;
|
|
export const recentDanger=(b:Bot)=>!!b.world?.dangerAt&&Date.now()/1000-b.world.dangerAt<600;
|
|
export function companionScore(a:Bot,b:Bot){
|
|
const roleBonus=b.world?.combatRole==='healer'&&a.world?.combatRole!=='healer'?40:b.world?.combatRole==='tank'&&a.world?.combatRole!=='tank'?20:0;
|
|
const shared=(a.world?.quests??[]).filter(q=>b.world?.quests?.includes(q)).length;
|
|
return Math.min(40,shared*15)+(recentDanger(b)?30:0)+roleBonus+Math.min(20,Math.max(0,affinity(a,b)))+Math.max(0,10-Math.abs(a.level-b.level));
|
|
}
|
|
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.inCombat&&!b.world.humanControlled&&!b.world.taxi&&!b.world.casting;
|
|
export function partyDecision(a:Bot,b:Bot){
|
|
if(!fresh(a)||!fresh(b)||a.id===b.id||a.world!.team!==b.world!.team||Math.abs(a.level-b.level)>5||!a.world!.nearby.includes(b.name)||!b.world!.nearby.includes(a.name))return {accepted:false,reason:'No compatible nearby pair'};
|
|
const danger=recentDanger(a)||recentDanger(b);
|
|
const willing=(x:Bot,y:Bot)=>affinity(x,y)>=0&&(danger||x.personality.sociability>=35||affinity(x,y)>=5)&&x.personality.activities.some(v=>v==='quest'||v==='explore');
|
|
return {accepted:willing(a,b)&&willing(b,a),reason:willing(a,b)&&willing(b,a)?danger?'Seeking safety together after recent danger; compatible faction, levels, and non-hostile companions.':'Both accepted: compatible levels, shared adventure interests, and no hostile bond.':'One character prefers to adventure alone or does not trust the other.'};
|
|
}
|
|
export function compileCommunityAction(kind:'party'|'leave'|'recruit',a:Bot,b?:Bot){
|
|
if(kind==='party'){
|
|
if(!b||!a.autonomy?.accountId||!b.autonomy?.accountId)throw new Error('Verified native accounts required');
|
|
const identity=(p:Bot)=>`{name=${luaString(p.name)},guid=${p.autonomy!.characterGuid},account=${p.autonomy!.accountId}}`;
|
|
return `if not AzerothAIQueueParty then error("Native invitation guard unavailable") end; return AzerothAIQueueParty(__command_id,${identity(a)},${identity(b)},${Math.floor(Date.now()/1000)+45})`;
|
|
}
|
|
const lines=[`local a=GetPlayerByName(${luaString(a.name)})`,`if not a or a:GetGUIDLow()~=${a.autonomy!.characterGuid} or not a:IsPlayerbot() or a:IsDead() or a:IsInCombat() or a:HasUnitState(0x00000100) or a:IsCasting() or a:GetBotAI():HasGameClientMaster() then error('Resident unavailable') end`];
|
|
if(kind==='leave'){
|
|
lines.push(`local g=a:GetGroup();if not g then return 'Already solo' end`,`if tostring(g:GetGUID())~=${luaString(a.world!.groupId)} then error('Party changed') end`,`if #g:GetMembers()~=g:GetMembersCount() then error('Party has offline members') end;for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() or m:GetBotAI():HasGameClientMaster() then error('Human party is protected') end end`,`a:RemoveFromGroup()`,`if a:GetGroup() then error('Departure was not applied') end`);
|
|
}else{
|
|
if(!b)throw new Error('Peer required');
|
|
lines.push(`local b=GetPlayerByName(${luaString(b.name)})`,`if not b or b:GetGUIDLow()~=${b.autonomy!.characterGuid} or not b:IsPlayerbot() or b:IsDead() or b:IsInCombat() or b:HasUnitState(0x00000100) or b:IsCasting() or b:GetBotAI():HasGameClientMaster() or a:GetTeam()~=b:GetTeam() or a:GetMapId()~=b:GetMapId() or a:GetPhaseMask()~=b:GetPhaseMask() or a:GetDistance(b)>35 then error('Peer unavailable nearby') end`);
|
|
lines.push(`local g=a:GetGuild();if not g or a:GetGuildId()~=${a.world!.guildId} or a:GetGuildRank()~=0 then error('Guild leader unavailable') end`,`if b:GetGuildId()==a:GetGuildId() then return 'Already a member' end`,`if b:GetGuildId()~=0 then error('Already belongs to a guild') end`,`g:AddMember(b)`,`if b:GetGuildId()~=a:GetGuildId() then error('Guild admission failed') end`);
|
|
}
|
|
lines.push('AzerothAIEmitSnapshots()','return "Confirmed '+kind+'"');return lines.join('; ');
|
|
}
|
|
async function ensure(db:D1Database){
|
|
await db.prepare('CREATE TABLE IF NOT EXISTS community_actions (id TEXT PRIMARY KEY,created_at TEXT,kind TEXT,a TEXT,b TEXT,command_id TEXT,status TEXT,reason TEXT)').run();
|
|
await db.prepare('CREATE TABLE IF NOT EXISTS community_clock (id TEXT PRIMARY KEY,at INTEGER)').run();
|
|
}
|
|
export async function communityTick(store:StateStore,db:D1Database){
|
|
await ensure(db);
|
|
const pending=await db.prepare("SELECT * FROM community_actions WHERE status='pending' LIMIT 20").all<any>();
|
|
for(const action of pending.results){
|
|
const receipt=await db.prepare('SELECT status,error FROM ale_commands WHERE id=?').bind(action.command_id).first<any>();
|
|
if(!receipt||!['completed','failed'].includes(receipt.status))continue;
|
|
await changeCommunity(store,s=>{
|
|
const a=s.bots.find(b=>b.id===action.a),b=s.bots.find(b=>b.id===action.b);if(!a)return;
|
|
const proposal=community(s).feed.find(e=>e.id===action.id+':proposal');if(proposal){proposal.status=receipt.status==='completed'?'confirmed':'failed';proposal.text=action.reason;}
|
|
socialEntry(s,{id:action.id,at:new Date().toISOString(),kind:action.kind,names:[a.name,...(b?[b.name]:[])],text:receipt.status==='completed'?action.reason:String(receipt.error||'Game rejected action').replace(/^.*:\d+:\s*/,''),status:receipt.status==='completed'?'confirmed':'failed'});
|
|
if(receipt.status==='completed'&&b&&action.kind==='party'&&bondEvidence(s,action.id+':bond',a,b,'Accepted an invitation to adventure together.',1)){
|
|
for(const [from,to] of [[a,b],[b,a]]){from.memories.push({id:action.id,text:'Joined a party with '+to.name+' to adventure together.',importance:6,source:'observation',at:new Date().toISOString()});from.memories=from.memories.slice(-100);}
|
|
}
|
|
});
|
|
await db.prepare('UPDATE community_actions SET status=? WHERE id=?').bind(receipt.status,action.id).run();
|
|
}
|
|
const now=Date.now();
|
|
await db.prepare("INSERT OR IGNORE INTO community_clock VALUES ('tick',0)").run();
|
|
const gate=await db.prepare("UPDATE community_clock SET at=? WHERE id='tick' AND at<?").bind(now,now-30_000).run();if(gate.meta.changes!==1)return;
|
|
await db.prepare("DELETE FROM community_actions WHERE status!='pending' AND created_at<?").bind(new Date(now-7*86400_000).toISOString()).run();
|
|
const s=await store.load(),c=community(s),bots=s.bots.filter(b=>fresh(b)).sort((a,b)=>Number(recentDanger(b))-Number(recentDanger(a)));
|
|
const recent=await db.prepare('SELECT a,b,created_at,status FROM community_actions WHERE created_at>?').bind(new Date(now-10*60_000).toISOString()).all<any>();
|
|
const busy=new Set([...pending.results.flatMap(r=>[r.a,r.b]),...recent.results.filter(r=>r.status==='pending'||now-Date.parse(r.created_at)<(r.status==='completed'?120_000:60_000)).flatMap(r=>[r.a,r.b])]);
|
|
for(const a of bots){
|
|
if(busy.has(a.id))continue;
|
|
// Leave only a bot-only party whose peers have moved away, never a human party.
|
|
const recoveringPeer=a.world!.groupNames.some(name=>s.bots.some(peer=>peer.name===name&&peer.status!=='dead'&&peer.world&&Date.now()/1000-peer.world.at<90&&(peer.world.healthPct??100)<=0));
|
|
const far=!recoveringPeer&&a.world!.groupId&&a.world!.groupNames.length>1&&a.world!.groupNames.filter(n=>n!==a.name).every(n=>!a.world!.nearby.includes(n));
|
|
if(c.partiesEnabled&&far){
|
|
await db.prepare('INSERT OR IGNORE INTO community_clock VALUES (?,?)').bind('apart:'+a.id,now).run();
|
|
const apart=await db.prepare('SELECT at FROM community_clock WHERE id=?').bind('apart:'+a.id).first<{at:number}>();
|
|
if(apart&&now-apart.at>20*60_000){await propose('leave',a,undefined,'Left the party after twenty minutes apart despite nearby regroup attempts.');return;}
|
|
}else await db.prepare('DELETE FROM community_clock WHERE id=?').bind('apart:'+a.id).run();
|
|
for(const b of [...bots].sort((x,y)=>companionScore(a,y)-companionScore(a,x))){
|
|
if(busy.has(b.id)||a.id===b.id||!a.world!.nearby.includes(b.name))continue;
|
|
if(c.guildsEnabled&&a.world!.guildId&&a.world!.guildRank===0&&!b.world!.guildId&&affinity(a,b)>=3&&affinity(b,a)>=3&&partyDecision(a,b).accepted){await propose('recruit',a,b,`Accepted an invitation to ${a.world!.guildName} after building mutual trust.`);return;}
|
|
if(c.partiesEnabled&&!b.world!.groupId&&(!a.world!.groupId||a.world!.groupNames.length<5)){
|
|
const decision=partyDecision(a,b);
|
|
if(!decision.accepted)continue;
|
|
await propose('party',a,b,decision.reason);return;
|
|
}
|
|
}
|
|
}
|
|
async function propose(kind:'party'|'leave'|'recruit',a:Bot,b:Bot|undefined,reason:string){
|
|
const command=await queueAleCommand(db,compileCommunityAction(kind,a,b));
|
|
await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(command.id,new Date().toISOString(),kind,a.id,b?.id??'',command.id,'pending',reason).run();
|
|
await changeCommunity(store,s=>socialEntry(s,{id:command.id+':proposal',at:new Date().toISOString(),kind:'invitation',names:[a.name,...(b?[b.name]:[])],text:reason+' Awaiting game confirmation.',status:'pending'}));
|
|
}
|
|
}
|