diff --git a/.gitignore b/.gitignore index cffa58a2cf..273935bce8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ compile_commands.json **/.ccls-cache **/.cache **/.clangd +**/__pycache__/ +node_modules/ **/build* @@ -104,6 +106,10 @@ LogPublish.txt LogLoading.txt LogLoadingWarning.txt LogConsole.txt +SPECTATOR_EVENT_LOG.txt Console.dump.log Console.input.log imgui.ini + +# Local isolated feature worktrees +.worktrees/ diff --git a/Data/Base.rte/Activities.ini b/Data/Base.rte/Activities.ini index 3afa83591a..7e47f2f700 100644 --- a/Data/Base.rte/Activities.ini +++ b/Data/Base.rte/Activities.ini @@ -313,6 +313,58 @@ AddActivity = GAScripted FogOfWarSwitchEnabled = 1 DeployUnitsSwitchEnabled = 1 +AddActivity = GAScripted + PresetName = Spectator Fire Sensor Fixture + Description = Isolated native firearm sensor diagnostic. + SceneName = Ketanot Hills + ScriptPath = Base.rte/Activities/SpectatorFireSensorFixture.lua + LuaClassName = SpectatorFireSensorFixture + TeamOfPlayer1 = 0 + TeamOfPlayer2 = 1 + MinTeamsRequired = 2 + DefaultRequireClearPathToOrbit = 0 + DefaultFogOfWar = 0 + DefaultDeployUnits = 0 + +AddActivity = GAScripted + PresetName = Spectator Simulation Progress Fixture + Description = Isolated native simulation progression diagnostic. + SceneName = Ketanot Hills + ScriptPath = Base.rte/Activities/SpectatorSimProgressFixture.lua + LuaClassName = SpectatorSimProgressFixture + TeamOfPlayer1 = 0 + TeamOfPlayer2 = 1 + MinTeamsRequired = 2 + DefaultRequireClearPathToOrbit = 0 + DefaultFogOfWar = 0 + DefaultDeployUnits = 0 + +AddActivity = GAScripted + PresetName = Spectator Fire Sensor Bisection + Description = Progressive native firearm boundary diagnostic. + SceneName = Ketanot Hills + ScriptPath = Base.rte/Activities/SpectatorFireSensorBisectFixture.lua + LuaClassName = SpectatorFireSensorBisectFixture + TeamOfPlayer1 = 0 + TeamOfPlayer2 = 1 + MinTeamsRequired = 2 + DefaultRequireClearPathToOrbit = 0 + DefaultFogOfWar = 0 + DefaultDeployUnits = 0 + +AddActivity = GAScripted + PresetName = Spectator Fire Sensor Differential + Description = Known-success setup with bounded simulation progression diagnostic. + SceneName = Ketanot Hills + ScriptPath = Base.rte/Activities/SpectatorFireSensorDifferential.lua + LuaClassName = SpectatorFireSensorDifferential + TeamOfPlayer1 = 0 + TeamOfPlayer2 = 1 + MinTeamsRequired = 2 + DefaultRequireClearPathToOrbit = 0 + DefaultFogOfWar = 0 + DefaultDeployUnits = 0 + /* AddActivity = GAScripted @@ -332,4 +384,22 @@ AddActivity = GAScripted DefaultGoldMediumDifficulty = 4000 DefaultGoldHardDifficulty = 3000 DefaultGoldNutsDifficulty = 2000 -*/ \ No newline at end of file +*/ +AddActivity = GAScripted + PresetName = Spectator Arena + Description = Autonomous 8v8 AI-vs-AI spectator arena. + SceneName = Ketanot Hills + ScriptPath = Base.rte/Activities/SpectatorArena.lua + LuaClassName = SpectatorArena + TeamOfPlayer1 = 0 + TeamOfPlayer2 = 1 + MinTeamsRequired = 2 + Team1Funds = 10000 + Team2Funds = 10000 + DefaultRequireClearPathToOrbit = 0 + DefaultFogOfWar = 0 + DefaultDeployUnits = 0 + RequireClearPathToOrbitSwitchEnabled = 0 + FogOfWarSwitchEnabled = 0 + DeployUnitsSwitchEnabled = 0 + diff --git a/Data/Base.rte/Activities/SpectatorAIController.lua b/Data/Base.rte/Activities/SpectatorAIController.lua new file mode 100644 index 0000000000..4d9a718642 --- /dev/null +++ b/Data/Base.rte/Activities/SpectatorAIController.lua @@ -0,0 +1,681 @@ +local SpectatorAIController = {} + +function SpectatorAIController.ClassifyWeapon(profile) + if type(profile) ~= "table" or type(profile.effectiveRange) ~= "number" or profile.effectiveRange <= 0 then + return { Class = "UNKNOWN", Confidence = 0 } + end + + local projectileCount = profile.projectileCount or 1 + local spread = profile.spread or 0 + if profile.effectiveRange < 200 or projectileCount >= 4 or spread > 0.25 then + return { Class = "CLOSE", Confidence = 1 } + end + if profile.effectiveRange >= 350 and projectileCount <= 2 and spread <= 0.2 then + return { Class = "LONG", Confidence = 1 } + end + return { Class = "MID", Confidence = 0.75 } +end + +function SpectatorAIController.ScoreDestination(context) + if type(context) ~= "table" or type(context.distance) ~= "number" then + return -math.huge + end + + local cover = math.max(0, math.min(1, context.cover or 0)) + local threat = math.max(0, context.threat or 0) + local lineOfSight = context.hasLOS and 1 or 0 + local distance = context.distance + local score + + if context.weaponClass == "CLOSE" then + score = math.max(0, 1 - math.abs(distance - 100) / 250) + cover * 3 + lineOfSight - threat + elseif context.weaponClass == "LONG" then + score = math.max(0, 1 - math.abs(distance - 500) / 600) + lineOfSight * 4 + cover - threat + else + score = math.max(0, 1 - math.abs(distance - 250) / 400) + cover * 2 + lineOfSight * 2 - threat + end + + return score +end + +function SpectatorAIController.SelectDistinctDestination(candidates, current, minimumImprovement) + local best = current + local bestScore = current and current.score or -math.huge + for _, candidate in ipairs(candidates or {}) do + if candidate.score > bestScore then + best = candidate + bestScore = candidate.score + end + end + + if not best or not current or best == current or bestScore <= (current.score + (minimumImprovement or 0)) then + return current + end + return best +end + +function SpectatorAIController.SelectVisibleOpponent(opponents, visibilityByID) + local nearestOpponent = nil + local nearestDistanceSquared = nil + local visibleOpponentCount = 0 + + for _, opponent in ipairs(opponents or {}) do + if visibilityByID and visibilityByID[opponent.UniqueID] == true then + visibleOpponentCount = visibleOpponentCount + 1 + if nearestDistanceSquared == nil + or opponent.distanceSquared < nearestDistanceSquared then + nearestOpponent = opponent + nearestDistanceSquared = opponent.distanceSquared + end + end + end + + return nearestOpponent, nearestDistanceSquared, visibleOpponentCount +end + +function SpectatorAIController.ClassifyRayHit(hitMOID, targetMOID, targetRootMOID, noMOID) + if hitMOID == nil or hitMOID == noMOID then + return "NO_MOID" + end + if hitMOID == targetMOID then + return "TARGET" + end + if hitMOID == targetRootMOID then + return "TARGET_ROOT" + end + return "BLOCKED" +end + +function SpectatorAIController.IsVisibleRayHit(hitMOID, targetMOID, targetRootMOID, noMOID) + local classification = SpectatorAIController.ClassifyRayHit( + hitMOID, + targetMOID, + targetRootMOID, + noMOID + ) + return classification == "TARGET" or classification == "TARGET_ROOT" +end + +function SpectatorAIController.BuildSightProbeTargets(bodyPosition, eyePosition) + local targets = {} + if bodyPosition then + targets[#targets + 1] = { kind = "BODY", position = bodyPosition } + end + if eyePosition + and (not bodyPosition + or eyePosition.X ~= bodyPosition.X + or eyePosition.Y ~= bodyPosition.Y) + then + targets[#targets + 1] = { kind = "EYE", position = eyePosition } + end + return targets +end + +function SpectatorAIController.CalculateCPUTimeMS(startSeconds, finishSeconds) + return (finishSeconds - startSeconds) * 1000 +end + +local function copySample(timestampMS, x, y, waypointX, waypointY, hardEngaged, pathPending) + return { + timestampMS = timestampMS, + x = x, + y = y, + waypointX = waypointX, + waypointY = waypointY, + hardEngaged = hardEngaged == true, + pathPending = pathPending == true + } +end + +local function fireSensorMetrics() + return { + FireSensorSamples = 0, + FirearmEquippedSamples = 0, + FirearmMissingSamples = 0, + FireFrameCount = 0, + RoundsDischargedObserved = 0, + FiredFrameSamples = 0, + FiredFrameTransitions = 0, + RoundsFiredSamples = 0, + RoundsFiredTotal = 0, + AlarmEventSnapshots = 0, + AlarmEventsObserved = 0, + LastAlarmEventTimestampMS = nil + } +end + +function SpectatorAIController.Create(config) + config = config or {} + + local controller = { + Mode = config.mode or "OFF", + PositionHistoryLimit = config.positionHistoryLimit or 4, + ContactMemoryTTLMS = config.contactMemoryTTLMS or 3000, + TaskHysteresisMS = config.taskHysteresisMS or 2000, + ReservationTTLMS = config.reservationTTLMS or 2000, + ProgressStallMS = config.progressStallMS or 1000, + MaxRecoveryStage = config.maxRecoveryStage or 3, + RoundGeneration = 0, + RoundID = nil, + RoundSeed = nil, + ActorState = {}, + ContactMemory = {}, + Reservations = {}, + Metrics = { + PositionSamples = 0, + ContactObservations = 0, + EngagementObservations = 0, + ShadowObservations = 0, + LOSChecks = 0, + LOSPositive = 0, + FireEvents = 0, + DamageEvents = 0, + VisibleOpponents = 0, + VisibleOpponentChecks = 0, + LOSProbeRays = 0, + ActorSkips = 0, + ContactAcquisitions = 0, + ContactLosses = 0, + ShadowObservationTimeMS = 0 + } + } + + for key, value in pairs(fireSensorMetrics()) do + controller.Metrics[key] = value + end + + return setmetatable(controller, { __index = SpectatorAIController }) +end + +function SpectatorAIController:BeginRound(roundID, seed) + self.RoundGeneration = roundID + self.RoundID = roundID + self.RoundSeed = seed + self.ActorState = {} + self.ContactMemory = {} + self.Reservations = {} + self.Metrics = { + PositionSamples = 0, + ContactObservations = 0, + EngagementObservations = 0, + ShadowObservations = 0, + LOSChecks = 0, + LOSPositive = 0, + FireEvents = 0, + DamageEvents = 0, + VisibleOpponents = 0, + VisibleOpponentChecks = 0, + LOSProbeRays = 0, + ActorSkips = 0, + ContactAcquisitions = 0, + ContactLosses = 0, + ShadowObservationTimeMS = 0 + } + for key, value in pairs(fireSensorMetrics()) do + self.Metrics[key] = value + end +end + +function SpectatorAIController:RegisterActor(actorID, team, spawnIndex) + self.ActorState[actorID] = { + UniqueID = actorID, + Team = team, + SpawnIndex = spawnIndex, + Released = false, + ReleaseTimeMS = nil, + PositionSamples = {} + } +end + +function SpectatorAIController:ReleaseActor(actorID, timestampMS) + local actor = self.ActorState[actorID] + if not actor then + return false + end + + actor.Released = true + actor.ReleaseTimeMS = timestampMS + return true +end + +function SpectatorAIController:RecordPosition(actorID, timestampMS, x, y, waypointX, waypointY, hardEngaged, pathPending) + local actor = self.ActorState[actorID] + if not actor then + return false + end + + local samples = actor.PositionSamples + samples[#samples + 1] = copySample( + timestampMS, + x, + y, + waypointX, + waypointY, + hardEngaged, + pathPending + ) + while #samples > self.PositionHistoryLimit do + table.remove(samples, 1) + end + + self.Metrics.PositionSamples = self.Metrics.PositionSamples + 1 + return true +end + +function SpectatorAIController:RecordContact(team, enemyID, timestampMS, x, y, confidence, source) + if source == "WORLD_TRUTH" then + return false + end + + self.ContactMemory[team] = self.ContactMemory[team] or {} + local contact = self.ContactMemory[team][enemyID] + + if not contact then + contact = { + EnemyUniqueID = enemyID, + LastKnownPosition = { x = x, y = y }, + x = x, + y = y, + LastSeenTimeMS = timestampMS, + Confidence = confidence, + Source = source + } + self.ContactMemory[team][enemyID] = contact + else + contact.LastSeenTimeMS = timestampMS + contact.Confidence = confidence + contact.Source = source + if source == "DIRECT" or source == "SHARED_DIRECT" then + contact.LastKnownPosition = { x = x, y = y } + contact.x = x + contact.y = y + end + end + + self.Metrics.ContactObservations = self.Metrics.ContactObservations + 1 + return true +end + +function SpectatorAIController:RecordFireEvent(actorID, timestampMS) + local actor = self.ActorState[actorID] + if not actor then + return false + end + + actor.LastFireTimeMS = timestampMS + actor.FireEventCount = (actor.FireEventCount or 0) + 1 + self.Metrics.FireEvents = self.Metrics.FireEvents + 1 + return true +end + +function SpectatorAIController:FiredRecently(actorID, timestampMS, windowMS) + local actor = self.ActorState[actorID] + return actor ~= nil + and actor.LastFireTimeMS ~= nil + and timestampMS - actor.LastFireTimeMS <= (windowMS or 1000) +end + +function SpectatorAIController:RecordDamage(actorID, timestampMS, amount) + local actor = self.ActorState[actorID] + if not actor then + return false + end + + actor.LastDamageTimeMS = timestampMS + actor.DamageEventCount = (actor.DamageEventCount or 0) + 1 + actor.LastDamageAmount = amount + self.Metrics.DamageEvents = self.Metrics.DamageEvents + 1 + return true +end + +function SpectatorAIController:RecordCombatSignals(actorID, timestampMS, firing, health, previousHealth) + local actor = self.ActorState[actorID] + if not actor then + return false + end + + if firing then + self:RecordFireEvent(actorID, timestampMS) + end + + if type(health) == "number" then + if actor.LastObservedHealth ~= nil and health < actor.LastObservedHealth then + self:RecordDamage(actorID, timestampMS, actor.LastObservedHealth - health) + end + actor.LastObservedHealth = health + elseif type(previousHealth) == "number" and actor.LastObservedHealth == nil then + actor.LastObservedHealth = previousHealth + end + + return true +end + +function SpectatorAIController:RecordFireSensorSample( + actorID, + timestampMS, + firearmMOID, + firearmRootMOID, + firedFrame, + roundsFired, + alarmEventCount +) + local actor = self.ActorState[actorID] + if not actor then + return false + end + + local hasFirearm = firearmMOID ~= nil + local fired = firedFrame == true + local firedRounds = type(roundsFired) == "number" and roundsFired or 0 + + actor.FirearmMOID = firearmMOID + actor.FirearmRootMOID = firearmRootMOID + actor.FireSensorSampleCount = (actor.FireSensorSampleCount or 0) + 1 + actor.FirstFireSensorSampleTimeMS = actor.FirstFireSensorSampleTimeMS or timestampMS + actor.LastFireSensorSampleTimeMS = timestampMS + actor.LastFiredFrame = fired + actor.LastRoundsFired = firedRounds + + if fired then + self:RecordFireEvent(actorID, timestampMS) + actor.FireFrameCount = (actor.FireFrameCount or 0) + 1 + actor.RoundsDischargedObserved = (actor.RoundsDischargedObserved or 0) + math.max(1, firedRounds) + self.Metrics.FireFrameCount = self.Metrics.FireFrameCount + 1 + self.Metrics.RoundsDischargedObserved = + self.Metrics.RoundsDischargedObserved + math.max(1, firedRounds) + end + + self.Metrics.FireSensorSamples = self.Metrics.FireSensorSamples + 1 + if hasFirearm then + self.Metrics.FirearmEquippedSamples = self.Metrics.FirearmEquippedSamples + 1 + else + self.Metrics.FirearmMissingSamples = self.Metrics.FirearmMissingSamples + 1 + end + if fired then + self.Metrics.FiredFrameSamples = self.Metrics.FiredFrameSamples + 1 + if actor.PreviousFiredFrame ~= true then + actor.FiredFrameTransitions = (actor.FiredFrameTransitions or 0) + 1 + self.Metrics.FiredFrameTransitions = self.Metrics.FiredFrameTransitions + 1 + end + end + if firedRounds > 0 then + actor.RoundsFiredSamples = (actor.RoundsFiredSamples or 0) + 1 + self.Metrics.RoundsFiredSamples = self.Metrics.RoundsFiredSamples + 1 + self.Metrics.RoundsFiredTotal = self.Metrics.RoundsFiredTotal + firedRounds + end + actor.PreviousFiredFrame = fired + + if type(alarmEventCount) == "number" + and self.Metrics.LastAlarmEventTimestampMS ~= timestampMS + then + self.Metrics.LastAlarmEventTimestampMS = timestampMS + self.Metrics.AlarmEventSnapshots = self.Metrics.AlarmEventSnapshots + 1 + self.Metrics.AlarmEventsObserved = self.Metrics.AlarmEventsObserved + alarmEventCount + end + + return true +end + +function SpectatorAIController:GetFireSensorState(actorID) + local actor = self.ActorState[actorID] + if not actor or not actor.FireSensorSampleCount then + return nil + end + + return { + ActorID = actor.UniqueID, + Team = actor.Team, + FirearmMOID = actor.FirearmMOID, + FirearmRootMOID = actor.FirearmRootMOID, + FirearmSlot = actor.FirearmSlot, + EquippedItemClass = actor.EquippedItemClass, + EquippedBGItemClass = actor.EquippedBGItemClass, + InventorySize = actor.InventorySize, + InventoryFirearmCount = actor.InventoryFirearmCount, + SampleCount = actor.FireSensorSampleCount, + FirstSampleTimeMS = actor.FirstFireSensorSampleTimeMS, + LastSampleTimeMS = actor.LastFireSensorSampleTimeMS, + FiredFrameTransitions = actor.FiredFrameTransitions or 0, + RoundsFiredSamples = actor.RoundsFiredSamples or 0, + LastFiredFrame = actor.LastFiredFrame == true, + LastRoundsFired = actor.LastRoundsFired or 0, + LastFireTimeMS = actor.LastFireTimeMS, + FireEventCount = actor.FireEventCount or 0, + FireFrameCount = actor.FireFrameCount or 0, + RoundsDischargedObserved = actor.RoundsDischargedObserved or 0 + } +end + +function SpectatorAIController:RecordFireSensorContext( + actorID, + firearmSlot, + equippedItemClass, + equippedBGItemClass, + inventorySize, + inventoryFirearmCount +) + local actor = self.ActorState[actorID] + if not actor then + return false + end + + actor.FirearmSlot = firearmSlot + actor.EquippedItemClass = equippedItemClass + actor.EquippedBGItemClass = equippedBGItemClass + actor.InventorySize = inventorySize + actor.InventoryFirearmCount = inventoryFirearmCount + return true +end + +function SpectatorAIController:GetFireSensorStates() + local states = {} + for actorID in pairs(self.ActorState) do + local state = self:GetFireSensorState(actorID) + if state then + states[#states + 1] = state + end + end + table.sort(states, function(left, right) + return left.ActorID < right.ActorID + end) + return states +end + +function SpectatorAIController:RecordShadowObservation(actorID, timestampMS, hasLOS, firing, health, previousHealth, visibleEnemyID) + local actor = self.ActorState[actorID] + if not actor then + return false + end + + self.Metrics.ShadowObservations = self.Metrics.ShadowObservations + 1 + self.Metrics.LOSChecks = self.Metrics.LOSChecks + 1 + if hasLOS then + self.Metrics.LOSPositive = self.Metrics.LOSPositive + 1 + end + + if visibleEnemyID ~= actor.LastVisibleEnemyID then + if visibleEnemyID ~= nil then + self.Metrics.ContactAcquisitions = self.Metrics.ContactAcquisitions + 1 + elseif actor.LastVisibleEnemyID ~= nil then + self.Metrics.ContactLosses = self.Metrics.ContactLosses + 1 + end + actor.LastVisibleEnemyID = visibleEnemyID + end + + self:RecordCombatSignals(actorID, timestampMS, firing, health, previousHealth) + + return true +end + +function SpectatorAIController:GetContact(team, enemyID, timestampMS) + local contacts = self.ContactMemory[team] + local contact = contacts and contacts[enemyID] + if not contact then + return nil + end + + if timestampMS - contact.LastSeenTimeMS > self.ContactMemoryTTLMS then + return nil + end + + return contact +end + +function SpectatorAIController:RecordEngagement(actorID, timestampMS, signal, untilMS) + local actor = self.ActorState[actorID] + if not actor then + return false + end + + actor.HardEngagedUntilMS = untilMS + actor.LastEngagementTimeMS = timestampMS + actor.LastEngagementSignal = signal + self.Metrics.EngagementObservations = self.Metrics.EngagementObservations + 1 + return true +end + +function SpectatorAIController:IsHardEngaged(actorID, timestampMS) + local actor = self.ActorState[actorID] + return actor ~= nil and actor.HardEngagedUntilMS ~= nil and timestampMS <= actor.HardEngagedUntilMS +end + +function SpectatorAIController:AssignTask(actorID, task, timestampMS) + local actor = self.ActorState[actorID] + if not actor or not task then + return false + end + + if actor.Task == task then + return true + end + + if actor.Task ~= nil and timestampMS - actor.TaskAssignedTimeMS < self.TaskHysteresisMS then + return false + end + + actor.Task = task + actor.TaskAssignedTimeMS = timestampMS + return true +end + +local function pruneReservations(reservations, timestampMS) + local active = {} + for _, reservation in ipairs(reservations or {}) do + if reservation.ExpiresAtMS >= timestampMS then + active[#active + 1] = reservation + end + end + return active +end + +function SpectatorAIController:CanReserveTarget(actorID, targetID, timestampMS, limit) + if not self.ActorState[actorID] or not targetID then + return false + end + + local active = pruneReservations(self.Reservations[targetID], timestampMS) + self.Reservations[targetID] = active + limit = limit or 1 + for _, reservation in ipairs(active) do + if reservation.ActorUniqueID == actorID then + return true + end + end + return #active < limit +end + +function SpectatorAIController:ReserveTarget(actorID, targetID, timestampMS, expiresAtMS, limit) + if not self:CanReserveTarget(actorID, targetID, timestampMS, limit) then + return false + end + + local active = self.Reservations[targetID] + active[#active + 1] = { + ActorUniqueID = actorID, + TargetUniqueID = targetID, + ReservedAtMS = timestampMS, + ExpiresAtMS = expiresAtMS or (timestampMS + self.ReservationTTLMS) + } + return true +end + +function SpectatorAIController:RecordProgress(actorID, timestampMS, progress) + local actor = self.ActorState[actorID] + if not actor then + return false + end + + if actor.LastProgressValue == nil or progress > actor.LastProgressValue then + actor.RecoveryStage = 0 + elseif timestampMS - actor.LastProgressTimeMS >= self.ProgressStallMS then + actor.RecoveryStage = math.min((actor.RecoveryStage or 0) + 1, self.MaxRecoveryStage) + end + + actor.LastProgressValue = progress + actor.LastProgressTimeMS = timestampMS + return true +end + +function SpectatorAIController:GetRecoveryStage(actorID) + local actor = self.ActorState[actorID] + return actor and (actor.RecoveryStage or 0) or 0 +end + +function SpectatorAIController:RecordShadowBatchMetrics(fields) + fields = fields or {} + self.Metrics.VisibleOpponents = self.Metrics.VisibleOpponents + (fields.visibleOpponents or 0) + self.Metrics.VisibleOpponentChecks = self.Metrics.VisibleOpponentChecks + (fields.visibleOpponentChecks or 0) + self.Metrics.LOSProbeRays = self.Metrics.LOSProbeRays + (fields.losProbeRays or 0) + self.Metrics.ActorSkips = self.Metrics.ActorSkips + (fields.actorSkips or 0) + self.Metrics.ContactAcquisitions = self.Metrics.ContactAcquisitions + (fields.contactAcquisitions or 0) + self.Metrics.ContactLosses = self.Metrics.ContactLosses + (fields.contactLosses or 0) + self.Metrics.ShadowObservationTimeMS = self.Metrics.ShadowObservationTimeMS + (fields.elapsedMS or 0) +end + +function SpectatorAIController:Snapshot() + local registeredActors = 0 + local releasedActors = 0 + for _, actor in pairs(self.ActorState) do + registeredActors = registeredActors + 1 + if actor.Released then + releasedActors = releasedActors + 1 + end + end + + return { + RoundID = self.RoundID, + RoundGeneration = self.RoundGeneration, + RoundSeed = self.RoundSeed, + Mode = self.Mode, + RegisteredActors = registeredActors, + ReleasedActors = releasedActors, + PositionSamples = self.Metrics.PositionSamples, + ContactObservations = self.Metrics.ContactObservations, + EngagementObservations = self.Metrics.EngagementObservations, + ShadowObservations = self.Metrics.ShadowObservations, + LOSChecks = self.Metrics.LOSChecks, + LOSPositive = self.Metrics.LOSPositive, + FireEvents = self.Metrics.FireEvents, + DamageEvents = self.Metrics.DamageEvents, + VisibleOpponents = self.Metrics.VisibleOpponents, + VisibleOpponentChecks = self.Metrics.VisibleOpponentChecks, + LOSProbeRays = self.Metrics.LOSProbeRays, + ActorSkips = self.Metrics.ActorSkips, + ContactAcquisitions = self.Metrics.ContactAcquisitions, + ContactLosses = self.Metrics.ContactLosses, + ShadowObservationTimeMS = self.Metrics.ShadowObservationTimeMS, + FireSensorSamples = self.Metrics.FireSensorSamples, + FirearmEquippedSamples = self.Metrics.FirearmEquippedSamples, + FirearmMissingSamples = self.Metrics.FirearmMissingSamples, + FireFrameCount = self.Metrics.FireFrameCount, + RoundsDischargedObserved = self.Metrics.RoundsDischargedObserved, + FiredFrameSamples = self.Metrics.FiredFrameSamples, + FiredFrameTransitions = self.Metrics.FiredFrameTransitions, + RoundsFiredSamples = self.Metrics.RoundsFiredSamples, + RoundsFiredTotal = self.Metrics.RoundsFiredTotal, + AlarmEventSnapshots = self.Metrics.AlarmEventSnapshots, + AlarmEventsObserved = self.Metrics.AlarmEventsObserved + } +end + +return SpectatorAIController diff --git a/Data/Base.rte/Activities/SpectatorArena.lua b/Data/Base.rte/Activities/SpectatorArena.lua new file mode 100644 index 0000000000..ea0862ab34 --- /dev/null +++ b/Data/Base.rte/Activities/SpectatorArena.lua @@ -0,0 +1,3134 @@ +function SpectatorArena:RecordLoadoutDiagnostic(stage, fields) + if not self.LoadoutDiagnosticEnabled or not self.Telemetry then + return + end + + fields = fields or {}; + fields.stage = stage; + self.LoadoutDiagnosticCount = self.LoadoutDiagnosticCount + 1; + self.Telemetry.Emit("LOADOUT_DIAGNOSTIC", fields); + + if self.LoadoutDiagnosticCount >= self.LoadoutDiagnosticLimit then + self.LoadoutDiagnosticEnabled = false; + end +end + +function SpectatorArena:RecordSpawnTrace(stage, team, actorIndex, success, durationMS) + if not self.ArenaSpawnTrace then + return + end + self.ArenaSpawnTraceSequence = self.ArenaSpawnTraceSequence + 1 + if #self.ArenaSpawnTrace >= self.ArenaSpawnTraceLimit then + table.remove(self.ArenaSpawnTrace, 1) + end + table.insert(self.ArenaSpawnTrace, { + sequence = self.ArenaSpawnTraceSequence, + wallMS = self.ArenaSpawnWallTimer.ElapsedRealTimeMS, + simMS = self.RoundElapsedTimer and self.RoundElapsedTimer.ElapsedSimTimeMS or 0, + stage = stage, + team = team, + actorIndex = actorIndex, + success = success, + durationMS = durationMS + }) +end + +function SpectatorArena:PersistSpawnTrace(reason) + if self.ArenaSpawnTracePersisted or not self.Telemetry then + return + end + self.ArenaSpawnTracePersisted = true + print("SpectatorArena: SPAWN_TRACE_BEGIN reason=" .. tostring(reason)) + for _, entry in ipairs(self.ArenaSpawnTrace) do + print("SpectatorArena: SPAWN_TRACE" + .. " sequence=" .. tostring(entry.sequence) + .. " wallMS=" .. tostring(entry.wallMS) + .. " simMS=" .. tostring(entry.simMS) + .. " stage=" .. tostring(entry.stage) + .. " team=" .. tostring(entry.team) + .. " actorIndex=" .. tostring(entry.actorIndex) + .. " success=" .. tostring(entry.success) + .. " durationMS=" .. tostring(entry.durationMS)) + end + print("SpectatorArena: SPAWN_TRACE_END reason=" .. tostring(reason)) + self.Telemetry.Snapshot("SPECTATOR_ARENA_SPAWN_TRACE_LOG.txt") +end + +function SpectatorArena:RecordPostSpawnTrace(stage, fields) + if not self.A1PostSpawnTrace or self.A1PostSpawnTracePersisted then + return + end + + fields = fields or {} + self.A1PostSpawnTraceSequence = self.A1PostSpawnTraceSequence + 1 + if #self.A1PostSpawnTrace >= self.A1PostSpawnTraceLimit then + table.remove(self.A1PostSpawnTrace, 1) + end + table.insert(self.A1PostSpawnTrace, { + sequence = self.A1PostSpawnTraceSequence, + wallMS = self.A1PostSpawnWallTimer and self.A1PostSpawnWallTimer.ElapsedRealTimeMS or 0, + simMS = self.RoundElapsedTimer and self.RoundElapsedTimer.ElapsedSimTimeMS or 0, + stage = stage, + updateCount = self.A1UpdateCount or 0, + round = self.RoundNumber or 0, + state = self.State, + mode = self.AI_V2_MODE, + spawned = fields.spawned or self.A1SpawnedActorCount, + landed = fields.landed or self.A1LandedActorCount, + released = fields.released or self.A1ReleasedActorCount, + team1Alive = fields.team1Alive or self.A1Team1Alive, + team2Alive = fields.team2Alive or self.A1Team2Alive, + pendingActorIDs = fields.pendingActorIDs or self.A1PendingActorIDs, + pendingActorID = fields.pendingActorID or self.A1PendingActorID, + pendingVelY = fields.pendingVelY or self.A1PendingVelY, + pendingGroundDistance = fields.pendingGroundDistance or self.A1PendingGroundDistance, + detail = fields.detail + }) +end + +function SpectatorArena:TracePostSpawnBoundary(stage, fields, force) + if self.A1PostSpawnTracePersisted then + return + end + + self.A1LastStage = stage + if force or self.A1HeartbeatUpdates[self.A1UpdateCount or 0] then + self:RecordPostSpawnTrace(stage, fields) + end +end + +function SpectatorArena:PersistPostSpawnTrace(reason) + if self.A1PostSpawnTracePersisted or not self.Telemetry then + return + end + + self:RecordPostSpawnTrace("A1_COMPLETE", { detail = reason }) + self.A1PostSpawnTracePersisted = true + print("SpectatorArena: A1_TRACE_BEGIN reason=" .. tostring(reason) + .. " lastStage=" .. tostring(self.A1LastStage) + .. " updates=" .. tostring(self.A1UpdateCount) + .. " spawned=" .. tostring(self.A1SpawnedActorCount) + .. " landed=" .. tostring(self.A1LandedActorCount) + .. " released=" .. tostring(self.A1ReleasedActorCount) + .. " team1Alive=" .. tostring(self.A1Team1Alive) + .. " team2Alive=" .. tostring(self.A1Team2Alive) + .. " mode=" .. tostring(self.AI_V2_MODE)) + for _, entry in ipairs(self.A1PostSpawnTrace) do + print("SpectatorArena: A1_TRACE" + .. " sequence=" .. tostring(entry.sequence) + .. " wallMS=" .. tostring(entry.wallMS) + .. " simMS=" .. tostring(entry.simMS) + .. " stage=" .. tostring(entry.stage) + .. " updateCount=" .. tostring(entry.updateCount) + .. " round=" .. tostring(entry.round) + .. " state=" .. tostring(entry.state) + .. " mode=" .. tostring(entry.mode) + .. " spawned=" .. tostring(entry.spawned) + .. " landed=" .. tostring(entry.landed) + .. " released=" .. tostring(entry.released) + .. " team1Alive=" .. tostring(entry.team1Alive) + .. " team2Alive=" .. tostring(entry.team2Alive) + .. " pendingActorIDs=" .. tostring(entry.pendingActorIDs) + .. " pendingActorID=" .. tostring(entry.pendingActorID) + .. " pendingVelY=" .. tostring(entry.pendingVelY) + .. " pendingGroundDistance=" .. tostring(entry.pendingGroundDistance) + .. " detail=" .. tostring(entry.detail)) + end + print("SpectatorArena: A1_TRACE_END reason=" .. tostring(reason)) + self.Telemetry.Snapshot("SPECTATOR_ARENA_POST_SPAWN_TRACE_LOG.txt") +end + +function SpectatorArena:RecordA1ProgressMarkers() + if self.A1PostSpawnTracePersisted + or self.AI_V2_MODE ~= "SHADOW" + or not self.AIController + then + return + end + + local snapshot = self.AIController:Snapshot() + local markers = { + { key = "A1FirstVisibilityObserved", stage = "FIRST_VISIBILITY_OBSERVATION", + value = snapshot.VisibleOpponents }, + { key = "A1FirstContactObserved", stage = "FIRST_CONTACT_ACQUISITION", + value = snapshot.ContactAcquisitions }, + { key = "A1FirstFirearmObserved", stage = "FIRST_FIREARM_DISCOVERY", + value = snapshot.FirearmEquippedSamples }, + { key = "A1FirstFiredFrameObserved", stage = "FIRST_FIRED_FRAME", + value = snapshot.FiredFrameSamples }, + { key = "A1FirstFireLatchObserved", stage = "FIRST_DURABLE_FIRE_LATCH", + value = snapshot.FireFrameCount }, + { key = "A1FirstDamageObserved", stage = "FIRST_DAMAGE_OBSERVATION", + value = snapshot.DamageEvents } + } + + for _, marker in ipairs(markers) do + if marker.value and marker.value > 0 and not self[marker.key] then + self[marker.key] = true + self:TracePostSpawnBoundary(marker.stage, { + detail = marker.value + }, true) + end + end +end + +function SpectatorArena:CreateFactionSoldier(factionName, team, actorIndex) +local moduleID = PresetMan:GetModuleID(factionName); + local tracePrefix = "T" .. tostring(team) .. "_A" .. tostring(actorIndex) + + local actorGroups = { + "Actors", + "Actors - Light", + "Actors - Heavy" + }; + + local actor = nil; + + -- Try several random infantry classes, but only accept an actor + -- actually belonging to the selected faction. + for attempt = 1, 20 do + local group = + actorGroups[math.random(1, #actorGroups)]; + + self:RecordSpawnTrace(tracePrefix .. "_CREATE_BEGIN", team, actorIndex) + local createStart = self.ArenaSpawnWallTimer.ElapsedRealTimeMS + local candidate = RandomAHuman(group, factionName) + self:RecordSpawnTrace(tracePrefix .. "_CREATE_RETURN", team, actorIndex, candidate ~= nil, + self.ArenaSpawnWallTimer.ElapsedRealTimeMS - createStart) + + if candidate then + if candidate.ModuleID == moduleID then + actor = candidate; + break; + else + DeleteEntity(candidate); + end + end + end + + -- Conservative fallback within the same faction. + if not actor then + for attempt = 1, 20 do + self:RecordSpawnTrace(tracePrefix .. "_CREATE_FALLBACK_BEGIN", team, actorIndex) + local createStart = self.ArenaSpawnWallTimer.ElapsedRealTimeMS + local candidate = RandomAHuman("Actors", factionName) + self:RecordSpawnTrace(tracePrefix .. "_CREATE_FALLBACK_RETURN", team, actorIndex, + candidate ~= nil, self.ArenaSpawnWallTimer.ElapsedRealTimeMS - createStart) + + if candidate then + if candidate.ModuleID == moduleID then + actor = candidate; + break; + else + DeleteEntity(candidate); + end + end + end + end + + if not actor then + return nil; + end + + + local weaponGroups = { + "Weapons - Primary", + "Weapons - Light", + "Weapons - Heavy", + "Weapons - Sniper", + "Weapons - Secondary" + }; + + local weapon = nil; + + -- Give every soldier one random firearm from its own faction. + for attempt = 1, 30 do + local group = + weaponGroups[math.random(1, #weaponGroups)]; + + self:RecordSpawnTrace(tracePrefix .. "_WEAPON_BEGIN", team, actorIndex) + local weaponStart = self.ArenaSpawnWallTimer.ElapsedRealTimeMS + local candidate = RandomHDFirearm(group, factionName) + self:RecordSpawnTrace(tracePrefix .. "_WEAPON_RETURN", team, actorIndex, candidate ~= nil, + self.ArenaSpawnWallTimer.ElapsedRealTimeMS - weaponStart) + + self:RecordLoadoutDiagnostic("WEAPON_CANDIDATE", { + attempt = attempt, + candidate = candidate and candidate.PresetName or "NONE", + candidateModule = candidate and candidate.ModuleID or -1, + expectedModule = moduleID, + faction = factionName, + group = group, + accepted = candidate and candidate.ModuleID == moduleID or false + }); + + if candidate then + if candidate.ModuleID == moduleID then + weapon = candidate; + break; + else + DeleteEntity(candidate); + end + end + end + + if weapon then + self:RecordSpawnTrace(tracePrefix .. "_INVENTORY_BEGIN", team, actorIndex) + local inventoryStart = self.ArenaSpawnWallTimer.ElapsedRealTimeMS + actor:AddInventoryItem(weapon) + self:RecordSpawnTrace(tracePrefix .. "_INVENTORY_RETURN", team, actorIndex, true, + self.ArenaSpawnWallTimer.ElapsedRealTimeMS - inventoryStart) + self:RecordLoadoutDiagnostic("WEAPON_HANDOFF", { + actor = actor.UniqueID, + actorModule = actor.ModuleID, + equipped = actor.EquippedItem and actor.EquippedItem.PresetName or "NONE", + faction = factionName, + inventory = actor.InventorySize, + weapon = weapon.PresetName, + weaponModule = weapon.ModuleID + }); + if self.A1RetainWeaponReference then + -- Diagnostic only: retain the Lua wrapper for the C++-owned item + -- through the first enumerable update. Do not mutate the weapon. + self.A1DiagnosticWeaponRefs[actor.UniqueID] = weapon; + end + end + + return actor; +end + + +function SpectatorArena:SpawnRound() + self.ArenaSpawnInProgress = true + self.ArenaSpawnComplete = false + self:RecordSpawnTrace("ROUND_SETUP_BEGIN") + self:TransitionState("SPAWN_TEAMS"); + self.RoundOver = false; + self.BattleStarted = false; + self.RoundResultText = ""; + self.SpawnGraceTimer:Reset(); + self.RoundElapsedTimer:Reset(); + + self.AISpawnSettleTimer:Reset(); + self.AISpawnSettled = false; + self.AIReleasedActors = {}; + self.AIDistributedTargetTimer:Reset(); + -- Actor UniqueIDs and routes belong only to this round. + self.AIPursuitTargets = {}; + self.AIPursuitProgress = {}; + self.AIRetargetTimer:Reset(); + + self.AICombatPressureTimer:Reset(); + self.AIPreviousTeam1Alive = nil; + self.AIPreviousTeam2Alive = nil; + + self:ResetCameraDirector(); + + self.RoundNumber = self.RoundNumber + 1; + self.AIController:BeginRound(self.RoundNumber, nil); + + self.FactionPool = { + "Coalition.rte", + "Ronin.rte", + "Dummy.rte", + "Imperatus.rte", + "Techion.rte", + "Browncoats.rte" + }; + + self.Team1Faction = + self.FactionPool[ + math.random(1, #self.FactionPool) + ]; + + repeat + self.Team2Faction = + self.FactionPool[ + math.random(1, #self.FactionPool) + ]; + until self.Team2Faction ~= self.Team1Faction; + self:RecordSpawnTrace("FACTIONS_SELECTED") + + self.Telemetry.Emit("ROUND_START", { + round = self.RoundNumber, + team1 = self.Team1Faction, + team2 = self.Team2Faction + }); + + + local team1X = + SceneMan.SceneWidth * 0.20; + + local team2X = + SceneMan.SceneWidth * 0.80; + + + for i = 1, 8 do + self:RecordSpawnTrace("TEAM0_SPAWN_BEGIN", self.Team1, i) + local actor = + self:CreateFactionSoldier( + self.Team1Faction, + self.Team1, + i + ); + + if actor then + actor.Team = self.Team1; + + -- V8: recovered Spectator Mod dependency. + -- BRAINHUNT's BrainSearch requires enemy actors + -- exposed through the "Brains" group. + actor:AddToGroup("Brains"); + + actor.Pos = Vector( + team1X + ((i - 1) * 18), + 50 + ); + + -- V7 baseline based on recovered Spectator Mod: + -- offensive actors start directly in native hunt mode. + actor.AIMode = Actor.AIMODE_SENTRY; + + self:RecordSpawnTrace("T0_A" .. tostring(i) .. "_ADD_ACTOR_BEGIN", self.Team1, i) + local addActorStart = self.ArenaSpawnWallTimer.ElapsedRealTimeMS + MovableMan:AddActor(actor) + self:RecordSpawnTrace("T0_A" .. tostring(i) .. "_ADD_ACTOR_RETURN", self.Team1, i, + MovableMan:IsActor(actor), self.ArenaSpawnWallTimer.ElapsedRealTimeMS - addActorStart) + local postInsertionItem = actor.EquippedItem; + local postInsertionBGItem = actor.EquippedBGItem; + self:RecordLoadoutDiagnostic("WEAPON_POST_INSERTION", { + actor = actor.UniqueID, + actorValid = MovableMan:IsActor(actor), + equipped = postInsertionItem and postInsertionItem.PresetName or "NONE", + equippedClass = postInsertionItem and postInsertionItem.ClassName or "NONE", + equippedIsFirearm = postInsertionItem and IsHDFirearm(postInsertionItem) or false, + equippedMOID = postInsertionItem and postInsertionItem.ID or -1, + equippedRootMOID = postInsertionItem and postInsertionItem.RootID or -1, + retentionEnabled = self.A1RetainWeaponReference, + retainedReference = self.A1DiagnosticWeaponRefs[actor.UniqueID] ~= nil, + background = postInsertionBGItem and postInsertionBGItem.PresetName or "NONE", + backgroundClass = postInsertionBGItem and postInsertionBGItem.ClassName or "NONE", + inventory = actor.InventorySize, + team = actor.Team + }); + if not self.A1PostSpawnStarted and MovableMan:IsActor(actor) then + self.A1SpawnedActorCount = self.A1SpawnedActorCount + 1 + end + self:RecordSpawnTrace("T0_A" .. tostring(i) .. "_REGISTER_BEGIN", self.Team1, i) + self.AIController:RegisterActor(actor.UniqueID, self.Team1, i); + self:RecordSpawnTrace("T0_A" .. tostring(i) .. "_REGISTER_RETURN", self.Team1, i, true) + end + end + self:RecordSpawnTrace("TEAM0_SPAWN_END", self.Team1, 8, true) + + + for i = 1, 8 do + self:RecordSpawnTrace("TEAM1_SPAWN_BEGIN", self.Team2, i) + local actor = + self:CreateFactionSoldier( + self.Team2Faction, + self.Team2, + i + ); + + if actor then + actor.Team = self.Team2; + + -- V8: make this combatant a valid enemy + -- target for native BRAINHUNT BrainSearch. + actor:AddToGroup("Brains"); + + actor.Pos = Vector( + team2X - ((i - 1) * 18), + 50 + ); + + -- V7 baseline based on recovered Spectator Mod: + -- offensive actors start directly in native hunt mode. + actor.AIMode = Actor.AIMODE_SENTRY; + + self:RecordSpawnTrace("T1_A" .. tostring(i) .. "_ADD_ACTOR_BEGIN", self.Team2, i) + local addActorStart = self.ArenaSpawnWallTimer.ElapsedRealTimeMS + MovableMan:AddActor(actor) + self:RecordSpawnTrace("T1_A" .. tostring(i) .. "_ADD_ACTOR_RETURN", self.Team2, i, + MovableMan:IsActor(actor), self.ArenaSpawnWallTimer.ElapsedRealTimeMS - addActorStart) + local postInsertionItem = actor.EquippedItem; + local postInsertionBGItem = actor.EquippedBGItem; + self:RecordLoadoutDiagnostic("WEAPON_POST_INSERTION", { + actor = actor.UniqueID, + actorValid = MovableMan:IsActor(actor), + equipped = postInsertionItem and postInsertionItem.PresetName or "NONE", + equippedClass = postInsertionItem and postInsertionItem.ClassName or "NONE", + equippedIsFirearm = postInsertionItem and IsHDFirearm(postInsertionItem) or false, + equippedMOID = postInsertionItem and postInsertionItem.ID or -1, + equippedRootMOID = postInsertionItem and postInsertionItem.RootID or -1, + retentionEnabled = self.A1RetainWeaponReference, + retainedReference = self.A1DiagnosticWeaponRefs[actor.UniqueID] ~= nil, + background = postInsertionBGItem and postInsertionBGItem.PresetName or "NONE", + backgroundClass = postInsertionBGItem and postInsertionBGItem.ClassName or "NONE", + inventory = actor.InventorySize, + team = actor.Team + }); + if not self.A1PostSpawnStarted and MovableMan:IsActor(actor) then + self.A1SpawnedActorCount = self.A1SpawnedActorCount + 1 + end + self:RecordSpawnTrace("T1_A" .. tostring(i) .. "_REGISTER_BEGIN", self.Team2, i) + self.AIController:RegisterActor(actor.UniqueID, self.Team2, i); + self:RecordSpawnTrace("T1_A" .. tostring(i) .. "_REGISTER_RETURN", self.Team2, i, true) + end + end + self:RecordSpawnTrace("TEAM1_SPAWN_END", self.Team2, 8, true) + self:RecordSpawnTrace("ROUND_SPAWN_COMPLETE", nil, nil, true) + self.ArenaSpawnComplete = true + self.ArenaSpawnInProgress = false + self:PersistSpawnTrace("ROUND_SPAWN_COMPLETE") + if not self.A1PostSpawnStarted then + self.A1PostSpawnStarted = true + self.A1PostSpawnWallTimer:Reset() + self:RecordPostSpawnTrace("ROUND_SPAWN_COMPLETE", { + spawned = self.A1SpawnedActorCount, + landed = 0, + released = 0 + }) + end + + + print( + "SpectatorArena: round " .. + tostring(self.RoundNumber) .. + " | " .. + self.Team1Faction .. + " vs " .. + self.Team2Faction + ); +end + +function SpectatorArena:AssignDistributedMovingTargets( + actors, + enemies, + forceRefresh +) + local livingActors = {}; + local livingEnemies = {}; + + for _, actor in ipairs(actors) do + if MovableMan:IsActor(actor) + and not actor:IsDead() + and ( + not self.AITouchdownGateActive + or ( + self.AIReleasedActors + and self.AIReleasedActors[actor.UniqueID] + ) + ) + then + table.insert(livingActors, actor); + end + end + + for _, enemy in ipairs(enemies) do + if MovableMan:IsActor(enemy) and not enemy:IsDead() then + table.insert(livingEnemies, enemy); + end + end + + if #livingEnemies == 0 then + return; + end + + -- Stable spatial ordering helps spread nearby soldiers across + -- different enemy targets instead of having all actors select + -- the same easiest BrainSearch destination. + table.sort( + livingActors, + function(a, b) + return a.Pos.X < b.Pos.X; + end + ); + + table.sort( + livingEnemies, + function(a, b) + return a.Pos.X < b.Pos.X; + end + ); + + for index, actor in ipairs(livingActors) do + local currentTarget = actor.MOMoveTarget; + + local targetInvalid = + not currentTarget + or not MovableMan:IsActor(currentTarget) + or currentTarget.Team == actor.Team; + + if forceRefresh or targetInvalid then + local enemyIndex = + ((index - 1) % #livingEnemies) + 1; + + local target = + livingEnemies[enemyIndex]; + + actor:ClearAIWaypoints(); + actor:AddAIMOWaypoint(target); + actor.AIMode = Actor.AIMODE_GOTO; + + print( + "SpectatorArena: AI_DISTRIBUTED_TARGET actor=" + .. tostring(actor.UniqueID) + .. " target=" + .. tostring(target.UniqueID) + ); + end + end +end + + +function SpectatorArena:UpdateDistributedMovingTargets( + team1Actors, + team2Actors +) + if self.State ~= "BATTLE" then + return; + end + + if not self.AIDistributedTargetTimer:IsPastSimMS( + self.AIDistributedTargetRefreshMS + ) then + return; + end + + self.AIDistributedTargetTimer:Reset(); + + self:AssignDistributedMovingTargets( + team1Actors, + team2Actors, + false + ); + + self:AssignDistributedMovingTargets( + team2Actors, + team1Actors, + false + ); +end + +function SpectatorArena:UpdateSpawnSettle(team1Actors, team2Actors) + if self.AISpawnSettled then + return; + end + + self.A1PendingActorID = nil; + self.A1PendingVelY = nil; + self.A1PendingGroundDistance = nil; + + local function releaseLandedActors( + arena, + actors, + enemies + ) + local newlyReleased = false; + + for _, actor in ipairs(actors) do + if MovableMan:IsActor(actor) + and not actor:IsDead() + and not arena.AIReleasedActors[actor.UniqueID] + then + -- Probe from actor center toward the feet. + -- Ground contact should put terrain within roughly + -- half an actor-height below the center. + local probeDistance = + math.max( + 18, + actor.Height * 0.70 + ); + + local groundDistance = + SceneMan:CastObstacleRay( + actor.Pos, + Vector(0, probeDistance), + Vector(), + Vector(), + actor.ID, + actor.IgnoresWhichTeam, + rte.grassID, + 3 + ); + + -- Require both terrain under the actor and a mostly + -- settled vertical velocity. + local touchedGround = + groundDistance >= 0 + and math.abs(actor.Vel.Y) <= 3; + + if touchedGround then + arena.A1LandedActors[actor.UniqueID] = true; + arena.AIReleasedActors[actor.UniqueID] = + true; + arena.AIController:ReleaseActor( + actor.UniqueID, + arena.RoundElapsedTimer.ElapsedSimTimeMS + ); + + newlyReleased = true; + + print( + "SpectatorArena: AI_TOUCHDOWN_RELEASE actor=" + .. tostring(actor.UniqueID) + .. " velY=" + .. tostring(actor.Vel.Y) + .. " groundDistance=" + .. tostring(groundDistance) + ); + else + if not arena.A1PendingActorID then + arena.A1PendingActorID = actor.UniqueID; + arena.A1PendingVelY = actor.Vel.Y; + arena.A1PendingGroundDistance = groundDistance; + end + -- Absolutely no pursuit before first touchdown. + actor:ClearAIWaypoints(); + actor.AIMode = Actor.AIMODE_SENTRY; + end + end + end + + -- Whenever another actor touches down, redistribute all + -- currently released teammates together. This preserves + -- the touchdown gate while avoiding the one-actor target + -- assignment that made early landers converge on one enemy. + if newlyReleased then + local releasedActors = {}; + + for _, actor in ipairs(actors) do + if MovableMan:IsActor(actor) + and not actor:IsDead() + and arena.AIReleasedActors[actor.UniqueID] + then + table.insert( + releasedActors, + actor + ); + end + end + + arena:AssignDistributedMovingTargets( + releasedActors, + enemies, + true + ); + + print( + "SpectatorArena: AI_TOUCHDOWN_REDISTRIBUTE count=" + .. tostring(#releasedActors) + ); + end + end + + releaseLandedActors( + self, + team1Actors, + team2Actors + ); + + releaseLandedActors( + self, + team2Actors, + team1Actors + ); + + local livingActorCount = 0; + local landedActorCount = 0; + local releasedActorCount = 0; + local pendingActorIDs = {}; + + local function countTeam(arena, actors) + for _, actor in ipairs(actors) do + if MovableMan:IsActor(actor) + and not actor:IsDead() + then + livingActorCount = + livingActorCount + 1; + + if arena.AIReleasedActors[actor.UniqueID] then + releasedActorCount = + releasedActorCount + 1; + else + table.insert(pendingActorIDs, tostring(actor.UniqueID)); + end + + if arena.A1LandedActors[actor.UniqueID] then + landedActorCount = + landedActorCount + 1; + end + end + end + end + + countTeam(self, team1Actors); + countTeam(self, team2Actors); + self.A1SpawnedActorCount = livingActorCount; + self.A1LandedActorCount = landedActorCount; + self.A1ReleasedActorCount = releasedActorCount; + self.A1PendingActorIDs = #pendingActorIDs > 0 + and table.concat(pendingActorIDs, ",") + or "NONE"; + + if livingActorCount > 0 + and releasedActorCount == livingActorCount + then + self.AISpawnSettled = true; + self.AIDistributedTargetTimer:Reset(); + + print( + "SpectatorArena: AI_TOUCHDOWN_ALL_RELEASED" + .. " living=" + .. tostring(livingActorCount) + ); + if not self.A1AllActorsReleasedObserved then + self.A1AllActorsReleasedObserved = true; + self:TracePostSpawnBoundary("ALL_ACTORS_RELEASED", nil, true) + end + end +end + +function SpectatorArena:ClearRoundActors() + local actorsToRemove = {}; + + for actor in MovableMan.Actors do + if actor.Team == self.Team1 or actor.Team == self.Team2 then + table.insert(actorsToRemove, actor); + end + end + + for _, actor in ipairs(actorsToRemove) do + if MovableMan:IsActor(actor) then + -- Round-reset cleanup only. + -- Surviving combatants disappear without creating artificial + -- gibs. Real battle gore, limbs, dropped equipment and terrain + -- destruction remain in the scene. + local removedActor = MovableMan:RemoveActor(actor); + + if removedActor then + DeleteEntity(removedActor); + end + end + end +end + + +function SpectatorArena:FinishRound(winner) + if self.RoundOver then + return; + end + + self.RoundOver = true; + self.WinnerTeam = winner; + self.RoundEndTimer:Reset(); + self:TransitionState("ROUND_RESULT"); + + if winner == self.Team1 then + self.Team1Score = self.Team1Score + 1; + self.RoundResultText = string.upper(string.gsub(self.Team1Faction or "TEAM 1", "%.rte$", "")) .. " WINS"; + + elseif winner == self.Team2 then + self.Team2Score = self.Team2Score + 1; + self.RoundResultText = string.upper(string.gsub(self.Team2Faction or "TEAM 2", "%.rte$", "")) .. " WINS"; + + else + self.RoundResultText = "DRAW"; + end + + print( + "SpectatorArena: round " .. + tostring(self.RoundNumber) .. + " finished - " .. + self.RoundResultText + ); + self.Telemetry.Emit("ROUND_RESULT", { + round = self.RoundNumber, + winner = self.RoundResultText, + durationMS = self.RoundElapsedTimer.ElapsedSimTimeMS, + team1Score = self.Team1Score, + team2Score = self.Team2Score + }); + self:TracePostSpawnBoundary("ROUND_RESULT", { + team1Alive = self.A1Team1Alive, + team2Alive = self.A1Team2Alive, + detail = self.RoundResultText + }, true) + self:PersistPostSpawnTrace("ROUND_RESULT") + if self.AI_V2_MODE == "SHADOW" then + local aiSnapshot = self.AIController:Snapshot(); + self.Telemetry.Emit("AI_SHADOW_ROUND_SUMMARY", { + round = self.RoundNumber, + observations = aiSnapshot.ShadowObservations, + losChecks = aiSnapshot.LOSChecks, + losPositive = aiSnapshot.LOSPositive, + fireEvents = aiSnapshot.FireEvents, + damageEvents = aiSnapshot.DamageEvents, + visibleOpponents = aiSnapshot.VisibleOpponents, + visibleOpponentChecks = aiSnapshot.VisibleOpponentChecks, + losProbeRays = aiSnapshot.LOSProbeRays, + actorSkips = aiSnapshot.ActorSkips, + contactAcquisitions = aiSnapshot.ContactAcquisitions, + contactLosses = aiSnapshot.ContactLosses, + shadowObservationTimeMS = aiSnapshot.ShadowObservationTimeMS, + fireSensorSamples = aiSnapshot.FireSensorSamples, + firearmEquippedSamples = aiSnapshot.FirearmEquippedSamples, + firearmMissingSamples = aiSnapshot.FirearmMissingSamples, + firedFrameSamples = aiSnapshot.FiredFrameSamples, + firedFrameTransitions = aiSnapshot.FiredFrameTransitions, + roundsFiredSamples = aiSnapshot.RoundsFiredSamples, + roundsFiredTotal = aiSnapshot.RoundsFiredTotal, + alarmEventSnapshots = aiSnapshot.AlarmEventSnapshots, + alarmEventsObserved = aiSnapshot.AlarmEventsObserved, + fireFrameCount = aiSnapshot.FireFrameCount, + roundsDischargedObserved = aiSnapshot.RoundsDischargedObserved + }); + for _, state in ipairs(self.AIController:GetFireSensorStates()) do + self.Telemetry.Emit("AI_SHADOW_FIRE_SENSOR_SUMMARY", { + round = self.RoundNumber, + actor = state.ActorID, + team = state.Team, + firearmFound = state.FirearmMOID ~= nil, + firearmMOID = state.FirearmMOID, + firearmRootMOID = state.FirearmRootMOID, + firearmSlot = state.FirearmSlot, + equippedItemClass = state.EquippedItemClass, + equippedBGItemClass = state.EquippedBGItemClass, + inventorySize = state.InventorySize, + inventoryFirearmCount = state.InventoryFirearmCount, + sampleCount = state.SampleCount, + firstSampleTimeMS = state.FirstSampleTimeMS, + lastSampleTimeMS = state.LastSampleTimeMS, + firedFrameTransitions = state.FiredFrameTransitions, + roundsFiredSamples = state.RoundsFiredSamples, + lastFiredFrame = state.LastFiredFrame, + lastRoundsFired = state.LastRoundsFired, + lastFireTimeMS = state.LastFireTimeMS, + fireEventCount = state.FireEventCount, + fireFrameCount = state.FireFrameCount, + roundsDischargedObserved = state.RoundsDischargedObserved + }); + end + end + self.Telemetry.Snapshot(); +end + + +function SpectatorArena:TransitionState(nextState) + if self.State ~= nextState then + self.State = nextState; + self.Telemetry.Emit("STATE", { round = self.RoundNumber, state = nextState }); + print("SpectatorArena: " .. nextState .. " " .. tostring(self.RoundNumber)); + end +end + + +function SpectatorArena:ResolveWatchdog(team1Alive, team2Alive) + print("SpectatorArena: WATCHDOG_TIMEOUT"); + self.Telemetry.Emit("WATCHDOG", { + round = self.RoundNumber, + team1Alive = team1Alive, + team2Alive = team2Alive, + reason = "timeout" + }); + + if team1Alive > team2Alive then + print("SpectatorArena: WATCHDOG_RESULT TEAM_1"); + self:FinishRound(self.Team1); + elseif team2Alive > team1Alive then + print("SpectatorArena: WATCHDOG_RESULT TEAM_2"); + self:FinishRound(self.Team2); + else + print("SpectatorArena: WATCHDOG_RESULT DRAW"); + self:FinishRound(Activity.NOTEAM); + end +end + + +function SpectatorArena:StartActivity() + print("SpectatorArena: autonomous AI vs AI spectator"); + self.Telemetry = require("Activities/SpectatorTelemetry"); + self.Telemetry.ConfigureRuntime("SPECTATOR_EVENT_LOG.txt"); + self.Telemetry.Emit("ACTIVITY_START", {}); + self.AI_V2_MODE = "OFF"; + self.LoadoutDiagnosticEnabled = true; + self.LoadoutDiagnosticCount = 0; + self.LoadoutDiagnosticLimit = 64; + self.LoadoutDiagnosticSnapshotFactions = {}; + self.A1RetainWeaponReference = false; + self.A1DiagnosticWeaponRefs = {}; + self.AIController = require("Activities/SpectatorAIController").Create({ + mode = self.AI_V2_MODE, + positionHistoryLimit = 4 + }); + self.Telemetry.Emit("AI_V2_CONFIG", { + version = "2", + mode = self.AI_V2_MODE + }); + + self.Team1 = Activity.TEAM_1; + self.Team2 = Activity.TEAM_2; + self.SpectatorTeam = Activity.TEAM_3; + + self.Team1Score = 0; + self.Team2Score = 0; + self.RoundNumber = 0; + + self.State = "BOOT"; + self.MaxRoundDurationMS = 300000; + self.RoundOver = false; + self.BattleStarted = false; + self.RoundResultText = ""; + + self.RoundEndDelay = 3000; + self.RoundEndTimer = Timer(); + self.RoundTimer = Timer(); + self.RoundElapsedTimer = Timer(); + self.ArenaSpawnWallTimer = Timer(); + self.ArenaSpawnTrace = {}; + self.ArenaSpawnTraceSequence = 0; + self.ArenaSpawnTraceLimit = 256; + self.ArenaSpawnTracePersisted = false; + self.ArenaSpawnTraceStartupTimeoutMS = 15000; + self.ArenaSpawnInProgress = false; + self.ArenaSpawnComplete = false; + self.A1PostSpawnWallTimer = Timer(); + self.A1PostSpawnTrace = {}; + self.A1PostSpawnTraceSequence = 0; + self.A1PostSpawnTraceLimit = 256; + self.A1PostSpawnTracePersisted = false; + self.A1PostSpawnStarted = false; + self.A1PostSpawnTimeoutMS = 20000; + self.A1UpdateCount = 0; + self.A1HeartbeatUpdates = { + [1] = true, + [2] = true, + [10] = true, + [60] = true, + [300] = true, + [600] = true, + [1200] = true + }; + self.A1LastStage = "NOT_STARTED"; + self.A1LandedActors = {}; + self.A1SpawnedActorCount = 0; + self.A1LandedActorCount = 0; + self.A1ReleasedActorCount = 0; + self.A1Team1Alive = 0; + self.A1Team2Alive = 0; + self.A1PendingActorIDs = "NONE"; + self.A1PendingActorID = nil; + self.A1PendingVelY = nil; + self.A1PendingGroundDistance = nil; + self.A1AllActorsReleasedObserved = false; + self.A1FirstVisibilityObserved = false; + self.A1FirstContactObserved = false; + self.A1FirstFirearmObserved = false; + self.A1FirstFiredFrameObserved = false; + self.A1FirstFireLatchObserved = false; + self.A1FirstDamageObserved = false; + self.A1FirstUpdateLoadoutObserved = {}; + -- Spectator Arena always runs at the engine-supported maximum. + -- These reproduce the maximum values exposed by the old setup menu: + -- Difficulty 100 / AI Skill "Unfair" 100. + self.Difficulty = Activity.MAXDIFFICULTY; + self:SetTeamAISkill(self.Team1, Activity.UNFAIRSKILL); + self:SetTeamAISkill(self.Team2, Activity.UNFAIRSKILL); + + print( + "SpectatorArena: AI_CONFIG difficulty=" + .. tostring(self.Difficulty) + .. " team1Skill=" + .. tostring(self:GetTeamAISkill(self.Team1)) + .. " team2Skill=" + .. tostring(self:GetTeamAISkill(self.Team2)) + ); + -- Force absolute maximum AI settings for Spectator Arena. + + self.SpawnGraceDelayMS = 5000; + self.SpawnGraceTimer = Timer(); + + -- V9: let freshly spawned actors land before aggressive hunting. + self.AISpawnSettleDelayMS = 1500; + self.AISpawnSettleTimer = Timer(); + self.AISpawnSettled = false; + self.AITouchdownGateActive = true; + self.AIReleasedActors = {}; + self.AIInstrumentationTimer = Timer(); + self.AIInstrumentationIntervalMS = 500; + + -- V10 distributed moving-target experiment. + self.AIDistributedTargetTimer = Timer(); + self.AIDistributedTargetRefreshMS = 2000; + + -- TEMPORARY dynamic-pursuit experiment. + self.AIRetargetIntervalMS = 6000; + self.AIRetargetTimer = Timer(); + self.AIPursuitTargets = {}; + self.AIPursuitProgress = {}; + + -- Anti-stall thresholds. + -- Two bad 6-second samples = roughly 12 seconds without progress. + self.AIStallDistanceThreshold = 24; + self.AIStallSamplesBeforeRepath = 2; + self.AIStallRepathEnemyDistance = 320; + + -- V5 global combat-pressure controller. + -- Prevent long spectator dead periods even when individual AI + -- technically considers its current state/path valid. + self.AICombatPressureTimer = Timer(); + self.AICombatPressureNormalMS = 12000; + self.AICombatPressureLowSurvivorMS = 6000; + self.AICombatPressureCriticalMS = 4000; + self.AICombatActivityRange = 800; + self.AIPreviousTeam1Alive = nil; + self.AIPreviousTeam2Alive = nil; + self.CameraEvaluationIntervalMS = 250; + self.CameraMinimumHoldMS = 1500; + self.CameraSwitchThreshold = 1.25; + self.CameraSoldierMinimumHoldMS = 750; + self.CameraPOIMinimumHoldMS = 2500; + self.CameraPOIMaximumHoldMS = 3000; + self.CameraPOISwitchThreshold = 1.35; + self.CameraPOICooldownMS = 3500; + self.CameraRecentFireWindowMS = 400; + self.CameraEventHoldMS = 2000; + self.CameraEventCooldownMS = 4000; + self.CameraEventMinimumAimDot = 0.85; + self.CameraEventMinimumDistance = 180; + self.CameraEventMaximumRange = 1200; + self.CameraEngagementHoldMS = 900; + self.CameraEngagementCooldownMS = 1400; + self.CameraEngagementMinimumAimDot = 0.80; + self.CameraEngagementMinimumDistance = 300; + self.CameraEngagementMaximumRange = 1600; + self.CameraEngagementEnemyBias = 0.55; + + -- TEMPORARY RAW CAMERA DIAGNOSTIC. + -- Bypasses normal timing/cooldown policy so selector behavior can be observed. + self.CameraRawDiagnosticMode = false; + self.CameraRawLastTargetType = nil; + self.CameraRawLastActorID = nil; + self.CameraRawLastEnemyID = nil; + self.CameraRawNextIdleTeam = 1; + self.CameraRawCurrentIdleTeam = nil; + + self.CameraEvaluationTimer = Timer(); + self.CameraHoldTimer = Timer(); + self.CameraModeTimer = Timer(); + self.CameraPOICooldownTimer = Timer(); + self.CameraRecentFireTimer = Timer(); + self.CameraEventCooldownTimer = Timer(); + self.CameraEngagementCooldownTimer = Timer(); + self.CameraPOICooldownReady = true; + self.CameraEventCooldownReady = true; + self.CameraEngagementCooldownReady = true; + self.CameraMode = "CAMERA_CENTER"; + self.CameraFollowActor = nil; + self.CameraPOIActor = nil; + self.CameraPOIEnemy = nil; + self.CameraEventLogic = require("Activities/SpectatorCameraEventLogic"); + self.CameraLastShot = nil; + self.CameraRoundsFiredByActor = {}; + self.CameraControllerFireByActor = {}; + self.CameraTrackedActors = {}; + self.CameraHandledVictims = {}; + self.CameraEventPosition = nil; + self.CameraEngagementPosition = nil; + self.CameraEngagementEnemy = nil; + -- Review-only trace state. This records camera evidence without changing + -- selection, priority, hold, or cooldown behavior. + self.CameraEventTraceEnabled = true; + self.CameraTraceSequence = 0; + self.CameraEventTraceID = nil; + self.CameraEventTargetIssued = false; + self.CameraEventObservationArrivalTolerance = 24; + self.CameraEventObservationMovementThreshold = 1; + self.CameraEventObservation = nil; + self.CameraFocusPosition = self.CameraPos; + self.CameraFocusScore = 0; + self.CameraFocusActor = nil; + self.CameraHasFocus = false; + self.HUDLogic = require("Activities/SpectatorHUDLogic"); + + self:SetPlayerBrain(nil, Activity.PLAYER_1); + self:SetTeamOfPlayer(Activity.PLAYER_1, self.SpectatorTeam); + self:SetViewState(Activity.OBSERVE, Activity.PLAYER_1); + + self.CameraPos = Vector( + SceneMan.SceneWidth * 0.5, + SceneMan.SceneHeight * 0.45 + ); + + self:ResetCameraDirector(); + + self:SetObservationTarget( + self.CameraPos, + Activity.PLAYER_1 + ); + + self:TransitionState("PREPARE_ROUND"); + self:SpawnRound(); +end + + +function SpectatorArena:FindBestCombatFocus(team1Actors, team2Actors) + local combatRadius = 260; + local combatRadiusSquared = combatRadius * combatRadius; + local bestScore = -1; + local bestPosition = nil; + local bestActor = nil; + local bestEnemy = nil; + + local function considerCandidates(candidates, enemies) + for _, candidate in ipairs(candidates) do + local nearbyEnemies = 0; + local nearestEnemyDistance = math.huge; + local nearestEnemy = nil; + + for _, enemy in ipairs(enemies) do + local distanceVector = SceneMan:ShortestDistance( + candidate.Pos, + enemy.Pos, + SceneMan.SceneWrapsX + ); + local distanceSquared = + (distanceVector.X * distanceVector.X) + + (distanceVector.Y * distanceVector.Y); + + if distanceSquared <= combatRadiusSquared then + nearbyEnemies = nearbyEnemies + 1; + end + + if distanceSquared < nearestEnemyDistance then + nearestEnemyDistance = distanceSquared; + nearestEnemy = enemy; + end + end + + if nearestEnemy then + local score = nearbyEnemies * 1000; + + -- Distance remains useful, but should not dominate actual combat activity. + local proximity = + math.max(0, combatRadiusSquared - nearestEnemyDistance) + / combatRadiusSquared; + + score = score + (proximity * 250); + + -- RAW ACTION-AWARE CAMERA TEST: + -- firing should outweigh passive actor density. + local candidateFiring = false; + local enemyFiring = false; + + local candidateItem = candidate.EquippedItem; + if candidateItem and IsHDFirearm(candidateItem) then + candidateFiring = ToHDFirearm(candidateItem).FiredFrame; + end + + local enemyItem = nearestEnemy.EquippedItem; + if enemyItem and IsHDFirearm(enemyItem) then + enemyFiring = ToHDFirearm(enemyItem).FiredFrame; + end + + if candidateFiring then + score = score + 5000; + end + + if enemyFiring then + score = score + 5000; + end + + -- Two actors actively exchanging fire should be overwhelmingly preferred. + if candidateFiring and enemyFiring then + score = score + 5000; + end + + -- Preserve last-survivor importance. + if #candidates == 1 then + score = score + 750; + end + + if score > bestScore then + bestScore = score; + bestActor = candidate; + bestEnemy = nearestEnemy; + end + end + end + end + + considerCandidates(team1Actors, team2Actors); + considerCandidates(team2Actors, team1Actors); + + if bestActor and bestEnemy then + local distance = SceneMan:ShortestDistance( + bestActor.Pos, + bestEnemy.Pos, + SceneMan.SceneWrapsX + ); + bestPosition = bestActor.Pos + (distance * 0.5); + elseif team1Actors[1] then + bestScore = 0; + bestActor = team1Actors[1]; + bestPosition = bestActor.Pos; + elseif team2Actors[1] then + bestScore = 0; + bestActor = team2Actors[1]; + bestPosition = bestActor.Pos; + end + + return bestPosition, bestScore, bestActor, bestEnemy; +end + + +function SpectatorArena:FindNearestDirectEnemy(actor, enemies) + local bestEnemy = nil; + local bestDistanceSquared = math.huge; + + for _, enemy in ipairs(enemies) do + if MovableMan:IsActor(enemy) and not enemy:IsDead() then + -- Deliberately direct/non-wrapped distance. + -- Ketanot Hills wraps horizontally, but spectator combat + -- should prefer the physically nearby opponent on screen. + local dx = enemy.Pos.X - actor.Pos.X; + local dy = enemy.Pos.Y - actor.Pos.Y; + local distanceSquared = (dx * dx) + (dy * dy); + + if distanceSquared < bestDistanceSquared then + bestDistanceSquared = distanceSquared; + bestEnemy = enemy; + end + end + end + + return bestEnemy, bestDistanceSquared; +end + + +function SpectatorArena:UpdateDynamicPursuit(team1Actors, team2Actors) + if self.State ~= "BATTLE" then + return; + end + + if not self.AIRetargetTimer:IsPastSimMS(self.AIRetargetIntervalMS) then + return; + end + + self.AIRetargetTimer:Reset(); + + -- V4: detect whether actors are actually making progress + -- toward enemies instead of merely moving/shuffling. + local requiredProgress = 32; + + local function retargetTeam(actors, enemies) + for _, actor in ipairs(actors) do + if MovableMan:IsActor(actor) and not actor:IsDead() then + local enemy, enemyDistanceSquared = + self:FindNearestDirectEnemy(actor, enemies); + + if enemy then + local actorID = actor.UniqueID; + local enemyID = enemy.UniqueID; + local enemyDistance = + math.sqrt(enemyDistanceSquared); + + local previousTargetID = + self.AIPursuitTargets[actorID]; + + local progress = + self.AIPursuitProgress[actorID]; + + local targetChanged = + previousTargetID ~= enemyID; + + if targetChanged or not progress then + progress = { + TargetID = enemyID, + LastDistance = enemyDistance, + StalledSamples = 0 + }; + + self.AIPursuitProgress[actorID] = progress; + else + local distanceImprovement = + progress.LastDistance - enemyDistance; + + if distanceImprovement < requiredProgress then + progress.StalledSamples = + progress.StalledSamples + 1; + else + progress.StalledSamples = 0; + end + + progress.LastDistance = enemyDistance; + end + + local stalled = + progress.StalledSamples + >= self.AIStallSamplesBeforeRepath; + + if targetChanged or stalled then + local destination = + SceneMan:MovePointToGround( + Vector(enemy.Pos.X, enemy.Pos.Y), + actor.Height * 0.5, + 4 + ); + + actor:ClearAIWaypoints(); + actor:AddAISceneWaypoint(destination); + actor.AIMode = Actor.AIMODE_GOTO; + actor:UpdateMovePath(); + + self.AIPursuitTargets[actorID] = enemyID; + + if stalled then + print( + "SpectatorArena: AI_STALL_REPATH actor=" + .. tostring(actorID) + .. " target=" + .. tostring(enemyID) + .. " distance=" + .. tostring(math.floor(enemyDistance)) + .. " no_progress" + ); + end + + progress.TargetID = enemyID; + progress.LastDistance = enemyDistance; + progress.StalledSamples = 0; + end + end + end + end + end + + retargetTeam(team1Actors, team2Actors); + retargetTeam(team2Actors, team1Actors); +end + +function SpectatorArena:ForceCombatPressurePursuit(actors, enemies) + self:AssignDistributedMovingTargets( + actors, + enemies, + true + ); + + print( + "SpectatorArena: AI_DISTRIBUTED_PRESSURE_REFRESH" + ); +end + +function SpectatorArena:HasMeaningfulCombatFire(team1Actors, team2Actors) + local activityRangeSquared = + self.AICombatActivityRange * self.AICombatActivityRange; + + local function teamHasCombatFire(actors, enemies) + for _, actor in ipairs(actors) do + if MovableMan:IsActor(actor) and not actor:IsDead() then + local item = actor.EquippedItem; + + if item and IsHDFirearm(item) then + local firearm = ToHDFirearm(item); + + if firearm.FiredFrame then + local enemy, enemyDistanceSquared = + self:FindNearestDirectEnemy(actor, enemies); + + if enemy + and enemyDistanceSquared + <= activityRangeSquared then + return true; + end + end + end + end + end + + return false; + end + + return + teamHasCombatFire(team1Actors, team2Actors) + or teamHasCombatFire(team2Actors, team1Actors); +end + + +function SpectatorArena:UpdateCombatPressure( + team1Actors, + team2Actors, + team1Alive, + team2Alive +) + if self.State ~= "BATTLE" then + return; + end + + local totalAlive = team1Alive + team2Alive; + + if totalAlive <= 1 then + return; + end + + -- V6: only an actual casualty counts as meaningful + -- round progress. Gunfire alone no longer suppresses + -- anti-stall pressure, because actors elsewhere may fire + -- while other survivors remain parked indefinitely. + local aliveCountChanged = + self.AIPreviousTeam1Alive ~= nil + and ( + team1Alive ~= self.AIPreviousTeam1Alive + or team2Alive ~= self.AIPreviousTeam2Alive + ); + + self.AIPreviousTeam1Alive = team1Alive; + self.AIPreviousTeam2Alive = team2Alive; + + if aliveCountChanged then + self.AICombatPressureTimer:Reset(); + + print( + "SpectatorArena: AI_COMBAT_PROGRESS" + .. " team1=" + .. tostring(team1Alive) + .. " team2=" + .. tostring(team2Alive) + ); + + return; + end + + local pressureThresholdMS = + self.AICombatPressureNormalMS; + + if totalAlive <= 3 then + pressureThresholdMS = + self.AICombatPressureCriticalMS; + elseif totalAlive <= 4 then + pressureThresholdMS = + self.AICombatPressureLowSurvivorMS; + end + + if not self.AICombatPressureTimer:IsPastSimMS( + pressureThresholdMS + ) then + return; + end + + print( + "SpectatorArena: AI_COMBAT_PRESSURE_HUNT" + .. " totalAlive=" + .. tostring(totalAlive) + .. " team1=" + .. tostring(team1Alive) + .. " team2=" + .. tostring(team2Alive) + .. " thresholdMS=" + .. tostring(pressureThresholdMS) + ); + + self:ForceCombatPressurePursuit( + team1Actors, + team2Actors + ); + + self:ForceCombatPressurePursuit( + team2Actors, + team1Actors + ); + + self.AICombatPressureTimer:Reset(); +end + +function SpectatorArena:UpdateAIShadowObservations(team1Actors, team2Actors) + if self.AI_V2_MODE ~= "SHADOW" + or not self.AIController + or not self.AISpawnSettled + then + return; + end + + local timestampMS = self.RoundElapsedTimer.ElapsedSimTimeMS; + local cpuStartSeconds = os.clock(); + local visibleOpponentTotal = 0; + local visibleOpponentChecks = 0; + local losProbeRays = 0; + local actorSkips = 0; + + local function observeTeam(actors, enemies) + for _, actor in ipairs(actors) do + if MovableMan:IsActor(actor) + and not actor:IsDead() + and self.AIReleasedActors[actor.UniqueID] + then + local nearestEnemy, nearestDistanceSquared = + self:FindNearestDirectEnemy(actor, enemies); + local opponents = {}; + local visibilityByID = {}; + local rayDetailsByID = {}; + + for _, opponent in ipairs(enemies) do + if MovableMan:IsActor(opponent) and not opponent:IsDead() then + -- Mirror Cortex's native target-acquisition profile: eyes + -- first cast to the body, then fall back to the eye point. + -- This remains SHADOW-only and read-only. + local origin = actor.EyePos or actor.Pos; + local ray = SceneMan:ShortestDistance(origin, opponent.Pos, false); + local distanceSquared = ray.X * ray.X + ray.Y * ray.Y; + local targetRootMOID = MovableMan:GetRootMOID(opponent.ID); + local hitMOID = rte.NoMOID; + local selectedTarget = nil; + local rayClassification = "NO_MOID"; + local isVisible = false; + + for _, probe in ipairs( + self.AIController.BuildSightProbeTargets( + opponent.Pos, + opponent.EyePos + ) + ) do + ray = SceneMan:ShortestDistance(origin, probe.position, false); + hitMOID = SceneMan:CastMORay( + origin, + ray, + actor.ID, + actor.IgnoresWhichTeam, + rte.grassID, + false, + 5 + ); + losProbeRays = losProbeRays + 1; + selectedTarget = probe; + rayClassification = self.AIController.ClassifyRayHit( + hitMOID, + opponent.ID, + targetRootMOID, + rte.NoMOID + ); + isVisible = rayClassification == "TARGET" + or rayClassification == "TARGET_ROOT"; + if isVisible then + break; + end + end + opponents[#opponents + 1] = { + UniqueID = opponent.UniqueID, + distanceSquared = distanceSquared, + actor = opponent + }; + visibilityByID[opponent.UniqueID] = isVisible; + rayDetailsByID[opponent.UniqueID] = { + rayReturn = hitMOID, + hitMOID = hitMOID, + targetMOID = opponent.ID, + targetRootMOID = targetRootMOID, + noMOID = rte.NoMOID, + rayClassification = rayClassification, + probeTarget = selectedTarget and selectedTarget.kind or nil, + startX = origin.X, + startY = origin.Y, + endX = selectedTarget and selectedTarget.position.X or nil, + endY = selectedTarget and selectedTarget.position.Y or nil + }; + visibleOpponentChecks = visibleOpponentChecks + 1; + end + end + + local visibleEnemy, visibleDistanceSquared, visibleOpponentCount = + self.AIController.SelectVisibleOpponent(opponents, visibilityByID); + visibleOpponentTotal = visibleOpponentTotal + visibleOpponentCount; + local enemy = visibleEnemy and visibleEnemy.actor or nil; + local distanceSquared = visibleDistanceSquared or nearestDistanceSquared; + local nearestRayDetails = nearestEnemy and rayDetailsByID[nearestEnemy.UniqueID] or nil; + local waypoint = actor:GetLastAIWaypoint(); + local item = actor.EquippedItem; + local firing = false; + + if item and IsHDFirearm(item) then + firing = ToHDFirearm(item).FiredFrame == true; + end + + local hasLOS = enemy ~= nil; + if enemy then + self.AIController:RecordContact( + actor.Team, + enemy.UniqueID, + timestampMS, + enemy.Pos.X, + enemy.Pos.Y, + 1.0, + "DIRECT" + ); + end + + self.AIController:RecordShadowObservation( + actor.UniqueID, + timestampMS, + hasLOS, + false, + actor.Health, + actor.PrevHealth, + enemy and enemy.UniqueID or nil + ); + local firedRecently = self.AIController:FiredRecently( + actor.UniqueID, + timestampMS, + 1000 + ); + + local waypointDistance = SceneMan:ShortestDistance( + actor.Pos, + waypoint, + SceneMan.SceneWrapsX + ); + local progress = -math.sqrt( + waypointDistance.X * waypointDistance.X + + waypointDistance.Y * waypointDistance.Y + ); + self.AIController:RecordProgress( + actor.UniqueID, + timestampMS, + progress + ); + + if firedRecently and enemy and hasLOS then + self.AIController:RecordEngagement( + actor.UniqueID, + timestampMS, + "FIRE_LOS", + timestampMS + 2500 + ); + end + + self.Telemetry.Emit("AI_SHADOW_OBSERVATION", { + round = self.RoundNumber, + actor = actor.UniqueID, + team = actor.Team, + enemy = nearestEnemy and nearestEnemy.UniqueID or nil, + nearestVisibleEnemy = enemy and enemy.UniqueID or nil, + rayReturn = nearestRayDetails and nearestRayDetails.rayReturn or nil, + hitMOID = nearestRayDetails and nearestRayDetails.hitMOID or nil, + targetMOID = nearestRayDetails and nearestRayDetails.targetMOID or nil, + targetRootMOID = nearestRayDetails and nearestRayDetails.targetRootMOID or nil, + rayNoMOID = nearestRayDetails and nearestRayDetails.noMOID or nil, + rayClassification = nearestRayDetails and nearestRayDetails.rayClassification or nil, + rayStartX = nearestRayDetails and nearestRayDetails.startX or nil, + rayStartY = nearestRayDetails and nearestRayDetails.startY or nil, + rayEndX = nearestRayDetails and nearestRayDetails.endX or nil, + rayEndY = nearestRayDetails and nearestRayDetails.endY or nil, + rayProbeTarget = nearestRayDetails and nearestRayDetails.probeTarget or nil, + distance = distanceSquared and math.sqrt(distanceSquared) or nil, + health = actor.Health, + prevHealth = actor.PrevHealth, + firing = firing, + firedRecently = firedRecently, + hasLOS = hasLOS, + visibleOpponentCount = visibleOpponentCount, + visibleOpponentChecks = #opponents, + waypointX = waypoint.X, + waypointY = waypoint.Y, + pathSize = actor.MovePathSize, + pathPending = actor.IsWaitingOnNewMovePath, + recoveryStage = self.AIController:GetRecoveryStage(actor.UniqueID) + }); + else + actorSkips = actorSkips + 1; + end + end + end + + observeTeam(team1Actors, team2Actors); + observeTeam(team2Actors, team1Actors); + self.AIController:RecordShadowBatchMetrics({ + visibleOpponents = visibleOpponentTotal, + visibleOpponentChecks = visibleOpponentChecks, + losProbeRays = losProbeRays, + actorSkips = actorSkips, + elapsedMS = self.AIController.CalculateCPUTimeMS(cpuStartSeconds, os.clock()) + }); +end + +function SpectatorArena:UpdateAIFireDamageLatches(team1Actors, team2Actors) + if self.AI_V2_MODE ~= "SHADOW" + or not self.AIController + or not self.AISpawnSettled + then + return; + end + + local timestampMS = self.RoundElapsedTimer.ElapsedSimTimeMS; + local alarmEventCount = 0; + for _ in MovableMan.AlarmEvents do + alarmEventCount = alarmEventCount + 1; + end + + local function sampleSignals(actors) + for _, actor in ipairs(actors) do + if MovableMan:IsActor(actor) + and not actor:IsDead() + and self.AIReleasedActors[actor.UniqueID] + then + local item = actor.EquippedItem; + local backgroundItem = actor.EquippedBGItem; + local firing = false; + local firearmMOID = nil; + local firearmRootMOID = nil; + local roundsFired = 0; + local firearmSlot = nil; + local inventoryFirearmCount = 0; + if item and IsHDFirearm(item) then + local firearm = ToHDFirearm(item); + firearmMOID = firearm.ID; + firearmRootMOID = firearm.RootID; + firing = firearm.FiredFrame == true; + roundsFired = firearm.RoundsFired; + firearmSlot = "FG"; + elseif backgroundItem and IsHDFirearm(backgroundItem) then + local firearm = ToHDFirearm(backgroundItem); + firearmMOID = firearm.ID; + firearmRootMOID = firearm.RootID; + firing = firearm.FiredFrame == true; + roundsFired = firearm.RoundsFired; + firearmSlot = "BG"; + end + for inventoryItem in actor.Inventory do + if IsHDFirearm(inventoryItem) then + inventoryFirearmCount = inventoryFirearmCount + 1; + end + end + self.AIController:RecordFireSensorSample( + actor.UniqueID, + timestampMS, + firearmMOID, + firearmRootMOID, + firing, + roundsFired, + alarmEventCount + ); + -- The sensor path owns fire latching; retain the generic + -- read-only signal call for health bookkeeping only. + self.AIController:RecordCombatSignals( + actor.UniqueID, + timestampMS, + false + ); + self.AIController:RecordFireSensorContext( + actor.UniqueID, + firearmSlot, + item and item.ClassName or nil, + backgroundItem and backgroundItem.ClassName or nil, + actor.InventorySize, + inventoryFirearmCount + ); + end + end + end + + sampleSignals(team1Actors); + sampleSignals(team2Actors); +end + +function SpectatorArena:UpdateAIInstrumentation(team1Actors, team2Actors) + if not self.AIController + or not self.AISpawnSettled + or not self.AIInstrumentationTimer:IsPastSimMS(self.AIInstrumentationIntervalMS) then + return; + end + + self.AIInstrumentationTimer:Reset(); + + local function sampleActors(actors) + for _, actor in ipairs(actors) do + if MovableMan:IsActor(actor) + and not actor:IsDead() + and self.AIReleasedActors[actor.UniqueID] + then + local waypoint = actor:GetLastAIWaypoint(); + self.AIController:RecordPosition( + actor.UniqueID, + self.RoundElapsedTimer.ElapsedSimTimeMS, + actor.Pos.X, + actor.Pos.Y, + waypoint.X, + waypoint.Y, + false, + actor.IsWaitingOnNewMovePath + ); + end + end + end + + sampleActors(team1Actors); + sampleActors(team2Actors); + + self:TracePostSpawnBoundary("BEFORE_SHADOW_UPDATE") + self:UpdateAIShadowObservations(team1Actors, team2Actors); + self:TracePostSpawnBoundary("AFTER_SHADOW_UPDATE") + self:RecordA1ProgressMarkers() +end + +function SpectatorArena:DrawSpectatorHUD(hud) + local screen = self:ScreenOfPlayer(Activity.PLAYER_1); + local screenWidth = FrameMan.PlayerScreenWidth; + local centerX = math.floor(screenWidth * 0.5); + local cameraOffset = CameraMan:GetOffset(screen); + + PrimitiveMan:DrawTextPrimitive( + screen, + cameraOffset + Vector(12, 28), + hud.team1, + true, + 0 + ); + PrimitiveMan:DrawTextPrimitive( + screen, + cameraOffset + Vector(screenWidth - 12, 28), + hud.team2, + true, + 2 + ); + PrimitiveMan:DrawTextPrimitive( + screen, + cameraOffset + Vector(centerX, 12), + hud.header, + true, + 1 + ); + + if hud.pressure then + PrimitiveMan:DrawTextPrimitive( + screen, + cameraOffset + Vector(centerX, 25), + hud.pressure, + true, + 1 + ); + end +end + +function SpectatorArena:UpdateActivity() + if self.ArenaSpawnInProgress + and not self.ArenaSpawnTracePersisted + and self.ArenaSpawnWallTimer.ElapsedRealTimeMS >= self.ArenaSpawnTraceStartupTimeoutMS + then + self:RecordSpawnTrace("STARTUP_DIAGNOSTIC_TIMEOUT", nil, nil, false) + self:PersistSpawnTrace("STARTUP_DIAGNOSTIC_TIMEOUT") + end + if self.A1PostSpawnStarted + and not self.A1PostSpawnTracePersisted + and self.A1PostSpawnWallTimer.ElapsedRealTimeMS >= self.A1PostSpawnTimeoutMS + then + self:PersistPostSpawnTrace("DIAGNOSTIC_TIMEOUT") + end + + self.A1UpdateCount = self.A1UpdateCount + 1; + self:TracePostSpawnBoundary("UPDATE_ACTIVITY_ENTER") + local team1Alive = 0; + local team2Alive = 0; + + local team1Actors = {}; + local team2Actors = {}; + + self:TracePostSpawnBoundary("BEFORE_ACTOR_SCAN") + for actor in MovableMan.Actors do + if actor.Team == self.Team1 then + team1Alive = team1Alive + 1; + table.insert(team1Actors, actor); + + elseif actor.Team == self.Team2 then + team2Alive = team2Alive + 1; + table.insert(team2Actors, actor); + end + end + self.A1Team1Alive = team1Alive; + self.A1Team2Alive = team2Alive; + self.A1SpawnedActorCount = #team1Actors + #team2Actors; + self:TracePostSpawnBoundary("AFTER_ACTOR_SCAN") + + local function recordFirstUpdateLoadout(arena, actors) + for _, actor in ipairs(actors) do + if not arena.A1FirstUpdateLoadoutObserved[actor.UniqueID] then + arena.A1FirstUpdateLoadoutObserved[actor.UniqueID] = true; + local equippedItem = actor.EquippedItem; + local backgroundItem = actor.EquippedBGItem; + local retainedWeapon = arena.A1DiagnosticWeaponRefs[actor.UniqueID]; + local foregroundArm = actor.FGArm; + local foregroundHeldDevice = foregroundArm and foregroundArm.HeldDevice; + local retainedAttached = false; + if retainedWeapon and IsAttachable(retainedWeapon) then + retainedAttached = retainedWeapon:IsAttached(); + end + local worldItemCount = 0; + local retainedWorldItem = false; + if retainedWeapon then + for item in MovableMan.Items do + worldItemCount = worldItemCount + 1; + if item.ID == retainedWeapon.ID then + retainedWorldItem = true; + end + end + end + arena:RecordLoadoutDiagnostic("WEAPON_FIRST_UPDATE", { + actor = actor.UniqueID, + actorValid = MovableMan:IsActor(actor), + equipped = equippedItem and equippedItem.PresetName or "NONE", + equippedClass = equippedItem and equippedItem.ClassName or "NONE", + equippedIsFirearm = equippedItem and IsHDFirearm(equippedItem) or false, + equippedMOID = equippedItem and equippedItem.ID or -1, + equippedRootMOID = equippedItem and equippedItem.RootID or -1, + retained = retainedWeapon ~= nil, + retainedValid = retainedWeapon and IsHDFirearm(retainedWeapon) or false, + retainedPreset = retainedWeapon and retainedWeapon.PresetName or "NONE", + retainedMOID = retainedWeapon and retainedWeapon.ID or -1, + retainedRootMOID = retainedWeapon and retainedWeapon.RootID or -1, + retainedAttached = retainedAttached, + foregroundArmAttached = foregroundArm and foregroundArm:IsAttached() or false, + foregroundArmMOID = foregroundArm and foregroundArm.ID or -1, + foregroundArmHeld = foregroundHeldDevice and foregroundHeldDevice.PresetName or "NONE", + foregroundArmHeldMOID = foregroundHeldDevice and foregroundHeldDevice.ID or -1, + worldItemCount = worldItemCount, + retainedWorldItem = retainedWorldItem, + background = backgroundItem and backgroundItem.PresetName or "NONE", + backgroundClass = backgroundItem and backgroundItem.ClassName or "NONE", + inventory = actor.InventorySize, + team = actor.Team, + updateCount = arena.A1UpdateCount + }); + end + end + end + recordFirstUpdateLoadout(self, team1Actors); + recordFirstUpdateLoadout(self, team2Actors); + + if self.A1HeartbeatUpdates[self.A1UpdateCount] then + self:RecordPostSpawnTrace("UPDATE_" .. tostring(self.A1UpdateCount), { + spawned = self.A1SpawnedActorCount, + team1Alive = team1Alive, + team2Alive = team2Alive + }) + end + + + self:TracePostSpawnBoundary("BEFORE_CAMERA_UPDATE") + self:UpdateCameraDirector(team1Actors, team2Actors); + self:TracePostSpawnBoundary("AFTER_CAMERA_UPDATE") + self:TracePostSpawnBoundary("BEFORE_TOUCHDOWN_UPDATE") + self:UpdateSpawnSettle( + team1Actors, + team2Actors + ); + self:TracePostSpawnBoundary("AFTER_TOUCHDOWN_UPDATE") + self:TracePostSpawnBoundary("BEFORE_FIRE_LATCH_UPDATE") + self:UpdateAIFireDamageLatches(team1Actors, team2Actors); + self:TracePostSpawnBoundary("AFTER_FIRE_LATCH_UPDATE") + self:TracePostSpawnBoundary("BEFORE_AI_INSTRUMENTATION") + self:UpdateAIInstrumentation(team1Actors, team2Actors); + self:TracePostSpawnBoundary("AFTER_AI_INSTRUMENTATION") + + if self.RoundOver then + local resultHUD = self.HUDLogic.BuildResultHUD( + self.RoundNumber, + self.Team1Faction, + team1Alive, + self.Team1Score, + self.Team2Faction, + team2Alive, + self.Team2Score + ); + self:DrawSpectatorHUD(resultHUD); + FrameMan:SetScreenText( + self.HUDLogic.BuildResultText( + self.RoundResultText, + self.Team1Score, + self.Team2Score + ), + self:ScreenOfPlayer(Activity.PLAYER_1), + 0, + -1, + true + ); + + if self.RoundEndTimer:IsPastSimMS(self.RoundEndDelay) then + self:TransitionState("ROUND_RESET"); + self:ClearRoundActors(); + self:TransitionState("PREPARE_ROUND"); + self:SpawnRound(); + end + + self:TracePostSpawnBoundary("UPDATE_ACTIVITY_EXIT") + return; + end + + + local team1FactionName = + string.gsub(self.Team1Faction or "TEAM 1", "%.rte$", ""); + + local team2FactionName = + string.gsub(self.Team2Faction or "TEAM 2", "%.rte$", ""); + + local elapsedRoundSeconds = + math.floor(self.RoundElapsedTimer.ElapsedSimTimeMS / 1000); + + local elapsedRoundMinutes = + math.floor(elapsedRoundSeconds / 60); + + local elapsedRoundSecondsPart = + elapsedRoundSeconds % 60; + + local elapsedRoundText = + string.format( + "%02d:%02d", + elapsedRoundMinutes, + elapsedRoundSecondsPart + ); + local totalAliveForPressure = + team1Alive + team2Alive; + + local pressureThresholdForHUD = + self.AICombatPressureNormalMS; + + if totalAliveForPressure <= 3 then + pressureThresholdForHUD = + self.AICombatPressureCriticalMS; + elseif totalAliveForPressure <= 4 then + pressureThresholdForHUD = + self.AICombatPressureLowSurvivorMS; + end + + local pressureElapsedSeconds = + math.floor( + self.AICombatPressureTimer.ElapsedSimTimeMS / 1000 + ); + + local pressureThresholdSeconds = + math.floor( + pressureThresholdForHUD / 1000 + ); + + local spectatorScreen = self:ScreenOfPlayer(Activity.PLAYER_1); + FrameMan:ClearScreenText(spectatorScreen); + self:DrawSpectatorHUD( + self.HUDLogic.BuildBattleHUD( + self.RoundNumber, + elapsedRoundText, + pressureElapsedSeconds, + pressureThresholdSeconds, + team1FactionName, + team1Alive, + self.Team1Score, + team2FactionName, + team2Alive, + self.Team2Score + ) + ); + + + if not self.BattleStarted then + self:TracePostSpawnBoundary("BEFORE_ROUND_RESULT_EVALUATION") + if self.SpawnGraceTimer:IsPastSimMS(self.SpawnGraceDelayMS) then + if team1Alive <= 0 and team2Alive <= 0 then + self:FinishRound(Activity.NOTEAM); + return; + elseif team1Alive <= 0 then + self:FinishRound(self.Team2); + return; + elseif team2Alive <= 0 then + self:FinishRound(self.Team1); + return; + end + end + + if team1Alive > 0 and team2Alive > 0 then + self.BattleStarted = true; + + self:TransitionState("BATTLE"); + self.RoundTimer:Reset(); + print( + "SpectatorArena: BATTLE_STARTED " .. + tostring(self.RoundNumber) .. + " armed" + ); + self:TracePostSpawnBoundary("BATTLE_STARTED", { + team1Alive = team1Alive, + team2Alive = team2Alive, + detail = "COMBAT_ACTIVE" + }, true) + end + + self:TracePostSpawnBoundary("UPDATE_ACTIVITY_EXIT") + return; + end + + -- Navigation and anti-idle pressure run only after the + -- round has definitively entered BATTLE. + -- V7 BRAINHUNT BASELINE: + -- periodic GOTO retargeting disabled for this experiment. + -- Native BRAINHUNT owns normal movement/combat. + + self:TracePostSpawnBoundary("BEFORE_DISTRIBUTED_TARGET_UPDATE") + self:UpdateDistributedMovingTargets( + team1Actors, + team2Actors + ); + self:TracePostSpawnBoundary("AFTER_DISTRIBUTED_TARGET_UPDATE") + + self:TracePostSpawnBoundary("BEFORE_COMBAT_PRESSURE_UPDATE") + self:UpdateCombatPressure( + team1Actors, + team2Actors, + team1Alive, + team2Alive + ); + self:TracePostSpawnBoundary("AFTER_COMBAT_PRESSURE_UPDATE") + if self.RoundTimer:IsPastSimMS(self.MaxRoundDurationMS) then + self:ResolveWatchdog(team1Alive, team2Alive); + return; + end + + + self:TracePostSpawnBoundary("BEFORE_ROUND_RESULT_EVALUATION") + if team1Alive <= 0 and team2Alive > 0 then + self:FinishRound(self.Team2); + + elseif team2Alive <= 0 and team1Alive > 0 then + self:FinishRound(self.Team1); + + elseif team1Alive <= 0 and team2Alive <= 0 then + self:FinishRound(Activity.NOTEAM); + end + self:TracePostSpawnBoundary("AFTER_ROUND_RESULT_EVALUATION") + self:TracePostSpawnBoundary("UPDATE_ACTIVITY_EXIT") +end + + +function SpectatorArena:PauseActivity(pause) +end + + +function SpectatorArena:EndActivity() +end + + +-- Hybrid camera revision: reliable soldier follow with occasional combat POIs. +function SpectatorArena:IsCameraAnchorValid(actor) + return actor ~= nil and MovableMan:IsActor(actor); +end + + +function SpectatorArena:SelectSoldierAnchor(team1Actors, team2Actors) + if #team1Actors == 1 then + return team1Actors[1]; + end + + if #team2Actors == 1 then + return team2Actors[1]; + end + + local _, _, combatActor = self:FindBestCombatFocus(team1Actors, team2Actors); + + if combatActor then + return combatActor; + end + + return team1Actors[1] or team2Actors[1]; +end + + +-- TEMPORARY RAW CAMERA DIAGNOSTIC: +-- choose a useful idle soldier while alternating teams. +function SpectatorArena:SelectRawIdleSoldier(team1Actors, team2Actors) + local selectedTeam = self.CameraRawNextIdleTeam or 1; + + local candidates = selectedTeam == 1 and team1Actors or team2Actors; + local enemies = selectedTeam == 1 and team2Actors or team1Actors; + + -- If the requested team has nobody alive, use the other one. + if #candidates == 0 then + selectedTeam = selectedTeam == 1 and 2 or 1; + candidates = selectedTeam == 1 and team1Actors or team2Actors; + enemies = selectedTeam == 1 and team2Actors or team1Actors; + end + + local bestActor = nil; + local bestDistanceSquared = math.huge; + + for _, actor in ipairs(candidates) do + local nearestDistanceSquared = math.huge; + + for _, enemy in ipairs(enemies) do + local distance = SceneMan:ShortestDistance( + actor.Pos, + enemy.Pos, + SceneMan.SceneWrapsX + ); + + local distanceSquared = + (distance.X * distance.X) + + (distance.Y * distance.Y); + + if distanceSquared < nearestDistanceSquared then + nearestDistanceSquared = distanceSquared; + end + end + + if nearestDistanceSquared < bestDistanceSquared then + bestDistanceSquared = nearestDistanceSquared; + bestActor = actor; + end + end + + -- Alternate the requested team for the next idle phase. + self.CameraRawNextIdleTeam = selectedTeam == 1 and 2 or 1; + self.CameraRawCurrentIdleTeam = selectedTeam; + + return bestActor, selectedTeam; +end + + +function SpectatorArena:FindBestCombatPOI(team1Actors, team2Actors) + local position, score, actor, enemy = self:FindBestCombatFocus(team1Actors, team2Actors); + + if not actor or not enemy or score < 1000 then + return nil, 0, nil, nil; + end + + return position, score, actor, enemy; +end + + +function SpectatorArena:ReturnToSoldierFollow(team1Actors, team2Actors) + local previousMode = self.CameraMode; + self.CameraMode = "CAMERA_SOLDIER"; + if not self:IsCameraAnchorValid(self.CameraFollowActor) then + self.CameraFollowActor = self:SelectSoldierAnchor(team1Actors, team2Actors); + end + self.CameraPOIActor = nil; + self.CameraPOIEnemy = nil; + self.CameraEngagementPosition = nil; + self.CameraEngagementEnemy = nil; + self.CameraFocusScore = 0; + self.CameraModeTimer:Reset(); + self.CameraEvaluationTimer:Reset(); + if previousMode == "CAMERA_EVENT" then + self:EmitCameraTrace("CAMERA_EVENT_RETURN", { + traceID = self.CameraEventTraceID, + shooter = self.CameraLastShot and self.CameraLastShot.shooterID or nil + }); + self.CameraEventTargetIssued = false; + self.CameraEventTraceID = nil; + self.CameraEventObservation = nil; + end +end + + +function SpectatorArena:EnterPOIMode(position, score, actor, enemy) + self.CameraMode = "CAMERA_POI"; + self.CameraFocusPosition = position; + self.CameraFocusScore = score; + self.CameraPOIActor = actor; + self.CameraPOIEnemy = enemy; + self.CameraModeTimer:Reset(); + self.CameraPOICooldownTimer:Reset(); + self.CameraPOICooldownReady = false; +end + + +function SpectatorArena:EmitCameraTrace(event, fields) + if not self.CameraEventTraceEnabled or not self.Telemetry then + return; + end + + fields = fields or {}; + if fields.traceID == nil then + fields.traceID = self.CameraEventTraceID + or (self.CameraLastShot and self.CameraLastShot.traceID) + or nil; + end + fields.round = self.RoundNumber; + fields.simMS = self.RoundTimer and self.RoundTimer.ElapsedSimTimeMS or 0; + self.Telemetry.Emit(event, fields); +end + + +function SpectatorArena:ObserveCameraEventExecution(phase) + if not self.CameraEventObservation or not self.CameraEventPosition then + return; + end + + local screen = self:ScreenOfPlayer(Activity.PLAYER_1); + local cameraOffset = CameraMan:GetOffset(screen); + local scrollTarget = CameraMan:GetScrollTarget(screen); + local targetOffset = self.CameraEventPosition - Vector( + FrameMan.PlayerScreenWidth * 0.5, + FrameMan.PlayerScreenHeight * 0.5 + ); + local distanceToTarget = SceneMan:ShortestDistance( + cameraOffset, + targetOffset, + SceneMan.SceneWrapsX + ).Magnitude; + local previousOffset = self.CameraEventObservation.previousOffset; + local deltaFromPrevious = SceneMan:ShortestDistance( + previousOffset, + cameraOffset, + SceneMan.SceneWrapsX + ).Magnitude; + local distanceFromPreRequest = SceneMan:ShortestDistance( + self.CameraEventObservation.preRequestOffset, + cameraOffset, + SceneMan.SceneWrapsX + ).Magnitude; + local movingTowardTarget = distanceToTarget + < self.CameraEventObservation.preRequestDistance + - self.CameraEventObservationMovementThreshold; + + self.CameraEventObservation.sampleCount = + self.CameraEventObservation.sampleCount + 1; + self:EmitCameraTrace("CAMERA_EVENT_CAMERA_SAMPLE", { + traceID = self.CameraEventObservation.traceID, + phase = phase, + sample = self.CameraEventObservation.sampleCount, + cameraX = cameraOffset.X, + cameraY = cameraOffset.Y, + scrollTargetX = scrollTarget.X, + scrollTargetY = scrollTarget.Y, + targetX = self.CameraEventPosition.X, + targetY = self.CameraEventPosition.Y, + targetOffsetX = targetOffset.X, + targetOffsetY = targetOffset.Y, + distanceToTarget = distanceToTarget, + preRequestDistance = self.CameraEventObservation.preRequestDistance, + distanceFromPreRequest = distanceFromPreRequest, + deltaFromPrevious = deltaFromPrevious + }); + + if not self.CameraEventObservation.movementOnsetObserved + and deltaFromPrevious >= self.CameraEventObservationMovementThreshold + and movingTowardTarget + then + self.CameraEventObservation.movementOnsetObserved = true; + self:EmitCameraTrace("CAMERA_EVENT_MOVEMENT_ONSET", { + traceID = self.CameraEventObservation.traceID, + phase = phase, + cameraX = cameraOffset.X, + cameraY = cameraOffset.Y, + targetX = self.CameraEventPosition.X, + targetY = self.CameraEventPosition.Y, + distanceToTarget = distanceToTarget, + deltaFromPrevious = deltaFromPrevious + }); + end + + if not self.CameraEventObservation.arrivalObserved + and distanceToTarget <= self.CameraEventObservationArrivalTolerance + then + self.CameraEventObservation.arrivalObserved = true; + self:EmitCameraTrace("CAMERA_EVENT_ARRIVED", { + traceID = self.CameraEventObservation.traceID, + phase = phase, + cameraX = cameraOffset.X, + cameraY = cameraOffset.Y, + targetX = self.CameraEventPosition.X, + targetY = self.CameraEventPosition.Y, + distanceToTarget = distanceToTarget, + arrivalTolerance = self.CameraEventObservationArrivalTolerance + }); + end + + self.CameraEventObservation.previousOffset = Vector( + cameraOffset.X, + cameraOffset.Y + ); +end + + +function SpectatorArena:TrackCameraFire() + if not self:IsCameraAnchorValid(self.CameraFollowActor) then + return; + end + + local actorID = self.CameraFollowActor.UniqueID; + local equippedItem = self.CameraFollowActor.EquippedItem; + if not equippedItem or not IsHDFirearm(equippedItem) then + local foregroundArm = self.CameraFollowActor.FGArm; + local heldDevice = foregroundArm and foregroundArm.HeldDevice; + if heldDevice and IsHDFirearm(heldDevice) then + equippedItem = heldDevice; + else + equippedItem = self.CameraFollowActor.EquippedBGItem; + end + end + if not equippedItem or not IsHDFirearm(equippedItem) then + local backgroundArm = self.CameraFollowActor.BGArm; + local heldDevice = backgroundArm and backgroundArm.HeldDevice; + if heldDevice and IsHDFirearm(heldDevice) then + equippedItem = heldDevice; + end + end + local controller = self.CameraFollowActor:GetController(); + local controllerFiring = controller + and controller:IsState(Controller.WEAPON_FIRE) + or false; + local previousControllerFiring = self.CameraControllerFireByActor[actorID]; + local controllerFireStarted = controllerFiring and previousControllerFiring ~= true; + self.CameraControllerFireByActor[actorID] = controllerFiring; + + if not equippedItem or not IsHDFirearm(equippedItem) then + if not controllerFireStarted then + return; + end + + self.CameraTraceSequence = self.CameraTraceSequence + 1; + self.CameraLastShot = { + traceID = self.CameraTraceSequence, + shooterID = actorID, + shooterTeam = self.CameraFollowActor.Team, + originX = self.CameraFollowActor.Pos.X, + originY = self.CameraFollowActor.Pos.Y, + directionX = Vector(1, 0):RadRotate(self.CameraFollowActor:GetAimAngle(true)).X, + directionY = Vector(1, 0):RadRotate(self.CameraFollowActor:GetAimAngle(true)).Y + }; + print("SpectatorArena: CAMERA_FIRE_CONTROLLER shooter=" .. tostring(actorID)); + self:EmitCameraTrace("CAMERA_FIRE_OBSERVED", { + shooter = actorID, + source = "CONTROLLER", + originX = self.CameraLastShot.originX, + originY = self.CameraLastShot.originY + }); + self.CameraRecentFireTimer:Reset(); + return; + end + + local firearm = ToHDFirearm(equippedItem); + local roundsFired = firearm.RoundsFired or 0; + local previousRoundsFired = self.CameraRoundsFiredByActor[actorID]; + local roundsAdvanced = previousRoundsFired ~= nil + and roundsFired > previousRoundsFired; + self.CameraRoundsFiredByActor[actorID] = roundsFired; + + if not firearm.FiredFrame and not roundsAdvanced then + return; + end + + local aimDirection = Vector(1, 0):RadRotate(self.CameraFollowActor:GetAimAngle(true)); + self.CameraTraceSequence = self.CameraTraceSequence + 1; + self.CameraLastShot = { + traceID = self.CameraTraceSequence, + shooterID = actorID, + shooterTeam = self.CameraFollowActor.Team, + originX = firearm.MuzzlePos.X, + originY = firearm.MuzzlePos.Y, + directionX = aimDirection.X, + directionY = aimDirection.Y + }; + print("SpectatorArena: CAMERA_FIRE shooter=" .. tostring(actorID)); + self:EmitCameraTrace("CAMERA_FIRE_OBSERVED", { + shooter = actorID, + source = "NATIVE", + originX = self.CameraLastShot.originX, + originY = self.CameraLastShot.originY + }); + self.CameraRecentFireTimer:Reset(); +end + + +function SpectatorArena:FindEngagementTarget(team1Actors, team2Actors) + if not self.CameraLastShot then + return nil; + end + + local enemies = self.CameraLastShot.shooterTeam == self.Team1 and team2Actors or team1Actors; + local candidates = {}; + local shotOrigin = Vector( + self.CameraLastShot.originX, + self.CameraLastShot.originY + ); + + for _, actor in ipairs(enemies) do + if self:IsCameraAnchorValid(actor) and not actor:IsDead() then + local offset = SceneMan:ShortestDistance( + shotOrigin, + actor.Pos, + SceneMan.SceneWrapsX + ); + table.insert(candidates, { + id = actor.UniqueID, + team = actor.Team, + x = shotOrigin.X + offset.X, + y = shotOrigin.Y + offset.Y, + actor = actor + }); + end + end + + local target = self.CameraEventLogic.SelectEngagementTarget( + self.CameraLastShot, + candidates, + self.CameraEngagementMinimumAimDot, + self.CameraEngagementMinimumDistance, + self.CameraEngagementMaximumRange + ); + + return target and target.actor or nil; +end + + +function SpectatorArena:EnterEngagementMode(enemy) + if not self:IsCameraAnchorValid(self.CameraFollowActor) + or not self:IsCameraAnchorValid(enemy) + or not self.CameraLastShot then + return; + end + + local shooterPosition = self.CameraFollowActor.Pos; + local distance = SceneMan:ShortestDistance( + shooterPosition, + enemy.Pos, + SceneMan.SceneWrapsX + ); + local frame = self.CameraEventLogic.CalculateEngagementFrame( + { x = shooterPosition.X, y = shooterPosition.Y }, + { + x = shooterPosition.X + distance.X, + y = shooterPosition.Y + distance.Y + }, + self.CameraEngagementEnemyBias + ); + + self.CameraMode = "CAMERA_ENGAGEMENT"; + self.CameraEngagementPosition = Vector(frame.x, frame.y); + self.CameraEngagementEnemy = enemy; + self.CameraModeTimer:Reset(); + self.CameraEngagementCooldownTimer:Reset(); + self.CameraEngagementCooldownReady = false; + print("SpectatorArena: CAMERA_ENGAGEMENT"); +end + + +function SpectatorArena:DetectCameraEvent(team1Actors, team2Actors) + local currentActors = {}; + for _, actor in ipairs(team1Actors) do + currentActors[actor.UniqueID] = actor; + end + for _, actor in ipairs(team2Actors) do + currentActors[actor.UniqueID] = actor; + end + + local disappearedActors = {}; + for uniqueID, tracked in pairs(self.CameraTrackedActors) do + local currentActor = currentActors[uniqueID]; + local observedDying = currentActor + and self.CameraEventLogic.HasObservedDying( + tracked.status, + currentActor.Status, + Actor.DYING + ) + or false; + local opposingActor = self.CameraLastShot + and tracked.team ~= self.CameraLastShot.shooterTeam; + + if opposingActor + and not currentActor + and tracked.status ~= Actor.DYING + then + self:EmitCameraTrace("CAMERA_EVENT_REMOVAL_UNCONFIRMED", { + shooter = self.CameraLastShot.shooterID, + victim = uniqueID, + victimTeam = tracked.team, + trackedStatus = tracked.status, + trackedHealth = tracked.health, + trackedWounds = tracked.wounds, + shotAgeMS = self.CameraRecentFireTimer.ElapsedSimTimeMS + }); + end + + if observedDying then + local eventPosition = currentActor.Pos; + local traceID = self.CameraLastShot and self.CameraLastShot.traceID or nil; + local shooterID = self.CameraLastShot and self.CameraLastShot.shooterID or nil; + local shooterTeam = self.CameraLastShot and self.CameraLastShot.shooterTeam or nil; + local x = eventPosition.X; + local y = eventPosition.Y; + + if self.CameraLastShot then + local offset = SceneMan:ShortestDistance( + Vector(self.CameraLastShot.originX, self.CameraLastShot.originY), + eventPosition, + SceneMan.SceneWrapsX + ); + x = self.CameraLastShot.originX + offset.X; + y = self.CameraLastShot.originY + offset.Y; + end + + table.insert(disappearedActors, { + id = uniqueID, + team = tracked.team, + x = x, + y = y, + position = Vector(eventPosition.X, eventPosition.Y), + deathObserved = true, + lifecycle = "DYING", + traceID = traceID + }); + self:EmitCameraTrace("CAMERA_EVENT_DYING_OBSERVED", { + traceID = traceID, + shooter = shooterID, + victim = uniqueID, + victimTeam = tracked.team, + victimX = eventPosition.X, + victimY = eventPosition.Y, + health = currentActor.Health, + prevHealth = currentActor.PrevHealth, + shotAgeMS = self.CameraLastShot + and self.CameraRecentFireTimer.ElapsedSimTimeMS + or nil + }); + end + end + + self.CameraTrackedActors = {}; + for _, actor in ipairs(team1Actors) do + self.CameraTrackedActors[actor.UniqueID] = { + team = actor.Team, + position = Vector(actor.Pos.X, actor.Pos.Y), + status = actor.Status, + health = actor.Health, + prevHealth = actor.PrevHealth, + wounds = actor.WoundCount + }; + end + for _, actor in ipairs(team2Actors) do + self.CameraTrackedActors[actor.UniqueID] = { + team = actor.Team, + position = Vector(actor.Pos.X, actor.Pos.Y), + status = actor.Status, + health = actor.Health, + prevHealth = actor.PrevHealth, + wounds = actor.WoundCount + }; + end + + local notEvaluatedReason = nil; + if not self.CameraLastShot then + notEvaluatedReason = "NO_CORRELATABLE_SHOT"; + elseif not self.CameraEventCooldownReady then + notEvaluatedReason = "COOLDOWN"; + elseif self:IsCameraAnchorValid(self.CameraFollowActor) + and self.CameraFollowActor.UniqueID ~= self.CameraLastShot.shooterID + then + notEvaluatedReason = "SHOOTER_MISMATCH"; + end + + if notEvaluatedReason then + for _, candidate in ipairs(disappearedActors) do + self:EmitCameraTrace("CAMERA_EVENT_ATTRIBUTION_NOT_EVALUATED", { + traceID = candidate.traceID, + shooter = self.CameraLastShot and self.CameraLastShot.shooterID or nil, + victim = candidate.id, + victimTeam = candidate.team, + candidateCount = #disappearedActors, + reason = notEvaluatedReason + }); + end + return nil; + end + + local shot = { + traceID = self.CameraLastShot.traceID, + ageMS = self.CameraRecentFireTimer.ElapsedSimTimeMS, + shooterTeam = self.CameraLastShot.shooterTeam, + originX = self.CameraLastShot.originX, + originY = self.CameraLastShot.originY, + directionX = self.CameraLastShot.directionX, + directionY = self.CameraLastShot.directionY + }; + + local selected, rejectionReason = self.CameraEventLogic.SelectEventCandidate( + shot, + disappearedActors, + self.CameraHandledVictims, + self.CameraRecentFireWindowMS, + self.CameraEventMinimumAimDot, + self.CameraEventMinimumDistance, + self.CameraEventMaximumRange + ); + + if #disappearedActors > 0 then + for _, candidate in ipairs(disappearedActors) do + local accepted = selected and selected.id == candidate.id; + self:EmitCameraTrace( + accepted and "CAMERA_EVENT_ATTRIBUTION_ACCEPTED" + or "CAMERA_EVENT_ATTRIBUTION_REJECTED", + { + traceID = shot.traceID, + shooter = self.CameraLastShot.shooterID, + victim = candidate.id, + candidateCount = #disappearedActors, + shotAgeMS = shot.ageMS, + cooldownReady = self.CameraEventCooldownReady, + selectedVictim = selected and selected.id or nil, + reason = accepted + and "ACCEPTED" + or (candidate.attributionReason or rejectionReason or "NO_CANDIDATE") + } + ); + end + end + + return selected; +end + + +function SpectatorArena:EnterEventMode(event) + self.CameraMode = "CAMERA_EVENT"; + self.CameraEventTraceID = event.traceID + or (self.CameraLastShot and self.CameraLastShot.traceID) + or nil; + self.CameraEventPosition = event.position; + self.CameraFocusPosition = event.position; + self.CameraHandledVictims[event.id] = true; + self.CameraEventTargetIssued = false; + local screen = self:ScreenOfPlayer(Activity.PLAYER_1); + local preRequestOffset = CameraMan:GetOffset(screen); + local targetOffset = event.position - Vector( + FrameMan.PlayerScreenWidth * 0.5, + FrameMan.PlayerScreenHeight * 0.5 + ); + local preRequestDistance = SceneMan:ShortestDistance( + preRequestOffset, + targetOffset, + SceneMan.SceneWrapsX + ).Magnitude; + self.CameraEventObservation = { + traceID = self.CameraEventTraceID, + preRequestOffset = Vector(preRequestOffset.X, preRequestOffset.Y), + previousOffset = Vector(preRequestOffset.X, preRequestOffset.Y), + preRequestDistance = preRequestDistance, + sampleCount = 0, + movementOnsetObserved = false, + arrivalObserved = false + }; + self.CameraModeTimer:Reset(); + self.CameraEventCooldownTimer:Reset(); + self.CameraEventCooldownReady = false; + print("SpectatorArena: CAMERA_EVENT"); + self:EmitCameraTrace("CAMERA_EVENT_REQUEST", { + traceID = self.CameraEventTraceID, + shooter = self.CameraLastShot and self.CameraLastShot.shooterID or nil, + victim = event.id, + victimTeam = event.team, + victimX = event.position.X, + victimY = event.position.Y, + holdMS = self.CameraEventHoldMS + }); +end + + +function SpectatorArena:ResetCameraDirector() + self.CameraMode = "CAMERA_CENTER"; + self.CameraFollowActor = nil; + self.CameraPOIActor = nil; + self.CameraPOIEnemy = nil; + self.CameraEventPosition = nil; + self.CameraEventTraceID = nil; + self.CameraEngagementPosition = nil; + self.CameraEngagementEnemy = nil; + self.CameraEventTargetIssued = false; + self.CameraEventObservation = nil; + self.CameraLastShot = nil; + self.CameraRoundsFiredByActor = {}; + self.CameraControllerFireByActor = {}; + self.CameraTrackedActors = {}; + self.CameraHandledVictims = {}; + self.CameraHasFocus = false; + self.CameraFocusPosition = self.CameraPos; + self.CameraFocusScore = 0; + self.CameraEvaluationTimer:Reset(); + self.CameraModeTimer:Reset(); + self.CameraPOICooldownTimer:Reset(); + self.CameraRecentFireTimer:Reset(); + self.CameraEventCooldownTimer:Reset(); + self.CameraEngagementCooldownTimer:Reset(); + self.CameraPOICooldownReady = true; + self.CameraEventCooldownReady = true; + self.CameraEngagementCooldownReady = true; +end + + +function SpectatorArena:UpdateCameraDirector(team1Actors, team2Actors) + if self.State ~= "BATTLE" then + if self.RoundOver and self:IsCameraAnchorValid(self.CameraFollowActor) then + self:SetObservationTarget(self.CameraFollowActor.Pos, Activity.PLAYER_1); + elseif self.RoundOver and self.CameraFocusPosition then + self:SetObservationTarget(self.CameraFocusPosition, Activity.PLAYER_1); + else + if self.CameraMode ~= "CAMERA_CENTER" then + self:ResetCameraDirector(); + end + self:SetObservationTarget(self.CameraPos, Activity.PLAYER_1); + end + return; + end + + local allTeam1Actors = team1Actors; + local allTeam2Actors = team2Actors; + local livingTeam1Actors = {}; + local livingTeam2Actors = {}; + + for _, actor in ipairs(allTeam1Actors) do + if not actor:IsDead() then + table.insert(livingTeam1Actors, actor); + end + end + for _, actor in ipairs(allTeam2Actors) do + if not actor:IsDead() then + table.insert(livingTeam2Actors, actor); + end + end + + team1Actors = livingTeam1Actors; + team2Actors = livingTeam2Actors; + + -- TEMPORARY RAW CAMERA DIAGNOSTIC. + -- No holds, no cooldowns, no event timing: + -- show exactly what the current selector prefers. + if self.CameraRawDiagnosticMode then + if self.CameraEventLogic.HasLastSurvivorPriority(#team1Actors, #team2Actors) then + local survivor = self.CameraEventLogic.SelectLastSurvivor(team1Actors, team2Actors); + + if self:IsCameraAnchorValid(survivor) then + local survivorID = survivor.UniqueID; + + if self.CameraRawLastTargetType ~= "SURVIVOR" + or self.CameraRawLastActorID ~= survivorID then + + print("SpectatorArena: CAMERA_RAW SURVIVOR actor=" .. tostring(survivorID)); + + self.CameraRawLastTargetType = "SURVIVOR"; + self.CameraRawLastActorID = survivorID; + self.CameraRawLastEnemyID = nil; + end + + self.CameraFollowActor = survivor; + self:SetObservationTarget(survivor.Pos, Activity.PLAYER_1); + end + + return; + end + + local position, score, actor, enemy = + self:FindBestCombatPOI(team1Actors, team2Actors); + + if position and actor and enemy then + local actorID = actor.UniqueID; + local enemyID = enemy.UniqueID; + + if self.CameraRawLastTargetType ~= "POI" + or self.CameraRawLastActorID ~= actorID + or self.CameraRawLastEnemyID ~= enemyID then + + print( + "SpectatorArena: CAMERA_RAW POI actor=" + .. tostring(actorID) + .. " enemy=" + .. tostring(enemyID) + .. " score=" + .. tostring(math.floor(score)) + ); + + self.CameraRawLastTargetType = "POI"; + self.CameraRawLastActorID = actorID; + self.CameraRawLastEnemyID = enemyID; + end + + self:SetObservationTarget(position, Activity.PLAYER_1); + return; + end + + -- Every time active combat ends and we return to idle observation, + -- deliberately choose the opposite team from the previous idle phase. + if self.CameraRawLastTargetType ~= "SOLDIER" + or not self:IsCameraAnchorValid(self.CameraFollowActor) then + + local idleActor, idleTeam = + self:SelectRawIdleSoldier(team1Actors, team2Actors); + + self.CameraFollowActor = idleActor; + self.CameraRawCurrentIdleTeam = idleTeam; + end + + if self:IsCameraAnchorValid(self.CameraFollowActor) then + local actorID = self.CameraFollowActor.UniqueID; + + if self.CameraRawLastTargetType ~= "SOLDIER" + or self.CameraRawLastActorID ~= actorID then + + print( + "SpectatorArena: CAMERA_RAW SOLDIER team=" + .. tostring(self.CameraRawCurrentIdleTeam) + .. " actor=" + .. tostring(actorID) + ); + + self.CameraRawLastTargetType = "SOLDIER"; + self.CameraRawLastActorID = actorID; + self.CameraRawLastEnemyID = nil; + end + + self:SetObservationTarget( + self.CameraFollowActor.Pos, + Activity.PLAYER_1 + ); + else + if self.CameraRawLastTargetType ~= "CENTER" then + print("SpectatorArena: CAMERA_RAW CENTER"); + + self.CameraRawLastTargetType = "CENTER"; + self.CameraRawLastActorID = nil; + self.CameraRawLastEnemyID = nil; + end + + self:SetObservationTarget( + self.CameraPos, + Activity.PLAYER_1 + ); + end + + return; + end + + if not self.CameraPOICooldownReady + and self.CameraPOICooldownTimer:IsPastSimMS(self.CameraPOICooldownMS) then + self.CameraPOICooldownReady = true; + end + + if not self.CameraEventCooldownReady + and self.CameraEventCooldownTimer:IsPastSimMS(self.CameraEventCooldownMS) then + self.CameraEventCooldownReady = true; + end + + if not self.CameraEngagementCooldownReady + and self.CameraEngagementCooldownTimer:IsPastSimMS(self.CameraEngagementCooldownMS) then + self.CameraEngagementCooldownReady = true; + end + + if self.CameraEventLogic.HasLastSurvivorPriority(#team1Actors, #team2Actors) then + self.CameraMode = "CAMERA_SOLDIER"; + self.CameraFollowActor = self.CameraEventLogic.SelectLastSurvivor(team1Actors, team2Actors); + self.CameraPOIActor = nil; + self.CameraPOIEnemy = nil; + self.CameraEventPosition = nil; + self.CameraEngagementPosition = nil; + self.CameraEngagementEnemy = nil; + if self:IsCameraAnchorValid(self.CameraFollowActor) then + self:SetObservationTarget(self.CameraFollowActor.Pos, Activity.PLAYER_1); + end + self:DetectCameraEvent(allTeam1Actors, allTeam2Actors); + return; + end + + self:TrackCameraFire(); + local cameraEvent = self:DetectCameraEvent(allTeam1Actors, allTeam2Actors); + if cameraEvent then + self:EnterEventMode(cameraEvent); + elseif self.CameraMode ~= "CAMERA_EVENT" + and self.CameraEngagementCooldownReady + and self.CameraRecentFireTimer.ElapsedSimTimeMS <= self.CameraRecentFireWindowMS then + local engagementEnemy = self:FindEngagementTarget(team1Actors, team2Actors); + if engagementEnemy then + self:EnterEngagementMode(engagementEnemy); + end + end + + if self.CameraMode == "CAMERA_EVENT" then + if not self.CameraEventPosition + or self.CameraModeTimer:IsPastSimMS(self.CameraEventHoldMS) then + self:ObserveCameraEventExecution("HOLD"); + self:EmitCameraTrace("CAMERA_EVENT_HOLD_COMPLETE", { + traceID = self.CameraEventTraceID, + shooter = self.CameraLastShot and self.CameraLastShot.shooterID or nil, + holdElapsedMS = self.CameraModeTimer.ElapsedSimTimeMS + }); + self.CameraEventPosition = nil; + self:ReturnToSoldierFollow(team1Actors, team2Actors); + else + if not self.CameraEventTargetIssued then + self.CameraEventTargetIssued = true; + self:EmitCameraTrace("CAMERA_EVENT_TARGET_ISSUED", { + traceID = self.CameraEventTraceID, + shooter = self.CameraLastShot and self.CameraLastShot.shooterID or nil, + targetX = self.CameraEventPosition.X, + targetY = self.CameraEventPosition.Y + }); + end + self:SetObservationTarget(self.CameraEventPosition, Activity.PLAYER_1); + self:ObserveCameraEventExecution("TARGET"); + return; + end + end + + if self.CameraMode == "CAMERA_ENGAGEMENT" then + local engagementValid = self.CameraEngagementPosition + and self:IsCameraAnchorValid(self.CameraEngagementEnemy) + and not self.CameraEngagementEnemy:IsDead(); + + if not engagementValid + or self.CameraModeTimer:IsPastSimMS(self.CameraEngagementHoldMS) then + self.CameraEngagementPosition = nil; + self.CameraEngagementEnemy = nil; + self:ReturnToSoldierFollow(team1Actors, team2Actors); + else + self:SetObservationTarget(self.CameraEngagementPosition, Activity.PLAYER_1); + return; + end + end + + if self.CameraMode == "CAMERA_POI" then + local poiActorsValid = self:IsCameraAnchorValid(self.CameraPOIActor) + and self:IsCameraAnchorValid(self.CameraPOIEnemy); + local poiDistanceValid = false; + + if poiActorsValid then + local distance = SceneMan:ShortestDistance( + self.CameraPOIActor.Pos, + self.CameraPOIEnemy.Pos, + SceneMan.SceneWrapsX + ); + poiDistanceValid = (distance.X * distance.X) + (distance.Y * distance.Y) <= 260 * 260; + end + + if not poiActorsValid + or not poiDistanceValid + or self.CameraModeTimer:IsPastSimMS(self.CameraPOIMaximumHoldMS) then + self:ReturnToSoldierFollow(team1Actors, team2Actors); + else + self:SetObservationTarget(self.CameraFocusPosition, Activity.PLAYER_1); + return; + end + end + + if self.CameraMode ~= "CAMERA_SOLDIER" + or not self:IsCameraAnchorValid(self.CameraFollowActor) then + self:ReturnToSoldierFollow(team1Actors, team2Actors); + end + + if self:IsCameraAnchorValid(self.CameraFollowActor) then + self:SetObservationTarget(self.CameraFollowActor.Pos, Activity.PLAYER_1); + else + self:SetObservationTarget(self.CameraPos, Activity.PLAYER_1); + end + + if self.CameraEvaluationTimer:IsPastSimMS(self.CameraEvaluationIntervalMS) + and self.CameraPOICooldownReady + and self.CameraModeTimer:IsPastSimMS(self.CameraSoldierMinimumHoldMS) then + self.CameraEvaluationTimer:Reset(); + local position, score, actor, enemy = self:FindBestCombatPOI(team1Actors, team2Actors); + + if position and score >= self.CameraFocusScore * self.CameraPOISwitchThreshold then + self:EnterPOIMode(position, score, actor, enemy); + end + end +end diff --git a/Data/Base.rte/Activities/SpectatorCameraEventLogic.lua b/Data/Base.rte/Activities/SpectatorCameraEventLogic.lua new file mode 100644 index 0000000000..d99411ceec --- /dev/null +++ b/Data/Base.rte/Activities/SpectatorCameraEventLogic.lua @@ -0,0 +1,129 @@ +local CameraEventLogic = {} + +function CameraEventLogic.HasLastSurvivorPriority(team1Count, team2Count) + return team1Count == 1 or team2Count == 1 +end + +function CameraEventLogic.SelectLastSurvivor(team1Actors, team2Actors) + if #team1Actors == 1 then + return team1Actors[1] + end + if #team2Actors == 1 then + return team2Actors[1] + end + return nil +end + +function CameraEventLogic.HasObservedDeath(previouslyDead, currentlyPresent, currentlyDead) + return (currentlyPresent and not previouslyDead and currentlyDead) + or (not currentlyPresent and previouslyDead) +end + +function CameraEventLogic.HasObservedDying(previousStatus, currentStatus, dyingStatus) + return previousStatus ~= dyingStatus and currentStatus == dyingStatus +end + +function CameraEventLogic.SelectEventCandidate(shot, disappearedActors, handledVictims, recentFireWindowMS, minimumAimDot, minimumRange, maximumRange) + if not shot or shot.ageMS < 0 or shot.ageMS > recentFireWindowMS then + return nil, "STALE_SHOT" + end + + local directionLength = math.sqrt((shot.directionX * shot.directionX) + (shot.directionY * shot.directionY)) + if directionLength <= 0 then + return nil, "NO_CANDIDATE" + end + + local plausible = nil + local plausibleCount = 0 + local rejectionReason = "NO_CANDIDATE" + + for _, actor in ipairs(disappearedActors) do + if actor.team == shot.shooterTeam then + actor.attributionReason = "SHOOTER_MISMATCH" + rejectionReason = "SHOOTER_MISMATCH" + elseif not actor.deathObserved then + actor.attributionReason = "NO_CANDIDATE" + rejectionReason = "NO_CANDIDATE" + elseif handledVictims[actor.id] then + actor.attributionReason = "HANDLED_VICTIM" + rejectionReason = "HANDLED_VICTIM" + else + local offsetX = actor.x - shot.originX + local offsetY = actor.y - shot.originY + local distance = math.sqrt((offsetX * offsetX) + (offsetY * offsetY)) + + if distance < minimumRange or distance > maximumRange then + actor.attributionReason = "DISTANCE" + rejectionReason = "DISTANCE" + else + local aimDot = ((offsetX * shot.directionX) + (offsetY * shot.directionY)) / (distance * directionLength) + if aimDot >= minimumAimDot then + plausible = actor + plausibleCount = plausibleCount + 1 + actor.attributionReason = nil + else + actor.attributionReason = "AIM_CONE" + rejectionReason = "AIM_CONE" + end + end + end + end + + if plausibleCount == 1 then + return plausible, nil + elseif plausibleCount > 1 then + for _, actor in ipairs(disappearedActors) do + if actor.attributionReason == nil then + actor.attributionReason = "MULTIPLE_VICTIMS" + end + end + return nil, "MULTIPLE_VICTIMS" + end + + return nil, rejectionReason +end + +function CameraEventLogic.SelectEngagementTarget(shot, actors, minimumAimDot, minimumRange, maximumRange) + if not shot then + return nil + end + + local directionLength = math.sqrt((shot.directionX * shot.directionX) + (shot.directionY * shot.directionY)) + if directionLength <= 0 then + return nil + end + + local selected = nil + local selectedDistance = math.huge + + for _, actor in ipairs(actors) do + if actor.team ~= shot.shooterTeam then + local offsetX = actor.x - shot.originX + local offsetY = actor.y - shot.originY + local distance = math.sqrt((offsetX * offsetX) + (offsetY * offsetY)) + + if distance >= minimumRange and distance <= maximumRange then + local aimDot = ((offsetX * shot.directionX) + (offsetY * shot.directionY)) / (distance * directionLength) + if aimDot >= minimumAimDot and distance < selectedDistance then + selected = actor + selectedDistance = distance + end + end + end + end + + return selected +end + +function CameraEventLogic.CalculateEngagementFrame(shooter, enemy, enemyBias) + local bias = math.max(0, math.min(1, enemyBias or 0.5)) + local deltaX = enemy.x - shooter.x + local deltaY = enemy.y - shooter.y + + return { + x = shooter.x + (deltaX * bias), + y = shooter.y + (deltaY * bias) + } +end + +return CameraEventLogic diff --git a/Data/Base.rte/Activities/SpectatorFireSensorBisectFixture.lua b/Data/Base.rte/Activities/SpectatorFireSensorBisectFixture.lua new file mode 100644 index 0000000000..1c54596f01 --- /dev/null +++ b/Data/Base.rte/Activities/SpectatorFireSensorBisectFixture.lua @@ -0,0 +1,155 @@ +SpectatorFireSensorBisectFixture = {} + +-- Change only this harness constant between native runs. Do not ship this +-- activity as gameplay behavior; it exists to isolate the firearm boundary. +local VARIANT = "B1_ACTOR_ONLY" + +local VARIANT_ORDER = { + B1_ACTOR_ONLY = 1, + B2_ACTOR_INSERTED = 2, + B3_SHOOTER_TARGET = 3, + B4_DETACHED_FIREARM = 4, + B5A_PRE_INSERTION = 5, + B5B_POST_INSERTION = 5, + B6_EQUIPPED_STABLE = 6, + B7_CONTROLLED_FIRE = 7 +} + +local function itemName(item) + return item and item.PresetName or "NONE" +end + +function SpectatorFireSensorBisectFixture:Valid(actor) + return actor ~= nil and MovableMan:IsActor(actor) +end + +function SpectatorFireSensorBisectFixture:Checkpoint(stage, result, extra, snapshot) + local weapon = self.Shooter and self.Shooter.EquippedItem + local fields = { + variant = VARIANT, + stage = stage, + updateCount = self.UpdateCount, + simTimeMS = self.Timer.ElapsedSimTimeMS, + shooterValid = self:Valid(self.Shooter), + targetValid = self:Valid(self.Target), + weaponCreated = self.Weapon ~= nil, + weaponAttached = self.Weapon ~= nil and self.Shooter ~= nil and weapon == self.Weapon, + foregroundPreset = itemName(weapon), + lastPhase = stage, + result = result or "PENDING" + } + if extra then + for key, value in pairs(extra) do + fields[key] = value + end + end + self.LastPhase = stage + self.LastResult = fields.result + self.Telemetry.Emit("FIXTURE_BISECT", fields) + if snapshot ~= false then + self.Telemetry.Snapshot() + end +end + +function SpectatorFireSensorBisectFixture:StartActivity() + self.Telemetry = require("Activities/SpectatorTelemetry") + self.Telemetry.ConfigureRuntime("SPECTATOR_FIRE_BISECT_" .. VARIANT .. "_LOG.txt") + self.Timer = Timer() + self.UpdateCount = 0 + self.LastPhase = "STARTED" + self.LastResult = "PENDING" + local level = VARIANT_ORDER[VARIANT] + if not level then + self:Checkpoint("CONFIG_ERROR", "ERROR", { reason = "unknown_variant" }) + self.Completed = true + return + end + self:Checkpoint("STARTED", "PENDING") + + self.Shooter = CreateAHuman("Brain Robot", "Base.rte") + self:Checkpoint("SHOOTER_CREATED", self.Shooter and "PENDING" or "ERROR", { + actorCreated = self.Shooter ~= nil + }) + if level >= 3 then + self.Target = CreateAHuman("Brain Robot", "Base.rte") + self:Checkpoint("TARGET_CREATED", self.Target and "PENDING" or "ERROR", { + actorCreated = self.Target ~= nil + }) + end + if level >= 4 then + self.Weapon = CreateHDFirearm("SMG", "Base.rte") + self:Checkpoint("WEAPON_CREATED", self.Weapon and "PENDING" or "ERROR", { + weaponPreset = itemName(self.Weapon), + weaponModule = self.Weapon and self.Weapon.ModuleID or -1 + }) + end + if level >= 2 and self.Shooter then + self.Shooter.Team = Activity.TEAM_1 + self.Shooter.Pos = SceneMan:MovePointToGround(Vector(1200, 0), 0, 3) + end + if level >= 3 and self.Target then + self.Target.Team = Activity.TEAM_2 + self.Target.Pos = SceneMan:MovePointToGround(Vector(1450, 0), 0, 3) + end + local preInsertionHandoff = VARIANT == "B5A_PRE_INSERTION" or VARIANT == "B6_EQUIPPED_STABLE" + local function handoff() + self:Checkpoint(VARIANT .. "_BEFORE_ADD_INVENTORY", "PENDING") + self.Shooter:AddInventoryItem(self.Weapon) + self:Checkpoint(VARIANT .. "_AFTER_ADD_INVENTORY", "PENDING", { + inventorySize = self.Shooter.InventorySize + }) + end + if preInsertionHandoff and self.Shooter and self.Weapon then + handoff() + end + if level >= 2 and self.Shooter then + self:Checkpoint(VARIANT .. "_BEFORE_ADD_ACTOR", "PENDING") + MovableMan:AddActor(self.Shooter) + self:Checkpoint(VARIANT .. "_AFTER_ADD_ACTOR", "PENDING", { + shooterMOID = self.Shooter.MOID, + shooterUniqueID = self.Shooter.UniqueID, + shooterX = self.Shooter.Pos.X, + shooterY = self.Shooter.Pos.Y + }) + end + if level >= 3 and self.Target then + MovableMan:AddActor(self.Target) + self:Checkpoint("TARGET_INSERTED", "PENDING", { + targetMOID = self.Target.MOID, + targetUniqueID = self.Target.UniqueID + }) + end + if level >= 5 and not preInsertionHandoff and self.Shooter and self.Weapon then + handoff() + end + self:Checkpoint("SETUP_COMPLETE", "PENDING") +end + +function SpectatorFireSensorBisectFixture:UpdateActivity() + if self.Completed then + return + end + self.UpdateCount = self.UpdateCount + 1 + if self.UpdateCount == 1 or self.UpdateCount == 2 or self.UpdateCount == 10 or self.UpdateCount == 60 then + self:Checkpoint("UPDATE_" .. self.UpdateCount, "PENDING", nil, self.UpdateCount ~= 10) + end + if self.UpdateCount == 60 then + local level = VARIANT_ORDER[VARIANT] + if level < 7 then + self:Checkpoint("PASS", "PASS") + self.Completed = true + end + elseif VARIANT == "B7_CONTROLLED_FIRE" and self.UpdateCount == 61 then + self:Checkpoint("FIRE_REQUESTED", "PENDING") + self.Shooter:GetController():SetState(Controller.WEAPON_FIRE, true) + elseif VARIANT == "B7_CONTROLLED_FIRE" and self.UpdateCount == 62 then + local weapon = self.Shooter and self.Shooter.EquippedItem + self:Checkpoint("FIRE_OBSERVED", "PASS", { + firedFrame = weapon and IsHDFirearm(weapon) and ToHDFirearm(weapon).FiredFrame or false, + roundsFired = weapon and IsHDFirearm(weapon) and ToHDFirearm(weapon).RoundsFired or 0, + activated = weapon and weapon:IsActivated() or false + }) + self.Shooter:GetController():SetState(Controller.WEAPON_FIRE, false) + self.Completed = true + end +end diff --git a/Data/Base.rte/Activities/SpectatorFireSensorDifferential.lua b/Data/Base.rte/Activities/SpectatorFireSensorDifferential.lua new file mode 100644 index 0000000000..7282e74b74 --- /dev/null +++ b/Data/Base.rte/Activities/SpectatorFireSensorDifferential.lua @@ -0,0 +1,195 @@ +SpectatorFireSensorDifferential = {} + +local function emit(stage, fields) + fields = fields or {} + fields.stage = stage + local parts = { "FIXTURE_F3" } + for key, value in pairs(fields) do + parts[#parts + 1] = key .. "=" .. tostring(value) + end + table.sort(parts) + print(table.concat(parts, " ")) +end + +local function itemName(item) + return item and item.PresetName or "NONE" +end + +function SpectatorFireSensorDifferential:Persist(stage, fields, snapshot) + fields = fields or {} + fields.variant = "F3_DURABLE_FIRE_LATCH" + fields.simTimeMS = self.Timer.ElapsedSimTimeMS + fields.updateCount = self.UpdateCount + emit(stage, fields) + self.Telemetry.Emit("FIXTURE_F3", fields) + if snapshot then + self.Telemetry.Snapshot() + end +end + +function SpectatorFireSensorDifferential:StartActivity() + self.Telemetry = require("Activities/SpectatorTelemetry") + self.Telemetry.ConfigureRuntime("SPECTATOR_FIRE_F3_LOG.txt") + self.Timer = Timer() + self.UpdateCount = 0 + self:Persist("STARTED", {}, true) + + self.Shooter = CreateAHuman("Brain Robot", "Base.rte") + self.Target = CreateAHuman("Brain Robot", "Base.rte") + self.Weapon = CreateHDFirearm("SMG", "Base.rte") + self:Persist("SETUP", { + actorCreated = self.Shooter ~= nil and self.Target ~= nil, + firearmCreated = self.Weapon ~= nil, + firearmPreset = itemName(self.Weapon), + firearmModule = self.Weapon and self.Weapon.ModuleID or -1 + }, true) + if not self.Shooter or not self.Target or not self.Weapon then + self:Persist("COMPLETE", { result = "FAIL", reason = "setup" }, true) + self.Completed = true + return + end + + self.Shooter.Team = Activity.TEAM_1 + self.Target.Team = Activity.TEAM_2 + self.Shooter.Pos = SceneMan:MovePointToGround(Vector(1200, 0), 0, 3) + self.Target.Pos = SceneMan:MovePointToGround(Vector(1450, 0), 0, 3) + self.Shooter:AddInventoryItem(self.Weapon) + self:Persist("INVENTORY_HANDOFF", { + inventorySize = self.Shooter.InventorySize, + foregroundPreset = itemName(self.Shooter.EquippedItem), + weaponValid = self.Shooter.EquippedItem ~= nil + and IsHDFirearm(self.Shooter.EquippedItem) + }, true) + + MovableMan:AddActor(self.Shooter) + MovableMan:AddActor(self.Target) + self:Persist("ACTORS_INSERTED", { + shooterValid = MovableMan:IsActor(self.Shooter), + targetValid = MovableMan:IsActor(self.Target), + foregroundPreset = itemName(self.Shooter.EquippedItem) + }, true) + + self.AIController = require("Activities/SpectatorAIController").Create({ + mode = "SHADOW" + }) + self.AIController:BeginRound(1, 1) + self.AIController:RegisterActor(self.Shooter.UniqueID, self.Shooter.Team, 1) + + local firearm = ToHDFirearm(self.Shooter.EquippedItem) + self.AmmoBefore = firearm.RoundInMagCount + self.LastAmmo = self.AmmoBefore + self.ShotCount = 0 + self.FalseFireIncrements = 0 + self.ReleaseUpdate = nil + self:Persist("BEFORE_ACTIVATION", { + ammo = self.AmmoBefore, + roundsFired = firearm.RoundsFired, + firedFrame = firearm.FiredFrame, + fireEventCount = 0, + weaponValid = true, + isActivated = self.Shooter.EquippedItem:IsActivated() + }, true) + self.Shooter:GetController():SetState(Controller.WEAPON_FIRE, true) + self:Persist("ACTIVATION_REQUESTED", {}, true) +end + +function SpectatorFireSensorDifferential:UpdateActivity() + if self.Completed then + return + end + + self.UpdateCount = self.UpdateCount + 1 + if not self.ReleaseUpdate then + self.Shooter:GetController():SetState(Controller.WEAPON_FIRE, true) + end + + local weapon = self.Shooter and self.Shooter.EquippedItem + local firearm = weapon and IsHDFirearm(weapon) and ToHDFirearm(weapon) or nil + local firedFrame = firearm and firearm.FiredFrame == true or false + local roundsFired = firearm and firearm.RoundsFired or 0 + local ammo = firearm and firearm.RoundInMagCount or -1 + self.AIController:RecordFireSensorSample( + self.Shooter.UniqueID, + self.Timer.ElapsedSimTimeMS, + firearm and firearm.ID or nil, + firearm and firearm.RootID or nil, + firedFrame, + roundsFired, + nil + ) + local fireState = self.AIController:GetFireSensorState(self.Shooter.UniqueID) + local fireEventCount = fireState and fireState.FireEventCount or 0 + if fireEventCount ~= self.ShotCount and not firedFrame then + self.FalseFireIncrements = self.FalseFireIncrements + 1 + end + + local state = { + foregroundPreset = itemName(weapon), + weaponValid = weapon ~= nil, + ammo = ammo, + roundsFired = roundsFired, + firedFrame = firedFrame, + fireEventCount = fireEventCount, + fireFrameCount = fireState and fireState.FireFrameCount or 0, + roundsDischargedObserved = fireState and fireState.RoundsDischargedObserved or 0, + lastFireTimeMS = fireState and fireState.LastFireTimeMS or nil, + firedRecently = self.AIController:FiredRecently( + self.Shooter.UniqueID, + self.Timer.ElapsedSimTimeMS, + 1000 + ) + } + self:Persist("SAMPLE_" .. self.UpdateCount, state, false) + + if ammo < self.LastAmmo then + self.ShotCount = self.ShotCount + 1 + self:Persist("SHOT_" .. self.ShotCount, { + ammoBefore = self.LastAmmo, + ammoAfter = ammo, + firedFrame = firedFrame, + roundsFired = roundsFired, + fireEventCount = fireEventCount, + fireFrameCount = state.fireFrameCount, + lastFireTimeMS = state.lastFireTimeMS + }, true) + if self.ShotCount >= 10 then + self.ReleaseUpdate = self.UpdateCount + self.Shooter:GetController():SetState(Controller.WEAPON_FIRE, false) + end + end + self.LastAmmo = ammo + + if self.ReleaseUpdate and self.UpdateCount >= self.ReleaseUpdate + 70 then + local expired = not self.AIController:FiredRecently( + self.Shooter.UniqueID, + self.Timer.ElapsedSimTimeMS, + 1000 + ) + local pass = self.ShotCount == 10 + and state.fireFrameCount == 10 + and state.roundsDischargedObserved == 10 + and fireEventCount == 10 + and self.FalseFireIncrements == 0 + and state.lastFireTimeMS ~= nil + and expired + self:Persist("COMPLETE", { + result = pass and "PASS" or "FAIL", + confirmedShots = self.ShotCount, + fireFrameCount = state.fireFrameCount, + roundsDischargedObserved = state.roundsDischargedObserved, + fireEventCount = fireEventCount, + lastFireTimeMS = state.lastFireTimeMS, + firedRecentlyAfterExpiry = not expired, + falseFireIncrements = self.FalseFireIncrements + }, true) + self.Completed = true + elseif self.UpdateCount >= 180 then + self.Shooter:GetController():SetState(Controller.WEAPON_FIRE, false) + self:Persist("COMPLETE", { + result = "FAIL", + reason = "timeout", + confirmedShots = self.ShotCount + }, true) + self.Completed = true + end +end diff --git a/Data/Base.rte/Activities/SpectatorFireSensorFixture.lua b/Data/Base.rte/Activities/SpectatorFireSensorFixture.lua new file mode 100644 index 0000000000..dadbb3da97 --- /dev/null +++ b/Data/Base.rte/Activities/SpectatorFireSensorFixture.lua @@ -0,0 +1,96 @@ +SpectatorFireSensorFixture = {} + +local function emit(fields) + local parts = { "FIXTURE_FIRE_SENSOR" } + for key, value in pairs(fields) do + parts[#parts + 1] = key .. "=" .. tostring(value) + end + table.sort(parts) + print(table.concat(parts, " ")) +end + +local function itemName(item) + return item and item.PresetName or "NONE" +end + +function SpectatorFireSensorFixture:Checkpoint(stage, fields) + fields = fields or {} + fields.stage = stage + self.Telemetry.Emit("FIXTURE_PHASE", fields) + self.Telemetry.Snapshot() +end + +function SpectatorFireSensorFixture:Sample(stage) + local weapon = self.Shooter and self.Shooter.EquippedItem + local targetHealth = self.Target and self.Target.Health or -1 + emit({ + stage = stage, + shooter = self.Shooter and self.Shooter.UniqueID or -1, + shooterValid = self.Shooter and MovableMan:IsActor(self.Shooter), + inventory = self.Shooter and self.Shooter.InventorySize or -1, + foreground = itemName(weapon), + firearm = weapon and IsHDFirearm(weapon), + firedFrame = weapon and IsHDFirearm(weapon) and ToHDFirearm(weapon).FiredFrame or false, + roundsFired = weapon and IsHDFirearm(weapon) and ToHDFirearm(weapon).RoundsFired or 0, + activated = weapon and weapon:IsActivated() or false, + targetHealth = targetHealth, + targetDelta = self.InitialTargetHealth and self.InitialTargetHealth - targetHealth or 0 + }) +end + +function SpectatorFireSensorFixture:StartActivity() + self.Telemetry = require("Activities/SpectatorTelemetry") + self.Telemetry.ConfigureRuntime("SPECTATOR_FIRE_SENSOR_FIXTURE_LOG.txt") + self.Telemetry.Emit("FIXTURE_CREATE_ENTERED", { activity = "SpectatorFireSensorFixture" }) + self.Telemetry.Snapshot() + self.Timer = Timer() + self.Fired = false + self.Completed = false + self:Checkpoint("SHOOTER_CREATE_REQUESTED") + self.Shooter = CreateAHuman("Brain Robot", "Base.rte") + self:Checkpoint("SHOOTER_CREATED", { success = self.Shooter ~= nil }) + self:Checkpoint("TARGET_CREATE_REQUESTED") + self.Target = CreateAHuman("Brain Robot", "Base.rte") + self:Checkpoint("TARGET_CREATED", { success = self.Target ~= nil }) + self:Checkpoint("WEAPON_CREATE_REQUESTED") + self.Weapon = CreateHDFirearm("SMG", "Base.rte") + self:Checkpoint("WEAPON_CREATED", { success = self.Weapon ~= nil }) + emit({ stage = "CREATED", shooter = self.Shooter ~= nil, target = self.Target ~= nil, weapon = self.Weapon ~= nil, + weaponPreset = self.Weapon and self.Weapon.PresetName or "NONE", weaponModule = self.Weapon and self.Weapon.ModuleID or -1 }) + if not self.Shooter or not self.Target or not self.Weapon then return end + self:Checkpoint("ACTORS_INITIALIZING") + self.Shooter.Team = Activity.TEAM_1 + self.Target.Team = Activity.TEAM_2 + self.Shooter.Pos = SceneMan:MovePointToGround(Vector(1200, 0), 0, 3) + self.Target.Pos = SceneMan:MovePointToGround(Vector(1450, 0), 0, 3) + self:Checkpoint("POSITIONS_ASSIGNED") + self.Shooter:AddInventoryItem(self.Weapon) + self:Checkpoint("WEAPON_ADDED", { inventory = self.Shooter.InventorySize }) + self:Sample("AFTER_ADD") + MovableMan:AddActor(self.Shooter) + MovableMan:AddActor(self.Target) + self:Checkpoint("ACTORS_INSERTED", { shooterValid = MovableMan:IsActor(self.Shooter), targetValid = MovableMan:IsActor(self.Target) }) + self.InitialTargetHealth = self.Target.Health + self:Sample("AFTER_INSERT") +end + +function SpectatorFireSensorFixture:UpdateActivity() + if self.Completed or not self.Shooter or not MovableMan:IsActor(self.Shooter) then return end + if not self.UpdateEntered then + self.UpdateEntered = true + self:Checkpoint("UPDATE_ENTERED", { elapsedSimMS = self.Timer.ElapsedSimTimeMS }) + end + if self.Timer:IsPastSimMS(500) and not self.Fired then + self:Sample("BEFORE_FIRE") + self.Shooter:GetController():SetState(Controller.WEAPON_FIRE, true) + self.Fired = true + elseif self.Fired and self.Timer:IsPastSimMS(850) then + self:Sample("DURING_FIRE") + self.Shooter:GetController():SetState(Controller.WEAPON_FIRE, false) + elseif self.Fired and self.Timer:IsPastSimMS(1200) then + self:Sample("AFTER_FIRE") + emit({ stage = "COMPLETE", reason = "bounded_fixture_finished" }) + self.Telemetry.Snapshot() + self.Completed = true + end +end diff --git a/Data/Base.rte/Activities/SpectatorHUDLogic.lua b/Data/Base.rte/Activities/SpectatorHUDLogic.lua new file mode 100644 index 0000000000..75c1f2281c --- /dev/null +++ b/Data/Base.rte/Activities/SpectatorHUDLogic.lua @@ -0,0 +1,85 @@ +local SpectatorHUDLogic = {} + +local function factionLabel(faction, fallback) + return string.upper( + string.gsub(faction or fallback, "%.rte$", "") + ) +end + +function SpectatorHUDLogic.BuildTeamPanel(faction, alive, score, fallback) + return factionLabel(faction, fallback) .. + " " .. tostring(alive) .. + " | SCORE " .. tostring(score) +end + +function SpectatorHUDLogic.BuildBattleHUD( + roundNumber, + elapsedRoundText, + pressureElapsedSeconds, + pressureThresholdSeconds, + team1Faction, + team1Alive, + team1Score, + team2Faction, + team2Alive, + team2Score +) + return { + header = "ROUND " .. tostring(roundNumber) .. + " | " .. tostring(elapsedRoundText), + team1 = SpectatorHUDLogic.BuildTeamPanel( + team1Faction, + team1Alive, + team1Score, + "TEAM 1" + ), + team2 = SpectatorHUDLogic.BuildTeamPanel( + team2Faction, + team2Alive, + team2Score, + "TEAM 2" + ), + pressure = "COMBAT PRESSURE " .. + tostring(pressureElapsedSeconds) .. + "/" .. tostring(pressureThresholdSeconds) + } +end + +function SpectatorHUDLogic.BuildResultHUD( + roundNumber, + team1Faction, + team1Alive, + team1Score, + team2Faction, + team2Alive, + team2Score +) + return { + header = "ROUND " .. tostring(roundNumber) .. " COMPLETE", + team1 = SpectatorHUDLogic.BuildTeamPanel( + team1Faction, + team1Alive, + team1Score, + "TEAM 1" + ), + team2 = SpectatorHUDLogic.BuildTeamPanel( + team2Faction, + team2Alive, + team2Score, + "TEAM 2" + ) + } +end + +function SpectatorHUDLogic.BuildResultText( + resultText, + team1Score, + team2Score +) + return string.upper(resultText or "ROUND RESULT") .. + " | SCORE " .. tostring(team1Score) .. + " - " .. tostring(team2Score) .. + " | NEXT ROUND..." +end + +return SpectatorHUDLogic diff --git a/Data/Base.rte/Activities/SpectatorSimProgressFixture.lua b/Data/Base.rte/Activities/SpectatorSimProgressFixture.lua new file mode 100644 index 0000000000..aa3889f9d2 --- /dev/null +++ b/Data/Base.rte/Activities/SpectatorSimProgressFixture.lua @@ -0,0 +1,26 @@ +SpectatorSimProgressFixture = {} + +function SpectatorSimProgressFixture:Checkpoint(stage) + self.Telemetry.Emit("SIM_PROGRESS", { + activityState = self.ActivityState, + elapsedSimMS = self.Timer.ElapsedSimTimeMS, + stage = stage, + updateCount = self.UpdateCount + }) + self.Telemetry.Snapshot() +end + +function SpectatorSimProgressFixture:StartActivity() + self.Telemetry = require("Activities/SpectatorTelemetry") + self.Telemetry.ConfigureRuntime("SPECTATOR_SIM_PROGRESS_LOG.txt") + self.Timer = Timer() + self.UpdateCount = 0 + self:Checkpoint("START") +end + +function SpectatorSimProgressFixture:UpdateActivity() + self.UpdateCount = self.UpdateCount + 1 + if self.UpdateCount == 1 or self.UpdateCount == 2 or self.UpdateCount == 10 then + self:Checkpoint("UPDATE_" .. self.UpdateCount) + end +end diff --git a/Data/Base.rte/Activities/SpectatorTelemetry.lua b/Data/Base.rte/Activities/SpectatorTelemetry.lua new file mode 100644 index 0000000000..5cbe1bd7e4 --- /dev/null +++ b/Data/Base.rte/Activities/SpectatorTelemetry.lua @@ -0,0 +1,60 @@ +local Telemetry = {} +local runtimeLogPath + +local fieldOrder = { + "round", "state", "team1", "team2", "team1Alive", "team2Alive", + "winner", "durationMS", "team1Score", "team2Score", "reason" +} + +local function scalar(value) + local text = tostring(value) + return string.gsub(text, "[^%w%._%-]", "_") +end + +function Telemetry.ConfigureRuntime(path) + runtimeLogPath = path +end + +function Telemetry.Snapshot(path) + local snapshotPath = path or runtimeLogPath + if not snapshotPath or not ConsoleMan or not ConsoleMan.SaveAllText then + return false + end + + ConsoleMan:SaveAllText(snapshotPath) + return true +end + +function Telemetry.Encode(event, fields) + local parts = { "SPECTATOR_EVENT", "event=" .. scalar(event) } + local used = {} + for _, key in ipairs(fieldOrder) do + if fields and fields[key] ~= nil then + parts[#parts + 1] = key .. "=" .. scalar(fields[key]) + used[key] = true + end + end + if fields then + local extras = {} + for key in pairs(fields) do + if not used[key] then extras[#extras + 1] = key end + end + table.sort(extras) + for _, key in ipairs(extras) do + parts[#parts + 1] = key .. "=" .. scalar(fields[key]) + end + end + return table.concat(parts, " ") +end + +function Telemetry.Emit(event, fields, sink) + local line = Telemetry.Encode(event, fields) + if sink then + sink(line) + else + print(line) + end + return line +end + +return Telemetry diff --git a/Source/Main.cpp b/Source/Main.cpp index de3fcb7abd..d1d1c5f257 100644 --- a/Source/Main.cpp +++ b/Source/Main.cpp @@ -445,6 +445,14 @@ int main(int argc, char** argv) { g_PresetMan.LoadAllDataModules(); + // This fork is distributed as a dedicated autonomous spectator executable. + // Keep the normal menu systems loaded for rollback, but select the proven + // spectator Activity through ActivityMan's existing direct-launch path. + g_ActivityMan.SetDefaultActivityType("GAScripted"); + g_ActivityMan.SetDefaultActivityName("Spectator Arena"); + g_SceneMan.SetDefaultSceneName("Ketanot Hills"); + g_ActivityMan.SetLaunchIntoActivity(true); + if (!System::IsInExternalModuleValidationMode()) { // Load the different input device icons. This can't be done during UInputMan::Create() because the icon presets don't exist so we need to do this after modules are loaded. g_UInputMan.LoadDeviceIcons(); diff --git a/Source/Managers/ActivityMan.h b/Source/Managers/ActivityMan.h index 4fcf90a3d6..eb2ba8e61f 100644 --- a/Source/Managers/ActivityMan.h +++ b/Source/Managers/ActivityMan.h @@ -101,6 +101,10 @@ namespace RTE { /// @return Whether the game is set to launch directly into the set default Activity or not. bool IsSetToLaunchIntoActivity() const { return m_LaunchIntoActivity; } + /// Sets whether the application should skip the intro and menu and launch the configured default Activity. + /// @param launchIntoActivity Whether to launch the configured default Activity directly. + void SetLaunchIntoActivity(bool launchIntoActivity) { m_LaunchIntoActivity = launchIntoActivity; } + /// Gets whether the intro and main menu should be skipped on game start and launch directly into the set editor Activity instead. /// @return Whether the game is set to launch directly into the set editor Activity or not. bool IsSetToLaunchIntoEditor() const { return m_LaunchIntoEditor; } diff --git a/docs/AUTONOMOUS_SESSION_SUMMARY_2026-09-02.md b/docs/AUTONOMOUS_SESSION_SUMMARY_2026-09-02.md new file mode 100644 index 0000000000..942a9b3851 --- /dev/null +++ b/docs/AUTONOMOUS_SESSION_SUMMARY_2026-09-02.md @@ -0,0 +1,33 @@ +# Autonomous session summary — 2026-09-02 + +## Starting state + +- Branch: `spectator-random-factions` +- HEAD: `de7031896 Improve spectator AI touchdown target spread` +- Working tree: dirty with intentional AI/camera review work and tests. +- Stable context from Drive: 8v8 real-time Ketanot Hills spectator loop; approximately 1h44m / 65 completed matches / 36–29 historical soak; camera review still pending. + +## Work completed + +- Added dependency-free structured telemetry encoding and emission. +- Instrumented activity start, lifecycle state changes, round selection, round results, and watchdog timeout. +- Added telemetry unit coverage and format documentation. + +## Tests + +- `git diff --check`: PASS. +- Lua tests: not run because no standalone Lua interpreter is installed. + +## Runtime evidence + +No runtime session was started in this environment. + +## Known issues and deferred work + +- Telemetry currently uses the game console `print` stream; soak parsing is not yet implemented. +- Camera remains uncommitted and requires human visual acceptance. +- No subjective visual work was attempted. + +## Final repository state + +Observability files are ready for a verified checkpoint; pre-existing camera/AI review files remain intentionally uncommitted. diff --git a/docs/AUTONOMOUS_WORK_LOG.md b/docs/AUTONOMOUS_WORK_LOG.md new file mode 100644 index 0000000000..b7a89b2ba9 --- /dev/null +++ b/docs/AUTONOMOUS_WORK_LOG.md @@ -0,0 +1,356 @@ +# Autonomous work log + +## 2026-09-02 — Project grounding and observability checkpoint + +Changed: +- Confirmed the actual repository at `C:\Users\mythz\Documents\Cortex-Command-Community-Project`. +- Read the canonical Google Drive development state: AI V11.1 is accepted; event-aware camera remains uncommitted pending human visual review. +- Added `Data/Base.rte/Activities/SpectatorTelemetry.lua` and integrated meaningful activity/round/state/watchdog events into `SpectatorArena.lua`. +- Added a pure-Lua telemetry test and telemetry format documentation. + +Verification: +- `git diff --check` — PASS. +- Lua tests — BLOCKED: no standalone `lua` executable is installed or discoverable in this environment. +- No game run performed; no visual acceptance was inferred. + +Preservation: +- Existing camera and AI experimental files remain unmodified by this checkpoint. +- No camera acceptance or production claim made. + +Next: +- Run Lua tests with the game/runtime interpreter. +- Use `tools/spectator_soak_report.py` on a captured game log and extend fields only when real output confirms them. + +## 2026-09-02 — AI V2 instrumentation foundation + +Changed: +- Reviewed the AI V2 pre-coding blueprint from Google Drive. +- Added the behavior-neutral `SpectatorAIController` state/metrics module. +- Added tests for OFF mode, round reset, touchdown release state, bounded progress samples, frozen contact memory, and deterministic snapshots. +- Added an implementation plan at `docs/superpowers/plans/2026-09-02-spectator-ai-v2-instrumentation.md`. + +Verification: +- New Lua controller test — PASS under cached Fengari. +- Existing telemetry and camera Lua tests — PASS. +- Python suite — 2 tests PASS. +- `git diff --check` — PASS. + +Preservation: +- Controller is not integrated into `SpectatorArena.lua` yet. +- Default mode is `OFF`; no actor behavior, NativeHumanAI, touchdown, waypoint, or camera behavior changed. + +Next: +- Integrate observation only after `AI_TOUCHDOWN_ALL_RELEASED`, retaining OFF as the default. +- Resolve live `SPECTATOR_EVENT` transport before baseline soak claims. + +## 2026-09-02 — AI V2 OFF-mode activity integration + +Changed: +- Wired `SpectatorAIController` into `SpectatorArena.lua` without changing V11/V11.1 actor behavior. +- Added round generation reset, spawn registration, accepted touchdown-release recording, and 500 ms released-actor position sampling. +- Added `AI_V2_CONFIG version=2 mode=OFF` through the existing telemetry path. +- Added a source-level integration test proving the release boundary and no tactical actor mutations in the instrumentation function. + +Verification: +- Focused controller and integration Lua/Python checks — PASS. +- Full Python suite — 2 tests PASS. +- Debug Release x64 MSBuild — PASS (existing compiler/linker warnings only). +- `git diff --check` — PASS. +- Fresh executable smoke launch stayed alive for the smoke window; legacy `LogConsole.txt` did not refresh, so no new round-level runtime claim is made. + +Preservation: +- `AI_V2_MODE` remains `OFF`; no tasks, tactical orders, contact sharing, or NativeHumanAI replacement was enabled. +- Uncommitted camera-review files remain untouched and uncommitted. + +## 2026-09-02 — Telemetry transport root-cause review + +Finding: +- Lua `print` is overridden by the engine to call `ConsoleMan:PrintString`, so the telemetry helper’s default emission path is valid. +- `LogConsole.txt` is written from `ConsoleMan::Destroy()` as a shutdown snapshot. Forced process termination can discard buffered records, explaining the apparent live-log gap. + +Action: +- Documented `-cout` stdout capture and orderly shutdown as the valid runtime verification paths. +- Added an activity-scoped `SPECTATOR_EVENT_LOG.txt` snapshot path and taught the soak parser to accept the engine’s `PRINT:` prefix. + +Verification: +- Fresh rebuilt executable smoke run produced 11 `SPECTATOR_EVENT` records, including `AI_V2_CONFIG`, `ROUND_START`, and `BATTLE` state. +- The parser correctly reported `1` started and `0` completed rounds for the intentionally short run. +- Runtime snapshot is ignored by Git; no generated log is committed. + +## 2026-09-02 — Preliminary OFF-mode baseline soak + +Observed: +- Fresh rebuilt executable ran through two completed rounds and entered a third before the exact process was stopped. +- Results: `2/3` rounds completed, winners `RONIN_WINS=1`, `DUMMY_WINS=1`, watchdog events `0`. +- Completed-round durations were `33915.31 ms` and `60464.248 ms` (average `47189.779 ms`). + +Correction: +- The live engine emits decimal `durationMS` values. Updated `tools/spectator_soak_report.py` and its test to parse numeric durations instead of integers only. + +Status: +- This is preliminary evidence, not the required 50-round activation baseline. + +## 2026-09-02 — Extended OFF-mode baseline sample + +Observed: +- A second fresh run completed two additional rounds before round 3 remained active at the evidence checkpoint. +- This run: `2/3` rounds completed, winners `RONIN_WINS=1`, `DUMMY_WINS=1`, watchdog events `0`. +- Completed-round durations were `37798.488 ms` and `34015.306 ms` (average `35906.897 ms`). + +Status: +- Combined with the earlier sample, four completed rounds are now observed across two runs; this remains far below the required 50-round baseline. +- `AI_V2_MODE` remains `OFF`; no SHADOW or tactical activation decision is justified yet. + +## 2026-09-02 — Deterministic soak parser + +Changed: +- Added `tools/spectator_soak_report.py` to aggregate structured events. +- Added two Python unit tests covering noise, completed rounds, incomplete final rounds, durations, and watchdog events. + +Verification: +- `python -m unittest tests/test_spectator_soak_report.py -v` — PASS (2 tests). +- `git diff --check` — PASS. + +Commit: +- `642c549f0 Add spectator soak report parser` + +## 2026-09-02 — Local AI and navigation API audit + +Verified observation-safe interfaces for the spectator AI V2 shadow layer: + +- Actor state: `Health`, `PrevHealth`, `MaxHealth`, `GetAimAngle`, `GetLastAIWaypoint`, `MovePathEnd`, `MovePathSize`, `IsWaitingOnNewMovePath`, `AIBaseDigStrength`, `JumpHeight`, and `DigStrength`. +- Weapon state: held `HDFirearm` `FiredFrame` and `MuzzlePos`. +- Damage state: `MOSRotating.WoundCount`. +- World queries: `SceneMan:ShortestDistance`, obstacle/strength/MO ray casts, `SceneMan:GetLastRayHitPos`, and `MovableMan:GetMOsAtPosition`. +- Navigation support: `Scene:CalculatePath` and `CalculatePathAsync` are available, but path calculation is potentially expensive and must remain bounded and cadence-limited in any future planner. +- Timing: `SettingsMan.AIUpdateInterval` and `TimerMan.AIDeltaTimeMS` are available for cadence-aligned sampling. + +Safety boundary: + +- `ClearAIWaypoints`, `AddAISceneWaypoint`, `AddAIMOWaypoint`, `SetMovePathToUpdate`, actor setters, and controller-input methods are mutating interfaces. They remain reserved for future TASKS/TACTICAL modes and must not be called by OFF or SHADOW instrumentation. +- No direct killer/instigator binding was identified in the inspected Lua-facing APIs. Any future contact attribution must therefore remain conservative and inference-based unless a stronger engine signal is found. + +Status: +- The audit confirms enough local read-only data to implement contact memory, engagement observations, and bounded scoring without replacing `NativeHumanAI` or changing V11/V11.1 touchdown/release behavior. + +## 2026-09-02 — AI V2 shadow implementation phases 2–5 + +Implemented and committed the pure shadow-layer foundations: + +- Contact memory with frozen direct positions, confidence updates, expiry, and engagement-lock observations. +- Task hysteresis, bounded target reservations with stale-expiry cleanup, and one-stage-at-a-time recovery escalation. +- Engine-independent weapon classification and destination scoring for range, cover, line of sight, threat, and meaningful-improvement filtering. +- SHADOW-only arena observations after the existing touchdown/release gate, covering nearest opponent, LOS, firing, health, waypoint/path state, progress, and recovery stage. + +Verification: +- Focused Lua controller tests — PASS under the locally restored cached Fengari runner. +- Telemetry Lua test — PASS. +- Python test suite — 3 tests PASS. +- SHADOW integration source tests — 3 tests PASS. +- `git diff --check` — PASS. +- `RTEA.sln` Debug Release x64 build — PASS, 0 errors; existing compiler warnings remain. + +Evidence boundary: +- No behavior-enabled AI decision has been activated. +- No non-default SHADOW runtime trace has yet been used to claim contact, task, recovery, CPU/UPS, or tactical quality improvements. +- Production remains OFF pending a deliberate SHADOW evidence capture and the required 50 completed OFF-mode baseline rounds. + +## 2026-09-02 — SHADOW smoke test + +Observed in a temporary non-default SHADOW run: + +- 4 rounds started, 3 completed, 0 watchdog events. +- 2,393 `AI_SHADOW_OBSERVATION` records: round counts 768, 714, 735, and 176. +- 0 LOS-positive records and 0 firing-positive records; contact/engagement semantics were therefore not exercised. +- No observation appeared before the first `AI_TOUCHDOWN_ALL_RELEASED` marker. +- Completed-round durations were 37,565.164 ms, 45,114.862 ms, and 47,598.096 ms. + +Decision: +- SHADOW instrumentation plumbing passes, but semantic readiness is incomplete. Keep production `OFF`; do not activate TASKS or TACTICAL behavior. +- Full details: `docs/SPECTATOR_AI_V2_SHADOW_SMOKE_REPORT_2026-09-02.md`. + +## 2026-09-02 — SHADOW telemetry I/O performance correction + +Finding: +- `Telemetry.Emit()` was calling `ConsoleMan:SaveAllText()` for every event, including every SHADOW actor observation. This was a confirmed high-probability source of periodic full-console disk-write stalls. + +Changed: +- Separated cheap `Telemetry.Emit()` from explicit `Telemetry.Snapshot()`. +- Moved the arena snapshot to the low-frequency `ROUND_RESULT` boundary. +- Kept production mode `OFF` and preserved the existing telemetry test contract through explicit snapshot verification. + +Verification: +- Telemetry/controller Lua tests — PASS. +- Python suite — 3 tests PASS. +- Runtime A/B confirmation: 894 SHADOW observations accumulated before the first round-result snapshot, with no snapshot file during the initial 30-second window; the file appeared after round 1 completed. +- Source restored to `AI_V2_MODE = "OFF"`; camera work remains untouched. + +Remaining: +- Add aggregate SHADOW counters and execution-cost timing before another semantic SHADOW run. + +## 2026-09-04 — Verified long-run raw evidence + +Changed: +- Independently parsed `logs/session-2026-09-03-debug-release-0924/` with `tools/spectator_soak_report.py` and a separate line-level cross-check. +- Added `docs/SPECTATOR_LONG_RUN_ANALYSIS_2026-09-03.md` with hashes, statistics, interpretation, and Drive links. + +Verified evidence: +- `SPECTATOR_EVENT_LOG.txt`: 104 round starts and 104 completed rounds. +- `LogConsole.txt`: 105 starts and 104 completed rounds; round 105 is an incomplete trailing round after shutdown-time console flushing. +- Winner distribution: Imperatus 28, Browncoats 22, Coalition 21, Techion 17, Ronin 11, Dummy 5. +- Completed-round duration: minimum 22,215.778 ms, maximum 91,712.998 ms, mean 43,078.56525 ms, median 40,656.707 ms. +- Watchdogs: 0. `AI_SHADOW_OBSERVATION` records: 0. +- AI V2 mode is evidenced as `OFF` by the session configuration and trace markers. + +Status: +- The 104 completed OFF-mode rounds qualify toward and exceed the canonical 50-round OFF baseline count; the incomplete round is excluded. +- The formal AI activation gate remains `OFF` because this run contains no SHADOW semantic evidence and does not establish behavior quality or CPU/UPS thresholds. + +## 2026-09-03 — Debug Release runtime log archive + +Changed: +- Launched the newest available executable, `Cortex Command.debug.release.exe`. +- Closed the game through its normal window-close path so buffered console and loading logs were flushed. +- Archived the session output under `logs/session-2026-09-03-debug-release-0924/`. + +Captured: +- 5 files, approximately 4.3 MB: `LogConsole.txt`, `LogLoading.txt`, `SPECTATOR_EVENT_LOG.txt`, `SPECTATOR_ARENA_SPAWN_TRACE_LOG.txt`, and `SPECTATOR_ARENA_POST_SPAWN_TRACE_LOG.txt`. +- The preserved console history contains 104 `ROUND_RESULT` records and no watchdog records. +- This launch did not produce new `AI_SHADOW_OBSERVATION` records, so it is not new SHADOW semantic evidence. + +Status: +- The process exited normally and the archive is retained for future evaluation. +- The SHADOW evidence gate and 50-round OFF baseline remain unchanged. + +## 2026-09-05 bounded integrity/R2 continuation + +See SPECTATOR_CONTINUATION_STATUS_2026-09-05.md for exact commands, raw evidence, binary hash, and next capture contract. Confirmed committed missing camera dependency; preserved candidate pending review. Reparsed OFF snapshots: 104/104 event and 104/105 console, zero watchdogs. R1B: 16/16 attached valid retained weapons and empty equipped discovery; 16/16 missing arm MOID, so R2 first divergence remains undetermined. Python 12 tests OK; camera/controller/telemetry Lua PASS. Initial mistyped telemetry filename corrected. No source/settings changes, native build, or native runtime claim. Existing dirty artifacts and historical tag preserved. Starting status/pre-existing tracked patch and Python results saved under work/continuation-2026-09-05/. Dedicated status report committed separately; this appended log remains recoverable with prior user edits intact. +Report commit: 0eb20c432. Drive connector upload success=true, destination parent 0AGSQc2hrH2H_Uk9PVA, file ID 1HSP6eelqR2ttV_oRzESaX4Ir7jcwbxWe. URL: https://drive.google.com/file/d/1HSP6eelqR2ttV_oRzESaX4Ir7jcwbxWe/view?usp=drivesdk + +## 2026-09-13 — native camera capture review + +Captured and reviewed 50 local PNG frames over approximately 25 seconds from the +visible Debug Release game window at 976x579. The segment covered the end of +round 28 and the beginning of round 29. The camera generally kept active combat +groups in view across the hill/valley and did not show a sustained empty-terrain +lock in the sampled frames. The transition into the next round centered the +airborne squad. No deliberately observed off-screen event cut, return timing, +victim deduplication, last-survivor priority, or 3–5 full-round acceptance was +established. Decision remains **HOLD_FOR_VISUAL_ACCEPTANCE**. Production +`AI_V2_MODE` remains `OFF`. + +## 2026-09-05 — isolated broad-development checkpoint + +See docs/SPECTATOR_IMPLEMENTATION_STATUS_2026-09-05.md. New implementation is in branch spectator-development-2026-09-05 at C:/Users/mythz/Documents/Codex/2026-09-05/cortex-command-community-project/work/development; it has not been merged or committed as implementation. Native build passed; R2 identified and corrected missing AHuman casts. Preserved corrected SHADOW evidence: 5/5 rounds, zero watchdogs, 1,662 fire events and 693 damage observations (supersedes the earlier three-round interim summary). Independent D1 fixture: health 100->98, wounds 0->1, PASS. HUD/configuration/proposal code remains candidate: first HUD run had an OPENGL32.DLL access violation; repeat visual verification was interrupted by physical Escape. All 16 Python and 10 Lua tests pass at documentation time, but native feature acceptance and TASKS-A GO remain pending. Saved defaults are OFF, retention=false, topology diagnostics=false. Historical tag and this checkout's pre-existing dirty work are preserved. Upload receipts will follow after connector readback. + +## 2026-09-13 — engagement camera offset review build + +Implemented the approved spectator-camera follow-up in an isolated worktree. The director now biases the camera toward the enemy in the followed actor's firing direction for a short, cooldown-gated engagement frame while preserving event/death and last-survivor priority. The implementation uses native firearm signals when available and a rising `Controller.WEAPON_FIRE` edge as a guarded fallback for the live actor-wrapper attachment mismatch. + +Verification passed: pure Lua camera tests, controller Lua tests, 10-test Python integration suite, Lua load checks, `git diff --check`, and native `Debug Release|x64` build with zero errors. A focused 50-frame Debug Release capture from the isolated worktree produced three `CAMERA_FIRE_CONTROLLER` followed by `CAMERA_ENGAGEMENT` transitions. Reviewed frames kept the firing side and opposing side readable, showed normal follow return, and showed no sampled jitter or empty-terrain lock. Engagement-offset decision: **ACCEPTED_FOR_THIS_REVIEW_BUILD**. The older event-aware camera milestone remains separately held; this was a local frame capture, not an MP4 screen recording. + +## 2026-09-13 — condition-based DYING accounting capture + +Ran the unchanged Arena until the first naturally accepted DYING-correlated +event. The run reached four completed rounds, retained a 120-frame rolling +buffer at 976x579, and exited normally after the first accepted hold/return. + +Verified counts: 91 fire observations, 44 DYING observations, 2 accepted, 20 +rejected, 22 not evaluated, 5 removal-unconfirmed, 2 requests, 2 targets, 2 +hold completions, and 2 returns. The invariant `44 = 2 + 20 + 22` closes. +Rejection/not-evaluated reasons were STALE_SHOT 19, SHOOTER_MISMATCH 10, +NO_CORRELATABLE_SHOT 8, and COOLDOWN 5. Trace 49 spans fire, DYING, +acceptance, request, target, hold, and return. + +The rendered 1 FPS buffer is visually sane but is insufficient for precise +movement-onset/arrival timing, so camera behavioral acceptance remains HOLD. +The capture exposed and the latest code fixed only an accepted-reason label +bug (`NO_CANDIDATE` caused by Lua truthiness); no camera or attribution policy +changed. Code checkpoints: `09a242eec`, `351bd91f1`, and latest sync +`fbb12f0d4`. + +## 2026-09-13 — physical camera execution telemetry + +Added observation-only sampling of `CameraMan:GetOffset` and the active event +target. Focused native verification produced accepted trace 6 with 121 +samples: request/target at 31833.970 ms, movement onset at 31850.637 ms +(+16.667 ms), arrival at 32683.987 ms (+850.017 ms, 19.063 px within 24 px), +and hold complete/return at 33834.010 ms (+2000.040 ms). + +The native physical execution sub-gate is PASS for one event. A separate 15 FPS +F12 screenshot attempt produced no frames because its launcher path did not +expose the game window, so no rendered-video acceptance is claimed. The next +gate is 3–5 complete-round behavioral review. Implementation checkpoint: +`cff83c0fe`; preserved log: `work/camera-event-telemetry-20260913-2312/`. + +## 2026-09-13 — stream HUD overlay + +Implemented the approved Lua-only stream HUD in the isolated worktree. The +overlay places team/alive/score panels in the upper corners, round/time and +combat pressure at center top, and keeps the centered winner/result banner for +round transitions. The camera and AI behavior are unchanged. The nearby-ally +fire aggregation idea is recorded as a future camera-v2 candidate rather than +expanded into a battlefield-wide tracking system. + +Verification passed: HUD formatter test, 11-test Python integration suite, +camera event test, AI controller test, Lua load checks, and `git diff --check`. +A 100-frame native Debug Release capture showed readable HUD placement and the +transition into round 2; runtime reached `ROUND_RESULT` without Lua errors. +The result banner was not retained in a captured frame because the window ended +just before the result transition. Decision: **ACCEPTED_FOR_THIS_REVIEW_BUILD**. + +## 2026-09-13 — camera acceptance status correction + +The engagement-camera review status is corrected to distinguish visual sanity +from behavioral acceptance. The 50-frame / approximately 25-second review over +rounds 28→29 supports **visual sanity: PASS**: combat remained readable over +uneven terrain, with no sustained empty-terrain fixation or obvious sampled +oscillation. **Camera behavioral acceptance remains HOLD.** The review did not +prove an attributable event cut, request-to-arrival timing, return behavior, +deduplication/retrigger suppression, survivor/end-of-round priority, or 3–5 +complete rounds. Earlier telemetry recorded zero events passing the conservative +attribution gate, so event selection is still unproven. The next task is the +first attributable camera-cut capture, using a narrow T − 2 s → request → +selection → movement → arrival → T + 3–5 s window, followed by 3–5 complete +rounds. HUD acceptance remains independent and unchanged. + +## 2026-09-13 — attributable camera-cut investigation + +The first telemetry-directed capture was executed with review-only camera trace +markers. The traced run covered five complete rounds plus a final bounded round; +the runtime recorded 12 `CAMERA_EVENT_REMOVAL_UNCONFIRMED` cases, including +victims whose tracked health was already below zero, but zero +`CAMERA_EVENT_DEATH_OBSERVED`, attribution accepts, requests, or target- +issuance markers. A 100-frame, approximately 25-second rendered capture stayed +visually sane but contained no attributable camera cut, so no behavioral +acceptance claim is made. + +Finding: the actor roster can remove a victim before the camera observes a +live→dead transition, and the observed removals were outside or near the end +of the 400 ms attribution window. Native source inspection confirms that the +activity update runs before the MovableMan update; actors enter `DYING` at +health `<= 0`, then become `DEAD` and are removed from the live actor list and +team rosters in the manager pass. Lua exposes `Status`, `Health`, `PrevHealth`, +and the `DYING`/`DEAD` values, so `DYING` is the last authoritative in-roster +signal. The next task is to test that signal diagnostically under the existing +attribution gates, followed by the same narrow end-to-end capture. Attribution +gates and camera-v2 ally tracking remain unchanged. + +## 2026-09-13 — DYING-edge attribution experiment + +The diagnostic-only change moved camera victim lifecycle observation from +live-to-DEAD/removal detection to a one-shot Lua-visible `DYING` edge and added +monotonic `traceID` correlation across the camera trace. The unchanged native +run produced 44 `CAMERA_FIRE_OBSERVED`, 11 +`CAMERA_EVENT_DYING_OBSERVED`, 3 `CAMERA_EVENT_REMOVAL_UNCONFIRMED`, and 9 +`CAMERA_EVENT_ATTRIBUTION_REJECTED` records. Rejections were seven +`STALE_SHOT` and two `DISTANCE`; there were zero accepts, requests, target +issuances, hold completions, or returns. The 90-second rendered sample stayed +visually readable, but no event-camera movement was claimed. + +Decision: **DEATH-OBSERVATION ROOT CAUSE PASS** and **DYING OBSERVATION PASS**; +**DYING AS ATTRIBUTION EVIDENCE NOT YET ACCEPTED**; camera behavioral +acceptance remains **HOLD**. Keep the 400 ms window and all existing gates +unchanged, and keep camera-v2 ally tracking deferred. Next proof is one +naturally accepted correlated event followed by the T−2 s → T+3–5 s frame +review. diff --git a/docs/CHATGPT_REVIEW_HANDOFF_2026-09-02.md b/docs/CHATGPT_REVIEW_HANDOFF_2026-09-02.md new file mode 100644 index 0000000000..b79dc66d65 --- /dev/null +++ b/docs/CHATGPT_REVIEW_HANDOFF_2026-09-02.md @@ -0,0 +1,80 @@ +# ChatGPT Review Handoff — Cortex Command AI V2 + +You are reviewing the Cortex Command Community Project's spectator AI V2 work on branch `spectator-random-factions`. + +## Context + +The project runs an autonomous spectator arena using Cortex Command's native `NativeHumanAI`. The approved direction is to add a coordinator above native AI rather than replace native actor behavior. Production must remain `AI_V2_MODE = "OFF"` until evidence supports activation. + +## What has been implemented + +- Round-scoped AI V2 controller state. +- Frozen direct contact memory with confidence expiry. +- Engagement locks with expiry. +- Task hysteresis. +- Bounded target reservations and stale-reservation cleanup. +- One-stage-at-a-time recovery escalation. +- Pure CLOSE/MID/LONG weapon classification. +- Destination scoring using range, cover, LOS, and threat. +- SHADOW-only post-touchdown observation collection for opponent, LOS, firing, health, waypoint/path, progress, and recovery. + +## Safety constraints + +- NativeHumanAI remains authoritative. +- V11/V11.1 touchdown/release behavior must not change. +- OFF and SHADOW must not mutate AIMode, waypoints, actor position, health, inventory, or controller input. +- No behavior improvement may be claimed without matched OFF and behavior-enabled evidence. +- Camera-review files in the working tree are separate user work; do not modify or commit them. + +## Evidence status + +- Lua and Python tests pass. +- Debug Release x64 build passes with 0 errors. +- Four completed OFF rounds have been observed in preliminary short runs. +- The required OFF baseline is 50 completed rounds. +- The first non-default SHADOW trace was captured: 2,393 observations across four started rounds, with no LOS-positive or firing-positive samples. It passed lifecycle and plumbing checks but was semantically incomplete. +- The telemetry path no longer snapshots the console per event; explicit snapshots occur at round-result boundaries. +- Deterministic nearest-visible opponent selection and aggregate SHADOW visibility/cost/contact metrics are now implemented in the latest checkout. +- Native runtime follow-up: the Debug Release x64 build passed and three SHADOW rounds ran without watchdogs or pre-touchdown observations, but semantic evidence remained zero-positive and execution-cost reporting returned zero. +- Sensor follow-up: `CastMORay` target/root-MOID matching and raw ray diagnostics are implemented. A fresh three-round run recorded nonzero CPU cost and durable damage events, but still zero LOS/visibility/fire/contact positives; an engine-backed deterministic fixture is now required. +- Latest checkpoint (`cd4ed43b3`): diagnostic telemetry exposed a Lua `:` versus `.` dispatch bug in `SelectVisibleOpponent`. A `TARGET` ray could be recorded while visible selection stayed zero. The static call is corrected, and native-aligned eye/body probes, deterministic ray classifications, and probe-count telemetry are added. Tests and a native build pass; a fresh post-fix semantic smoke is still pending because the last direct launch did not start the spectator activity. + +## Review questions + +1. Inspect `Data/Base.rte/Activities/SpectatorAIController.lua` for semantic, numerical, and edge-case issues in contact expiry, task hysteresis, reservations, recovery escalation, weapon classification, and destination scoring. +2. Inspect `Data/Base.rte/Activities/SpectatorArena.lua` and confirm that SHADOW observation is truly post-release, nil-safe, bounded, and behavior-neutral. +3. Identify any engine API assumptions that should be verified before runtime SHADOW capture. +4. Recommend the smallest useful SHADOW evidence experiment and the exact metrics to collect. +5. Recommend whether to prioritize the 50-round OFF baseline, SHADOW capture, parser/report improvements, or another safety task. +6. Propose one narrowly scoped behavior-enabled experiment only if the evidence gate is satisfied; include rollback criteria. TASKS remains blocked for now. +7. Confirm the direct-launch path and collect a fresh post-fix semantic SHADOW smoke before recommending any gate change. + +## Desired review output + +Return: + +- findings ordered by severity; +- concrete code/documentation corrections; +- a prioritized next-step plan; +- explicit separation of verified facts, inferences, and open questions; +- a go/no-go recommendation for SHADOW capture and later TASKS activation. + +Primary local references: + +- `docs/PROJECT_SUMMARY_AND_NEXT_STEPS_2026-09-02.md` +- `docs/superpowers/specs/2026-09-02-spectator-ai-v2-shadow-design.md` +- `docs/superpowers/plans/2026-09-02-spectator-ai-v2-shadow-implementation.md` +- `Data/Base.rte/Activities/SpectatorAIController.lua` +- `Data/Base.rte/Activities/SpectatorArena.lua` +- `tests/spectator_ai_controller_test.lua` +- `tests/spectator_ai_integration_test.py` + +Drive review bundle: + +- [Source snapshot manifest](https://drive.google.com/file/d/1Y8SULsRv7pW2IiRyw3FqtEH2g466ATZn/view) +- [SpectatorAIController.lua](https://drive.google.com/file/d/1z-ejFEDlVaflUMfJwFr3s3XlNBHAAUse/view) +- [SpectatorArena.lua](https://drive.google.com/file/d/1CEyZGfMjzwKhi_HZ1XPRzBXpmVmmYnCE/view) +- [Controller tests](https://drive.google.com/file/d/19xOIqYhNJYw8VPA87DPCIMQHSMWx7tzO/view) +- [Integration tests](https://drive.google.com/file/d/1MZtVsiG4hahyitxMeuU3QNd-zuhzLxjx/view) +- [Telemetry test](https://drive.google.com/file/d/1O7URrZAL5PkVdk207Tlctx23UWg1iXTu/view) +- [Soak report parser](https://drive.google.com/file/d/15ki8-2vuP5nnYj5ZPqVn1awVeHr10Tqe/view) diff --git a/docs/CORTEX_COMMAND_KNOWLEDGE_BRIDGE.md b/docs/CORTEX_COMMAND_KNOWLEDGE_BRIDGE.md new file mode 100644 index 0000000000..8f328289e1 --- /dev/null +++ b/docs/CORTEX_COMMAND_KNOWLEDGE_BRIDGE.md @@ -0,0 +1,197 @@ +# Cortex Command — Shared Knowledge Bridge + +Last synchronized locally: 2026-09-13 + +This is the repository-side continuity contract for regular ChatGPT and Codex. External Drive and Notion records are continuity mirrors; newer local Git, tests, and runtime evidence take precedence. + +## Source-of-truth order + +1. Local Git state and runtime/test evidence. +2. Current repository documentation under `docs/`. +3. The shared Google Drive bridge and canonical `SPECTATOR_ARENA.md`. +4. Notion project dashboard. +5. Historical snapshots and older chat context. + +Before implementation, inspect `git status`, the current branch/HEAD, recent history, tags, local handoffs, and relevant logs. Never reset or overwrite newer local work with an older external snapshot. + +## Repository + +`C:\Users\mythz\Documents\Cortex-Command-Community-Project` + +Current local branch: `spectator-random-factions` +Current local HEAD: `2225e44bd feat: lead spectator camera toward firing targets` + +The working tree is dirty with newer post-checkpoint runtime evidence and +unrelated camera/research work. Preserve that state; do not reset, clean, +merge, rebase, or downgrade it. + +Important local milestones: + +- `spectator-soak-2026-08-31`: approximately 1h44m unattended, round 66, 65 completed matches, observed score 36–29. +- `53c2b1f2d`: V10.1 AI baseline. +- `94ee8e328`: V11 touchdown gate. +- `de7031896`: V11.1 target-spread improvement. +- `46755ce40`: telemetry sink emission fix. +- `1bff421ae`: R1 attachment-boundary trace; retained Arena firearm wrappers + remain valid/attached while normal actor-owned discovery is empty. + +## Current product + +Spectator Arena is an autonomous real-time 8v8 AI-vs-AI activity on `Ketanot Hills`. Its loop is: + +`spawn -> fight -> winner detection -> score update -> reset -> next round` + +The lifecycle is: + +`BOOT -> PREPARE_ROUND -> SPAWN_TEAMS -> BATTLE -> ROUND_RESULT -> ROUND_RESET` + +Direct launch is source-controlled through `Source/Main.cpp`; `Userdata/Settings.ini` remains runtime state and must not be committed. + +## Accepted AI state + +V11/V11.1 AI behavior is accepted and frozen for now. Actors spawn in SENTRY, must touch terrain before distributed pursuit, are released individually after touchdown, and landed teammates are redistributed when another teammate lands. Do not resume pursuit-pulse/tap experiments or modify native AI files without a new explicit decision. + +## Unresolved camera milestone + +The event-aware hybrid camera is packaged in `e7422a9c8` and proposed in +upstream PR #284, but remains pending human visual acceptance. Its priority is: + +`LAST_SURVIVOR > CAMERA_EVENT > CAMERA_SOLDIER > CAMERA_POI > CAMERA_CENTER` + +Review values are a 400 ms fire window, 2000 ms event hold, 4000 ms cooldown, 180–1200 pixel range, 0.85 aim-dot threshold, and per-round victim deduplication. Attribution is conservative inference, not engine-confirmed killer attribution. + +The 2026-09-13 local capture showed generally action-centered hill/valley +framing without a sustained empty-terrain lock, but did not establish a +credible off-screen event cut, return timing, deduplication, last-survivor +priority, or 3–5 complete rounds. Decision remains +`HOLD_FOR_VISUAL_ACCEPTANCE`. Required before acceptance: review 3–5 complete +rounds, observe a credible off-screen event cut, confirm timing, no false cuts, +clean return to soldier-follow, deduplication, and last-survivor priority. + +The approved engagement offset is now implemented in PR #284. When the +followed actor fires, the camera selects the nearest opposing living actor in +the firing cone and biases the frame 55% toward that enemy for 900 ms, with a +1400 ms cooldown. Native firearm signals are preferred; a rising +`Controller.WEAPON_FIRE` edge is a guarded fallback for the live actor-wrapper +attachment mismatch. A completed native round emitted +`CAMERA_FIRE_CONTROLLER` followed by `CAMERA_ENGAGEMENT`. This confirms the +runtime trigger path, but does not replace the pending targeted visual review. + +Read: + +- `docs/HANDOFF_CAMERA_EVENT_AWARE.md` +- `docs/HANDOFF_CAMERA_HYBRID_REVIEW.md` +- `docs/SPECTATOR_ARENA.md` + +The separate engagement-camera offset follow-up is implemented on PR #284's +`spectator-random-factions` branch. A focused 50-frame Debug Release capture on +2026-09-13 covered approximately 25 seconds across rounds 28→29 and showed +readable combat, ordinary follow return, no sustained empty-terrain fixation, +and no obvious sampled jitter. This is **visual sanity: PASS**; **camera +behavioral acceptance: HOLD**. The capture does not prove attributable event +cuts, request-to-arrival timing, deduplication/retrigger suppression, return or +reset behavior, survivor priority, or 3–5 complete rounds. Earlier telemetry +also recorded zero events passing the conservative attribution gate, so the +event-selection pipeline remains unproven. The next milestone is a +telemetry-directed window from T − 2 s through request, selection, movement, +arrival, and T + 3–5 s, followed by 3–5 complete-round observation. + +That attribution-directed capture was then run on the unchanged camera logic with +review-only trace markers. Across five complete rounds plus a final bounded +round, the runtime recorded 12 `CAMERA_EVENT_REMOVAL_UNCONFIRMED` records but +zero `CAMERA_EVENT_DEATH_OBSERVED`, attribution accepts, event requests, or +target issuances. The rendered sample stayed visually sane, but no attributable +cut occurred. The current diagnostic finding is an actor-removal/death- +observation boundary: victims can leave the live roster before a live→dead +transition is visible, and several removals were already outside the 400 ms +window. Native source inspection now explains the boundary: the activity update +runs before the MovableMan update, actors enter `DYING` when health reaches zero, +and dead actors are then removed from the live actor list and team rosters in +the manager pass. Lua exposes `Status`, `Health`, `PrevHealth`, and the +`DYING`/`DEAD` values, making `DYING` the last authoritative in-roster signal. +Next action is to test that signal under the existing conservative gate; do not +loosen the gate or add camera-v2 tracking yet. + +The first DYING-edge experiment confirmed the seam in native runtime: 44 fire +observations yielded 11 `CAMERA_EVENT_DYING_OBSERVED` records across an +approximately 90-second run. Nine candidates were rejected by the unchanged +classifier—seven `STALE_SHOT` and two `DISTANCE`—with zero attribution accepts, +requests, target issuances, hold completions, or returns. This closes the +lifecycle-observation subproblem but leaves DYING attribution acceptance and +camera behavioral acceptance on HOLD. Do not widen 400 ms or add nearby-ally +tracking; the next proof remains one naturally accepted correlated event. + +The next product-facing milestone, the stream-facing HUD overlay, is likewise +accepted for this review build. It adds Lua-only corner team panels, centered +round/time and combat-pressure text, and the existing result banner, without +changing camera or AI behavior. A 100-frame native capture showed readable HUD +placement and the runtime log reached `ROUND_RESULT` without Lua errors; the +result banner was not retained in a frame because the capture ended just before +that transition. The nearby-ally firing aggregation suggestion is recorded as +a future camera-v2 candidate, not an active tracking-system task. + +## Observability state + +The telemetry helper and Python soak parser are implemented and unit-tested. Root-cause review found that Lua `print` is routed into the in-memory `ConsoleMan` buffer, while `LogConsole.txt` is written only during orderly `ConsoleMan::Destroy()`. The activity now snapshots the console buffer to ignored `SPECTATOR_EVENT_LOG.txt` after telemetry events. A fresh smoke run produced the expected records; a longer soak is still needed for completed-round statistics. + +Relevant files: + +- `Data/Base.rte/Activities/SpectatorTelemetry.lua` +- `tools/spectator_soak_report.py` +- `tests/spectator_telemetry_test.lua` +- `tests/spectator_camera_event_test.lua` +- `tests/test_spectator_soak_report.py` + +## AI V2 status + +The first behavior-neutral foundation is present in `Data/Base.rte/Activities/SpectatorAIController.lua`, and `SpectatorArena.lua` now wires it in explicit `OFF` mode. Actors are registered at spawn, released only at the existing accepted touchdown boundary, and sampled at a low cadence after `AI_TOUCHDOWN_ALL_RELEASED`; the controller does not mutate actors, AIMode, waypoints, controllers, or combat behavior. `AI_V2_CONFIG` is emitted through the existing telemetry helper. + +This is instrumentation only. Production `AI_V2_MODE` remains explicitly +`OFF`; do not enable `TASKS` or `TACTICAL` behavior until baseline and SHADOW +evidence exists. R1B rules out simple wrapper loss, sampled deletion, simple +world drop, and a SHADOW-only cause, but leaves an unresolved +attachment/discovery-path mismatch. Runtime telemetry capture is functioning +through the activity-scoped `SPECTATOR_EVENT_LOG.txt` snapshot path, but the +50-round OFF-mode baseline is still incomplete. + +## Work protocol + +Prefer deterministic tests, logs, state, and soak reports during autonomous work. Preserve unrelated changes. Record meaningful progress in `docs/AUTONOMOUS_WORK_LOG.md` and a dated session summary. Synchronize external Drive/Notion state only after verified local milestones. + +Next decision gate: + +`R2 fixture/Arena identity-topology comparison at T0-T3 -> resolve firearm discovery -> live Arena fire evidence -> D1 damage semantics -> telemetry freeze -> OFF baseline` + +## Camera DYING accounting checkpoint — 2026-09-13 + +The condition-based unchanged-policy sample reached the first natural +attribution accepts after four completed rounds. It recorded 44 DYING edges, +2 accepted, 20 rejected, and 22 not-evaluated candidates, so the required +terminal accounting invariant closes exactly: `44 = 2 + 20 + 22`. The two +accepted traces issued camera requests and targets, completed the existing +2-second hold, and returned normally. Trace `49` is the first complete +correlated chain. The 120-frame rolling buffer is retained locally under +`work/camera-dying-edge-rolling-20260913-223423/accepted/`. + +The 1 FPS rendered sample is visually sane and combat-centered, but does not +measure movement onset or arrival precisely. Camera behavioral acceptance is +therefore still **HOLD**. Do not widen the 400 ms window or add nearby-ally +tracking. Implementation provenance: `09a242eec`; accounting checkpoint: +`351bd91f1`; latest code/diagnostic-label fix: `fbb12f0d4`. + +## Physical camera execution checkpoint — 2026-09-13 + +The observation-only camera sampler now records native `CameraMan` offset, +requested target, scroll target, distance-to-target, and per-sample movement +while an event target is active. In a repeat native run, trace `6` produced 121 +samples and proved: accepted/request/target at `31833.970 ms`, movement onset +at `31850.637 ms` (+16.667 ms), arrival at `32683.987 ms` (+850.017 ms) with +19.063 px distance inside the 24 px observation tolerance, and hold/return at +`33834.010 ms` (+2000.040 ms). + +This closes the physical movement/onset/arrival sub-gate for one event. The +separate rendered screenshot attempt produced no frames, so no video-level +acceptance claim is made. Multi-round behavioral acceptance remains **HOLD**; +next is 3–5 complete rounds covering dedupe, return, survivor priority, +stability, and visual framing. Latest implementation checkpoint: +`cff83c0fe`. Preserved log: `work/camera-event-telemetry-20260913-2312/`. diff --git a/docs/DIRECT_LAUNCH_SPECTATOR.md b/docs/DIRECT_LAUNCH_SPECTATOR.md new file mode 100644 index 0000000000..4b9868c4f3 --- /dev/null +++ b/docs/DIRECT_LAUNCH_SPECTATOR.md @@ -0,0 +1,42 @@ +# Direct-launch spectator mode + +This fork’s debug-release executable is configured to start the existing autonomous spectator activity directly. The normal menu code and assets remain present; startup selects the activity before the normal menu loop can run. + +## Runtime configuration + +The activity and scene are: + +```ini +LaunchIntoActivity = 1 +DefaultActivityType = GAScripted +DefaultActivityName = Spectator Arena +DefaultSceneName = Ketanot Hills +``` + +These values are also present in `Userdata/Settings.ini` when testing locally. That file is runtime/user state and is not tracked by Git. + +## Source-controlled startup behavior + +`Source/Main.cpp` applies the same four values through the existing `ActivityMan` and `SceneMan` startup path after data modules load. This makes the standalone executable reproducible even when a user has a different local `Userdata/Settings.ini`. `ActivityMan` still initializes the normal menu systems, so the menu remains available for future rollback or a non-spectator build. + +## Restoring normal menu startup + +For a local runtime-only test, set `LaunchIntoActivity = 0` in `Userdata/Settings.ini`. For a normal-menu source build, remove or conditionally disable the four dedicated-spectator assignments in `Source/Main.cpp`, then rebuild. The activity registration and Lua spectator implementation should remain unchanged. + +## Verification + +- Repository checkpoint `spectator-soak-2026-08-31` remained intact before editing. +- `Debug Release|x64` rebuilt successfully with MSBuild after the source change. +- Fresh launches bypassed the menu path and produced: `Scene "Ketanot Hills" was loaded`, `SpectatorArena: autonomous AI vs AI spectator`, and `Activity "Spectator Arena" was successfully started`. +- The fresh-run console log recorded round 1 finishing (`BROWNCOATS WINS`), followed by automatic selection and arming of round 2. The current executable also contains the Spectator Arena lifecycle/watchdog changes documented in `SPECTATOR_ARENA.md`. +- No Lua-loading errors or abort-log update were observed. Repeated audio-device warnings and one `Finding Scene preset '' failed` message were emitted; the requested scene loaded successfully immediately afterward. These should be revisited separately if clean logs are required. + +The executable was allowed to run unattended for multiple minutes on repeated fresh launches. The observed round duration was long enough that each verification run reached one completed round and the next round armed before the process was closed for log flushing. + +## Distribution recommendation + +Keep the source startup selection in Git. Do not commit `Userdata/Settings.ini`; it is user/runtime state. A distribution package may include the four settings as a convenience, but the source-controlled startup path is the reproducible requirement for the dedicated spectator executable. + +## Current camera review + +The current Camera Director now uses the hybrid policy: soldier-following remains the default, with occasional, stability-limited switches to a stronger combat point of interest. Human review found the POI switch can be abrupt and late. The next revision should make the camera event-aware so a likely off-screen kill by the followed soldier briefly brings the event location into view. See `docs/HANDOFF_CAMERA_EVENT_AWARE.md`. diff --git a/docs/HANDOFF_CAMERA_EVENT_AWARE.md b/docs/HANDOFF_CAMERA_EVENT_AWARE.md new file mode 100644 index 0000000000..2b8bf1ce2f --- /dev/null +++ b/docs/HANDOFF_CAMERA_EVENT_AWARE.md @@ -0,0 +1,127 @@ +# ChatGPT handoff — event-aware Spectator Arena camera + +Continue development at: + +`C:\Users\mythz\Documents\Cortex-Command-Community-Project` + +Current branch state: + +- The pre-checkpoint source milestone is `48bf4c8e9 chore: ignore local dependency and Python cache artifacts`. +- The hybrid soldier-follow/temporary-POI implementation is present in `Data/Base.rte/Activities/SpectatorArena.lua`; its required helper module and pure test are packaged by this integrity checkpoint. +- The historical stability tag `spectator-soak-2026-08-31` must remain unchanged. + +## Current review snapshot + +The event-aware hybrid implementation is packaged as a self-contained dependency checkpoint, but remains **unaccepted pending human visual review**. The current camera acceptance decision is recorded in `docs/SPECTATOR_CAMERA_ACCEPTANCE_2026-09-13.md`. Do not describe the behavior as complete without reviewing rendered frames from the running camera. + +Files added or materially changed for this review build: + +- `Data/Base.rte/Activities/SpectatorArena.lua` +- `Data/Base.rte/Activities/SpectatorCameraEventLogic.lua` +- `tests/spectator_camera_event_test.lua` +- `docs/SPECTATOR_ARENA.md` +- `docs/HANDOFF_CAMERA_HYBRID_REVIEW.md` +- `docs/HANDOFF_CAMERA_EVENT_AWARE.md` + +The active priority is: + +```text +LAST_SURVIVOR +> CONFIDENT INFERRED EVENT +> SOLDIER FOLLOW +> ORDINARY COMBAT POI +> CENTER FALLBACK +``` + +### Engine/API research result + +No activity-level Lua API was found that directly exposes an actor killer, instigator, damage source, projectile owner, or durable projectile-to-victim attribution. Available and used signals are `AHuman.EquippedItem`, `HDFirearm.FiredFrame`, `HDFirearm.MuzzlePos`, `Actor:GetAimAngle(true)`, `Actor:IsDead()`, `Actor.Health`, `MOSRotating.WoundCount`, and stable `MovableObject.UniqueID`. Collision fields such as `HitWhatMOID` are frame-local and do not provide durable killer attribution. + +### Implemented conservative inference + +The followed soldier must have fired within `CameraRecentFireWindowMS = 400`. Exactly one opposing actor must then have an observed live-to-dead transition, or be removed after death was already observed. Unexplained disappearance alone is rejected. The victim must be `180–1200` pixels from the muzzle and within `CameraEventMinimumAimDot = 0.85` (about ±32 degrees) of the shot direction. Ambiguous, stale, nearby, out-of-cone, and previously handled victim candidates produce no event. + +This remains inference, not proof of authorship. It deliberately favors missed events over false cuts. In particular, a victim removed before the activity observes its dead state will not trigger an event. Reviewers should reject any implementation or documentation change that calls this engine-confirmed attribution. + +### Event state and timing + +- Default mode: `CAMERA_SOLDIER`. +- Event mode: `CAMERA_EVENT` at the victim's last meaningful position. +- Event hold: `CameraEventHoldMS = 2000`. +- Event cooldown: `CameraEventCooldownMS = 4000`. +- Dedupe: round-scoped handled-victim map keyed by `UniqueID`. +- Return: reuse the prior soldier if still alive; otherwise select a new living soldier. +- Last survivor: explicitly re-anchor to the lone living actor and suppress event/POI cuts. +- Round reset: clear tracked actors, shot context, event position, handled IDs, POI state, and cooldowns. + +### Verification completed + +- Standalone Lua behavior tests pass under `fengari-node-cli`. +- Lua syntax loading passes. +- `git diff --check` passes. +- `Debug Release|x64` builds with zero errors; existing MSBuild/LuaJIT warnings remain. +- A fresh `Cortex Command.debug.release.exe` process loaded `Ketanot Hills`, automatically started `Spectator Arena`, entered `BATTLE`, and remained responsive. +- Hardware-rendered capture works and sampled frames showed soldier-centered combat without an observed empty-terrain lock. +- `AbortLog.txt` predates this run; the known empty-scene warning still precedes a successful `Ketanot Hills` load. + +### Review still required + +The corrected build has not yet completed the required 3–5 visually reviewed rounds. A reviewer must confirm event usefulness/timing, no unrelated cut, smooth return, dedupe, last-survivor framing, ordinary POI secondary behavior, clean round reset, and no empty-terrain hold. Do not commit, mark complete, or advance to the stream-facing HUD until this review is accepted. + +## Human visual review + +The hybrid revision is acceptable as a baseline: + +- The camera usually follows a soldier. +- The general behavior works. +- The temporary POI switch is too abrupt. +- POI selection is usually late, after the interesting action has already happened. + +New requirement: if the followed soldier shoots and kills an opponent off screen, the camera should recognize that likely event and temporarily move to the event location so the viewer can see the result. Preserve normal soldier-following when no high-confidence event exists. + +## Requested next milestone: event-aware camera response + +Extend the hybrid camera conservatively: + +1. Keep `CAMERA_SOLDIER` as the default mode. +2. Investigate the available Cortex Command Lua/engine APIs for reliable kill attribution, projectile impact, wound, or firearm firing information. +3. Track enough recent context to associate an off-screen kill with the followed soldier when possible: + - followed actor identity; + - recent firing/attack activity; + - victim disappearance or death; + - victim position and/or last known position; + - event timestamp and confidence. +4. When confidence is high that the followed soldier caused a kill, enter a short event-focus mode at the victim/event location. +5. Use observation-target smoothing or a short transition so the move is readable rather than an abrupt hard cut. +6. Return to the followed soldier after the event hold, or immediately if the event focus becomes invalid. + +## Important constraints + +- Do not invent fake kill attribution. +- Do not trigger on every shot, actor disappearance, or unrelated death. +- If the engine cannot provide trustworthy attribution, implement the safest useful approximation and document its confidence limitations rather than pretending certainty. +- Prefer an event-focus lead: identify the likely event as early as possible from firing/projectile/wound state, then move toward the victim before or as the kill resolves. +- Keep event focus brief and cooldown-limited so soldier-follow remains dominant. +- Keep the existing POI cluster detector as a secondary occasional behavior. +- Do not change factions, weapons, loadouts, team size, map, combat rules, scoring, lifecycle state machine, watchdog, or direct launch. +- Do not add HUD, zoom cinematography, tournaments, map rotation, stream integrations, or viewer voting. + +## Camera quality targets + +- Soldier-follow remains the normal view. +- Event response should be useful without feeling like a hard cut. +- The camera should show the aftermath or active engagement caused by the followed soldier, including an off-screen kill when the event can be identified. +- No stale empty terrain, repeated triggers, or rapid oscillation. + +## Verification required + +- Build `Debug Release|x64`. +- Launch a fresh process and visually review several rounds. +- Confirm soldier-follow remains dominant. +- Confirm a deliberately observed off-screen kill triggers a useful event response if attribution is available. +- Confirm event response does not trigger repeatedly for one kill. +- Confirm return to soldier-follow after the event hold. +- Inspect `LogConsole.txt` and current abort/error logs. +- Do not commit until human visual review accepts the transition timing and usefulness. + +After acceptance, update `docs/SPECTATOR_ARENA.md`, synchronize the existing Drive documentation, update the existing Notion project record, and commit with a clear event-aware camera message. diff --git a/docs/HANDOFF_CAMERA_HYBRID_REVIEW.md b/docs/HANDOFF_CAMERA_HYBRID_REVIEW.md new file mode 100644 index 0000000000..af970642f3 --- /dev/null +++ b/docs/HANDOFF_CAMERA_HYBRID_REVIEW.md @@ -0,0 +1,53 @@ +# ChatGPT handoff — hybrid Spectator Arena camera revision + +Continue development at: + +`C:\Users\mythz\Documents\Cortex-Command-Community-Project` + +Current milestone: commit `9049540d0` (`Add automatic Spectator Arena camera director`). The historical stability tag `spectator-soak-2026-08-31` must remain unchanged. + +## Review finding + +The current Camera Director sometimes approaches active combat correctly, but the midpoint/cluster focus is not consistently readable. At other times it does not center on a soldier as reliably as the earlier implementation. The reliable soldier-centered behavior should become the basis again. + +## Requested next design + +Implement a conservative hybrid camera policy: + +1. Default to following a valid living combatant, preserving the earlier soldier-centered behavior. +2. Periodically evaluate nearby opposing actors for a meaningful point of interest. +3. Switch to the point of interest only when it is clearly stronger than the current soldier focus. +4. Hold the point of interest briefly, without hard cuts or jitter. +5. Return to a valid living soldier after the point-of-interest hold, or immediately if its anchor disappears. +6. Fall back to another living combatant, then the scene center. + +The camera should occasionally show action, not permanently abandon the soldier-follow basis. Keep the existing real-time combat, factions, weapons, map, scoring, watchdog, lifecycle state machine, and direct launch unchanged. + +## Constraints + +- Do not add zoom cinematography, stream HUD, event hooks, randomized factions/loadouts, tournaments, map rotation, or viewer integration. +- Reuse Cortex Command observation-target smoothing. +- Keep camera evaluation timer-driven and avoid per-frame log spam. +- Preserve last-survivor handling and safe non-`BATTLE` framing. + +## Suggested implementation questions + +- Store a `CameraMode` such as `SOLDIER_FOLLOW` or `POINT_OF_INTEREST`. +- Keep a `CameraFollowActor` and a separate `CameraPOIPosition`/score. +- Evaluate POI candidates on the existing interval, but require a meaningful score margin before leaving soldier-follow mode. +- Use a modest POI hold duration, then return to the best valid soldier. +- If the followed soldier dies, immediately select another living actor; do not wait for the POI timer. +- Prefer the selected soldier’s nearby engagement location as the POI rather than an arbitrary empty midpoint. + +## Verification required before implementation is accepted + +- Build `Debug Release|x64`. +- Launch a fresh `Cortex Command.debug.release.exe` process. +- Visually confirm the camera normally centers on a soldier. +- Visually confirm occasional switches to active combat points. +- Confirm no rapid jitter or empty-terrain lock. +- Confirm recovery when the selected soldier dies. +- Let several normal rounds complete and verify scoring/reset/watchdog behavior remains unchanged. +- Inspect `LogConsole.txt` and current abort/error logs. + +Do not claim completion until the hybrid behavior has been visually reviewed. After implementation, update `docs/SPECTATOR_ARENA.md`, synchronize the existing Drive documentation, update the existing Notion project record, and report the resulting commit and remaining issues. diff --git a/docs/PROJECT_STATUS_2026-09-02.md b/docs/PROJECT_STATUS_2026-09-02.md new file mode 100644 index 0000000000..0743a42459 --- /dev/null +++ b/docs/PROJECT_STATUS_2026-09-02.md @@ -0,0 +1,70 @@ +# Cortex Command Community Project — Development Status + +Date: 2026-09-02 +Branch: `spectator-random-factions` +Current HEAD: `840e14b50 Measure any-visible SHADOW contacts` + +## What the project is + +The project is a dedicated autonomous Spectator Arena build for Cortex Command Community Project. It launches an 8v8 AI-vs-AI activity on `Ketanot Hills`, with randomized faction matchups and an automated lifecycle: + +`spawn -> touchdown/release -> battle -> winner detection -> score update -> next round` + +The normal menu and engine systems remain available in the source, but the debug full executable is configured to launch the spectator activity directly. + +## Accepted behavior + +V11/V11.1 behavior is the current frozen gameplay baseline: + +- Actors spawn in SENTRY mode. +- Actors must touch terrain before the existing distributed pursuit logic releases them. +- Release is individual, and landed teammates are redistributed when another teammate lands. +- NativeHumanAI remains responsible for aiming, firing, locomotion, jetpack behavior, digging, and local reactions. + +No AI V2 tactical orders are enabled. + +## AI V2 development state + +The AI V2 work is intentionally staged: + +1. Behavior-neutral controller state and metrics exist in `Data/Base.rte/Activities/SpectatorAIController.lua`. +2. `SpectatorArena.lua` integrates the controller in explicit `AI_V2_MODE = "OFF"`. +3. Actors are registered at spawn, released at the accepted touchdown boundary, and sampled at 500 ms cadence after all actors release. +4. `AI_V2_CONFIG` is emitted through telemetry. + +The controller currently does not change actor AIMode, waypoints, controllers, targeting, combat, or camera behavior. SHADOW, TASKS, and TACTICAL modes remain future work and require baseline evidence first. + +## Telemetry and baseline evidence + +Telemetry is encoded as `SPECTATOR_EVENT event=...` records. The engine prefixes Lua console output with `PRINT:`; the parser accepts this real runtime form. The activity snapshots the console buffer to the ignored file `SPECTATOR_EVENT_LOG.txt` after telemetry events because the legacy `LogConsole.txt` is only written during orderly shutdown. + +Observed preliminary evidence: + +- Four completed rounds across two fresh OFF-mode runs. +- Winners: `RONIN_WINS=2`, `DUMMY_WINS=2`. +- Watchdog events: `0`. +- Observed completed-round duration range: `34,015.306–60,464.248 ms`. +- The required activation baseline is at least 50 completed rounds; it has not been reached. + +The report tool is `tools/spectator_soak_report.py`. It reports completed/incomplete rounds, winner counts, durations, and watchdog events. + +## Camera state + +The event-aware hybrid camera work is present locally but remains uncommitted and requires human visual acceptance over 3–5 complete rounds. Review priorities are last survivor, camera event, soldier follow, combat POI, then center. No camera acceptance should be inferred from automated tests. + +## Verification + +Passing checks include: + +- Lua controller, telemetry, and camera-event tests under cached Fengari. +- Python soak-report and integration tests. +- `git diff --check`. +- Debug Release x64 MSBuild. +- Fresh executable runtime capture with expected activity, configuration, round, and battle telemetry. + +## Recommended next decisions + +1. Continue the OFF-mode soak to 50 completed rounds and preserve the report as the activation baseline. +2. Perform human visual camera review and either accept or revise the uncommitted camera work. +3. SHADOW plumbing has been exercised, but the first sample had no LOS-positive or firing-positive observations. Deterministic any-visible LOS selection and aggregate cost/contact metrics are now implemented; capture a richer semantic SHADOW sample next. +4. Do not enable TASKS or TACTICAL orders until SHADOW evidence and the 50-round OFF baseline are reviewed. diff --git a/docs/PROJECT_STATUS_2026-09-03.md b/docs/PROJECT_STATUS_2026-09-03.md new file mode 100644 index 0000000000..9f69de1a20 --- /dev/null +++ b/docs/PROJECT_STATUS_2026-09-03.md @@ -0,0 +1,80 @@ +# Cortex Command spectator AI V2 — project status + +Date: 2026-09-03 +Branch: `spectator-random-factions` +Checkpoint HEAD: `1bff421aefaa5f383aa815502d150a885c5b2735` (`Trace Arena firearm attachment boundary`) + +Local evidence state: post-checkpoint R1B/A1 runtime artifacts are present in +the working tree and are authoritative for this status. The working tree is +intentionally dirty with unrelated camera/research work and diagnostic output; +none of that work is being reset or downgraded. + +## Current conclusion + +The controlled firearm chain is proven end to end: inventory handoff, equipped +idle state, safe activation, physical discharge, native `FiredFrame`, and the +durable SHADOW latch all pass. The Arena also progresses normally after spawn, +and a three-round SHADOW smoke produced real LOS-positive contacts and contact +transitions without watchdog/runtime errors. + +The current blocker is narrower: Arena actor-owned firearm discovery is empty by +the first enumerable activity update. R1B retained-reference evidence shows the +firearm wrapper remains valid and attached, with no matching world item, so this +is an unresolved attachment/discovery-path mismatch rather than a proven +deletion or Lua-wrapper lifetime failure. Because live Arena firearm discovery +remains zero, the smoke cannot yet validate live fire-latch events. F3 should +not be modified unless new Arena evidence contradicts the controlled 10/10 +result. + +## Stable project boundaries + +- Production default remains `AI_V2_MODE = "OFF"`. +- Startup remains `Spectator Arena` on `Ketanot Hills`. +- V11/V11.1 touchdown/release and distributed pursuit behavior remain the + accepted gameplay baseline. +- NativeHumanAI continues to own aiming, firing, movement, jetpack use, + digging, and local reactions. +- OFF and SHADOW instrumentation must not issue tactical orders. +- Camera/research work is independent and remains untouched by the current AI + diagnostics. +- No merge or pull request is authorized at this checkpoint. + +## Evidence gates + +| Gate | Status | Meaning | +| --- | --- | --- | +| B5–B7 | PASS | Controlled inventory, idle equipment, and activation safety are proven. | +| F1–F3 | PASS | A physical shot, native fire signal, and 10/10 durable latch are proven. | +| A0 spawn | PASS | All 16 actors cross creation, handoff, insertion, and registration. | +| A1 progression | PASS | No post-spawn blocker; release, combat, and round completion occur. | +| Arena visibility/contact | PASS | Three rounds produced positive LOS and acquire/loss transitions. | +| Arena weapon discovery | FAIL | 0 equipped discoveries across 73,697 samples. | +| Arena live fire latch | BLOCKED | Cannot evaluate until firearm lifetime is resolved. | +| D1 damage semantics | BLOCKED | Follows Arena live-fire evidence. | +| 50-round OFF baseline | BLOCKED | Preserve until telemetry/runtime schema is trustworthy. | +| SHADOW promotion | BLOCKED | Requires live Arena weapon/fire evidence and larger sample. | +| TASKS-A | NO-GO | No tactical behavior activation. | +| Merge / PR | NO-GO | Branch remains isolated. | + +## Next recommended action + +Run R2: one controlled known-good fixture/Arena identity comparison at T0–T3, +with matching direct `Actor`, `FGArm`/`BGArm`, `HeldDevice`, weapon, +attachment/parent/root, `UniqueID`/`MOID`/`RootMOID`, and bounded world-item +fields. Change no Arena behavior. The retained-reference experiment already +rules out simple wrapper loss; the remaining question is which attachment or +discovery path owns the still-attached object. + +After Arena firearm discovery and live fire-latch evidence pass, proceed in +this order: + +```text +D1 damage semantics +→ telemetry schema freeze +→ canonical 50-round OFF baseline +→ larger SHADOW evidence run +→ formal SHADOW promotion review +→ TASKS-A consideration +``` + +Detailed evidence: `docs/SPECTATOR_ARENA_A1_FIREARM_RECON_REPORT_2026-09-03.md`. diff --git a/docs/PROJECT_SUMMARY_AND_NEXT_STEPS_2026-09-02.md b/docs/PROJECT_SUMMARY_AND_NEXT_STEPS_2026-09-02.md new file mode 100644 index 0000000000..01ea7e5adc --- /dev/null +++ b/docs/PROJECT_SUMMARY_AND_NEXT_STEPS_2026-09-02.md @@ -0,0 +1,151 @@ +# Cortex Command Community Project — Project Summary and Next Steps + +Date: 2026-09-02 +Branch: `spectator-random-factions` + +## Executive summary + +This project is a Cortex Command community-project checkout with an autonomous spectator arena. The current work is a staged AI V2 foundation designed to improve squad-level coordination without replacing the engine's native human AI or changing the existing touchdown/release safety behavior. + +The implementation is intentionally conservative: production remains `AI_V2_MODE = "OFF"`. AI V2 currently supports round-scoped observations and pure decision helpers, while SHADOW mode is available for proposal/telemetry capture. No behavior-enabled comparison has been made yet. + +## Current architecture + +- `SpectatorArena.lua` owns round setup, faction selection, actor spawning, touchdown gating, battle lifecycle, native AI configuration, telemetry, and camera behavior. +- `NativeHumanAI` remains responsible for local actor behavior: aiming, firing, reload, locomotion, jetpack use, digging, and immediate reactions. +- `SpectatorAIController.lua` is the AI V2 coordinator/state layer. It currently stores observations and deterministic rules without directly controlling actors. +- `SpectatorTelemetry.lua` emits structured `SPECTATOR_EVENT` records and can snapshot them to `SPECTATOR_EVENT_LOG.txt`. +- `spectator_soak_report.py` parses runtime event logs and reports round counts, winners, durations, incomplete rounds, and watchdog events. + +## AI V2 design + +The approved design uses a coordinator above native AI with four intended modes: + +- `OFF`: current production behavior. +- `SHADOW`: observe and propose, but never apply actor control. +- `TASKS`: assign bounded squad tasks. +- `TACTICAL`: apply carefully gated tactical orders after evidence supports activation. + +The intended squad model is two four-actor squads per team. Planned tasks are `PRESSURE`, `MANEUVER`, `SEARCH`, `REGROUP`, and `RECOVER`. The design includes engagement locks, imperfect contact memory, task hysteresis, soft target reservations, progress/recovery escalation, and weapon/environment scoring. + +## Implemented AI V2 capabilities + +### Contact and engagement state + +- Frozen direct-contact positions. +- Lower-confidence memory/shared reports do not rewrite the frozen direct position. +- Configurable contact expiry, currently defaulting to 3000 ms. +- Engagement locks with explicit expiry. + +### Tasks, reservations, and recovery + +- Task assignment hysteresis prevents rapid task churn. +- Target reservations enforce a per-target limit and remove stale reservations. +- Progress observations escalate recovery one stage at a time, bounded by a configurable maximum. + +### Weapon and environment scoring + +- Pure weapon classification into `CLOSE`, `MID`, `LONG`, or `UNKNOWN`. +- Destination scoring considers distance, cover, line of sight, and threat. +- Candidate destination selection rejects marginal improvements to reduce oscillation. + +### SHADOW observations + +After all living actors pass the existing touchdown/release gate, SHADOW mode can observe: + +- nearest opponent and distance; +- line of sight; +- current-frame firearm firing; +- health and previous health; +- current AI waypoint and path state; +- progress toward the current waypoint; +- recovery stage. + +These observations emit `AI_SHADOW_OBSERVATION` telemetry and do not set AIMode, add or clear waypoints, alter position, alter health, alter inventory, or inject controller input. + +Telemetry performance correction: `Telemetry.Emit()` is now cheap and no longer snapshots the full console. Explicit snapshots occur at low-frequency round-result boundaries, eliminating per-observation disk writes during SHADOW. + +## Verified engine APIs + +Observation-safe APIs include actor health/previous health, aim angle, last AI waypoint, move-path state, dig/jump properties, firearm firing/muzzle state, wound count, SceneMan distance/ray queries, MovableMan local MO queries, AI timing values, and bounded path-calculation interfaces. + +Mutating interfaces such as `ClearAIWaypoints`, `AddAISceneWaypoint`, `AddAIMOWaypoint`, `SetMovePathToUpdate`, actor setters, and controller-input methods are reserved for future TASKS/TACTICAL work. No direct killer/instigator binding was identified, so future contact attribution must remain conservative and inference-based. + +## Verification status + +Passing checks: + +- Lua controller tests under the cached Fengari runner. +- Lua telemetry test. +- Python test suite: 3 tests. +- SHADOW integration source tests: 3 tests. +- `git diff --check`. +- `RTE.sln` Debug Release x64 build with 0 errors. + +The build still emits existing compiler warnings; no new build failure is present. + +## Runtime evidence + +Telemetry transport was verified through activity-scoped `SPECTATOR_EVENT_LOG.txt` snapshots and orderly shutdown behavior. The preliminary OFF sample contains four completed rounds across two short runs: + +- Ronin wins: 2. +- Dummy wins: 2. +- Watchdog events: 0. +- Completed-round durations: approximately 34.0–60.5 seconds. + +This is not an activation baseline. The approved evidence gate requires at least 50 completed OFF-mode rounds before defining activation thresholds. + +## Known limitations and discrepancies + +- AI V2 scoring helpers are implemented and tested but are not yet driving actor behavior. +- The first non-default SHADOW plumbing smoke trace passed instrumentation, but it produced no LOS-positive or firing-positive observations. +- The follow-up evidence phase now evaluates all living opponents for LOS, selects the nearest visible opponent, and records aggregate visibility, contact-transition, actor-skip, and execution-cost metrics. +- A native three-round SHADOW smoke was completed after the Windows Debug Release x64 build. It showed both teams and zero pre-touchdown observations/watchdogs, but still had zero visible opponents, LOS positives, firing positives, and contact transitions; reported execution cost was zero and is not yet trusted. +- A sensor-validation follow-up replaced obstacle-ray boolean interpretation with explicit `CastMORay` target/root-MOID matching. Three fresh rounds produced nonzero CPU-cost measurements (185/276/270 ms) and durable damage events, but still zero visible opponents, LOS positives, fire events, or contact transitions. Raw ray IDs are now recorded; semantic validation remains blocked. +- No claim can be made about improved win rate, tactical quality, CPU/UPS impact, recovery quality, or hidden-position violations. +- The nearest-enemy observation is conservative but does not provide direct killer/instigator attribution. +- The existing camera-review files are separate user work and remain dirty/uncommitted. + +## Recommended next steps + +1. Validate LOS semantics with deterministic tests and distinguish nearest-opponent LOS from any-visible-opponent LOS. +2. Add aggregate counters and measured execution-cost fields to the next SHADOW trace. +3. Run a second semantic SHADOW smoke requiring LOS-positive, contact, firing, and damage evidence. +4. Complete the 50-round OFF baseline and record winner distribution, duration distribution, watchdog rate, incomplete rounds, runtime stability, and CPU/UPS if available. +5. Compare SHADOW observations against the OFF baseline, then request explicit approval before changing the default from OFF or applying TASKS behavior. +6. Only after evidence supports it, implement one narrow behavior-enabled experiment with rollback and a matched OFF comparison. + +## Recent committed work + +- `369a1cc45` — Record spectator AI V2 shadow implementation status +- `143439d54` — Add spectator AI V2 shadow observations +- `6c41463a4` — Add deterministic spectator AI weapon environment scoring +- `7bfefb573` — Add spectator AI V2 task and recovery rules +- `de6f348f1` — Add spectator AI V2 contact observations +- `37b32e3af` — Audit local spectator AI APIs +- `f7c27b0bf` — Plan spectator AI V2 shadow implementation +- `b7be3c4c0` — Specify spectator AI V2 shadow design + +## Review request + +The next reviewer should assess whether the SHADOW observation boundary is genuinely behavior-neutral, whether contact/progress semantics are sound, whether the proposed evidence gates are sufficient, and what smallest safe behavior-enabled experiment should follow the evidence phase. + +## Latest SHADOW selection fix — 2026-09-02 + +Commit `cd4ed43b3` fixes a real Lua dispatch defect in SHADOW: the static +`SelectVisibleOpponent` helper was invoked with `:` instead of `.`. That +shifted its arguments and produced zero selected visible opponents even when +native telemetry classified a ray as `TARGET`. + +The checkpoint also adds native-AI-aligned eye/body probes, deterministic ray +classifications (`TARGET`, `TARGET_ROOT`, `NO_MOID`, `BLOCKED`), and probe-count +telemetry. Lua controller tests, SHADOW source-integration tests, and the +Windows `Debug Release|x64` build pass. Production remains `AI_V2_MODE = "OFF"`. + +A pre-fix native trace demonstrated the defect by recording +`rayClassification=TARGET` alongside `visibleOpponentCount=0`. A fresh +post-fix three-round smoke has not yet been captured because the subsequent +direct-launch process did not enter the spectator activity or write new events. +This is a runtime-launch issue, not evidence that the corrected selector has +failed. The semantic SHADOW gate remains pending; TASKS-A, merge/PR, and the +canonical 50-round OFF baseline remain blocked. diff --git a/docs/SPECTATOR_AI_V2_SHADOW_SMOKE_REPORT_2026-09-02.md b/docs/SPECTATOR_AI_V2_SHADOW_SMOKE_REPORT_2026-09-02.md new file mode 100644 index 0000000000..6d147f1d08 --- /dev/null +++ b/docs/SPECTATOR_AI_V2_SHADOW_SMOKE_REPORT_2026-09-02.md @@ -0,0 +1,114 @@ +# Spectator AI V2 SHADOW Smoke Report + +Date: 2026-09-02 +Branch: `spectator-random-factions` +Test mode: temporary non-default `SHADOW` +Production mode after test: `OFF` + +## Purpose + +Validate the SHADOW instrumentation path and its lifecycle boundary before collecting a larger semantic/performance sample. This was not a behavior comparison and does not justify TASKS activation. + +## Captured result + +- Rounds started: 4. +- Rounds completed: 3. +- Incomplete final round: yes; the process was stopped during round 4 after the smoke target was reached. +- Watchdog events: 0. +- Parsed `AI_SHADOW_OBSERVATION` records: 2,393. +- Observation counts by round: round 1 = 768, round 2 = 714, round 3 = 735, round 4 = 176. +- `hasLOS=true` records: 0. +- `hasLOS=false` records: 2,393. +- `firing=true` records: 0. +- Completed-round durations: 37,565.164 ms; 45,114.862 ms; 47,598.096 ms. +- Winners: Ronin twice; Techion once. + +## Invariants checked + +- No SHADOW observation appeared before the first `AI_TOUCHDOWN_ALL_RELEASED` marker. +- SHADOW emitted observations across both teams after release. +- No actor-control calls were introduced; source-level safety tests and build remain passing. +- The observation payload contained opponent ID and distance but no opponent X/Y coordinates. +- The exact test process was stopped after evidence capture and the source mode was restored to `OFF`. + +## Interpretation + +The smoke test validates event transport, round lifecycle ordering, post-release gating, and basic observation volume. It did not exercise direct contact acquisition, engagement locks, firing capture, or meaningful LOS differentiation because this trace contained no LOS-positive or firing-positive samples. + +The current 500 ms observation cadence should not be treated as a reliable firing-event detector. Fire and damage latches now preserve transitions observed by the SHADOW loop, but a future implementation should capture high-frequency timestamps and let the slower tactical loop consume those observations. + +The log's nearest-enemy ID/distance fields are diagnostic world-truth observations. They must remain separate from team knowledge and must not become strategic targets when LOS is false. + +## Performance follow-up + +The original smoke path exposed a telemetry architecture problem: `Telemetry.Emit()` called `ConsoleMan:SaveAllText()` for every event. That meant each 500 ms SHADOW batch could trigger repeated full-console snapshots. + +The fix separates cheap event emission from explicit `Telemetry.Snapshot()`. The arena now snapshots at `ROUND_RESULT` only. A runtime confirmation produced 894 SHADOW observations during round 1; no snapshot file existed during the first 30 seconds, and the file appeared only after the round-result boundary. This confirms that SHADOW observations no longer force per-observation disk snapshots. + +This validates the I/O hypothesis but is not a complete frame-pacing benchmark. CPU/UPS and per-update timing counters remain future work. + +## Decision + +- SHADOW smoke: pass for instrumentation plumbing. +- SHADOW semantic readiness: incomplete; collect a richer trace. +- TASKS activation: no-go. +- TACTICAL activation: strong no-go. +- Production default: remain `OFF`. + +## Next evidence step + +Capture a larger SHADOW sample using the deterministic any-visible LOS selection and the new counters for visible opponents, visibility checks, contact acquisition/loss, actor skips, and AI V2 execution cost. Then complete the 50-round OFF baseline for comparison. + +## Follow-up native semantic smoke + +A native Windows `Debug Release|x64` build completed successfully before this run. A temporary SHADOW run then completed three rounds before being stopped and the source mode was restored to `OFF`. + +- Observations: 2,597 across the three completed-round summaries. +- Both teams represented: team 0 = 1,411 observations; team 1 = 1,192 observations. +- Pre-touchdown observations: 0. +- Watchdogs: 0. +- Visible opponents: 0. +- LOS-positive observations: 0. +- Firing-positive observations: 0. +- Contact acquisitions/losses: 0/0. +- Reported `shadowObservationTimeMS`: 0 in all three summaries; this is not accepted as valid execution-cost evidence and requires instrumentation follow-up. + +This validates native loading, lifecycle gating, telemetry emission, and runtime stability only. It does not pass the semantic SHADOW gate and does not justify TASKS-A. + +## Sensor-validation follow-up + +After the failed smoke, the LOS sensor was changed from `CastObstacleRay` to `CastMORay` with explicit target/root-MOID matching. The native build passed again, and a fresh three-round SHADOW run completed. + +- Observations: 2,637 across the three summaries. +- Both teams represented: team 0 = 1,423; team 1 = 1,244. +- Pre-touchdown observations: 0. +- Watchdogs: 0. +- Visible opponents: 0. +- LOS-positive observations: 0. +- Fire events: 0. +- Contact acquisitions/losses: 0/0. +- Damage events: 2,707; 153; and 239 by round. +- SHADOW CPU time: 185 ms; 276 ms; and 270 ms by round. + +Raw ray fields are now included in observations. Sampled first-hit values were commonly `hitMOID=255` with valid target MOIDs, indicating that the target was not the first ray hit. This confirms the sensor is no longer treating a target hit as a generic obstacle, but it does not yet establish useful direct visibility during the observed combat positions. + +The semantic gate remains failed. The next step is an engine-backed deterministic LOS fixture or controlled scene, not TASKS-A or the canonical OFF baseline. + +## Selection-dispatch correction + +Native diagnostic telemetry subsequently produced `rayClassification=TARGET` +with `visibleOpponentCount=0`. Investigation found that +`SelectVisibleOpponent` is a static controller helper but was called with Lua +instance syntax (`:`), shifting its arguments and preventing all visible +selection. Commit `cd4ed43b3` corrects that call to `.`. + +The same checkpoint adds native-aligned body/eye probe ordering, deterministic +ray classifications, and `losProbeRays` accounting. The controller and source +integration tests pass, and the native Windows `Debug Release|x64` build passes. +Production source was restored to `AI_V2_MODE = "OFF"` after each test. + +No fresh post-fix completed round is recorded yet: the final temporary SHADOW +launch remained responsive but did not begin the spectator activity or update +the event log. Therefore the selector fix is implementation-verified, not +semantic-runtime-validated. Do not advance to TASKS-A, merge/PR, or the 50-round +OFF baseline until a fresh direct-launch semantic smoke completes. diff --git a/docs/SPECTATOR_ARENA.md b/docs/SPECTATOR_ARENA.md new file mode 100644 index 0000000000..8fe9e54736 --- /dev/null +++ b/docs/SPECTATOR_ARENA.md @@ -0,0 +1,203 @@ +# Spectator Arena + +## Purpose + +Spectator Arena is an autonomous AI-vs-AI Cortex Command activity intended to become a dedicated livestream/spectator application. It keeps the original actors, weapons, AI, projectile physics, gore, particles, destructible terrain, and map systems active while the player observes the match. + +The current content is deliberately conservative: two autonomous teams of eight fight in real time on `Ketanot Hills`. + +## Current local state + +The authoritative local checkout is on branch `spectator-random-factions` at +`1bff421aefaa5f383aa815502d150a885c5b2735` (`Trace Arena firearm attachment +boundary`). The V11/V11.1 AI baseline is committed and accepted for now. +Post-checkpoint R1B/A1 runtime evidence is retained locally; the working tree +also contains unrelated uncommitted camera/research work and diagnostic +artifacts that must not be reset. See +`docs/CORTEX_COMMAND_KNOWLEDGE_BRIDGE.md` for the cross-environment +source-of-truth and continuation protocol. + +The current diagnostic conclusion is **UNRESOLVED attachment/discovery-path +mismatch**: a retained Arena firearm wrapper remains valid and attached after +the first enumerable update, but `FGArm.HeldDevice` and ordinary +foreground/background/inventory discovery are empty, with no matching bounded +`MovableMan.Items` entry. This does not justify a production workaround. +Production `AI_V2_MODE` remains explicitly `OFF`. + +## Startup flow + +The debug-release executable applies the dedicated startup selection in `Source/Main.cpp`: + +1. Initialize the normal Cortex Command managers and load data modules. +2. Select activity type `GAScripted`. +3. Select activity `Spectator Arena`. +4. Select scene `Ketanot Hills`. +5. Set direct activity launch, bypassing the normal menu loop. + +Launch from the repository root with: + +```powershell +.\Cortex Command.debug.release.exe +``` + +The equivalent runtime settings are `LaunchIntoActivity = 1`, `DefaultActivityType = GAScripted`, `DefaultActivityName = Spectator Arena`, and `DefaultSceneName = Ketanot Hills`. `Userdata/Settings.ini` remains runtime state and is not source controlled. + +## Activity implementation + +The active registration is in `Data/Base.rte/Activities.ini`; the implementation is `Data/Base.rte/Activities/SpectatorArena.lua` with Lua class `SpectatorArena`. + +The activity preserves the existing faction pool, faction-owned weapons, eight actors per side, real-time AI movement/combat, spectator camera, elimination detection, cumulative score, and automatic round restart. + +## Procedural close-quarters environments — proposed extension + +The fixed `Ketanot Hills` scene remains the current verification baseline. A +planned environment extension will add compact, seedable room-grammar layouts +for short-form spectator encounters: authored room archetypes assembled through +socket constraints, topology/readability scoring, encounter seeds, and a +bounded runtime director for doors, hazards, lights, and route changes. + +This is a design-only proposal at present. It is intentionally separated from +the accepted round lifecycle, NativeHumanAI behavior, camera acceptance, and +AI V2 activation gates. See +`docs/SPECTATOR_PROCEDURAL_CLOSE_QUARTERS_DESIGN_2026-09-05.md` for the +detailed contract, manifests, staged gates, and fixed-scene fallback. + +## Round state machine + +The lifecycle is explicit in `SpectatorArena.lua` and emits one concise transition log per state change: + +```text +BOOT -> PREPARE_ROUND -> SPAWN_TEAMS -> BATTLE + -> ROUND_RESULT -> ROUND_RESET -> PREPARE_ROUND +``` + +- `BOOT`: initialize teams, score, timers, and spectator view. +- `PREPARE_ROUND`: prepare the next round number and faction selection. +- `SPAWN_TEAMS`: create and place both eight-person teams with faction-owned loadouts. +- `BATTLE`: begin the combat timer once both teams have living actors. +- `ROUND_RESULT`: resolve a winner/draw once, incrementing score at most once. +- `ROUND_RESET`: remove only the previous round’s team actors, then spawn the next round. + +## Camera Director v1 + +During `BATTLE`, `UpdateCameraDirector` evaluates candidate focus areas every `500` ms. Each living actor is scored against nearby opposing actors within a 260-pixel combat radius: multiple nearby enemies dominate the score, with proximity providing a tie-break contribution. The selected focus is the midpoint between the best opposing pair, so the viewer sees the interaction rather than an arbitrary soldier. + +The director holds a focus for at least `1500` ms and only switches when a new score is at least `1.25x` the current score, unless the current anchor has disappeared. This limits jitter while still recovering cleanly when a target dies. If one side has only one living actor, that candidate receives a strong priority bonus. Outside `BATTLE`, combat scoring is disabled: result framing holds the last meaningful focus, while preparation/reset framing returns to the deterministic scene center. + +The centralized controls are `CameraEvaluationIntervalMS`, `CameraMinimumHoldMS`, and `CameraSwitchThreshold`. Fallback priority is strongest opposing interaction, nearest opposing pair, any living combatant, then the scene center. Existing Cortex Command observation-target smoothing remains responsible for the final camera movement. + +### Event-aware hybrid camera — review build + +The current uncommitted review build uses the explicit priority `LAST_SURVIVOR > CAMERA_EVENT > CAMERA_SOLDIER > CAMERA_POI > CAMERA_CENTER`. A real living soldier remains the normal anchor. The earlier combat-cluster midpoint remains available only as an occasional secondary POI; Cortex Command's observation-target scrolling supplies transition smoothing. + +The engine exposes no direct killer or instigator field to this activity. The implementation therefore uses conservative inference from supported Lua APIs: `AHuman.EquippedItem`, `HDFirearm.FiredFrame`, `HDFirearm.MuzzlePos`, `Actor:GetAimAngle(true)`, `Actor:IsDead()`, `Actor.Health`, `MOSRotating.WoundCount`, and `MovableObject.UniqueID`. A camera event is eligible only when the followed soldier fired within `400` ms, exactly one opposing actor has an observed live-to-dead transition (or is removed after death was already observed), the victim is `180–1200` pixels away, and the victim lies within an aim dot threshold of `0.85` (about ±32 degrees). Unexplained disappearance, ambiguous deaths, stale fire, nearby deaths, out-of-cone deaths, and already-handled victim IDs are rejected. + +An accepted event holds the victim's last meaningful position for `2000` ms and starts a `4000` ms event cooldown. The prior soldier reference is retained and reused if still alive; otherwise a new living soldier is selected. Handled victim IDs, tracked actor state, shot context, event state, and cooldown state are cleared between rounds. One event can therefore produce at most one response per round. This is intentionally a high-miss/low-false-positive approximation: kills removed before a death state can be observed may receive no cut. + +### Engagement camera offset — review build + +When the followed actor begins a firing action, the director searches the +opposing living roster for the nearest actor in the shot direction. If the +target is at least `300` pixels away and within `1600` pixels and the aim dot +is at least `0.80`, the observation target moves to a frame interpolated `55%` +toward that enemy. The engagement frame holds for `900` ms, then returns to +normal soldier follow; a `1400` ms cooldown prevents repeated oscillation. +Death/event framing keeps priority over this short presentation cue. + +The detector prefers the native `HDFirearm.FiredFrame`/`RoundsFired` signals +and can fall back to a rising `Controller.WEAPON_FIRE` edge when the live +actor wrapper exposes no firearm. The fallback uses the actor position and aim +angle, and remains gated by the same opposing-target cone and distance checks. +Pure selection/frame tests and a native Debug Release smoke run passed; visual +acceptance still requires a longer targeted capture of several deliberate +opposite-edge exchanges. + +## Winner and score logic + +The first team with no living actors loses. If both teams are eliminated, the result is a draw. `RoundOver` prevents duplicate results, score increments, or reset operations. The score remains cumulative for the life of the activity. + +## Watchdog + +`MaxRoundDurationMS` is centralized in `StartActivity` and is currently `300000` ms (five minutes). The timer starts when the round enters `BATTLE`. + +On timeout, the activity logs `SpectatorArena: WATCHDOG_TIMEOUT`, compares living actor counts, and awards the round to the team with more survivors. Equal survivor counts are recorded as a draw; no fake kills are created. The same `RoundOver` guard prevents a timeout from producing a second result. + +## Recovery behavior + +The round reset removes surviving team actors without creating artificial gibs, preserves the scene’s real combat damage, and starts the next round automatically. The spectator camera falls back to the center when no valid combatant exists. Spawn and one-team failure cases resolve through the same guarded result path rather than issuing duplicate resets. + +## Verification and known issues + +The event-aware review build passes its standalone Lua behavioral tests and Lua syntax check. Its required helper module and pure test are now packaged with the activity so a clean checkout is self-contained. The source was rebuilt as `Debug Release|x64` with zero build errors. The separate engagement-offset follow-up was visually reviewed in an isolated Debug Release capture on 2026-09-13: 50 frames over approximately 25 seconds across rounds 28→29, readable opposing combatants, ordinary follow return, and no sampled jitter or empty-terrain lock. **Visual sanity: PASS. Camera acceptance: HOLD.** A subsequent review-only trace run covered five complete rounds plus a final bounded round and recorded 12 `CAMERA_EVENT_REMOVAL_UNCONFIRMED` records, but zero observed deaths, attribution accepts, event requests, or target issuances. The rendered sample remained visually sane, but no attributable cut occurred. The likely boundary is actor removal before a live→dead observation, with several removals also outside the 400 ms attribution window. The HUD remains **ACCEPTED_FOR_THIS_REVIEW_BUILD** independently. + +The stream-facing HUD follow-up is also **ACCEPTED_FOR_THIS_REVIEW_BUILD**. It uses Lua-only screen primitives for upper-corner team panels, a centered round/time header, combat pressure, and the existing centered result banner. A 100-frame native capture showed readable battle HUD placement and the transition into round 2; the runtime log reached `ROUND_RESULT` without Lua errors. The result banner itself was not retained in a frame because the final capture window ended immediately before that transition. + +The last pre-checkpoint source milestone remains `48bf4c8e9` (`chore: ignore local dependency and Python cache artifacts`). The camera dependency packaging and this acceptance decision are separate from visual behavior acceptance. The existing deterministic short-timeout watchdog evidence and historical soak checkpoint remain unchanged. + +The runtime still emits an empty-scene-preset warning before successfully loading `Ketanot Hills`, plus repeated sound-device initialization warnings. These are known warnings and are separate from the Lua lifecycle changes. The historical checkpoint/tag `spectator-soak-2026-08-31` remains unchanged. + +### Native lifecycle boundary — 2026-09-13 + +Source inspection confirms that `g_ActivityMan.Update()` runs before +`g_MovableMan.Update()` in `Source/Main.cpp`. Native actor update changes an +actor to `DYING` at health `<= 0`, later changes it to `DEAD`, and the following +MovableMan pass moves dead actors out of the live list and team rosters. Lua +exposes `Status`, `Health`, `PrevHealth`, `IsDead()`, and the `DYING`/`DEAD` +values, so `DYING` is the last authoritative in-roster signal available to the +activity. The runtime trace and source ordering therefore identify lifecycle +observability—not camera presentation—as the primary blocker. The next test is +to correlate `DYING` under the existing conservative attribution gates; no gate +widening or camera-v2 ally tracking is authorized by this checkpoint. + +### DYING-edge runtime experiment — 2026-09-13 + +The first diagnostic runtime experiment observed the repaired lifecycle seam: +44 followed-shooter fire observations produced 11 one-shot +`CAMERA_EVENT_DYING_OBSERVED` records across an approximately 90-second native +run. Nine candidates were rejected by the unchanged attribution policy (seven +`STALE_SHOT`, two `DISTANCE`); three unrelated removals remained +`CAMERA_EVENT_REMOVAL_UNCONFIRMED`. No attribution accept, camera request, +target issuance, hold completion, or return occurred. This is **lifecycle +observation PASS**, not camera behavioral acceptance; the camera remains +**HOLD** pending one naturally accepted correlated event. Rendered frames were +reviewed for sanity only and remained readable. + +Recent live verification also reached `BATTLE` but produced no `SPECTATOR_EVENT` records in `LogConsole.txt`; the telemetry helper passes standalone tests, but live telemetry transport/module resolution remains unresolved. + +## Rollback and next milestones + +To restore normal menu startup, set `LaunchIntoActivity = 0` for a runtime-only test and remove or conditionally disable the dedicated startup assignments in `Source/Main.cpp` for a normal-menu source build. Do not delete the original menu systems. + +Next priorities are: + +1. test the Lua-visible `DYING` transition as the authoritative in-roster death signal without loosening attribution gates +2. prove the first attributable camera cut with a telemetry-directed capture: T − 2 s through request, selection, movement, arrival, and T + 3–5 s +3. observe 3–5 complete rounds for deduplication/retrigger suppression, return/reset behavior, and survivor/end-of-round priority +4. keep the HUD accepted and the nearby-ally firing aggregation idea deferred as a camera-v2 candidate +5. configurable teams/loadouts +6. define and fixture-test procedural close-quarters environment descriptors +7. longer-duration soak testing for any accepted generated-scene candidate + +### Camera DYING accounting checkpoint — 2026-09-13 + +The unchanged-policy Arena sample reached two natural attribution accepts in +four completed rounds. Counts were 44 DYING observations, 2 accepted, 20 +rejected, and 22 not evaluated; the accounting invariant closes exactly. +Both accepted traces issued requests and targets, completed the existing hold, +and returned. Trace 49 is the first end-to-end telemetry correlation. The +associated 120-frame, 1 FPS rolling buffer is visually sane and combat- +centered, but precise movement/arrival timing is not accepted from this +sampling rate. Camera behavioral acceptance remains **HOLD** pending higher- +rate visual review and the 3–5-round behavioral pass. Keep the 400 ms gate, +all other attribution policy, and nearby-ally tracking scope unchanged. + +### Physical camera execution checkpoint — 2026-09-13 + +Native observation telemetry proved one accepted event's physical camera +execution under trace `6`: movement onset occurred 16.667 ms after the +request, arrival occurred 850.017 ms after the request at 19.063 px from the +requested camera target, and the existing 2000.040 ms hold ended with a normal +return. The sampler produced 121 samples and did not alter camera behavior. +The rendered 15 FPS screenshot attempt was unavailable through its launcher, +so video-level acceptance is not claimed. Multi-round behavioral acceptance +remains **HOLD** pending 3–5 complete rounds and visual framing review. diff --git a/docs/SPECTATOR_ARENA_A1_FIREARM_RECON_REPORT_2026-09-03.md b/docs/SPECTATOR_ARENA_A1_FIREARM_RECON_REPORT_2026-09-03.md new file mode 100644 index 0000000000..6d6916853b --- /dev/null +++ b/docs/SPECTATOR_ARENA_A1_FIREARM_RECON_REPORT_2026-09-03.md @@ -0,0 +1,163 @@ +# Spectator Arena A1 and firearm reconciliation report — 2026-09-03 + +## Executive result + +The apparent post-spawn Arena stall is closed as an A1 **PASS**. Native OFF, +SHADOW, and OFF-control runs all continued through actor insertion, touchdown, +release, combat, and normal activity updates. A three-round SHADOW run also +completed without a watchdog or Lua runtime failure. + +The three-round smoke is nevertheless only **PARTIAL**: visibility and contact +semantics produced real positive evidence, but the Arena never discovered an +equipped firearm after spawn. The controlled F1–F3 firearm fixture remains the +reference implementation and is not contradicted. The open defect is now the +Arena firearm lifetime between actor insertion and the first enumerable +activity update. + +## A1 post-spawn progression + +The diagnostic used a bounded 256-entry in-memory trace and sparse lifecycle +heartbeats. It sampled BEFORE/AFTER boundaries around actor scanning, camera, +touchdown, fire sensing, AI instrumentation, SHADOW observation, target +distribution, combat pressure, and round-result evaluation. It persisted only +once at the diagnostic timeout or round result; per-event snapshots were not +reintroduced. + +Observed native runs: + +| Run | Evidence | +| --- | --- | +| OFF control 1 | 1,183 updates and 19.73 simulated seconds by the 20-second wall checkpoint; all 16 actors released; combat progressed. | +| SHADOW comparison 1 | 396 updates and 6.616 simulated seconds by the checkpoint; all 16 actors released; every sampled boundary returned. | +| OFF control 2 | 395 updates and 6.6 simulated seconds; pacing closely matched the SHADOW comparison. | +| SHADOW completion | Round result reached after 48.614 simulated seconds; the process continued through three completed rounds. | + +The OFF → SHADOW → OFF comparison rules out a SHADOW-specific blocking call in +the traced path. The large wall/simulation-rate variation is environmental or +native frame pacing in these unattended launches, not evidence of a SHADOW +post-spawn deadlock. + +## Three-round semantic SHADOW smoke + +| Metric | Round 1 | Round 2 | Round 3 | Total | +| --- | ---: | ---: | ---: | ---: | +| Duration (ms) | 48,614.722 | 51,031.292 | 43,698.252 | — | +| Visible opponents | 56 | 67 | 76 | 199 | +| LOS-positive observations | 56 | 59 | 73 | 188 | +| Contact acquisitions | 37 | 41 | 54 | 132 | +| Contact losses | 36 | 29 | 46 | 111 | +| SHADOW observations | 954 | 552 | 890 | 2,396 | +| Damage events | 263 | 69 | 119 | 451 | +| Fire-sensor samples | 29,398 | 16,870 | 27,429 | 73,697 | +| Equipped-firearm samples | 0 | 0 | 0 | 0 | +| Missing-firearm samples | 29,398 | 16,870 | 27,429 | 73,697 | +| Fire/latch events | 0 | 0 | 0 | 0 | +| SHADOW observation cost (ms) | 135 | 76 | 118 | 329 | + +All three rounds represented both teams, completed normally, emitted positive +LOS/contact transitions, and recorded execution cost. No SHADOW observation +preceded the accepted touchdown/release gate, and no world-truth/team-memory +violation or watchdog event was observed. The only `ERROR` text in the native +log was the pre-existing empty-scene lookup warning; no Lua stack trace was +present. + +This passes lifecycle, visibility, contact, gating, and bounded execution-cost +evidence. It does not pass Arena firearm discovery or live Arena fire-latch +evidence. + +## Exact firearm lifetime boundary + +A narrow read-only reconciliation sampled all 16 spawned actors at four points: + +1. candidate firearm creation; +2. inventory handoff; +3. immediately after `MovableMan:AddActor`; +4. the first update in which the actor appeared in the MovableMan actor scan. + +Every actor had a named `HDFirearm` immediately after insertion, with +`equippedIsFirearm=true`, valid actor/team state, and no background or inventory +item. At that point the held firearm reported `MOID=255` and `RootMOID=255`. +On the first enumerable update, all 16 actors were still valid but foreground, +background, and inventory firearm discovery were all empty. + +Therefore: + +```text +valid equipped firearm immediately after AddActor + ↓ +weapon disappears before first enumerable activity update + ↓ +73,697 Arena sensor samples see no firearm +``` + +This is not evidence that `FiredFrame`, the durable F3 latch, or generic +`AddInventoryItem` is broken. Controlled fixture evidence already proves those +primitives. It is an Arena-specific lifetime/ownership transition. + +## R1 identity/lifetime differential + +The retained-reference differential was run with production behavior OFF. The +flag was explicitly verified before launch and restored afterward. All 16 +post-insertion records showed `retentionEnabled=true` and +`retainedReference=true`. At the first actor-enumerable update, all 16 retained +wrappers were still valid named firearms with populated identity fields. The +sampled retained firearms also reported `retainedAttached=true`; none appeared +in the bounded `MovableMan.Items` scan. At the same update, the actor’s +foreground-arm holder and normal foreground/background/inventory discovery +paths were empty. + +The no-retention OFF control reported empty actor discovery and zero nearby world +items, matching the original R1A boundary. The known-good controlled fixture +comparison remains consistent: it stores the created weapon as `self.Weapon` and +still reports the SMG as foreground at its first update, but its historical log +does not include the same MOID/arm identity fields. + +Classification: **UNRESOLVED** attachment/discovery-path mismatch. + +Native evidence rules out simple Lua wrapper loss, firearm deletion during the +sampled interval, a simple world drop into `MovableMan.Items`, and a SHADOW-only +cause. It does not yet prove which parent/attachment path owns the still-attached +object, or whether the `EquippedItem`/arm discovery path is failing for this +Arena topology. Do not turn retained wrapper storage into a production fix. + +The next discriminator is a controlled fixture/Arena identity comparison that +adds the same direct `FGArm`/`HeldDevice`, attachment, parent/root identity, and +bounded world-item fields to the known-good fixture. Keep the Arena unchanged +and compare one lifecycle difference at a time. + +## Gate status + +| Gate | Status | +| --- | --- | +| B5 inventory handoff | PASS in controlled fixture | +| B6 equipped idle | PASS in controlled fixture | +| B7 activation safety | PASS in controlled fixture | +| F1 real discharge | PASS in controlled fixture | +| F2 native `FiredFrame` signal | PASS in controlled fixture | +| F3 durable fire latch | PASS, 10/10 controlled cycles | +| A1 Arena post-spawn progression | PASS | +| Arena visibility/contact | PASS for the three-round smoke | +| Arena firearm discovery/fire latch | FAIL / unresolved lifetime boundary | +| D1 damage semantics | BLOCKED | +| Canonical 50-round OFF baseline | BLOCKED | +| Larger SHADOW evidence/promotion | BLOCKED | +| TASKS-A | NO-GO | +| Merge / PR | NO-GO | + +## Safety and evidence artifacts + +- Branch remains `spectator-random-factions`; no merge or PR was created. +- Startup target is `Spectator Arena`. +- Production `AI_V2_MODE = "OFF"`. +- No Cortex Command process remains running. +- Camera/research work was not modified by this diagnostic. +- `SPECTATOR_ARENA_A1_OFF_TRACE_LOG.txt` +- `SPECTATOR_ARENA_A1_OFF2_TRACE_LOG.txt` +- `SPECTATOR_ARENA_A1_SHADOW_TRACE_LOG.txt` +- `SPECTATOR_ARENA_A1_SHADOW2_TRACE_LOG.txt` +- `SPECTATOR_ARENA_SHADOW_3_ROUND_LOG_2026-09-03.txt` +- `SPECTATOR_ARENA_LOADOUT_RECON_TRACE_LOG_2026-09-03.txt` +- `ARENA_LOADOUT_RECON_NATIVE_STDOUT.txt` +- `ARENA_LOADOUT_RECON_NATIVE_STDERR.txt` +- `SPECTATOR_ARENA_R1B_ATTACHMENT_TRACE_LOG_2026-09-03.txt` +- `SPECTATOR_ARENA_R1_ATTACHMENT_OFF_TRACE_LOG_2026-09-03.txt` diff --git a/docs/SPECTATOR_CAMERA_ACCEPTANCE_2026-09-13.md b/docs/SPECTATOR_CAMERA_ACCEPTANCE_2026-09-13.md new file mode 100644 index 0000000000..fa7225d82f --- /dev/null +++ b/docs/SPECTATOR_CAMERA_ACCEPTANCE_2026-09-13.md @@ -0,0 +1,83 @@ +# Spectator camera acceptance — 2026-09-13 + +## Decision + +**HOLD_FOR_VISUAL_ACCEPTANCE** + +The event-aware camera helper and integration remain behaviorally unaccepted. +The required native review of 3–5 complete rounds was not completed in the +initial attempt because the Windows computer-use surface exposed no native app +window. A later short local screen capture is documented below; it provides +partial visual evidence but is not enough to classify the camera as accepted. + +## Partial native screen capture — 2026-09-13 + +A local 25-second capture of the visible `Cortex Command Community Project +(Debug Release)` window was reviewed at 2 frames per second (50 PNG frames, +976x579). The segment covered the end of round 28 and the beginning of round +29. + +Observed: + +- the camera kept the active hill/valley combat area in view and followed the + main combat groups across the terrain; +- the round transition centered the airborne squad rather than holding on empty + terrain; +- no sustained empty-terrain lock or obvious jitter was visible in the sampled + frames. + +Not established by this short capture: + +- a deliberately observed off-screen attributed event cut; +- event hold/return timing and victim deduplication; +- last-survivor priority and clean reset across 3–5 complete rounds. + +Capture frames are preserved locally under +`work/native-camera-capture-2026-09-13/`. This is useful visual evidence, but +it does not replace the required full-round event-aware review. Decision remains +**HOLD_FOR_VISUAL_ACCEPTANCE**. + +## Integrity checkpoint + +- Branch: `spectator-random-factions` +- Starting HEAD: `48bf4c8e980a0d9410cce83fd35a0eba456b2b88` +- Production mode remains `AI_V2_MODE = "OFF"`. +- `SpectatorArena.lua` already contained the runtime require for + `Activities/SpectatorCameraEventLogic`; the module and its pure test were + untracked. This checkpoint packages that existing dependency atomically so a + clean checkout is self-contained. +- No gameplay, AI, faction, loadout, map, scoring, watchdog, or telemetry + semantics were changed. + +## Verification + +- `spectator_camera_event_test.lua`: PASS. +- Existing preserved OFF evidence: 104/104 completed rounds in the event + snapshot, 104/105 in the console snapshot with one incomplete trailing start, + and 0 watchdog events. +- The Debug Release x64 build completed with exit code 0; existing MSBuild and + LuaJIT warnings remain. + +## Native review attempt + +The freshly built executable was launched from the repository root and exposed +a responsive game process with title `Cortex Command Community Project (Debug +Release)`. The computer-use inventory nevertheless returned no native app +windows, so no rendered-frame inspection was possible. The process did not +produce a new `LogConsole.txt` capture suitable for round review; the existing +`AbortLog.txt` predates this attempt and was not used to classify the result. + +The following acceptance items therefore remain unverified: + +- soldier-follow dominance across 3–5 complete rounds; +- one credible off-screen event response; +- smooth return and victim deduplication; +- last-survivor framing and clean round reset; +- absence of empty-terrain holds or camera jitter. + +## Next gate + +Run the self-contained package on a host with native rendered-frame capture, +review the required complete rounds, and then mark this decision `ACCEPT`, +`HOLD_FOR_TUNING`, or `REJECT`. Do not enable HUD work or change the AI gate +based on this report alone. diff --git a/docs/SPECTATOR_CAMERA_CAPTURE_2026-09-13.md b/docs/SPECTATOR_CAMERA_CAPTURE_2026-09-13.md new file mode 100644 index 0000000000..35fea2af7d --- /dev/null +++ b/docs/SPECTATOR_CAMERA_CAPTURE_2026-09-13.md @@ -0,0 +1,36 @@ +# Spectator camera capture review — 2026-09-13 + +## Result + +**Promising partial visual evidence; HOLD_FOR_VISUAL_ACCEPTANCE.** + +## Capture + +- Window: `Cortex Command Community Project (Debug Release)` +- Local capture: `work/native-camera-capture-2026-09-13/` +- Sample: 50 PNG frames at approximately 2 frames per second +- Frame size: 976x579 +- Observed segment: end of round 28 and beginning of round 29 +- Source: `e7422a9c8` (`fix: make spectator camera dependency self-contained`) +- Production mode: `AI_V2_MODE = "OFF"` + +## Observations + +- Active hill/valley combat remained in view across the sampled segment. +- The camera followed the main combat groups as they moved across the terrain. +- The round transition centered the airborne squad instead of holding on empty + terrain. +- No sustained empty-terrain lock or obvious jitter was visible in these frames. + +## Limits + +This short capture did not establish a deliberately observed off-screen event +cut, event hold/return timing, victim deduplication, last-survivor priority, or +the required 3–5 complete-round review. Static frame sampling also cannot prove +kill attribution. The camera remains unaccepted until those gates are reviewed. + +## Next gate + +Review 3–5 complete rounds with deliberate attention to event response and +return behavior. Keep the conservative thresholds and `AI_V2_MODE = "OFF"` +unless the evidence supports a narrowly scoped tuning change. diff --git a/docs/SPECTATOR_CAMERA_ENGAGEMENT_OFFSET_2026-09-13.md b/docs/SPECTATOR_CAMERA_ENGAGEMENT_OFFSET_2026-09-13.md new file mode 100644 index 0000000000..010a2f5764 --- /dev/null +++ b/docs/SPECTATOR_CAMERA_ENGAGEMENT_OFFSET_2026-09-13.md @@ -0,0 +1,168 @@ +# Spectator camera engagement offset — 2026-09-13 + +## Decision + +Implement the approved spectator-camera follow-up in an isolated worktree and keep it behind the existing spectator camera priority rules. The feature is a review candidate pending a targeted rendered-frame review. + +## Behavior + +- Preserve ordinary soldier follow as the default. +- On a followed actor's firing edge, find the nearest living enemy in the aim cone, excluding same-team, behind-shooter, too-near, and out-of-range actors. +- Frame 55% of the way from shooter to enemy so the enemy and the shot line are visible without abandoning the shooter. +- Hold the engagement frame for 900 ms, then return to soldier follow. +- Apply a 1400 ms cooldown and preserve event/death and last-survivor priority. + +## Implementation + +- `SpectatorCameraEventLogic.lua` contains the pure target-selection and frame interpolation helpers. +- `SpectatorArena.lua` owns timers, cooldowns, actor snapshots, native firearm signals, and the controller-fire fallback. +- `spectator_camera_event_test.lua` covers cone, team, range, and frame bias. +- `spectator_ai_integration_test.py` covers activity integration and priority ordering. + +## Verification + +- Lua behavioral tests: PASS. +- AI controller Lua tests: PASS. +- Python integration suite: 10 tests PASS. +- Lua load/syntax checks: PASS. +- `git diff --check`: PASS. +- Native `Debug Release|x64` build: PASS, 0 errors; existing compiler/project warnings remain. +- Native runtime log: `CAMERA_FIRE_CONTROLLER` followed by `CAMERA_ENGAGEMENT` in a completed round. + +## Visual acceptance — 2026-09-13 + +- Focused Debug Release capture: 50 frames over approximately 25 seconds from an isolated worktree. +- Three runtime engagement transitions were observed in the same session: each `CAMERA_FIRE_CONTROLLER` was followed by `CAMERA_ENGAGEMENT`. +- Reviewed frames kept the firing side, opposing side, and active shot effects readable during combat; no sampled jitter or empty-terrain lock was observed. +- The camera returned to ordinary follow framing between combat phases, including round transition and regrouping views. + +Visual sanity: **PASS**. Camera behavioral acceptance: **HOLD**. + +This sample supports ordinary watchability only: the camera kept combat readable over uneven terrain, avoided sustained empty-terrain fixation, and showed no obvious sampled oscillation. It does not establish that an attributable event cut occurred, request-to-arrival timing, return behavior, deduplication/retrigger suppression, survivor/end-of-round priority, or acceptance across 3–5 complete rounds. Earlier telemetry also recorded zero events passing the conservative attribution gate, so this visual sample does not validate the event-selection pipeline. + +Next milestone: prove the first attributable camera cut with a telemetry-directed capture covering **T − 2 s → request → selection → camera movement → arrival → T + 3–5 s**, then separately observe 3–5 complete rounds for return/reset and survivor behavior. The nearby-ally firing aggregation idea remains deferred as a camera-v2 candidate. + +## Attribution-directed capture — 2026-09-13 + +Review-only trace markers were added for observed fire, actor removal, attribution, request, target issuance, hold completion, and return. The markers do not change camera selection, priority, thresholds, holds, cooldowns, or target positions. + +- The traced run covered five complete rounds and recorded 68 followed-shooter fire observations; a final bounded round was also captured with 100 rendered frames at 976×579 over approximately 25 seconds. +- Runtime recorded 12 `CAMERA_EVENT_REMOVAL_UNCONFIRMED` records, including removed victims with tracked health below zero, but zero `CAMERA_EVENT_DEATH_OBSERVED`, zero attribution accepts, zero event requests, and zero target-issuance markers. +- The rendered sample remained visually sane, but no attributable camera cut occurred and therefore no end-to-end visual verification is claimed. + +Finding: the live actor roster can remove a victim before the camera observes a live→dead transition. Several unconfirmed removals were also outside the 400 ms attribution window. The next task is to resolve this death-observation boundary in diagnostic-only scope and then repeat the same narrow capture. Do not loosen attribution gates or add camera-v2 tracking before that proof exists. + +## Native lifecycle root-cause investigation — 2026-09-13 + +The native source confirms the observation boundary. `Source/Main.cpp` calls +`g_ActivityMan.Update()` before `g_MovableMan.Update()` each frame. During the +native actor update, `Actor.cpp` changes an actor to `DYING` when health reaches +zero or below; after the death timer expires it changes the actor to `DEAD`. +The subsequent `MovableMan.cpp` pass partitions dead actors into particles, +removes them from team rosters, and erases them from the live actor list. + +Lua exposes `Actor.Status`, `Actor.Health`, `Actor.PrevHealth`, `Actor.IsDead()`, +and the `DYING`/`DEAD` status values, but the activity update cannot reliably +observe `DEAD` while the actor is still in `MovableMan.Actors`. Therefore the +last authoritative in-roster lifecycle signal is the transition to `DYING`, +with health and previous health available for correlation. This confirms the +runtime evidence without changing camera behavior or attribution policy. + +Next hypothesis: test the Lua-visible `DYING` transition as death evidence +under the existing single-victim, shooter-identity, aim-cone, distance, and +400 ms gates. No implementation or acceptance claim is made by this finding; +the next change must remain diagnostic-only until one end-to-end attributable +cut is proven. + +## DYING-edge experiment — 2026-09-13 + +The diagnostic experiment replaced the prior live-to-DEAD/removal candidate +with a one-shot Lua-visible `DYING` edge. Trace correlation was added with a +monotonic `traceID`; the shooter, victim lifecycle, attribution, request, +target, hold, and return markers use that ID. The existing shooter identity, +single-victim, opposing-team, aim-cone, distance, recency, cooldown, priority, +and camera behavior were left unchanged. + +- Implementation checkpoint: `09a242eec`. +- Native run: 90 rendered frames over approximately 90 seconds; orderly exit; + two round results were recorded and the next round began. +- Trace counts: 44 `CAMERA_FIRE_OBSERVED`, 11 + `CAMERA_EVENT_DYING_OBSERVED`, 3 `CAMERA_EVENT_REMOVAL_UNCONFIRMED`, 9 + `CAMERA_EVENT_ATTRIBUTION_REJECTED`, and zero attribution accepts, requests, + target issuances, hold completions, or returns. +- Rejection reasons: 7 `STALE_SHOT` and 2 `DISTANCE`. No 400 ms widening was + applied. +- Representative rendered frames remained readable, but no event-camera + movement was claimed because no attribution was accepted. + +Status: **DEATH-OBSERVATION ROOT CAUSE PASS** and **DYING OBSERVATION PASS**; +**DYING ATTRIBUTION ACCEPTANCE UNPROVEN**; **CAMERA BEHAVIORAL ACCEPTANCE +HOLD**. The lifecycle seam is repaired for observation, while the unchanged +classifier still needs a naturally accepted candidate before camera-event +behavior can be accepted. + +## Condition-based DYING accounting capture — 2026-09-13 + +The accounting follow-up kept the existing attribution policy unchanged and +ran until the first natural accept. The run stopped after four completed +rounds when the first accepted event was observed, then exited normally after +the hold and return. A bounded rolling buffer retained 120 rendered frames at +976x579; the accepted window is under +`work/camera-dying-edge-rolling-20260913-223423/accepted/`. + +- `CAMERA_FIRE_OBSERVED`: 91 +- `CAMERA_EVENT_DYING_OBSERVED`: 44 +- `CAMERA_EVENT_ATTRIBUTION_ACCEPTED`: 2 +- `CAMERA_EVENT_ATTRIBUTION_REJECTED`: 20 +- `CAMERA_EVENT_ATTRIBUTION_NOT_EVALUATED`: 22 +- `CAMERA_EVENT_REQUEST`: 2 +- `CAMERA_EVENT_TARGET_ISSUED`: 2 +- `CAMERA_EVENT_HOLD_COMPLETE`: 2 +- `CAMERA_EVENT_RETURN`: 2 +- `CAMERA_EVENT_REMOVAL_UNCONFIRMED`: 5 + +The accounting invariant closes exactly: `44 = 2 + 20 + 22`. Terminal +reasons were `STALE_SHOT` 19, `SHOOTER_MISMATCH` 10, +`NO_CORRELATABLE_SHOT` 8, and `COOLDOWN` 5. No 400 ms widening, gate change, +priority change, or ally-tracking behavior was added. + +Trace `49` provides the first complete telemetry chain: +`FIRE_OBSERVED -> DYING_OBSERVED -> ATTRIBUTION_ACCEPTED -> REQUEST -> +TARGET_ISSUED -> HOLD_COMPLETE -> RETURN`, with one trace ID throughout. +The sampled frames around the accepted window remain combat-centered and +readable. Because the capture was sampled at 1 FPS, it supports visual sanity +and the telemetry chain but does not precisely prove movement onset/arrival +latency. Behavioral acceptance therefore remains **HOLD** pending a higher- +rate event-window review and the separate 3–5-round acceptance pass. + +The initial capture exposed a diagnostic-only Lua truthiness label bug that +reported accepted dispositions as `NO_CANDIDATE`. It was corrected and +regression-tested in `fbb12f0d4`; camera behavior and attribution policy were +not changed. Implementation provenance remains `09a242eec`, accounting +checkpoint `351bd91f1`, and latest code sync `fbb12f0d4`. + +## Physical camera execution observation — 2026-09-13 + +Observation-only instrumentation sampled `CameraMan:GetOffset` every native +update while an event target was active, alongside the requested target, +scroll target, distance-to-target, and movement delta. It does not change the +camera command, speed, target, hold, cooldown, priority, or attribution. + +The repeat native run produced accepted trace `6` in round 3 with 121 samples: + +- accepted/request/target: `31833.970 ms` +- movement onset: `31850.637 ms` (`+16.667 ms`) +- arrival: `32683.987 ms` (`+850.017 ms`), distance `19.063 px` within the + `24 px` observation tolerance +- hold complete and return: `33834.010 ms` (`+2000.040 ms`) + +This proves the physical camera offset departed toward the requested event +target and crossed the declared arrival threshold under one trace ID. A +separate attempted 15 FPS rendered screenshot capture produced no frames +because the headless launcher path did not expose the game window; no rendered +video acceptance is claimed from that attempt. Native high-rate telemetry is +verified, while multi-round behavioral acceptance remains **HOLD** pending +3–5 complete rounds and visual review. + +Latest implementation checkpoint: `cff83c0fe`. Preserved telemetry log: +`work/camera-event-telemetry-20260913-2312/SPECTATOR_EVENT_LOG.txt`. diff --git a/docs/SPECTATOR_CHECKPOINT_2026-08-31.md b/docs/SPECTATOR_CHECKPOINT_2026-08-31.md new file mode 100644 index 0000000000..eb4f615eab --- /dev/null +++ b/docs/SPECTATOR_CHECKPOINT_2026-08-31.md @@ -0,0 +1,132 @@ +# Cortex Command AI-vs-AI Spectator Simulation + +## Proven checkpoint 2026-08-31 + +This build is the first proven long-running autonomous spectator version. + +### Soak test + +- Approximate unattended runtime: 1 hour 44 minutes +- State at manual exit: Round 66 +- Completed matches: 65 +- Visible cumulative score: 3629 +- No manual intervention was required during the run + +The following loop operated repeatedly: + +1. Spawn two AI teams +2. Give actors their combat loadouts +3. Run real-time autonomous Cortex Command combat +4. Detect team elimination / winner +5. Update persistent match score +6. Reset the battlefield / round state +7. Spawn the next match +8. Continue indefinitely + +This demonstrates that the autonomous livestream/spectator concept is viable. + +## Project direction + +This is no longer being developed as a turn-based game. + +The goal is a dedicated autonomous AI-vs-AI spectator simulation suitable +for TikTok, livestreaming, recordings and tournament-style content. + +Cortex Command is primarily being retained for the parts that make the +simulation visually and mechanically interesting: + +- actors and sprites +- factions +- weapons +- projectiles +- AI combat +- wounds and gore +- gibbing +- particles +- explosions +- physics +- destructible terrain +- usable maps/scenes +- sound and combat effects + +Unrelated Cortex Command systems such as campaign/conquest, editors, +traditional player modes and unnecessary menus should not be aggressively +removed yet. They may have hidden dependencies. Strip them only after the +spectator application is mature and verified. + +## Current working capabilities + +- Real-time AI-vs-AI combat +- 8v8 baseline +- Automatic actor spawning +- Automatic weapon assignment +- Autonomous movement and engagement +- Spectator camera +- Winner/elimination detection +- Correct winner state +- Automatic round restart +- Persistent Red/Blue scoring across rounds +- Continuous unattended match cycling + +## Development policy + +Preserve this checkpoint before major modifications. + +Do not reintroduce turn-based behavior unless that concept is explicitly +revisited later. + +Avoid rewriting Cortex Command's native combat systems when the existing +engine already provides the desired behavior. + +## Next priorities + +1. Automatic camera director + - follow meaningful firefights + - detect explosions / concentrated action + - avoid staring at isolated or inactive actors + - special handling for last survivors + +2. Stream-facing HUD + - team names + - current score + - round number + - alive counts + - winner transition + - clean presentation for viewers + +3. Reliability + - maximum match duration + - stuck-round detection + - automatic recovery/reset + - protection against empty or broken spawns + - long-duration soak testing + +4. Match configuration + - team sizes + - factions + - actors + - weapons/loadouts + - maps + - randomization + - tournament formats + +5. Streaming presentation + - OBS-friendly capture + - vertical 9:16 layout + - TikTok-oriented framing + - eventually optional viewer interaction/voting + +## Launch + +From PowerShell: + + cd "$HOME\Documents\Cortex-Command-Community-Project" + & ".\Cortex Command.debug.release.exe" + +## Git checkpoint + +An annotated Git tag named: + + spectator-soak-2026-08-31 + +marks the proven unattended spectator build associated with this document. diff --git a/docs/SPECTATOR_CONTINUATION_STATUS_2026-09-05.md b/docs/SPECTATOR_CONTINUATION_STATUS_2026-09-05.md new file mode 100644 index 0000000000..9c69e83c1d --- /dev/null +++ b/docs/SPECTATOR_CONTINUATION_STATUS_2026-09-05.md @@ -0,0 +1,46 @@ +# Spectator continuation status and R2 investigation — 2026-09-05 + +## Verified starting state + +Repository: C:/Users/mythz/Documents/Cortex-Command-Community-Project +Branch: spectator-random-factions +Starting HEAD: 4bcbd0076e3a41e7378dc8eef11ab72f6791b6da +Historical tag spectator-soak-2026-08-31: 241c42119886c992a020ddfc73482a1dae71e4b4 (preserved). +The starting index was empty. Three existing modified documents and all untracked camera, research, runtime-log and dependency artifacts remain protected. Starting status and a pre-existing tracked-diff snapshot are saved under work/continuation-2026-09-05/. No reset, clean, merge, rebase, broad deletion, settings edit, or gameplay edit was performed. Production remains AI_V2_MODE = "OFF" at SpectatorArena.lua:955. + +## Repository integrity + +`git grep -n SpectatorCameraEventLogic HEAD -- Data/Base.rte/Activities/SpectatorArena.lua` finds the committed require at line 1124; `git ls-tree HEAD Data/Base.rte/Activities/SpectatorCameraEventLogic.lua` returns no file. The working-tree module and camera test are untracked. Therefore working-tree tests passing does not establish a self-contained clean checkout. The camera handoff explicitly records pending human visual acceptance. This bounded continuation records the defect without adopting or rejecting that candidate or staging existing user work. Next action: review the candidate and package module/test/integration together, or explicitly reject the integration while preserving the candidate. + +## Rechecked baseline evidence + +Commands: `python tools/spectator_soak_report.py` with each snapshot below. +- logs/session-2026-09-03-debug-release-0924/SPECTATOR_EVENT_LOG.txt: 104/104 completed; incomplete_final_round false; watchdog_events 0. +- logs/session-2026-09-03-debug-release-0924/LogConsole.txt: 104/105 completed; incomplete_final_round true; watchdog_events 0. +- Both: average duration 43078.56525 ms, shortest 22215.778 ms, longest 91712.998 ms. + +The count requirement is met by existing OFF evidence. This is not a new native run and does not approve SHADOW, damage semantics, TASKS, or merge. Older statements that the 50-round count itself remains blocked are superseded by this recheck; semantic activation remains blocked. + +## R2 evidence and first-divergence limitation + +Raw source: SPECTATOR_ARENA_R1B_ATTACHMENT_TRACE_LOG_2026-09-03.txt, first records at lines 251–252. Parsed all 16 WEAPON_FIRST_UPDATE records: 16 equipped=NONE; 16 retainedValid=true and retainedAttached=true; 16 worldItemCount=0; all 16 lack foregroundArmMOID. Example actor 16975 has retainedMOID=8, retainedRootMOID=1, foregroundArmHeld=NONE, equippedMOID=-1, updateCount=2. An attached retained weapon does not establish its exact parent or a usable actor discovery path. + +Known-good SPECTATOR_FIRE_F3_LOG.txt lines 14–18 show foregroundPreset=SMG at INVENTORY_HANDOFF and ACTORS_INSERTED; lines 27–30 still show SMG at SAMPLE_1/2. These fixture records do not contain the corresponding arm/root identity schema. The duplicate plain and SPECTATOR_EVENT fixture records must not be counted as separate samples. + +Current source SpectatorArena.lua:1897 onward captures FGArm and HeldDevice, but the historical attachment log lacks arm identity values present in current source. Do not backfill those values from source or combine different snapshots into a claimed R2 run. BGArm topology and identical four-stage fixture/Arena samples remain absent. First divergence is UNDETERMINED, not a proven ownership defect. + +Next bounded implementation: add the planned pure ordered comparison contract and tests; capture identical T0_AFTER_INVENTORY, T1_AFTER_ADD_ACTOR, T2_FIRST_UPDATE, T3_STABLE_UPDATE schema in fixture and Arena; reject missing stages/fields. Compare attachment relationships and classes; treat numeric MOIDs as run-local identities, not expected equal values across independent runs. Match actor/team/sample identity and normalize plain/PRINT duplicates. Retain wrappers only in diagnostic fixtures, never as production sensing or a gameplay fix. Only then classify the earliest divergence. + +## Validation and limits + +- python -m unittest discover -s tests -p '*.py' -v: 12 tests, OK (output saved in work/continuation-2026-09-05/python-tests.txt). +- Fengari tests/spectator_camera_event_test.lua: PASS. +- Fengari tests/spectator_ai_controller_test.lua: PASS. +- Fengari tests/spectator_telemetry_test.lua: PASS. An initial command used nonexistent spectator_ai_telemetry_test.lua; corrected after listing actual tests. +- git diff --check: no whitespace errors; existing CRLF conversion warnings only. +- MSBuild not found by Get-Command; no native build attempted. Native executables exist, but the normalized topology fixture is not implemented and no controlled native capture was attempted within the four-minute window. +- Existing Cortex Command.debug.release.exe SHA256: 477AA880D7BC55C3E7D8E6C2B864B21104EC4A7210D028BCDA9D6CDB9D2021A7. This identifies the local binary only, not the provenance of historical logs. + +## Recovery and external mirror + +This report is the only newly committed file; the existing work log receives an appended continuation entry and remains dirty to preserve its prior changes. No runtime or camera candidate is staged. Drive destination is the observed parent of the existing Cortex project reports: 0AGSQc2hrH2H_Uk9PVA. Upload outcome is recorded separately in the local work log after the connector returns; this report makes no advance upload claim. diff --git a/docs/SPECTATOR_FIRE_SENSOR_F3_REPORT_2026-09-03.md b/docs/SPECTATOR_FIRE_SENSOR_F3_REPORT_2026-09-03.md new file mode 100644 index 0000000000..881431dd57 --- /dev/null +++ b/docs/SPECTATOR_FIRE_SENSOR_F3_REPORT_2026-09-03.md @@ -0,0 +1,98 @@ +# Spectator firearm sensor F3 report — 2026-09-03 + +## Gate result + +F3 **PASS** on the native Windows `Debug Release|x64` executable. The run used +the temporary `Spectator Fire Sensor Differential` startup target and restored +`Spectator Arena` before the final verification build. No tactical behavior, +waypoint logic, TASKS, TACTICAL mode, or camera/research files were changed by +this gate. + +## Evidence + +The controlled fixture completed ten held-activation fire cycles: + +| Signal | Result | +| --- | ---: | +| Confirmed shots by ammo decrement | 10 | +| Ammo | 30 → 20 | +| `FiredFrame` captures | 10 | +| Durable fire-frame count | 10 | +| Durable discharged-round count | 10 | +| Durable fire-event count | 10 | +| False fire increments | 0 | +| Last fire timestamp | `849.966 ms` retained after the transient frame | +| `FiredRecently` before expiry | true | +| `FiredRecently` after the 1000 ms window | false | +| Fixture result | PASS | + +The highest-value transition was observed at the tenth shot: the same update +reported `ammo=20`, `roundsFired=1`, `firedFrame=true`, +`fireFrameCount=10`, `roundsDischargedObserved=10`, and +`lastFireTimeMS=849.966`. The following samples reported +`firedFrame=false` and `roundsFired=0` while retaining the durable counts and +timestamp. + +Evidence artifact: `SPECTATOR_FIRE_F3_LOG.txt`. + +## Implementation boundary + +`RecordFireSensorSample` is now the owner of high-frequency fire latching. It +updates `LastFireTimeMS`, `FireEventCount`, `FireFrameCount`, and +`RoundsDischargedObserved` only when the read-only `FiredFrame` signal is true. +The Arena shadow-observation path no longer passes the transient fire signal, +avoiding duplicate latches while retaining read-only health bookkeeping. + +## Verification and restoration + +- Focused Fengari controller test: PASS. +- Python integration tests: 3/3 PASS. +- `git diff --check`: PASS. +- Native `RTEA.sln` `Debug Release|x64`: PASS, 0 errors, 11 existing warnings. +- Startup target restored to `Spectator Arena`. +- Production `AI_V2_MODE = "OFF"` verified. +- No native test process remains. +- `Source/Main.cpp` has no temporary startup-target diff. + +The native log still contains the known scene/audio initialization warnings +(`Finding Scene preset ''` and sound-device readiness messages). They did not +prevent fixture completion and are unrelated to the F3 acceptance criteria. + +## Arena follow-up status — revised after A1 + +A later native three-round SHADOW run closed the apparent post-spawn stall and +produced real semantic evidence: 199 visible-opponent observations, 188 +LOS-positive observations, 132 contact acquisitions, 111 contact losses, 2,396 +SHADOW observations, and zero watchdog events. No observation preceded the +accepted touchdown/release gate. + +The smoke remains **PARTIAL**, because 73,697 fire-sensor samples discovered no +equipped Arena firearm and therefore emitted no live fire-latch event. A narrow +reconciliation found every actor holding a named `HDFirearm` immediately after +`MovableMan:AddActor`, but no foreground, background, or inventory firearm was +present on the first actor-enumerable update. This is now an Arena +weapon-lifetime problem, not an F3 sensor problem. + +Full evidence and the next single-variable diagnostic are documented in +`docs/SPECTATOR_ARENA_A1_FIREARM_RECON_REPORT_2026-09-03.md`. + +## Next gate + +F3 remains closed as PASS. The next gate is to resolve Arena firearm lifetime +and then rerun the 3–5 round SHADOW smoke to prove live firearm discovery and +fire-latch events. Damage semantics, the canonical OFF baseline, merge/PR, and +TASKS-A remain blocked. + +## A0 spawn-progression diagnostic — 2026-09-03 + +The bounded in-memory spawn trace was run as an OFF control and as a temporary +SHADOW comparison. Both runs reached `ROUND_SPAWN_COMPLETE`; all eight actors +per team passed actor creation, firearm selection, inventory handoff, actor +insertion, and controller registration boundaries. The trace is capped at 256 +entries and persisted once at the terminal spawn boundary. No SHADOW-only spawn +boundary was identified. + +The later A1 diagnostic supersedes this initial bounded observation: Arena +progression and round completion now pass, while live Arena firearm discovery +remains unresolved. Production was restored to `AI_V2_MODE = "OFF"` and no test +process remains. diff --git a/docs/SPECTATOR_HUD_OVERLAY_2026-09-13.md b/docs/SPECTATOR_HUD_OVERLAY_2026-09-13.md new file mode 100644 index 0000000000..bffb3a1f7b --- /dev/null +++ b/docs/SPECTATOR_HUD_OVERLAY_2026-09-13.md @@ -0,0 +1,39 @@ +# Spectator HUD overlay — 2026-09-13 + +## Decision + +Add a small stream-facing overlay using the existing Lua activity state and +screen primitive APIs. Keep the accepted engagement camera unchanged and do +not add actor tracking or gameplay behavior. + +## Behavior + +- Upper-left panel: team 1 faction, living count, and cumulative score. +- Upper-right panel: team 2 faction, living count, and cumulative score. +- Center header: round number and elapsed time. +- Center subheader: combat-pressure elapsed/threshold indicator. +- Round result: centered winner banner with the updated score and next-round cue. + +## Verification + +- Pure HUD formatter test: PASS. +- Python integration suite: 11 tests PASS. +- Camera event test: PASS. +- AI controller test: PASS. +- Lua load checks: PASS. +- `git diff --check`: PASS. +- Native Debug Release capture: 100 frames reviewed with readable corner and + center HUD, unchanged camera framing, and a transition into round 2. +- Runtime log: `ROUND_RESULT` winner/score transition observed without Lua + errors. The final capture window ended before a result-banner frame could be + retained, so result rendering is supported by code and formatter coverage + plus the runtime boundary, not claimed as a captured visual frame. + +Decision: **ACCEPTED_FOR_THIS_REVIEW_BUILD**. + +## Deferred camera idea + +The suggested nearby-ally firing aggregation remains a future camera-v2 +candidate. It should only be explored as a bounded, cooldown-gated aggregate +of recent fire events if a later visual review shows the single-shooter cue is +insufficient. No battlefield-wide tracking system is introduced here. diff --git a/docs/SPECTATOR_TELEMETRY.md b/docs/SPECTATOR_TELEMETRY.md new file mode 100644 index 0000000000..55d76c724a --- /dev/null +++ b/docs/SPECTATOR_TELEMETRY.md @@ -0,0 +1,34 @@ +# Spectator telemetry + +The activity emits one-line records prefixed with `SPECTATOR_EVENT`. Fields are space-separated `key=value` pairs; unsafe characters are replaced with `_`. Records are emitted only for startup, state transitions, round selection/results, and watchdog intervention. + +Examples: + +```text +SPECTATOR_EVENT event=ACTIVITY_START +SPECTATOR_EVENT event=ROUND_START round=1 team1=Coalition.rte team2=Ronin.rte +SPECTATOR_EVENT event=STATE round=1 state=BATTLE +SPECTATOR_EVENT event=ROUND_RESULT round=1 winner=COALITION_RTE_WINS durationMS=42000 team1Score=1 team2Score=0 +SPECTATOR_EVENT event=WATCHDOG round=2 team1Alive=3 team2Alive=3 reason=timeout +``` + +`Data/Base.rte/Activities/SpectatorTelemetry.lua` owns encoding and emission. It is intentionally dependency-free and accepts a test sink so behavior can be verified without starting the game. The game’s Lua `print` output is routed into `ConsoleMan` and is the current transport; future soak tooling can filter on the stable prefix. + +`LogConsole.txt` is a shutdown snapshot, not a live append-only file: `ConsoleMan::Destroy()` writes the in-memory console buffer when the engine exits normally. For live capture, launch the executable with `-cout` and capture stdout, or close the game cleanly before reading `LogConsole.txt`. A forced process termination can discard the in-memory records and falsely appear to show a telemetry gap. + +The dependency-free Python report tool reads a captured console log: + +```text +python tools/spectator_soak_report.py path\to\console.log +``` + +It reports completed/started rounds, incomplete final rounds, winner counts, duration statistics, and watchdog events. + +The checkout has no standalone Lua executable on PATH. The pure-Lua tests have nevertheless been executed with the cached Fengari Lua CLI used for verification in this environment: + +```text +lua tests/spectator_camera_event_test.lua +lua tests/spectator_telemetry_test.lua +``` + +The runtime transport is now verified: a fresh executable smoke run produced `SPECTATOR_EVENT` records in `SPECTATOR_EVENT_LOG.txt`, including activity start, AI V2 configuration, round start, and state transitions. The snapshot is intentionally ignored by Git. A longer soak is still required for completed-round distributions and watchdog rates. diff --git a/docs/reviews/AI_REVIEW_SOURCE_SNAPSHOT_2026-09-02.md b/docs/reviews/AI_REVIEW_SOURCE_SNAPSHOT_2026-09-02.md new file mode 100644 index 0000000000..c49d02dde4 --- /dev/null +++ b/docs/reviews/AI_REVIEW_SOURCE_SNAPSHOT_2026-09-02.md @@ -0,0 +1,41 @@ +# AI V2 Review Source Snapshot + +Generated: 2026-09-02 +Branch: `spectator-random-factions` +HEAD at snapshot creation: `26b5f35f2a69d3d3f43c9b57ae05712340382eef` + +This manifest accompanies the source files uploaded to Drive for independent ChatGPT review. The local repository remains the engineering source of truth. + +## Uploaded source files + +- `Data/Base.rte/Activities/SpectatorAIController.lua` — controller state, contact memory, engagement, task/reservation/recovery rules, and pure scoring helpers. +- `Data/Base.rte/Activities/SpectatorArena.lua` — complete activity source, including the post-release SHADOW observation boundary. +- `tests/spectator_ai_controller_test.lua` — pure controller tests. +- `tests/spectator_ai_integration_test.py` — source-level integration/safety tests. +- `tests/spectator_telemetry_test.lua` — telemetry behavior test. +- `tools/spectator_soak_report.py` — runtime event parser. + +## Canonical local state + +- AI V2 mode: `OFF`. +- NativeHumanAI: authoritative for actor behavior. +- Touchdown/release: V11/V11.1 behavior preserved. +- Verification: Lua tests pass, Python tests pass, integration tests pass, `git diff --check` passes, and Debug Release x64 build passes with 0 errors. +- Runtime evidence: four completed OFF rounds in preliminary short runs; 50 completed rounds remain required for the baseline. + +## Intentionally dirty/untracked user work + +These files are separate camera-review work and are not part of the AI V2 source snapshot: + +- `docs/DIRECT_LAUNCH_SPECTATOR.md` +- `docs/HANDOFF_CAMERA_HYBRID_REVIEW.md` +- `Data/Base.rte/Activities/SpectatorCameraEventLogic.lua` +- `docs/HANDOFF_CAMERA_EVENT_AWARE.md` +- `_research_spectator_mod/` +- `tests/spectator_camera_event_test.lua` + +Generated Python cache directories are also untracked and are not review artifacts. + +## Review boundary + +SHADOW currently records nearest-opponent/world-query observations for diagnostics. These observations must not be treated as team knowledge unless visibility/share rules authorize them. No TASKS or TACTICAL behavior is enabled. diff --git a/docs/superpowers/plans/2026-09-02-shadow-evidence-phase.md b/docs/superpowers/plans/2026-09-02-shadow-evidence-phase.md new file mode 100644 index 0000000000..8331c4a40f --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-shadow-evidence-phase.md @@ -0,0 +1,94 @@ +# SHADOW Evidence Phase Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make SHADOW LOS semantics and runtime cost measurable while keeping AI V2 behavior-neutral and production `OFF`. + +**Architecture:** Add a pure, dependency-free visibility-selection helper to the controller for deterministic testing, then have the arena evaluate every living opponent and record nearest/visible counts without inserting world-truth coordinates into team contact memory. Extend controller metrics with bounded counters and elapsed observation cost, and expose them in the per-round SHADOW summary. + +**Tech Stack:** Lua activity scripts, Fengari Lua tests, Python unittest source checks, Cortex Command engine ray APIs, Markdown documentation. + +**Spec:** `docs/superpowers/specs/2026-09-02-spectator-ai-v2-shadow-design.md` + +## Global Constraints + +- Production default remains `AI_V2_MODE = "OFF"`. +- `OFF` and `SHADOW` must not mutate actor AIMode, waypoints, controllers, inventory, health, position, or combat state. +- NativeHumanAI remains authoritative for local movement and combat. +- Diagnostic world-truth observations must not become team contact knowledge. +- Camera/research working-tree changes are outside this phase and must remain untouched. +- Every production behavior change requires a failing test before implementation. + +### Task 1: Deterministic visibility-selection helper + +**Files:** +- Modify: `Data/Base.rte/Activities/SpectatorAIController.lua` +- Modify: `tests/spectator_ai_controller_test.lua` + +**Interfaces:** +- Produces `SpectatorAIController.SelectVisibleOpponent(opponents, visibilityByID)` returning `nearestOpponent`, `nearestDistanceSquared`, and `visibleOpponentCount`. +- `opponents` contains entries with `UniqueID` and `distanceSquared`. +- `visibilityByID[id]` is a boolean; only visible opponents may be selected. + +- [ ] Write a failing test covering a blocked nearest opponent and a visible farther opponent. +- [ ] Run the controller test and confirm the new assertion fails because the helper is absent. +- [ ] Implement the minimal pure helper with deterministic nearest-visible selection and count. +- [ ] Run the controller test and confirm it passes. +- [ ] Commit as `Add deterministic SHADOW visibility selection`. + +### Task 2: Controller SHADOW metrics + +**Files:** +- Modify: `Data/Base.rte/Activities/SpectatorAIController.lua` +- Modify: `tests/spectator_ai_controller_test.lua` + +**Interfaces:** +- Add metrics for `VisibleOpponents`, `VisibleOpponentChecks`, `ActorSkips`, `ContactAcquisitions`, `ContactLosses`, and `ShadowObservationTimeMS`. +- Add `RecordShadowBatchMetrics(fields)` accepting numeric counters and elapsed milliseconds. +- Include all counters in `Snapshot()`. + +- [ ] Write failing tests for batch-counter accumulation and snapshot output. +- [ ] Run the controller test and confirm failure. +- [ ] Implement accumulation with numeric defaults and no engine dependencies. +- [ ] Run the controller test and confirm pass. +- [ ] Commit as `Add SHADOW aggregate metrics`. + +### Task 3: Arena any-visible LOS observation + +**Files:** +- Modify: `Data/Base.rte/Activities/SpectatorArena.lua` +- Modify: `tests/spectator_ai_integration_test.py` + +**Interfaces:** +- For each released living actor, evaluate all living opponents with the existing obstacle-ray API. +- Use the pure helper to select the nearest visible opponent while retaining the nearest world-truth opponent only as a diagnostic field. +- Record contact only for the selected visible opponent. +- Emit `visibleOpponentCount`, `visibleOpponentChecks`, and `nearestVisibleEnemy` in SHADOW telemetry. +- Record skipped actors and batch elapsed time in controller metrics. + +- [ ] Add source-level assertions requiring all-opponent visibility selection and the new telemetry fields while prohibiting actor-control calls. +- [ ] Run integration tests and confirm they fail before wiring is present. +- [ ] Implement the smallest read-only arena integration. +- [ ] Run integration tests and confirm pass. +- [ ] Commit as `Measure any-visible SHADOW contacts`. + +### Task 4: Documentation and verification + +**Files:** +- Modify: `docs/PROJECT_STATUS_2026-09-02.md` +- Modify: `docs/PROJECT_SUMMARY_AND_NEXT_STEPS_2026-09-02.md` +- Modify: `docs/CHATGPT_REVIEW_HANDOFF_2026-09-02.md` +- Modify: `docs/SPECTATOR_AI_V2_SHADOW_SMOKE_REPORT_2026-09-02.md` + +- [ ] Correct stale HEAD and SHADOW statements. +- [ ] Document that the first smoke passed plumbing but lacked LOS/firing positives. +- [ ] Document the new deterministic LOS and metric phase. +- [ ] Run Lua tests, Python tests, `git diff --check`, and the relevant build verification. +- [ ] Commit documentation and verification evidence as `Document SHADOW evidence phase`. + +## Self-review + +- The plan keeps LOS selection pure and testable while leaving engine ray calls in the activity layer. +- No task changes production mode or applies TASKS behavior. +- World-truth nearest-enemy diagnostics remain separate from contact memory. +- Each code task has a failing-test step before production changes. diff --git a/docs/superpowers/plans/2026-09-02-spectator-ai-v2-instrumentation.md b/docs/superpowers/plans/2026-09-02-spectator-ai-v2-instrumentation.md new file mode 100644 index 0000000000..d1d90a9612 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-spectator-ai-v2-instrumentation.md @@ -0,0 +1,90 @@ +# Spectator AI V2 Instrumentation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a behavior-neutral, testable AI V2 observation layer that records released actor state, contact memory inputs, tactical proposals, progress/stuck measurements, and baseline metrics before any tactical orders are enabled. + +**Architecture:** `SpectatorAIController` will sit above the existing `NativeHumanAI` and be inactive in `OFF` mode. In `SHADOW` mode it will compute and log proposals without modifying actor AIMode, waypoints, controllers, or combat behavior. The controller will own round-scoped state and expose pure data-oriented helpers wherever possible so tests do not require the game. + +**Tech Stack:** Cortex Command Lua 5.1/LuaJIT, existing activity Lua APIs, dependency-free Python reporting/tests, Fengari for standalone Lua tests. + +**Spec:** Google Drive `CORTEX_AI_V2_PRECODING_BLUEPRINT.md` (Drive ID `1mQ8YAmf9Ked4AeYK8tVN8Oh8g5Yq51Ua`) and repository bridge `docs/CORTEX_COMMAND_KNOWLEDGE_BRIDGE.md`. + +## Global Constraints + +- Preserve V11/V11.1 touchdown and release behavior exactly. +- Do not replace or directly control `NativeHumanAI` aiming, firing, reload, locomotion, jetpack, digging, or local reactions. +- Do not apply AI V2 behavior in the first milestone; `OFF` is the default and `SHADOW` must not mutate actors. +- Do not use omniscient hidden enemy positions as team tactical contacts. +- Do not add an influence map, GOAP, custom aiming, forced jetpack input, pacing director, or permanent cover nodes. +- Preserve uncommitted camera-review work and unrelated user changes. + +--- + +### Task 1: Pure controller state and metrics + +**Files:** +- Create: `Data/Base.rte/Activities/SpectatorAIController.lua` +- Test: `tests/spectator_ai_controller_test.lua` + +**Interfaces:** +- `SpectatorAIController.Create(config)` returns a controller with mode `OFF` unless explicitly configured. +- `BeginRound(roundID, seed)` clears all actor/contact/squad state and records the round generation. +- `RegisterActor(actorID, team, spawnIndex)` creates released=false actor state. +- `ReleaseActor(actorID, timestampMS)` marks an actor released and records release time. +- `RecordPosition(actorID, timestampMS, x, y, waypointX, waypointY, hardEngaged, pathPending)` stores bounded progress samples without changing actor state outside the controller. +- `RecordContact(team, enemyID, timestampMS, x, y, confidence, source)` stores current strategic knowledge; hidden positions must not be updated by this API unless the caller supplies a visible/shared observation. +- `Snapshot()` returns deterministic counts and metrics for tests/reporting. + +- [ ] Write failing tests for default OFF mode, round reset, release timing, stale-ID isolation, bounded position history, contact memory freezing, and deterministic snapshots. +- [ ] Run `fengari tests/spectator_ai_controller_test.lua` and confirm expected failures because the module does not exist. +- [ ] Implement the smallest dependency-free controller satisfying those tests. +- [ ] Run the focused test and confirm PASS. +- [ ] Run the existing Lua and Python tests. +- [ ] Commit: `Add behavior-neutral spectator AI V2 controller state`. + +### Task 2: Activity integration in OFF mode + +**Files:** +- Modify: `Data/Base.rte/Activities/SpectatorArena.lua` +- Modify: `Data/Base.rte/Activities/SpectatorTelemetry.lua` if runtime transport requires a targeted fix +- Test: `tests/spectator_activity_integration_test.lua` or a deterministic source/trace check if engine construction cannot be isolated + +- [x] Add controller construction and round-generation initialization without changing actor AIMode or waypoint calls. +- [x] Register actors when spawned and release them only from the existing accepted touchdown-release path. +- [x] Sample positions at a low, staggered cadence after release. +- [x] Keep `AI_V2_MODE = OFF` as the explicit default and emit configuration/version metadata through the existing telemetry path. +- [x] Add tests/checks proving OFF mode makes no tactical actor mutations. +- [x] Run Lua tests, Python tests, `git diff --check`, and the Debug Release x64 build. +- [x] Commit: `Integrate spectator AI V2 controller in off mode`. + +### Task 3: Shadow contact and progress observations + +**Files:** +- Modify: `Data/Base.rte/Activities/SpectatorAIController.lua` +- Modify: `Data/Base.rte/Activities/SpectatorArena.lua` +- Test: `tests/spectator_ai_controller_test.lua` +- Modify: `tools/spectator_soak_report.py` and `tests/test_spectator_soak_report.py` only for fields proven by real output + +- [ ] Add deterministic contact confidence decay and expiry with frozen last-known positions. +- [ ] Add hard-engagement observations and progress/stuck suspicion without recovery actions. +- [ ] Add SHADOW-only proposal records for contact/task/destination choices; proposals must not call AIMode or waypoint mutators. +- [ ] Add round metrics for first contact/shot/damage, participation, target/task churn, stuck exposure, path refreshes, and watchdog rate where source evidence exists. +- [ ] Test memory expiry, engagement lock timing, progress thresholds, and no hidden-position updates. +- [ ] Run focused/full tests and a short runtime trace. +- [ ] Commit: `Add spectator AI V2 shadow observations`. + +### Task 4: Baseline run and activation gate + +**Files:** +- Modify: `docs/SPECTATOR_TELEMETRY.md` +- Modify: `docs/AUTONOMOUS_WORK_LOG.md` +- Create: `docs/SPECTATOR_AI_V2_BASELINE.md` +- Modify: `tools/spectator_soak_report.py` only after structured runtime records are available + +- [ ] Resolve the live `SPECTATOR_EVENT` output gap before treating reports as authoritative. +- [ ] Run accepted V11/V11.1 in `OFF` with instrumentation only. +- [ ] Collect at least 50 completed rounds before defining improvement thresholds. +- [ ] Record distributions and runtime cost without enabling behavior changes. +- [ ] Review SHADOW proposals for plausible contacts, low churn, no omniscient tracking, bounded CPU cost, and clean round resets. +- [ ] Do not enable `TASKS` until the baseline and SHADOW acceptance evidence is recorded. diff --git a/docs/superpowers/plans/2026-09-02-spectator-ai-v2-shadow-implementation.md b/docs/superpowers/plans/2026-09-02-spectator-ai-v2-shadow-implementation.md new file mode 100644 index 0000000000..843272159f --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-spectator-ai-v2-shadow-implementation.md @@ -0,0 +1,109 @@ +# Spectator AI V2 Shadow and Tactical Coordination Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox syntax. + +**Goal:** Add SHADOW-only observations for contact memory, engagement, task proposals, weapon/environment scoring, and recovery while preserving V11/V11.1 and keeping tactical behavior disabled. + +**Architecture:** Extend `SpectatorAIController` as a pure state/decision layer above `NativeHumanAI`. `SpectatorArena` supplies verified observations after the all-released boundary; OFF and SHADOW record or propose data without mutating actors. + +**Tech Stack:** Cortex Command Lua 5.1/LuaJIT, cached Fengari, Python `unittest`, Visual Studio MSBuild Debug Release x64. + +**Spec:** `docs/superpowers/specs/2026-09-02-spectator-ai-v2-shadow-design.md` + +## Global Constraints + +- Preserve V11/V11.1 touchdown and release exactly. +- Keep `AI_V2_MODE = "OFF"` as production default. +- Do not replace or directly control NativeHumanAI aiming, firing, reload, locomotion, jetpack, digging, or local reactions. +- Do not use omniscient hidden enemy positions as contacts. +- Do not add full influence maps, GOAP, RL, replacement pathfinding, permanent cover nodes, or pacing logic. +- Preserve uncommitted camera-review files. +- Add telemetry fields only after real runtime output proves them. + +--- + +### Task 1: Audit local AI and navigation APIs + +**Files:** Read `Data/Base.rte/AI/NativeHumanAI.lua`, `Data/Base.rte/Activities/SpectatorArena.lua`, `Source/Lua/LuaBindingsEntities.cpp`, and `Source/Lua/LuaBindingsManagers.cpp`; modify `docs/AUTONOMOUS_WORK_LOG.md`. + +**Produces:** A dated list of verified observation-safe signatures for waypoint, path, LOS, health, weapon, and terrain APIs. Unsupported or ambiguous APIs are recorded as unavailable. + +- [ ] Run `rg -n "GetLastAIWaypoint|IsWaitingOnNewMovePath|CalculatePath|ShortestDistance|CastAllMOsRay|FiredFrame|Health|WoundCount|EquippedItem" Data Source`. +- [ ] Record owner/type, arguments, return shape, and observation safety for each API actually present. +- [ ] Run `git diff --check`. +- [ ] Commit with `git add docs/AUTONOMOUS_WORK_LOG.md; git commit -m "Audit local spectator AI APIs"`. + +### Task 2: Add contact memory and engagement observations + +**Files:** Modify `Data/Base.rte/Activities/SpectatorAIController.lua`; test `tests/spectator_ai_controller_test.lua`. + +**Produces:** `RecordEngagement(actorID, timestampMS, signal, untilMS)`, `IsHardEngaged(actorID, timestampMS)`, `GetContact(team, enemyID, timestampMS)`, confidence expiry, and deterministic snapshot counts. + +- [ ] Add failing tests for frozen direct contact positions, lower-confidence shared reports, contact expiry, engagement-lock retention, and engagement expiry. +- [ ] Run `fengari tests/spectator_ai_controller_test.lua`; confirm failure because the methods are absent. +- [ ] Implement only round-scoped pure state; do not call engine APIs or mutate actors. +- [ ] Re-run the focused controller and telemetry tests; expect PASS. +- [ ] Commit `Add spectator AI V2 contact and engagement observations`. + +### Task 3: Add task, reservation, and recovery rules + +**Files:** Modify `Data/Base.rte/Activities/SpectatorAIController.lua`; test `tests/spectator_ai_controller_test.lua`. + +**Produces:** `AssignTask`, `ReserveTarget`, `CanReserveTarget`, `RecordProgress`, and `GetRecoveryStage` with hysteresis, reservation limits, expiry, and staged recovery. + +- [ ] Add failing tests for task hysteresis, reservation dogpile limits, stale reservation expiry, and one-stage-at-a-time recovery escalation. +- [ ] Run the focused test and confirm the expected missing-method failure. +- [ ] Implement deterministic rules with configurable thresholds and bounded round-scoped state. +- [ ] Re-run the focused controller test; expect PASS. +- [ ] Commit `Add spectator AI V2 task and recovery rules`. + +### Task 4: Add weapon and environment scoring + +**Files:** Modify `Data/Base.rte/Activities/SpectatorAIController.lua`; test `tests/spectator_ai_controller_test.lua`. + +**Produces:** Pure `ClassifyWeapon(profile)`, `ScoreDestination(context)`, and `SelectDistinctDestination(candidates, current, minimumImprovement)` helpers. + +- [ ] Add failing tests showing close-range weapons prefer covered close positions, long-range weapons prefer distance/LOS, invalid profiles reduce confidence, and marginal destination improvements are rejected. +- [ ] Run the focused test and confirm failure. +- [ ] Implement numeric, engine-independent scoring; do not encode faction-specific assumptions. +- [ ] Re-run focused tests; expect PASS. +- [ ] Commit `Add deterministic spectator AI weapon environment scoring`. + +### Task 5: Integrate SHADOW observations into SpectatorArena + +**Files:** Modify `Data/Base.rte/Activities/SpectatorArena.lua`; update `tests/spectator_ai_integration_test.py`; modify telemetry only if a proven transport issue appears. + +**Produces:** `UpdateAIShadowObservations(team1Actors, team2Actors)`, called only after all actors release and only when `AI_V2_MODE == "SHADOW"`. Production remains `OFF`. + +- [ ] Add failing source assertions for the explicit mode branch, post-release ordering, verified actor observations, and absence of actor mutation calls inside the shadow function. +- [ ] Run `python -m unittest discover -s tests -p 'spectator_ai_integration_test.py' -v`; confirm failure. +- [ ] Implement conservative nil-safe observation of weapon, health, firing, LOS, waypoint, progress, and terrain context using only Task 1 APIs. +- [ ] Emit proposals without applying AIMode, waypoint, controller, inventory, health, position, or combat changes. +- [ ] Run Lua tests, Python tests, integration tests, `git diff --check`, and the Debug Release x64 build. +- [ ] Commit `Add spectator AI V2 shadow observations`. + +### Task 6: Capture and review SHADOW evidence + +**Files:** Modify `tools/spectator_soak_report.py` and `tests/test_spectator_soak_report.py` only for proven fields; create `docs/SPECTATOR_AI_V2_SHADOW_REPORT.md`; update `docs/SPECTATOR_TELEMETRY.md` and `docs/AUTONOMOUS_WORK_LOG.md`. + +**Produces:** A report separating measured observations from proposals, malformed lines, and limitations. + +- [ ] Add failing parser tests using captured `PRINT: SPECTATOR_EVENT` lines with decimal values and malformed optional fields. +- [ ] Implement backward-compatible parsing; ignore malformed fields without losing round counts. +- [ ] Run a non-default SHADOW trace, capture `SPECTATOR_EVENT_LOG.txt`, and do not commit generated logs. +- [ ] Report contact plausibility, task/target churn, weapon/environment differentiation, recovery correlation, CPU/UPS observations, and hidden-position violations. +- [ ] Run all Lua/Python tests, `git diff --check`, and the Debug Release x64 build. +- [ ] Commit `Add spectator AI V2 shadow report`. + +### Task 7: Complete baseline comparison and activation review + +**Files:** Create `docs/SPECTATOR_AI_V2_BASELINE.md`; update `docs/PROJECT_STATUS_2026-09-02.md` and `docs/AUTONOMOUS_WORK_LOG.md`. + +**Produces:** An explicit recommendation to remain OFF, continue SHADOW, or request approval for TASKS. + +- [ ] Require at least 50 completed OFF-mode rounds before defining activation thresholds. +- [ ] Record winner distribution, durations, watchdog rate, incomplete rounds, runtime stability, and CPU/UPS impact when available. +- [ ] Compare SHADOW proposals against OFF observations and identify measured values, inferences, limitations, and unanswered questions. +- [ ] Do not claim improvement without a behavior-enabled comparison. +- [ ] Run the complete verification suite and `git diff --check`. +- [ ] Commit `Record spectator AI V2 baseline review`. diff --git a/docs/superpowers/plans/2026-09-02-spectator-observability.md b/docs/superpowers/plans/2026-09-02-spectator-observability.md new file mode 100644 index 0000000000..038af5245e --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-spectator-observability.md @@ -0,0 +1,65 @@ +# Spectator Observability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make autonomous spectator runs auditable from structured log lines and a deterministic soak-report script without changing the proven round lifecycle. + +**Architecture:** Add a small Lua telemetry helper that emits stable `SPECTATOR_EVENT` records and is called only at meaningful lifecycle boundaries. Add a dependency-free Lua log parser that aggregates those records into a compact report and tests it with synthetic input. Existing camera and lifecycle changes remain untouched except for instrumentation call sites. + +**Tech Stack:** Cortex Command Lua, Lua 5.x standard library, PowerShell test runner. + +**Spec:** User-provided Cortex Command Spectator Project brief in the session attachment. + +## Global Constraints + +- Do not return to turn-based gameplay. +- Do not casually redesign the working round lifecycle. +- Do not spend this session on subjective visual tuning. +- Preserve all pre-existing uncommitted camera work. +- Verification must be test/log based. + +--- + +### Task 1: Structured event emitter + +**Files:** +- Create: `Data/Base.rte/Activities/SpectatorTelemetry.lua` +- Test: `tests/spectator_telemetry_test.lua` + +- [ ] Define `Telemetry.Encode(event, fields)` with deterministic key ordering and safe scalar encoding. +- [ ] Define `Telemetry.Emit(event, fields, sink)` so tests can capture lines and runtime defaults to `print`. +- [ ] Test lifecycle, faction, score, watchdog, and malformed-field cases. +- [ ] Run the Lua test and require `PASS`. + +### Task 2: Instrument meaningful lifecycle events + +**Files:** +- Modify: `Data/Base.rte/Activities/SpectatorArena.lua` + +- [ ] Require/load the telemetry module using the project’s existing activity module convention. +- [ ] Emit activity start, round start/spawn counts, battle start, round result, watchdog intervention, reset, and cumulative score events. +- [ ] Keep per-frame logging unchanged except for state-transition guards; do not emit frame-rate telemetry. +- [ ] Run static Lua loading checks and the pure Lua tests. + +### Task 3: Deterministic soak report + +**Files:** +- Create: `tools/spectator_soak_report.lua` +- Create: `tests/spectator_soak_report_test.lua` +- Create: `docs/SPECTATOR_TELEMETRY.md` + +- [ ] Parse `SPECTATOR_EVENT` lines and calculate runtime, rounds, wins, draws, duration extrema/average, watchdogs, abnormal resets, and errors. +- [ ] Ignore unrelated log lines and report incomplete final rounds explicitly. +- [ ] Add synthetic-log tests covering normal rounds, watchdog result, duplicate result, and malformed lines. +- [ ] Document event format, parser usage, and output fields. + +### Task 4: Audit checkpoint + +**Files:** +- Create/update: `docs/AUTONOMOUS_WORK_LOG.md` +- Create: `docs/AUTONOMOUS_SESSION_SUMMARY_2026-09-02.md` + +- [ ] Record starting Git state, changed files, commands, and evidence. +- [ ] Run all available tests and inspect `git diff --check`. +- [ ] Commit only this session’s verified changes; leave pre-existing experimental work recoverable and clearly listed. + diff --git a/docs/superpowers/plans/2026-09-13-camera-dying-edge-experiment.md b/docs/superpowers/plans/2026-09-13-camera-dying-edge-experiment.md new file mode 100644 index 0000000000..932327d5d9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-camera-dying-edge-experiment.md @@ -0,0 +1,204 @@ +# Camera DYING-Edge Attribution Experiment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace unreliable live-to-DEAD/removal observation with a Lua-visible DYING edge while preserving the existing conservative attribution and camera behavior. + +**Architecture:** Keep `SpectatorCameraEventLogic.lua` as the pure lifecycle/attribution policy layer and keep `SpectatorArena.lua` responsible for actor snapshots, trace correlation, and camera state. A shot receives one monotonically increasing trace ID; all lifecycle, attribution, request, target, hold, and return markers inherit that ID. + +**Tech Stack:** Cortex Command Lua activity scripts, Lua fixture tests executed through the repository's configured Lua harness, Python source/integration tests, native Debug Release runtime capture, Markdown project records. + +**Spec:** Approved in-chat design from 2026-09-13: `DEAD/removal-based → DYING-edge observation`; keep shooter identity, single-victim, opposing-team, aim-cone, distance, shot-recency, 400 ms, priority, request/target/hold/return behavior unchanged. + +## Global Constraints + +- Do not widen the 400 ms recent-fire window. +- Do not change camera priority, target position, hold duration, cooldowns, or return behavior. +- Treat DYING as a candidate lifecycle signal, not as global proof of camera-worthy death. +- Preserve the opposing-team, single-victim, shooter-identity, aim-cone, distance, and handled-victim gates. +- Record explicit rejection reasons for stale shots, shooter mismatch, no candidate, aim-cone failure, distance failure, multiple candidates, and handled victims. +- Keep `AI_V2_MODE` OFF and nearby-ally tracking deferred. +- Do not stage local runtime logs, captures, or other untracked artifacts. + +### Task 1: Add pure DYING-edge and rejection-reason tests + +**Files:** +- Modify: `tests/spectator_camera_event_test.lua` +- Modify: `tests/spectator_ai_integration_test.py` + +**Interfaces:** +- Consumes: Existing `CameraEventLogic.HasObservedDeath` and `SelectEventCandidate` contracts. +- Produces: Expected `CameraEventLogic.HasObservedDying(previousStatus, currentStatus)` behavior and trace-marker/source assertions for `CAMERA_EVENT_DYING_OBSERVED` and `traceID`. + +- [x] **Step 1: Write the failing test** + +Add these assertions to `tests/spectator_camera_event_test.lua` using the +engine's `Actor.Status` enum values (`STABLE = 0`, `DYING = 3`, `DEAD = 4`): + +```lua +assertEqual( + CameraEventLogic.HasObservedDying(0, 3, 3), + true, + "stable-to-dying transition is lifecycle evidence" +) +assertEqual( + CameraEventLogic.HasObservedDying(3, 3, 3), + false, + "a sustained dying state is not a second edge" +) +assertEqual( + CameraEventLogic.HasObservedDying(4, 4, 3), + false, + "dead state is not a new dying edge" +) +``` + +Extend the integration source checks to require `CAMERA_EVENT_DYING_OBSERVED`, `traceID`, and rejection strings `STALE_SHOT`, `SHOOTER_MISMATCH`, `NO_CANDIDATE`, `AIM_CONE`, `DISTANCE`, and `MULTIPLE_VICTIMS`. + +- [x] **Step 2: Run test to verify it fails** + +Run the repository's Lua camera-event test and the focused Python integration test. Expected result: the Lua test fails because `HasObservedDying` is not defined, and the Python test fails because the new marker/reason strings are absent. + +- [x] **Step 3: Commit** + +Do not commit this red test-only state; continue directly to Task 2 after recording the expected failures. + +### Task 2: Implement the minimal DYING-edge observation and correlated diagnostics + +**Files:** +- Modify: `Data/Base.rte/Activities/SpectatorCameraEventLogic.lua` +- Modify: `Data/Base.rte/Activities/SpectatorArena.lua` + +**Interfaces:** +- Consumes: Actor `Status`, `Health`, `PrevHealth`, and `UniqueID`; existing shot and attribution structures. +- Produces: `HasObservedDying(previousStatus, currentStatus)`, one `CAMERA_EVENT_DYING_OBSERVED` per new edge, unchanged event selection gates, explicit attribution rejection reason, and one `traceID` carried through the camera event lifecycle. + +- [x] **Step 1: Add the pure failing behavior's minimal implementation** + +Implement `HasObservedDying` as `previousStatus ~= Actor.DYING and currentStatus == Actor.DYING`. Extend candidate evaluation only enough to return a reason alongside a nil selection; do not alter the existing aim, range, recency, team, or single-candidate decisions. + +- [x] **Step 2: Add trace correlation at fire creation** + +Initialize `self.CameraTraceSequence = 0`. Increment it whenever `CameraLastShot` is created and store the value as `traceID`. Update `EmitCameraTrace` to default `fields.traceID` from `CameraLastShot.traceID` when the caller does not provide one. + +- [x] **Step 3: Replace the lifecycle observation point** + +Store `status`, `health`, `prevHealth`, and position in each tracked actor record. During `DetectCameraEvent`, recognize only a transition to `Actor.DYING` as the new lifecycle candidate. Emit `CAMERA_EVENT_DYING_OBSERVED` with `traceID`, shooter, victim, victim team, health, previous health, position, and shot age. Keep removal logging diagnostic-only and do not treat an unobserved removal as a candidate. + +- [x] **Step 4: Preserve downstream camera behavior** + +Pass DYING candidates through the existing `SelectEventCandidate` gates. Emit `CAMERA_EVENT_ATTRIBUTION_ACCEPTED` or `CAMERA_EVENT_ATTRIBUTION_REJECTED` with the same existing fields plus `traceID` and `reason`; keep `EnterEventMode`, target issuance, hold, return, cooldown, and priority code unchanged except for passing the accepted event's trace ID to downstream markers. + +- [x] **Step 5: Run the focused tests** + +Run the Lua camera-event test, Lua controller test, Python integration suite, and `git diff --check`. Expected result: all tests pass and the only source changes are the lifecycle/diagnostic experiment plus its tests. + +- [x] **Step 6: Commit the implementation** + +```powershell +git add tests/spectator_camera_event_test.lua tests/spectator_ai_integration_test.py Data/Base.rte/Activities/SpectatorCameraEventLogic.lua Data/Base.rte/Activities/SpectatorArena.lua +git commit -m "test: observe camera victim DYING edges" +``` + +### Task 3: Run the bounded runtime experiment + +**Files:** +- Create: `work/camera-dying-edge-experiment-20260913-2100/` runtime capture directory (replace `2100` with the actual capture start time) +- Inspect: `SPECTATOR_EVENT_LOG.txt` and the camera trace output + +**Interfaces:** +- Consumes: Debug Release executable and the Task 2 diagnostic build. +- Produces: Counts and one correlated trace chain, or evidence that DYING is observed while attribution remains rejected. + +- [x] **Step 1: Launch the existing direct Arena runtime** + +Use the already verified Debug Release launch path without changing startup settings, AI mode, map, team size, or camera parameters. + +- [x] **Step 2: Capture trace and rendered-frame evidence** + +Capture approximately 25–60 seconds at the established frame size/rate and retain the trace log. Do not use the failed second-capture procedure or terminate the game in a way that discards buffered logs; use orderly shutdown. + +- [x] **Step 3: Correlate the result** + +Search for `traceID` and verify whether any sequence reaches `FIRE_OBSERVED → DYING_OBSERVED → ATTRIBUTION_ACCEPTED → CAMERA_EVENT_REQUEST → CAMERA_EVENT_TARGET_ISSUED → CAMERA_EVENT_HOLD_COMPLETE → CAMERA_EVENT_RETURN`. If no accept occurs, report DYING count and rejection-reason counts without loosening any gate. + +- [x] **Step 4: Review frames only around a correlated event** + +If an accepted event exists, inspect frames from approximately T−2 seconds through T+3–5 seconds and verify movement, arrival, hold, and return. If no accepted event exists, record that visual behavioral acceptance remains HOLD. + +### Task 4: Synchronize evidence and project records + +**Files:** +- Modify: `docs/SPECTATOR_CAMERA_ENGAGEMENT_OFFSET_2026-09-13.md` +- Modify: `docs/SPECTATOR_ARENA.md` +- Modify: `docs/CORTEX_COMMAND_KNOWLEDGE_BRIDGE.md` +- Modify: `docs/AUTONOMOUS_WORK_LOG.md` + +**Interfaces:** +- Consumes: Task 2 commit hash, Task 3 trace counts, rendered capture result, and rejection reasons. +- Produces: A source-grounded experiment record that distinguishes lifecycle-observation PASS from DYING attribution acceptance and camera behavioral acceptance. + +- [x] **Step 1: Record the experiment outcome locally** + +State the exact commit, runtime duration, DYING count, accepted/rejected counts, reason counts, and whether the full correlated chain was observed. Preserve the existing HOLD language when no full chain is proven. + +- [x] **Step 2: Run final verification** + +Run the focused tests, Python integration suite, `git diff --check`, and `git status --short`; confirm runtime artifacts remain untracked. + +- [x] **Step 3: Commit and push the evidence record** + +```powershell +git add docs/SPECTATOR_CAMERA_ENGAGEMENT_OFFSET_2026-09-13.md docs/SPECTATOR_ARENA.md docs/CORTEX_COMMAND_KNOWLEDGE_BRIDGE.md docs/AUTONOMOUS_WORK_LOG.md +git commit -m "docs: record camera dying-edge experiment" +git push fork HEAD:spectator-random-factions +``` + +- [x] **Step 4: Update Drive and Notion** + +Replace the four canonical Drive markdown artifacts from the isolated worktree and update the existing Notion project page with the new commit, experiment status, and next milestone. Read back all five records before reporting completion. + +### Task 5: Close DYING attribution accounting and run the condition-based sample + +**Files:** +- Modify: `tests/spectator_ai_integration_test.py` +- Modify: `Data/Base.rte/Activities/SpectatorArena.lua` +- Modify: `docs/SPECTATOR_CAMERA_ENGAGEMENT_OFFSET_2026-09-13.md` +- Modify: `docs/SPECTATOR_ARENA.md` +- Modify: `docs/CORTEX_COMMAND_KNOWLEDGE_BRIDGE.md` +- Modify: `docs/AUTONOMOUS_WORK_LOG.md` + +**Interfaces:** +- Consumes: One-shot `CAMERA_EVENT_DYING_OBSERVED` records and the existing attribution selector. +- Produces: Exactly one terminal `CAMERA_EVENT_ATTRIBUTION_ACCEPTED`, `CAMERA_EVENT_ATTRIBUTION_REJECTED`, or `CAMERA_EVENT_ATTRIBUTION_NOT_EVALUATED` disposition per DYING edge, plus condition-based runtime evidence. + +- [x] **Step 1: Write the failing accounting assertions** + +Require the activity source to contain `CAMERA_EVENT_ATTRIBUTION_NOT_EVALUATED`, `NO_CORRELATABLE_SHOT`, `COOLDOWN`, and `DYING_OBSERVED` alongside the existing acceptance/rejection markers. The existing runtime parser must count the three terminal disposition event names separately. + +- [x] **Step 2: Run the focused test to verify it fails** + +Run `python tests/spectator_ai_integration_test.py`. Expected result: the test fails because the not-evaluated marker and reasons are not yet present. + +- [x] **Step 3: Implement terminal accounting without changing attribution policy** + +Collect DYING candidates even when no shot is available, and emit `CAMERA_EVENT_ATTRIBUTION_NOT_EVALUATED` with `NO_CORRELATABLE_SHOT`, `COOLDOWN`, or `SHOOTER_MISMATCH` when the existing early-return conditions prevent evaluation. When evaluation runs, emit one accepted or rejected disposition per candidate; use `MULTIPLE_VICTIMS` for every candidate in an ambiguous set. Keep selection, thresholds, recency, cooldown, priority, and camera state unchanged. + +- [x] **Step 4: Run all focused verification** + +Run the Python integration suite, native Debug Release launch, and `git diff --check`. Confirm the accounting invariant in the runtime parser: DYING observed equals accepted plus rejected plus not evaluated. + +- [x] **Step 5: Run until an accept or the round bound** + +Capture a rolling rendered-frame buffer at the established window size while the unchanged Arena runs until the first accepted trace ID or 20 completed rounds, whichever comes first. Preserve the prior 8 seconds of frames when an accept appears, then retain at least 5 seconds after return. If no accept appears, keep only the bounded summary and report all terminal dispositions. + +- [x] **Step 6: Document and synchronize the outcome** + +Record the exact counts, reason distribution, first accepted trace ID if any, frame-buffer path, and acceptance status in the four local documents. Commit, push the PR branch, fast-forward the original checkout, update the four Drive artifacts and the Notion project page, and read back all external records. + +### Task 6: Observe physical camera execution without changing behavior + +- [x] Add high-rate native camera offset, target-distance, onset, and arrival telemetry. +- [x] Run one accepted event through movement onset, arrival, hold, and return. +- [x] Preserve the native telemetry log and record the rendered-capture limitation. +- [ ] Complete the separate 3–5-round behavioral acceptance review. diff --git a/docs/superpowers/specs/2026-09-02-spectator-ai-v2-shadow-design.md b/docs/superpowers/specs/2026-09-02-spectator-ai-v2-shadow-design.md new file mode 100644 index 0000000000..4e172ae944 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-spectator-ai-v2-shadow-design.md @@ -0,0 +1,221 @@ +# Spectator AI V2 Shadow and Tactical Coordination Design + +Date: 2026-09-02 +Project: Cortex Command Community Project / Spectator Arena +Status: Approved design; implementation plan follows user review + +## Objective + +Improve the 8v8 Spectator Arena AI with more independent squad intent, weapon-aware positioning, environmental reasoning, imperfect contact memory, and gradual recovery while preserving the proven V11/V11.1 combat loop. + +The system must make the AI more capable at deciding where and why to fight without replacing the native actor motor that already handles how actors move, aim, fire, reload, jetpack, dig, and react locally. + +## Architectural boundary + +`SpectatorAIController` is an activity-level tactical coordinator above `NativeHumanAI`. + +The coordinator may observe actors, maintain round-scoped state, score strategic destinations, assign squad tasks, remember uncertain contacts, and propose high-level intent. + +The coordinator must not directly implement or replace: + +- aiming or firing +- reload or weapon handling +- locomotion execution +- jetpack execution +- digging execution +- immediate enemy reaction +- custom controller input +- replacement pathfinding + +During `OFF` and `SHADOW`, the coordinator must not mutate actor AIMode, waypoints, controllers, inventory, health, position, or combat state. + +## Preservation contract + +The accepted V11/V11.1 flow remains unchanged: + +`SPAWN -> SENTRY -> TOUCHDOWN -> RELEASE -> ALL_RELEASED -> AI_V2_WARMUP -> TACTICAL_ACTIVE` + +AI V2 may not register tactical control before the existing touchdown/release boundary. All living actors must still be individually released, and the all-released gate remains authoritative. + +The current production default is `AI_V2_MODE = "OFF"`. No mode change is part of this design approval. + +## Rollout modes + +### OFF + +The coordinator records only lifecycle-safe instrumentation. Existing V11/V11.1 behavior is authoritative and unchanged. + +### SHADOW + +The coordinator computes contacts, tasks, destinations, reservations, weapon/environment scores, and recovery proposals, but logs proposals without applying actor changes. SHADOW is the first behavior-analysis mode after the 50-round OFF baseline. + +### TASKS + +The coordinator may apply validated squad-level task and destination assignments after explicit evidence review. NativeHumanAI still executes movement and combat. + +### TACTICAL + +The coordinator may use the complete validated task, weapon, environment, contact, and recovery policy. It remains prohibited until TASKS evidence shows no regression in lifecycle, combat participation, stuck exposure, watchdog rate, or CPU cost. + +## State model + +The controller owns round-scoped state: + +`Config`, `RoundGeneration`, `ActorState`, `TeamState`, `Squads`, `ContactMemory`, `TargetReservations`, `EventLog`, `Metrics`, and `Scheduler`. + +Each actor state includes: + +- UniqueID, team, spawn index, squad ID, and released flag +- current task, task start time, strategic target, destination, and destination score +- bounded position history and waypoint distance +- last progress time and recovery stage +- last health, fire, damage, and visible-enemy timestamps +- hard-engagement expiry +- decision and retask counters + +Each contact includes enemy ID, frozen last-known position, last-seen time, confidence, source, and uncertainty. Hidden enemy positions must never be injected as direct team knowledge. + +## Squad model and tasks + +Each team begins with two four-actor squads. Squad membership is deterministic within a round and may be reassigned only at a controlled future boundary. + +Initial task vocabulary: + +- `PRESSURE`: advance toward a credible contact or strategic engagement anchor. +- `MANEUVER`: approach from a distinct alternate angle or route. +- `SEARCH`: investigate a stale last-known contact with bounded confidence. +- `REGROUP`: restore spacing and reconnect separated actors. +- `RECOVER`: resolve path delay, blockage, or prolonged lack of progress. + +Every task has a reason, destination, start time, success condition, failure condition, timeout, and recovery task. Task changes use hysteresis so a small score difference does not cause churn. + +## Engagement lock + +An actor becomes hard-engaged after strong recent evidence such as direct line of sight, recent firing, recent damage, or close hostile proximity. The initial observation window is configurable in the controller and must be tuned from logs rather than assumed permanent. + +While hard-engaged, SHADOW may continue measuring but must not propose minor waypoint replacement as an urgent decision. Future TASKS/Tactical application may override only for emergency recovery or a materially superior validated action. + +## Weapon reasoning + +Weapon metadata is observational first. The coordinator records broad weapon class, range tendency, ammunition/reload limitations when available, recent firing, and confidence of the classification. + +Initial strategic implications: + +- close-range weapons prefer flanks, cover, and shorter approach paths +- long-range weapons prefer distance, elevation, and clear sightlines +- heavy or suppression-capable weapons prefer support lanes and clustered enemy pressure +- explosives may score blocked routes, dense targets, and destructible barriers +- low ammunition or uncertain weapon state reduces confidence rather than forcing an unsafe action + +The coordinator chooses destinations and tasks; NativeHumanAI remains responsible for using the weapon. + +## Environment reasoning + +Use bounded, transient spatial queries rather than a permanent influence map in the first implementation. A destination score may combine: + +`weapon suitability + line of sight + cover/exposure + squad spacing + path reliability + strategic proximity - enemy concentration - crowding` + +Queries may include terrain height, slope, LOS, path length, path status, vertical access, nearby actors, chokepoints, and recent terrain changes when supported by verified local APIs. + +The coordinator must audit local API signatures before relying on development-branch assumptions. No replacement `PathFinder`, GOAP system, full influence grid, permanent cover-node database, or forced jetpack behavior is included. + +## Contact memory and information limits + +Contacts are timestamped and confidence-decayed. Direct observations have higher confidence than teammate reports. Shared reports include age and uncertainty. Expired contacts become search candidates or disappear from tactical consideration. + +The system must never use omniscient hidden enemy positions as if an actor had seen them. This rule applies to scoring, reservations, destination selection, and telemetry labels. + +## Recovery ladder + +Recovery escalates only when evidence persists: + +1. wait for a pending path or delayed engine response +2. re-evaluate the current destination +3. refresh the path +4. choose a nearby alternate destination +5. regroup with teammates +6. search from a new bounded position +7. apply a stronger recovery proposal only in a later validated mode + +Recovery is measured separately from ordinary task changes and must not become a high-frequency retask loop. + +## Telemetry and metrics + +Telemetry uses the existing `SPECTATOR_EVENT` format and activity-scoped snapshot path. New fields are added only when real runtime output proves them. + +Required observations before behavior activation include: + +- first contact, first shot, first damage, and combat participation +- task and target churn +- hard-engagement duration +- target reservations and dogpiling +- squad spacing and separation +- position progress and stuck exposure +- path refreshes and recovery stages +- weapon classification confidence and usage context +- round duration, watchdog outcome, and CPU/UPS impact when available + +Every record must identify round generation and mode. The reporting tool must ignore malformed or unrelated console noise. + +## Testing strategy + +Pure controller rules are tested under cached Fengari without engine construction. Tests cover: + +- default mode and round reset +- stale-ID isolation +- bounded actor position history +- frozen contact memory and confidence expiry +- engagement-lock timing +- task hysteresis +- reservation expiry and dogpile limits +- progress thresholds and recovery escalation +- weapon/environment score determinism +- no hidden-position contact updates +- deterministic snapshots + +Activity integration tests verify: + +- V11/V11.1 release boundary remains authoritative +- OFF and SHADOW contain no tactical actor mutation +- round generation resets all AI V2 state +- telemetry fields are emitted through the existing path + +Before each mode transition, run Lua tests, Python tests, `git diff --check`, Debug Release x64 build, and a runtime soak appropriate to the gate. + +## Activation gates + +### Gate 1: OFF baseline + +- at least 50 completed rounds +- no unexplained lifecycle or touchdown regression +- reportable round durations, winners, watchdog rate, and runtime stability + +### Gate 2: SHADOW review + +- plausible contacts without omniscient tracking +- bounded task/target churn +- meaningful weapon/environment differentiation +- recovery proposals correlate with actual lack of progress +- no unacceptable CPU/UPS cost + +### Gate 3: TASKS review + +- task application does not break touchdown or native combat +- lower or equal stuck exposure +- no watchdog-rate regression +- stable squad separation and participation + +### Gate 4: TACTICAL review + +- repeatable improvement against the same baseline conditions +- explainable decisions in telemetry +- preserved round-loop stability +- explicit user approval before default activation + +## Deferred scope + +The first implementation does not include reinforcement learning, runtime LLM control, full GOAP, direct controller manipulation, custom aiming, custom firing, a replacement pathfinder, permanent cover nodes, grenade micro-management, personality simulation, or a pacing director. + +## Immediate next step + +Finish the 50-round OFF baseline and preserve its report. Then implement SHADOW-only contact, progress, weapon, environment, and task proposals behind an explicit mode flag. Do not enable TASKS or TACTICAL behavior until the corresponding evidence gates are reviewed. diff --git a/tests/spectator_ai_controller_test.lua b/tests/spectator_ai_controller_test.lua new file mode 100644 index 0000000000..9c4ed0bda7 --- /dev/null +++ b/tests/spectator_ai_controller_test.lua @@ -0,0 +1,225 @@ +package.path = "Data/Base.rte/?.lua;" .. package.path + +local Controller = require("Activities/SpectatorAIController") + +local function assertEqual(actual, expected, message) + if actual ~= expected then + error((message or "values differ") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual)) + end +end + +local function assertTrue(value, message) + if value ~= true then + error(message or "expected true") + end +end + +local function assertFalse(value, message) + if value ~= false then + error(message or "expected false") + end +end + +assertTrue(Controller.IsVisibleRayHit(42, 42, 42, -1), "direct target ray hit is visible") +assertTrue(Controller.IsVisibleRayHit(99, 42, 99, -1), "target child ray hit is visible") +assertFalse(Controller.IsVisibleRayHit(-1, 42, 42, -1), "terrain ray hit is blocked") +assertFalse(Controller.IsVisibleRayHit(77, 42, 42, -1), "intervening actor ray hit is blocked") +assertEqual(Controller.ClassifyRayHit(42, 42, 42, -1), "TARGET", "direct target hit is classified") +assertEqual(Controller.ClassifyRayHit(99, 42, 99, -1), "TARGET_ROOT", "target root hit is classified") +assertEqual(Controller.ClassifyRayHit(-1, 42, 42, -1), "NO_MOID", "no-MOID hit is classified") +assertEqual(Controller.ClassifyRayHit(77, 42, 42, -1), "BLOCKED", "intervening actor hit is classified") +assertEqual(Controller.CalculateCPUTimeMS(1.25, 1.5), 250, "CPU time converts to milliseconds") + +local sightTargets = Controller.BuildSightProbeTargets( + { X = 100, Y = 200 }, + { X = 100, Y = 180 } +) +assertEqual(#sightTargets, 2, "body and eye sight targets are both probed") +assertEqual(sightTargets[1].kind, "BODY", "body is the first native-aligned sight probe") +assertEqual(sightTargets[2].kind, "EYE", "eye is the fallback native-aligned sight probe") +assertEqual(#Controller.BuildSightProbeTargets({ X = 1, Y = 2 }, { X = 1, Y = 2 }), 1, + "identical body and eye targets are not double-probed") + +local controller = Controller.Create({ + mode = "OFF", + positionHistoryLimit = 2, + contactMemoryTTLMS = 3000, + taskHysteresisMS = 2000, + reservationTTLMS = 2000, + progressStallMS = 1000, + maxRecoveryStage = 3 +}) +assertEqual(controller.Mode, "OFF", "controller defaults to OFF") + +local nearestVisible, nearestVisibleDistance, visibleCount = + Controller.SelectVisibleOpponent({ + { UniqueID = 202, distanceSquared = 100 }, + { UniqueID = 303, distanceSquared = 400 }, + }, { + [202] = false, + [303] = true + }) +assertEqual(nearestVisible.UniqueID, 303, "blocked nearest opponent is skipped") +assertEqual(nearestVisibleDistance, 400, "nearest visible distance is selected") +assertEqual(visibleCount, 1, "visible opponent count is reported") + +controller:BeginRound(7, 1234) +controller:RegisterActor(101, 1, 1) +controller:RegisterActor(202, 2, 1) +controller:ReleaseActor(101, 500) + +local actor = controller.ActorState[101] +assertEqual(actor.Released, true, "release marks actor released") +assertEqual(actor.ReleaseTimeMS, 500, "release time is recorded") +assertEqual(controller.ActorState[202].Released, false, "unreleased actor remains gated") + +controller:RecordPosition(101, 1000, 10, 20, 100, 20, false, false) +controller:RecordPosition(101, 1500, 11, 20, 100, 20, false, false) +controller:RecordPosition(101, 2000, 12, 20, 100, 20, false, false) +assertEqual(#controller.ActorState[101].PositionSamples, 2, "position history is bounded") +assertEqual(controller.ActorState[101].PositionSamples[1].timestampMS, 1500, "oldest sample is evicted") + +controller:RecordContact(1, 202, 2100, 200, 300, 1.0, "DIRECT") +controller:RecordContact(1, 202, 2500, 999, 999, 0.5, "MEMORY") +assertEqual(controller.ContactMemory[1][202].x, 200, "memory observation does not rewrite frozen position") +assertEqual(controller.ContactMemory[1][202].Confidence, 0.5, "memory confidence can decay explicitly") +assertEqual(controller:GetContact(1, 202, 3000).x, 200, "live contact returns frozen position") +assertEqual(controller:GetContact(1, 202, 6000), nil, "expired contact is not returned") +assertFalse(controller:RecordContact(1, 404, 2600, 400, 400, 1.0, "WORLD_TRUTH"), + "world-truth coordinates cannot enter team contact memory") + +controller:RecordEngagement(101, 1000, "FIRE", 2500) +assertTrue(controller:IsHardEngaged(101, 2000), "engagement lock remains active until expiry") +assertFalse(controller:IsHardEngaged(101, 2501), "engagement lock expires after its deadline") + +local snapshot = controller:Snapshot() +assertEqual(snapshot.RoundID, 7, "snapshot identifies round") +assertEqual(snapshot.RegisteredActors, 2, "snapshot counts actors") +assertEqual(snapshot.ReleasedActors, 1, "snapshot counts released actors") +assertEqual(snapshot.EngagementObservations, 1, "snapshot counts engagement observations") + +controller:AssignTask(101, "PRESSURE", 1000) +assertEqual(controller.ActorState[101].Task, "PRESSURE", "initial task is assigned") +assertFalse(controller:AssignTask(101, "MANEUVER", 1500), "task hysteresis rejects rapid churn") +assertTrue(controller:AssignTask(101, "MANEUVER", 3001), "task hysteresis permits a mature switch") + +controller:RegisterActor(303, 1, 2) +assertTrue(controller:ReserveTarget(101, 202, 4000, 5000, 1), "first target reservation succeeds") +assertFalse(controller:CanReserveTarget(303, 202, 4500, 1), "reservation limit prevents a dogpile") +assertFalse(controller:ReserveTarget(303, 202, 4500, 5000, 1), "blocked reservation is not recorded") +assertTrue(controller:CanReserveTarget(303, 202, 7001, 1), "stale reservation expires") +assertTrue(controller:ReserveTarget(303, 202, 7001, 5000, 1), "replacement reservation succeeds after expiry") + +controller:RecordProgress(101, 8000, 10) +assertEqual(controller:GetRecoveryStage(101), 0, "fresh progress has no recovery stage") +controller:RecordProgress(101, 9501, 10) +assertEqual(controller:GetRecoveryStage(101), 1, "first stall escalates one recovery stage") +controller:RecordProgress(101, 11002, 10) +assertEqual(controller:GetRecoveryStage(101), 2, "continued stall escalates only one stage at a time") +assertEqual(controller:GetRecoveryStage(101), 2, "recovery stage remains bounded between observations") + +controller:RecordShadowObservation(101, 12000, true, false, 90, 100, 202) +controller:RecordShadowObservation(101, 12500, false, true, 80, 90, nil) +assertEqual(controller.Metrics.ShadowObservations, 2, "shadow observations are aggregated") +assertEqual(controller.Metrics.LOSChecks, 2, "LOS checks are aggregated") +assertEqual(controller.Metrics.LOSPositive, 1, "positive LOS checks are aggregated") +assertEqual(controller.Metrics.FireEvents, 1, "fire events are latched") +assertEqual(controller.Metrics.DamageEvents, 1, "damage events are latched") +assertEqual(controller.Metrics.ContactAcquisitions, 1, "contact acquisition is latched") +assertEqual(controller.Metrics.ContactLosses, 1, "contact loss is latched") +assertTrue(controller:FiredRecently(101, 13000, 1000), "recent fire latch remains active") +assertFalse(controller:FiredRecently(101, 13501, 1000), "recent fire latch expires") + +local fireEventsBeforeSignal = controller.Metrics.FireEvents +local damageEventsBeforeSignal = controller.Metrics.DamageEvents +controller:RecordCombatSignals(101, 14000, true, 70, 80) +assertEqual(controller.Metrics.FireEvents, fireEventsBeforeSignal + 1, "high-frequency fire signal is latched") +assertEqual(controller.Metrics.DamageEvents, damageEventsBeforeSignal + 1, "high-frequency damage signal is latched") + +local fireEventsBeforeSensor = controller.Metrics.FireEvents +controller:RecordFireSensorSample(101, 14500, 77, 101, true, 2, 3) +controller:RecordFireSensorSample(101, 14517, 77, 101, false, 0, 0) +local fireSensorState = controller:GetFireSensorState(101) +assertEqual(fireSensorState.Team, 1, "fire sensor state retains the actor team") +assertEqual(fireSensorState.FirearmMOID, 77, "fire sensor state retains the equipped firearm MOID") +assertEqual(fireSensorState.FirearmRootMOID, 101, "fire sensor state retains the firearm root MOID") +assertEqual(fireSensorState.SampleCount, 2, "fire sensor state counts consecutive samples") +assertEqual(fireSensorState.FirstSampleTimeMS, 14500, "fire sensor state records the first sample timestamp") +assertEqual(fireSensorState.LastSampleTimeMS, 14517, "fire sensor state records the latest sample timestamp") +assertEqual(fireSensorState.FiredFrameTransitions, 1, "fire sensor state records a fired-frame rising transition") +assertEqual(fireSensorState.RoundsFiredSamples, 1, "fire sensor state records positive rounds-fired samples") +assertEqual(fireSensorState.FireEventCount, fireEventsBeforeSensor + 1, "fire sensor samples latch one durable fire event") +assertEqual(fireSensorState.FireFrameCount, 1, "fire sensor state counts fired frames") +assertEqual(fireSensorState.RoundsDischargedObserved, 2, "fire sensor state counts observed discharged rounds") + +local fireSensorSnapshot = controller:Snapshot() +assertEqual(fireSensorSnapshot.FireSensorSamples, 2, "fire sensor samples are aggregated") +assertEqual(fireSensorSnapshot.FirearmEquippedSamples, 2, "equipped firearms are aggregated") +assertEqual(fireSensorSnapshot.FiredFrameSamples, 1, "positive fired-frame samples are aggregated") +assertEqual(fireSensorSnapshot.RoundsFiredSamples, 1, "positive rounds-fired samples are aggregated") +assertEqual(fireSensorSnapshot.FireFrameCount, 1, "fired frames are aggregated") +assertEqual(fireSensorSnapshot.RoundsDischargedObserved, 2, "discharged rounds are aggregated") +assertEqual(fireSensorSnapshot.AlarmEventsObserved, 3, "alarm events are aggregated once per timestamp") + +controller:RecordFireSensorContext(101, "FG", "HDFirearm", "HeldDevice", 3, 1) +fireSensorState = controller:GetFireSensorState(101) +assertEqual(fireSensorState.FirearmSlot, "FG", "fire sensor state records the hand holding the firearm") +assertEqual(fireSensorState.EquippedItemClass, "HDFirearm", "fire sensor state records the foreground item class") +assertEqual(fireSensorState.EquippedBGItemClass, "HeldDevice", "fire sensor state records the background item class") +assertEqual(fireSensorState.InventorySize, 3, "fire sensor state records the inventory size") +assertEqual(fireSensorState.InventoryFirearmCount, 1, "fire sensor state records inventory firearms") + +controller:RecordShadowBatchMetrics({ + visibleOpponents = 3, + visibleOpponentChecks = 8, + losProbeRays = 11, + actorSkips = 1, + contactAcquisitions = 2, + contactLosses = 1, + elapsedMS = 4.5 +}) +local metricsSnapshot = controller:Snapshot() +assertEqual(metricsSnapshot.VisibleOpponents, 3, "visible opponents are aggregated") +assertEqual(metricsSnapshot.VisibleOpponentChecks, 8, "visibility checks are aggregated") +assertEqual(metricsSnapshot.LOSProbeRays, 11, "native-aligned LOS probe rays are aggregated") +assertEqual(metricsSnapshot.ActorSkips, 1, "actor skips are aggregated") +assertEqual(metricsSnapshot.ContactAcquisitions, 3, "contact acquisitions are aggregated") +assertEqual(metricsSnapshot.ContactLosses, 2, "contact losses are aggregated") +assertEqual(metricsSnapshot.ShadowObservationTimeMS, 4.5, "shadow timing is aggregated") + +local closeWeapon = Controller.ClassifyWeapon({ effectiveRange = 100, projectileCount = 8, spread = 0.4 }) +assertEqual(closeWeapon.Class, "CLOSE", "scatter weapon is classified for close engagement") +local longWeapon = Controller.ClassifyWeapon({ effectiveRange = 500, projectileCount = 1, spread = 0.05 }) +assertEqual(longWeapon.Class, "LONG", "accurate weapon is classified for distance") +assertEqual(Controller.ClassifyWeapon({}).Confidence, 0, "invalid weapon profile has no confidence") + +local closeCovered = Controller.ScoreDestination({ + weaponClass = "CLOSE", distance = 80, hasLOS = true, cover = 1.0, threat = 0.2 +}) +local closeOpen = Controller.ScoreDestination({ + weaponClass = "CLOSE", distance = 80, hasLOS = true, cover = 0.0, threat = 0.2 +}) +assertTrue(closeCovered > closeOpen, "close-range weapons prefer covered close positions") + +local longLOS = Controller.ScoreDestination({ + weaponClass = "LONG", distance = 500, hasLOS = true, cover = 0.5, threat = 0.2 +}) +local longBlocked = Controller.ScoreDestination({ + weaponClass = "LONG", distance = 500, hasLOS = false, cover = 0.5, threat = 0.2 +}) +assertTrue(longLOS > longBlocked, "long-range weapons prefer line of sight") + +local selected = Controller.SelectDistinctDestination({ + { id = "marginal", score = 11 }, + { id = "strong", score = 15 } +}, { id = "current", score = 12 }, 2) +assertEqual(selected.id, "strong", "destination selection accepts meaningful improvement") +assertEqual(Controller.SelectDistinctDestination({ { id = "marginal", score = 13 } }, { id = "current", score = 12 }, 2).id, + "current", "marginal destination improvement is rejected") + +controller:BeginRound(8, 5678) +assertEqual(controller.ActorState[101], nil, "new round clears actor IDs") +assertEqual(controller.ContactMemory[1], nil, "new round clears contact memory") +assertEqual(controller.RoundGeneration, 8, "new round increments generation") + +print("spectator_ai_controller_test: PASS") diff --git a/tests/spectator_ai_integration_test.py b/tests/spectator_ai_integration_test.py new file mode 100644 index 0000000000..46d1688e51 --- /dev/null +++ b/tests/spectator_ai_integration_test.py @@ -0,0 +1,253 @@ +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +ACTIVITY = ROOT / "Data" / "Base.rte" / "Activities" / "SpectatorArena.lua" +CAMERA_LOGIC = ROOT / "Data" / "Base.rte" / "Activities" / "SpectatorCameraEventLogic.lua" + + +class SpectatorAIIntegrationTests(unittest.TestCase): + def test_controller_is_wired_to_release_boundary_in_off_mode(self): + source = ACTIVITY.read_text(encoding="utf-8") + + self.assertIn('self.AI_V2_MODE = "OFF"', source) + self.assertIn('self.Telemetry.ConfigureRuntime("SPECTATOR_EVENT_LOG.txt")', source) + self.assertIn('self.Telemetry.Emit("AI_V2_CONFIG"', source) + self.assertIn('self.AIController:RegisterActor(', source) + self.assertRegex(source, r'AIController:ReleaseActor\(') + self.assertIn('self.AIController:RecordPosition(', source) + self.assertIn('self:UpdateAIInstrumentation(', source) + self.assertLess( + source.index("self.RoundNumber = self.RoundNumber + 1"), + source.index("self.AIController:BeginRound(") + ) + + def test_instrumentation_has_no_actor_control_calls(self): + source = ACTIVITY.read_text(encoding="utf-8") + start = source.index("function SpectatorArena:UpdateAIInstrumentation") + end = source.index("\nend", start) + body = source[start:end] + + self.assertNotIn("AIMode =", body) + self.assertNotIn("ClearAIWaypoints", body) + self.assertNotIn("AddAISceneWaypoint", body) + self.assertNotIn("AddAIMOWaypoint", body) + + def test_shadow_observations_are_post_release_and_read_only(self): + source = ACTIVITY.read_text(encoding="utf-8") + + self.assertIn("function SpectatorArena:UpdateAIShadowObservations", source) + shadow_start = source.index("function SpectatorArena:UpdateAIShadowObservations") + shadow_end = source.index("\nfunction ", shadow_start + 10) + shadow_body = source[shadow_start:shadow_end] + + self.assertIn('self.AI_V2_MODE ~= "SHADOW"', shadow_body) + self.assertIn("self.AIController:RecordContact(", shadow_body) + self.assertIn("self.AIController:RecordShadowObservation(", shadow_body) + self.assertIn("self.AIController.SelectVisibleOpponent(", shadow_body) + self.assertIn("visibleOpponentCount", shadow_body) + self.assertIn("visibleOpponentChecks", shadow_body) + self.assertIn("BuildSightProbeTargets", shadow_body) + self.assertIn("rte.grassID", shadow_body) + self.assertIn("losProbeRays", shadow_body) + self.assertIn("self.AIController:RecordShadowBatchMetrics(", shadow_body) + self.assertIn("SceneMan:CastMORay(", shadow_body) + self.assertIn("ClassifyRayHit(", shadow_body) + self.assertIn("rayClassification", shadow_body) + self.assertIn("rayReturn", shadow_body) + self.assertIn("hitMOID", shadow_body) + self.assertIn("CalculateCPUTimeMS", shadow_body) + self.assertIn("self.AIController:FiredRecently(", shadow_body) + self.assertIn("self.AIController:RecordEngagement(", shadow_body) + self.assertIn('self.Telemetry.Emit("AI_SHADOW_OBSERVATION"', shadow_body) + self.assertNotIn("AIMode =", shadow_body) + self.assertNotIn("ClearAIWaypoints", shadow_body) + self.assertNotIn("AddAISceneWaypoint", shadow_body) + self.assertNotIn("AddAIMOWaypoint", shadow_body) + self.assertIn('self.Telemetry.Emit("AI_SHADOW_ROUND_SUMMARY"', source) + self.assertIn("visibleOpponents = aiSnapshot.VisibleOpponents", source) + self.assertIn("visibleOpponentChecks = aiSnapshot.VisibleOpponentChecks", source) + self.assertIn("losProbeRays = aiSnapshot.LOSProbeRays", source) + self.assertIn("actorSkips = aiSnapshot.ActorSkips", source) + self.assertIn("shadowObservationTimeMS = aiSnapshot.ShadowObservationTimeMS", source) + + instrumentation_start = source.index("function SpectatorArena:UpdateAIInstrumentation") + instrumentation_end = source.index("\nfunction ", instrumentation_start + 10) + instrumentation_body = source[instrumentation_start:instrumentation_end] + self.assertIn("self:UpdateAIShadowObservations(", instrumentation_body) + self.assertLess( + instrumentation_body.index("self.AISpawnSettled"), + instrumentation_body.index("self:UpdateAIShadowObservations(") + ) + self.assertIn("UpdateAIFireDamageLatches", source) + self.assertIn("RecordCombatSignals", source) + + def test_spawn_loadout_diagnostic_does_not_flush_console(self): + source = ACTIVITY.read_text(encoding="utf-8") + start = source.index("function SpectatorArena:CreateFactionSoldier") + end = source.index("\nfunction ", start + 10) + spawn_body = source[start:end] + self.assertNotIn("self.Telemetry.Snapshot()", spawn_body) + + def test_arena_loadout_reconciliation_samples_post_insertion_and_first_update(self): + source = ACTIVITY.read_text(encoding="utf-8") + + self.assertIn('self:RecordLoadoutDiagnostic("WEAPON_POST_INSERTION"', source) + self.assertIn('RecordLoadoutDiagnostic("WEAPON_FIRST_UPDATE"', source) + self.assertIn("self.A1FirstUpdateLoadoutObserved", source) + self.assertIn("actor.EquippedItem", source) + self.assertIn("actor.EquippedBGItem", source) + + def test_arena_retained_weapon_reference_is_opt_in_and_read_only(self): + source = ACTIVITY.read_text(encoding="utf-8") + + self.assertIn("self.A1RetainWeaponReference", source) + self.assertIn("self.A1DiagnosticWeaponRefs[actor.UniqueID] = weapon", source) + self.assertIn("local retainedWeapon = arena.A1DiagnosticWeaponRefs[actor.UniqueID]", source) + self.assertNotIn("retainedWeapon:Set", source) + self.assertNotIn("retainedWeapon.ToDelete =", source) + + def test_arena_reconciliation_inspects_attachment_and_world_presence_read_only(self): + source = ACTIVITY.read_text(encoding="utf-8") + + self.assertIn("actor.FGArm", source) + self.assertIn("foregroundArmAttached", source) + self.assertIn("retainedAttached", source) + self.assertIn("worldItemCount", source) + self.assertIn("retainedWorldItem", source) + + def test_spawn_trace_is_bounded_and_persisted_only_at_terminal_boundaries(self): + source = ACTIVITY.read_text(encoding="utf-8") + self.assertIn("function SpectatorArena:RecordSpawnTrace", source) + self.assertIn("self.ArenaSpawnTraceLimit", source) + self.assertIn("function SpectatorArena:PersistSpawnTrace", source) + self.assertIn('"ROUND_SPAWN_COMPLETE"', source) + self.assertIn('"STARTUP_DIAGNOSTIC_TIMEOUT"', source) + trace_start = source.index("function SpectatorArena:RecordSpawnTrace") + trace_end = source.index("\nfunction ", trace_start + 10) + trace_body = source[trace_start:trace_end] + self.assertNotIn("Snapshot()", trace_body) + + def test_post_spawn_trace_is_bounded_sparse_and_one_shot(self): + source = ACTIVITY.read_text(encoding="utf-8") + + self.assertIn("function SpectatorArena:RecordPostSpawnTrace", source) + self.assertIn("self.A1PostSpawnTraceLimit", source) + self.assertIn("function SpectatorArena:PersistPostSpawnTrace", source) + self.assertIn("self.A1PostSpawnTracePersisted", source) + self.assertIn("SPECTATOR_ARENA_POST_SPAWN_TRACE_LOG.txt", source) + self.assertIn("self.A1PostSpawnWallTimer.ElapsedRealTimeMS", source) + self.assertIn('self:PersistPostSpawnTrace("DIAGNOSTIC_TIMEOUT"', source) + self.assertIn('self:PersistPostSpawnTrace("ROUND_RESULT"', source) + + for milestone in (1, 2, 10, 60, 300): + self.assertIn(f"[{milestone}] = true", source) + + for stage in ( + "UPDATE_ACTIVITY_ENTER", + "BEFORE_CAMERA_UPDATE", + "AFTER_CAMERA_UPDATE", + "BEFORE_TOUCHDOWN_UPDATE", + "AFTER_TOUCHDOWN_UPDATE", + "BEFORE_FIRE_LATCH_UPDATE", + "AFTER_FIRE_LATCH_UPDATE", + "BEFORE_AI_INSTRUMENTATION", + "AFTER_AI_INSTRUMENTATION", + "ALL_ACTORS_RELEASED", + "BATTLE_STARTED", + "ROUND_RESULT", + ): + self.assertIn(f'"{stage}"', source) + + trace_start = source.index("function SpectatorArena:RecordPostSpawnTrace") + trace_end = source.index("\nfunction ", trace_start + 10) + trace_body = source[trace_start:trace_end] + self.assertNotIn("Snapshot(", trace_body) + self.assertNotIn("AIMode =", trace_body) + self.assertNotIn("ClearAIWaypoints", trace_body) + self.assertNotIn("AddAISceneWaypoint", trace_body) + self.assertNotIn("AddAIMOWaypoint", trace_body) + + instrumentation_start = source.index("function SpectatorArena:UpdateAIInstrumentation") + instrumentation_end = source.index("\nfunction ", instrumentation_start + 10) + instrumentation_body = source[instrumentation_start:instrumentation_end] + self.assertIn("self:RecordA1ProgressMarkers()", instrumentation_body) + + def test_camera_engagement_leads_from_followed_shooter_to_enemy(self): + source = ACTIVITY.read_text(encoding="utf-8") + + self.assertIn("function SpectatorArena:FindEngagementTarget", source) + self.assertIn("function SpectatorArena:EnterEngagementMode", source) + + def test_spectator_hud_uses_screen_primitives_and_preserves_result_banner(self): + source = ACTIVITY.read_text(encoding="utf-8") + camera_logic = CAMERA_LOGIC.read_text(encoding="utf-8") + + self.assertIn('self.HUDLogic = require("Activities/SpectatorHUDLogic")', source) + self.assertIn("function SpectatorArena:DrawSpectatorHUD", source) + self.assertIn("PrimitiveMan:DrawTextPrimitive", source) + self.assertIn("local cameraOffset = CameraMan:GetOffset(screen)", source) + self.assertIn("cameraOffset + Vector(12, 28)", source) + self.assertIn("FrameMan:ClearScreenText(spectatorScreen)", source) + self.assertIn("self.HUDLogic.BuildBattleHUD", source) + self.assertIn("self.HUDLogic.BuildResultHUD", source) + self.assertIn("self.HUDLogic.BuildResultText", source) + self.assertIn("self.CameraLastShot.shooterTeam == self.Team1", source) + self.assertIn("self.CameraRoundsFiredByActor", source) + self.assertIn("self.CameraControllerFireByActor", source) + self.assertIn("roundsAdvanced", source) + self.assertIn("Controller.WEAPON_FIRE", source) + self.assertIn("CAMERA_FIRE_CONTROLLER", source) + self.assertIn("foregroundArm.HeldDevice", source) + self.assertIn('self.CameraMode = "CAMERA_ENGAGEMENT"', source) + self.assertIn("self.CameraEventLogic.SelectEngagementTarget(", source) + self.assertIn("self.CameraEventLogic.CalculateEngagementFrame(", source) + self.assertIn("self.CameraEngagementPosition", source) + self.assertIn("self.CameraEngagementHoldMS", source) + self.assertIn("self.CameraEngagementCooldownReady", source) + self.assertIn("self.CameraEngagementCooldownTimer:IsPastSimMS(self.CameraEngagementCooldownMS)", source) + self.assertIn("CAMERA_EVENT_DYING_OBSERVED", source) + self.assertIn("CAMERA_EVENT_REMOVAL_UNCONFIRMED", source) + self.assertIn("CAMERA_EVENT_ATTRIBUTION_ACCEPTED", source) + self.assertIn("CAMERA_EVENT_ATTRIBUTION_NOT_EVALUATED", source) + self.assertRegex(source, r'reason = accepted\s+and "ACCEPTED"') + self.assertIn("NO_CORRELATABLE_SHOT", source) + self.assertIn("COOLDOWN", source) + self.assertIn("DYING_OBSERVED", source) + self.assertIn("traceID", source) + self.assertIn("STALE_SHOT", camera_logic) + self.assertIn("SHOOTER_MISMATCH", camera_logic) + self.assertIn("NO_CANDIDATE", camera_logic) + self.assertIn("AIM_CONE", camera_logic) + self.assertIn("DISTANCE", camera_logic) + self.assertIn("MULTIPLE_VICTIMS", camera_logic) + self.assertIn("CAMERA_EVENT_REQUEST", source) + self.assertIn("CAMERA_EVENT_TARGET_ISSUED", source) + self.assertIn("function SpectatorArena:ObserveCameraEventExecution", source) + self.assertIn("CAMERA_EVENT_CAMERA_SAMPLE", source) + self.assertIn("CAMERA_EVENT_MOVEMENT_ONSET", source) + self.assertIn("CAMERA_EVENT_ARRIVED", source) + self.assertIn("CameraEventObservationArrivalTolerance", source) + self.assertIn("CameraMan:GetOffset(screen)", source) + self.assertIn("CameraMan:GetScrollTarget(screen)", source) + self.assertIn("distanceToTarget", source) + self.assertIn("CAMERA_EVENT_HOLD_COMPLETE", source) + self.assertIn("CAMERA_EVENT_RETURN", source) + update_body = source[source.index("function SpectatorArena:UpdateCameraDirector"):] + self.assertLess( + update_body.index('self:EnterEventMode(cameraEvent)'), + update_body.index('self:EnterEngagementMode(engagementEnemy)') + ) + self.assertLess( + update_body.index('self:EnterEngagementMode(engagementEnemy)'), + update_body.index( + 'self:SetObservationTarget(self.CameraFollowActor.Pos, Activity.PLAYER_1)', + update_body.index('self:EnterEngagementMode(engagementEnemy)') + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/spectator_camera_event_test.lua b/tests/spectator_camera_event_test.lua new file mode 100644 index 0000000000..38fbd01d3d --- /dev/null +++ b/tests/spectator_camera_event_test.lua @@ -0,0 +1,190 @@ +package.path = "Data/Base.rte/?.lua;" .. package.path + +local CameraEventLogic = require("Activities/SpectatorCameraEventLogic") + +local function assertEqual(actual, expected, message) + if actual ~= expected then + error((message or "values differ") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual)) + end +end + +local function candidate(id, x, y, team, deathObserved) + return { id = id, x = x, y = y, team = team, deathObserved = deathObserved ~= false } +end + +local recentShot = { + ageMS = 250, + shooterTeam = 1, + originX = 100, + originY = 100, + directionX = 1, + directionY = 0 +} + +local selected = CameraEventLogic.SelectEventCandidate( + recentShot, + { candidate(21, 300, 110, 2) }, + {}, + 500, + 0.85, + 180, + 1200 +) +assertEqual(selected and selected.id, 21, "one recent forward enemy death should be attributed") + +selected = CameraEventLogic.SelectEventCandidate( + recentShot, + { candidate(21, 300, 110, 2), candidate(22, 340, 90, 2) }, + {}, + 500, + 0.85, + 180, + 1200 +) +assertEqual(selected, nil, "ambiguous deaths should not be attributed") + +selected = CameraEventLogic.SelectEventCandidate( + recentShot, + { candidate(21, 20, 100, 2) }, + {}, + 500, + 0.85, + 180, + 1200 +) +assertEqual(selected, nil, "death behind the shooter should not be attributed") + +local rejected, rejectionReason = CameraEventLogic.SelectEventCandidate( + { ageMS = 700, shooterTeam = 1, originX = 100, originY = 100, directionX = 1, directionY = 0 }, + { candidate(21, 300, 100, 2) }, + {}, + 500, + 0.85, + 180, + 1200 +) +assertEqual(selected, nil, "stale shots should not be attributed") +assertEqual(rejectionReason, "STALE_SHOT", "stale shots should report their rejection reason") + +selected = CameraEventLogic.SelectEventCandidate( + recentShot, + { candidate(21, 300, 100, 2) }, + { [21] = true }, + 500, + 0.85, + 180, + 1200 +) +assertEqual(selected, nil, "handled victim should not trigger twice") + +selected = CameraEventLogic.SelectEventCandidate( + recentShot, + { candidate(23, 180, 100, 2) }, + {}, + 500, + 0.85, + 180, + 1200 +) +assertEqual(selected, nil, "nearby deaths already visible with the shooter should not trigger a cut") + +selected = CameraEventLogic.SelectEventCandidate( + recentShot, + { candidate(24, 300, 100, 2, false) }, + {}, + 500, + 0.85, + 180, + 1200 +) +assertEqual(selected, nil, "an unexplained disappearance without an observed death should not be attributed") + +selected = CameraEventLogic.SelectEventCandidate( + recentShot, + { candidate(25, 300, 300, 2) }, + {}, + 500, + 0.85, + 180, + 1200 +) +assertEqual(selected, nil, "a death outside the narrow aim cone should not be attributed") +rejected, rejectionReason = CameraEventLogic.SelectEventCandidate( + recentShot, + { candidate(25, 300, 300, 2) }, + {}, + 500, + 0.85, + 180, + 1200 +) +assertEqual(rejected, nil, "aim-cone rejection should not select a victim") +assertEqual(rejectionReason, "AIM_CONE", "aim-cone rejection should report its reason") + +assertEqual(CameraEventLogic.HasLastSurvivorPriority(1, 4), true, "one team-1 survivor should suppress event cuts") +assertEqual(CameraEventLogic.HasLastSurvivorPriority(3, 1), true, "one team-2 survivor should suppress event cuts") +assertEqual(CameraEventLogic.HasLastSurvivorPriority(3, 4), false, "ordinary battles should allow event evaluation") +assertEqual(CameraEventLogic.SelectLastSurvivor({ 11 }, { 21, 22 }), 11, "team-1 last survivor should become the anchor") +assertEqual(CameraEventLogic.SelectLastSurvivor({ 11, 12 }, { 21 }), 21, "team-2 last survivor should become the anchor") +assertEqual(CameraEventLogic.SelectLastSurvivor({ 11 }, { 21 }), 11, "one-versus-one should deterministically prefer team 1") +assertEqual(CameraEventLogic.HasObservedDeath(false, true, true), true, "live-to-dead transition is strong death evidence") +assertEqual(CameraEventLogic.HasObservedDeath(true, false, false), true, "removal after observed death preserves death evidence") +assertEqual(CameraEventLogic.HasObservedDeath(false, false, false), false, "unexplained removal is not death evidence") +assertEqual(CameraEventLogic.HasObservedDeath(false, true, false), false, "a living actor is not a death event") +assertEqual( + CameraEventLogic.HasObservedDying(0, 3, 3), + true, + "stable-to-dying transition is lifecycle evidence" +) +assertEqual( + CameraEventLogic.HasObservedDying(3, 3, 3), + false, + "a sustained dying state is not a second edge" +) +assertEqual( + CameraEventLogic.HasObservedDying(4, 4, 3), + false, + "dead state is not a new dying edge" +) + +local engagementShot = { + shooterTeam = 1, + originX = 100, + originY = 100, + directionX = 1, + directionY = 0 +} + +local engagementTarget = CameraEventLogic.SelectEngagementTarget( + engagementShot, + { + { id = 21, team = 2, x = 900, y = 120 }, + { id = 22, team = 2, x = -300, y = 100 }, + { id = 23, team = 1, x = 700, y = 100 } + }, + 0.8, + 300, + 1200 +) +assertEqual(engagementTarget.id, 21, "engagement framing selects the enemy in the firing direction") + +engagementTarget = CameraEventLogic.SelectEngagementTarget( + engagementShot, + { + { id = 24, team = 2, x = 180, y = 250 } + }, + 0.8, + 300, + 1200 +) +assertEqual(engagementTarget, nil, "engagement framing ignores near targets") + +local frame = CameraEventLogic.CalculateEngagementFrame( + { x = 100, y = 100 }, + { x = 900, y = 300 }, + 0.55 +) +assertEqual(frame.x, 540, "engagement frame biases the camera toward the enemy") +assertEqual(frame.y, 210, "engagement frame preserves the line between actors") + +print("spectator_camera_event_test: PASS") diff --git a/tests/spectator_hud_test.lua b/tests/spectator_hud_test.lua new file mode 100644 index 0000000000..4fc2cf0958 --- /dev/null +++ b/tests/spectator_hud_test.lua @@ -0,0 +1,51 @@ +package.path = "Data/Base.rte/?.lua;" .. package.path + +local HUDLogic = require("Activities/SpectatorHUDLogic") + +local function assertEqual(actual, expected, message) + if actual ~= expected then + error((message or "values differ") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual)) + end +end + +local battle = HUDLogic.BuildBattleHUD( + 3, + "01:07", + 12, + 20, + "Browncoats.rte", + 5, + 2, + "Ronin.rte", + 7, + 1 +) + +assertEqual(battle.header, "ROUND 3 | 01:07", "battle header") +assertEqual(battle.team1, "BROWNCOATS 5 | SCORE 2", "team one panel") +assertEqual(battle.team2, "RONIN 7 | SCORE 1", "team two panel") +assertEqual(battle.pressure, "COMBAT PRESSURE 12/20", "pressure panel") + +local resultHUD = HUDLogic.BuildResultHUD( + 3, + "Browncoats.rte", + 0, + 2, + "Ronin.rte", + 0, + 1 +) + +assertEqual(resultHUD.header, "ROUND 3 COMPLETE", "result header") +assertEqual(resultHUD.team1, "BROWNCOATS 0 | SCORE 2", "result team one panel") +assertEqual(resultHUD.team2, "RONIN 0 | SCORE 1", "result team two panel") + +local result = HUDLogic.BuildResultText("Browncoats Wins", 2, 1) + +assertEqual( + result, + "BROWNCOATS WINS | SCORE 2 - 1 | NEXT ROUND...", + "result banner" +) + +print("spectator_hud_test: PASS") diff --git a/tests/spectator_telemetry_test.lua b/tests/spectator_telemetry_test.lua new file mode 100644 index 0000000000..7d8de37081 --- /dev/null +++ b/tests/spectator_telemetry_test.lua @@ -0,0 +1,28 @@ +package.path = "Data/Base.rte/?.lua;" .. package.path +local Telemetry = require("Activities/SpectatorTelemetry") + +local function assertEqual(actual, expected, message) + if actual ~= expected then error(message .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual)) end +end + +assertEqual( + Telemetry.Encode("ROUND_RESULT", { round = 3, winner = "TEAM 1", team1Score = 2, team2Score = 1 }), + "SPECTATOR_EVENT event=ROUND_RESULT round=3 winner=TEAM_1 team1Score=2 team2Score=1", + "stable event encoding" +) + +local captured +local line = Telemetry.Emit("WATCHDOG", { round = 4, reason = "no progress" }, function(value) captured = value end) +assertEqual(captured, line, "sink receives encoded event") +assertEqual(captured, "SPECTATOR_EVENT event=WATCHDOG round=4 reason=no_progress", "safe field encoding") + +local savedPath +ConsoleMan = { + SaveAllText = function(_, path) savedPath = path end +} +Telemetry.ConfigureRuntime("SPECTATOR_EVENT_LOG.txt") +Telemetry.Emit("ACTIVITY_START", {}) +assertEqual(savedPath, nil, "event emission does not force a disk snapshot") +Telemetry.Snapshot() +assertEqual(savedPath, "SPECTATOR_EVENT_LOG.txt", "runtime telemetry snapshot path") +print("spectator_telemetry_test: PASS") diff --git a/tests/test_spectator_soak_report.py b/tests/test_spectator_soak_report.py new file mode 100644 index 0000000000..6ff6fc6b25 --- /dev/null +++ b/tests/test_spectator_soak_report.py @@ -0,0 +1,34 @@ +import importlib.util +import unittest +from pathlib import Path + +path = Path(__file__).parents[1] / "tools" / "spectator_soak_report.py" +spec = importlib.util.spec_from_file_location("report", path) +report = importlib.util.module_from_spec(spec) +spec.loader.exec_module(report) + +class SoakReportTests(unittest.TestCase): + def test_ignores_noise_and_reports_completed_rounds(self): + lines = ["warning: audio", "SPECTATOR_EVENT event=ROUND_START round=1", "SPECTATOR_EVENT event=ROUND_RESULT round=1 winner=TEAM_1 durationMS=1200 team1Score=1 team2Score=0"] + result = report.parse(lines) + self.assertEqual(result["rounds_completed"], 1) + self.assertEqual(result["winners"], {"TEAM_1": 1}) + self.assertEqual(result["duration_ms"]["average"], 1200) + self.assertFalse(result["incomplete_final_round"]) + + def test_detects_incomplete_round_and_watchdog(self): + result = report.parse(["SPECTATOR_EVENT event=ROUND_START round=2", "SPECTATOR_EVENT event=WATCHDOG round=2 reason=timeout"]) + self.assertTrue(result["incomplete_final_round"]) + self.assertEqual(result["watchdog_events"], 1) + + def test_accepts_engine_print_prefix_in_console_snapshot(self): + result = report.parse([ + "PRINT: SPECTATOR_EVENT event=ROUND_START round=3", + "PRINT: SPECTATOR_EVENT event=ROUND_RESULT round=3 winner=TEAM_2 durationMS=900.5" + ]) + self.assertEqual(result["rounds_started"], 1) + self.assertEqual(result["rounds_completed"], 1) + self.assertEqual(result["winners"], {"TEAM_2": 1}) + self.assertEqual(result["duration_ms"]["average"], 900.5) + +if __name__ == "__main__": unittest.main() diff --git a/tools/spectator_soak_report.py b/tools/spectator_soak_report.py new file mode 100644 index 0000000000..e17b003f07 --- /dev/null +++ b/tools/spectator_soak_report.py @@ -0,0 +1,53 @@ +"""Parse SPECTATOR_EVENT lines emitted by SpectatorArena.""" +import re +import sys +from collections import Counter + +EVENT = re.compile(r"^(?:PRINT:\s+)?SPECTATOR_EVENT\s+event=(\S+)(?:\s+(.*))?$") + +def parse(lines): + events, winners, durations, watchdogs = 0, Counter(), [], 0 + starts, completed = 0, 0 + for line in lines: + match = EVENT.match(line.strip()) + if not match: + continue + fields = {p.split("=", 1)[0]: p.split("=", 1)[1] for p in (match.group(2) or "").split() if "=" in p} + event = match.group(1) + events += 1 + if event == "ROUND_START": starts += 1 + elif event == "ROUND_RESULT": + completed += 1 + winner = fields.get("winner", "UNKNOWN") + winners[winner] += 1 + try: + durations.append(float(fields["durationMS"])) + except (KeyError, TypeError, ValueError): + pass + elif event == "WATCHDOG": watchdogs += 1 + report = { + "events": events, "rounds_started": starts, "rounds_completed": completed, + "incomplete_final_round": starts > completed, "watchdog_events": watchdogs, + "winners": dict(sorted(winners.items())), + "duration_ms": {"average": sum(durations) / len(durations) if durations else None, + "shortest": min(durations) if durations else None, + "longest": max(durations) if durations else None}, + } + return report + +def format_report(report): + d = report["duration_ms"] + return "\n".join([ + f"rounds: {report['rounds_completed']}/{report['rounds_started']} completed", + f"incomplete_final_round: {str(report['incomplete_final_round']).lower()}", + f"winners: {report['winners']}", + f"duration_ms: average={d['average']} shortest={d['shortest']} longest={d['longest']}", + f"watchdog_events: {report['watchdog_events']}", + ]) + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "-" + stream = sys.stdin if path == "-" else open(path, encoding="utf-8", errors="replace") + try: print(format_report(parse(stream))) + finally: + if path != "-": stream.close()