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,367 @@
// SKETCH - NOT PRODUCTION CODE
// This is a conceptual exploration of what BotActor might look like
package net.psforever.actors.bot
import akka.actor.{Actor, ActorRef, Cancellable, Props}
import net.psforever.objects.Player
import net.psforever.objects.avatar.Avatar
import net.psforever.objects.zones.Zone
import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
import net.psforever.types.{PlanetSideGUID, Vector3}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
/**
* BotActor - Controls a single bot entity
*
* Unlike SessionActor (which handles network packets from a client),
* BotActor makes decisions internally and broadcasts state to other players.
*
* Key differences from SessionActor:
* - No middlewareActor (no network connection)
* - No incoming packets to process
* - Generates actions based on AI logic
* - Still broadcasts via zone.AvatarEvents (same as real players)
*/
class BotActor(
player: Player,
avatar: Avatar,
zone: Zone,
botClass: BotClass,
botPersonality: BotPersonality
) extends Actor {
// Tick timer - how often bot makes decisions and broadcasts state
private var tickTimer: Cancellable = _
// Timestamp counter for PlayerStateMessage
private var timestamp: Int = 0
// Current AI state
private var currentTarget: Option[Player] = None
private var currentObjective: Option[BotObjective] = None
private var attitude: Float = 0.5f // 0 = calm, 1 = raging
// Death memory for vengeance system
private var lastDeathLocation: Option[Vector3] = None
private var lastKiller: Option[PlanetSideGUID] = None
override def preStart(): Unit = {
// Start the tick loop
// 10 FPS = 100ms, could go lower for distant bots
tickTimer = context.system.scheduler.scheduleWithFixedDelay(
initialDelay = 100.millis,
delay = 100.millis, // 10 ticks per second
receiver = self,
message = BotActor.Tick
)
}
override def postStop(): Unit = {
tickTimer.cancel()
}
def receive: Receive = {
case BotActor.Tick =>
tick()
case BotActor.TakeDamage(amount, source, sourcePosition) =>
handleDamage(amount, source, sourcePosition)
case BotActor.Die(killer) =>
handleDeath(killer)
case BotActor.Respawn(spawnPoint) =>
handleRespawn(spawnPoint)
case BotActor.ReceiveOrder(order) =>
handleOrder(order)
case BotActor.HelpRequest(requester, helpType, location) =>
handleHelpRequest(requester, helpType, location)
}
/**
* Main AI tick - called every 100ms (10 FPS)
*/
private def tick(): Unit = {
if (!player.isAlive) return
timestamp = (timestamp + 1) % 65536
// 1. Perception - what can we see?
val visibleTargets = detectTargets()
// 2. Decision - what should we do?
val action = decideAction(visibleTargets)
// 3. Execute - do the thing
executeAction(action)
// 4. Broadcast - tell everyone where we are
broadcastState()
}
/**
* Detect targets within vision cone
*/
private def detectTargets(): Seq[Player] = {
val myPos = player.Position
val myFacing = player.Orientation.z
val fovHalf = botPersonality.fovDegrees / 2
val maxRange = botPersonality.detectionRange
zone.LivePlayers
.filter(p => p != player)
.filter(p => p.Faction != player.Faction) // enemies only
.filter(p => p.isAlive)
.filter { target =>
val targetPos = target.Position
val distance = Vector3.Distance(myPos, targetPos)
val angle = calculateAngle(myPos, targetPos, myFacing)
distance <= maxRange && math.abs(angle) <= fovHalf
}
.toSeq
.sortBy(t => Vector3.DistanceSquared(myPos, t.Position))
}
/**
* Decide what action to take based on current state and perception
*/
private def decideAction(visibleTargets: Seq[Player]): BotAction = {
// Check health - should we retreat?
if (player.Health < player.MaxHealth * botPersonality.retreatThreshold) {
return BotAction.Retreat
}
// Check ammo - need resupply?
if (isOutOfAmmo()) {
return BotAction.Resupply
}
// Have a target?
visibleTargets.headOption match {
case Some(target) =>
currentTarget = Some(target)
BotAction.Attack(target)
case None =>
currentTarget = None
// Follow objective or patrol
currentObjective match {
case Some(obj) => BotAction.FollowObjective(obj)
case None => BotAction.Patrol
}
}
}
/**
* Execute the decided action
*/
private def executeAction(action: BotAction): Unit = action match {
case BotAction.Attack(target) =>
// Turn toward target
val newFacing = calculateFacingToward(player.Position, target.Position)
player.Orientation = Vector3(0, 0, newFacing)
// Move based on bot type
botPersonality.movementStyle match {
case MovementStyle.Newbie =>
// Run toward target with held strafe
moveToward(target.Position, strafeOffset = 2f)
case MovementStyle.Veteran =>
// ADAD strafe
moveWithADAD(target.Position)
case MovementStyle.NCClose =>
// Close distance aggressively (shotgun range)
moveToward(target.Position, speed = 1.5f)
}
// Fire if we have line of sight
if (hasLineOfSight(target)) {
fireWeapon(target)
}
case BotAction.Retreat =>
val retreatPosition = findRetreatPosition()
moveToward(retreatPosition)
// Call for help
if (shouldCallForHelp()) {
sendVoiceCommand("VVV") // HELP!
}
case BotAction.Resupply =>
val terminal = findNearestTerminal()
terminal.foreach(moveToward)
case BotAction.FollowObjective(obj) =>
moveToward(obj.targetPosition)
case BotAction.Patrol =>
patrol()
}
/**
* Broadcast current state to other players via AvatarService
*/
private def broadcastState(): Unit = {
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.PlayerState(
player.GUID,
player.Position,
Some(player.Velocity),
player.Orientation.z, // facingYaw
player.Orientation.x, // facingPitch
player.facingYawUpper, // upper body yaw
timestamp,
player.Crouching,
player.Jumping,
jumpThrust = false,
player.Cloaked,
spectator = false,
weaponInHand = player.DrawnSlot != Player.HandsDownSlot
)
)
}
/**
* Handle taking damage
*/
private def handleDamage(amount: Int, source: PlanetSideGUID, sourcePosition: Vector3): Unit = {
// If not in combat, react to damage
if (currentTarget.isEmpty) {
// Turn toward damage source
val newFacing = calculateFacingToward(player.Position, sourcePosition)
player.Orientation = Vector3(0, 0, newFacing)
// Panic behavior based on class
if (botClass.role == BotRole.Support || botClass.role == BotRole.Hacker) {
// Panic! Run to cover, swap to weapon
// (Support was probably repairing/healing)
}
}
// Increase attitude if repeatedly dying to same source
if (botPersonality.experienceLevel >= ExperienceLevel.Veteran) {
lastKiller.foreach { killer =>
if (killer == source) {
attitude = math.min(1.0f, attitude + 0.1f)
}
}
}
}
/**
* Handle death
*/
private def handleDeath(killer: PlanetSideGUID): Unit = {
lastDeathLocation = Some(player.Position)
lastKiller = Some(killer)
// Veteran+ bots remember for vengeance
if (botPersonality.experienceLevel >= ExperienceLevel.Veteran) {
// Store vengeance target
}
}
/**
* Send a V-menu voice command
*/
private def sendVoiceCommand(command: String): Unit = {
// TODO: Send ChatMsg with voice command
// zone.AvatarEvents ! ... ChatMsg ...
}
// Helper methods (stubs)
private def calculateAngle(from: Vector3, to: Vector3, facing: Float): Float = ???
private def calculateFacingToward(from: Vector3, to: Vector3): Float = ???
private def moveToward(target: Vector3, strafeOffset: Float = 0f, speed: Float = 1f): Unit = ???
private def moveWithADAD(target: Vector3): Unit = ???
private def hasLineOfSight(target: Player): Boolean = ???
private def fireWeapon(target: Player): Unit = ???
private def isOutOfAmmo(): Boolean = ???
private def findRetreatPosition(): Vector3 = ???
private def findNearestTerminal(): Option[Vector3] = ???
private def shouldCallForHelp(): Boolean = ???
private def patrol(): Unit = ???
}
object BotActor {
def props(player: Player, avatar: Avatar, zone: Zone, botClass: BotClass, personality: BotPersonality): Props =
Props(classOf[BotActor], player, avatar, zone, botClass, personality)
// Messages
case object Tick
case class TakeDamage(amount: Int, source: PlanetSideGUID, sourcePosition: Vector3)
case class Die(killer: PlanetSideGUID)
case class Respawn(spawnPoint: Vector3)
case class ReceiveOrder(order: BotOrder)
case class HelpRequest(requester: PlanetSideGUID, helpType: String, location: Vector3)
}
// Supporting types (would be in separate files)
sealed trait BotAction
object BotAction {
case class Attack(target: Player) extends BotAction
case object Retreat extends BotAction
case object Resupply extends BotAction
case class FollowObjective(objective: BotObjective) extends BotAction
case object Patrol extends BotAction
}
sealed trait BotRole
object BotRole {
case object Driver extends BotRole
case object Support extends BotRole
case object Hacker extends BotRole
case object AV extends BotRole
case object MAX extends BotRole
case object Veteran extends BotRole
case object Ace extends BotRole
}
sealed trait ExperienceLevel
object ExperienceLevel {
case object Newbie extends ExperienceLevel
case object Regular extends ExperienceLevel
case object Veteran extends ExperienceLevel
case object Ace extends ExperienceLevel
}
sealed trait MovementStyle
object MovementStyle {
case object Newbie extends MovementStyle // straight run with held strafe
case object Veteran extends MovementStyle // ADAD + crouch spam
case object NCClose extends MovementStyle // aggressive closing for shotguns
}
case class BotClass(
name: String,
role: BotRole,
// certifications, loadout, etc.
)
case class BotPersonality(
experienceLevel: ExperienceLevel,
movementStyle: MovementStyle,
fovDegrees: Float = 60f,
detectionRange: Float = 100f,
retreatThreshold: Float = 0.25f, // retreat at 25% HP
accuracyModifier: Float = 1.0f
)
case class BotObjective(
targetPosition: Vector3,
objectiveType: String // "attack", "defend", "capture", etc.
)
case class BotOrder(
orderType: String,
targetPosition: Option[Vector3],
priority: Int
)

