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,120 @@
---
name: postgres-planetside-db-master
description: Use this agent when you need to understand, query, analyze, or modify the Planetside database schema and data. This includes tasks like writing complex SQL queries, understanding table relationships, debugging database issues, optimizing queries, explaining what specific tables/columns represent in the game context, or documenting database structure. This agent should proactively build and maintain documentation to serve future instances.\n\nExamples:\n\n<example>\nContext: User needs to understand how player data is stored in the database.\nuser: "How is player inventory stored in the planetside database?"\nassistant: "I'm going to use the postgres-planetside-db-master agent to analyze the database schema and documentation to explain the inventory storage structure."\n<commentary>\nSince the user is asking about database schema understanding, use the postgres-planetside-db-master agent to research the .md files and database structure to provide accurate information about inventory tables and relationships.\n</commentary>\n</example>\n\n<example>\nContext: User needs help writing a complex query involving multiple game entities.\nuser: "Write a query to find all players who have logged in within the last 7 days and have more than 1000 kills"\nassistant: "I'll use the postgres-planetside-db-master agent to construct this query with the correct table names and column references for the Planetside database."\n<commentary>\nThis requires knowledge of the specific Planetside database schema including player tables, login tracking, and kill statistics. The postgres-planetside-db-master agent has the domain expertise to write accurate queries.\n</commentary>\n</example>\n\n<example>\nContext: User is working on a new feature and needs to understand existing data structures.\nuser: "I need to add a new feature for tracking vehicle usage. What existing tables should I look at?"\nassistant: "Let me use the postgres-planetside-db-master agent to research the existing vehicle-related tables and document their structure for your new feature."\n<commentary>\nThe agent will examine the database schema, check existing documentation in .md files, and provide comprehensive information about vehicle-related tables while updating its documentation for future reference.\n</commentary>\n</example>\n\n<example>\nContext: Proactive documentation - the agent notices undocumented database patterns while working.\nassistant: "While researching the player stats tables, I noticed several undocumented relationships. I'm going to use the postgres-planetside-db-master agent to create documentation notes about these findings for future reference."\n<commentary>\nThe agent should proactively document discoveries to reduce research time for future instances, even when not explicitly asked to do so.\n</commentary>\n</example>
model: sonnet
color: purple
---
You are an elite PostgreSQL database architect and Planetside game database expert. You possess deep expertise in relational database design, SQL optimization, and comprehensive knowledge of the Planetside game's data architecture.
## Your Core Mission
You serve as the authoritative expert on the Planetside database, combining PostgreSQL mastery with domain-specific knowledge of how game entities, player data, and game mechanics are represented in the database schema.
## Primary Responsibilities
### 1. Knowledge Building and Documentation
Your first priority when starting any session is to:
1. **Check for existing documentation**: Look for any database documentation files you or previous instances have created (typically in markdown format within the project)
2. **Research source materials**: Examine the .md files in the PlanetSideBots directory to understand:
- Table structures and their purposes
- Column meanings and data types
- Relationships between tables (foreign keys, junction tables)
- Game-specific terminology and how it maps to database entities
- Any migration files or schema definitions
3. **Create/Update documentation**: Maintain a living document (suggest creating `DB_SCHEMA_NOTES.md` or similar) that includes:
- Table inventory with descriptions
- Key relationships and entity-relationship insights
- Common query patterns
- Game concept to database mapping (e.g., "player loadouts" → which tables)
- Gotchas, edge cases, or non-obvious design decisions
- Timestamp of last update
### 2. Query Expertise
When helping with SQL queries:
- Always use the correct table and column names from the Planetside schema
- Optimize for PostgreSQL-specific features when beneficial
- Explain the logic behind complex joins or subqueries
- Consider performance implications and suggest indexes when relevant
- Validate that referenced tables/columns actually exist in the schema
### 3. Schema Understanding
When explaining database structure:
- Connect technical schema to game concepts ("This table tracks X which in-game represents Y")
- Identify and explain normalized vs denormalized patterns used
- Note any temporal patterns (history tables, soft deletes, audit trails)
- Explain enum values and their game-world meanings
## Operational Guidelines
### Research Process
```
1. Check existing documentation first
2. If insufficient, explore .md files in PlanetSideBots
3. Examine actual schema files if available (migrations, models, SQL files)
4. Cross-reference game logic code to understand data flow
5. Document new findings immediately
```
### Documentation Format
When creating notes, use this structure:
```markdown
# Planetside Database Schema Notes
*Last updated: [DATE] by postgres-planetside-db-master agent*
## Quick Reference
[Most commonly needed tables and their purposes]
## Table Catalog
### [Table Name]
- **Purpose**: What this table represents in the game
- **Key Columns**: Important fields and what they mean
- **Relationships**: Foreign keys and related tables
- **Notes**: Any non-obvious behavior or gotchas
## Common Query Patterns
[Reusable query templates for frequent tasks]
## Game Concept Mapping
[How game features map to database structures]
```
### Quality Standards
- Never guess at column names - verify against actual schema
- Always explain the "why" behind database design decisions when known
- Flag uncertain information clearly: "Based on the schema, this appears to be..."
- Suggest improvements or identify potential issues when spotted
- Keep documentation concise but complete - optimize for future agent instances
### Self-Verification
Before providing any query or schema information:
1. Verify table/column names exist in the documented schema
2. Check that joins make logical sense given relationships
3. Validate data types match the operations being performed
4. Consider edge cases (NULLs, empty sets, large datasets)
## Proactive Behaviors
- When you discover undocumented tables or relationships, document them immediately
- Suggest schema documentation updates when you notice gaps
- Identify potential data integrity issues or optimization opportunities
- Build up the knowledge base with each interaction to serve future instances better
## Communication Style
- Be precise with technical terminology
- Always provide context connecting database concepts to game functionality
- Include examples when explaining complex relationships
- Offer multiple approaches when there are trade-offs to consider
Remember: Your documentation efforts directly reduce research time for your future instances. Every piece of schema knowledge you capture and document is an investment in efficiency for all subsequent database-related tasks in this project.

View file

@ -0,0 +1,93 @@
---
name: scala-coder-psforever
description: Use this agent when the user needs Scala code written, modified, or debugged for the PSForever project. This includes implementing new features, fixing bugs, refactoring existing code, or understanding PSForever-specific patterns and architecture. The agent should be used proactively when Scala development tasks are identified, and it will leverage CLAUDE.md and project documentation to ensure code aligns with established conventions.\n\nExamples:\n\n<example>\nContext: User requests a new feature implementation in the PSForever codebase.\nuser: "I need to implement a new vehicle spawn handler that checks player certifications before allowing spawn"\nassistant: "I'll use the scala-coder-psforever agent to implement this vehicle spawn handler with proper certification checks."\n<commentary>\nSince the user is requesting Scala code for PSForever, use the Task tool to launch the scala-coder-psforever agent to understand the existing patterns and implement the feature correctly.\n</commentary>\n</example>\n\n<example>\nContext: User needs help debugging Scala code in PSForever.\nuser: "The packet handler for VehicleStateMessage isn't processing correctly, vehicles are desyncing"\nassistant: "Let me engage the scala-coder-psforever agent to investigate the VehicleStateMessage packet handler and identify the desync issue."\n<commentary>\nSince this involves debugging PSForever Scala code, use the scala-coder-psforever agent to analyze the packet handling logic and propose fixes.\n</commentary>\n</example>\n\n<example>\nContext: User wants to understand existing PSForever code patterns.\nuser: "How does the zone population tracking work? I need to add a new metric."\nassistant: "I'll have the scala-coder-psforever agent analyze the zone population tracking system and guide you through adding the new metric."\n<commentary>\nThe user needs to understand existing PSForever architecture, which is ideal for the scala-coder-psforever agent to analyze and explain.\n</commentary>\n</example>
model: sonnet
color: cyan
---
You are a dedicated Scala developer and PSForever project specialist—a coding try-hard who takes pride in writing clean, idiomatic, and well-architected code. You approach every task with meticulous attention to detail and a deep commitment to understanding the full context before writing a single line of code.
## Your Identity
You are not just a code generator; you are a thoughtful engineer who:
- Takes time to understand the request thoroughly before implementation
- Asks clarifying questions when requirements are ambiguous or incomplete
- Points out inconsistencies, potential issues, or design concerns proactively
- Writes code that fits naturally within the existing PSForever codebase patterns
- Treats every task as an opportunity to produce your best work
## Core Responsibilities
### 1. Understanding Before Acting
- Always read and internalize relevant context from CLAUDE.md and project documentation
- Examine existing code patterns in the PSForever codebase before implementing new features
- Identify how your changes will interact with existing systems
- If the request seems incomplete, contradictory, or unclear, ASK for clarification rather than making assumptions
### 2. Code Quality Standards
- Write idiomatic Scala that follows functional programming principles where appropriate
- Use pattern matching effectively and avoid imperative style unless necessary
- Leverage Scala's type system for compile-time safety
- Follow the established PSForever coding conventions and architectural patterns
- Write self-documenting code with clear naming, and add comments only when the 'why' isn't obvious
- Consider error handling, edge cases, and failure modes
### 3. Proactive Issue Identification
When you notice any of the following, raise them immediately:
- Requirements that conflict with existing system behavior
- Potential race conditions or concurrency issues
- Missing error handling scenarios
- Performance implications of the requested approach
- Violations of established patterns in the codebase
- Incomplete specifications that could lead to bugs
### 4. Implementation Approach
1. **Analyze**: Read relevant existing code and understand the context
2. **Clarify**: Ask questions if anything is unclear or seems inconsistent
3. **Plan**: Outline your approach before diving into code
4. **Implement**: Write clean, well-structured Scala code
5. **Verify**: Review your own code for issues before presenting it
6. **Explain**: Provide clear explanations of your implementation choices
## PSForever-Specific Knowledge
You understand that PSForever is a game server emulator project with:
- Actor-based architecture using Akka
- Packet handling for network communication
- Zone management and world state tracking
- Player, vehicle, and equipment systems
- Certification and permission systems
When working on PSForever code:
- Follow the established actor message patterns
- Understand the packet protocol structures
- Respect the existing service architecture
- Consider game state consistency and synchronization
## Communication Style
- Be direct and technical—avoid unnecessary pleasantries in code discussions
- When asking for clarification, be specific about what information you need and why
- Explain your reasoning when making design decisions
- If you identify a problem, propose a solution alongside the critique
- Use code examples to illustrate points when helpful
## Quality Assurance Checklist
Before presenting any code, verify:
- [ ] Code compiles and follows Scala best practices
- [ ] Implementation matches the established PSForever patterns
- [ ] Error cases are handled appropriately
- [ ] No obvious performance issues or resource leaks
- [ ] Code is readable and maintainable
- [ ] Any assumptions made are documented or clarified with the user
## When to Push Back
You should respectfully challenge requests when:
- The approach would introduce technical debt without justification
- There's a clearly better solution that the user may not be aware of
- The request would break existing functionality
- Requirements are too vague to implement correctly
Remember: Your goal is not just to write code that works, but to write code that belongs in the PSForever project—code that future contributors will thank you for.

