import { AccesRights } from '../accesRights.js' import { AgentStore } from './agentStore.js' import { CollisionRegistry } from './collisionRegistry.js' import { ArenaAgentLoader } from './arenaAgentLoader.js' import { SimState, validateAgentSets } from './simulationState.js' import { compareWorldlinePair, positionAt, needsPrismRefresh, advanceAgentSegment, } from './handlers/arena/worldline.js' export class gpsServer { constructor(configHelper, allRediscnx, debug) { this.configHelper = configHelper this.rootConfig = configHelper.config this.gpsConfig = configHelper.config.gps ?? {} this.allRediscnx = allRediscnx this.debug = debug this.accessRights = new AccesRights(this.rootConfig, debug) this.agents = new Map() this.registry = new CollisionRegistry() this.arenaCnx = null this.arenaCnxs = [] this.systemCnx = null this.agentStore = null this.arenaLoader = null this.state = SimState.IDLE this.simulationId = null this.bigBangEpoch = null this.resumeEpoch = null this.pausedAt = null this.ignoredChangeCount = 0 } isLive() { return(this.state === SimState.LIVE) } isPaused() { return(this.state === SimState.PAUSED) } canQuerySim() { return(this.isLive() || this.isPaused()) } isPrepare() { return(this.state === SimState.PREPARE) } simNow() { if(this.bigBangEpoch === null) return(null) if(this.resumeEpoch !== null) { return(this.pausedAt + (performance.now() - this.resumeEpoch) / 1000) } return((performance.now() - this.bigBangEpoch) / 1000) } now() { if(this.isLive()) return(this.simNow()) if(this.isPaused()) return(this.pausedAt) if(this.isPrepare()) return(0) return(null) } wireSystemConnexion(cnx) { cnx.gpsSrv = this cnx.accessRights = this.accessRights cnx.reloadAccessRights = () => this.reloadAccessRights() cnx.getAccessRights = () => this.getAccessRights() if(!this.systemCnx || cnx.redisConfig.role === 'primary') { this.systemCnx = cnx this.initAgentStore() } } initAgentStore() { const gpsStorage = this.gpsConfig.GPSstorage if(gpsStorage && this.systemCnx) { this.agentStore = new AgentStore(this.systemCnx, gpsStorage, this.debug) } } wireArenaConnexion(cnx) { cnx.gpsSrv = this this.arenaCnxs.push(cnx) if(!this.arenaCnx || cnx.redisConfig.role === 'primary') { this.arenaCnx = cnx const arenaStorage = this.gpsConfig.arenaStorage if(arenaStorage) { this.arenaLoader = new ArenaAgentLoader(cnx, arenaStorage, this.debug) } } } getAgent(agentId) { return(this.agents.get(agentId) ?? null) } getAllAgents() { return([...this.agents.values()]) } buildAgentSnapshot(agent, at) { return({ id: agent.id, position: positionAt(agent, at), vector: { ...agent.vector }, since: agent.since, generation: agent.generation ?? 0, t: at, }) } getAgentPosition(agentId, at = null) { if(!this.canQuerySim()) return(null) const agent = this.agents.get(agentId) if(!agent) return(null) const t = at ?? this.now() if(t === null) return(null) return(this.buildAgentSnapshot(agent, t)) } isPositionInPrism(position, prism) { return( position.x >= prism.xMin && position.x <= prism.xMax && position.y >= prism.yMin && position.y <= prism.yMax && position.z >= prism.zMin && position.z <= prism.zMax ) } isValidPrism(prism) { if(!prism || typeof(prism) !== 'object') return(false) const axes = ['xMin', 'xMax', 'yMin', 'yMax', 'zMin', 'zMax'] for(const key of axes) { if(typeof(prism[key]) !== 'number' || Number.isNaN(prism[key])) return(false) } return( prism.xMin <= prism.xMax && prism.yMin <= prism.yMax && prism.zMin <= prism.zMax ) } getAgentsInPrism(prism, at = null) { if(!this.canQuerySim()) return([]) const t = at ?? this.now() if(t === null) return([]) const agents = [] for(const agent of this.agents.values()) { const position = positionAt(agent, t) if(this.isPositionInPrism(position, prism)) { agents.push(this.buildAgentSnapshot(agent, t)) } } return(agents) } async resetSimulation() { this.agents.clear() this.registry = new CollisionRegistry() this.simulationId = null this.bigBangEpoch = null this.resumeEpoch = null this.pausedAt = null this.ignoredChangeCount = 0 this.state = SimState.IDLE await this.agentStore?.clearAll() } runInitialPairScan() { const { nearMissDistance, prismTimeHeight } = this.gpsConfig const ids = [...this.agents.keys()] for(let i = 0; i < ids.length; i++) { for(let j = i + 1; j < ids.length; j++) { const hit = compareWorldlinePair( this.agents.get(ids[i]), this.agents.get(ids[j]), prismTimeHeight, nearMissDistance, 0 ) if(hit) this.registry.add(hit) } } if(this.debug) console.log(`[GPS] Initial pair scan: ${this.registry.entries.length} proximity entries at T=0`) } async publishReadyToStart(result) { if(!this.arenaCnx) return await this.arenaCnx.redisPublish(this.gpsConfig.lifecycle.godsReadyChannel, { eventType: 'readyToStart', sender: this.gpsConfig.senderId, payload: { success: result.success, simulationId: this.simulationId, agentIds: [...this.agents.keys()], err: result.err ?? null, }, }) } async onYourMarks(payload = {}) { await this.resetSimulation() this.simulationId = payload.simulationId ?? null if(!this.simulationId) { console.error('[GPS] onYourMarks rejected: missing simulationId') await this.publishReadyToStart({ success: false, err: 'Missing simulationId' }) return } if(!this.arenaLoader) { console.error('[GPS] onYourMarks rejected: no arena loader') await this.publishReadyToStart({ success: false, err: 'No arena Redis connection' }) return } const loadResult = await this.arenaLoader.loadAgents(payload.agentIds ?? null) if(!loadResult.ok) { console.error(`[GPS] onYourMarks load failed: ${loadResult.err}`) await this.publishReadyToStart({ success: false, err: loadResult.err }) return } for(const agent of loadResult.agents) { this.agents.set(agent.id, agent) } if(Array.isArray(payload.agentIds) && payload.agentIds.length) { const mismatch = validateAgentSets(payload.agentIds, [...this.agents.keys()]) if(mismatch) { console.error(`[GPS] onYourMarks agent mismatch: ${mismatch}`) await this.resetSimulation() await this.publishReadyToStart({ success: false, err: mismatch }) return } } this.runInitialPairScan() this.state = SimState.PREPARE if(this.debug) console.log(`[GPS] PREPARE: ${this.agents.size} agent(s), simulationId=${this.simulationId}`) await this.publishReadyToStart({ success: true }) } async onBigBang(payload = {}) { if(this.state !== SimState.PREPARE) { console.error(`[GPS] bigBang rejected: expected PREPARE, got ${this.state}`) return } if(!payload.simulationId || payload.simulationId !== this.simulationId) { console.error('[GPS] bigBang rejected: simulationId mismatch') return } if(Array.isArray(payload.agentIds) && payload.agentIds.length) { const mismatch = validateAgentSets(payload.agentIds, [...this.agents.keys()]) if(mismatch) { console.error(`[GPS] bigBang agent mismatch: ${mismatch}`) return } } this.bigBangEpoch = performance.now() this.resumeEpoch = null this.pausedAt = null this.state = SimState.LIVE for(const agent of this.agents.values()) { await this.agentStore?.exportSegment(agent, 'start', 0, this.simulationId) } if(this.debug) console.log(`[GPS] LIVE: bigBangEpoch set, simulationId=${this.simulationId}`) } onSimulationPaused(payload = {}) { if(this.state === SimState.IDLE || this.state === SimState.PAUSED) return if(payload.simulationId && this.simulationId && payload.simulationId !== this.simulationId) { console.error('[GPS] simulationPaused rejected: simulationId mismatch') return } const t = (typeof(payload.t) === 'number' && !Number.isNaN(payload.t)) ? payload.t : this.now() this.pausedAt = t this.state = SimState.PAUSED if(this.debug) console.log(`[GPS] PAUSED at t=${t}, simulationId=${this.simulationId}`) } async onSimulationStopped(payload = {}) { if(this.state === SimState.IDLE) return if(payload.simulationId && this.simulationId && payload.simulationId !== this.simulationId) { console.error('[GPS] simulationStopped rejected: simulationId mismatch') return } if(this.debug) console.log(`[GPS] STOPPED (reset), simulationId=${this.simulationId}`) await this.resetSimulation() } onSimulationResumed(payload = {}) { if(this.state !== SimState.PAUSED) { console.error(`[GPS] simulationResumed rejected: expected PAUSED, got ${this.state}`) return } if(payload.simulationId && this.simulationId && payload.simulationId !== this.simulationId) { console.error('[GPS] simulationResumed rejected: simulationId mismatch') return } this.resumeEpoch = performance.now() this.state = SimState.LIVE if(this.debug) console.log(`[GPS] RESUMED at t=${this.pausedAt}, simulationId=${this.simulationId}`) } upsertAgent(agentId, newVector, newPosition = null) { const now = this.now() let agent = this.agents.get(agentId) if(!agent) { agent = { id: agentId, position: newPosition ?? { x: 0, y: 0, z: 0 }, vector: { ...newVector }, since: now, generation: 1, } this.agents.set(agentId, agent) this.agentStore?.exportSegment(agent, 'change', now, this.simulationId) return(agent) } agent.position = positionAt(agent, now) agent.vector = { ...newVector } agent.since = now agent.generation = (agent.generation ?? 0) + 1 if(newPosition) agent.position = { ...newPosition } this.agentStore?.exportSegment(agent, 'change', now, this.simulationId) return(agent) } ensurePrismValid(agentId, refreshed = new Set()) { return(this.refreshAgentPrism(agentId, refreshed)) } refreshAgentPrism(agentId, refreshed = new Set()) { if(refreshed.has(agentId)) return(false) const agent = this.agents.get(agentId) if(!agent) return(false) const { prismTimeHeight, prismRefreshLeadSeconds } = this.gpsConfig const now = this.now() if(!needsPrismRefresh(agent, now, prismTimeHeight, prismRefreshLeadSeconds)) return(false) refreshed.add(agentId) this.registry.purge(agentId) advanceAgentSegment(agent, now) const hits = this.scanAgentPairs(agentId, refreshed) for(const hit of hits) this.registry.add(hit) this.agentStore?.exportSegment(agent, 'refresh', now, this.simulationId) if(this.debug) console.log(`[GPS] Prism refresh: ${agentId}`) return(true) } scanAgentPairs(changedAgentId, refreshed = new Set()) { this.ensurePrismValid(changedAgentId, refreshed) const changed = this.agents.get(changedAgentId) if(!changed) return([]) const { nearMissDistance, prismTimeHeight } = this.gpsConfig const now = this.now() const hits = [] for(const [otherId, other] of this.agents) { if(otherId === changedAgentId) continue this.ensurePrismValid(otherId, refreshed) const hit = compareWorldlinePair( changed, other, prismTimeHeight, nearMissDistance, now ) if(hit) hits.push(hit) } return(hits) } registerHits(hits) { for(const hit of hits) this.registry.add(hit) } onVectorChange(agentId, newVector, newPosition = null) { if(!this.isLive()) { if(this.isPrepare()) this.ignoredChangeCount++ return([]) } this.registry.purge(agentId) this.upsertAgent(agentId, newVector, newPosition) const hits = this.scanAgentPairs(agentId) this.registerHits(hits) if(this.debug && hits.length) console.log(`[GPS] ${hits.length} proximity pair(s) for ${agentId}`) return(hits) } onAgentRemove(agentId) { if(!this.isLive()) return this.agents.delete(agentId) this.registry.purge(agentId) this.agentStore?.exportRemove(agentId, this.now()) if(this.debug) console.log(`[GPS] Agent removed: ${agentId}`) } tickPrismRefresh() { for(const agentId of this.agents.keys()) { this.refreshAgentPrism(agentId) } } buildProximityBatches(due) { const byAgent = new Map() for(const entry of due) { for(const agentId of [entry.agentA, entry.agentB]) { const otherAgent = entry.agentA === agentId ? entry.agentB : entry.agentA if(!byAgent.has(agentId)) byAgent.set(agentId, []) byAgent.get(agentId).push({ otherAgent, distance: entry.distance, t: entry.time, minDistance: entry.minDistance, minT: entry.minTime, }) } } return(byAgent) } async publishProximityBatch(targetAgentId, pairs) { if(!this.arenaCnx || !pairs.length) return const chan = this.gpsConfig.collisionsChannel.replace(/\[UID\]/g, targetAgentId) await this.arenaCnx.redisPublish(chan, { eventType: 'proximity', payload: { pairs, simulationId: this.simulationId, }, sender: this.gpsConfig.senderId, }) } async tickCollisions() { const now = this.now() if(now === null) return const due = this.registry.dueBefore(now) if(!due.length) return const batches = this.buildProximityBatches(due) for(const entry of due) this.registry.remove(entry) for(const [agentId, pairs] of batches) { await this.publishProximityBatch(agentId, pairs) } } tickArena() { if(!this.isLive()) return this.tickPrismRefresh() this.tickCollisions() } async reloadAccessRights() { await this.configHelper.refreshAccessRights() this.rootConfig.accessRights = this.configHelper.config.accessRights this.accessRights.refreshAccessRights(this.rootConfig) } getAccessRights() { return(this.rootConfig.accessRights) } }