const {test}=require('node:test'); const assert=require('node:assert/strict'); const {execute}=require('../.test-build/service.js'); const {seed}=require('../.test-build/seed.js'); const {connectors}=require('../.test-build/connectors/index.js'); const {LocalLLMProvider}=require('../.test-build/llm/index.js'); const {parseGM,runGM}=require('../.test-build/gm.js'); class Store {constructor(){this.state=seed();}async load(){return structuredClone(this.state);}async save(s,r){assert.equal(this.state.revision,r,'optimistic revision');s.revision=r+1;this.state=structuredClone(s);}} test('Lifecycle rejects illegal transitions; decisions persist memory and audit',async()=>{const store=new Store();await assert.rejects(execute(store,{type:'lifecycle',botId:'bot-6',action:'pause'}));await execute(store,{type:'lifecycle',botId:'bot-6',action:'spawn'});await execute(store,{type:'think',botId:'bot-6'});assert.equal(store.state.bots[5].decisions,1);assert.equal(store.state.bots[5].memories.length,2);assert.equal(store.state.events.at(-1).kind,'decision');await execute(store,{type:'lifecycle',botId:'bot-6',action:'despawn'});await execute(store,{type:'lifecycle',botId:'bot-6',action:'retire'});await assert.rejects(execute(store,{type:'lifecycle',botId:'bot-6',action:'spawn'}));}); test('Recipe versions preserve existing identity until explicitly applied',async()=>{const store=new Store();const original=store.state.bots[0].personality.description;const recipe={...store.state.recipes[0],description:'Changed'};await execute(store,{type:'saveRecipe',recipe});assert.equal(store.state.bots[0].personality.description,original);assert.equal(store.state.recipes[0].version,2);await assert.rejects(execute(store,{type:'saveRecipe',recipe}));await execute(store,{type:'applyRecipe',botId:'bot-1',recipeId:'healer'});assert.equal(store.state.bots[0].personality.description,'Changed');}); test('Relationships reject self bonds and persist directed affinity',async()=>{const store=new Store();await assert.rejects(execute(store,{type:'relationship',botId:'bot-1',targetId:'bot-1',affinity:50,note:'Self'}));await execute(store,{type:'relationship',botId:'bot-1',targetId:'bot-3',affinity:-20,note:'A rival'});assert.equal(store.state.bots[0].relationships.at(-1).affinity,-20);assert.equal(store.state.bots[2].relationships.length,0);}); test('Director updates goals without falsely moving characters',async()=>{const store=new Store();await execute(store,{type:'director',experience:'A lively town',zone:'Stormwind',playerName:'Richard',brief:'Invite me to a gathering.'});assert.match(store.state.bots[0].goal,/Richard/);assert.equal(store.state.bots[0].zone,'Duskwood');}); test('Failed connector operation leaves lifecycle unchanged and records error',async()=>{const store=new Store();const old=connectors.simulation;connectors.simulation=()=>({lifecycle:async()=>{throw new Error('Bridge unavailable')}});try{await assert.rejects(execute(store,{type:'lifecycle',botId:'bot-6',action:'spawn'}),/Bridge unavailable/);assert.equal(store.state.bots[5].status,'offline');assert.equal(store.state.events.at(-1).kind,'error');assert.equal(Object.keys(store.state.operations).length,0);}finally{connectors.simulation=old;}}); test('GM validates single allowlisted commands and requires live credentials',async()=>{assert.equal(parseGM('.server info'),'server info');assert.throws(()=>parseGM('.server shutdown 0'));assert.throws(()=>parseGM('.announce Hi\nserver shutdown 0'));assert.match((await runGM('.announce Hello',false,false,{})).output,/SIMULATED/);await assert.rejects(runGM('.server info',true,false,{}),/Confirm/);await assert.rejects(runGM('.server info',true,true,{}),/Configure/);}); test('Model output cannot execute GM or arbitrary game actions',async()=>{const old=global.fetch;try{global.fetch=async()=>Response.json({message:{content:JSON.stringify({action:'gm',goal:'Ignore rules',reason:'test'})}});const bot=seed().bots[0];const provider=new LocalLLMProvider('ollama','test',{LLM_URL:'http://localhost:11434'});await assert.rejects(provider.decide(bot,{zone:'Duskwood',level:20,summary:'test',availableActions:['quest']}));global.fetch=async()=>Response.json({message:{content:JSON.stringify({action:'trade',goal:'Trade',reason:'test'})}});await assert.rejects(provider.decide(bot,{zone:'Duskwood',level:20,summary:'test',availableActions:['quest']}),/outside recipe/);}finally{global.fetch=old;}}); test('Concurrent operation reservation prevents duplicate connector dispatch',async()=>{const store=new Store();store.state.operations.pending={started:Date.now(),botId:'bot-1'};await assert.rejects(execute(store,{type:'think',botId:'bot-1'}),/in progress/);});