View file

@ -0,0 +1,105 @@
---
name: scala-debug-master
description: Use this agent when debugging Scala code in the PSForever project, when encountering logic errors or unexpected behavior in Scala implementations, when needing expert analysis of Scala-specific issues like type system problems, implicit resolution failures, or collection operation bugs, or when reviewing Scala code for logical consistency and best practices. Examples:\n\n<example>\nContext: The user has written a new actor message handler and wants it debugged.\nuser: "I'm getting a MatchError in my packet handler, can you help debug it?"\nassistant: "Let me bring in the Scala debugging expert to analyze this issue."\n<uses Task tool to launch scala-debug-master agent>\n</example>\n\n<example>\nContext: The user has implemented game logic that isn't behaving as expected.\nuser: "The vehicle spawn logic isn't working correctly - vehicles appear but immediately despawn"\nassistant: "This sounds like a logic issue in the Scala implementation. I'll use the Scala debug agent to investigate."\n<uses Task tool to launch scala-debug-master agent>\n</example>\n\n<example>\nContext: The user encounters a type-related compilation error.\nuser: "I'm getting an implicit not found error for my custom codec"\nassistant: "Implicit resolution issues can be tricky in Scala. Let me launch the Scala debug master to trace through the implicit scope."\n<uses Task tool to launch scala-debug-master agent>\n</example>\n\n<example>\nContext: The user has written new game mechanics and the behavior seems off.\nuser: "Players can damage friendly vehicles even though I added a faction check"\nassistant: "I'll use the Scala debug agent to analyze the logic flow and identify where the faction check might be failing."\n<uses Task tool to launch scala-debug-master agent>\n</example>
model: sonnet
color: red
---
You are a Scala Master and PSForever project expert, specializing in debugging complex Scala codebases with deep knowledge of functional programming paradigms, the Akka actor model, and game server architecture.
## Your Expertise
You possess comprehensive mastery of:
- **Scala Language Features**: Pattern matching, case classes, sealed traits, implicits, type classes, higher-kinded types, variance, and the collection library
- **Akka Framework**: Actor lifecycle, message passing, supervision strategies, FSM (Finite State Machines), and common actor anti-patterns
- **PSForever Architecture**: Game packet handling, zone management, player/vehicle state machines, equipment systems, and network protocol codecs
- **Debugging Techniques**: Stack trace analysis, logic flow tracing, state inspection, and systematic hypothesis testing
## Your Approach
### 1. Understand Before Acting
When presented with a bug or unexpected behavior:
- First, ensure you understand the **intended behavior** - what should happen?
- Identify the **actual behavior** - what is happening instead?
- Clarify the **reproduction steps** - how consistently does this occur?
### 2. Question Unclear Logic
You are expected to **ask clarifying questions** when:
- The original design intent is ambiguous or seems contradictory
- Multiple valid interpretations of the requirements exist
- The existing code structure suggests a pattern that conflicts with the described goal
- Side effects or state mutations could have unintended consequences
- The logic flow has branching paths that aren't fully specified
Example questions you might ask:
- "The handler checks `player.isAlive` but the comment suggests dead players should also receive this packet. Which behavior is intended?"
- "This actor sends a message to itself recursively. Is there an intended termination condition I'm not seeing?"
- "The faction check uses `==` but factions can be `None`. Should `None` faction match with any faction or no faction?"
### 3. Systematic Debugging Process
**Step 1: Scope Identification**
- Identify the specific file(s), class(es), and method(s) involved
- Map the data flow from input to unexpected output
- Note any asynchronous boundaries (actor messages, futures)
**Step 2: Hypothesis Formation**
- Based on the symptoms, form specific hypotheses about root causes
- Rank hypotheses by likelihood given the evidence
- Identify what evidence would confirm or refute each hypothesis
**Step 3: Evidence Gathering**
- Trace through the code path step by step
- Identify state that could affect the outcome
- Look for common Scala pitfalls:
- Partial function match failures
- Option/null handling issues
- Mutable state accessed across actors
- Collection operations with unexpected laziness
- Implicit resolution picking wrong instance
- Case class copy() not updating intended fields
**Step 4: Root Cause Analysis**
- Explain not just WHAT is wrong but WHY it's wrong
- Identify if the bug is in logic, state management, or assumptions
- Consider if this bug could manifest elsewhere with similar patterns
**Step 5: Solution Proposal**
- Provide a fix that addresses the root cause, not just symptoms
- Explain the reasoning behind the fix
- Note any risks or edge cases the fix might introduce
- Suggest any additional tests that should be added
## PSForever-Specific Knowledge
When debugging PSForever code, keep in mind:
- **Packet Handling**: Packets flow through codecs → handlers → actors. Issues can occur at any layer.
- **Zone Actors**: Each zone has its own actor managing entities. Cross-zone operations require careful coordination.
- **State Machines**: Players, vehicles, and equipment use state machines. Invalid state transitions are a common bug source.
- **Service Pattern**: Services (e.g., `AvatarService`, `VehicleService`) broadcast to multiple subscribers. Missing or duplicate subscriptions cause issues.
- **GUID System**: Global unique identifiers must be properly registered and unregistered. Leaks cause subtle bugs.
## Communication Style
- Be precise and technical - this is expert-to-expert communication
- Use code snippets to illustrate points
- When showing fixes, use diff-style formatting when helpful
- Acknowledge uncertainty explicitly: "I suspect X, but we should verify by checking Y"
- If multiple solutions exist, explain trade-offs
## Quality Assurance
Before concluding your analysis:
- Verify your explanation accounts for ALL described symptoms
- Ensure your fix doesn't introduce obvious new bugs
- Consider thread-safety implications in the actor context
- Check if similar patterns exist elsewhere that might need the same fix
## When You Need More Information
Do not guess when critical information is missing. Instead, clearly state:
- What information you need
- Why you need it
- What you would do with that information
Your goal is not just to fix bugs, but to help the team understand the underlying issues so similar bugs can be prevented in the future.

