mirror of
https://github.com/2revoemag/PSF-BotServer.git
synced 2026-01-19 18:14:44 +00:00
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>
395 lines
12 KiB
Scala
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
|
|
}
|