feat: Add bot player system for PlanetSide population

Initial implementation of server-side bots that:
- Spawn as real Player entities with full equipment
- Move and broadcast position updates (10 tick/sec)
- Take damage and die with backpack drops
- Respawn after death
- Combat system with accuracy model (adjustment vs recoil)

Includes project documentation in bot-docs/ and Claude agent helpers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2revoemag 2025-11-23 00:22:30 -05:00
parent 9646b3f99e
commit 2e5b5e0dbd
17 changed files with 3813 additions and 0 deletions

View file

@ -0,0 +1,27 @@
// Copyright (c) 2024 PSForever
package net.psforever.actors.bot
import akka.actor.typed.{Behavior, PostStop}
import akka.actor.typed.scaladsl.Behaviors
import net.psforever.actors.session.AvatarActor
/**
* A stub typed actor that handles messages from PlayerControl for bots.
* Bots don't need real avatar persistence, so this actor just absorbs messages.
*/
object BotAvatarActor {
def apply(): Behavior[AvatarActor.Command] = Behaviors.setup { _ =>
active()
}
private def active(): Behavior[AvatarActor.Command] = {
Behaviors.receiveMessage[AvatarActor.Command] { _ =>
// Absorb all messages - bots don't need real avatar management
Behaviors.same
}.receiveSignal {
case (_, PostStop) =>
Behaviors.same
}
}
}

View file