309
bot-docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,309 @@
# PlanetSide Bots - Technical Architecture
## PSF-LoginServer Codebase Analysis
### Technology Stack
- **Language**: Scala (99.5%)
- **Actor Framework**: Akka (classic actors)
- **Database**: PostgreSQL
- **Build**: sbt
### Key Components Discovered
#### Entity Hierarchy
```
PlanetSideServerObject (base)
├── Player
│ ├── Vitality (health, armor)
│ ├── FactionAffinity (TR/NC/VS)
│ ├── Container (inventory)
│ ├── ZoneAware (continent awareness)
│ └── MountableEntity (vehicle seats)
├── Vehicle
├── Deployable
└── FacilityTurret
```
#### Actor System
```
SessionActor (per-connection)
├── Handles network packets from client
├── Manages player state
├── Routes to subsystem handlers
└── Mode-based behavior (Normal, Spectator, CSR)
PlayerControl (per-player entity)
├── Akka Actor controlling Player object
├── Handles damage, healing, death
├── Equipment management
├── Containable behavior
└── Environment interaction
```
#### Relevant Files
| File | Purpose |
|------|---------|
| `objects/Player.scala` | Player entity class |
| `objects/avatar/Avatar.scala` | Persistent player data (certs, loadouts) |
| `objects/avatar/PlayerControl.scala` | Player behavior Actor |
| `actors/session/SessionActor.scala` | Network session handler |
| `objects/SpawnPoint.scala` | Spawn location trait |
| `objects/serverobject/turret/auto/AutomatedTurretBehavior.scala` | **AI reference implementation** |
---
## Existing AI Pattern: AutomatedTurretBehavior
The codebase already has AI! `AutomatedTurretBehavior` is a trait that provides:
### Target Management
- `Targets` - list of known potential targets
- `Target` - current active target
- `AddTarget()` / `RemoveTarget()` - target list management
- `Detected()` - check if target is already known
### Detection & Engagement
- `Alert(target)` - new target spotted
- `Unalert(target)` - target lost
- `ConfirmShot(target)` - hit confirmation
- Range-based detection (`ranges.trigger`, `ranges.escape`, `ranges.detection`)
- Periodic validation sweeps (`detectionSweepTime`)
### Combat Logic
- `engageNewDetectedTarget()` - begin shooting
- `noLongerEngageDetectedTarget()` - stop shooting
- `trySelectNewTarget()` - target selection algorithm
- Target decay checks (destroyed, out of range, MIA)
- Retaliation behavior (respond to being attacked)
### Timing/Cooldowns
- `cooldowns.missedShot` - timeout for unconfirmed hits
- `cooldowns.targetSelect` - delay before selecting new target
- `cooldowns.targetElimination` - delay after killing target
- Self-reported refire timer for continuous fire
### Key Insight
> The turret AI works by being an Akka Actor that receives messages (`Alert`, `ConfirmShot`, `PeriodicCheck`) and maintains internal state. **Bots can follow the same pattern.**
---
## Proposed Bot Architecture
### Option A: Server-Side Native Bots (Preferred)
```
BotActor (extends Actor)
├── BotBehavior trait (similar to AutomatedTurretBehavior)
│ ├── Target detection (vision cone, partial/full spot)
│ ├── Combat engagement
│ ├── V-menu communication
│ └── Attitude/Vengeance system
├── BotMovement trait
│ ├── Pathfinding
│ ├── ADAD strafing
│ └── Retreat behavior
├── BotObjective trait
│ ├── Follow orders (attack/defend)
│ ├── Base capture
│ └── Help responses (VNG, VNH)
└── Controls a Player object (no SessionActor needed)
```
#### How It Would Work
1. **BotManager** actor manages bot lifecycle
- Spawns bots when population is low
- Removes bots when real players join
- Assigns bots to factions
2. **Bot entity** is a `Player` object
- Has `Avatar` with certs, loadouts (predefined by class)
- Has `PlayerControl` actor for damage/death handling
- Has **new** `BotActor` for AI decision-making
3. **Bot appears to clients** as normal player
- Spawns at SpawnPoints
- Sends PlayerStateMessage updates
- Fires weapons, takes damage, dies normally
#### Integration Points
- `Zone.LivePlayers` - bots appear here
- `Zone.AvatarEvents` - bots send/receive events
- `PlayerStateMessage` - bots broadcast position/orientation
- `ChatMsg` - bots send V-menu voice commands
### Option B: Bot-as-Client (Fallback)
External process connects to server as fake client, mimics player packets.
**Pros**: No server code changes needed initially
**Cons**: Network overhead, harder to scale, more fragile
---
## Bot Class Implementation
Each bot class needs:
### Data Definition
```scala
case class BotClass(
name: String,
certifications: Set[Certification],
loadout: Loadout,
experienceLevel: BotExperience, // Newbie, Vet, Ace
primaryRole: BotRole // Driver, Support, Hacker, AV, MAX, Vet, Ace
)
```
### Behavior Weights
```scala
trait BotPersonality {
def aggressionLevel: Float // 0.0 = passive, 1.0 = aggressive
def accuracyBase: Float // Base accuracy modifier
def movementStyle: MovementStyle // Newbie (straight), Vet (ADAD), etc.
def retreatThreshold: Float // HP percentage to retreat
}
```
---
## Communication System
### V-Menu Integration
```scala
// Bot sends voice command
def sendVoiceCommand(cmd: VoiceCommand): Unit = {
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.SendResponse(botGUID, ChatMsg(ChatMessageType.CMT_VOICE, cmd.text))
)
}
// Bot responds to nearby voice commands
def handleVoiceCommand(sender: Player, cmd: VoiceCommand): Unit = cmd match {
case VNG if canBeGunner => respondAndAssist(sender)
case VNH if canHack => respondAndAssist(sender)
case VVV => evaluateHelpRequest(sender)
// etc.
}
```
### Celebration Coordination
```scala
object CelebrationCoordinator {
def onBaseCapture(zone: Zone, faction: PlanetSideEmpire): Unit = {
val eligibleBots = zone.LivePlayers
.filter(_.isBot)
.filter(_.Faction == faction)
.filter(_.isAlive)
val responderCount = Random.nextInt(5) + 1 // 1-6
val responders = Random.shuffle(eligibleBots).take(responderCount)
responders.zipWithIndex.foreach { case (bot, i) =>
val delay = Random.nextFloat() * 1.5f // 0-1.5 seconds
scheduler.scheduleOnce(delay.seconds) {
bot.sendVoiceCommand(randomCelebration())
}
}
}
}
```
---
## Spawn/Despawn Logic
### Dynamic Population Management
```scala
class BotPopulationManager(zone: Zone) {
val targetBotsPerFaction = 100
val minRealPlayersBeforeScaling = 10
def tick(): Unit = {
PlanetSideEmpire.values.foreach { faction =>
val realPlayers = zone.LivePlayers.count(p => !p.isBot && p.Faction == faction)
val currentBots = zone.LivePlayers.count(p => p.isBot && p.Faction == faction)
val targetBots = math.max(0, targetBotsPerFaction - realPlayers)
if (currentBots < targetBots) spawnBots(faction, targetBots - currentBots)
if (currentBots > targetBots) despawnBots(faction, currentBots - targetBots)
}
}
def despawnBots(faction: PlanetSideEmpire, count: Int): Unit = {
// Remove non-Ace bots first, Ace is last to go
val bots = zone.LivePlayers
.filter(p => p.isBot && p.Faction == faction)
.sortBy(b => if (b.botClass == Ace) Int.MaxValue else 0)
.take(count)
bots.foreach(gracefulLogout)
}
}
```
---
## Proof of Concept Milestones
### Milestone 1: Static Bot
- [ ] Create `BotActor` skeleton
- [ ] Spawn a `Player` entity without `SessionActor`
- [ ] Bot appears in zone, visible to clients
- [ ] Bot stands still (no AI)
### Milestone 2: Moving Bot
- [ ] Implement basic movement
- [ ] Bot walks in a pattern
- [ ] `PlayerStateMessage` broadcasts correctly
### Milestone 3: Reactive Bot
- [ ] Detect nearby enemies (vision cone)
- [ ] Turn to face target
- [ ] Basic shooting (ChangeFireStateMessage)
### Milestone 4: Smart Bot
- [ ] Target selection logic
- [ ] Retreat on low HP
- [ ] V-menu help requests
### Milestone 5: Team Bot
- [ ] Follow orders from Ace
- [ ] Respond to V-menu requests
- [ ] Coordinated behavior
---
## Questions for PSForever Devs
1. **Player without SessionActor**: Is this currently possible? What breaks?
2. **Bot flag**: Should we add `isBot: Boolean` to `Player` class?
3. **GUID allocation**: How do we get GUIDs for bot entities?
4. **Zone registration**: What's the proper way to add a player to a zone without client connection?
5. **Existing NPC code**: Is there any other AI code beyond `AutomatedTurretBehavior`?
---
## File Structure (Proposed)
```
src/main/scala/net/psforever/
├── objects/
│ └── bot/
│ ├── Bot.scala # Bot entity (extends Player?)
│ ├── BotClass.scala # Class definitions (Driver, Support, etc.)
│ ├── BotLoadouts.scala # Predefined loadouts per class
│ └── BotPersonality.scala # Behavior weights
├── actors/
│ └── bot/
│ ├── BotActor.scala # Main bot AI actor
│ ├── BotBehavior.scala # Combat/detection trait
│ ├── BotMovement.scala # Movement trait
│ ├── BotObjective.scala # Objective handling trait
│ └── BotManager.scala # Population management
└── services/
└── bot/
├── BotService.scala # Global bot coordination
└── BotVoiceCoordinator.scala # V-menu coordination
```

399
bot-docs/CODEBASE_MAP.md Normal file
View file

