PSF-BotServer/bot-docs/SKETCHES/BotSpawner_v1.scala
2revoemag 2e5b5e0dbd 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>
2025-11-23 00:22:30 -05:00

395 lines
12 KiB
Scala

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