@ -0,0 +1,689 @@
// Copyright (c) 2024 PSForever
package net.psforever.actors.bot
import akka.actor.typed.ActorRef
import akka.actor.typed.scaladsl.adapter._
import akka.actor.{Actor, ActorContext, Cancellable, Props, ActorRef => ClassicActorRef}
import net.psforever.actors.session.AvatarActor
import net.psforever.objects.avatar.Avatar
import net.psforever.objects.guid.{GUIDTask, TaskWorkflow}
import net.psforever.objects.zones.Zone
import net.psforever.objects.{Player, Tool}
import net.psforever.objects.sourcing.{PlayerSource, SourceEntry}
import net.psforever.objects.vital.Vitality
import net.psforever.objects.vital.base.DamageResolution
import net.psforever.objects.vital.interaction.DamageInteraction
import net.psforever.objects.vital.projectile.ProjectileReason
import net.psforever.objects.ballistics.Projectile
import net.psforever.packet.game.objectcreate.BasicCharacterData
import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
import net.psforever.types.{CharacterSex, CharacterVoice, PlanetSideEmpire, PlanetSideGUID, Vector3}
import net.psforever.util.DefinitionUtil
import net.psforever.zones.Zones
import scala.collection.mutable
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
import scala.util.{Random, Success, Failure}
/**
* Manages bot spawning and lifecycle for a zone.
* Uses DB bot IDs (2-301) to ensure unique bots that can be damaged/killed properly.
*/
class BotManager(zone: Zone) extends Actor {
import BotManager._
private val log = org.log4s.getLogger
private val bots: mutable.Map[Int, BotState] = mutable.Map()
private val random = new Random()
// Pool of available bot IDs (DB IDs 2-301 = xxBOTxxTestBot1 through xxBOTxxTestBot300)
private val availableBotIds: mutable.Queue[Int] = mutable.Queue.from(2 to 301)
private val usedBotIds: mutable.Set[Int] = mutable.Set()
// Track dead bots waiting to "tap out" (botId -> tap out info)
private val waitingToTapOut: mutable.Map[Int, TapOutInfo] = mutable.Map()
// Track pending respawns (botId -> respawn info)
private val pendingRespawns: mutable.Map[Int, RespawnInfo] = mutable.Map()
// Movement tick scheduler - 10 ticks per second
private var tickScheduler: Option[Cancellable] = None
private var tickCount: Int = 0
/**
* Generate weighted random respawn delay.
* Most bots respawn quickly (1-5 sec), some take longer, rare cases up to 90 sec.
* Distribution: ~80% 1-5sec, ~15% 6-30sec, ~4% 31-60sec, ~1% 61-90sec
*/
private def randomRespawnDelayTicks(): Int = {
val roll = random.nextInt(100)
val seconds = if (roll < 80) {
1 + random.nextInt(5) // 1-5 seconds (80% chance)
} else if (roll < 95) {
6 + random.nextInt(25) // 6-30 seconds (15% chance)
} else if (roll < 99) {
31 + random.nextInt(30) // 31-60 seconds (4% chance)
} else {
61 + random.nextInt(30) // 61-90 seconds (1% chance)
}
seconds * 10 // Convert to ticks (10 ticks per second)
}
override def preStart(): Unit = {
tickScheduler = Some(
context.system.scheduler.scheduleWithFixedDelay(
100.milliseconds,
100.milliseconds,
self,
Tick
)
)
}
override def postStop(): Unit = {
tickScheduler.foreach(_.cancel())
}
def receive: Receive = {
case SpawnBot(faction, position) =>
spawnBot(faction, position)
case CompleteSpawn(botId, name, avatar, player, botAvatarActor) =>
completeSpawn(botId, name, avatar, player, botAvatarActor)
case DespawnBot(botId) =>
despawnBot(botId)
case DespawnAllBots =>
bots.keys.toSeq.foreach(despawnBot)
case Tick =>
tickCount += 1
checkForDeadBots()
processTapOuts()
processRespawns()
updateBots()
case _ => ()
}
private def spawnBot(faction: PlanetSideEmpire.Value, position: Vector3): Unit = {
if (availableBotIds.isEmpty) {
log.warn("No available bot IDs - all 300 bots are in use!")
return
}
val botId = availableBotIds.dequeue()
usedBotIds.add(botId)
val botNumber = botId - 1 // ID 2 = TestBot1, ID 3 = TestBot2, etc.
val name = s"xxBOTxxTestBot$botNumber"
log.info(s"Spawning bot '$name' (dbId=$botId) at $position in zone ${zone.id}")
val avatar = Avatar(
botId,
BasicCharacterData(
name,
faction,
CharacterSex.Male,
0,
CharacterVoice.Voice5
)
)
val player = new Player(avatar)
player.Position = position
player.Orientation = Vector3(0, 0, 0)
DefinitionUtil.applyDefaultLoadout(player)
player.Spawn()
val typedSystem = context.system.toTyped
val botAvatarActor: ActorRef[AvatarActor.Command] =
typedSystem.systemActorOf(BotAvatarActor(), s"bot-avatar-$botId-${System.currentTimeMillis}")
val selfRef = self
TaskWorkflow.execute(GUIDTask.registerAvatar(zone.GUID, player)).onComplete {
case Success(_) =>
log.info(s"GUID registration complete for bot '$name', GUID=${player.GUID}")
selfRef ! CompleteSpawn(botId, name, avatar, player, botAvatarActor)
case Failure(ex) =>
log.error(s"GUID registration failed for bot '$name': ${ex.getMessage}")
// Return the ID to the pool on failure
availableBotIds.enqueue(botId)
usedBotIds.remove(botId)
}
}
private def completeSpawn(
botId: Int,
name: String,
avatar: Avatar,
player: Player,
botAvatarActor: ActorRef[AvatarActor.Command]
): Unit = {
log.info(s"Completing spawn for bot '$name' with GUID ${player.GUID}")
zone.Population ! Zone.Population.Join(avatar)
zone.Population ! Zone.Population.Spawn(avatar, player, botAvatarActor)
val definition = player.Definition
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.LoadPlayer(
player.GUID,
definition.ObjectId,
player.GUID,
definition.Packet.ConstructorData(player).get,
None
)
)
// Initialize movement state
val moveAngle = random.nextFloat() * 360f
val moveState = MovementState(
targetYaw = moveAngle,
moveSpeed = 3f, // Slightly slower walk speed
moveUntilTick = tickCount + 30 + random.nextInt(50)
)
bots(botId) = BotState(botId, name, avatar, player, botAvatarActor, moveState, CombatState(), player.Position)
log.info(s"Bot '$name' spawned successfully with GUID ${player.GUID} (${bots.size} bots active, ${availableBotIds.size} available)")
}
private def checkForDeadBots(): Unit = {
// Find newly dead bots (dead but not yet in waitingToTapOut)
val newlyDeadBots = bots.values.filter { b =>
!b.player.isAlive && !waitingToTapOut.contains(b.id)
}.toSeq
newlyDeadBots.foreach { botState =>
// Calculate random "time on ground" before tap out
val delayTicks = randomRespawnDelayTicks()
val delaySec = delayTicks / 10f
log.info(s"Bot '${botState.name}' died at ${botState.player.Position}, will tap out in ${delaySec}s")
// Schedule tap out - body stays on ground until then
waitingToTapOut(botState.id) = TapOutInfo(
botState = botState,
tapOutAtTick = tickCount + delayTicks
)
}
}
private def processTapOuts(): Unit = {
val readyToTapOut = waitingToTapOut.values.filter(_.tapOutAtTick <= tickCount).toSeq
readyToTapOut.foreach { info =>
waitingToTapOut.remove(info.botState.id)
handleBotTapOut(info.botState)
}
}
private def handleBotTapOut(botState: BotState): Unit = {
val player = botState.player
val avatar = botState.avatar
val botId = botState.id
log.info(s"Bot '${botState.name}' tapping out, creating backpack and scheduling respawn")
// Convert the dead player to a backpack/corpse (sets isBackpack = true)
player.Release
// Register corpse for tracking (must happen before Release broadcast, and while still in zone population)
zone.Population ! Zone.Corpse.Add(player)
// Broadcast the corpse/backpack to all clients
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.Release(player, zone)
)
// Now leave the zone population (avatar is no longer "playing")
zone.Population ! Zone.Population.Release(avatar)
// Remove from active bots
bots.remove(botId)
// Schedule immediate respawn (tap out already waited)
pendingRespawns(botId) = RespawnInfo(
botId = botId,
name = botState.name,
faction = avatar.faction,
spawnPosition = botState.spawnPosition,
respawnAtTick = tickCount + 10 // Small delay for respawn after tap out (1 second)
)
}
private def processRespawns(): Unit = {
val readyToRespawn = pendingRespawns.values.filter(_.respawnAtTick <= tickCount).toSeq
readyToRespawn.foreach { info =>
pendingRespawns.remove(info.botId)
log.info(s"Respawning bot '${info.name}' at ${info.spawnPosition}")
respawnBot(info)
}
}
private def respawnBot(info: RespawnInfo): Unit = {
val botId = info.botId
val name = info.name
log.info(s"Respawning bot '$name' (dbId=$botId) at ${info.spawnPosition}")
val avatar = Avatar(
botId,
BasicCharacterData(
name,
info.faction,
CharacterSex.Male,
0,
CharacterVoice.Voice5
)
)
val player = new Player(avatar)
player.Position = info.spawnPosition
player.Orientation = Vector3(0, 0, 0)
DefinitionUtil.applyDefaultLoadout(player)
player.Spawn()
val typedSystem = context.system.toTyped
val botAvatarActor: ActorRef[AvatarActor.Command] =
typedSystem.systemActorOf(BotAvatarActor(), s"bot-avatar-$botId-${System.currentTimeMillis}")
val selfRef = self
TaskWorkflow.execute(GUIDTask.registerAvatar(zone.GUID, player)).onComplete {
case Success(_) =>
log.info(s"GUID registration complete for respawning bot '$name', GUID=${player.GUID}")
selfRef ! CompleteSpawn(botId, name, avatar, player, botAvatarActor)
case Failure(ex) =>
log.error(s"GUID registration failed for respawning bot '$name': ${ex.getMessage}")
// Return the ID to the pool on failure
usedBotIds.remove(botId)
availableBotIds.enqueue(botId)
}
}
private def updateBots(): Unit = {
val timestamp = (System.currentTimeMillis() % 65536).toInt
bots.values.foreach { botState =>
val player = botState.player
if (player.isAlive) {
// Update combat first (finds targets, fires)
val newCombatState = updateCombat(botState)
val updatedBotState = botState.copy(combat = newCombatState)
// Update movement (will face target if in combat)
val newMoveState = updateMovement(updatedBotState, player)
bots(botState.id) = updatedBotState.copy(movement = newMoveState)
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.PlayerState(
player.GUID,
player.Position,
player.Velocity,
player.Orientation.z,
0f,
player.Orientation.z,
timestamp,
is_crouching = false,
is_jumping = false,
jump_thrust = false,
is_cloaked = false,
spectator = false,
weaponInHand = true
)
)
}
}
}
private def updateMovement(botState: BotState, player: Player): MovementState = {
var moveState = botState.movement
// If we have a target, face them instead of random wandering
if (botState.combat.target.isDefined) {
botState.combat.target.flatMap(guid => zone.LivePlayers.find(_.GUID == guid)) match {
case Some(targetPlayer) =>
val dx = targetPlayer.Position.x - player.Position.x
val dy = targetPlayer.Position.y - player.Position.y
val targetYaw = math.toDegrees(math.atan2(dx, dy)).toFloat
moveState = moveState.copy(targetYaw = targetYaw)
case None => ()
}
} else if (tickCount >= moveState.moveUntilTick) {
moveState = moveState.copy(
targetYaw = random.nextFloat() * 360f,
moveUntilTick = tickCount + 30 + random.nextInt(50)
)
}
val yawRad = math.toRadians(moveState.targetYaw).toFloat
val speed = moveState.moveSpeed / 10f
val vx = math.sin(yawRad).toFloat * speed
val vy = math.cos(yawRad).toFloat * speed
val newPos = Vector3(
player.Position.x + vx,
player.Position.y + vy,
player.Position.z
)
player.Position = newPos
player.Orientation = Vector3(0, 0, moveState.targetYaw)
player.Velocity = Some(Vector3(vx * 10, vy * 10, 0))
moveState
}
// ============ COMBAT SYSTEM ============
/** Maximum engagement range in game units */
private val MaxEngagementRange = 100f
/** Recognition time in ticks based on distance */
private def recognitionTimeTicks(distance: Float): Int = {
if (distance < 15f) 1 // Near instant for close
else if (distance < 50f) 3 + random.nextInt(5) // 0.3-0.8 sec for medium
else 10 + random.nextInt(10) // 1-2 sec for far
}
/** Find the closest enemy player in range */
private def findTarget(botState: BotState): Option[Player] = {
val player = botState.player
val botFaction = botState.avatar.faction
zone.LivePlayers
.filter { p =>
p.isAlive &&
p.Faction != botFaction &&
!p.Name.startsWith("xxBOTxx") && // Don't target other bots for now
Vector3.Distance(player.Position, p.Position) <= MaxEngagementRange
}
.sortBy(p => Vector3.Distance(player.Position, p.Position))
.headOption
}
/** Calculate hit chance based on distance and the two-force accuracy system */
private def calculateHitChance(distance: Float, shotsFired: Int, ticksAiming: Int): Float = {
// Base accuracy decreases with distance
val baseAccuracy = if (distance < 10f) 0.95f
else if (distance < 20f) 0.80f
else if (distance < 40f) 0.60f
else if (distance < 60f) 0.40f
else 0.25f
// Adjustment bonus: accuracy improves over time as bot "dials in"
// Max bonus after ~1 second (10 ticks) of aiming
val adjustmentBonus = math.min(ticksAiming * 0.03f, 0.30f)
// Recoil penalty: spread worsens with each shot
// Gets bad after ~10 shots
val recoilPenalty = math.min(shotsFired * 0.04f, 0.50f)
// Final hit chance: base + adjustment - recoil, clamped to [0.05, 0.95]
val hitChance = baseAccuracy + adjustmentBonus - recoilPenalty
math.max(0.05f, math.min(0.95f, hitChance))
}
/** Get the bot's equipped weapon */
private def getWeapon(player: Player): Option[Tool] = {
val slot = player.DrawnSlot
if (slot >= 0 && slot < player.Holsters().length) {
player.Holsters()(slot).Equipment match {
case Some(tool: Tool) => Some(tool)
case _ => None
}
} else None
}
/** Update combat state for a bot */
private def updateCombat(botState: BotState): CombatState = {
val player = botState.player
var combat = botState.combat
// Find or validate target
val currentTarget = combat.target.flatMap(guid => zone.LivePlayers.find(_.GUID == guid))
val targetStillValid = currentTarget.exists { t =>
t.isAlive && Vector3.Distance(player.Position, t.Position) <= MaxEngagementRange
}
if (!targetStillValid) {
// Lost target or need new one - stop firing if we were
if (combat.isFiring) {
stopFiring(botState)
}
// Look for new target
findTarget(botState) match {
case Some(newTarget) =>
combat = combat.copy(
target = Some(newTarget.GUID),
targetAcquiredTick = tickCount,
shotsFired = 0,
isFiring = false
)
case None =>
combat = CombatState() // No target, reset combat state
}
}
// If we have a target, handle combat
combat.target.flatMap(guid => zone.LivePlayers.find(_.GUID == guid)) match {
case Some(targetPlayer) =>
val distance = Vector3.Distance(player.Position, targetPlayer.Position)
val ticksSinceAcquired = tickCount - combat.targetAcquiredTick
val recognitionTime = recognitionTimeTicks(distance)
// Wait for recognition time before firing
if (ticksSinceAcquired >= recognitionTime) {
// Start firing if not already
if (!combat.isFiring) {
startFiring(botState)
combat = combat.copy(isFiring = true, burstStartTick = tickCount, shotsFired = 0)
}
// Fire every 2 ticks (~5 shots per second for automatic weapons)
if (tickCount - combat.lastShotTick >= 2) {
val ticksAiming = tickCount - combat.burstStartTick
val hitChance = calculateHitChance(distance, combat.shotsFired, ticksAiming)
if (random.nextFloat() < hitChance) {
fireAtTarget(botState, targetPlayer)
}
combat = combat.copy(
shotsFired = combat.shotsFired + 1,
lastShotTick = tickCount
)
// Burst control: after 15-25 shots, pause briefly to "reset recoil"
if (combat.shotsFired >= 15 + random.nextInt(10)) {
stopFiring(botState)
combat = combat.copy(
isFiring = false,
shotsFired = 0
)
}
}
}
case None =>
// Target no longer valid
if (combat.isFiring) {
stopFiring(botState)
combat = combat.copy(isFiring = false)
}
}
combat
}
/** Broadcast that bot started firing */
private def startFiring(botState: BotState): Unit = {
getWeapon(botState.player).foreach { weapon =>
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.ChangeFireState_Start(botState.player.GUID, weapon.GUID)
)
}
}
/** Broadcast that bot stopped firing */
private def stopFiring(botState: BotState): Unit = {
getWeapon(botState.player).foreach { weapon =>
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.ChangeFireState_Stop(botState.player.GUID, weapon.GUID)
)
}
}
/** Deal damage to target */
private def fireAtTarget(botState: BotState, target: Player): Unit = {
getWeapon(botState.player).foreach { weapon =>
val projectileType = weapon.Projectile
val fireMode = weapon.FireMode
// Create a projectile for damage calculation
val projectile = Projectile(
profile = projectileType,
tool_def = weapon.Definition,
fire_mode = fireMode,
mounted_in = None,
owner = PlayerSource(botState.player),
attribute_to = weapon.Definition.ObjectId,
shot_origin = botState.player.Position,
shot_angle = botState.player.Orientation,
shot_velocity = None
)
// Create damage interaction and calculate the damage function
val damageInteraction = DamageInteraction(
SourceEntry(target),
target.Position,
ProjectileReason(
DamageResolution.Hit,
projectile,
target.DamageModel
),
DamageResolution.Hit
)
// Send damage to target's actor (calculate() returns the function needed by Vitality.Damage)
target.Actor ! Vitality.Damage(damageInteraction.calculate())
// Send hit hint to target (so they know they're being shot)
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.HitHint(botState.player.GUID, target.GUID)
)
}
}
private def despawnBot(botId: Int): Unit = {
bots.get(botId) match {
case Some(botState) =>
log.info(s"Despawning bot '${botState.name}' (dbId=$botId)")
val player = botState.player
val avatar = botState.avatar
zone.Population ! Zone.Population.Leave(avatar)
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.ObjectDelete(player.GUID, player.GUID)
)
TaskWorkflow.execute(GUIDTask.unregisterAvatar(zone.GUID, player))
bots.remove(botId)
// Return the ID to the available pool
usedBotIds.remove(botId)
availableBotIds.enqueue(botId)
log.info(s"Bot '${botState.name}' despawned (${bots.size} bots active, ${availableBotIds.size} available)")
case None =>
// Also check pending respawns
pendingRespawns.get(botId) match {
case Some(info) =>
pendingRespawns.remove(botId)
usedBotIds.remove(botId)
availableBotIds.enqueue(botId)
log.info(s"Cancelled pending respawn for bot '${info.name}'")
case None =>
log.warn(s"Bot with id $botId not found")
}
}
}
}
object BotManager {
def props(zone: Zone): Props = Props(classOf[BotManager], zone)
// Commands - removed name from SpawnBot since we use DB names
sealed trait Command
final case class SpawnBot(faction: PlanetSideEmpire.Value, position: Vector3) extends Command
final case class DespawnBot(botId: Int) extends Command
case object DespawnAllBots extends Command
private case object Tick extends Command
private[bot] final case class CompleteSpawn(
botId: Int,
name: String,
avatar: Avatar,
player: Player,
botAvatarActor: ActorRef[AvatarActor.Command]
) extends Command
case class MovementState(
targetYaw: Float = 0f,
moveSpeed: Float = 3f,
moveUntilTick: Int = 0
)
/**
* Combat state tracking for accuracy system.
* Two opposing forces: adjustment (improves accuracy) vs recoil (worsens spread)
*/
case class CombatState(
target: Option[PlanetSideGUID] = None, // Current target GUID
isFiring: Boolean = false, // Currently shooting
shotsFired: Int = 0, // Shots in current burst (for recoil)
targetAcquiredTick: Int = 0, // When we first spotted target (for recognition time)
lastShotTick: Int = 0, // When we last fired
burstStartTick: Int = 0 // When current burst started (for adjustment)
)
case class BotState(
id: Int,
name: String,
avatar: Avatar,
player: Player,
avatarActor: ActorRef[AvatarActor.Command],
movement: MovementState = MovementState(),
combat: CombatState = CombatState(),
spawnPosition: Vector3 = Vector3.Zero
)
case class TapOutInfo(
botState: BotState,
tapOutAtTick: Int
)
case class RespawnInfo(
botId: Int,
name: String,
faction: PlanetSideEmpire.Value,
spawnPosition: Vector3,
respawnAtTick: Int
)
def spawnTestBot(context: ActorContext, position: Vector3): Option[ClassicActorRef] = {
Zones.zones.find(_.id == "home2") match {
case Some(zone) =>
val manager = context.actorOf(props(zone), s"bot-manager-${System.currentTimeMillis}")
manager ! SpawnBot(PlanetSideEmpire.TR, position)
Some(manager)
case None =>
None
}
}
}