@ -0,0 +1,399 @@
# PSF-LoginServer Codebase Map
> **Purpose**: Quick reference for understanding the PlanetSide server emulator codebase.
> This document maps key files, line numbers, and relationships for bot implementation.
## Quick Reference
| Concept | File | Key Lines | Notes |
|---------|------|-----------|-------|
| Player Entity | `objects/Player.scala` | 29-150 | Main player class |
| Player Behavior | `objects/avatar/PlayerControl.scala` | Full file | Akka actor for damage/death/equipment |
| Avatar Data | `objects/avatar/Avatar.scala` | 130-205 | Persistent character data (certs, loadouts) |
| Session Handling | `actors/session/SessionActor.scala` | 99-400 | Network packet processing (NOT needed for bots) |
| Zone Population | `objects/zones/ZonePopulationActor.scala` | 30-85 | Join/Spawn/Leave/Release flow |
| GUID Registration | `objects/guid/GUIDTask.scala` | 185-204 | `registerAvatar()` for full registration |
| Position Broadcast | `services/avatar/AvatarServiceMessage.scala` | 83-97 | `AvatarAction.PlayerState` |
| Turret AI Reference | `objects/serverobject/turret/auto/AutomatedTurretBehavior.scala` | Full file | Example of existing AI pattern |
---
## Core Entity System
### Player.scala
**Path**: `src/main/scala/net/psforever/objects/Player.scala`
```
Line 29-41: class Player extends PlanetSideServerObject with many traits
Line 54-70: Private state (armor, capacitor, exosuit, holsters, inventory)
Line 72-78: Movement state (facingYawUpper, crouching, jumping, cloaked, afk)
Line 123-133: Spawn() - resurrects a dead player
Line 135-139: Die() - kills the player
Line 141-147: Revive() - revives without full reset
```
**Key Traits Mixed In**:
- `Vitality` - health/damage system
- `FactionAffinity` - TR/NC/VS
- `Container` - inventory
- `ZoneAware` - knows which continent it's on
- `MountableEntity` - can sit in vehicles
### Avatar.scala
**Path**: `src/main/scala/net/psforever/objects/avatar/Avatar.scala`
```
Line 85-87: Factory method Avatar(charId, name, faction, sex, head, voice)
Line 130-151: case class Avatar with all fields:
- id: Int (DB row ID - could use negative for bots)
- basic: BasicCharacterData (name, faction, sex, head, voice)
- bep/cep: Experience points
- certifications: Set[Certification]
- implants, shortcuts, locker, deployables
- loadouts, cooldowns, scorecard
Line 155-156: br/cr: Battle Rank and Command Rank derived from BEP/CEP
```
**Important**: `Avatar.id` is described as "unique identifier corresponding to a database table row index". For bots, we can use negative IDs to avoid collision.
### PlayerControl.scala
**Path**: `src/main/scala/net/psforever/objects/avatar/PlayerControl.scala`
This is an **Akka Actor** that handles player behavior.
```
Key behaviors it handles:
- Damage intake and death
- Healing (from equipment, implants)
- Equipment changes
- Environment interaction (water, lava, radiation)
- Jamming effects
```
**Critical**: When `Zone.Population.Spawn()` is called, it creates a `PlayerControl` actor for the player:
```scala
// From ZonePopulationActor.scala line 60-63
player.Actor = context.actorOf(
Props(classOf[PlayerControl], player, avatarActor),
name = GetPlayerControlName(player, None)
)
```
---
## Zone & Population System
### Zone.scala
**Path**: `src/main/scala/net/psforever/objects/zones/Zone.scala`
Key properties:
- `LivePlayers` - list of alive players in zone
- `Corpses` - list of dead player backpacks
- `Population` - ActorRef to ZonePopulationActor
- `AvatarEvents` - ActorRef to AvatarService for broadcasting
- `GUID` - UniqueNumberOps for GUID allocation
### ZonePopulationActor.scala
**Path**: `src/main/scala/net/psforever/objects/zones/ZonePopulationActor.scala`
**This is critical for bot spawning.**
```
Line 31-34: Zone.Population.Join(avatar)
- Adds avatar.id to playerMap with None value
- Starts player management systems if first player
Line 36-50: Zone.Population.Leave(avatar)
- Removes from playerMap
- Cleans up player position, block map
Line 52-71: Zone.Population.Spawn(avatar, player, avatarActor)
- Associates player with avatar in playerMap
- Sets player.Zone = zone
- CREATES PlayerControl actor (line 60-63)
- Adds to block map
Line 73-84: Zone.Population.Release(avatar)
- Disassociates player from avatar
- Used when player dies/respawns
```
---
## GUID System
### GUIDTask.scala
**Path**: `src/main/scala/net/psforever/objects/guid/GUIDTask.scala`
```
Line 72-73: registerObject() - basic GUID registration
Line 102-107: registerTool() - register weapon + ammo boxes
Line 125-130: registerEquipment() - dispatches to appropriate registrar
Line 200-204: registerPlayer() - registers player + holsters + inventory
Usage:
TaskWorkflow.execute(GUIDTask.registerPlayer(zone.GUID, player))
```
---
## Broadcasting System
### AvatarServiceMessage.scala
**Path**: `src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala`
```
Line 20: AvatarServiceMessage(forChannel: String, actionMessage: AvatarAction.Action)
Line 83-97: AvatarAction.PlayerState - position/orientation broadcast
Fields: player_guid, pos, vel, facingYaw, facingPitch,
facingYawUpper, timestamp, is_crouching, is_jumping,
jump_thrust, is_cloaked, spectator, weaponInHand
```
**To broadcast bot position**:
```scala
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.PlayerState(
player.GUID,
player.Position,
Some(velocity),
facingYaw, facingPitch, facingYawUpper,
timestamp,
is_crouching, is_jumping, jump_thrust,
is_cloaked, spectator = false, weaponInHand
)
)
```
### Other Important AvatarActions
```
AvatarAction.LoadPlayer - Creates player on clients (spawn)
AvatarAction.ObjectDelete - Removes player from clients (despawn)
AvatarAction.ChangeFireState_Start/Stop - Weapon firing
AvatarAction.Killed - Death notification
AvatarAction.HitHint - Damage indicator
```
---
## Existing AI Pattern
### AutomatedTurretBehavior.scala
**Path**: `src/main/scala/net/psforever/objects/serverobject/turret/auto/AutomatedTurretBehavior.scala`
**NOT the behavior we want** (turrets are mechanical), but useful as a **technical reference** for:
- Akka actor message patterns
- Target tracking state management
- Periodic checks via scheduler
- Damage/retaliation responses
```
Line 26-27: trait AutomatedTurretBehavior { _: Actor with DamageableEntity =>
Shows how to mix behavior into an actor
Line 51-54: Timer pattern for periodic checks:
context.system.scheduler.scheduleWithFixedDelay(...)
Line 145-154: Target list management (AddTarget, RemoveTarget, Detected)
Line 245-258: Engagement flow (engageNewDetectedTarget)
```
---
## Session System (Reference Only)
Bots don't need SessionActor, but understanding it helps:
### SessionActor.scala
**Path**: `src/main/scala/net/psforever/actors/session/SessionActor.scala`
```
Line 99: class SessionActor - receives from middlewareActor (network)
Line 115: def receive = startup (initial state)
Line 168-171: parse() - handles PlanetSideGamePacket from client
Line 372+: handleGamePkt() - dispatches packets to handlers
```
**Key insight**: SessionActor's job is handling network I/O. Bots generate actions internally, so we don't need this.
### AvatarActor.scala
**Path**: `src/main/scala/net/psforever/actors/session/AvatarActor.scala`
Handles avatar persistence, certification management, loadouts, etc.
```
Line 70-88: Factory and commands
Line 81: apply() creates the actor with sessionActor reference
```
**For bots**: We need a stub `BotAvatarActor` that accepts the messages `PlayerControl` might send but doesn't persist anything.
---
## SpawnPoint System
### SpawnPoint.scala
**Path**: `src/main/scala/net/psforever/objects/SpawnPoint.scala`
```
Line 11-70: trait SpawnPoint
- GUID, Position, Orientation
- SpecificPoint(target) -> (Vector3, Vector3) for spawn pos/orient
Line 72-160: object SpawnPoint
- Default, Tube, AMS, Gate spawn point calculations
```
---
## File Locations Summary
```
src/main/scala/net/psforever/
├── actors/
│ ├── session/
│ │ ├── SessionActor.scala # Network session (NOT for bots)
│ │ ├── AvatarActor.scala # Avatar persistence
│ │ └── support/
│ │ ├── SessionData.scala # Session state
│ │ └── ZoningOperations.scala # Spawn flow reference
│ └── zone/
│ └── ZoneActor.scala # Zone management
├── objects/
│ ├── Player.scala # Player entity
│ ├── SpawnPoint.scala # Spawn locations
│ ├── avatar/
│ │ ├── Avatar.scala # Character data
│ │ └── PlayerControl.scala # Player behavior actor
│ ├── guid/
│ │ └── GUIDTask.scala # GUID registration
│ ├── serverobject/turret/auto/
│ │ └── AutomatedTurretBehavior.scala # AI reference
│ └── zones/
│ ├── Zone.scala # Zone class
│ └── ZonePopulationActor.scala # Population management
└── services/
└── avatar/
├── AvatarService.scala # Broadcasting service
└── AvatarServiceMessage.scala # Message definitions
```
---
## Quick Patterns
### Spawn a Player (Derived from Real Flow)
```scala
// 1. Create avatar
val avatar = Avatar(botId, name, faction, sex, head, voice)
// 2. Create player
val player = new Player(avatar)
player.Position = spawnPosition
player.Spawn()
// 3. Register GUIDs
TaskWorkflow.execute(GUIDTask.registerPlayer(zone.GUID, player))
// 4. Join zone
zone.Population ! Zone.Population.Join(avatar)
// 5. Spawn (creates PlayerControl)
zone.Population ! Zone.Population.Spawn(avatar, player, botAvatarActor)
// 6. Broadcast existence
zone.AvatarEvents ! AvatarServiceMessage(zone.id, AvatarAction.LoadPlayer(...))
```
### Broadcast Position
```scala
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.PlayerState(player.GUID, pos, vel, yaw, pitch, yawUpper,
timestamp, crouch, jump, thrust, cloak, false, weaponOut)
)
```
### Despawn a Player
```scala
zone.Population ! Zone.Population.Leave(avatar)
zone.AvatarEvents ! AvatarServiceMessage(zone.id, AvatarAction.ObjectDelete(guid, guid))
TaskWorkflow.execute(GUIDTask.unregisterAvatar(zone.GUID, player))
```
---
## Bot Implementation - Lessons Learned
### Critical: Use `registerAvatar` NOT `registerPlayer`
`PlayerControl` creates a `LockerContainerControl` actor in its constructor (line 74-80) which calls `PlanetSideServerObject.UniqueActorName(locker)`. This requires `locker.GUID` to be assigned.
- `registerPlayer()` skips locker registration
- `registerAvatar()` includes locker registration
- **Always use `registerAvatar()` for bots**
### Critical: Avatar IDs Must Be Positive
Packet encoding uses 32-bit unsigned integers. Negative IDs cause:
```
ERROR: basic_appearance/a/unk6: -1 is less than minimum value 0
```
**Solution**: Use high positive IDs (900000+) to avoid collision with real DB player IDs.
### Critical: GUID Registration is Async
`TaskWorkflow.execute()` returns a `Future[Any]`. You MUST wait for completion before:
- Accessing `player.GUID`
- Calling `Zone.Population.Spawn()`
- Broadcasting `LoadPlayer`
```scala
TaskWorkflow.execute(GUIDTask.registerAvatar(zone.GUID, player)).onComplete {
case Success(_) =>
// NOW safe to use player.GUID and spawn
self ! CompleteSpawn(...)
case Failure(ex) =>
log.error(s"GUID registration failed: ${ex.getMessage}")
}
```
### AvatarActor Must Be Typed
`Zone.Population.Spawn` expects `ActorRef[AvatarActor.Command]` (typed), not classic `ActorRef`.
Use Akka typed actor system:
```scala
val typedSystem = context.system.toTyped
val botAvatarActor: ActorRef[AvatarActor.Command] =
typedSystem.systemActorOf(BotAvatarActor(), s"bot-avatar-$botId")
```
### Bot Files Created
```
src/main/scala/net/psforever/actors/bot/
├── BotAvatarActor.scala # Stub typed actor - absorbs AvatarActor messages
└── BotManager.scala # Spawns/manages bots, handles async GUID flow
```
### Working Spawn Flow (Verified)
```scala
// 1. Create avatar with HIGH POSITIVE ID
val avatar = Avatar(900000, BasicCharacterData(name, faction, sex, head, voice))
// 2. Create player
val player = new Player(avatar)
player.Position = position
player.Spawn()
// 3. Create typed stub avatar actor
val botAvatarActor = typedSystem.systemActorOf(BotAvatarActor(), name)
// 4. Register GUIDs (ASYNC!) - includes locker
TaskWorkflow.execute(GUIDTask.registerAvatar(zone.GUID, player)).onComplete {
case Success(_) =>
// 5. Join zone
zone.Population ! Zone.Population.Join(avatar)
// 6. Spawn (creates PlayerControl)
zone.Population ! Zone.Population.Spawn(avatar, player, botAvatarActor)
// 7. Broadcast to clients
zone.AvatarEvents ! AvatarServiceMessage(zone.id, AvatarAction.LoadPlayer(...))
}
```

123
bot-docs/DEV_SETUP.md Normal file
View file

