import { MySQLClient } from '@p42/p42modules' import { SimRepository } from '../../Maestro/simRepository.js' function agentHashKey(template, agentId) { return(template.replace(/\[UID\]/g, agentId)) } function hashFieldValue(raw) { if(raw == null) return(null) if(typeof(raw) !== 'string') return(raw) try { return(JSON.parse(raw)) } catch { return(raw) } } function valuesEqual(a, b) { if(a === b) return(true) if(a == null || b == null) return(false) if(typeof(a) !== 'object' && typeof(b) !== 'object') return(a == b) if(typeof(a) !== typeof(b)) return(false) if(typeof(a) !== 'object') return(false) if(Array.isArray(a) !== Array.isArray(b)) return(false) const keysA = Object.keys(a) const keysB = Object.keys(b) if(keysA.length !== keysB.length) return(false) for(const key of keysA) { if(!valuesEqual(a[key], b[key])) return(false) } return(true) } const RESERVED_HASH_FIELDS = new Set(['position', 'vector', 'speed', 'segment']) function vectorsEqual(a, b) { for(const axis of ['x', 'y', 'z']) { if(a[axis] !== b[axis]) return(false) } return(true) } async function findSimulationFixture(ctx) { const { guiDatabase, simDatabase } = ctx.databases const rows = await MySQLClient.poolExecute(ctx.db, ` SELECT usr_uuid AS user_uuid, BIN_TO_UUID(sim_uuid) AS simulation_uuid FROM \`${guiDatabase}\`.users INNER JOIN \`${guiDatabase}\`.simowners ON own_usr_id = usr_id INNER JOIN \`${simDatabase}\`.simulations ON own_sim_uuid = sim_uuid INNER JOIN \`${simDatabase}\`.edited_kf_store ON ekfs_ekf_uuid = sim_root_kf_uuid GROUP BY usr_uuid, sim_uuid HAVING COUNT(ekfs_agent_id) > 0 LIMIT 1 `) if(!rows.length) { throw(new Error( 'No simulation fixture found in MySQL (need user-owned sim with root keyframe agents). ' + 'Pass --userUuid, --simulationUuid explicitly, or use --guiDatabase / --simDatabase for a test DB.' )) } return({ userUuid: rows[0].user_uuid, simulationUuid: rows[0].simulation_uuid, }) } function godsReadyChannel(config) { return(config.maestro?.lifecycle?.godsReadyChannel ?? config.gps?.lifecycle?.godsReadyChannel ?? 'arena:gods:ready') } async function publishFakeReadyToStart(ctx, { sender, simulationId, agentIds }) { const { arenaCnx } = ctx const payload = { success: true, simulationId, err: null, } if(agentIds) payload.agentIds = agentIds await arenaCnx.redisPublish(godsReadyChannel(ctx.config), { eventType: 'readyToStart', sender, payload, }) } async function publishFakeReadies(ctx, simulationId, agentIds) { const { argv, config, log } = ctx if(argv.fakeGpsReady) { const senderId = config.gps?.senderId ?? 'gps' log('action', `Faking GPS readyToStart (sender=${senderId})...`) await publishFakeReadyToStart(ctx, { sender: senderId, simulationId, agentIds, }) log('success', 'Published fake GPS readyToStart') } if(argv.fakeAgentsReady) { log('action', `Faking readyToStart for ${agentIds.length} agent(s)...`) for(const agentId of agentIds) { await publishFakeReadyToStart(ctx, { sender: agentId, simulationId, }) } log('success', `Published fake readyToStart for ${agentIds.length} agent(s)`) } } function waitForLifecycleEvent(ctx, eventType, timeoutMs) { return(new Promise((resolve, reject) => { const lifecyclePattern = ctx.config.maestro.lifecycle.arenaChannel const timer = setTimeout(() => { ctx.offArenaMessage(handler) reject(new Error(`Timeout waiting for ${eventType} on ${lifecyclePattern} (${timeoutMs}ms)`)) }, timeoutMs) const handler = (msg, chan) => { if(!ctx.arenaCnx.matchesChan(chan, lifecyclePattern)) return if(msg.eventType !== eventType) return clearTimeout(timer) ctx.offArenaMessage(handler) resolve(msg) } ctx.onArenaMessage(handler) })) } export function configureYargs(yargsBuilder) { return(yargsBuilder.options({ userUuid: { describe: 'User UUID (dashed or 0x-prefixed hex; auto-discovered if omitted)', type: 'string', }, simulationUuid: { describe: 'Simulation UUID (dashed or 0x-prefixed hex; auto-discovered if omitted)', type: 'string', }, timeout: { describe: 'Milliseconds to wait for onYourMarks', default: 15000, type: 'number', }, fakeAgentsReady: { describe: 'After onYourMarks, publish fake readyToStart for each seeded agent', type: 'boolean', default: false, }, fakeGpsReady: { describe: 'After onYourMarks, publish fake GPS readyToStart', type: 'boolean', default: false, }, })) } export async function run(ctx) { const { log, argv, config, systemCnx, arenaCnx, normalizeUuid } = ctx const arenaStorage = config.gps?.arenaStorage ?? { agentHashKey: 'arena:agents:[UID]', agentsIndexKey: 'arena:agents', } log('action', 'Resolving simulation fixture from MySQL...') let userUuid = argv.userUuid ? normalizeUuid(argv.userUuid) : undefined let simulationUuid = argv.simulationUuid ? normalizeUuid(argv.simulationUuid) : undefined if(!userUuid || !simulationUuid) { const fixture = await findSimulationFixture(ctx) userUuid = userUuid ?? fixture.userUuid simulationUuid = simulationUuid ?? fixture.simulationUuid } log('action', `User: ${userUuid}`) log('action', `Simulation: ${simulationUuid}`) const simRepo = new SimRepository(ctx.db, ctx.databases, false) const access = await simRepo.validateSimulationAccess(userUuid, simulationUuid) if(!access.ok) throw(new Error(`Simulation access check failed: ${access.err}`)) const keyframeId = access.sim.sim_root_kf_uuid log('action', `Root keyframe: ${keyframeId}`) const agentsResult = await simRepo.loadKeyframeAgents(keyframeId) if(!agentsResult.ok) throw(new Error(`Failed to load keyframe agents: ${agentsResult.err}`)) if(!agentsResult.agents.length) throw(new Error('Keyframe has no agents with valid GPS values')) log('success', `Loaded ${agentsResult.agents.length} agent(s) from MySQL`) const expectedById = new Map(agentsResult.agents.map(a => [a.id, a])) const lifecycleWait = waitForLifecycleEvent(ctx, 'onYourMarks', argv.timeout) const reqid = `maestro1-${Date.now()}` const actionsChan = config.maestro.maestroActionsChannel log('action', `Publishing STARTSIMULATION on ${actionsChan} (reqid=${reqid})...`) await systemCnx.redisPublish(actionsChan, { action: 'STARTSIMULATION', reqid, sender: userUuid, roles: ['*'], payload: { simulationUuid, infraId: null, }, }) log('action', `Waiting for onYourMarks on ${config.maestro.lifecycle.arenaChannel}...`) const lifecycleMsg = await lifecycleWait const payload = lifecycleMsg.payload ?? {} if(payload.simulationId !== simulationUuid) { throw(new Error(`onYourMarks simulationId mismatch: expected ${simulationUuid}, got ${payload.simulationId}`)) } log('success', `Received onYourMarks for simulationId=${payload.simulationId}`) const expectedIds = [...expectedById.keys()] if(argv.fakeAgentsReady || argv.fakeGpsReady) { await publishFakeReadies(ctx, payload.simulationId, expectedIds) } log('action', 'Reading arena store...') const indexIds = await arenaCnx.redisSmembers(arenaStorage.agentsIndexKey) const sortedExpectedIds = [...expectedIds].sort() const actualIds = [...indexIds].sort() if(actualIds.length !== sortedExpectedIds.length) { throw(new Error(`Agent index count mismatch: expected ${sortedExpectedIds.length}, got ${actualIds.length}`)) } for(let i = 0; i < sortedExpectedIds.length; i++) { if(actualIds[i] !== sortedExpectedIds[i]) { throw(new Error(`Agent index mismatch at ${i}: expected ${sortedExpectedIds[i]}, got ${actualIds[i]}`)) } } log('success', `Arena agents index contains ${actualIds.length} agent(s)`) for(const agentId of sortedExpectedIds) { const key = agentHashKey(arenaStorage.agentHashKey, agentId) const hash = await arenaCnx.redisHgetall(key) const expected = expectedById.get(agentId) const position = hashFieldValue(hash.position) const vector = hashFieldValue(hash.vector) if(!position || !vector) { throw(new Error(`Agent ${agentId}: missing position or vector in arena hash ${key}`)) } if(!vectorsEqual(position, expected.position)) { throw(new Error(`Agent ${agentId}: position mismatch (MySQL vs arena store)`)) } if(!vectorsEqual(vector, expected.vector)) { throw(new Error(`Agent ${agentId}: vector mismatch (MySQL vs arena store)`)) } const storeExpected = expected.store ?? {} for(const [field, expVal] of Object.entries(storeExpected)) { const actualVal = hashFieldValue(hash[field]) if(!valuesEqual(actualVal, expVal)) { throw(new Error(`Agent ${agentId}: store field "${field}" mismatch (MySQL vs arena store)`)) } } for(const field of Object.keys(hash)) { if(RESERVED_HASH_FIELDS.has(field)) continue if(!(field in storeExpected)) { throw(new Error(`Agent ${agentId}: unexpected store field "${field}" in arena hash ${key}`)) } } log('success', `Agent ${agentId}: position, vector, and store values match MySQL`) } if(argv.fakeAgentsReady || argv.fakeGpsReady) { log('success', 'Arena store seeded correctly from MySQL; fake prepare acks published') } else { log('success', 'Arena store seeded correctly from MySQL (Maestro will wait for agent + primordial daemon readyToStart until timeout)') } }