View file

@ -0,0 +1,394 @@
// SKETCH - NOT PRODUCTION CODE
// Bot spawning flow - what would it take to spawn a bot?
package net.psforever.actors.bot
import akka.actor.{Actor, ActorContext, ActorRef, Props}
import net.psforever.objects.{GlobalDefinitions, Player}
import net.psforever.objects.avatar.Avatar
import net.psforever.objects.definition.ExoSuitDefinition
import net.psforever.objects.guid.GUIDTask
import net.psforever.objects.loadouts.InfantryLoadout
import net.psforever.objects.zones.Zone
import net.psforever.packet.game.objectcreate.BasicCharacterData
import net.psforever.types._
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{Success, Failure}
import java.util.concurrent.atomic.AtomicInteger
/**
* BotSpawner - Responsible for spawning bots into a zone
*
* Key insight: Looking at ZonePopulationActor.scala:
* - Zone.Population.Join(avatar) -> registers avatar in playerMap
* - Zone.Population.Spawn(avatar, player, avatarActor) -> creates PlayerControl
* - GUIDTask.registerPlayer(zone.GUID, player) -> assigns GUIDs
*
* For bots, we need:
* 1. Create an Avatar (normally from DB, but we can construct directly)
* 2. Create a Player with that Avatar
* 3. Equip the player with loadout
* 4. Register GUIDs for player and equipment
* 5. Join zone population
* 6. Spawn player
* 7. Create BotActor to control AI
*/
object BotSpawner {
// Counter for generating unique bot IDs
// Using negative numbers to avoid collision with real player charIds
private val botIdCounter = new AtomicInteger(-1)
/**
* Spawn a single bot into a zone
*
* @param zone The zone to spawn into
* @param faction Which empire (TR, NC, VS)
* @param botClass The class/role of the bot
* @param spawnPosition Where to spawn
* @param context ActorContext for creating BotActor
* @return The spawned player entity
*/
def spawnBot(
zone: Zone,
faction: PlanetSideEmpire.Value,
botClass: BotClass,
spawnPosition: Vector3,
context: ActorContext
): Player = {
// 1. Generate unique bot ID (negative to avoid DB collision)
val botId = botIdCounter.getAndDecrement()
// 2. Create Avatar
val avatar = createBotAvatar(botId, faction, botClass)
// 3. Create Player entity
val player = new Player(avatar)
// 4. Configure player
configurePlayer(player, botClass, spawnPosition)
// 5. Register GUIDs for player and all equipment
// This is async - we need to wait for it to complete
val registerTask = GUIDTask.registerPlayer(zone.GUID, player)
TaskWorkflow.execute(registerTask)
// 6. Join zone population (avatar-level)
zone.Population ! Zone.Population.Join(avatar)
// 7. Create a placeholder BotAvatarActor
// Real AvatarActor handles DB persistence - we don't need that
val botAvatarActor = context.actorOf(
BotAvatarActor.props(avatar),
name = s"bot-avatar-$botId"
)
// 8. Spawn player in zone (creates PlayerControl actor)
zone.Population ! Zone.Population.Spawn(avatar, player, botAvatarActor)
// 9. Add to block map for spatial queries
zone.actor ! ZoneActor.AddToBlockMap(player, spawnPosition)
// 10. Create BotActor for AI control
val personality = createPersonality(botClass)
val botActor = context.actorOf(
BotActor.props(player, avatar, zone, botClass, personality),
name = s"bot-ai-$botId"
)
// 11. Broadcast player existence to all connected clients
broadcastPlayerSpawn(zone, player)
player
}
/**
* Create an Avatar for a bot
*/
private def createBotAvatar(
botId: Int,
faction: PlanetSideEmpire.Value,
botClass: BotClass
): Avatar = {
val name = generateBotName(faction, botClass, botId)
val sex = if (scala.util.Random.nextBoolean()) CharacterSex.Male else CharacterSex.Female
val head = scala.util.Random.nextInt(5) + 1
val voice = CharacterVoice.values.toSeq(scala.util.Random.nextInt(CharacterVoice.values.size))
// Create avatar with predefined certifications for the class
Avatar(
id = botId,
basic = BasicCharacterData(name, faction, sex, head, voice),
bep = botClass.battleRank * 1000L, // Fake BEP for appearance
cep = if (botClass.role == BotRole.Ace) 10000L else 0L, // CR for Ace only
certifications = botClass.certifications
)
}
/**
* Generate a bot name like "[BOT]Grunt_TR_042"
*/
private def generateBotName(
faction: PlanetSideEmpire.Value,
botClass: BotClass,
botId: Int
): String = {
val factionPrefix = faction match {
case PlanetSideEmpire.TR => "TR"
case PlanetSideEmpire.NC => "NC"
case PlanetSideEmpire.VS => "VS"
case _ => "XX"
}
val classPrefix = botClass.role match {
case BotRole.Driver => "Driver"
case BotRole.Support => "Medic"
case BotRole.Hacker => "Hacker"
case BotRole.AV => "Heavy"
case BotRole.MAX => "MAX"
case BotRole.Veteran => "Vet"
case BotRole.Ace => "Ace"
}
f"[BOT]${classPrefix}_${factionPrefix}_${math.abs(botId)}%03d"
}
/**
* Configure player entity with position, equipment, etc.
*/
private def configurePlayer(
player: Player,
botClass: BotClass,
spawnPosition: Vector3
): Unit = {
// Set position and orientation
player.Position = spawnPosition
player.Orientation = Vector3(0, 0, scala.util.Random.nextFloat() * 360f)
// Set exosuit based on class
val exosuit = botClass.role match {
case BotRole.MAX => ExoSuitType.MAX
case BotRole.Hacker => ExoSuitType.Agile // Infiltrators use Agile
case _ => ExoSuitType.Reinforced
}
player.ExoSuit = exosuit
// Equip loadout
equipLoadout(player, botClass)
// Spawn the player (set health, armor)
player.Spawn()
}
/**
* Equip player with class-appropriate loadout
*/
private def equipLoadout(player: Player, botClass: BotClass): Unit = {
// TODO: Load from predefined loadouts
// For now, just give basic equipment based on faction + class
// Example: Standard infantry loadout
// player.Slot(0).Equipment = ... // Rifle
// player.Slot(1).Equipment = ... // Sidearm
// etc.
}
/**
* Create personality/behavior weights for a bot class
*/
private def createPersonality(botClass: BotClass): BotPersonality = {
botClass.role match {
case BotRole.Veteran | BotRole.Ace =>
BotPersonality(
experienceLevel = if (botClass.role == BotRole.Ace) ExperienceLevel.Ace else ExperienceLevel.Veteran,
movementStyle = MovementStyle.Veteran,
fovDegrees = 75f, // Better awareness
accuracyModifier = 1.2f,
retreatThreshold = 0.3f
)
case BotRole.MAX =>
BotPersonality(
experienceLevel = ExperienceLevel.Regular,
movementStyle = MovementStyle.Newbie, // MAXes are slower
fovDegrees = 60f,
accuracyModifier = 1.0f,
retreatThreshold = 0.2f // MAXes don't retreat easily
)
case _ =>
BotPersonality(
experienceLevel = ExperienceLevel.Newbie,
movementStyle = MovementStyle.Newbie,
fovDegrees = 60f,
accuracyModifier = 0.8f, // Worse accuracy
retreatThreshold = 0.25f
)
}
}
/**
* Broadcast player spawn to all connected clients
*/
private def broadcastPlayerSpawn(zone: Zone, player: Player): Unit = {
// Use ObjectCreateMessage to create the player on all clients
// This is what makes the bot visible to everyone
import net.psforever.packet.game.ObjectCreateMessage
import net.psforever.packet.game.objectcreate._
// Build the player data for ObjectCreateMessage
val playerData = PlayerData.create(player)
// Broadcast via AvatarService
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.LoadPlayer(
player.GUID,
player.Definition.ObjectId,
player.GUID, // target_guid - same as player for self
playerData,
None // no parent (not in vehicle)
)
)
}
/**
* Despawn a bot from a zone (graceful logout)
*/
def despawnBot(zone: Zone, player: Player): Unit = {
// 1. Stop BotActor
// 2. Notify zone population
zone.Population ! Zone.Population.Leave(player.avatar)
// 3. Broadcast player deletion
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.ObjectDelete(player.GUID, player.GUID)
)
// 4. Unregister GUIDs
TaskWorkflow.execute(GUIDTask.unregisterPlayer(zone.GUID, player))
// 5. Remove from block map
zone.actor ! ZoneActor.RemoveFromBlockMap(player)
}
}
/**
* Simplified AvatarActor for bots
*
* The real AvatarActor handles:
* - DB persistence (saving stats, certs, etc.)
* - Character selection
* - Login/logout flow
*
* For bots, we don't need most of that. Just a stub actor
* that can handle the messages PlayerControl expects.
*/
class BotAvatarActor(avatar: Avatar) extends Actor {
// Minimal implementation - just accept messages and do nothing
def receive: Receive = {
case _ => // Ignore most messages - bots don't persist
}
}
object BotAvatarActor {
def props(avatar: Avatar): Props = Props(classOf[BotAvatarActor], avatar)
}
/**
* BotManager - Manages bot population across a zone
*/
class BotManager(zone: Zone) extends Actor {
import scala.concurrent.duration._
private val targetBotsPerFaction = 100
private var bots: Map[PlanetSideGUID, Player] = Map.empty
// Population check timer
context.system.scheduler.scheduleWithFixedDelay(
initialDelay = 5.seconds,
delay = 10.seconds,
receiver = self,
message = BotManager.CheckPopulation
)(context.dispatcher)
def receive: Receive = {
case BotManager.CheckPopulation =>
balancePopulation()
case BotManager.SpawnBot(faction, botClass, position) =>
val player = BotSpawner.spawnBot(zone, faction, botClass, position, context)
bots += (player.GUID -> player)
case BotManager.DespawnBot(guid) =>
bots.get(guid).foreach { player =>
BotSpawner.despawnBot(zone, player)
bots -= guid
}
case BotManager.DespawnAll =>
bots.values.foreach(player => BotSpawner.despawnBot(zone, player))
bots = Map.empty
}
private def balancePopulation(): Unit = {
PlanetSideEmpire.values.foreach { faction =>
if (faction != PlanetSideEmpire.NEUTRAL) {
val realPlayers = zone.LivePlayers.count(p =>
!isBotPlayer(p) && p.Faction == faction
)
val currentBots = bots.values.count(_.Faction == faction)
val targetBots = math.max(0, targetBotsPerFaction - realPlayers)
if (currentBots < targetBots) {
// Spawn more bots
val toSpawn = targetBots - currentBots
(0 until toSpawn).foreach { _ =>
val position = findSpawnPosition(faction)
val botClass = randomBotClass()
self ! BotManager.SpawnBot(faction, botClass, position)
}
} else if (currentBots > targetBots) {
// Despawn excess bots (non-Ace first)
val toRemove = currentBots - targetBots
val botsToRemove = bots.values
.filter(_.Faction == faction)
.toSeq
.sortBy(p => if (isAce(p)) 1 else 0) // Ace last
.take(toRemove)
botsToRemove.foreach(p => self ! BotManager.DespawnBot(p.GUID))
}
}
}
}
private def isBotPlayer(player: Player): Boolean = {
// Check if name starts with [BOT]
player.Name.startsWith("[BOT]")
}
private def isAce(player: Player): Boolean = {
player.Name.contains("Ace_")
}
private def findSpawnPosition(faction: PlanetSideEmpire.Value): Vector3 = {
// TODO: Find appropriate spawn point for faction
// Could use owned bases, warpgates, etc.
Vector3(100f, 100f, 10f) // Placeholder
}
private def randomBotClass(): BotClass = {
// Weighted random class selection
// More grunts than specialists
BotClass("Grunt", BotRole.Veteran, Set(), 10) // Placeholder
}
}
object BotManager {
case object CheckPopulation
case class SpawnBot(faction: PlanetSideEmpire.Value, botClass: BotClass, position: Vector3)
case class DespawnBot(guid: PlanetSideGUID)
case object DespawnAll
}