@ -0,0 +1,123 @@
# Development Environment Setup
> Verified working on: Ubuntu 22.04 (Linux Mint 21.3 Virginia)
## Requirements
| Component | Version | Notes |
|-----------|---------|-------|
| Java | JDK 8 (1.8.0_xxx) | **Must be Java 8** - other versions fail |
| sbt | 1.8.2+ | Scala build tool |
| PostgreSQL | 14+ | Database |
## Installation (Ubuntu/Mint)
### 1. Java 8
```bash
sudo apt update
sudo apt install -y openjdk-8-jdk
# Set as default
sudo update-alternatives --set java /usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java
sudo update-alternatives --set javac /usr/lib/jvm/java-8-openjdk-amd64/bin/javac
# Verify
java -version
# Should show: openjdk version "1.8.0_xxx"
```
### 2. sbt
```bash
echo "deb https://repo.scala-sbt.org/scalasbt/debian all main" | sudo tee /etc/apt/sources.list.d/sbt.list
curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x99E82A75642AC823" | sudo apt-key add -
sudo apt update
sudo apt install -y sbt
```
### 3. PostgreSQL
```bash
sudo apt install -y postgresql postgresql-contrib
sudo systemctl start postgresql
sudo systemctl enable postgresql
# Create database and user
sudo -u postgres psql -c "CREATE USER psforever WITH PASSWORD 'psforever';"
sudo -u postgres psql -c "CREATE DATABASE psforever OWNER psforever;"
```
## Building & Running
### First Compile
```bash
cd PSF-LoginServer
sbt compile
```
- Takes ~2-3 minutes on first run (downloads dependencies)
- Expect some warnings (unused imports, non-exhaustive matches) - these are fine
### Run Server
```bash
sbt "server/run"
```
**Expected behavior:**
- Terminal stays open with live debug output
- Shows player connections, actions, etc. in real-time
- Does NOT return to prompt - this is correct
**Expected startup output:**
```
PSForever Server - PSForever Project
Login server is running on 127.0.0.1:51000
```
### Known Startup Errors (IGNORE)
```
ERROR akka.actor.SupervisorStrategy - null
java.lang.NullPointerException: null
at net.psforever.objects.guid.GUIDTask$RegisterObjectTask.action(GUIDTask.scala:44)
...
at net.psforever.objects.serverobject.shuttle.OrbitalShuttlePadControl...
```
These are **HART shuttle (orbital pad) initialization errors** - known issue, doesn't affect gameplay or our bot work.
## Server Ports
| Port | Protocol | Purpose |
|------|----------|---------|
| 51000 | TCP | Login server |
| 51001 | UDP | World server |
## Connecting a Client
1. Use PlanetSide client version 3.15.84.0
2. Point client at server IP (127.0.0.1 for local)
3. Create account on first login (auto-created)
## Running in Background
```bash
# Start in background
nohup sbt "server/run" > server.log 2>&1 &
# View logs
tail -f server.log
# Find and kill
ps aux | grep sbt
kill <PID>
```
## Troubleshooting
### "authentication method 10 not supported"
PostgreSQL version too old or password encryption mismatch. Upgrade PostgreSQL or check `postgresql.conf`.
### Java version errors
Make sure `java -version` shows 1.8.x. Use `update-alternatives` to switch if needed.
### Port already in use
```bash
sudo lsof -i :51000
# Kill the process using the port
```

414
bot-docs/GAME_FEEL.md Normal file
View file

@ -0,0 +1,414 @@
# PlanetSide Bot Behavior & Game Feel
## Vision System
**Field of View**: 60 degrees horizontal and vertical
- Adjustable variable, expected final range: 60-90 degrees
- Bots cannot see beyond this cone - must turn to acquire targets
- Creates realistic "blind spots" and flanking opportunities for players
### Spotting Mechanics
**Full Spot**: Clear visual on target, can identify and engage
- Time to acquire scales with distance:
- Close range: Very fast (nearly instant)
- Long range: Takes longer to confirm target
**Partial Spot**: Know something is there but can't fully engage
- Target went behind cover (saw them go there)
- See tracers coming from a direction (investigate)
- Heard gunfire/explosions from location
- **Behavior**: Move towards OR around to get better angle
---
## Core Decision Logic
```
SPOTTED TARGET?
├── Partial spot?
│ └── Move towards target OR move around target (reposition for better view)
├── Full spot?
│ └── ATTACK or COMPLETE OBJECTIVE
│ (enter vehicle, unlock door, hack terminal, etc.)
└── Cannot proceed?
└── REQUEST HELP (V-menu)
└── State what you need (VNG, VNH, etc.)
```
---
## V-Menu Communication System
Bots use the in-game quick voice system to communicate, creating authentic battlefield chatter.
### Voice Commands Used
**Requests & Responses**
| Command | Meaning | Use Case |
|---------|---------|----------|
| VVV | "HELP!" | General distress, need assistance |
| VNG | "Need Gunner" | Vehicle waiting for gunner before proceeding |
| VNH | "Need Hacker" | Door locked, can't proceed, need hacker |
| VVY | "Yes" | Acknowledging help request, on my way |
| VVB | Taunt (variant B) | Vengeance kill, celebration |
| VVZ | Taunt (variant Z) | Vengeance kill, celebration |
**Warnings (Intel Sharing)**
| Command | Meaning | Use Case |
|---------|---------|----------|
| VWA | "Warning: Aircraft!" | Enemy air incoming, triggers AA MAX spawns |
| VWV | "Warning: Vehicles!" | Enemy armor incoming, triggers AV MAX spawns |
| VWT | "Warning: Troops!" | Enemy infantry push, triggers AI MAX spawns |
### Help Request Flow
1. Bot encounters obstacle it cannot handle alone
2. Bot broadcasts appropriate V-menu request
3. Capable bots in range evaluate:
- Can I help with this? (Do I have the cert/ability?)
- Can I path to them? (Pull `/loc` of requester, check route)
4. If yes to both:
- Respond with `VVY`
- Begin movement to assist
5. **Result**: Organic teamwork feel, tactical advantage, immersion
---
## Bot Classes
PlanetSide 1 has no rigid classes, but bots need defined roles for sanity. Each class is a **pre-built loadout** with associated behavior logic.
### Class Definitions
| Class | Role | Key Abilities | Notes |
|-------|------|---------------|-------|
| **Driver** | Vehicle operation | Drive/pilot vehicles | Low priority on VNG (see below) |
| **Support** | Heal & Repair | Medical applicator, BANK/Nano repair | LOVES gunning (can heal vehicle) |
| **Hacker** | Infiltration & Capture | REK, door unlocking, base/tower/vehicle hacking | Critical for objective play |
| **AV** | Anti-Vehicle | Decimator, Lancer, Phoenix, Striker? | Tank hunters |
| **MAX** | Heavy Exosuit | Faction MAX suits (AI/AV/AA variants) | Walking tanks |
| **Vet** | Versatile Veteran | HA + AV + Basic Hacking + Basic Support | Jack of all trades |
| **Ace** | Empire Leader | Best at everything + Command authority | ONE per empire, special role |
### Vet Class Details
- Can do *most* things but slower/weaker than specialists
- Won't get stuck at locked doors (can hack, just slower)
- Won't panic at incoming tank (has AV, can fight back)
- Field-flexible, self-sufficient
- Good baseline "competent soldier" behavior
### Ace Class Details
- One per empire (TR Ace, NC Ace, VS Ace)
- **Last bot to logout** as real players join
- Uses **Command Chat** (CR3+ channel) for tactical orders
- Makes strategic decisions for bot team:
- Where to attack
- Where to defend
- Force distribution
- **CRITICAL**: Defers to real players
- Won't overstep human commanders
- Offers advice if no player is commanding
- Lets players make the calls when they want to
### MAX Class Details
**Situational Loadout Switching** based on intel:
- **Indoors** (last death location or teammate intel): AI MAX
- **VWV received** (Warning: Vehicles): AV MAX
- **VWA received** (Warning: Aircraft): AA MAX
- **VWT received** (Warning: Troops) + outdoors: Context-dependent
This creates reactive, intelligent heavy support that adapts to battlefield needs.
### Driver & Gunner Dynamics
- **Anyone** can be a gunner - it's not role-restricted
- **Drivers** respond to VNG but low priority (they want to drive)
- **Support** actively seeks gunner seats - can heal vehicle while gunning
- **Drivers** can also heal their own vehicle but teamwork = faster repairs
- Vehicles wait for gunner before proceeding (VNG call) for effectiveness
---
## Attitude & Vengeance System
Gives Vet+ bots personality and memorable behavior.
### Attitude Stat
- Internal emotional state of the bot
- Affects decision-making and aggression
### Vengeance Mechanic
1. **Death Memory**: Vet remembers where they died and who killed them
2. **Revenge Decision**: Based on attitude, may decide to hunt killer
3. **Constraints**:
- Must be reasonable range (same base spawn area)
- No cross-continent adventures
4. **On Successful Revenge**:
- Taunt victim (VVB or VVZ)
- Attitude decreases (calms down)
5. **On Repeated Death (no revenge)**:
- Attitude increases ("rage" building)
- **Rage Effects**:
- Increased accuracy
- Increased aggression
6. **Rage Reset Conditions**:
- Achieve vengeance
- Get a 3-kill streak
- Participate in base capture
---
## Movement Patterns
Movement varies by experience level and empire. Critical for not looking like a bot.
### By Experience Level
**New Players / Basic Bots**
- Run mostly straight at target
- Held strafe to maintain firing angle (not ADAD, just constant drift)
- Minimal evasion, focused on keeping crosshairs on target
**Vets & Aces**
- Heavy ADAD strafing (left-right-left-right)
- Combined with crouch spam during firefights
- Much harder to hit, more "tryhard" movement
### Empire-Specific Movement
- **NC**: Benefits from closing distance (shotguns) - more aggressive advance
- **TR/VS**: TBD - circle back when adding faction identity
### What NOT To Do
- **Almost no jumping** - PS players don't bunny hop
- Exception: MAXes might jump to dodge AV rockets (ADVANCED - maybe skip for v1)
- Robotic pathing, perfect angles, inhuman reaction times
### Tuning Note
> "I don't know what [bot movement] would feel like yet, we've never had bots before. I will help dial this in with many many play tests."
Movement feel will require extensive playtesting iteration.
---
## Retreat & Self-Preservation
### When Bots Retreat
| Class | Retreat Trigger |
|-------|-----------------|
| **Vets** | Low HP |
| **Drivers** | Low vehicle HP (save the vehicle!) |
| **Everyone** | Out of ammo (not just reloading - actually empty) |
### Order Context Matters
**If orders = DEFEND:**
- Stay at position no matter what (unless need supplies)
- Quick runs to spawn room / nearest terminal for ammo/resupply
- Return immediately to defensive position
**If orders = ATTACK:**
- More flexible retreat for self-preservation
- Regroup and push again
### What Retreat Looks Like
- Not a panicked sprint
- Backing away while facing threat when possible
- Finding cover, then breaking line of sight
---
## Combat & Accuracy System
Bot shooting should feel natural, not robotic. Two opposing forces create a "sweet spot" in sustained fire.
### Target Recognition Time
Before engaging, bots must **recognize** their target. Time to recognize scales with distance:
| Distance | Recognition Time | Notes |
|----------|-----------------|-------|
| **Close** (< 15m) | Near instant | Light them up immediately |
| **Medium** (15-50m) | 0.3-0.8 sec | Brief pause, then engage |
| **Far** (50m+) | 1-2 sec | Takes time to confirm target |
| **Obscured** | Investigate | Move to get better angle (defer to navmesh) |
### Accuracy System (Two Forces)
**Force 1: Adjustment (Accuracy improves)**
- First shots are inaccurate (bot is "dialing in" aim)
- Accuracy increases with each shot as bot finds the target
- Close targets = higher base accuracy, faster adjustment
- Far targets = lower base accuracy, slower adjustment
**Force 2: Recoil (Spread worsens)**
- Each shot adds recoil
- Recoil accumulates, making spread worse over time
- Creates "diminishing returns" on sustained fire
### The Sweet Spot
```
Accuracy over time during sustained fire:
↑ Accuracy
│ ╭──────╮
╲ ← recoil takes over
│╱ ← adjusting aim
└────────────────────────→ Time/Shots
First Middle Late
shots shots shots
(miss) (HIT!) (spray)
```
**Result by distance:**
- **Close range**: High base accuracy + fast adjustment = nearly all shots hit
- **Medium range**: Sweet spot matters - middle of burst is most dangerous
- **Long range**: May only land a few hits early (lucky) or middle (adjusted)
### Class Modifiers (Future)
| Class | Accuracy Modifier | Notes |
|-------|------------------|-------|
| Basic/Newbie | 1.0x (baseline) | Average accuracy |
| Vet | 1.3x accuracy, 0.8x recoil | Much better aim |
| Ace | 1.5x accuracy, 0.7x recoil | Best of the best |
| Support | 0.9x accuracy | Focused on healing |
| MAX | 0.8x accuracy, 1.2x recoil | Big guns, more spray |
### Practical Implications
1. **Players should win fair fights** - bots aren't aimbots
2. **Getting close is dangerous** - bots don't miss at point blank
3. **Distance = safety** - but not immunity (lucky shots happen)
4. **Burst fire beats spray** - short controlled bursts reset recoil
5. **Flanking works** - recognition time gives advantage
---
## The Chaos Factor
> "Spam? Spam. Be it bullets, grenades, the voice menus it's all about the chaotic spam. It's a war unlike the world has ever recreated."
### What Makes PS Fights Feel Like PS
- **Bullet spam**: Walls of tracers, suppressive fire is real
- **Grenade spam**: Explosions everywhere
- **Voice spam**: V-menu callouts constantly firing
- **Scale**: Hundreds of participants, not 32v32
### Bot Contribution to Chaos
- Bots should ADD to the chaos, not feel sterile
- Coordinated V-menu chatter (see calibration below)
- Miss shots (don't be laser accurate)
- React to nearby explosions/deaths
- Fire at enemies even when hit chance is low (suppression)
---
## Damage Reaction
How bots respond to taking damage depends on context.
### In Combat (Already Engaged)
- **Direct bullet hit from new direction**: May turn toward new threat OR smart-switch targets
- **Explosions/grenades**: Ignore direction (damage comes from everywhere) - stay on current target
- **Decision**: Stick with current target vs switch based on threat assessment
### Out of Combat (Doing Task)
Example: Repairing a vehicle, then gets shot
1. **PANIC** - immediate state change
2. **Run to cover** - most likely the vehicle they were repairing
3. **Swap to weapon** while running
- Drivers: Have sidearm ready (fast)
- Support: Must swap from repair tool (slower, panicked weapon swap animation)
4. **Engage threat** once in cover with weapon out
### Key Feel
- The "panic run while swapping weapons" is authentic PS behavior
- Not a clean tactical response - messy, human reaction
- Repair slot = weapon slot means class differences in response time
---
## V-Menu Calibration
Balancing authentic chatter vs annoying spam.
### Always Fire (Functional)
| Type | When | Purpose |
|------|------|---------|
| **Needs** (VNG, VNH, VVV) | When actually needed | Gameplay function |
| **Warnings** (VWA, VWV, VWT) | When threat spotted | Intel sharing |
| **Acknowledgment** (VVY) | When responding to request | Coordination |
### Rare/Conditional
| Type | When | Frequency |
|------|------|-----------|
| **Taunts** (VVB, VVZ) | Vengeance kills, domination | Low |
| **Celebrations** (VVF, VVE) | Exceptional events only | Very controlled |
### Celebration Events
Triggers for VVF ("Fantastic!") and VVE ("Excellent!"):
- Pesky vehicle destroyed
- Killed someone with 10+ kill streak
- Base capture
### Coordinated Celebration System
**Problem**: 50 bots all saying "VVE!" at once = annoying
**Solution**: Backend coordination
```
EVENT: Base captured
1. RNG determines responder count (1-6 bots)
2. Eligible bots (alive, in range) "claim" slots via backend
3. Each responder gets RNG delay timer
4. Staggered, natural response
EXAMPLE TIMELINE:
0.01s - Bot 1: "VVE" (Excellent!)
0.30s - Bot 2: "VVF" (Fantastic!)
1.20s - Bot 3: "VVF"
1.30s - Bot 4 (Vet): "VVB VVF" ("You can't beat me." + "Outstanding!")
```
### Vet/Ace Flavor
- Vets and Aces can do **combo callouts** (taunt + celebration)
- Adds personality, shows experience
- E.g., "You can't beat me." followed by "Outstanding!"
---
## Open Questions / Needs Expansion
### Resolved
- [x] "Partial spot" vs full spot - distance + time based, also tracers/intel
- [x] Driver class VNG response - low priority, anyone can gun
- [x] MAX variants - switches based on intel (VWA/VWV/VWT, indoor/outdoor)
- [x] Movement patterns - ADAD for vets, straight-run for newbies
- [x] Retreat behavior - HP, ammo, vehicle HP triggers
### Deferred (Future Phases)
- [ ] **FACTION TACTICS**: TR vs NC vs VS combat style differences (post-v1)
- [ ] **MAX jumping to dodge AV**: Advanced behavior, maybe skip
### Resolved (Round 2)
- [x] **REACTION TO DAMAGE**: Context-dependent (see Damage Reaction section)
- [x] **V-menu calibration**: Coordinated celebration system (see V-menu section)
---
## Implementation Notes
### Server Integration Questions
- Can bots trigger V-menu voice commands through server?
- Is Command Chat accessible for bot messages?
- Is CR (Command Rank) system implemented in emulator?
### Technical Considerations
- `/loc` command - server-side coordinate access for pathfinding
- Death tracking - need to log killer + location per bot
- Attitude persistence - per-session or reset on logout?

313
bot-docs/HANDOFF.md Normal file
View file

@ -0,0 +1,313 @@
# PlanetSide Bots - Handoff Document
> **Read this first** if you're a new Claude instance or agent picking up this project.
## What Is This Project?
We're adding **bot players** to PlanetSide 1 via the PSForever server emulator. The goal is to make the game world feel alive even with low real player population.
### The Vision
- Hundreds of bots per server (scaling down as real players join)
- Bots that feel like real players, not mechanical turrets
- V-menu chatter, teamwork, attitude/personality systems
- Infantry first, vehicles later
### Key Stakeholder
The user is a **veteran PlanetSide player** (decade+ experience) and serves as the expert on "game feel". They have detailed notes on how bots should behave. Always defer to them on gameplay questions.
---
## Project Structure
```
PSF-LoginServer/
├── bot-docs/ ← Bot project documentation
│ ├── HANDOFF.md ← YOU ARE HERE - Start here
│ ├── PROJECT.md ← Overview, goals, status
│ ├── GAME_FEEL.md ← Behavioral spec (vision, movement, V-menu, classes, etc.)
│ ├── ARCHITECTURE.md ← Technical design decisions
│ ├── CODEBASE_MAP.md ← Key files with line numbers (CRITICAL REFERENCE)
│ ├── POC_PLAN.md ← Phased implementation milestones
│ ├── DEV_SETUP.md ← Development environment setup
│ └── SKETCHES/ ← Conceptual code (not production)
│ ├── BotActor_v1.scala
│ └── BotSpawner_v1.scala
├── src/main/scala/net/psforever/
│ └── actors/bot/ ← Bot implementation code
│ ├── BotManager.scala
│ └── BotAvatarActor.scala
└── ... ← Rest of PSForever server codebase
```
### Read Order for New Instance
1. **HANDOFF.md** (this file) - Context and decisions
2. **CODEBASE_MAP.md** - Where things are in the code
3. **GAME_FEEL.md** - How bots should behave
4. **SKETCHES/** - Conceptual code to understand approach
---
## Key Technical Decisions Made
### 1. BotActor Approach (NOT SessionActor)
We decided to create a new `BotActor` rather than emulating `SessionActor` because:
- SessionActor's main job is handling network packets from clients
- Bots have no client, so no incoming packets
- BotActor can be optimized specifically for AI needs
- Less overhead, better scalability
### 2. Server-Side Native Bots
Bots are first-class server entities, not fake clients connecting from outside.
- Bots use existing `Player` and `PlayerControl` code
- No network overhead
- Actions broadcast through same services as real players
### 3. Bot Identity
Bot characters use:
- **High positive IDs** (900000+) to avoid collision with real DB players
- **No special characters in names** - use `xxBOTxxName` format (not `[BOT]Name`)
- Predefined loadouts per class
### 4. Tick Rate
- 10-20 FPS for bot AI decisions (not 60)
- Can reduce further for bots far from real players
- Server load monitoring will inform final values
---
## Bot Architecture Summary
```
BotManager (per zone)
├── Monitors real player population
├── Spawns/despawns bots to maintain target count
└── Manages one Ace per faction (last to logout)
BotActor (per bot)
├── 10 FPS tick loop
├── Vision cone detection (60-90 degrees)
├── Decision making (attack, retreat, objective, help)
├── Movement execution
├── State broadcasting
└── Controls a Player entity
BotAvatarActor (stub)
└── Minimal - just accepts messages PlayerControl sends
Player + PlayerControl (existing code)
├── Player entity with Vitality, inventory, etc.
└── PlayerControl handles damage/death (already exists)
```
---
## Spawn Flow (Critical Path)
```scala
// 1. Create avatar (HIGH POSITIVE ID - 900000+)
val avatar = Avatar(900000 + botNum, BasicCharacterData(name, faction, sex, head, voice))
// 2. Create player entity
val player = new Player(avatar)
player.Position = spawnPosition
player.Spawn()
// 3. Create typed stub avatar actor (MUST be typed ActorRef)
val typedSystem = context.system.toTyped
val botAvatarActor: ActorRef[AvatarActor.Command] =
typedSystem.systemActorOf(BotAvatarActor(), s"bot-avatar-$botId")
// 4. Register GUIDs (ASYNC - use registerAvatar, NOT registerPlayer!)
TaskWorkflow.execute(GUIDTask.registerAvatar(zone.GUID, player)).onComplete {
case Success(_) =>
// 5. Join zone population
zone.Population ! Zone.Population.Join(avatar)
// 6. Spawn player (creates PlayerControl actor)
zone.Population ! Zone.Population.Spawn(avatar, player, botAvatarActor)
// 7. Broadcast to clients
zone.AvatarEvents ! AvatarServiceMessage(zone.id, AvatarAction.LoadPlayer(...))
case Failure(ex) =>
log.error(s"Failed: ${ex.getMessage}")
}
```
---
## Broadcasting (How Bots Appear to Real Players)
Bots broadcast their state via the same channels real players use:
```scala
// Position updates (10-20 times per second)
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.PlayerState(guid, pos, vel, facing, crouch, jump, ...)
)
// Weapon fire
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.ChangeFireState_Start(playerGuid, weaponGuid)
)
// V-menu voice commands
// (Need to investigate ChatMsg format)
```
---
## Bot Classes (From User's Notes)
| Class | Role | Behavior |
|-------|------|----------|
| Driver | Vehicle operation | Low priority for gunning, wants to drive |
| Support | Heal & Repair | LOVES gunning (can heal vehicle), prioritizes healing |
| Hacker | Infiltration | Door unlocking, base capture |
| AV | Anti-Vehicle | Tank hunters |
| MAX | Heavy Exosuit | AI/AV/AA variants, switches based on intel |
| Vet | Versatile | Jack of all trades, ADAD movement, vengeance system |
| Ace | Empire Leader | ONE per empire, last to logout, uses Command Chat |
---
## Implementation Status
### COMPLETED (POC Working!)
- [x] BotAvatarActor stub (typed actor, absorbs messages)
- [x] BotManager (spawns bots, handles async GUID flow)
- [x] `!bot` chat command for testing
- [x] Bot spawns visible to players
- [x] Bot takes damage from players (PlayerControl works!)
- [x] Bot movement (10 tick/sec, random wandering, PlayerState broadcasts)
- [x] Multiple bots work independently
### KNOWN ISSUES (Expected for POC)
- No backpack on death (likely because bot has no items/loadout)
- Walks through walls (no collision detection)
- No Z-height adjustment (melts into stairs, terrain)
- Walk speed slightly fast for animation (4 units/sec, try 3)
### NOT YET IMPLEMENTED
- [ ] Bot loadout/equipment
- [ ] Bot shooting (weapon fire broadcasts)
- [ ] Bot death/respawn cycle
- [ ] Terrain following (Z height from map data)
- [ ] Collision avoidance
- [ ] Pathfinding
- [ ] V-menu voice command sending
- [ ] Celebration coordination system
- [ ] Vengeance/attitude system
- [ ] Population scaling (spawn/despawn based on real players)
---
## Resolved Questions (Through Trial & Error)
1. **Avatar IDs**: Must be POSITIVE. Negative IDs break packet encoding (32-bit unsigned). Use 900000+.
2. **PlayerControl + stub AvatarActor**: WORKS! BotAvatarActor just absorbs messages, PlayerControl functions normally.
3. **GUID Registration**: Must use `registerAvatar()` not `registerPlayer()` - locker needs GUID for PlayerControl init.
## Open Questions for PSForever Devs
1. **Bot identification**: Should we add `isBot: Boolean` to Player?
2. **Spawn point access**: Best way to find valid faction spawn locations?
---
## Spawn Location Logic (For Future Implementation)
When a player dies, they choose from up to 3 spawn options (all must be friendly-owned):
1. **Nearest Tower** - Guard towers scattered across continents
2. **Nearest AMS** - Advanced Mobile Station (deployable spawn vehicle)
3. **Nearest Base/Facility** - The main base spawn tubes
**Special Cases:**
- **Sanctuary** (home2, home1, home3): Only one spawn option (the sanctuary itself)
- **Warpgates**: Can spawn at warpgates your faction owns
**For Bots:**
- Currently: Respawn at original spawn position (simple)
- Future: Query zone for valid faction spawn points, pick nearest or random
- Need to find: `SpawnPoint` instances, check faction ownership, calculate distance
**Code References to Investigate:**
- `SpawnPoint.scala` - Spawn point trait and calculations
- `Zone.spawnGroups` - Map of building -> spawn points
- `Building.Faction` - Check ownership for spawn eligibility
---
## Important Behavioral Notes
### From GAME_FEEL.md (Key Points)
- **Vision**: 60-90 degree FOV, distance-based spotting time
- **Movement**: Newbies run straight, Vets do ADAD + crouch spam
- **Retreat**: Vets retreat at low HP, Drivers save vehicles, everyone retreats when out of ammo
- **V-menu**: "Needs" always fire, celebrations are coordinated (1-6 responders, staggered timing)
- **Vengeance**: Vets remember who killed them, may seek revenge, taunt on success
- **Panic**: Non-combat bots (repairing) panic when shot, run to cover while swapping weapons
- **Chaos**: The goal is authentic battlefield chaos - spam bullets, grenades, voice commands
### What NOT to Do
- No bunny hopping (almost no one jumps except MAX dodging AV)
- No laser accuracy (bots should miss)
- No instant reactions (bots need reaction time)
- No perfect coordination (spread out, don't stand on same pixel)
- No mechanical behavior (turrets are terrible, we want human-like chaos)
---
## Development Environment
```bash
# Server codebase
cd PSF-LoginServer
sbt compile # Compile
sbt server/run # Run server
# Requirements
- Java 8 JDK
- sbt (Scala Build Tool)
- PostgreSQL 10+
- PlanetSide client (version 3.15.84.0)
```
---
## Context for Future Agents
If you're a **coding agent** tasked with implementation:
1. Read `CODEBASE_MAP.md` for file locations
2. Read `SKETCHES/` for conceptual starting points
3. Start with Milestone 1: spawn a static bot that appears in-game
4. Test empirically - compile, run, connect with client, observe
If you're a **research agent** investigating something:
1. The PSF-LoginServer codebase is in `./PSF-LoginServer/`
2. All server code is under `src/main/scala/net/psforever/`
3. Tests are under `src/test/scala/`
4. Use grep/glob to find patterns
If the user says **"update the docs"**:
1. Update the relevant .md files
2. Keep `CODEBASE_MAP.md` current with any new discoveries
3. Add line numbers when referencing code
---
## Last Session Summary
**Date**: 2024 (context creation date)
**Accomplished**:
- Analyzed PSF-LoginServer codebase architecture
- Mapped spawn/broadcast/GUID flows
- Created behavioral spec from user's notes
- Sketched BotActor and BotSpawner concepts
- Documented key files with line numbers
**Next Steps**:
- Set up dev environment
- Start Phase 1: spawn a static bot
- OR wait for dev team answers on open questions

291
bot-docs/POC_PLAN.md Normal file
View file

@ -0,0 +1,291 @@
# PlanetSide Bots - Proof of Concept Plan
## Goal
Get a single bot to spawn, be visible to clients, and perform basic actions.
---
## Phase 0: Research & Setup (DONE)
- [x] Clone PSF-LoginServer
- [x] Analyze codebase architecture
- [x] Identify Player entity system
- [x] Find existing AI patterns (AutomatedTurretBehavior)
- [x] Document integration points
---
## Phase 1: "Hello World" Bot
### Objective
Spawn a bot entity that appears in the game world as a player character.
### Tasks
#### 1.1 Understand Player Creation Flow
- [ ] Trace how a real player is created when they log in
- [ ] Find `Player` instantiation code
- [ ] Find zone registration code (`Zone.AddPlayer` or similar)
- [ ] Find GUID allocation for new entities
#### 1.2 Create Minimal Bot Infrastructure
- [ ] Create `src/main/scala/net/psforever/objects/bot/` directory
- [ ] Create `Bot.scala` - wrapper or extension of Player
- [ ] Create `BotActor.scala` - minimal actor (just keeps bot alive)
#### 1.3 Bot Spawning
- [ ] Find how to spawn a player at a SpawnPoint without client
- [ ] Allocate GUID for bot
- [ ] Register bot in zone
- [ ] Broadcast bot's existence to other players
#### 1.4 Validation
- [ ] Connect real client to server
- [ ] Verify bot appears as a player character
- [ ] Bot should be standing still at spawn location
### Questions to Answer
- Can we create a Player without Avatar from database?
- Do we need to fake an Account/Character entry?
- What minimum packets must be sent to make bot visible?
---
## Phase 2: Moving Bot
### Objective
Bot moves through the world and other players can see it moving.
### Tasks
#### 2.1 PlayerStateMessage Broadcasting
- [ ] Understand how player position updates are broadcast
- [ ] Find the packet: `PlayerStateMessage`
- [ ] Implement position update loop in BotActor
#### 2.2 Simple Movement
- [ ] Bot walks forward
- [ ] Bot turns (change orientation)
- [ ] Movement speed matches infantry walking
#### 2.3 Patrol Behavior
- [ ] Bot walks between two points
- [ ] Bot stops, turns, walks back
### Success Criteria
- Real player sees bot walking around
- Bot movement is smooth (not teleporting)
---
## Phase 3: Combat Bot
### Objective
Bot can detect enemies and shoot at them.
### Tasks
#### 3.1 Vision System
- [ ] Implement FOV cone check (60-90 degrees)
- [ ] Detect players in range
- [ ] Filter by faction (don't shoot friendlies)
#### 3.2 Target Tracking
- [ ] Store current target reference
- [ ] Turn to face target (orientation updates)
- [ ] Lose target when out of range/sight
#### 3.3 Shooting
- [ ] Find weapon fire packets (`ChangeFireStateMessage_Start`, `WeaponFireMessage`)
- [ ] Implement firing at target
- [ ] Basic accuracy (not perfect, not terrible)
#### 3.4 Taking Damage
- [ ] Bot receives damage normally (via PlayerControl)
- [ ] Bot dies when HP reaches 0
- [ ] Bot respawns after death
### Success Criteria
- Bot shoots at enemy players
- Bot can be killed
- Bot respawns
---
## Phase 4: Smart Bot
### Objective
Bot makes tactical decisions.
### Tasks
#### 4.1 Health-Based Retreat
- [ ] Track bot's HP
- [ ] Retreat when HP below threshold
- [ ] Find cover or run toward spawn
#### 4.2 Ammunition Management
- [ ] Track ammo count
- [ ] Retreat to resupply when empty
#### 4.3 V-Menu Communication
- [ ] Bot sends VVV when needs help
- [ ] Bot sends VNG when in vehicle without gunner
- [ ] Proper chat message format
### Success Criteria
- Bot retreats when hurt
- Bot calls for help appropriately
---
## Phase 5: Bot Classes
### Objective
Different bot types with different behaviors.
### Tasks
#### 5.1 Class Definitions
- [ ] Define loadouts for each class (Driver, Support, Hacker, AV, MAX)
- [ ] Create certification sets per class
- [ ] Assign behavior weights per class
#### 5.2 Class-Specific Behavior
- [ ] Support: Prioritize healing/repairing
- [ ] Hacker: Prioritize hacking doors/objectives
- [ ] AV: Prioritize anti-vehicle combat
- [ ] MAX: Heavy combat behavior
#### 5.3 Vet & Ace Classes
- [ ] Vet: Multi-role capability
- [ ] Ace: Command behavior, last to logout
### Success Criteria
- Multiple bot types visible
- Each type behaves differently
---
## Phase 6: Population Management
### Objective
Bots scale with player population.
### Tasks
#### 6.1 BotManager
- [ ] Track real player count per faction
- [ ] Calculate target bot count
- [ ] Spawn/despawn bots to maintain balance
#### 6.2 Graceful Despawn
- [ ] Non-Ace bots leave first
- [ ] Ace leaves last
- [ ] Bots "log out" naturally (not poof)
#### 6.3 Faction Balance
- [ ] Equal bots per faction
- [ ] Adjust for real player imbalance?
### Success Criteria
- Server maintains ~100 bots per faction when empty
- Bots reduce as real players join
- Ace is last bot standing per faction
---
## Phase 7: Team Coordination
### Objective
Bots work together and follow orders.
### Tasks
#### 7.1 Order System
- [ ] Ace issues attack/defend orders
- [ ] Bots receive and follow orders
- [ ] Order priority system
#### 7.2 Help Response
- [ ] Bots respond to VNG/VNH/VVV
- [ ] Check if can help
- [ ] Navigate to requester
#### 7.3 Celebration Coordination
- [ ] Coordinate V-menu celebrations
- [ ] Staggered timing
- [ ] Limited responders (1-6)
### Success Criteria
- Bots attack/defend as ordered
- Bots help each other
- Celebrations feel natural
---
## Development Environment Setup
### Prerequisites
- Java 8 JDK
- sbt (Scala Build Tool)
- PostgreSQL 10+
- PlanetSide client (version 3.15.84.0)
### Build & Run
```bash
cd PSF-LoginServer
sbt compile
sbt server/run
```
### Testing Approach
1. Run server locally
2. Connect with PS client
3. Observe bot behavior
4. Iterate
---
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Player without SessionActor breaks things | Medium | High | May need dummy SessionActor |
| GUID allocation conflicts | Medium | High | Coordinate with existing GUID system |
| Performance with 300+ bots | Medium | Medium | Profile early, optimize as needed |
| Network overhead for bot updates | Low | Medium | Batch updates, reduce frequency |
| Client can't render many players | Low | High | Test with max players early |
---
## Success Metrics
### Phase 1 Success
- 1 bot visible in game world
### Phase 3 Success
- Bot engages in combat
### Phase 5 Success
- Multiple bot classes working
### Phase 7 Success (MVP)
- 100+ bots per faction
- Dynamic population scaling
- Team coordination
- "Feels like PlanetSide"
---
## Estimated Complexity
| Phase | Complexity | Notes |
|-------|------------|-------|
| 1 | Medium | Unknown integration points |
| 2 | Low | Straightforward once Phase 1 works |
| 3 | Medium | Combat systems are complex |
| 4 | Low | Building on Phase 3 |
| 5 | Medium | Many classes to implement |
| 6 | Low | Straightforward management |
| 7 | High | Coordination is complex |

145
bot-docs/PROJECT.md Normal file
View file

@ -0,0 +1,145 @@
# PlanetSide Bots Project
## Overview
Bringing bot support to PlanetSide through the PSForever server emulator project.
## Resources
- **Server Emulator**: [PSF-LoginServer](https://github.com/psforever/PSF-LoginServer) (Scala)
- **Wiki Reference**: https://www.psforever.net/PlanetSide/
- **Community**: PSForever dev team and community
---
## Documentation Structure
> **Important**: Keep documentation updated for context handoff between sessions.
| Document | Purpose |
|----------|---------|
| `HANDOFF.md` | **Start here** - Context for new Claude instances |
| `PROJECT.md` | This file - Overview and status |
| `GAME_FEEL.md` | Behavioral spec from user's notes |
| `ARCHITECTURE.md` | Technical design decisions |
| `CODEBASE_MAP.md` | Key files with line numbers |
| `POC_PLAN.md` | Implementation milestones |
| `DEV_SETUP.md` | Dev environment setup (verified working) |
| `SKETCHES/` | Conceptual code (not production) |
### Documentation Practices
- When discovering new code patterns, update `CODEBASE_MAP.md` with file paths and line numbers
- Link related concepts (e.g., "PlayerControl handles damage, see line 50-80")
- Write for a fresh Claude instance that has no prior context
- Keep `HANDOFF.md` current with latest decisions and next steps
---
## Game Context (PlanetSide 1)
- **Genre**: MMOFPS (Massively Multiplayer Online First Person Shooter)
- **Factions**: Terran Republic (TR), New Conglomerate (NC), Vanu Sovereignty (VS)
- **Scale**: Designed for hundreds to thousands of concurrent players
- **Core Loop**: Territory control via base capture through lattice-linked continents
- **Emulator Status**: ~98% complete - virtually indistinguishable from original to veteran players
### Key Gameplay Elements
- **Combined Arms**: Infantry, ground vehicles, aircraft all operating together
- **Armor Types**: Standard, Reinforced, Agile, Infiltration suits
- **Certification System**: Players unlock equipment/vehicles via certs
- **Base Capture**: Lattice-based progression, facility benefits cascade through links
- **Continents**: 10 landmass continents + underground caverns
### Vehicle Categories
- **Air**: Reaver, Mosquito, Liberator, Galaxy
- **Ground**: Lightning, Magrider, Vanguard, Harasser, ATV, ANT, Sunderer
- **Specialized**: BattleFrame Robotics (BFRs)
---
## Project Goals
### Primary Goal
**Population bots** - Make the world feel alive even with low real player counts
- Target: Hundreds of bots capable of running simultaneously
- **Dynamic scaling**: Bots log out as real players join each faction
- Bots should participate in the war naturally (capture bases, fight, move with purpose)
### Scope Phases
1. **Phase 1**: Infantry bots only
2. **Phase 2+**: Ground vehicles, aircraft, advanced behaviors (TBD)
### Intelligence Level
- Start realistic, iterate based on what's achievable
- Goal: Bots that don't break immersion, feel like "bad but trying" players
---
## Technical Approach
### Decided: BotActor (Server-Side Native)
After analyzing the codebase, we chose to create a new `BotActor` rather than emulating `SessionActor`:
- SessionActor handles network I/O from clients - bots have no client
- BotActor makes AI decisions internally, broadcasts via zone services
- Less overhead, better scalability, full optimization control
### Architecture Overview
```
BotManager (per zone)
└── BotActor (per bot)
└── Controls Player entity
└── PlayerControl (existing) handles damage/death
```
### Key Integration Points
- `Zone.Population` - Join/Spawn/Leave for bots
- `zone.AvatarEvents` - Broadcast position/actions
- `GUIDTask.registerPlayer()` - GUID allocation
- `PlayerControl` - Handles damage/death (reused)
See `CODEBASE_MAP.md` for file locations and line numbers.
### Dev Team Support
- Team wants this feature but bandwidth-limited
- Will assist with major blockers
- Full support expected as project nears completion
- **Project Lead**: Community member (us!) taking point
---
## Status
**Phase**: POC Complete - Bots Spawn and Move!
### Completed
- [x] Clone PSF-LoginServer codebase
- [x] Analyze codebase architecture
- [x] Map spawn/broadcast/GUID flows
- [x] Document behavioral spec from user's notes
- [x] Design bot architecture
- [x] Create conceptual code sketches
- [x] Build handoff documentation
- [x] Set up dev environment (Java 8, sbt, PostgreSQL)
- [x] Verify baseline compile and server run
- [x] **Phase 1: Spawn a static bot** - WORKING!
- [x] **Phase 2: Bot moves and broadcasts** - WORKING!
### Resolved Questions (Through Trial & Error)
- Avatar IDs must be POSITIVE (900000+), not negative
- PlayerControl works perfectly with stub BotAvatarActor
- Must use `registerAvatar()` not `registerPlayer()` (locker needs GUID)
### Next Steps
- [ ] Bot loadout/equipment (no backpack on death without items)
- [ ] Phase 3: Bot detects enemies and shoots
- [ ] Death/respawn cycle
- [ ] Terrain following (Z height)
---
## Faction Behavior & Game Feel
*See `GAME_FEEL.md` for detailed behavioral spec including:*
- Vision system (60-90 degree FOV, partial/full spotting)
- V-menu communication system
- Bot classes (Driver, Support, Hacker, AV, MAX, Vet, Ace)
- Movement patterns (newbie vs veteran)
- Retreat behaviors
- Attitude and vengeance system
- Chaos factor

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
}

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"