445 lines
15 KiB
JavaScript
445 lines
15 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 './actions/arena/worldline.js'
|
|
|
|
export class gpsServer {
|
|
|
|
constructor(configHelper, allRediscnx, debug) {
|
|
this.configHelper = configHelper
|
|
this.gpsConfig = configHelper.config
|
|
this.allRediscnx = allRediscnx
|
|
this.debug = debug
|
|
this.accessRights = new AccesRights(this.gpsConfig, 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.ignoredChangeCount = 0
|
|
}
|
|
|
|
getGpsSettings() {
|
|
const gps = this.gpsConfig.gps ?? {}
|
|
return({
|
|
nearMissDistance: gps.nearMissDistance ?? 1,
|
|
prismTimeHeight: gps.prismTimeHeight ?? 60,
|
|
collisionTickMs: gps.collisionTickMs ?? 100,
|
|
prismRefreshLeadSeconds: gps.prismRefreshLeadSeconds ?? 1,
|
|
})
|
|
}
|
|
|
|
getLifecycleSettings() {
|
|
const gps = this.gpsConfig.gps ?? {}
|
|
return({
|
|
arenaChannel: gps.lifecycle?.arenaChannel ?? 'arena:lifecycle',
|
|
godsReadyChannel: gps.lifecycle?.godsReadyChannel ?? 'arena:gods:ready',
|
|
senderId: gps.senderId ?? 'gps',
|
|
})
|
|
}
|
|
|
|
getArenaStorageSettings() {
|
|
const gps = this.gpsConfig.gps ?? {}
|
|
return({
|
|
agentHashKey: gps.arenaStorage?.agentHashKey ?? 'arena:agents:[UID]',
|
|
agentsIndexKey: gps.arenaStorage?.agentsIndexKey ?? 'arena:agents',
|
|
})
|
|
}
|
|
|
|
isLive() {
|
|
return(this.state === SimState.LIVE)
|
|
}
|
|
|
|
isPrepare() {
|
|
return(this.state === SimState.PREPARE)
|
|
}
|
|
|
|
simNow() {
|
|
if(this.bigBangEpoch === null) return(null)
|
|
return((performance.now() - this.bigBangEpoch) / 1000)
|
|
}
|
|
|
|
now() {
|
|
if(this.isLive()) return(this.simNow())
|
|
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 storage = this.gpsConfig.gps?.storage
|
|
if(storage && this.systemCnx) {
|
|
this.agentStore = new AgentStore(this.systemCnx, storage, this.debug)
|
|
}
|
|
}
|
|
|
|
wireArenaConnexion(cnx) {
|
|
cnx.gpsSrv = this
|
|
this.arenaCnxs.push(cnx)
|
|
if(!this.arenaCnx || cnx.redisConfig.role === 'primary') {
|
|
this.arenaCnx = cnx
|
|
this.arenaLoader = new ArenaAgentLoader(cnx, this.getArenaStorageSettings(), 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.isLive()) 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.isLive()) 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.ignoredChangeCount = 0
|
|
this.state = SimState.IDLE
|
|
await this.agentStore?.clearAll()
|
|
}
|
|
|
|
runInitialPairScan() {
|
|
const { nearMissDistance, prismTimeHeight } = this.getGpsSettings()
|
|
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
|
|
const { godsReadyChannel, senderId } = this.getLifecycleSettings()
|
|
await this.arenaCnx.redisPublish(godsReadyChannel, {
|
|
eventType: 'readyToStart',
|
|
sender: 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.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}`)
|
|
}
|
|
|
|
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.getGpsSettings()
|
|
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.getGpsSettings()
|
|
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.arenaCnx.config.gps.collisionsChannel.replace(/\[UID\]/g, targetAgentId)
|
|
await this.arenaCnx.redisPublish(chan, {
|
|
eventType: 'proximity',
|
|
payload: {
|
|
pairs,
|
|
simulationId: this.simulationId,
|
|
},
|
|
sender: this.getLifecycleSettings().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.gpsConfig.accessRights = this.configHelper.config.accessRights
|
|
this.accessRights.refreshAccessRights(this.gpsConfig)
|
|
}
|
|
|
|
getAccessRights() {
|
|
return(this.gpsConfig.accessRights)
|
|
}
|
|
|
|
}
|