Repository navigation
PlayerTrackerComponentSprint3
PlayerTrackerComponent is the event-observation component of the Enemy Memory System.
It is attached to the player entity and observes confirmed combat events. It converts runtime card and phase information into simplified player behaviour, then writes that information into EnemyMemoryComponent.
Its main responsibilities are:
- Listening to successful card-play events;
- Resolving a runtime card instance into its card configuration;
- Identifying the card's
CardType; - Detecting positive
BLOCKeffects; - Detecting when the player's real turn ends;
- Ignoring temporary
CARD_RESOLVINGtransitions; - Sending interpreted behaviour to
EnemyMemoryComponent.
The tracker does not make enemy decisions and does not store long-term combat memory.
Player actions
↓
BattleController events
↓
PlayerTrackerComponent
↓
EnemyMemoryComponent
↓
PlayerMemory snapshot
↓
EnemyAIContext
↓
Enemy AI
Implementation:
source/core/src/main/com/csse3200/game/components/enemy/Memory/PlayerTrackerComponent.java
Tests:
source/core/src/test/com/csse3200/game/components/enemy/Memory/PlayerTrackerComponentTest.java
Related files:
EnemyMemoryComponent.java
PlayerMemory.java
EnemyAIContext.java
EnemyBehaviourComponent.java
PlayerFactory.java
BattleScreen.java
BattleController.java
BattleDeck.java
CardService.java
flowchart LR
Player["Player Entity"] --> Tracker["PlayerTrackerComponent"]
Player --> Memory["EnemyMemoryComponent"]
Controller["BattleController"] --> Tracker
Deck["BattleDeck"] --> Tracker
CardService["CardService"] --> Tracker
Tracker --> Memory
Memory --> Snapshot["PlayerMemory"]
Snapshot --> Context["EnemyAIContext"]
Context --> EnemyAI["Enemy AI"]
The responsibilities are divided as follows:
| Class | Responsibility |
|---|---|
PlayerTrackerComponent |
Observes and interprets player combat activity |
EnemyMemoryComponent |
Stores accumulated per-combat behaviour |
PlayerMemory |
Provides an immutable memory snapshot |
EnemyAIContext |
Transfers battle and memory data to enemy AI |
EnemyAI |
Uses the information to select an intent |
This separation prevents enemy AI from depending directly on battle events, cards or player entities.
classDiagram
class Component {
+create()
}
class PlayerTrackerComponent {
-EnemyMemoryComponent enemyMemory
-BattleController battleController
-CardService cardService
-BattleDeck battleDeck
-boolean connected
+create()
+connect(battleController, cardService, battleDeck)
-onCardPlayed(instanceId, targetId)
-onPhaseChanged(previousPhase, nextPhase)
-findCardConfig(instanceId) CardConfig
-grantsPositiveBlock(config) boolean
}
class EnemyMemoryComponent {
+recordCardPlayed(cardType, gainedBlock)
+settlePlayerTurn()
+snapshot() PlayerMemory
}
class BattleController {
+addCardPlayedListener(listener)
+addPhaseChangeListener(listener)
}
class BattleDeck {
+getAllInstances() List
}
class CardService {
+getCard(cardId) Optional
}
class CardInstance {
+instanceId() String
+cardId() String
}
class CardConfig {
+CardType type
+EffectConfig[] effects
}
Component <|-- PlayerTrackerComponent
PlayerTrackerComponent --> EnemyMemoryComponent : writes
PlayerTrackerComponent --> BattleController : observes
PlayerTrackerComponent --> BattleDeck : resolves instance
PlayerTrackerComponent --> CardService : resolves definition
BattleDeck --> CardInstance : contains
CardService --> CardConfig : provides
PlayerTrackerComponent and EnemyMemoryComponent are attached to the same player entity.
Player Entity
├── CombatStatsComponent
├── EnergyComponent
├── EnemyMemoryComponent
└── PlayerTrackerComponent
During ECS creation, the tracker retrieves the memory component:
@Override
public void create() {
super.create();
enemyMemory =
entity.getComponent(
EnemyMemoryComponent.class);
if (enemyMemory == null) {
throw new IllegalStateException(
"PlayerTrackerComponent requires "
+ "EnemyMemoryComponent "
+ "on the same entity");
}
}The tracker cannot exist on a player without EnemyMemoryComponent.
This guarantees that every valid tracker has a memory destination.
stateDiagram-v2
[*] --> CONSTRUCTED
CONSTRUCTED --> CREATED: Entity create
CREATED --> CONNECTED: connect called
CONNECTED --> OBSERVING: Battle events received
OBSERVING --> OBSERVING: Continue tracking
CREATED --> ERROR: Memory component missing
CONNECTED --> ERROR: Second connection attempted
The lifecycle has two stages.
PlayerFactory attaches the tracker and memory components to the player.
After BattleController, CardService and BattleDeck have been created, BattleScreen connects the tracker:
playerTracker.connect(
battleController,
cardService,
battleDeck);This separates entity ownership from battle-specific wiring.
The tracker receives three runtime dependencies:
public void connect(
BattleController battleController,
CardService cardService,
BattleDeck battleDeck)| Dependency | Purpose |
|---|---|
BattleController |
Provides card-play and phase-change events |
CardService |
Resolves a card ID into CardConfig
|
BattleDeck |
Resolves an instance ID into CardInstance
|
All dependencies must be non-null.
Objects.requireNonNull(
battleController,
"battleController cannot be null");
Objects.requireNonNull(
cardService,
"cardService cannot be null");
Objects.requireNonNull(
battleDeck,
"battleDeck cannot be null");The tracker may only be connected once:
if (connected) {
throw new IllegalStateException(
"PlayerTrackerComponent is already connected");
}This prevents duplicate listener registration.
Without this protection, one successful card play could be recorded multiple times.
After registering its listeners, the component sets:
connected = true;The tracker registers two listeners:
battleController.addCardPlayedListener(
this::onCardPlayed);
battleController.addPhaseChangeListener(
this::onPhaseChanged);sequenceDiagram
participant Screen as BattleScreen
participant Tracker as PlayerTrackerComponent
participant Controller as BattleController
Screen->>Tracker: connect(controller, cards, deck)
Tracker->>Tracker: Validate dependencies
Tracker->>Controller: Register card-play listener
Tracker->>Controller: Register phase-change listener
Tracker->>Tracker: connected = true
The card listener receives:
private void onCardPlayed(
String instanceId,
String targetId)| Parameter | Meaning |
|---|---|
instanceId |
Runtime identifier of the successfully played card |
targetId |
Selected target identifier |
Only successful card plays generate this event. A rejected card attempt therefore does not update memory.
targetId is currently unused because the memory system tracks how the player acts, rather than which enemy the player targets.
The event provides an instance ID, but memory requires semantic card information.
The tracker resolves the card through:
instanceId
↓
BattleDeck
↓
CardInstance
↓
cardId
↓
CardService
↓
CardConfig
flowchart TD
Event["Successful card event"] --> Id["Read instanceId"]
Id --> Valid{"ID is valid?"}
Valid -->|No| Ignore["Ignore event"]
Valid -->|Yes| Deck["Search BattleDeck"]
Deck --> Instance{"Instance found?"}
Instance -->|No| Ignore
Instance -->|Yes| CardId["Read cardId"]
CardId --> Service["CardService.getCard"]
Service --> Config{"CardConfig found?"}
Config -->|No| Ignore
Config -->|Yes| Classify["Classify card"]
private CardConfig findCardConfig(
String instanceId) {
if (instanceId == null
|| instanceId.isBlank()) {
return null;
}
for (CardInstance instance
: battleDeck.getAllInstances()) {
if (instance.instanceId()
.equals(instanceId)) {
return cardService
.getCard(instance.cardId())
.orElse(null);
}
}
return null;
}Invalid or unknown card events return null and are ignored safely.
They do not interrupt the battle.
After resolving the card, the tracker validates its type:
if (config == null
|| config.type == null) {
return;
}It then sends the interpreted data to memory:
enemyMemory.recordCardPlayed(
config.type,
grantsPositiveBlock(config));EnemyMemoryComponent handles card types as follows:
| Card type | Attack total | Skill total | Current-turn total |
|---|---|---|---|
ATTACK |
+1 | No change | +1 |
SKILL |
No change | +1 | +1 |
POWER |
No change | No change | +1 |
| Other valid type | No change | No change | +1 |
The tracker performs classification, while the memory component owns the counters.
sequenceDiagram
participant Battle as BattleController
participant Tracker as PlayerTrackerComponent
participant Deck as BattleDeck
participant Cards as CardService
participant Memory as EnemyMemoryComponent
Battle->>Tracker: onCardPlayed(instanceId, targetId)
Tracker->>Deck: getAllInstances()
Deck-->>Tracker: CardInstance list
Tracker->>Tracker: Match instanceId
Tracker->>Cards: getCard(cardId)
Cards-->>Tracker: CardConfig
Tracker->>Tracker: Read CardType
Tracker->>Tracker: Detect positive BLOCK
Tracker->>Memory: recordCardPlayed(type, gainedBlock)
The tracker considers a card defensive when at least one effect satisfies:
effect is not null
AND
effect.type == BLOCK
AND
effect.value > 0
Implementation:
private boolean grantsPositiveBlock(
CardConfig config) {
if (config.effects == null) {
return false;
}
for (EffectConfig effect : config.effects) {
if (effect != null
&& effect.type == EffectType.BLOCK
&& effect.value > 0) {
return true;
}
}
return false;
}flowchart TD
Config["CardConfig"] --> Effects{"effects is null?"}
Effects -->|Yes| False["Return false"]
Effects -->|No| Next["Inspect next effect"]
Next --> Null{"Effect is null?"}
Null -->|Yes| More{"More effects?"}
Null -->|No| Type{"Effect type is BLOCK?"}
Type -->|No| More
Type -->|Yes| Value{"Effect value above 0?"}
Value -->|Yes| True["Return true"]
Value -->|No| More
More -->|Yes| Next
More -->|No| False
| Effects | Counts as gaining block? |
|---|---|
BLOCK 5 |
Yes |
DAMAGE 5, BLOCK 3
|
Yes |
BLOCK 0 |
No |
BLOCK -2 |
No |
HEAL 4 |
No |
| No effects | No |
Null effect followed by BLOCK 4
|
Yes |
| Null effects only | No |
A multi-effect card only needs one positive Block effect to mark the turn as defended.
The tracker receives both the previous and next battle phase:
private void onPhaseChanged(
BattlePhase previousPhase,
BattlePhase nextPhase)A player turn is settled when:
previousPhase == BattlePhase.PLAYER_TURN
&& nextPhase != BattlePhase.PLAYER_TURN
&& nextPhase != BattlePhase.CARD_RESOLVINGIf the condition is true:
enemyMemory.settlePlayerTurn();Playing a card can temporarily change the phase:
PLAYER_TURN
↓
CARD_RESOLVING
↓
PLAYER_TURN
This is not the end of the player's turn.
If memory were settled during this transition:
-
cardsPlayedLastTurnwould update after every card; - The current-turn card counter would reset too early;
- No-block streaks could increase multiple times in one turn;
- High-card-count behaviour could never be tracked correctly.
Therefore:
PLAYER_TURN -> CARD_RESOLVING
does not settle memory.
flowchart TD
Event["Phase changed"] --> Previous{"Previous phase is PLAYER_TURN?"}
Previous -->|No| Ignore["Ignore transition"]
Previous -->|Yes| Same{"Next phase is PLAYER_TURN?"}
Same -->|Yes| Ignore
Same -->|No| Resolving{"Next phase is CARD_RESOLVING?"}
Resolving -->|Yes| Ignore
Resolving -->|No| Settle["EnemyMemoryComponent.settlePlayerTurn"]
| Previous phase | Next phase | Settle memory? |
|---|---|---|
PLAYER_TURN |
CARD_RESOLVING |
No |
CARD_RESOLVING |
PLAYER_TURN |
No |
PLAYER_TURN |
PLAYER_END |
Yes |
PLAYER_TURN |
ENEMY_TURN |
Yes |
SETUP |
PLAYER_TURN |
No |
PLAYER_END |
ENEMY_TURN |
No |
Only a transition that genuinely leaves PLAYER_TURN settles the recorded turn.
Before settlement:
cardsPlayedThisTurn = 3
gainedBlockThisTurn = false
cardsPlayedLastTurn = 1
consecutiveTurnsWithoutBlock = 2
After settlement:
cardsPlayedThisTurn = 0
gainedBlockThisTurn = false
cardsPlayedLastTurn = 3
consecutiveTurnsWithoutBlock = 3
If the player gained positive Block during the turn:
consecutiveTurnsWithoutBlock = 0
sequenceDiagram
participant Player
participant Battle as BattleController
participant Tracker as PlayerTrackerComponent
participant Deck as BattleDeck
participant Cards as CardService
participant Memory as EnemyMemoryComponent
participant AI as EnemyAI
Player->>Battle: Successfully play a card
Battle->>Tracker: Card-play event
Tracker->>Deck: Resolve runtime instance
Tracker->>Cards: Resolve card definition
Tracker->>Memory: recordCardPlayed(type, block)
Player->>Battle: End player turn
Battle->>Tracker: Phase-change event
Tracker->>Memory: settlePlayerTurn()
Memory-->>AI: Immutable PlayerMemory snapshot
Suppose the player performs the following actions.
Strike
Defend
End turn
Tracker output:
ATTACK card recorded
SKILL card recorded
Positive Block detected
Turn settled
Memory snapshot:
attackCardsPlayed = 1
skillCardsPlayed = 1
consecutiveTurnsWithoutBlock = 0
cardsPlayedLastTurn = 2
Strike
Strike
End turn
Memory snapshot:
attackCardsPlayed = 3
skillCardsPlayed = 1
consecutiveTurnsWithoutBlock = 1
cardsPlayedLastTurn = 2
Power card
End turn
Memory snapshot:
attackCardsPlayed = 3
skillCardsPlayed = 1
consecutiveTurnsWithoutBlock = 2
cardsPlayedLastTurn = 1
The Power card contributes to cardsPlayedLastTurn, but not to the attack or skill counters.
The tracker rejects programming errors during setup but safely ignores invalid runtime event data.
| Condition | Result |
|---|---|
Missing EnemyMemoryComponent
|
IllegalStateException |
Null BattleController
|
NullPointerException |
Null CardService
|
NullPointerException |
Null BattleDeck
|
NullPointerException |
Calling connect() twice |
IllegalStateException |
| Condition | Result |
|---|---|
| Null instance ID | Event ignored |
| Blank instance ID | Event ignored |
| Unknown instance ID | Event ignored |
| Missing card definition | Event ignored |
| Card with null type | Event ignored |
| Null effects array | Treated as no Block |
| Null effect entry | Entry skipped |
Runtime data problems do not terminate the battle.
flowchart TD
Input["Incoming operation"] --> Setup{"Setup operation?"}
Setup -->|Yes| ValidDependency{"Dependencies valid?"}
ValidDependency -->|No| Exception["Throw exception"]
ValidDependency -->|Yes| Connect["Register listeners"]
Setup -->|No| ValidEvent{"Runtime data resolvable?"}
ValidEvent -->|No| Ignore["Ignore safely"]
ValidEvent -->|Yes| Record["Write to EnemyMemoryComponent"]
The player must contain both memory components:
new Entity()
.addComponent(new EnemyMemoryComponent())
.addComponent(new PlayerTrackerComponent());EnemyMemoryComponent must be available when the entity is created so the tracker can locate it.
After the battle controller, card service and battle deck exist, BattleScreen connects the tracker:
PlayerTrackerComponent playerTracker =
Objects.requireNonNull(
player.getComponent(
PlayerTrackerComponent.class),
"Battle player requires "
+ "PlayerTrackerComponent");
playerTracker.connect(
controller,
cardService,
battleDeck);Conceptually:
flowchart TD
Factory["PlayerFactory"] --> Player["Create player entity"]
Player --> Components["Attach tracker and memory"]
Components --> Screen["BattleScreen"]
Screen --> Runtime["Create controller, cards and deck"]
Runtime --> Connect["Connect PlayerTrackerComponent"]
Connect --> Observe["Begin observing battle events"]
The factory establishes ownership. The battle screen supplies runtime dependencies.
The test suite mocks:
-
BattleController; -
CardService; -
BattleDeck.
It uses a real:
-
Entity; -
EnemyMemoryComponent; -
PlayerTrackerComponent.
Listeners registered with BattleController are captured using Mockito:
ArgumentCaptor<EventListener2<String, String>>
cardPlayedCaptor;
ArgumentCaptor<
EventListener2<
BattlePhase,
BattlePhase>>
phaseChangeCaptor;The tests invoke the captured listeners directly to simulate battle events.
- Requires
EnemyMemoryComponenton the same entity.
- Rejects a null battle controller;
- Rejects a null card service;
- Rejects a null battle deck;
- Rejects a second connection;
- Registers both required listeners.
- Records attack cards;
- Records skill cards;
- Counts other card types without classifying them as attack or skill;
- Ignores a card with a null type.
- Detects a positive Block effect;
- Finds Block among multiple effects;
- Rejects zero Block;
- Rejects negative Block;
- Safely ignores null effects.
- Ignores null IDs;
- Ignores blank IDs;
- Ignores unknown instance IDs;
- Ignores missing card definitions.
- Does not settle during card resolution;
- Settles when the player turn ends;
- Ignores unrelated phase changes.
flowchart TD
Tests["PlayerTrackerComponentTest"] --> Ownership["ECS ownership"]
Tests --> Connection["Connection validation"]
Tests --> Classification["Card classification"]
Tests --> Block["Block detection"]
Tests --> Phases["Phase transitions"]
Tests --> Invalid["Invalid runtime data"]
Classification --> Attack["ATTACK"]
Classification --> Skill["SKILL"]
Classification --> Other["Other card types"]
Block --> Positive["Positive Block"]
Block --> Zero["Zero Block"]
Block --> Negative["Negative Block"]
Block --> Multiple["Multiple effects"]
Phases --> Resolving["CARD_RESOLVING ignored"]
Phases --> End["Real turn end settled"]
The current tracker resolves:
CardInstance
↓
cardId
↓
CardService
↓
CardConfig
It reads Block information from:
config.effectsThis means the current implementation observes the base CardConfig.
It does not currently resolve the played card through CardResolver into a ResolvedCard.
Consequently, if a card upgrade changes its type or Block value only at runtime, the tracker may still observe the base card definition.
The Wiki documents this as the current implementation boundary rather than claiming upgraded effects are already supported.
The card-play event includes targetId, but the current tracker does not store it.
This is intentional for the current player-memory model, which records:
- Attack-card preference;
- Skill-card preference;
- Number of cards played;
- Consecutive turns without gaining Block.
Possible future memory features may use the target ID to track:
- Preferred enemy targets;
- Focus-fire behaviour;
- Repeated targeting of low-health enemies;
- Avoidance of guarded enemies.
These features are outside the current component scope.
PlayerTrackerComponent and EnemyMemoryComponent belong to one battle.
They do not currently:
- Persist data between battles;
- Write to save data;
- Aggregate achievement progress;
- Track map decisions;
- Track inventory history;
- Track rewards collected outside combat.
The tracker observes the current combat only.
PlayerTrackerComponent is an event-driven adapter between the battle system and the Enemy Memory System.
Its complete responsibility is:
flowchart TD
Events["Observe confirmed battle events"] --> Resolve["Resolve runtime card instance"]
Resolve --> Classify["Classify card and Block"]
Classify --> Write["Write semantic information to memory"]
Phase["Observe battle phase"] --> Validate["Detect real player-turn exit"]
Validate --> Settle["Settle per-turn memory"]
The component provides:
- Clear separation between event observation and memory storage;
- ECS ownership through the player entity;
- Runtime dependency injection;
- Protection against duplicate connections;
- Safe handling of invalid event data;
- Correct card-resolution phase handling;
- Attack and skill classification;
- Positive Block detection;
- A stable input boundary for adaptive enemy AI.