View file

@ -142,6 +142,7 @@ class ChatLogic(val ops: ChatOperations, implicit val context: ActorContext) ext
case "macro" => ops.customCommandMacro(session, params)
case "progress" => ops.customCommandProgress(session, params)
case "squad" => ops.customCommandSquad(params)
case "bot" => ops.customCommandBot(session)
case _ =>
// command was not handled
sendResponse(

View file

@ -46,6 +46,7 @@ import net.psforever.types.ChatMessageType.{CMT_GMOPEN, UNK_227, UNK_229}
import net.psforever.types.{ChatMessageType, Cosmetic, ExperienceType, ImplantType, PlanetSideEmpire, PlanetSideGUID, Vector3}
import net.psforever.util.{Config, PointOfInterest}
import net.psforever.zones.Zones
import net.psforever.actors.bot.BotManager
trait ChatFunctions extends CommonSessionInterfacingFunctionality {
def ops: ChatOperations
@ -1410,6 +1411,22 @@ class ChatOperations(
)
}
def customCommandBot(
session: Session
): Boolean = {
val zone = session.zone
val player = session.player
val spawnPos = player.Position + Vector3(2, 2, 0) // Spawn slightly offset from player
// Use the zone's persistent BotManager
zone.BotManager ! BotManager.SpawnBot(player.Faction, spawnPos)
sendResponse(
ChatMsg(CMT_GMOPEN, wideContents = false, "Server", s"Spawning bot at $spawnPos", None)
)
true
}
override protected[session] def stop(): Unit = {
silenceTimer.cancel()
chatService ! ChatService.LeaveAllChannels(chatServiceAdapter)

View file

@ -20,6 +20,7 @@ import org.log4s.Logger
import net.psforever.services.avatar.AvatarService
import net.psforever.services.local.LocalService
import net.psforever.services.vehicle.VehicleService
import net.psforever.actors.bot.BotManager
import scala.collection.concurrent.TrieMap
import scala.collection.mutable.ListBuffer
@ -143,6 +144,8 @@ class Zone(val id: String, val map: ZoneMap, zoneNumber: Int) {
*/
private var population: ActorRef = Default.Actor
private var botManager: ActorRef = Default.Actor
private var buildings: PairMap[Int, Building] = PairMap.empty[Int, Building]
private var lattice: Graph[Building, UnDiEdge] = Graph()
@ -451,6 +454,8 @@ class Zone(val id: String, val map: ZoneMap, zoneNumber: Int) {
def Population: ActorRef = population
def BotManager: ActorRef = botManager
def Buildings: Map[Int, Building] = buildings
def Building(id: Int): Option[Building] = {
@ -1384,6 +1389,7 @@ object Zone {
zone.projectiles = context.actorOf(Props(classOf[ZoneProjectileActor], zone, zone.projectileList), s"$id-projectiles")
zone.transport = context.actorOf(Props(classOf[ZoneVehicleActor], zone, zone.vehicles, zone.linkDynamicTurretWeapon), s"$id-vehicles")
zone.population = context.actorOf(Props(classOf[ZonePopulationActor], zone, zone.players, zone.corpses), s"$id-players")
zone.botManager = context.actorOf(BotManager.props(zone), s"$id-bots")
zone.projector = context.actorOf(
Props(classOf[ZoneHotSpotDisplay], zone, zone.hotspots, 15 seconds, zone.hotspotHistory, 60 seconds),
s"$id-hotspots"