Files

479 lines
16 KiB
JavaScript

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)
}
}