Add social feed, cooperative parties, guild goals, and operations recovery

This commit is contained in:
Codex
2026-09-22 17:43:07 -04:00
parent 5bee808492
commit 46935c791e
17 changed files with 477 additions and 12 deletions
+2
View File
@@ -141,6 +141,8 @@ export async function runLiveAutonomyTick(
let bot=pick.bot;
const observation=observationFrom(bot,pick.obs,events);
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.`;
const brain=providerForBot(bot,s.settings,config);
let decision:Decision;
let usedFallback=false;
+118
View File
@@ -0,0 +1,118 @@
import type {Bot,State,StateStore,SocialEntry} from './types';
import {luaString} from './ale';
import {queueAleCommand} from './ale-store';
export function community(s:State){return s.community??(s.community={feed:[],evidence:[],guilds:[],partiesEnabled:true,guildsEnabled:true});}
export function socialEntry(s:State,e:SocialEntry){const c=community(s);if(!c.feed.some(x=>x.id===e.id))c.feed.push(e);c.feed=c.feed.slice(-250);}
export function bondEvidence(s:State,id:string,a:Bot,b:Bot,reason:string,delta:number){
const c=community(s);if(c.evidence.includes(id)||a.id===b.id)return false;
c.evidence.push(id);c.evidence=c.evidence.slice(-1000);
for(const [from,to] of [[a,b],[b,a]]){
let r=from.relationships.find(x=>x.targetId===to.id);
if(!r){r={targetId:to.id,affinity:0,note:reason};from.relationships.push(r);}
r.affinity=Math.max(-100,Math.min(100,r.affinity+delta));r.note=reason;
from.relationships=from.relationships.slice(-100);
}
socialEntry(s,{id,at:new Date().toISOString(),kind:'relationship',names:[a.name,b.name],text:reason,affinityDelta:delta,status:'confirmed'});
return true;
}
export async function changeCommunity(store:StateStore,fn:(s:State)=>void){
for(let n=0;n<4;n++){const s=await store.load(),r=s.revision;fn(s);try{await store.save(s,r);return;}catch(e){if(n===3||!(e instanceof Error)||!e.message.includes('State changed'))throw e;}}
}
export function observePresence(s:State,b:Bot,p:Record<string,unknown>){
const at=Number(p.observedAt);if(!Number.isFinite(at)||at<=(b.world?.at??0)||Math.abs(Date.now()/1000-at)>120)return;
if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return;
const old=b.world;
const stationary=old&&old.mapId===p.mapId&&Math.hypot(old.x-p.x,old.y-p.y)<3;
b.world={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};
const c=community(s);
if(b.world.guildId&&!c.guilds.some(g=>g.id===b.world!.guildId))c.guilds.push({id:b.world.guildId,name:b.world.guildName,goal:['quest','support','explore'][b.world.guildId%3] as 'quest'|'support'|'explore',progress:0,target:20});
if(old&&old.mapId!==b.world.mapId&&b.world.guildId){
const g=c.guilds.find(g=>g.id===b.world!.guildId),key=`explore:${b.id}:${b.world.mapId}:${Math.floor(at/86400)}`;
if(g?.goal==='explore'&&!c.evidence.includes(key)){g.progress++;c.evidence.push(key);c.evidence=c.evidence.slice(-1000);socialEntry(s,{id:key,at:new Date().toISOString(),kind:'guild',names:[b.name,g.name],text:'Reached another map for the guild exploration goal.',status:'confirmed'});}
}
}
export function applyCooperation(s:State,p:Record<string,unknown>){
const at=Number(p.observedAt);if(!Number.isFinite(at)||Math.abs(Date.now()/1000-at)>120)return;
const id=String(p.evidenceId||'');if(!id||community(s).evidence.includes(id))return;
const a=s.bots.find(b=>b.autonomy?.characterGuid===p.characterGuid),b=s.bots.find(b=>b.autonomy?.characterGuid===p.peerGuid);
if(!a||!b||a.status==='dead'||b.status==='dead')return;
const kind=p.cooperation;
if(kind!=='quest'&&kind!=='assist')return;
if(bondEvidence(s,id,a,b,kind==='quest'?'Completed the same quest while adventuring together.':'Helped defeat an enemy together.',kind==='quest'?3:1)){
for(const bot of [a,b]){bot.memories.push({id,text:kind==='quest'?`Completed quest ${String(p.questId)} with ${bot.id===a.id?b.name:a.name}.`:`Fought alongside ${bot.id===a.id?b.name:a.name}.`,importance:6,source:'observation',at:new Date().toISOString()});bot.memories=bot.memories.slice(-100);}
if(a.world?.guildId&&a.world.guildId===b.world?.guildId){const g=community(s).guilds.find(g=>g.id===a.world!.guildId);if(g&&((g.goal==='quest'&&kind==='quest')||(g.goal==='support'&&kind==='assist')))g.progress++;}
}
}
const affinity=(a:Bot,b:Bot)=>a.relationships.find(r=>r.targetId===b.id)?.affinity??0;
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.inCombat;
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)>10||!a.world!.nearby.includes(b.name)||!b.world!.nearby.includes(a.name))return {accepted:false,reason:'No compatible nearby pair'};
const willing=(x:Bot,y:Bot)=>affinity(x,y)>=0&&(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)?'Both accepted: compatible levels, shared adventure interests, and no hostile bond.':'One character prefers to adventure alone or does not trust the other.'};
}
export function compileCommunityAction(kind:'party'|'leave'|'recruit',a:Bot,b?:Bot){
const lines=[`local a=GetPlayerByName(${luaString(a.name)})`,`if not a or a:GetGUIDLow()~=${a.autonomy!.characterGuid} or not a:IsPlayerbot() or a:IsDead() or a:IsInCombat() then error('Resident unavailable') end`];
if(kind==='leave'){
lines.push(`local g=a:GetGroup();if not g then return 'Already solo' end`,`if tostring(g:GetGUID())~=${luaString(a.world!.groupId)} then error('Party changed') end`,`if #g:GetMembers()~=g:GetMembersCount() then error('Party has offline members') end;for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() then error('Human party is protected') end end`,`a:RemoveFromGroup()`,`if a:GetGroup() then error('Departure was not applied') end`);
}else{
if(!b)throw new Error('Peer required');
lines.push(`local b=GetPlayerByName(${luaString(b.name)})`,`if not b or b:GetGUIDLow()~=${b.autonomy!.characterGuid} or not b:IsPlayerbot() or b:IsDead() or b:IsInCombat() or a:GetTeam()~=b:GetTeam() or a:GetMapId()~=b:GetMapId() or a:GetPhaseMask()~=b:GetPhaseMask() or a:GetDistance(b)>35 then error('Peer unavailable nearby') end`);
if(kind==='party')lines.push(`if math.abs(a:GetLevel()-b:GetLevel())>10 then error('Level mismatch') end`,`local g=a:GetGroup();if g and b:GetGroup()==g then return 'Already grouped' end`,`if b:GetGroup() then error('Peer already grouped') end`,`if g then if g:IsRaidGroup() or g:GetMembersCount()>=5 then error('Party full') end;if #g:GetMembers()~=g:GetMembersCount() then error('Party has offline members') end;for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() then error('Human party is protected') end end;if not g:AddMember(b) then error('Party join failed') end else g=a:GroupCreate(b) end`,`if not g or b:GetGroup()~=g then error('Party membership was not applied') end`);
else lines.push(`local g=a:GetGuild();if not g or a:GetGuildId()~=${a.world!.guildId} or a:GetGuildRank()~=0 then error('Guild leader unavailable') end`,`if b:GetGuildId()==a:GetGuildId() then return 'Already a member' end`,`if b:GetGuildId()~=0 then error('Already belongs to a guild') end`,`g:AddMember(b)`,`if b:GetGuildId()~=a:GetGuildId() then error('Guild admission failed') end`);
}
lines.push('AzerothAIEmitSnapshots()','return "Confirmed '+kind+'"');return lines.join('; ');
}
async function ensure(db:D1Database){
await db.prepare('CREATE TABLE IF NOT EXISTS community_actions (id TEXT PRIMARY KEY,created_at TEXT,kind TEXT,a TEXT,b TEXT,command_id TEXT,status TEXT,reason TEXT)').run();
await db.prepare('CREATE TABLE IF NOT EXISTS community_clock (id TEXT PRIMARY KEY,at INTEGER)').run();
}
export async function communityTick(store:StateStore,db:D1Database){
await ensure(db);
const pending=await db.prepare("SELECT * FROM community_actions WHERE status='pending' LIMIT 20").all<any>();
for(const action of pending.results){
const receipt=await db.prepare('SELECT status,error FROM ale_commands WHERE id=?').bind(action.command_id).first<any>();
if(!receipt||!['completed','failed'].includes(receipt.status))continue;
await changeCommunity(store,s=>{
const a=s.bots.find(b=>b.id===action.a),b=s.bots.find(b=>b.id===action.b);if(!a)return;
const proposal=community(s).feed.find(e=>e.id===action.id+':proposal');if(proposal){proposal.status=receipt.status==='completed'?'confirmed':'failed';proposal.text=action.reason;}
socialEntry(s,{id:action.id,at:new Date().toISOString(),kind:action.kind,names:[a.name,...(b?[b.name]:[])],text:receipt.status==='completed'?action.reason:String(receipt.error||'Game rejected action').replace(/^.*:\d+:\s*/,''),status:receipt.status==='completed'?'confirmed':'failed'});
if(receipt.status==='completed'&&b&&action.kind==='party'&&bondEvidence(s,action.id+':bond',a,b,'Accepted an invitation to adventure together.',1)){
for(const [from,to] of [[a,b],[b,a]]){from.memories.push({id:action.id,text:'Joined a party with '+to.name+' to adventure together.',importance:6,source:'observation',at:new Date().toISOString()});from.memories=from.memories.slice(-100);}
}
});
await db.prepare('UPDATE community_actions SET status=? WHERE id=?').bind(receipt.status,action.id).run();
}
const now=Date.now();
await db.prepare("INSERT OR IGNORE INTO community_clock VALUES ('tick',0)").run();
const gate=await db.prepare("UPDATE community_clock SET at=? WHERE id='tick' AND at<?").bind(now,now-30_000).run();if(gate.meta.changes!==1)return;
await db.prepare("DELETE FROM community_actions WHERE status!='pending' AND created_at<?").bind(new Date(now-7*86400_000).toISOString()).run();
const s=await store.load(),c=community(s),bots=s.bots.filter(b=>fresh(b));
const recent=await db.prepare('SELECT a,b FROM community_actions WHERE created_at>?').bind(new Date(now-10*60_000).toISOString()).all<any>();
const busy=new Set(recent.results.flatMap(r=>[r.a,r.b]));
for(const a of bots){
if(busy.has(a.id))continue;
// Leave only a bot-only party whose peers have moved away, never a human party.
const far=a.world!.groupId&&a.world!.groupNames.length>1&&a.world!.groupNames.filter(n=>n!==a.name).every(n=>!a.world!.nearby.includes(n));
if(c.partiesEnabled&&far){
await db.prepare('INSERT OR IGNORE INTO community_clock VALUES (?,?)').bind('apart:'+a.id,now).run();
const apart=await db.prepare('SELECT at FROM community_clock WHERE id=?').bind('apart:'+a.id).first<{at:number}>();
if(apart&&now-apart.at>10*60_000){await propose('leave',a,undefined,'Left the party after ten minutes apart.');return;}
}else await db.prepare('DELETE FROM community_clock WHERE id=?').bind('apart:'+a.id).run();
for(const b of bots){
if(busy.has(b.id)||a.id===b.id||!a.world!.nearby.includes(b.name))continue;
if(c.guildsEnabled&&a.world!.guildId&&a.world!.guildRank===0&&!b.world!.guildId&&affinity(a,b)>=3&&affinity(b,a)>=3&&partyDecision(a,b).accepted){await propose('recruit',a,b,`Accepted an invitation to ${a.world!.guildName} after building mutual trust.`);return;}
if(c.partiesEnabled&&!b.world!.groupId&&(!a.world!.groupId||a.world!.groupNames.length<5)){
const decision=partyDecision(a,b);
if(!decision.accepted){if(decision.reason==='No compatible nearby pair')continue;await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(crypto.randomUUID(),new Date().toISOString(),'declined',a.id,b.id,'','declined',decision.reason).run();await changeCommunity(store,s=>socialEntry(s,{id:crypto.randomUUID(),at:new Date().toISOString(),kind:'invitation',names:[a.name,b.name],text:decision.reason,status:'declined'}));return;}
await propose('party',a,b,decision.reason);return;
}
}
}
async function propose(kind:'party'|'leave'|'recruit',a:Bot,b:Bot|undefined,reason:string){
const command=await queueAleCommand(db,compileCommunityAction(kind,a,b));
await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(command.id,new Date().toISOString(),kind,a.id,b?.id??'',command.id,'pending',reason).run();
await changeCommunity(store,s=>socialEntry(s,{id:command.id+':proposal',at:new Date().toISOString(),kind:'invitation',names:[a.name,...(b?[b.name]:[])],text:reason+' Awaiting game confirmation.',status:'pending'}));
}
}
+4 -6
View File
@@ -1,3 +1,4 @@
import {bondEvidence,socialEntry,communityTick} from './community';
import { z } from 'zod';
import { queueAleCommand } from './ale-store';
import { luaString } from './ale';
@@ -145,14 +146,10 @@ async function remember(store:StateStore,botId:string,request:ConversationReques
if(peer){
peer.memories.push({id:request.id,text:`I said to ${bot.name}: "${request.message.slice(0,180)}" They replied: "${reply.slice(0,180)}"`,importance:6,source:'observation',at:new Date().toISOString()});
peer.memories=peer.memories.slice(-100);
for(const [from,to] of [[bot,peer],[peer,bot]]){
const existing=from.relationships.find(r=>r.targetId===to.id);
if(existing)existing.note='We have spoken together in person.';
else from.relationships.push({targetId:to.id,affinity:0,note:'We have spoken together in person.'});
from.relationships=from.relationships.slice(-100);
}
bondEvidence(state,request.id+':bond',bot,peer,'Shared a confirmed conversation.',1);
}
}
socialEntry(state,{id:request.id,at:new Date().toISOString(),kind:'conversation',names:[request.speakerName,bot.name],text:`${request.speakerName}: ${request.message}\n${bot.name}: ${reply}`,status:'confirmed'});
bot.simulationBand='foreground';
realmEvent(state,'memory',`${bot.name} had a live conversation with ${request.speakerName}.`,bot.id);
try{
@@ -177,6 +174,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
}
}
await cleanupConversations(db);
await communityTick(store,db);
const request=await leaseConversation(db);
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
+10
View File
@@ -1,3 +1,4 @@
import {community,observePresence,applyCooperation,socialEntry} from './community';
import { runGM } from './gm';
import { actionSchema } from './validation';
import { connectors } from './connectors';
@@ -72,6 +73,7 @@ function observedHardcoreDeath(s:State,b:Bot,observation:Observation){
export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,payload:Record<string,unknown>){
let s=await store.load();
const revision=s.revision;
if(kind==='player.snapshot'&&payload.cooperation){applyCooperation(s,payload);await store.save(s,revision);return {matched:true,cooperation:true};}
// Complete world snapshots own presence once available; historical events
// must never bring disconnected residents back online.
if(kind==='player.snapshot' && payload.roster===true){
@@ -151,6 +153,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
}
if(b.status==='retired')return {matched:true,botId:b.id,retired:true};
if(kind==='player.snapshot')observePresence(s,b,payload);
b.level=level;
if(zone)b.zone=zone;
@@ -231,6 +234,13 @@ export async function execute(store:StateStore,input:unknown,config:RuntimeConfi
if(a.type==='gm'){
const id=crypto.randomUUID();s.operations[id]={started:Date.now(),botId:'gm'};event(s,'gm',`${a.live?'LIVE':'SIMULATED'} command requested: ${a.command}`);await store.save(s,revision);
try{const result=await runGM(a.command,a.live,a.confirmed,config);delete s.operations[id];event(s,'gm',`${a.live?'LIVE':'SIMULATED'} ${result.command}: ${result.output}`);await store.save(s,s.revision);return result;}catch(e){delete s.operations[id];event(s,'error',`GM command failed: ${e instanceof Error?e.message:'Unknown error'}`);await store.save(s,s.revision);throw e;}
}else if(a.type==='retentionPolicy'){
s.opsRetentionDays=a.days;
}else if(a.type==='communityPolicy'){
Object.assign(community(s),{partiesEnabled:a.partiesEnabled,guildsEnabled:a.guildsEnabled});
}else if(a.type==='guildGoal'){
const g=community(s).guilds.find(g=>g.id===a.guildId);if(!g)throw new Error('Guild not observed');g.goal=a.goal;g.progress=0;
socialEntry(s,{id:crypto.randomUUID(),at:new Date().toISOString(),kind:'guild',names:[g.name],text:'Guild goal changed to '+a.goal,status:'confirmed'});
}else if(a.type==='director'){
s.director={experience:a.experience,zone:a.zone,playerName:a.playerName,brief:a.brief};
for(const b of s.bots.filter(b=>b.status!=='retired'&&b.status!=='dead')) b.goal=`${a.experience} in ${a.zone}${a.playerName?' around '+a.playerName:''}: ${a.brief}`.slice(0,200);
+6 -2
View File
@@ -10,7 +10,11 @@ export type DeathRecord = {id:string; at:string; zone:string; level:number; caus
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 AutonomyState = {managed:boolean; characterGuid?:number; accountId?:number; observedAt?:string; lastIntentAt?:string; nextIntentAt?:string; lastAleCommandId?:string};
export type Bot = {id:string; name:string; role:string; race:string; level:number; zone:string; status:Status; tier:AgentTier; mortal:boolean; plotArmorHpFloor:number; simulationBand:SimulationBand; homeId?:string; routine:Routine; autonomy?:AutonomyState; recipeId:string; recipeVersion:number; personality:Recipe; goal:string; memories:Memory[]; relationships:Relationship[]; decisions:number; death?:DeathRecord; lastDecision?:Decision};
export type WorldPresence = {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 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 Bot = {world?:WorldPresence;id:string; name:string; role:string; race:string; level:number; zone:string; status:Status; tier:AgentTier; mortal:boolean; plotArmorHpFloor:number; simulationBand:SimulationBand; homeId?:string; routine:Routine; autonomy?:AutonomyState; recipeId:string; recipeVersion:number; personality:Recipe; goal:string; memories:Memory[]; relationships:Relationship[]; decisions:number; death?:DeathRecord; lastDecision?:Decision};
export type Decision = {action:Activity; reason:string; goal:string};
export type RealmEvent = {id:string; seq:number; at:string; kind:'system'|'lifecycle'|'decision'|'memory'|'relationship'|'recipe'|'gm'|'death'|'housing'|'population'|'error'; message:string; botId?:string; mode:'simulation'|'azerothcore'};
export type HardcoreSettings = {enabled:boolean; requireVerifiedDeath:boolean; memorialize:boolean};
@@ -18,7 +22,7 @@ export type PopulationSettings = {defaultZoneTarget:number; zoneMax:number; serv
export type BrainRouterSettings = {enabled:boolean; residentModel:string; heroModel:string; backgroundUseLLM:boolean; residentMemoryLimit:number; heroMemoryLimit:number; residentMaxTokens:number; heroMaxTokens:number};
export type WorldTrigger = {id:string; kind:'zone-change'|'interaction'|'time'|'manual'; zone:string; at:string; playerName?:string; detail?:string};
export type PopulationPlan = {id:string; trigger:WorldTrigger; target:number; activeBefore:number; requested:number; capacity:number; selectedBotIds:string[]; rejectedReasons:string[]; createdAt:string};
export type State = {revision:number; aleRosterAt?:number; bots:Bot[]; homes:Home[]; recipes:Recipe[]; events:RealmEvent[]; sequence:number; settings:{connector:'simulation'|'azerothcore'; provider:'deterministic'|'ollama'|'openai-compatible'; model:string; brains:BrainRouterSettings; hardcore:HardcoreSettings; population:PopulationSettings}; director:{experience:string; zone:string; playerName:string; brief:string}; population:{lastPlan?:PopulationPlan}; operations:Record<string,{started:number; botId:string}>};
export type State = {revision:number; aleRosterAt?:number; community?:Community; opsRetentionDays?:number; bots:Bot[]; homes:Home[]; recipes:Recipe[]; events:RealmEvent[]; sequence:number; settings:{connector:'simulation'|'azerothcore'; provider:'deterministic'|'ollama'|'openai-compatible'; model:string; brains:BrainRouterSettings; hardcore:HardcoreSettings; population:PopulationSettings}; director:{experience:string; zone:string; playerName:string; brief:string}; population:{lastPlan?:PopulationPlan}; operations:Record<string,{started:number; botId:string}>};
export type DeathSignal = {id:string; at:string; cause:string; killer?:string; zone?:string; verified:boolean};
export type Observation = {zone:string; level:number; availableActions:Activity[]; summary:string; alive:boolean; death?:DeathSignal};
export interface GameConnector {id:string; health():Promise<{ok:boolean; detail:string}>; observe(bot:Bot):Promise<Observation>; lifecycle(bot:Bot,action:'spawn'|'pause'|'resume'|'despawn'|'retire',requestId:string):Promise<void>; decide(bot:Bot,decision:Decision,requestId:string):Promise<{summary:string;zone?:string}>;}
+3
View File
@@ -5,6 +5,9 @@ export const recipeSchema=z.object({id:z.string().max(80).optional(),name:z.stri
const populationSchema=z.object({defaultZoneTarget:z.number().int().min(0).max(100),zoneMax:z.number().int().min(1).max(200),serverMax:z.number().int().min(1).max(1000),foregroundMax:z.number().int().min(1).max(200),backgroundMax:z.number().int().min(1).max(1000)});
const brainsSchema=z.object({enabled:z.boolean(),residentModel:z.string().trim().min(1).max(100),heroModel:z.string().trim().min(1).max(100),backgroundUseLLM:z.boolean(),residentMemoryLimit:z.number().int().min(1).max(50),heroMemoryLimit:z.number().int().min(1).max(100),residentMaxTokens:z.number().int().min(64).max(2048),heroMaxTokens:z.number().int().min(64).max(4096)});
export const actionSchema=z.discriminatedUnion('type',[
z.object({type:z.literal('retentionPolicy'),days:z.number().int().min(3).max(30)}),
z.object({type:z.literal('communityPolicy'),partiesEnabled:z.boolean(),guildsEnabled:z.boolean()}),
z.object({type:z.literal('guildGoal'),guildId:z.number().int().positive(),goal:z.enum(['quest','support','explore'])}),
z.object({type:z.literal('createBot'),name:z.string().trim().regex(/^[A-Za-z][A-Za-z '-]{1,23}$/),role:z.enum(['Warrior','Priest','Mage','Rogue','Hunter','Paladin','Warlock','Druid','Shaman']),race:z.enum(['Human','Night Elf','Dwarf','Orc','Undead','Tauren','Gnome','Troll','Blood Elf','Draenei']),recipeId:z.string(),goal:z.string().trim().min(1).max(200)}),
z.object({type:z.literal('lifecycle'),botId:z.string(),action:z.enum(['spawn','pause','resume','despawn','retire'])}),
z.object({type:z.literal('think'),botId:z.string()}),