Compare commits
9
Commits
d573ae2345
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db5570a1ee | ||
|
|
38887997fb | ||
|
|
9af188f7e1 | ||
|
|
6eda894689 | ||
|
|
ec2bfda198 | ||
|
|
1f9ad52d07 | ||
|
|
502cb1e4fd | ||
|
|
20ccd0fced | ||
|
|
9d1575b8c6 |
@@ -0,0 +1,45 @@
|
|||||||
|
name: AzerothAI Host Operations
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
operation:
|
||||||
|
description: Approved AzerothAI operation
|
||||||
|
required: true
|
||||||
|
default: status
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
azeroth:
|
||||||
|
|
||||||
|
runs-on: azerothai-host
|
||||||
|
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
env:
|
||||||
|
AZEROTH_OPERATION: ${{ inputs.operation }}
|
||||||
|
AZEROTH_ACTOR: ${{ gitea.actor }}
|
||||||
|
AZEROTH_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Verify authorized request
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$AZEROTH_REPOSITORY" != "richard/AzerothAI" ]; then
|
||||||
|
echo "Rejected repository: $AZEROTH_REPOSITORY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$AZEROTH_ACTOR" != "richard" ]; then
|
||||||
|
echo "Rejected actor: $AZEROTH_ACTOR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Execute approved operation
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sudo -n /usr/local/bin/azerothai-ops "$AZEROTH_OPERATION"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---BEGIN WORKFLOW---
|
||||||
|
ame: AzerothAI Validated Patch
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
patch_file:
|
||||||
|
description: Patch file under ops/patches/
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
patch:
|
||||||
|
runs-on: azerothai-host
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
PATCH_FILE: ${{ inputs.patch_file }}
|
||||||
|
AZEROTH_ACTOR: ${{ gitea.actor }}
|
||||||
|
AZEROTH_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Verify authorized request
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[ "$AZEROTH_ACTOR" = "richard" ] || {
|
||||||
|
echo "Unauthorized actor: $AZEROTH_ACTOR"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[ "$AZEROTH_REPOSITORY" = "richard/AzerothAI" ] || {
|
||||||
|
echo "Wrong repository: $AZEROTH_REPOSITORY"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
base="${PATCH_FILE#ops/patches/}"
|
||||||
|
|
||||||
|
[ "$base" != "$PATCH_FILE" ] || {
|
||||||
|
echo "Patch must be under ops/patches/"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ "$base" != */* ]] || {
|
||||||
|
echo "Nested patch paths are not allowed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ "$base" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.patch$ ]] || {
|
||||||
|
echo "Invalid patch filename"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Check out patch source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Apply validated patch
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[ -f "$PATCH_FILE" ] || {
|
||||||
|
echo "Patch file not found: $PATCH_FILE"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
sha="$(sha256sum "$PATCH_FILE" | awk '{print $1}')"
|
||||||
|
payload="$(base64 -w0 "$PATCH_FILE")"
|
||||||
|
|
||||||
|
sudo -n /usr/local/bin/azerothai-ops "apply-patch:${sha}:${payload}"
|
||||||
|
---END WORKFLOW---
|
||||||
@@ -142,6 +142,37 @@ local function nearby_bot_names(player)
|
|||||||
return table.concat(names,",")
|
return table.concat(names,",")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Read only server knowledge; cache static quest text and bound prompt size.
|
||||||
|
local questKnowledge={}
|
||||||
|
local function decision_knowledge(player,p)
|
||||||
|
p.xp=player:GetXP()
|
||||||
|
local quests={}
|
||||||
|
for _,id in ipairs(p.quests) do
|
||||||
|
if #quests>=8 then break end
|
||||||
|
local q=questKnowledge[id]
|
||||||
|
if not q then
|
||||||
|
local row=WorldDBQuery('SELECT LogTitle,QuestLevel FROM quest_template WHERE ID='..id)
|
||||||
|
q=row and {title=string.sub(row:GetString(0) or '',1,100),level=row:GetInt32(1)} or {title='Quest '..id,level=0}
|
||||||
|
questKnowledge[id]=q
|
||||||
|
end
|
||||||
|
local ok,status=pcall(function() return player:GetQuestStatus(id) end)
|
||||||
|
local label=ok and (status==1 and 'ready to turn in' or status==3 and 'in progress' or status==5 and 'failed' or 'status '..tostring(status)) or 'status unavailable'
|
||||||
|
quests[#quests+1]=id..': '..q.title..' (level '..q.level..', '..label..')'
|
||||||
|
end
|
||||||
|
p.questBrief=table.concat(quests,'; ')
|
||||||
|
local threats={}
|
||||||
|
for _,enemy in pairs(player:GetUnfriendlyUnitsInRange(35) or {}) do
|
||||||
|
if not enemy:IsDead() then threats[#threats+1]=enemy end
|
||||||
|
end
|
||||||
|
table.sort(threats,function(a,b)return player:GetDistance(a)<player:GetDistance(b) end)
|
||||||
|
local descriptions={}
|
||||||
|
for i=1,math.min(4,#threats) do
|
||||||
|
local e=threats[i];local c=e:ToCreature()
|
||||||
|
descriptions[#descriptions+1]=string.sub(e:GetName(),1,70)..' level '..e:GetLevel()..' at '..math.floor(player:GetDistance(e))..' yards'..(c and c:GetRank()>0 and ' (non-normal rank)' or '')
|
||||||
|
end
|
||||||
|
p.threatBrief=table.concat(descriptions,'; ')
|
||||||
|
end
|
||||||
|
|
||||||
local function world_payload(player)
|
local function world_payload(player)
|
||||||
local p=player_payload(player)
|
local p=player_payload(player)
|
||||||
local ai=player:IsPlayerbot() and player:GetBotAI() or nil
|
local ai=player:IsPlayerbot() and player:GetBotAI() or nil
|
||||||
@@ -162,6 +193,8 @@ local function world_payload(player)
|
|||||||
if AzerothAIExperienceSnapshot then for k,v in pairs(AzerothAIExperienceSnapshot(player)) do p[k]=v end end
|
if AzerothAIExperienceSnapshot then for k,v in pairs(AzerothAIExperienceSnapshot(player)) do p[k]=v end end
|
||||||
if AzerothAITrainingSnapshot then for k,v in pairs(AzerothAITrainingSnapshot(player)) do p[k]=v end end
|
if AzerothAITrainingSnapshot then for k,v in pairs(AzerothAITrainingSnapshot(player)) do p[k]=v end end
|
||||||
if AzerothAIReadinessSnapshot then for k,v in pairs(AzerothAIReadinessSnapshot(player)) do p[k]=v end end
|
if AzerothAIReadinessSnapshot then for k,v in pairs(AzerothAIReadinessSnapshot(player)) do p[k]=v end end
|
||||||
|
local ok,err=pcall(decision_knowledge,player,p)
|
||||||
|
if not ok then p.questBrief=nil;p.threatBrief=nil end
|
||||||
p.nearbyBots=nearby_bot_names(player)
|
p.nearbyBots=nearby_bot_names(player)
|
||||||
return p
|
return p
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -174,6 +174,22 @@ local function sample()
|
|||||||
r.dangerAt=now;r.retreatAt=now;r.action='retreat requested';r.actionAt=now
|
r.dangerAt=now;r.retreatAt=now;r.action='retreat requested';r.actionAt=now
|
||||||
if p:IsCasting() then p:StopSpellCast() end
|
if p:IsCasting() then p:StopSpellCast() end
|
||||||
r.accepted=ai:DoSpecificAction('flee',true)
|
r.accepted=ai:DoSpecificAction('flee',true)
|
||||||
|
-- The bot action may reject fleeing when its target/context is missing.
|
||||||
|
-- Bound a native pathfinding attempt; never teleport or clear combat/threat.
|
||||||
|
if not r.accepted and not p:IsMoving() and now-(r.nativeFleeAt or 0)>=15 then
|
||||||
|
local threat=enemy
|
||||||
|
if not threat then for _,attacker in pairs(p:GetAttackers()) do threat=attacker;break end end
|
||||||
|
if threat then
|
||||||
|
local dx,dy=p:GetX()-threat:GetX(),p:GetY()-threat:GetY()
|
||||||
|
local distance=math.sqrt(dx*dx+dy*dy)
|
||||||
|
if distance>0.1 then
|
||||||
|
p:AttackStop()
|
||||||
|
p:MoveTo(7822,p:GetX()+20*dx/distance,p:GetY()+20*dy/distance,p:GetZ(),true)
|
||||||
|
r.nativeFleeAt=now
|
||||||
|
audit(p,'retreat-path-request','Playerbot flee rejected; 20-yard path away from attacker requested')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
audit(p,'retreat-request', 'accepted='..tostring(r.accepted)..'; hp='..math.floor(hp)..'; dangerous='..tostring(overmatched)..'; attackers='..attackers..'; nearbySupport='..support)
|
audit(p,'retreat-request', 'accepted='..tostring(r.accepted)..'; hp='..math.floor(hp)..'; dangerous='..tostring(overmatched)..'; attackers='..attackers..'; nearbySupport='..support)
|
||||||
if not r.retreat then r.retreat={at=now,map=p:GetMapId(),x=p:GetX(),y=p:GetY()} end
|
if not r.retreat then r.retreat={at=now,map=p:GetMapId(),x=p:GetX(),y=p:GetY()} end
|
||||||
elseif avoid and now-(r.avoidAt or 0)>=8 then
|
elseif avoid and now-(r.avoidAt or 0)>=8 then
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {env} from 'cloudflare:workers';
|
||||||
|
import {z} from 'zod';
|
||||||
|
import {requireAleBridge} from '@/core/ale-auth';
|
||||||
|
import {recordAleEvent} from '@/core/ale-store';
|
||||||
|
import {queueSocialEncounter} from '@/core/conversation-store';
|
||||||
|
import {D1Store} from '@/core/store';
|
||||||
|
import {applyAuthoritativeAleSnapshots} from '@/core/service';
|
||||||
|
import type {RuntimeConfig} from '@/core/types';
|
||||||
|
const schema=z.object({payloads:z.array(z.record(z.unknown())).min(1).max(64)}).strict();
|
||||||
|
export async function POST(request:Request){
|
||||||
|
try{
|
||||||
|
requireAleBridge(request,env as unknown as RuntimeConfig);
|
||||||
|
const text=await request.text();
|
||||||
|
if(text.length>512000)return Response.json({error:'Snapshot batch too large'},{status:413});
|
||||||
|
const {payloads}=schema.parse(JSON.parse(text));
|
||||||
|
const result=await applyAuthoritativeAleSnapshots(new D1Store(),payloads);
|
||||||
|
for(const payload of payloads){
|
||||||
|
await recordAleEvent(env.DB!,'player.snapshot',payload);
|
||||||
|
if(payload.roster===true)await queueSocialEncounter(env.DB!,payload);
|
||||||
|
}
|
||||||
|
return Response.json(result);
|
||||||
|
}catch(e){
|
||||||
|
const error=e instanceof Error?e.message:'Snapshot batch failure';
|
||||||
|
return Response.json({error},{status:error.startsWith('Unauthorized')?401:400});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ import type {RuntimeConfig} from '@/core/types';
|
|||||||
import {observeInsights,activityReason,reportWindow,summarize,DAY,type Cursor,type Evidence} from '@/core/realm-insights';
|
import {observeInsights,activityReason,reportWindow,summarize,DAY,type Cursor,type Evidence} from '@/core/realm-insights';
|
||||||
const recovery=z.object({id:z.number().int().positive(),guid:z.number().int().positive(),name:z.string().max(64),at:z.number().finite(),detail:z.string().max(255),status:z.enum(['pending','resumed','no_progress','unverified']).optional(),checkedAt:z.number().nonnegative().optional(),result:z.string().max(255).optional()});
|
const recovery=z.object({id:z.number().int().positive(),guid:z.number().int().positive(),name:z.string().max(64),at:z.number().finite(),detail:z.string().max(255),status:z.enum(['pending','resumed','no_progress','unverified']).optional(),checkedAt:z.number().nonnegative().optional(),result:z.string().max(255).optional()});
|
||||||
const count=z.number().int().nonnegative();
|
const count=z.number().int().nonnegative();
|
||||||
const outcome=z.object({guid:count,name:z.string().max(64),quests:count,abandoned:count,lootEvents:count,lootItems:count,partyKills:count,rewarded:count,lootedCopper:count.default(0)});
|
const outcome=z.object({guid:count,name:z.string().max(64),quests:count,abandoned:count,lootEvents:count,lootItems:count,partyKills:count,rewarded:count,lootedCopper:count.default(0),nativeDeaths:count.optional()});
|
||||||
const measurement=z.number().finite().nonnegative();
|
const measurement=z.number().finite().nonnegative();
|
||||||
const performance=z.object({at:measurement,enabled:z.boolean(),ticks:measurement,averageTickMs:measurement,rows:z.array(z.object({category:z.string().max(40),name:z.string().max(160),calls:measurement,totalMs:measurement,averageMs:measurement,maxMs:measurement,perTickMs:measurement})).max(20)});
|
const performance=z.object({at:measurement,enabled:z.boolean(),ticks:measurement,averageTickMs:measurement,rows:z.array(z.object({category:z.string().max(40),name:z.string().max(160),calls:measurement,totalMs:measurement,averageMs:measurement,maxMs:measurement,perTickMs:measurement})).max(20)});
|
||||||
async function ensure(){await env.DB!.batch([
|
async function ensure(){await env.DB!.batch([
|
||||||
|
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_evaluation (id INTEGER PRIMARY KEY,started_at INTEGER)'),
|
||||||
|
env.DB!.prepare('INSERT OR IGNORE INTO insights_evaluation VALUES (1,?)').bind(Math.floor(Date.now()/1000)),
|
||||||
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_native (id INTEGER PRIMARY KEY,payload TEXT)'),
|
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_native (id INTEGER PRIMARY KEY,payload TEXT)'),
|
||||||
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_performance (at INTEGER PRIMARY KEY,payload TEXT)'),
|
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_performance (at INTEGER PRIMARY KEY,payload TEXT)'),
|
||||||
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_cursor (id INTEGER PRIMARY KEY,payload TEXT)'),
|
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_cursor (id INTEGER PRIMARY KEY,payload TEXT)'),
|
||||||
@@ -38,8 +40,14 @@ export async function GET(){
|
|||||||
const totals:Record<string,number>={};for(const e of aggregates.results)totals[e.kind]=(totals[e.kind]??0)+e.amount;
|
const totals:Record<string,number>={};for(const e of aggregates.results)totals[e.kind]=(totals[e.kind]??0)+e.amount;
|
||||||
const byBot=s.bots.filter(b=>b.autonomy?.managed&&b.status==='online').map(b=>({name:b.name,zone:b.zone,grouped:!!b.world?.groupId&&now-b.world.at<120,groupBots:(b.world?.groupNames??[]).filter(n=>s.bots.some(o=>o.name===n&&o.autonomy?.managed)),totals:Object.fromEntries([...counterKeys,'groupedSeconds','nearPartySeconds','sharedQuests'].map(k=>[k,aggregates.results.filter(e=>e.name===b.name&&e.kind===k).reduce((sum,e)=>sum+e.amount,0)]))}));
|
const byBot=s.bots.filter(b=>b.autonomy?.managed&&b.status==='online').map(b=>({name:b.name,zone:b.zone,grouped:!!b.world?.groupId&&now-b.world.at<120,groupBots:(b.world?.groupNames??[]).filter(n=>s.bots.some(o=>o.name===n&&o.autonomy?.managed)),totals:Object.fromEntries([...counterKeys,'groupedSeconds','nearPartySeconds','sharedQuests'].map(k=>[k,aggregates.results.filter(e=>e.name===b.name&&e.kind===k).reduce((sum,e)=>sum+e.amount,0)]))}));
|
||||||
const publicRecoveries=attempts.map((r:Recovery)=>({name:r.name,at:r.at,detail:r.detail,status:r.status??'unverified',checkedAt:r.checkedAt??null,result:r.result??'No verification recorded'}));
|
const publicRecoveries=attempts.map((r:Recovery)=>({name:r.name,at:r.at,detail:r.detail,status:r.status??'unverified',checkedAt:r.checkedAt??null,result:r.result??'No verification recorded'}));
|
||||||
|
const evaluationRow=await env.DB!.prepare('SELECT started_at FROM insights_evaluation WHERE id=1').first<{started_at:number}>();
|
||||||
|
const evaluationStart=Math.max(now-DAY,evaluationRow?.started_at??now);
|
||||||
|
const evaluationRows=await env.DB!.prepare("SELECT json_extract(payload,'$.kind') AS kind,SUM(json_extract(payload,'$.amount')) AS amount FROM insights_evidence WHERE at>=? GROUP BY kind").bind(evaluationStart).all<{kind:string;amount:number}>();
|
||||||
|
const evaluationTotals=Object.fromEntries(evaluationRows.results.map(r=>[r.kind,r.amount]));
|
||||||
|
const hours=(evaluationTotals.onlineSeconds??0)/3600;
|
||||||
|
const evaluation={since:evaluationStart,onlineBotHours:hours,activeBotHours:(evaluationTotals.activeSeconds??0)/3600,deaths:evaluationTotals.nativeDeaths??0,deathsPerBotHour:hours?(evaluationTotals.nativeDeaths??0)/hours:null,questTurnIns:evaluationTotals.rewarded??0,questTurnInsPerBotHour:hours?(evaluationTotals.rewarded??0)/hours:null,intentsObserved:evaluationTotals.intentObserved??0,intentsUnverified:evaluationTotals.intentUnverified??0,intentsInterrupted:evaluationTotals.intentInterrupted??0,nearbyPartyHours:(evaluationTotals.nearPartySeconds??0)/3600};
|
||||||
const attention=attentionItems(s,now,cursor?.at??null,native?.at??null,perf,attempts,health);
|
const attention=attentionItems(s,now,cursor?.at??null,native?.at??null,perf,attempts,health);
|
||||||
return Response.json({attention,outcomes:{at:native?.at??null,startedAt:native?.startedAt??null,windowStart:Math.max(now-DAY,native?.startedAt??now),totals,bots:byBot,recent:recent.slice(0,60)},recoveryResults:publicRecoveries,performance:{latest:perf,history:perfHistory.map(p=>({at:p.at,averageTickMs:p.averageTickMs,ticks:p.ticks})).reverse()},collectedAt:cursor?.at??null,startedAt:cursor?.startedAt??null,nextReportAt:reportWindow(now)+DAY,current:await report(reportWindow(now),now+1,cursor?.startedAt??now),reports:history.results.map(r=>JSON.parse(r.payload)),activity:s.bots.filter(b=>b.autonomy?.managed&&b.status==='online').map(b=>({name:b.name,zone:b.zone,action:b.lastDecision?.action??'unknown',observedAt:b.world?.at??null,stationarySince:b.world?.stationarySince??null,lastProgressAt:cursor?.bots[b.id]?.lastProgressAt??null,...activityReason(b,now),recoveries:attempts.filter(r=>r.guid===b.autonomy?.characterGuid).slice(0,3).map(({at,detail})=>({at,detail}))}))},{headers:{'Cache-Control':'no-store'}});
|
return Response.json({evaluation,attention,outcomes:{at:native?.at??null,startedAt:native?.startedAt??null,windowStart:Math.max(now-DAY,native?.startedAt??now),totals,bots:byBot,recent:recent.slice(0,60)},recoveryResults:publicRecoveries,performance:{latest:perf,history:perfHistory.map(p=>({at:p.at,averageTickMs:p.averageTickMs,ticks:p.ticks})).reverse()},collectedAt:cursor?.at??null,startedAt:cursor?.startedAt??null,nextReportAt:reportWindow(now)+DAY,current:await report(reportWindow(now),now+1,cursor?.startedAt??now),reports:history.results.map(r=>JSON.parse(r.payload)),activity:s.bots.filter(b=>b.autonomy?.managed&&b.status==='online').map(b=>({name:b.name,zone:b.zone,action:b.lastDecision?.action??'unknown',observedAt:b.world?.at??null,stationarySince:b.world?.stationarySince??null,lastProgressAt:cursor?.bots[b.id]?.lastProgressAt??null,...activityReason(b,now),intentResults:(b.autonomy?.intentChecks??[]).slice(-3).map(({action,status,detail,startedAt,checkedAt})=>({action,status,detail,startedAt,checkedAt})),recoveries:attempts.filter(r=>r.guid===b.autonomy?.characterGuid).slice(0,3).map(({at,detail})=>({at,detail}))}))},{headers:{'Cache-Control':'no-store'}});
|
||||||
}
|
}
|
||||||
export async function POST(request:Request){
|
export async function POST(request:Request){
|
||||||
const token=(env as unknown as RuntimeConfig).AUTONOMY_TOKEN;if(!token||request.headers.get('authorization')!==`Bearer ${token}`)return Response.json({error:'Unauthorized'},{status:401});
|
const token=(env as unknown as RuntimeConfig).AUTONOMY_TOKEN;if(!token||request.headers.get('authorization')!==`Bearer ${token}`)return Response.json({error:'Unauthorized'},{status:401});
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import {useEffect,useState} from 'react';
|
|||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
import type {summarize} from '@/core/realm-insights';
|
import type {summarize} from '@/core/realm-insights';
|
||||||
type Report=ReturnType<typeof summarize>;
|
type Report=ReturnType<typeof summarize>;
|
||||||
type Activity={name:string;zone:string;action:string;label:string;detail:string;observedAt:number|null;lastProgressAt:number|null;recoveries:{at:number;detail:string}[]};
|
type Activity={name:string;zone:string;action:string;label:string;detail:string;observedAt:number|null;lastProgressAt:number|null;intentResults?:{action:string;status:string;detail:string}[];recoveries:{at:number;detail:string}[]};
|
||||||
type Insights={collectedAt:number|null;startedAt:number|null;nextReportAt:number;current:Report;reports:Report[];activity:Activity[]};
|
type Insights={evaluation?:{since:number;onlineBotHours:number;activeBotHours:number;deaths:number;deathsPerBotHour:number|null;questTurnInsPerBotHour:number|null;intentsObserved:number;intentsUnverified:number;intentsInterrupted:number;nearbyPartyHours:number};collectedAt:number|null;startedAt:number|null;nextReportAt:number;current:Report;reports:Report[];activity:Activity[]};
|
||||||
const stamp=(at:number|null)=>at?new Date(at*1000).toLocaleString():'Not yet observed';
|
const stamp=(at:number|null)=>at?new Date(at*1000).toLocaleString():'Not yet observed';
|
||||||
const metrics:Record<string,string>={levels:'Levels gained',deaths:'Verified deaths',arrivals:'First arrivals',conversations:'Bot exchanges',bonds:'New positive bonds',parties:'Party invitations',recruits:'Guild admissions',guild:'Guild goal progress',recoveries:'Recovery attempts'};
|
const metrics:Record<string,string>={levels:'Levels gained',deaths:'Permanent deaths',nativeDeaths:'Native deaths (all modes)',arrivals:'First arrivals',conversations:'Bot exchanges',bonds:'New positive bonds',parties:'Party invitations',recruits:'Guild admissions',guild:'Guild goal progress',recoveries:'Recovery attempts'};
|
||||||
export function RealmInsights({mode,initialQuery=''}:{mode:string;initialQuery?:string}){
|
export function RealmInsights({mode,initialQuery=''}:{mode:string;initialQuery?:string}){
|
||||||
const [data,setData]=useState<Insights|null>(null),[error,setError]=useState(''),[selected,setSelected]=useState('current'),[query,setQuery]=useState(initialQuery),[attention,setAttention]=useState(false);
|
const [data,setData]=useState<Insights|null>(null),[error,setError]=useState(''),[selected,setSelected]=useState('current'),[query,setQuery]=useState(initialQuery),[attention,setAttention]=useState(false);
|
||||||
useEffect(()=>{let stopped=false;const poll=async()=>{try{const r=await fetch('/api/realm-insights');if(!r.ok)throw new Error('Insights temporarily unavailable');const d=await r.json() as Insights;if(!stopped){setData(d);setError('');}}catch(e){if(!stopped)setError(e instanceof Error?e.message:'Unable to load insights');}};void poll();const timer=setInterval(poll,15000);return()=>{stopped=true;clearInterval(timer);};},[]);
|
useEffect(()=>{let stopped=false;const poll=async()=>{try{const r=await fetch('/api/realm-insights');if(!r.ok)throw new Error('Insights temporarily unavailable');const d=await r.json() as Insights;if(!stopped){setData(d);setError('');}}catch(e){if(!stopped)setError(e instanceof Error?e.message:'Unable to load insights');}};void poll();const timer=setInterval(poll,15000);return()=>{stopped=true;clearInterval(timer);};},[]);
|
||||||
@@ -14,10 +14,11 @@ export function RealmInsights({mode,initialQuery=''}:{mode:string;initialQuery?:
|
|||||||
const report=selected==='current'?data.current:data.reports.find(r=>String(r.start)===selected)??data.current;
|
const report=selected==='current'?data.current:data.reports.find(r=>String(r.start)===selected)??data.current;
|
||||||
const rows=data.activity.filter(b=>`${b.name} ${b.zone} ${b.label}`.toLowerCase().includes(query.toLowerCase())&&(!attention||['Possible stall','Telemetry delayed'].includes(b.label)));
|
const rows=data.activity.filter(b=>`${b.name} ${b.zone} ${b.label}`.toLowerCase().includes(query.toLowerCase())&&(!attention||['Possible stall','Telemetry delayed'].includes(b.label)));
|
||||||
return <><p className="fineprint">Last collection: {stamp(data.collectedAt)} · Refreshes every 15 seconds.</p>{error&&<p role="alert" className="notice">{error}; showing the last available observations.</p>}{(!data.collectedAt||Date.now()/1000-data.collectedAt>180)&&<p role="status" className="notice">Collection is pending or delayed. Report totals may be incomplete.</p>}
|
return <><p className="fineprint">Last collection: {stamp(data.collectedAt)} · Refreshes every 15 seconds.</p>{error&&<p role="alert" className="notice">{error}; showing the last available observations.</p>}{(!data.collectedAt||Date.now()/1000-data.collectedAt>180)&&<p role="status" className="notice">Collection is pending or delayed. Report totals may be incomplete.</p>}
|
||||||
|
{data.evaluation&&<section className="panel" style={{marginBottom:20}}><div className="panel-head"><h2>Gameplay evaluation</h2></div><div className="panel-body"><p>Since {stamp(data.evaluation.since)} · {data.evaluation.onlineBotHours.toFixed(1)} observed bot-hours · {data.evaluation.activeBotHours.toFixed(1)} active bot-hours</p><p>{data.evaluation.deaths} native deaths · {data.evaluation.deathsPerBotHour?.toFixed(2)??'—'} deaths / bot-hour · {data.evaluation.questTurnInsPerBotHour?.toFixed(2)??'—'} quest turn-ins / bot-hour</p><p>{data.evaluation.intentsObserved} intentions with observed results · {data.evaluation.intentsUnverified} without verified results · {data.evaluation.intentsInterrupted} interrupted</p><p className="fineprint">Fresh consecutive samples only; collection gaps are excluded. Activity includes movement, combat and casting. Results after an intention show correlation, not proof that the model caused them. Party proximity: {data.evaluation.nearbyPartyHours.toFixed(1)} bot-hours.</p></div></section>}
|
||||||
{mode==='Nightly Report'?<>
|
{mode==='Nightly Report'?<>
|
||||||
<section className="panel"><div className="panel-body"><label className="field">Report period<select value={selected} onChange={e=>setSelected(e.target.value)}><option value="current">Today · in progress</option>{data.reports.map(r=><option key={r.start} value={r.start}>{stamp(r.start)} — {stamp(r.end)}</option>)}</select></label><p>{stamp(report.start)} — {selected==='current'?'now':stamp(report.end)}</p><p className="fineprint">Reports close daily at 06:00 UTC. Next close: {stamp(data.nextReportAt)}. Reports are kept for 30 days.</p><p className="fineprint">{report.partial?'Partial period · ':''}Observations since {stamp(report.observedSince)} · {report.samples} minute samples. Gaps can miss activity; these are observed changes, not a complete game log.</p></div></section>
|
<section className="panel"><div className="panel-body"><label className="field">Report period<select value={selected} onChange={e=>setSelected(e.target.value)}><option value="current">Today · in progress</option>{data.reports.map(r=><option key={r.start} value={r.start}>{stamp(r.start)} — {stamp(r.end)}</option>)}</select></label><p>{stamp(report.start)} — {selected==='current'?'now':stamp(report.end)}</p><p className="fineprint">Reports close daily at 06:00 UTC. Next close: {stamp(data.nextReportAt)}. Reports are kept for 30 days.</p><p className="fineprint">{report.partial?'Partial period · ':''}Observations since {stamp(report.observedSince)} · {report.samples} minute samples. Gaps can miss activity; these are observed changes, not a complete game log.</p></div></section>
|
||||||
<div className="stats" style={{marginTop:20}}>{Object.entries(metrics).map(([key,label])=><div className="panel stat" key={key}><div className="stat-label">{label}</div><div className="stat-value">{report.totals[key]??0}</div></div>)}</div>
|
<div className="stats" style={{marginTop:20}}>{Object.entries(metrics).map(([key,label])=><div className="panel stat" key={key}><div className="stat-label">{label}</div><div className="stat-value">{report.totals[key]??0}</div></div>)}</div>
|
||||||
<section className="panel"><div className="panel-head"><h2>Realm highlights</h2></div><div className="panel-body">{report.highlights.length?report.highlights.map(e=><div className="memory" key={e.id}><strong>{e.name}</strong><p>{e.detail}</p><small>{stamp(e.at)}</small></div>):<p className="empty-note">No changes recorded in this period yet.</p>}<p className="fineprint">Exchanges count confirmed bot replies, excluding synthetic openers and private player messages. Positive bonds need not be mutual friendships. Arrivals mean first observed online, not necessarily newly created. Recovery attempts do not prove recovery succeeded.</p></div></section>
|
<section className="panel"><div className="panel-head"><h2>Realm highlights</h2></div><div className="panel-body">{report.highlights.length?report.highlights.map(e=><div className="memory" key={e.id}><strong>{e.name}</strong><p>{e.detail}</p><small>{stamp(e.at)}</small></div>):<p className="empty-note">No changes recorded in this period yet.</p>}<p className="fineprint">Exchanges count confirmed bot replies, excluding synthetic openers and private player messages. Positive bonds need not be mutual friendships. Arrivals mean first observed online, not necessarily newly created. Recovery attempts do not prove recovery succeeded.</p></div></section>
|
||||||
</>:<><section className="panel"><div className="panel-body"><Input aria-label="Search bot activity" placeholder="Search by name, zone, or activity" value={query} onChange={e=>setQuery(e.target.value)}/><label style={{display:'flex',gap:8,marginTop:16}}><input type="checkbox" checked={attention} onChange={e=>setAttention(e.target.checked)}/>Show possible stalls and delayed observations</label><p className="fineprint">{rows.length} of {data.activity.length} online residents. Progress means observed movement, level gain, or health change. Intentions describe plans, not completed game actions.</p></div></section><div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(min(100%,320px),1fr))',gap:16,marginTop:20}}>{rows.map(b=><section className="panel" key={b.name}><div className="panel-head"><h2>{b.name}</h2><span className="tag">{b.label}</span></div><div className="panel-body"><p>{b.zone} · Intent: {b.action}</p><p style={{marginTop:12}}>{b.detail}</p><p className="fineprint">Game observation: {stamp(b.observedAt)}<br/>Last observed progress: {stamp(b.lastProgressAt)}</p><details><summary>Recent recovery attempts ({b.recoveries.length})</summary>{b.recoveries.length?b.recoveries.map((r,i)=><p className="fineprint" key={i}>{stamp(r.at)} · {r.detail}</p>):<p className="fineprint">No retained attempts. Recovery requires ten minutes without native progress, safe conditions, and an expired 30-minute cooldown.</p>}</details></div></section>)}</div>{!rows.length&&<p className="empty-note">No residents match this filter.</p>}</>}
|
</>:<><section className="panel"><div className="panel-body"><Input aria-label="Search bot activity" placeholder="Search by name, zone, or activity" value={query} onChange={e=>setQuery(e.target.value)}/><label style={{display:'flex',gap:8,marginTop:16}}><input type="checkbox" checked={attention} onChange={e=>setAttention(e.target.checked)}/>Show possible stalls and delayed observations</label><p className="fineprint">{rows.length} of {data.activity.length} online residents. Progress means observed movement, level gain, or health change. Intentions describe plans, not completed game actions.</p></div></section><div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(min(100%,320px),1fr))',gap:16,marginTop:20}}>{rows.map(b=><section className="panel" key={b.name}><div className="panel-head"><h2>{b.name}</h2><span className="tag">{b.label}</span></div><div className="panel-body"><p>{b.zone} · Intent: {b.action}</p><p style={{marginTop:12}}>{b.detail}</p><p className="fineprint">Game observation: {stamp(b.observedAt)}<br/>Last observed progress: {stamp(b.lastProgressAt)}</p>{b.intentResults?.map((r,i)=><p className="fineprint" key={'intent'+i}><strong>{r.action} · {r.status}</strong><br/>{r.detail}</p>)}<details><summary>Recent recovery attempts ({b.recoveries.length})</summary>{b.recoveries.length?b.recoveries.map((r,i)=><p className="fineprint" key={i}>{stamp(r.at)} · {r.detail}</p>):<p className="fineprint">No retained attempts. Recovery requires ten minutes without native progress, safe conditions, and an expired 30-minute cooldown.</p>}</details></div></section>)}</div>{!rows.length&&<p className="empty-note">No residents match this filter.</p>}</>}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-6
@@ -1,3 +1,5 @@
|
|||||||
|
import {startIntentCheck} from './intent-evidence';
|
||||||
|
import {intentCompanions,partyIntentBusy,recordPartyIntent,compileCommunityAction} from './community';
|
||||||
import {DeterministicProvider,providerForBot} from './llm';
|
import {DeterministicProvider,providerForBot} from './llm';
|
||||||
import {luaString} from './ale';
|
import {luaString} from './ale';
|
||||||
import {queueAleCommand,recentAleEvents} from './ale-store';
|
import {queueAleCommand,recentAleEvents} from './ale-store';
|
||||||
@@ -26,7 +28,7 @@ function latestObservationFor(bot:Bot,events:Awaited<ReturnType<typeof recentAle
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function observationFrom(bot:Bot,event:Awaited<ReturnType<typeof recentAleEvents>>[number],events:Awaited<ReturnType<typeof recentAleEvents>>,hardcore=true):Observation{
|
export function observationFrom(bot:Bot,event:Awaited<ReturnType<typeof recentAleEvents>>[number],events:Awaited<ReturnType<typeof recentAleEvents>>,hardcore=true):Observation{
|
||||||
const p=payloadObject(event.payload);
|
const p=payloadObject(event.payload);
|
||||||
const zone=typeof p.zoneName==='string'&&p.zoneName.trim()?p.zoneName.trim().slice(0,120):bot.zone;
|
const zone=typeof p.zoneName==='string'&&p.zoneName.trim()?p.zoneName.trim().slice(0,120):bot.zone;
|
||||||
const level=typeof p.level==='number'&&Number.isInteger(p.level)&&p.level>0&&p.level<=100?p.level:bot.level;
|
const level=typeof p.level==='number'&&Number.isInteger(p.level)&&p.level>0&&p.level<=100?p.level:bot.level;
|
||||||
@@ -38,11 +40,23 @@ function observationFrom(bot:Bot,event:Awaited<ReturnType<typeof recentAleEvents
|
|||||||
zone,
|
zone,
|
||||||
level,
|
level,
|
||||||
alive:bot.status!=='dead',
|
alive:bot.status!=='dead',
|
||||||
availableActions:[...bot.personality.activities],
|
availableActions:availableIntentActions(bot),
|
||||||
summary:`Realm death rules: ${hardcore?'permanent death':'normal resurrection; recover and resume progression after death'}. Authoritative live Playerbot observation in ${zone} at level ${level}. Recent server events: ${recent.join(', ')||'snapshot'}.`
|
summary:`Realm death rules: ${hardcore?'permanent death':'normal resurrection; recover and resume progression after death'}. Authoritative live Playerbot observation in ${zone} at level ${level}. Recent server events: ${recent.join(', ')||'snapshot'}. ${decisionContext(bot)}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function availableIntentActions(bot:Bot){
|
||||||
|
const actions=[...bot.personality.activities],w=bot.world;
|
||||||
|
if(w&&Date.now()/1000-w.at>=0&&Date.now()/1000-w.at<90&&(w.healthPct??0)>=95&&(w.manaPct===undefined||w.manaPct>=65)&&actions.some(a=>a!=='rest'))return actions.filter(a=>a!=='rest');
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
export function decisionContext(bot:Bot){
|
||||||
|
const w=bot.world;if(!w||Date.now()/1000-w.at>90)return 'Live condition details unavailable; do not invent them.';
|
||||||
|
const ready=w.readinessAt&&Date.now()/1000-w.readinessAt<180;
|
||||||
|
const last=bot.autonomy?.intentChecks?.filter(c=>c.status!=='pending').slice(-2).map(c=>`${c.action}: ${c.status}; ${c.detail}`).join(' | ');
|
||||||
|
return JSON.stringify({health:w.healthPct,mana:w.manaPct,dead:w.isDead||w.isGhost,inCombat:w.inCombat,nearbyThreats:w.threatBrief??'unknown',activeQuestLog:w.questBrief??'unknown',supplies:ready?{food:w.foodCount,drinks:w.drinkCount,durability:w.minDurabilityPct,brokenItems:w.brokenItems}:null,party:w.groupNames,nearby:w.nearby,recentOutcomes:last||'No outcome verified yet',capabilities:'quest/explore request native progression; trade requests native supplies/repair; socialize can invite an eligible nearby companion. Never claim an action completed until observed.'});
|
||||||
|
}
|
||||||
|
|
||||||
function strategy(ai:string,change:string){
|
function strategy(ai:string,change:string){
|
||||||
return `${ai}:ChangeStrategy(${luaString(change)},1)`;
|
return `${ai}:ChangeStrategy(${luaString(change)},1)`;
|
||||||
}
|
}
|
||||||
@@ -55,6 +69,7 @@ export function compileLivePlayerbotIntent(botName:string,decision:Decision){
|
|||||||
`if not ${p}:IsPlayerbot() then error("Managed character is not controlled by PlayerbotAI") end`,
|
`if not ${p}:IsPlayerbot() then error("Managed character is not controlled by PlayerbotAI") end`,
|
||||||
`local ${ai}=${p}:GetBotAI()`,
|
`local ${ai}=${p}:GetBotAI()`,
|
||||||
`if not ${ai} then error("PlayerbotAI unavailable") end`,
|
`if not ${ai} then error("PlayerbotAI unavailable") end`,
|
||||||
|
`if ${p}:IsDead() then return "Native resurrection takes priority" end`,
|
||||||
`if ${ai}:HasGameClientMaster() then return "Human control preserved" end`,
|
`if ${ai}:HasGameClientMaster() then return "Human control preserved" end`,
|
||||||
`if ${p}:HasUnitState(0x00000100) then return "Native taxi flight takes priority" end`,
|
`if ${p}:HasUnitState(0x00000100) then return "Native taxi flight takes priority" end`,
|
||||||
`if AzerothAISurvivalOverride and AzerothAISurvivalOverride(${p}) then return "Native survival action takes priority" end`,
|
`if AzerothAISurvivalOverride and AzerothAISurvivalOverride(${p}) then return "Native survival action takes priority" end`,
|
||||||
@@ -82,7 +97,7 @@ export function compileLivePlayerbotIntent(botName:string,decision:Decision){
|
|||||||
case 'trade':
|
case 'trade':
|
||||||
clearStay();
|
clearStay();
|
||||||
body.push(strategy(ai,'+rpg'));
|
body.push(strategy(ai,'+rpg'));
|
||||||
body.push(`${ai}:DoSpecificAction("rpg sell",true,"")`);
|
body.push(`${ai}:DoSpecificAction("rpg sell",true,"");${ai}:DoSpecificAction("rpg buy",true,"");${ai}:DoSpecificAction("repair",true,"")`);
|
||||||
body.push(`${ai}:DoNextAction(false)`);
|
body.push(`${ai}:DoNextAction(false)`);
|
||||||
break;
|
break;
|
||||||
case 'rest':
|
case 'rest':
|
||||||
@@ -112,7 +127,7 @@ export async function runLiveAutonomyTick(
|
|||||||
const events=await recentAleEvents(db,400);
|
const events=await recentAleEvents(db,400);
|
||||||
|
|
||||||
const candidates=s.bots
|
const candidates=s.bots
|
||||||
.filter(b=>b.status!=='dead'&&b.status!=='retired'&&b.autonomy?.managed===true)
|
.filter(b=>b.status==='online'&&b.autonomy?.managed===true&&!b.world?.isDead&&!b.world?.isGhost&&!b.world?.humanControlled&&!b.world?.taxi)
|
||||||
.map(bot=>({bot,obs:latestObservationFor(bot,events)}))
|
.map(bot=>({bot,obs:latestObservationFor(bot,events)}))
|
||||||
.filter((x):x is {bot:Bot;obs:NonNullable<typeof x.obs>}=>!!x.obs)
|
.filter((x):x is {bot:Bot;obs:NonNullable<typeof x.obs>}=>!!x.obs)
|
||||||
.filter(x=>now-Date.parse(x.obs.at)<=OBSERVATION_MAX_AGE_MS)
|
.filter(x=>now-Date.parse(x.obs.at)<=OBSERVATION_MAX_AGE_MS)
|
||||||
@@ -147,6 +162,8 @@ export async function runLiveAutonomyTick(
|
|||||||
const observation=observationFrom(bot,pick.obs,events,s.settings.hardcore.enabled);
|
const observation=observationFrom(bot,pick.obs,events,s.settings.hardcore.enabled);
|
||||||
const guild=s.community?.guilds.find(g=>g.id===bot.world?.guildId);
|
const guild=s.community?.guilds.find(g=>g.id===bot.world?.guildId);
|
||||||
if(guild)observation.summary+=` Your guild ${guild.name} works toward ${guild.goal}; confirmed progress ${guild.progress}/${guild.target}. Favor helpful actions toward that goal when safe.`;
|
if(guild)observation.summary+=` Your guild ${guild.name} works toward ${guild.goal}; confirmed progress ${guild.progress}/${guild.target}. Favor helpful actions toward that goal when safe.`;
|
||||||
|
const companions=intentCompanions(s,bot).slice(0,3);
|
||||||
|
observation.summary+=' Eligible nearby party candidates: '+(companions.map(p=>`${p.name} (level ${p.level}, ${p.world?.combatRole??p.role})`).join(', ')||'none; do not invent a companion or promise a party')+'.';
|
||||||
const brain=providerForBot(bot,s.settings,config);
|
const brain=providerForBot(bot,s.settings,config);
|
||||||
let decision:Decision;
|
let decision:Decision;
|
||||||
let usedFallback=false;
|
let usedFallback=false;
|
||||||
@@ -168,8 +185,14 @@ export async function runLiveAutonomyTick(
|
|||||||
}
|
}
|
||||||
bot=currentBot;
|
bot=currentBot;
|
||||||
if(usedFallback)realmEvent(s,'system',`${bot.name} LLM route unavailable; used deterministic fallback for this heartbeat.`,bot.id);
|
if(usedFallback)realmEvent(s,'system',`${bot.name} LLM route unavailable; used deterministic fallback for this heartbeat.`,bot.id);
|
||||||
const source=compileLivePlayerbotIntent(bot.name,decision);
|
// Recheck eligibility after inference; native guards recheck once more at execution.
|
||||||
|
let peer=decision.action==='socialize'?intentCompanions(s,bot)[0]:undefined;
|
||||||
|
if(peer&&await partyIntentBusy(db,bot,peer))peer=undefined;
|
||||||
|
const source=peer?compileCommunityAction('party',bot,peer):compileLivePlayerbotIntent(bot.name,decision);
|
||||||
const queued=await queueAleCommand(db,source);
|
const queued=await queueAleCommand(db,source);
|
||||||
|
if(peer)await recordPartyIntent(db,s,queued.id,bot,peer);
|
||||||
|
const check=startIntentCheck(bot,decision,queued.id,Date.now()/1000,peer?.name);
|
||||||
|
|
||||||
|
|
||||||
bot.lastDecision=decision;
|
bot.lastDecision=decision;
|
||||||
bot.decisions++;
|
bot.decisions++;
|
||||||
@@ -182,6 +205,7 @@ export async function runLiveAutonomyTick(
|
|||||||
observedAt:newerObservation?bot.autonomy?.observedAt:pick.obs.at,
|
observedAt:newerObservation?bot.autonomy?.observedAt:pick.obs.at,
|
||||||
lastIntentAt:new Date(now).toISOString(),
|
lastIntentAt:new Date(now).toISOString(),
|
||||||
nextIntentAt:new Date(now+nextIntervalMs(bot)).toISOString(),
|
nextIntentAt:new Date(now+nextIntervalMs(bot)).toISOString(),
|
||||||
|
intentChecks:[...(bot.autonomy?.intentChecks??[]),...(check?[check]:[])].slice(-12),
|
||||||
lastAleCommandId:queued.id
|
lastAleCommandId:queued.id
|
||||||
};
|
};
|
||||||
realmEvent(s,'system',`${bot.name} brain route: ${brain.route.provider} · ${brain.route.model} · autonomous live heartbeat.`,bot.id);
|
realmEvent(s,'system',`${bot.name} brain route: ${brain.route.provider} · ${brain.route.model} · autonomous live heartbeat.`,bot.id);
|
||||||
|
|||||||
+20
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import {observeIntentChecks} from './intent-evidence';
|
||||||
import type {Bot,State,StateStore,SocialEntry} from './types';
|
import type {Bot,State,StateStore,SocialEntry} from './types';
|
||||||
import {luaString} from './ale';
|
import {luaString} from './ale';
|
||||||
import {queueAleCommand} from './ale-store';
|
import {queueAleCommand} from './ale-store';
|
||||||
@@ -24,7 +25,8 @@ export function observePresence(s:State,b:Bot,p:Record<string,unknown>){
|
|||||||
if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return;
|
if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return;
|
||||||
const old=b.world;
|
const old=b.world;
|
||||||
const stationary=old&&old.mapId===p.mapId&&Math.hypot(old.x-p.x,old.y-p.y)<3;
|
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};
|
b.world={xp:typeof p.xp==='number'&&Number.isFinite(p.xp)?p.xp:undefined,questBrief:typeof p.questBrief==='string'?p.questBrief.slice(0,1800):undefined,threatBrief:typeof p.threatBrief==='string'?p.threatBrief.slice(0,600):undefined,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};
|
||||||
|
observeIntentChecks(b);
|
||||||
if(b.world.deathMemoryId && b.world.deathMemoryId>(b.deathMemorySeenId??0)){
|
if(b.world.deathMemoryId && b.world.deathMemoryId>(b.deathMemorySeenId??0)){
|
||||||
b.deathMemorySeenId=b.world.deathMemoryId;
|
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.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()});
|
||||||
@@ -58,13 +60,29 @@ export function companionScore(a:Bot,b:Bot){
|
|||||||
const shared=(a.world?.quests??[]).filter(q=>b.world?.quests?.includes(q)).length;
|
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));
|
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;
|
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.isDead&&!b.world.isGhost&&!b.world.inCombat&&!b.world.humanControlled&&!b.world.taxi&&!b.world.casting;
|
||||||
export function partyDecision(a:Bot,b:Bot){
|
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'};
|
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 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');
|
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.'};
|
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 intentCompanions(s:State,a:Bot){
|
||||||
|
if(!s.community?.partiesEnabled)return [];
|
||||||
|
const groupSafe=(p:Bot)=>!p.world?.groupId||(p.world.groupNames.length<5&&p.world.groupNames.length>0&&p.world.groupNames.every(n=>s.bots.some(m=>m.name===n&&fresh(m)&&m.world!.groupId===p.world!.groupId)));
|
||||||
|
if(!groupSafe(a))return [];
|
||||||
|
return s.bots.filter(b=>!b.world?.groupId&&partyDecision(a,b).accepted).sort((x,y)=>companionScore(a,y)-companionScore(a,x));
|
||||||
|
}
|
||||||
|
export async function partyIntentBusy(db:D1Database,a:Bot,b:Bot){
|
||||||
|
await ensure(db);
|
||||||
|
const row=await db.prepare("SELECT id FROM community_actions WHERE (a IN (?,?) OR b IN (?,?)) AND (status='pending' OR created_at>?) LIMIT 1").bind(a.id,b.id,a.id,b.id,new Date(Date.now()-120000).toISOString()).first();
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
export async function recordPartyIntent(db:D1Database,s:State,id:string,a:Bot,b:Bot){
|
||||||
|
const reason='Seeking a nearby compatible adventuring companion; awaiting native confirmation.';
|
||||||
|
await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(id,new Date().toISOString(),'party',a.id,b.id,id,'pending',reason).run();
|
||||||
|
socialEntry(s,{id:id+':proposal',at:new Date().toISOString(),kind:'invitation',names:[a.name,b.name],text:reason,status:'pending'});
|
||||||
|
}
|
||||||
export function compileCommunityAction(kind:'party'|'leave'|'recruit',a:Bot,b?:Bot){
|
export function compileCommunityAction(kind:'party'|'leave'|'recruit',a:Bot,b?:Bot){
|
||||||
if(kind==='party'){
|
if(kind==='party'){
|
||||||
if(!b||!a.autonomy?.accountId||!b.autonomy?.accountId)throw new Error('Verified native accounts required');
|
if(!b||!a.autonomy?.accountId||!b.autonomy?.accountId)throw new Error('Verified native accounts required');
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type {Bot,Decision,IntentCheck} from './types';
|
||||||
|
export function startIntentCheck(bot:Bot,decision:Decision,commandId:string,now:number,partyPeer?:string):IntentCheck|undefined{
|
||||||
|
const w=bot.world;if(!w||now-w.at>90||now<w.at)return;
|
||||||
|
return {id:commandId,action:decision.action,startedAt:now,baselineAt:w.at,status:'pending',detail:'Waiting for a fresh game observation; command submission is not completion.',partyPeer,baseline:{level:bot.level,xp:w.xp,map:w.mapId,x:w.x,y:w.y,hp:w.healthPct,mana:w.manaPct,food:w.foodCount,drink:w.drinkCount,durability:w.minDurabilityPct,readinessAt:w.readinessAt,groupNames:[...w.groupNames]}};
|
||||||
|
}
|
||||||
|
export function observeIntentChecks(bot:Bot){
|
||||||
|
const w=bot.world;if(!w)return;
|
||||||
|
for(const c of bot.autonomy?.intentChecks??[]){
|
||||||
|
if(c.status!=='pending'||w.at<=c.baselineAt||w.at<c.startedAt)continue;
|
||||||
|
const finish=(status:IntentCheck['status'],detail:string)=>{c.status=status;c.detail=detail;c.checkedAt=w.at;};
|
||||||
|
if(w.isDead||w.isGhost){finish('interrupted','Death interrupted the intention; native resurrection takes priority.');continue;}
|
||||||
|
if(w.humanControlled){finish('interrupted','Human control took priority.');continue;}
|
||||||
|
const b=c.baseline,elapsed=w.at-c.startedAt;
|
||||||
|
if(c.action==='quest'&&(bot.level>b.level||(w.xp!==undefined&&b.xp!==undefined&&w.xp>b.xp))){finish('observed','Experience or level increased after the intention; this alone does not prove a quest turn-in.');continue;}
|
||||||
|
if(c.action==='explore'&&(w.mapId!==b.map||Math.hypot(w.x-b.x,w.y-b.y)>=20)){finish('observed','Travel of at least 20 yards or a map change observed.');continue;}
|
||||||
|
if(c.action==='rest'&&!w.inCombat&&(w.healthPct??0)>=95&&(w.manaPct===undefined||w.manaPct>=65)){
|
||||||
|
finish('observed','Recovered health and sufficient mana observed out of combat.');continue;
|
||||||
|
}
|
||||||
|
if(c.action==='trade'&&(w.readinessAt??0)>(b.readinessAt??0)&&((w.foodCount??0)>(b.food??Infinity)||(w.drinkCount??0)>(b.drink??Infinity)||(w.minDurabilityPct??0)>(b.durability??Infinity))){finish('observed','Supplies or equipment condition improved; purchase attribution is not established.');continue;}
|
||||||
|
if(c.action==='socialize'&&c.partyPeer&&w.groupId&&w.groupNames.includes(c.partyPeer)){
|
||||||
|
finish('observed','Party membership with '+c.partyPeer+' verified in the game.');continue;
|
||||||
|
}
|
||||||
|
if(elapsed>=600)finish('no_progress',c.action==='socialize'&&!c.partyPeer?'No measurable party objective was attached; dialogue is counted separately.':'No matching outcome observed within ten minutes. This is not proof of an engine failure.');
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -11,7 +11,7 @@ export type BrainRoute={
|
|||||||
|
|
||||||
function survivalDecision(bot:Bot,observation:Observation){
|
function survivalDecision(bot:Bot,observation:Observation){
|
||||||
const w=bot.world;
|
const w=bot.world;
|
||||||
if(w&&Date.now()/1000-w.at<90&&typeof w.healthPct==='number'&&w.healthPct<85&&bot.personality.activities.includes('rest')&&observation.availableActions.includes('rest'))
|
if(w&&Date.now()/1000-w.at>=0&&Date.now()/1000-w.at<90&&typeof w.healthPct==='number'&&(w.healthPct<85||(typeof w.manaPct==='number'&&w.manaPct<35))&&bot.personality.activities.includes('rest')&&observation.availableActions.includes('rest'))
|
||||||
return {action:'rest' as const,goal:'Recover before risking another fight.',reason:'Staying alive comes before my task. I need to recover and reassess the danger.'};
|
return {action:'rest' as const,goal:'Recover before risking another fight.',reason:'Staying alive comes before my task. I need to recover and reassess the danger.'};
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ export class LocalLLMProvider implements LLMProvider {
|
|||||||
content:JSON.stringify({
|
content:JSON.stringify({
|
||||||
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},
|
||||||
home:bot.homeId??null,
|
home:bot.homeId??null,
|
||||||
safety:{healthPercent:bot.world?.healthPct??null,observedAt:bot.world?.at??null,inCombat:bot.world?.inCombat??null,nearbyCompanions:bot.world?.nearby??[],lastSurvivalAction:bot.world?.survivalAction??null},
|
safety:bot.world&&Date.now()/1000-bot.world.at<90?{manaPercent:bot.world.manaPct??null,healthPercent:bot.world?.healthPct??null,observedAt:bot.world?.at??null,inCombat:bot.world?.inCombat??null,nearbyCompanions:bot.world?.nearby??[],lastSurvivalAction:bot.world?.survivalAction??null}:null,
|
||||||
personality:bot.personality,
|
personality:bot.personality,
|
||||||
goal:bot.goal,
|
goal:bot.goal,
|
||||||
memories:[...bot.memories].sort((a,b)=>b.importance-a.importance).slice(0,memoryLimit),
|
memories:[...bot.memories].sort((a,b)=>b.importance-a.importance).slice(0,memoryLimit),
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ export const DAY=86400,BOUNDARY=6*3600;
|
|||||||
export const reportWindow=(at:number)=>Math.floor((at-BOUNDARY)/DAY)*DAY+BOUNDARY;
|
export const reportWindow=(at:number)=>Math.floor((at-BOUNDARY)/DAY)*DAY+BOUNDARY;
|
||||||
export type Evidence={id:string;at:number;kind:string;name:string;amount:number;detail:string};
|
export type Evidence={id:string;at:number;kind:string;name:string;amount:number;detail:string};
|
||||||
export type Progress={partyKey?:string;nearPartyKey?:string;worldAt?:number;deathMemoryId?:number;level:number;status:string;seenOnline:boolean;x?:number;y?:number;map?:number;health?:number;lastProgressAt?:number};
|
export type Progress={partyKey?:string;nearPartyKey?:string;worldAt?:number;deathMemoryId?:number;level:number;status:string;seenOnline:boolean;x?:number;y?:number;map?:number;health?:number;lastProgressAt?:number};
|
||||||
export type Cursor={at:number;startedAt:number;bots:Record<string,Progress>;bonds:string[];guilds:Record<string,{goal:string;progress:number}>;social:string[]};
|
export type Cursor={intentResults?:string[];at:number;startedAt:number;bots:Record<string,Progress>;bonds:string[];guilds:Record<string,{goal:string;progress:number}>;social:string[]};
|
||||||
export function observeInsights(s:State,previous:Cursor|undefined,now:number){
|
export function observeInsights(s:State,previous:Cursor|undefined,now:number){
|
||||||
const events:Evidence[]=[];const cursor:Cursor={at:now,startedAt:previous?.startedAt??now,bots:{},bonds:[],guilds:{},social:[]};
|
const events:Evidence[]=[];const cursor:Cursor={at:now,startedAt:previous?.startedAt??now,bots:{},bonds:[],guilds:{},social:[]};
|
||||||
const add=(id:string,kind:string,name:string,amount:number,detail:string,at=now)=>events.push({id,at,kind,name,amount,detail});
|
const add=(id:string,kind:string,name:string,amount:number,detail:string,at=now)=>events.push({id,at,kind,name,amount,detail});
|
||||||
|
cursor.intentResults=[];
|
||||||
const managed=s.bots.filter(b=>b.autonomy?.managed),ids=new Set(managed.map(b=>b.id));
|
const managed=s.bots.filter(b=>b.autonomy?.managed),ids=new Set(managed.map(b=>b.id));
|
||||||
for(const b of managed){
|
for(const b of managed){
|
||||||
const old=previous?.bots[b.id],w=b.world;
|
const old=previous?.bots[b.id],w=b.world;
|
||||||
@@ -17,10 +18,16 @@ export function observeInsights(s:State,previous:Cursor|undefined,now:number){
|
|||||||
const partyKey=fresh&&w!.groupId&&peers.length?[b.name,...peers].sort().join('|'):undefined;
|
const partyKey=fresh&&w!.groupId&&peers.length?[b.name,...peers].sort().join('|'):undefined;
|
||||||
const nearPartyKey=partyKey&&peers.some(n=>(w!.nearby??[]).includes(n))?partyKey:undefined;
|
const nearPartyKey=partyKey&&peers.some(n=>(w!.nearby??[]).includes(n))?partyKey:undefined;
|
||||||
const elapsed=previous?now-previous.at:0;
|
const elapsed=previous?now-previous.at:0;
|
||||||
if(old&&fresh&&w!.at>(old.worldAt??0)&&elapsed>0&&elapsed<=120){
|
if(old&&fresh&&w!.at>(old.worldAt??0)&&elapsed>0&&elapsed<=180){
|
||||||
|
if(b.status==='online'&&old.status==='online'){add(`online-time:${b.id}:${now}`,'onlineSeconds',b.name,elapsed,'Fresh consecutive online observations, including recovery time');if(!w!.isDead&&!w!.isGhost&&(moved||w!.inCombat||w!.casting))add(`active-time:${b.id}:${now}`,'activeSeconds',b.name,elapsed,'Sampled movement, combat or casting; not proof of useful progress');}
|
||||||
if(partyKey&&partyKey===old.partyKey)add(`party-time:${b.id}:${now}`,'groupedSeconds',b.name,elapsed,'Sampled seconds in the same living party');
|
if(partyKey&&partyKey===old.partyKey)add(`party-time:${b.id}:${now}`,'groupedSeconds',b.name,elapsed,'Sampled seconds in the same living party');
|
||||||
if(nearPartyKey&&nearPartyKey===old.nearPartyKey)add(`party-near:${b.id}:${now}`,'nearPartySeconds',b.name,elapsed,'Sampled seconds with a party member within 35 yards');
|
if(nearPartyKey&&nearPartyKey===old.nearPartyKey)add(`party-near:${b.id}:${now}`,'nearPartySeconds',b.name,elapsed,'Sampled seconds with a party member within 35 yards');
|
||||||
}
|
}
|
||||||
|
for(const check of b.autonomy?.intentChecks??[]){
|
||||||
|
if(check.status==='pending')continue;
|
||||||
|
cursor.intentResults!.push(check.id);
|
||||||
|
if(previous&&!(previous.intentResults??[]).includes(check.id)&&(check.checkedAt??0)>=previous.at)add('intent:'+check.id,'intent'+(check.status==='observed'?'Observed':check.status==='interrupted'?'Interrupted':'Unverified'),b.name,1,check.action+': '+check.detail,check.checkedAt);
|
||||||
|
}
|
||||||
if(previous&&w?.deathMemoryId&&w.deathMemoryId!==(old?.deathMemoryId??0)&&(w.deathMemoryAt??0)>=previous.at)add(`learned-death:${b.id}:${w.deathMemoryId}`,'learnedDeaths',b.name,1,`Encounter remembered: ${w.deathMemoryKiller??'unknown enemy'}`);
|
if(previous&&w?.deathMemoryId&&w.deathMemoryId!==(old?.deathMemoryId??0)&&(w.deathMemoryAt??0)>=previous.at)add(`learned-death:${b.id}:${w.deathMemoryId}`,'learnedDeaths',b.name,1,`Encounter remembered: ${w.deathMemoryKiller??'unknown enemy'}`);
|
||||||
cursor.bots[b.id]={partyKey,nearPartyKey,worldAt:fresh?w!.at:old?.worldAt,deathMemoryId:w?.deathMemoryId??old?.deathMemoryId,level:b.level,status:b.status,seenOnline:!!old?.seenOnline||b.status==='online',x:fresh?w!.x:old?.x,y:fresh?w!.y:old?.y,map:fresh?w!.mapId:old?.map,health:fresh?w!.healthPct:old?.health,lastProgressAt:moved||advanced?now:old?.lastProgressAt};
|
cursor.bots[b.id]={partyKey,nearPartyKey,worldAt:fresh?w!.at:old?.worldAt,deathMemoryId:w?.deathMemoryId??old?.deathMemoryId,level:b.level,status:b.status,seenOnline:!!old?.seenOnline||b.status==='online',x:fresh?w!.x:old?.x,y:fresh?w!.y:old?.y,map:fresh?w!.mapId:old?.map,health:fresh?w!.healthPct:old?.health,lastProgressAt:moved||advanced?now:old?.lastProgressAt};
|
||||||
if(previous){
|
if(previous){
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type {State} from './types';
|
import type {State} from './types';
|
||||||
import {activityReason,type Evidence} from './realm-insights';
|
import {activityReason,type Evidence} from './realm-insights';
|
||||||
export const counterKeys=['quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper'] as const;
|
export const counterKeys=['quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper','nativeDeaths'] as const;
|
||||||
export type NativeOutcome={guid:number;name:string}&Record<typeof counterKeys[number],number>;
|
export type NativeOutcome={guid:number;name:string}&Record<Exclude<typeof counterKeys[number],'nativeDeaths'>,number>&{nativeDeaths?:number};
|
||||||
export type OutcomeBaseline={at:number;startedAt:number;rows:NativeOutcome[]};
|
export type OutcomeBaseline={at:number;startedAt:number;rows:NativeOutcome[]};
|
||||||
export function outcomeChanges(rows:NativeOutcome[],previous:OutcomeBaseline|undefined,now:number){
|
export function outcomeChanges(rows:NativeOutcome[],previous:OutcomeBaseline|undefined,now:number){
|
||||||
const events:Evidence[]=[];
|
const events:Evidence[]=[];
|
||||||
for(const r of rows){const old=previous?.rows.find(b=>b.guid===r.guid);if(!old)continue;
|
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:({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]});}
|
for(const key of counterKeys){const value=r[key],prior=old[key];if(typeof value!=='number'||typeof prior!=='number')continue;const amount=value-prior;if(amount>0)events.push({id:`outcome:${r.guid}:${key}:${now}:${r[key]}`,at:now,kind:key,name:r.name,amount,detail:({nativeDeaths:'Native death records, including normal resurrection',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};
|
return {baseline:{at:now,startedAt:previous?.startedAt??now,rows},events};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ function observedHardcoreDeath(s:State,b:Bot,observation:Observation){
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply one transport batch atomically; one large state write instead of one per bot.
|
||||||
|
export async function applyAuthoritativeAleSnapshots(store:StateStore,payloads:Record<string,unknown>[]){
|
||||||
|
const initial=await store.load(),revision=initial.revision;
|
||||||
|
let staged=structuredClone(initial),dirty=false;
|
||||||
|
const batchStore:StateStore={load:async()=>staged,save:async(next)=>{staged=next;dirty=true;}};
|
||||||
|
for(const payload of payloads)await applyAuthoritativeAleEvent(batchStore,'player.snapshot',payload);
|
||||||
|
if(dirty)await store.save(staged,revision);
|
||||||
|
return {ok:true,count:payloads.length};
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,payload:Record<string,unknown>){
|
export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,payload:Record<string,unknown>){
|
||||||
let s=await store.load();
|
let s=await store.load();
|
||||||
const revision=s.revision;
|
const revision=s.revision;
|
||||||
|
|||||||
+3
-2
@@ -9,8 +9,9 @@ export type Relationship = {targetId:string; affinity:number; note:string};
|
|||||||
export type DeathRecord = {id:string; at:string; zone:string; level:number; cause:string; killer?:string; source:'operator'|'simulation'|'azerothcore'; verified:boolean};
|
export type DeathRecord = {id:string; at:string; zone:string; level:number; cause:string; killer?:string; source:'operator'|'simulation'|'azerothcore'; verified:boolean};
|
||||||
export type Home = {id:string; name:string; zone:string; type:HousingType; capacity:number; ownerIds:string[]; residentIds:string[]; storageSlots:number; safety:number; description:string};
|
export type Home = {id:string; name:string; zone:string; type:HousingType; capacity:number; ownerIds:string[]; residentIds:string[]; storageSlots:number; safety:number; description:string};
|
||||||
export type Routine = {wakeHour:number; sleepHour:number; preferredZones:string[]};
|
export type Routine = {wakeHour:number; sleepHour:number; preferredZones:string[]};
|
||||||
export type AutonomyState = {managed:boolean; characterGuid?:number; accountId?:number; observedAt?:string; lastIntentAt?:string; nextIntentAt?:string; lastAleCommandId?:string};
|
export type IntentCheck = {id:string;action:Activity;startedAt:number;baselineAt:number;checkedAt?:number;status:"pending"|"observed"|"no_progress"|"interrupted";detail:string;partyPeer?:string;baseline:{level:number;xp?:number;map:number;x:number;y:number;hp?:number;mana?:number;food?:number;drink?:number;durability?:number;readinessAt?:number;groupNames:string[]}};
|
||||||
export type WorldPresence = {trainingPending?:number;trainingLevel?:number;trainingTrainer?:string;trainingStatus?:string;trainingAt?:number;trainingLearned?:number;lastTrainingAt?:number;survivalSince?:number;isDead?:boolean;isGhost?:boolean;manaPct?:number;leaderName?:string;minDurabilityPct?:number;brokenItems?:number;freeTalents?:number;foodCount?:number;drinkCount?:number;readinessAt?:number;deathMemoryId?:number;deathMemoryAt?:number;deathMemoryKiller?:string;deathMemoryMap?:number;deathMemoryX?:number;deathMemoryY?:number;deathMemoryLevel?:number;quests?:number[];dangerAt?:number;survivalAction?:string;survivalAt?:number;combatRole?:string;casting?:boolean;taxi?:boolean;humanControlled?:boolean;movementType?:number;healthPct?:number;at:number;team:number;guildId:number;guildName:string;guildRank:number;groupId:string;groupNames:string[];nearby:string[];inCombat:boolean;x:number;y:number;mapId:number;stationarySince?:number};
|
export type AutonomyState = {intentChecks?:IntentCheck[];managed:boolean; characterGuid?:number; accountId?:number; observedAt?:string; lastIntentAt?:string; nextIntentAt?:string; lastAleCommandId?:string};
|
||||||
|
export type WorldPresence = {xp?:number;questBrief?:string;threatBrief?:string;trainingPending?:number;trainingLevel?:number;trainingTrainer?:string;trainingStatus?:string;trainingAt?:number;trainingLearned?:number;lastTrainingAt?:number;survivalSince?:number;isDead?:boolean;isGhost?:boolean;manaPct?:number;leaderName?:string;minDurabilityPct?:number;brokenItems?:number;freeTalents?:number;foodCount?:number;drinkCount?:number;readinessAt?:number;deathMemoryId?:number;deathMemoryAt?:number;deathMemoryKiller?:string;deathMemoryMap?:number;deathMemoryX?:number;deathMemoryY?:number;deathMemoryLevel?:number;quests?:number[];dangerAt?:number;survivalAction?:string;survivalAt?:number;combatRole?:string;casting?:boolean;taxi?:boolean;humanControlled?:boolean;movementType?:number;healthPct?:number;at:number;team:number;guildId:number;guildName:string;guildRank:number;groupId:string;groupNames:string[];nearby:string[];inCombat:boolean;x:number;y:number;mapId:number;stationarySince?:number};
|
||||||
export type SocialEntry = {id:string;at:string;kind:string;names:string[];text:string;affinityDelta?:number;status?:string};
|
export type SocialEntry = {id:string;at:string;kind:string;names:string[];text:string;affinityDelta?:number;status?:string};
|
||||||
export type GuildGoal = {id:number;name:string;goal:'quest'|'support'|'explore';progress:number;target:number};
|
export type GuildGoal = {id:number;name:string;goal:'quest'|'support'|'explore';progress:number;target:number};
|
||||||
export type Community = {feed:SocialEntry[];evidence:string[];guilds:GuildGoal[];partiesEnabled:boolean;guildsEnabled:boolean};
|
export type Community = {feed:SocialEntry[];evidence:string[];guilds:GuildGoal[];partiesEnabled:boolean;guildsEnabled:boolean};
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Grounded decisions and gameplay evaluation
|
||||||
|
|
||||||
|
The live model receives up to eight active quest-log entries from this server's
|
||||||
|
quest_template table, nearby hostile units within 35 yards, health/mana, fresh
|
||||||
|
inventory readiness, eligible nearby party candidates, and recent measured
|
||||||
|
outcomes. Static quest titles are cached in ALE. Live facts remain snapshots;
|
||||||
|
missing/stale readings are not replaced with invented details.
|
||||||
|
|
||||||
|
Fully recovered bots cannot select rest when another allowed action exists.
|
||||||
|
Low mana joins low health as a recovery override. Dead/ghost, human-controlled,
|
||||||
|
and taxi residents are excluded from intention selection. Native survival,
|
||||||
|
trainer, human-party and movement guards remain authoritative.
|
||||||
|
|
||||||
|
Socialize requests a guarded native party invitation when an eligible candidate
|
||||||
|
is present and neither participant has a pending/recent invitation. Otherwise it
|
||||||
|
requests the normal social strategy. Confirmed membership, rather than narrative
|
||||||
|
claims, verifies party formation. Trade also requests normal supply purchasing
|
||||||
|
and repair, never spawned items or free repairs.
|
||||||
|
|
||||||
|
Each resident retains 12 intention checks. Later fresh snapshots can verify
|
||||||
|
travel, health/mana recovery, XP/level progress, supply/equipment improvement,
|
||||||
|
or the intended companion's party membership. XP is not called a quest turn-in;
|
||||||
|
inventory improvements are not called verified purchases. Death or human control
|
||||||
|
interrupts a check. Ten minutes without matching observations is unverified,
|
||||||
|
not proof the engine failed. Confirmed dialogue has its own existing counters.
|
||||||
|
|
||||||
|
The monitor exposes sampled online/active bot-hours, native death deltas including
|
||||||
|
normal resurrection, quest turn-ins per bot-hour, and observed/unverified/
|
||||||
|
interrupted intention results. Rates start with this evaluation deployment and
|
||||||
|
exclude collection gaps over two minutes. Native deaths use retained database
|
||||||
|
records; retention rollover can undercount. Quest/XP rate and deaths must be read
|
||||||
|
together: idling is not successful survival. Aggregates alone are insufficient to
|
||||||
|
compare different levels/classes; inspect per-bot gameplay tables as well.
|
||||||
|
|
||||||
|
Run `python3 scripts/evaluate-gameplay.py` on the mini PC for a read-only summary.
|
||||||
|
Compare the first full day, investigate failures, and retain the existing model.
|
||||||
|
No fine-tuning or paid compute is started by this change.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
diff --git a/app-staging/core/connectors/index.ts b/app-staging/core/connectors/index.ts
|
||||||
|
--- a/app-staging/core/connectors/index.ts
|
||||||
|
+++ b/app-staging/core/connectors/index.ts
|
||||||
|
@@ -36,1 +36,1 @@
|
||||||
|
-const response=await fetch(`${url.href.replace(/\/$/,'')}/v1${path}`,{method:body?'POST':'GET',headers:{Authorization:`Bearer ${this.config.BRIDGE_TOKEN}`,'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.timeout(12000),redirect:'error'});
|
||||||
|
+const response=await fetch(`${url.href.replace(/\/$/,'')}/v1${path}`,{method:body?'POST':'GET',headers:{Authorization:`Bearer ${this.config.BRIDGE_TOKEN}`,'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.timeout(30_000),redirect:'error'});
|
||||||
@@ -99,14 +99,22 @@ async function deliverEvents(){
|
|||||||
FROM acore_auth.azerothai_event_outbox
|
FROM acore_auth.azerothai_event_outbox
|
||||||
WHERE delivered_at IS NULL AND kind='player.snapshot'
|
WHERE delivered_at IS NULL AND kind='player.snapshot'
|
||||||
AND created_at >= NOW() - INTERVAL 90 SECOND
|
AND created_at >= NOW() - INTERVAL 90 SECOND
|
||||||
ORDER BY id ASC LIMIT ${Math.max(1,Math.floor(EVENT_BATCH/2))}`);
|
ORDER BY id ASC LIMIT 64`);
|
||||||
|
if(fresh.length){
|
||||||
|
const payloads=fresh.map(([, ,hex])=>JSON.parse(Buffer.from(hex,'hex').toString('utf8')));
|
||||||
|
const response=await backend('/api/bridge/ale/snapshots',{
|
||||||
|
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({payloads})
|
||||||
|
});
|
||||||
|
if(!response.ok)throw new Error(`snapshot batch HTTP ${response.status}: ${await response.text()}`);
|
||||||
|
mysql(`UPDATE acore_auth.azerothai_event_outbox SET delivered_at=NOW(6) WHERE id IN (${fresh.map(row=>Number(row[0])).join(',')})`);
|
||||||
|
}
|
||||||
const batch=rows(`SELECT id,HEX(kind),HEX(payload)
|
const batch=rows(`SELECT id,HEX(kind),HEX(payload)
|
||||||
FROM acore_auth.azerothai_event_outbox
|
FROM acore_auth.azerothai_event_outbox
|
||||||
WHERE delivered_at IS NULL
|
WHERE delivered_at IS NULL
|
||||||
${fresh.length?`AND id NOT IN (${fresh.map(row=>Number(row[0])).join(',')})`:''}
|
${fresh.length?`AND id NOT IN (${fresh.map(row=>Number(row[0])).join(',')})`:''}
|
||||||
ORDER BY CASE WHEN kind IN ('player.permanent-death','bridge.ready') THEN 0 WHEN kind IN ('player.whisper','player.chat') AND created_at >= NOW() - INTERVAL 90 SECOND THEN 1 ELSE 2 END, id
|
ORDER BY CASE WHEN kind IN ('player.permanent-death','bridge.ready') THEN 0 WHEN kind IN ('player.whisper','player.chat') AND created_at >= NOW() - INTERVAL 90 SECOND THEN 1 ELSE 2 END, id
|
||||||
LIMIT ${Math.max(1,EVENT_BATCH-fresh.length)}`);
|
LIMIT ${EVENT_BATCH}`);
|
||||||
for(const [id,kindHex,payloadHex] of [...fresh,...batch]){
|
for(const [id,kindHex,payloadHex] of batch){
|
||||||
const kind=Buffer.from(kindHex||'','hex').toString('utf8');
|
const kind=Buffer.from(kindHex||'','hex').toString('utf8');
|
||||||
const payloadText=Buffer.from(payloadHex||'','hex').toString('utf8');
|
const payloadText=Buffer.from(payloadHex||'','hex').toString('utf8');
|
||||||
let payload;
|
let payload;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only checkpoint for the grounded-decision trial. No keys or player whispers."""
|
||||||
|
import collections,json,time,urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
base='http://127.0.0.1:8787'
|
||||||
|
def get(path):
|
||||||
|
with urllib.request.urlopen(base+path,timeout=20) as r:return json.load(r)
|
||||||
|
s=get('/api/state');insights=get('/api/realm-insights');now=time.time()
|
||||||
|
bots=[b for b in s['bots'] if b.get('status')=='online' and b.get('autonomy',{}).get('managed')]
|
||||||
|
checks=[c for b in bots for c in b.get('autonomy',{}).get('intentChecks',[])]
|
||||||
|
report={'checkedAt':now,'online':len(bots),'teams':dict(collections.Counter(b.get('world',{}).get('team') for b in bots)),
|
||||||
|
'freshObservations':sum(0<=now-b.get('world',{}).get('at',0)<90 for b in bots),
|
||||||
|
'groupedBots':sum(bool(b.get('world',{}).get('groupId')) for b in bots),
|
||||||
|
'questKnowledge':sum(bool(b.get('world',{}).get('questBrief')) for b in bots),
|
||||||
|
'evaluation':insights.get('evaluation'),'recentIntentResults':dict(collections.Counter(c['status'] for c in checks)),
|
||||||
|
'currentActivity':dict(collections.Counter(b['label'] for b in insights.get('activity',[]))),
|
||||||
|
'zaku':[{k:b.get(k) for k in ('name','level','zone','lastDecision')} for b in bots if b['name']=='Zaku'],
|
||||||
|
'limitations':['Rates use fresh sampled bot-hours; collection gaps excluded.','Native death deltas use retained death records; retention can undercount at rollover.','An observed outcome following an intention does not establish causation.','One day is an initial comparison, not proof of long-term reliability.']}
|
||||||
|
print(json.dumps(report,indent=2))
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Read native counters and profiler output; return bounded public measurements."""
|
"""Read native counters and profiler output; return bounded public measurements."""
|
||||||
import json,subprocess,time,math
|
import json,subprocess,time,math
|
||||||
COUNTERS=('quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper')
|
COUNTERS=('quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper','nativeDeaths')
|
||||||
def normalize_performance(raw):
|
def normalize_performance(raw):
|
||||||
def number(v):
|
def number(v):
|
||||||
try:
|
try:
|
||||||
@@ -28,9 +28,13 @@ def collect(sql,state):
|
|||||||
for line in sql('SELECT guid,COUNT(*) FROM acore_characters.character_queststatus_rewarded WHERE guid IN ('+guids+') GROUP BY guid').splitlines():
|
for line in sql('SELECT guid,COUNT(*) FROM acore_characters.character_queststatus_rewarded WHERE guid IN ('+guids+') GROUP BY guid').splitlines():
|
||||||
guid,count=map(int,line.split('\t'));rewarded[guid]=count
|
guid,count=map(int,line.split('\t'));rewarded[guid]=count
|
||||||
money={int(g):int(c) for g,c in (line.split('\t') for line in sql('SELECT guid,looted_copper FROM acore_auth.azerothai_currency').splitlines())}
|
money={int(g):int(c) for g,c in (line.split('\t') for line in sql('SELECT guid,looted_copper FROM acore_auth.azerothai_currency').splitlines())}
|
||||||
|
deaths={}
|
||||||
|
for line in sql('SELECT guid,account_id,name,COUNT(*) FROM acore_auth.azerothai_death_experience GROUP BY guid,account_id,name').splitlines():
|
||||||
|
g,account,name,count=line.split('\t');g=int(g)
|
||||||
|
if g in allowed and allowed[g]['name']==name and allowed[g]['autonomy'].get('accountId')==int(account):deaths[g]=int(count)
|
||||||
result=[]
|
result=[]
|
||||||
for guid,b in allowed.items():
|
for guid,b in allowed.items():
|
||||||
counters=rows.get(guid,dict.fromkeys(COUNTERS,0));counters['rewarded']=rewarded.get(guid,0);counters['lootedCopper']=money.get(guid,0)
|
counters=rows.get(guid,dict.fromkeys(COUNTERS,0));counters['rewarded']=rewarded.get(guid,0);counters['lootedCopper']=money.get(guid,0);counters['nativeDeaths']=deaths.get(guid,0)
|
||||||
result.append({'guid':guid,'name':b['name'],**counters})
|
result.append({'guid':guid,'name':b['name'],**counters})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -34,3 +34,14 @@ test('surface inventory includes every documented ALE class family',()=>{
|
|||||||
assert.equal(ALE_SURFACE.playerbotClasses.length,7);
|
assert.equal(ALE_SURFACE.playerbotClasses.length,7);
|
||||||
assert.equal(ALE_SURFACE.modelAccess,'none');
|
assert.equal(ALE_SURFACE.modelAccess,'none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('snapshot batch saves once and does not commit a partial invalid roster',async()=>{
|
||||||
|
const {applyAuthoritativeAleSnapshots}=require('../.test-build/service.js');
|
||||||
|
const {seed}=require('../.test-build/seed.js');
|
||||||
|
let state=seed(),writes=0;
|
||||||
|
const store={load:async()=>structuredClone(state),save:async(s,r)=>{assert.equal(r,state.revision);writes++;state=s;}};
|
||||||
|
await applyAuthoritativeAleSnapshots(store,[{roster:true,observedAt:100,characterGuids:[]},{roster:true,observedAt:101,characterGuids:[]}]);
|
||||||
|
assert.equal(writes,1);assert.equal(state.aleRosterAt,101);
|
||||||
|
await assert.rejects(()=>applyAuthoritativeAleSnapshots(store,[{roster:true,observedAt:102,characterGuids:[]},{roster:true,observedAt:103,characterGuids:[-1]}]));
|
||||||
|
assert.equal(writes,1);assert.equal(state.aleRosterAt,101);
|
||||||
|
});
|
||||||
|
|||||||
@@ -35,3 +35,29 @@ test('rest requests never enable indefinite stay',()=>{
|
|||||||
const source=compileLivePlayerbotIntent('A',{action:'rest',goal:'Recover',reason:'Tired'});
|
const source=compileLivePlayerbotIntent('A',{action:'rest',goal:'Recover',reason:'Tired'});
|
||||||
assert.doesNotMatch(source,/\+stay/);assert.match(source,/-stay/);assert.match(source,/GetHealthPct/);assert.match(source,/AzerothAITrainingOverride/);
|
assert.doesNotMatch(source,/\+stay/);assert.match(source,/-stay/);assert.match(source,/GetHealthPct/);assert.match(source,/AzerothAITrainingOverride/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('fresh full recovery removes unnecessary rest but stale or low mana does not',()=>{
|
||||||
|
const {availableIntentActions,decisionContext}=require('../.test-build/autonomy.js');
|
||||||
|
const bot={personality:{activities:['quest','rest']},world:{at:Date.now()/1000,healthPct:100,manaPct:100,questBrief:'A real quest',threatBrief:'Wolf level 7',readinessAt:Date.now()/1000,foodCount:0,drinkCount:0}};
|
||||||
|
assert.deepEqual(availableIntentActions(bot),['quest']);assert.match(decisionContext(bot),/A real quest/);assert.match(decisionContext(bot),/Wolf level 7/);
|
||||||
|
bot.world.manaPct=20;assert.deepEqual(availableIntentActions(bot),['quest','rest']);
|
||||||
|
bot.world.at-=200;assert.deepEqual(availableIntentActions(bot),['quest','rest']);assert.doesNotMatch(decisionContext(bot),/Wolf/);
|
||||||
|
});
|
||||||
|
test('intent outcomes require fresh matching evidence, not just accepted commands',()=>{
|
||||||
|
const {startIntentCheck,observeIntentChecks}=require('../.test-build/intent-evidence.js');
|
||||||
|
const now=Date.now()/1000,b={level:10,world:{at:now,x:0,y:0,mapId:0,healthPct:50,manaPct:10,groupNames:[],xp:100},autonomy:{}};
|
||||||
|
const check=startIntentCheck(b,{action:'quest'},'q',now);b.autonomy.intentChecks=[check];
|
||||||
|
b.world.x=30;b.world.at=now+30;observeIntentChecks(b);assert.equal(check.status,'pending','Walking is not quest progress');
|
||||||
|
b.world.xp=110;observeIntentChecks(b);assert.equal(check.status,'observed');
|
||||||
|
const rest=startIntentCheck(b,{action:'rest'},'r',now+30);b.autonomy.intentChecks=[rest];b.world.healthPct=100;b.world.manaPct=100;
|
||||||
|
observeIntentChecks(b);assert.equal(rest.status,'pending','Same snapshot cannot prove recovery');
|
||||||
|
b.world.at=now+60;b.world.inCombat=false;observeIntentChecks(b);assert.equal(rest.status,'observed');
|
||||||
|
const party=startIntentCheck(b,{action:'socialize'},'p',now+60,'Peer');b.autonomy.intentChecks=[party];b.world.at=now+90;b.world.nearby=['Peer'];observeIntentChecks(b);assert.equal(party.status,'pending');
|
||||||
|
b.world.groupId='g';b.world.groupNames=['Peer'];observeIntentChecks(b);assert.equal(party.status,'observed');
|
||||||
|
});
|
||||||
|
test('death interrupts intentions and unverified work times out honestly',()=>{
|
||||||
|
const {startIntentCheck,observeIntentChecks}=require('../.test-build/intent-evidence.js');const n=Date.now()/1000;
|
||||||
|
const b={level:10,world:{at:n,x:0,y:0,mapId:0,groupNames:[],xp:100},autonomy:{}};
|
||||||
|
const c=startIntentCheck(b,{action:'trade'},'t',n);b.autonomy.intentChecks=[c];b.world.at=n+601;observeIntentChecks(b);assert.equal(c.status,'no_progress');
|
||||||
|
const d=startIntentCheck(b,{action:'quest'},'q',n+601);b.autonomy.intentChecks=[d];b.world.at=n+630;b.world.isGhost=true;observeIntentChecks(b);assert.equal(d.status,'interrupted');
|
||||||
|
});
|
||||||
|
|||||||
@@ -54,3 +54,10 @@ test('adventuring companions stay within five levels',()=>{
|
|||||||
assert.equal(partyDecision(a,b).accepted,false);
|
assert.equal(partyDecision(a,b).accepted,false);
|
||||||
b.level=a.level+5;assert.equal(partyDecision(a,b).accepted,true);
|
b.level=a.level+5;assert.equal(partyDecision(a,b).accepted,true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('party intentions exclude ghosts and protect unknown party members',()=>{
|
||||||
|
const {intentCompanions}=require('../.test-build/community.js');const a=bot('A'),b=bot('B'),s={bots:[a,b],community:{partiesEnabled:true}};
|
||||||
|
assert.deepEqual(intentCompanions(s,a).map(x=>x.name),['B']);
|
||||||
|
b.world.isGhost=true;assert.equal(intentCompanions(s,a).length,0);b.world.isGhost=false;
|
||||||
|
a.world.groupId='g';a.world.groupNames=['A','Unknown'];assert.equal(intentCompanions(s,a).length,0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,3 +34,17 @@ test('trainer activity reports pending work and verified learning separately',()
|
|||||||
const a=activityReason(b,now);assert.equal(a.label,'Visiting class trainer');assert.match(a.detail,/3 identified spells pending, 2 verified trainer purchases/);
|
const a=activityReason(b,now);assert.equal(a.label,'Visiting class trainer');assert.match(a.detail,/3 identified spells pending, 2 verified trainer purchases/);
|
||||||
b.world.inCombat=true;assert.equal(activityReason(b,now).label,'In combat');
|
b.world.inCombat=true;assert.equal(activityReason(b,now).label,'In combat');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('exposure excludes stale snapshots and gaps; outcome evidence counts once',()=>{
|
||||||
|
const s=state();const first=observeInsights(s,undefined,now);
|
||||||
|
for(const b of s.bots)b.world.at=now+60;
|
||||||
|
s.bots[0].autonomy.intentChecks=[{id:'q',action:'quest',status:'observed',checkedAt:now+30,detail:'XP gained'}];
|
||||||
|
const next=observeInsights(s,first.cursor,now+60);
|
||||||
|
assert.equal(next.events.filter(e=>e.kind==='onlineSeconds').reduce((n,e)=>n+e.amount,0),120);
|
||||||
|
assert.equal(next.events.filter(e=>e.kind==='intentObserved').length,1);
|
||||||
|
assert.equal(observeInsights(s,next.cursor,now+90).events.length,0);
|
||||||
|
for(const b of s.bots)b.world.at=now+185;
|
||||||
|
assert.equal(observeInsights(s,next.cursor,now+185).events.filter(e=>e.kind==='onlineSeconds').reduce((n,e)=>n+e.amount,0),250);
|
||||||
|
for(const b of s.bots)b.world.at=now+600;
|
||||||
|
assert.equal(observeInsights(s,next.cursor,now+600).events.filter(e=>e.kind==='onlineSeconds').length,0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,3 +8,8 @@ test('resolved old recovery failures do not remain actionable',()=>{const b={id:
|
|||||||
test('currency formats copper and remains independent of item counts and historical baselines',()=>{const {formatCopper}=require('../.test-build/realm-outcomes.js');assert.equal(formatCopper(12345),'1g 23s 45c');assert.equal(formatCopper(0),'0g 0s 0c');const first=outcomeChanges([{...row,lootedCopper:100}],{at:1,startedAt:1,rows:[row]},2);assert.equal(first.events.length,0);const next=outcomeChanges([{...row,lootedCopper:250}],first.baseline,3);assert.deepEqual(next.events.map(e=>[e.kind,e.amount]),[['lootedCopper',150]]);});
|
test('currency formats copper and remains independent of item counts and historical baselines',()=>{const {formatCopper}=require('../.test-build/realm-outcomes.js');assert.equal(formatCopper(12345),'1g 23s 45c');assert.equal(formatCopper(0),'0g 0s 0c');const first=outcomeChanges([{...row,lootedCopper:100}],{at:1,startedAt:1,rows:[row]},2);assert.equal(first.events.length,0);const next=outcomeChanges([{...row,lootedCopper:250}],first.baseline,3);assert.deepEqual(next.events.map(e=>[e.kind,e.amount]),[['lootedCopper',150]]);});
|
||||||
|
|
||||||
test('large state chunks preserve Unicode and all history within per-value limits',()=>{const {splitState}=require('../.test-build/state-chunks.js');const input={history:'🎮'.repeat(600000)};const chunks=splitState(input);assert.ok(chunks.length>1);assert.deepEqual(JSON.parse(chunks.join('')),input);for(const c of chunks)assert.ok(Buffer.byteLength(c)<512*1024);});
|
test('large state chunks preserve Unicode and all history within per-value limits',()=>{const {splitState}=require('../.test-build/state-chunks.js');const input={history:'🎮'.repeat(600000)};const chunks=splitState(input);assert.ok(chunks.length>1);assert.deepEqual(JSON.parse(chunks.join('')),input);for(const c of chunks)assert.ok(Buffer.byteLength(c)<512*1024);});
|
||||||
|
|
||||||
|
test('normal death counts establish a baseline and count subsequent native deaths',()=>{
|
||||||
|
const first=outcomeChanges([{...row,nativeDeaths:100}],{at:1,startedAt:1,rows:[row]},2);assert.equal(first.events.length,0);
|
||||||
|
const next=outcomeChanges([{...row,nativeDeaths:103}],first.baseline,3);assert.equal(next.events.find(e=>e.kind==='nativeDeaths').amount,3);
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ function make(id,name)
|
|||||||
function p:GetGUIDLow()return self.id end;function p:GetName()return self.name end;function p:GetAccountId()return 10+self.id end
|
function p:GetGUIDLow()return self.id end;function p:GetName()return self.name end;function p:GetAccountId()return 10+self.id end
|
||||||
function p:IsPlayerbot()return not self.human end;function p:IsDead()return self.dead end;function p:GetBotAI()return self.ai end
|
function p:IsPlayerbot()return not self.human end;function p:IsDead()return self.dead end;function p:GetBotAI()return self.ai end
|
||||||
function p:GetPowerType()return self.powerType or 1 end;function p:GetPowerPct()return self.mana or 100 end;function p:GetAttackers()return self.attackers or {} end;function p:GetHealthPct()return self.hp end;function p:IsInCombat()return self.combat end
|
function p:GetPowerType()return self.powerType or 1 end;function p:GetPowerPct()return self.mana or 100 end;function p:GetAttackers()return self.attackers or {} end;function p:GetHealthPct()return self.hp end;function p:IsInCombat()return self.combat end
|
||||||
function p:HasUnitState(state)assert(state==0x00000100);return self.taxi end;function p:IsCasting()return self.cast end;function p:IsRooted()return false end
|
function p:HasUnitState(state)assert(state==0x00000100);return self.taxi end;function p:IsMoving()return self.moving end;function p:GetMovementType()return self.motion or 0 end;function p:MoveExpire()self.expired=true;self.motion=0 end;function p:MoveFleeing(t,ms)self.fleeTarget=t;self.motion=9;self.fleeDuration=ms end;function p:IsCasting()return self.cast end;function p:IsRooted()return false end
|
||||||
function p:GetX()return self.x or 0 end;function p:GetY()return 0 end
|
function p:GetX()return self.x or 0 end;function p:GetY()return 0 end;function p:GetZ()return 0 end;function p:MoveTo(id,x,y,z,path)self.escape={id=id,x=x,y=y,z=z,path=path} end
|
||||||
function p:GetUnfriendlyUnitsInRange()return self.friendly and {} or self.selected and {self.selected} or {} end;function p:GetSelection()return self.selected end;function p:StopSpellCast()self.cast=false;self.interrupted=true end
|
function p:GetUnfriendlyUnitsInRange()return self.friendly and {} or self.selected and {self.selected} or {} end;function p:GetSelection()return self.selected end;function p:StopSpellCast()self.cast=false;self.interrupted=true end
|
||||||
function p:AttackStop()self.attackStopped=true end;function p:MoveStop()self.waited=true end
|
function p:AttackStop()self.attackStopped=true end;function p:MoveStop()self.waited=true end
|
||||||
function p:GetGroup()return self.group end;function p:GetVictim()return self.enemy end;function p:GetLevel()return 5 end
|
function p:GetGroup()return self.group end;function p:GetVictim()return self.enemy end;function p:GetLevel()return 5 end
|
||||||
@@ -69,3 +69,7 @@ print('Cohesion suppresses competing work, keeps catch-up hysteresis, yields to
|
|||||||
l=new();l.execute("A.strategies={stay=true};step(0);assert(not A.strategies.stay,'Fully recovered autonomous resident must not remain in stay')")
|
l=new();l.execute("A.strategies={stay=true};step(0);assert(not A.strategies.stay,'Fully recovered autonomous resident must not remain in stay')")
|
||||||
l=new();l.execute("group();B.distance=50;step(0);step(40);assert(B.master==nil,'No-progress regroup must release its owned master');assert(not AzerothAISurvivalOverride(B));step(30);assert(B.master==nil,'Backoff must prevent immediate retry')")
|
l=new();l.execute("group();B.distance=50;step(0);step(40);assert(B.master==nil,'No-progress regroup must release its owned master');assert(not AzerothAISurvivalOverride(B));step(30);assert(B.master==nil,'Backoff must prevent immediate retry')")
|
||||||
print('Recovered stay cleared and non-progressing regroup bounded')
|
print('Recovered stay cleared and non-progressing regroup bounded')
|
||||||
|
|
||||||
|
l=new();l.execute("A.combat=true;A.hp=20;B.x=5;A.attackers={B};function A.ai:DoSpecificAction(s)table.insert(calls,s);return false end;step(0);assert(A.escape.x==-20 and A.escape.path==true);A.escape=nil;step(8);assert(A.escape==nil,'Native fallback must honor cooldown');step(8);assert(A.escape.x==-20)")
|
||||||
|
l=new();l.execute("A.combat=true;A.hp=20;A.controlled=true;B.x=5;A.attackers={B};function A.ai:DoSpecificAction()return false end;step(0);assert(A.escape==nil)")
|
||||||
|
print('Rejected flee uses bounded path away from threat, with cooldown and human protection')
|
||||||
|
|||||||
Reference in New Issue
Block a user