mirror of
https://github.com/2revoemag/PSF-BotServer.git
synced 2026-07-11 14:34:40 +00:00
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:
parent
9646b3f99e
commit
2e5b5e0dbd
17 changed files with 3813 additions and 0 deletions
367
bot-docs/SKETCHES/BotActor_v1.scala
Normal file
367
bot-docs/SKETCHES/BotActor_v1.scala
Normal 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
|
||||
)
|
||||
394
bot-docs/SKETCHES/BotSpawner_v1.scala
Normal file
394
bot-docs/SKETCHES/BotSpawner_v1.scala
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue