Repository navigation
BossAI
BossAI controls the decision-making behaviour of the Boss enemy, Vitium.
Unlike ordinary enemies that rely on short action cycles, the Boss combines several decision layers:
Health-based phase state machine
+
Phase-specific action weights
+
Global battle pressure
+
Player-memory reaction
+
Behaviour constraints
+
Weighted random selection
The Boss currently supports four actions:
| Boss move | Result |
|---|---|
ATTACK |
Deals the Boss's current attack value as damage |
DEFEND |
Grants armour, with stronger defence in later phases |
SILENCE |
Prevents the player from playing cards temporarily |
DAMAGE_ON_CARD_PLAY |
Damages the player whenever a card is played |
BossAI only decides which action should be used. It does not directly change health, armour or status effects. Instead, it returns an EnemyIntent, which is executed by EnemyBehaviourComponent.
source/core/src/main/com/csse3200/game/components/enemy/EnemyAI/BossAI.java
source/core/src/test/com/csse3200/game/components/enemy/EnemyAI/BossAITest.java
EnemyAI.java
EnemyAIContext.java
EnemyAIFactory.java
EnemyBehaviourComponent.java
EnemyIntent.java
IntentEffectType.java
PlayerMemory.java
EnemyMemoryComponent.java
The Boss is defined in configs/enemies.json:
{
"id": "boss_knight",
"name": "Vitium",
"health": 150,
"baseAttack": 14,
"armour": 8,
"tier": "BOSS",
"behaviour": "boss",
"sprite": "images/enemies/boss_knight.atlas"
}The important field for AI selection is:
"behaviour": "boss"EnemyAIFactory maps this behaviour identifier to a new BossAI instance:
case BOSS -> new BossAI();Every Boss entity therefore owns an independent AI instance with its own:
- Phase;
- Previous move;
- Consecutive attack counter;
- Debuff cooldown.
flowchart LR
Config["enemies.json"] --> EnemyFactory["EnemyFactory"]
EnemyFactory --> Behaviour["EnemyBehaviourComponent"]
Behaviour --> AIFactory["EnemyAIFactory"]
AIFactory --> Boss["BossAI"]
PlayerActions["Player actions"] --> Tracker["PlayerTrackerComponent"]
Tracker --> MemoryComponent["EnemyMemoryComponent"]
MemoryComponent --> Snapshot["PlayerMemory snapshot"]
PlayerStats["Player combat stats"] --> Context["EnemyAIContext"]
BossStats["Boss combat stats"] --> Context
Snapshot --> Context
Context --> Boss
Boss --> Intent["EnemyIntent"]
Intent --> Behaviour
Behaviour --> Stats["CombatStatsComponent"]
The Boss does not directly access the player entity or the memory component.
Instead, it receives a read-only snapshot through EnemyAIContext.
classDiagram
class EnemyAI {
<<interface>>
+decide(context) EnemyIntent
}
class BossAI {
-Random random
-BossPhase currentPhase
-BossMove previousMove
-int consecutiveAttacks
-int debuffCooldownRemaining
+BossAI()
~BossAI(random)
+decide(context) EnemyIntent
-shouldPrioritiseSilence(context) boolean
-updatePhase(context)
-tickDebuffCooldown()
-calculatePressure(context) PressureLevel
-getBaseWeights(phase)
-applyPressure(weights, pressure)
-applyConstraints(weights, context)
-selectWeightedMove(weights) BossMove
-createIntent(move, context) EnemyIntent
-recordMove(move)
}
class EnemyAIContext {
-int playerHealth
-int enemyHealth
-int enemyMaxHealth
-int enemyAttack
-int enemyArmour
-EnemyIntent previousIntent
-int turnNumber
-PlayerMemory playerMemory
+getPlayerHealth() int
+getEnemyHealthRatio() float
+getEnemyAttack() int
+getEnemyArmour() int
+getTurnNumber() int
+getPlayerMemory() PlayerMemory
}
class PlayerMemory {
<<record>>
+attackCardsPlayed() int
+skillCardsPlayed() int
+consecutiveTurnsWithoutBlock() int
+cardsPlayedLastTurn() int
+favoursAttack() boolean
+isUndefended() boolean
}
class EnemyIntent {
-IntentType type
-int value
-IntentEffectType effectType
-int duration
+attack(damage) EnemyIntent
+defend(armour) EnemyIntent
+debuff(effectType, value, duration) EnemyIntent
}
class BossPhase {
<<enumeration>>
PHASE_ONE
PHASE_TWO
ENRAGED
}
class BossMove {
<<enumeration>>
ATTACK
DEFEND
SILENCE
DAMAGE_ON_CARD_PLAY
~isDebuff() boolean
}
class PressureLevel {
<<enumeration>>
LOW
MEDIUM
HIGH
CRITICAL
}
BossAI ..|> EnemyAI : implements
BossAI --> EnemyAIContext : reads
BossAI --> EnemyIntent : creates
BossAI --> BossPhase : maintains
BossAI --> BossMove : selects
BossAI --> PressureLevel : calculates
EnemyAIContext o-- PlayerMemory : contains
Each BossAI instance maintains four mutable state values:
private BossPhase currentPhase = BossPhase.PHASE_ONE;
private BossMove previousMove;
private int consecutiveAttacks;
private int debuffCooldownRemaining;| Field | Responsibility |
|---|---|
currentPhase |
Stores the highest phase reached by the Boss |
previousMove |
Records the action selected during the previous decision |
consecutiveAttacks |
Counts uninterrupted attack actions |
debuffCooldownRemaining |
Prevents repeated applications of Boss Debuffs |
These fields are instance fields rather than static fields. Multiple Boss instances therefore do not share decision state.
Every call to:
BossAI.decide(context)performs the following process:
flowchart TD
Start["decide(context)"] --> NullCheck{"Is context null?"}
NullCheck -->|Yes| Error["Throw NullPointerException"]
NullCheck -->|No| Phase["Update Boss phase"]
Phase --> Cooldown["Tick Debuff cooldown"]
Cooldown --> Pressure["Calculate battle pressure"]
Pressure --> BaseWeights["Create phase weight table"]
BaseWeights --> PressureWeights["Apply pressure adjustments"]
PressureWeights --> Constraints["Apply behaviour constraints"]
Constraints --> MemoryCheck{"At least 4 cards last turn and cooldown ready?"}
MemoryCheck -->|Yes| Silence["Select SILENCE"]
MemoryCheck -->|No| Weighted["Perform weighted selection"]
Silence --> Record["Record selected move"]
Weighted --> Record
Record --> Convert["Convert move into EnemyIntent"]
Convert --> Return["Return EnemyIntent"]
Equivalent implementation structure:
updatePhase(context);
tickDebuffCooldown();
PressureLevel pressure =
calculatePressure(context);
EnumMap<BossMove, Integer> weights =
getBaseWeights(currentPhase);
applyPressure(weights, pressure);
applyConstraints(weights, context);
BossMove selectedMove;
if (shouldPrioritiseSilence(context)) {
selectedMove = BossMove.SILENCE;
} else {
selectedMove = selectWeightedMove(weights);
}
recordMove(selectedMove);
return createIntent(selectedMove, context);The Boss has three health-based phases:
public enum BossPhase {
PHASE_ONE,
PHASE_TWO,
ENRAGED
}| Phase | Health condition |
|---|---|
PHASE_ONE |
Health above 65% |
PHASE_TWO |
Health above 30% and at or below 65% |
ENRAGED |
Health at or below 30% |
Phase calculation:
if (healthRatio <= 0.30f) {
desiredPhase = BossPhase.ENRAGED;
} else if (healthRatio <= 0.65f) {
desiredPhase = BossPhase.PHASE_TWO;
} else {
desiredPhase = BossPhase.PHASE_ONE;
}stateDiagram-v2
[*] --> PHASE_ONE
PHASE_ONE --> PHASE_TWO: Health at or below 65 percent
PHASE_ONE --> ENRAGED: Health at or below 30 percent
PHASE_TWO --> ENRAGED: Health at or below 30 percent
PHASE_TWO --> PHASE_TWO: Boss is healed
ENRAGED --> ENRAGED: Boss is healed
Phase transitions are irreversible.
The Boss only changes phase when the desired phase is later than the current phase:
if (desiredPhase.ordinal() > currentPhase.ordinal()) {
currentPhase = desiredPhase;
}Valid transitions:
PHASE_ONE -> PHASE_TWO
PHASE_ONE -> ENRAGED
PHASE_TWO -> ENRAGED
Invalid transitions:
PHASE_TWO -> PHASE_ONE
ENRAGED -> PHASE_TWO
ENRAGED -> PHASE_ONE
Healing the Boss cannot reduce its difficulty after a later phase has been reached.
| Move | Weight |
|---|---|
ATTACK |
45 |
DEFEND |
55 |
SILENCE |
0 |
DAMAGE_ON_CARD_PLAY |
0 |
Phase One is normally defensive and does not randomly select Debuffs.
However, the memory-driven SILENCE response may still activate if the player used at least four cards during the previous turn.
| Move | Weight |
|---|---|
ATTACK |
65 |
DEFEND |
35 |
SILENCE |
20 |
DAMAGE_ON_CARD_PLAY |
10 |
Phase Two becomes more aggressive and introduces both Debuff actions.
| Move | Weight |
|---|---|
ATTACK |
90 |
DEFEND |
10 |
SILENCE |
15 |
DAMAGE_ON_CARD_PLAY |
25 |
The Enraged phase strongly favours attacking and increases the likelihood of punishing repeated card usage.
The final action weights are added together:
int totalWeight =
weights.values()
.stream()
.mapToInt(Integer::intValue)
.sum();A random value is selected inside the total range:
int roll = random.nextInt(totalWeight);The value is then consumed in enum order until the selected range is reached.
flowchart LR
Roll["Random roll"] --> Attack["ATTACK range"]
Attack --> Defend["DEFEND range"]
Defend --> Silence["SILENCE range"]
Silence --> CardDamage["DAMAGE_ON_CARD_PLAY range"]
A higher weight gives an action a larger section of the random range.
The pressure system represents the urgency of the battle.
Pressure is calculated from:
- Number of battle turns;
- Percentage of Boss health lost;
- Player health relative to the Boss's attack.
flowchart TD
Turn["Battle duration"] --> Total["Pressure score"]
BossHealth["Boss health lost"] --> Total
PlayerHealth["Player health"] --> Total
Total --> Level{"Classify pressure"}
Level --> Low["LOW"]
Level --> Medium["MEDIUM"]
Level --> High["HIGH"]
Level --> Critical["CRITICAL"]
Longer battles make the Boss more aggressive:
pressure +=
Math.min(
30,
Math.max(0, context.getTurnNumber() - 1) * 3);| Turn | Added pressure |
|---|---|
| 1 | 0 |
| 2 | 3 |
| 3 | 6 |
| 5 | 12 |
| 8 | 21 |
| 11 or later | 30 |
The turn contribution cannot exceed 30.
Pressure increases as the Boss loses health:
pressure +=
Math.round(
(1f - context.getEnemyHealthRatio()) * 40f);| Remaining Boss health | Added pressure |
|---|---|
| 100% | 0 |
| 75% | 10 |
| 50% | 20 |
| 30% | 28 |
| 10% | 36 |
| 0% | 40 |
The Boss becomes more aggressive when it is close to defeating the player:
if (context.getPlayerHealth() > 0) {
if (context.getPlayerHealth()
<= context.getEnemyAttack()) {
pressure += 30;
} else if (context.getPlayerHealth()
<= context.getEnemyAttack() * 2) {
pressure += 15;
}
}| Player condition | Added pressure |
|---|---|
| More than two attacks from defeat | 0 |
| Within two attacks of defeat | 15 |
| Within one attack of defeat | 30 |
| Score | Pressure level |
|---|---|
| 0–19 | LOW |
| 20–44 | MEDIUM |
| 45–69 | HIGH |
| 70 or more | CRITICAL |
flowchart TD
Score["Calculated pressure"] --> CriticalCheck{"Score at least 70?"}
CriticalCheck -->|Yes| Critical["CRITICAL"]
CriticalCheck -->|No| HighCheck{"Score at least 45?"}
HighCheck -->|Yes| High["HIGH"]
HighCheck -->|No| MediumCheck{"Score at least 20?"}
MediumCheck -->|Yes| Medium["MEDIUM"]
MediumCheck -->|No| Low["LOW"]
| Pressure | Attack adjustment | Defence adjustment |
|---|---|---|
LOW |
-10 | +10 |
MEDIUM |
0 | 0 |
HIGH |
+15 | -15 |
CRITICAL |
+25 | -25 |
Pressure only changes ATTACK and DEFEND.
It does not increase Debuff weights. This prevents high pressure from turning the Boss into a continuous Debuff loop.
The Boss reads the player's previous-turn card count through the Enemy Memory System:
sequenceDiagram
participant Player
participant Tracker as PlayerTrackerComponent
participant Memory as EnemyMemoryComponent
participant Behaviour as EnemyBehaviourComponent
participant Context as EnemyAIContext
participant Boss as BossAI
Player->>Tracker: Successfully plays cards
Tracker->>Memory: Record card information
Tracker->>Memory: Settle completed player turn
Memory-->>Behaviour: PlayerMemory snapshot
Behaviour->>Context: Create context with snapshot
Context-->>Boss: cardsPlayedLastTurn
Boss->>Boss: Evaluate SILENCE reaction
The AI receives immutable memory data and cannot modify the memory component.
The Boss reads:
context
.getPlayerMemory()
.cardsPlayedLastTurn();The trigger condition is:
cardsPlayedLastTurn >= 4
AND
debuffCooldownRemaining == 0
Implementation:
private boolean shouldPrioritiseSilence(
EnemyAIContext context) {
return debuffCooldownRemaining == 0
&& context
.getPlayerMemory()
.cardsPlayedLastTurn()
>= SILENCE_CARD_THRESHOLD;
}The threshold is stored centrally:
private static final int SILENCE_CARD_THRESHOLD = 4;The memory response does not merely increase the SILENCE weight.
When the trigger condition is satisfied, the Boss selects SILENCE before weighted random selection:
if (shouldPrioritiseSilence(context)) {
selectedMove = BossMove.SILENCE;
} else {
selectedMove = selectWeightedMove(weights);
}This makes the response:
- Deterministic;
- Easy for the player to learn;
- Easy to test;
- Independent of random rolls;
- Clearly connected to the player's previous behaviour.
The regular weighted system remains active whenever the memory trigger is not satisfied.
flowchart TD
Start["Read PlayerMemory"] --> Count["Read cardsPlayedLastTurn"]
Count --> Threshold{"At least 4 cards?"}
Threshold -->|No| Random["Use weighted selection"]
Threshold -->|Yes| Cooldown{"Debuff cooldown ready?"}
Cooldown -->|Yes| Silence["Select SILENCE"]
Cooldown -->|No| Random
Silence --> Record["Start shared Debuff cooldown"]
Random --> Result["Return selected move"]
Record --> Result
Phase One has a base SILENCE weight of zero.
Therefore, Phase One cannot randomly select SILENCE.
However, the memory response is an intentional exception:
Phase One
+
At least four cards played last turn
+
Debuff cooldown ready
=
SILENCE
This allows the Boss to react to excessive card usage even during the early stage of the encounter.
Without the memory trigger, Phase One retains its original attack-and-defend behaviour.
The original seven-argument EnemyAIContext constructor remains available.
When no memory is provided, it uses:
PlayerMemory.empty()This produces:
attackCardsPlayed = 0
skillCardsPlayed = 0
consecutiveTurnsWithoutBlock = 0
cardsPlayedLastTurn = 0
Therefore:
cardsPlayedLastTurn >= 4
is false, and the Boss retains its original weighted behaviour.
Existing AI classes and older tests do not need to provide player memory.
SILENCE creates the following intent:
EnemyIntent.debuff(
IntentEffectType.SILENCE,
1,
2);| Property | Value |
|---|---|
| Effect | SILENCE |
| Value | 1 |
| Duration | 2 player turns |
The effect prevents the player from playing cards while active.
DAMAGE_ON_CARD_PLAY creates:
EnemyIntent.debuff(
IntentEffectType.DAMAGE_ON_CARD_PLAY,
3,
3);| Property | Value |
|---|---|
| Effect | DAMAGE_ON_CARD_PLAY |
| Damage per card | 3 |
| Duration | 3 player turns |
While active, the player loses health whenever a card is played.
Both Boss Debuffs share one cooldown:
SILENCE
DAMAGE_ON_CARD_PLAY
After either Debuff is selected:
debuffCooldownRemaining =
DEBUFF_COOLDOWN_TURNS;The configured cooldown is:
private static final int DEBUFF_COOLDOWN_TURNS = 3;While the cooldown is active, all Debuff weights are set to zero:
if (debuffCooldownRemaining > 0) {
for (BossMove move : BossMove.values()) {
if (move.isDebuff()) {
weights.put(move, 0);
}
}
}The memory-trigger condition also checks the same cooldown. Therefore, player memory cannot bypass it.
stateDiagram-v2
[*] --> READY
READY --> COOLDOWN_3: Debuff selected
COOLDOWN_3 --> COOLDOWN_2: Next decision
COOLDOWN_2 --> COOLDOWN_1: Next decision
COOLDOWN_1 --> READY: Next decision
READY --> READY: Attack or defend
COOLDOWN_3 --> COOLDOWN_3: Debuffs unavailable
COOLDOWN_2 --> COOLDOWN_2: Debuffs unavailable
Example:
| Decision | Cooldown before selection | Result |
|---|---|---|
| 1 | 0 | Debuff selected; cooldown becomes 3 |
| 2 | 2 after ticking | Debuffs unavailable |
| 3 | 1 after ticking | Debuffs unavailable |
| 4 | 0 after ticking | Debuffs available again |
The Boss does not defend when it already has substantial armour:
if (context.getEnemyArmour()
>= HIGH_ARMOUR_THRESHOLD) {
weights.put(BossMove.DEFEND, 0);
}Threshold:
private static final int HIGH_ARMOUR_THRESHOLD = 8;This prevents the Boss from wasting turns repeatedly adding armour.
If the previous action was DEFEND, the next defence weight is divided by four:
if (previousMove == BossMove.DEFEND) {
int defendWeight =
weights.getOrDefault(
BossMove.DEFEND,
0);
weights.put(
BossMove.DEFEND,
defendWeight / 4);
}Defending remains possible, but becomes significantly less likely.
After two or more consecutive attacks:
ATTACK -20
DEFEND +20
Implementation:
if (consecutiveAttacks >= 2) {
adjustWeight(
weights,
BossMove.ATTACK,
-20);
adjustWeight(
weights,
BossMove.DEFEND,
20);
}This encourages action variation without completely forbidding another attack.
Weight adjustments are clamped to zero:
weights.put(
move,
Math.max(0, currentWeight + adjustment));Negative action weights are therefore impossible.
After applying constraints, the Boss confirms that at least one move remains available:
if (!hasAvailableMove) {
weights.put(BossMove.ATTACK, 1);
}If every weight becomes zero, ATTACK becomes the safe fallback.
flowchart LR
Attack["BossMove.ATTACK"] --> AttackIntent["EnemyIntent.attack"]
Defend["BossMove.DEFEND"] --> DefendIntent["EnemyIntent.defend"]
Silence["BossMove.SILENCE"] --> SilenceIntent["SILENCE Debuff intent"]
CardDamage["BossMove.DAMAGE_ON_CARD_PLAY"] --> CardIntent["Card-play damage Debuff intent"]
Implementation:
return switch (move) {
case ATTACK ->
EnemyIntent.attack(
context.getEnemyAttack());
case DEFEND ->
EnemyIntent.defend(
getDefendAmount(currentPhase));
case SILENCE ->
EnemyIntent.debuff(
IntentEffectType.SILENCE,
SILENCE_VALUE,
SILENCE_DURATION);
case DAMAGE_ON_CARD_PLAY ->
EnemyIntent.debuff(
IntentEffectType.DAMAGE_ON_CARD_PLAY,
CARD_PLAY_DAMAGE,
CARD_PLAY_DAMAGE_DURATION);
};Defence becomes stronger in later phases:
| Phase | Armour granted |
|---|---|
PHASE_ONE |
4 |
PHASE_TWO |
6 |
ENRAGED |
8 |
private int getDefendAmount(
BossPhase phase) {
return switch (phase) {
case PHASE_ONE -> 4;
case PHASE_TWO -> 6;
case ENRAGED -> 8;
};
}sequenceDiagram
participant Battle
participant Behaviour as EnemyBehaviourComponent
participant Context as EnemyAIContext
participant Boss as BossAI
participant Intent as EnemyIntent
participant Stats as CombatStatsComponent
Battle->>Behaviour: rollIntent()
Behaviour->>Context: Build combat snapshot
Behaviour->>Boss: decide(context)
Boss->>Boss: Update phase and cooldown
Boss->>Boss: Calculate pressure and weights
Boss->>Boss: Check PlayerMemory
Boss->>Intent: Create selected intent
Boss-->>Behaviour: Return intent
Behaviour-->>Battle: Telegraph intent
Battle->>Behaviour: executeIntent(player)
Behaviour->>Stats: Apply damage, armour or status effect
Decision-making and action execution remain separate.
Runtime instances use:
public BossAI() {
this(new Random());
}Tests can provide a controlled random source:
BossAI(Random random) {
this.random =
Objects.requireNonNull(
random,
"random cannot be null");
}The package-private constructor allows tests to use:
-
FixedRollRandom; -
SequenceRandom.
This makes weighted choices deterministic and repeatable.
The memory-triggered SILENCE does not depend on randomness.
BossAITest verifies the following behaviour.
- Starts in
PHASE_ONE; - Enters
PHASE_TWOat 65% health; - Enters
ENRAGEDat 30% health; - Never returns to an earlier phase after healing.
- Produces attack intents;
- Produces defence intents;
- Uses stronger defence in later phases;
- Avoids defending at high armour;
- Becomes more aggressive under pressure.
- Records the previous move;
- Counts consecutive attacks;
- Resets the attack counter after defending;
- Resets the attack counter after using a Debuff;
- Rejects a null context.
- Does not randomly use Debuffs in Phase One;
- Can use
SILENCEin Phase Two; - Can use
DAMAGE_ON_CARD_PLAYwhile Enraged; - Cannot use Debuffs repeatedly;
- Can use a Debuff again after cooldown expiration.
- Exactly four cards trigger prioritised
SILENCE; - Fewer than four cards do not trigger the response;
- Memory-driven
SILENCErecords the correct move; - Memory-driven
SILENCEstarts the shared cooldown; - Memory cannot bypass an active cooldown;
- Memory may trigger
SILENCEagain after cooldown expiration; -
DAMAGE_ON_CARD_PLAYprevents immediate memory-drivenSILENCE; - Empty memory preserves the original behaviour.
flowchart TD
Setup["Create PlayerMemory"] --> FourCards{"cardsPlayedLastTurn >= 4?"}
FourCards -->|No| NormalTest["Expect normal weighted action"]
FourCards -->|Yes| Ready{"Cooldown is zero?"}
Ready -->|Yes| SilenceTest["Expect SILENCE"]
Ready -->|No| BlockedTest["Expect non-Debuff action"]
SilenceTest --> CooldownTest["Verify cooldown becomes 3"]
BlockedTest --> SharedTest["Verify shared cooldown remains active"]
Boss health: 100%
Turn: 1
Boss armour: 0
Cards played last turn: 1
Result:
Phase: PHASE_ONE
Pressure: LOW
Memory trigger: false
Weights:
Base:
ATTACK = 45
DEFEND = 55
After LOW pressure:
ATTACK = 35
DEFEND = 65
The Boss is more likely to defend.
Boss health: 100%
Phase: PHASE_ONE
Cards played last turn: 4
Debuff cooldown: 0
Although the Phase One SILENCE weight is zero, the memory condition is satisfied.
Result:
Selected move: SILENCE
Cooldown after selection: 3
No random roll is needed.
Cards played last turn: 5
Debuff cooldown after ticking: 2
The card threshold is satisfied, but the cooldown is not ready.
Result:
Memory-driven SILENCE is blocked
SILENCE weight is zero
DAMAGE_ON_CARD_PLAY weight is zero
Boss selects ATTACK or DEFEND
Decision one:
Phase: ENRAGED
Selected move: DAMAGE_ON_CARD_PLAY
Cooldown becomes 3
Decision two:
Cards played last turn: 4
Cooldown becomes 2 after ticking
Result:
SILENCE is not selected
This confirms that both Debuffs use the same cooldown.
Turn pressure: 24
Boss-health pressure: 28
Low-player-health pressure: 30
Total pressure: 82
Result:
Pressure level: CRITICAL
ATTACK weight: +25
DEFEND weight: -25
Unless the memory-driven SILENCE condition is active, the Boss strongly favours attacking.
| System | Responsibility |
|---|---|
PlayerTrackerComponent |
Observes player actions |
EnemyMemoryComponent |
Stores per-combat behaviour data |
PlayerMemory |
Provides an immutable memory snapshot |
EnemyAIContext |
Transfers combat and memory data to AI |
BossAI |
Selects an action |
EnemyIntent |
Describes the selected action |
EnemyBehaviourComponent |
Telegraphs and executes the intent |
CombatStatsComponent |
Applies health, armour and status changes |
The Boss AI is not coupled to battle events, the player entity or mutable memory storage.
flowchart TD
Phase["Phase layer"] --> Pressure["Pressure layer"]
Pressure --> Constraints["Constraint layer"]
Constraints --> Memory["Memory reaction layer"]
Memory --> Selection["Weighted selection or direct priority"]
Selection --> Intent["Intent conversion"]
| Layer | Purpose |
|---|---|
| Phase | Defines broad Boss behaviour based on health |
| Pressure | Responds to current battle urgency |
| Constraints | Prevents repetitive or invalid decisions |
| Memory | Responds to the player's previous behaviour |
| Selection | Adds controlled variation when no direct response is needed |
| Intent conversion | Connects AI decisions to the battle system |
A new Boss action normally requires:
- Add a new
BossMove; - Add its phase weights;
- Add any required constraints;
- Map it to an
EnemyIntent; - Add execution support if the effect is new;
- Add an intent icon or animation;
- Add deterministic tests;
- Update this Wiki.
The architecture has already expanded from:
ATTACK
DEFEND
to:
ATTACK
DEFEND
SILENCE
DAMAGE_ON_CARD_PLAY
without replacing the phase, pressure or weighted-selection systems.
The complete Boss decision model is:
flowchart TD
Health["Boss health"] --> Phase["Select irreversible phase"]
Battle["Turn and health information"] --> Pressure["Calculate pressure"]
Phase --> Weights["Create phase weights"]
Pressure --> Weights
Weights --> Constraints["Apply behaviour constraints"]
Memory["PlayerMemory"] --> Reaction{"High card usage and cooldown ready?"}
Constraints --> Reaction
Reaction -->|Yes| Silence["Prioritise SILENCE"]
Reaction -->|No| Random["Weighted action selection"]
Silence --> History["Record move and cooldown"]
Random --> History
History --> Intent["Return EnemyIntent"]
BossAI provides:
- Three irreversible combat phases;
- Phase-specific action weights;
- Global pressure calculation;
- Controlled random behaviour;
- Attack and defence variation;
- Two status-effect actions;
- A shared Debuff cooldown;
- Memory-driven adaptation to player card usage;
- Backwards compatibility through empty memory;
- Deterministic and repeatable unit tests;
- Clear separation between decision and execution.