PlayerMemory - UQcsse3200/2026-studio-3 GitHub Wiki
PlayerMemory is the immutable data model of the Enemy Memory System. It records the player's behavioural habits during the current combat so that enemy AI can read them.
The existing AI can only see immediate state — player health, enemy health, enemy attack, enemy armour, turn number, and previous intent. It cannot tell how the player has been playing. PlayerMemory adds that dimension:
- Whether the player favours attack cards
- Whether the player regularly uses skill cards
- Whether the player has avoided gaining block for several turns
- How many cards the player played during the previous turn
It does not collect events, modify combat state, or make AI decisions. Each AI decides whether to read it and how to use it.
| Feature | Status |
|---|---|
| Immutable player behaviour snapshot | Implemented |
| Attack-card preference detection | Implemented |
| Undefended-player detection | Implemented |
| Empty / null-object memory | Implemented |
| Non-negative validation | Implemented |
| Unit tests | Implemented |
| Event tracking, context integration, Boss rules | Planned (later tasks) |
| Cross-combat persistence | Not planned |
source/core/src/main/com/csse3200/game/components/enemy/Memory/PlayerMemory.java
source/core/src/test/com/csse3200/game/components/enemy/Memory/PlayerMemoryTest.java
package com.csse3200.game.components.enemy;public record PlayerMemory(
int attackCardsPlayed,
int skillCardsPlayed,
int consecutiveTurnsWithoutBlock,
int cardsPlayedLastTurn)PlayerMemory is implemented as a Java record. Fields are immutable, and the canonical constructor, field accessors, equals(), hashCode(), and toString() are generated automatically. There are no setters.
classDiagram
class PlayerMemory {
<<record>>
-int ATTACK_FAVOUR_THRESHOLD
-int ATTACK_TO_SKILL_RATIO
-int UNDEFENDED_TURN_THRESHOLD
-PlayerMemory EMPTY
+int attackCardsPlayed
+int skillCardsPlayed
+int consecutiveTurnsWithoutBlock
+int cardsPlayedLastTurn
+PlayerMemory(int attackCardsPlayed, int skillCardsPlayed, int consecutiveTurnsWithoutBlock, int cardsPlayedLastTurn)
+boolean favoursAttack()
+boolean isUndefended()
+PlayerMemory empty()
+boolean equals(Object other)
+int hashCode()
+String toString()
}
Generated by Java: the four accessors, equals(), hashCode(), toString().
Implemented by the system: favoursAttack(), isUndefended(), empty().
| Field | Description |
|---|---|
attackCardsPlayed |
Total attack cards observed |
skillCardsPlayed |
Total skill cards observed |
consecutiveTurnsWithoutBlock |
Consecutive turns without gaining block |
cardsPlayedLastTurn |
Cards played during the previous turn |
attackCardsPlayed and skillCardsPlayed accumulate across the combat and are used together to detect attack preference. consecutiveTurnsWithoutBlock stores only the resulting count; incrementing and resetting belong to the tracker. cardsPlayedLastTurn is a single-turn value, not a cumulative total, and allows an enemy to react to a high-volume turn.
private static final int ATTACK_FAVOUR_THRESHOLD = 4;
private static final int ATTACK_TO_SKILL_RATIO = 2;
private static final int UNDEFENDED_TURN_THRESHOLD = 3;| Constant | Value | Purpose |
|---|---|---|
ATTACK_FAVOUR_THRESHOLD |
4 | Minimum attack cards before attack preference can be detected |
ATTACK_TO_SKILL_RATIO |
2 | Attack cards must exceed twice the skill cards |
UNDEFENDED_TURN_THRESHOLD |
3 | Consecutive turns without block required to be considered undefended |
Thresholds are centralised here so that balance changes happen in one place and are not duplicated across AI implementations.
public boolean favoursAttack() {
return attackCardsPlayed >= ATTACK_FAVOUR_THRESHOLD
&& (long) attackCardsPlayed
> (long) ATTACK_TO_SKILL_RATIO * skillCardsPlayed;
}Both conditions must hold: at least four attack cards, and the attack count strictly greater than twice the skill count.
flowchart TD
A["favoursAttack()"] --> B{"attackCardsPlayed >= 4?"}
B -- "No" --> C["Return false"]
B -- "Yes" --> D{"attackCardsPlayed > 2 × skillCardsPlayed?"}
D -- "No" --> C
D -- "Yes" --> E["Return true"]
| Attack | Skill | Result | Reason |
|---|---|---|---|
| 3 | 0 | false |
Minimum attack threshold not reached |
| 4 | 1 | true |
4 > 2×1 |
| 4 | 2 | false |
4 equals 2×2, not strictly greater |
| 5 | 2 | true |
5 > 4 |
| 9 | 4 | true |
9 > 8 |
The (long) casts prevent integer overflow when the skill count is multiplied by the configured ratio.
public boolean isUndefended() {
return consecutiveTurnsWithoutBlock
>= UNDEFENDED_TURN_THRESHOLD;
}flowchart TD
A["isUndefended()"] --> B{"consecutiveTurnsWithoutBlock >= 3?"}
B -- "No" --> C["Return false"]
B -- "Yes" --> D["Return true"]
| Turns without block | Result |
|---|---|
| 2 | false |
| 3 | true |
| 5 | true |
The condition uses >= rather than ==, so the player remains classified as undefended once the threshold is passed. The classification ends only when the tracker resets the counter after the player gains block.
When no player behaviour has been observed, returning null would force every AI to perform a null check. PlayerMemory.empty() provides a shared neutral object instead:
private static final PlayerMemory EMPTY =
new PlayerMemory(0, 0, 0, 0);
public static PlayerMemory empty() {
return EMPTY;
}PlayerMemory memory = PlayerMemory.empty();
memory.favoursAttack(); // false
memory.isUndefended(); // falseflowchart TD
A["PlayerMemory.empty()"] --> B["Return shared EMPTY instance"]
B --> C["attackCardsPlayed = 0"]
B --> D["skillCardsPlayed = 0"]
B --> E["consecutiveTurnsWithoutBlock = 0"]
B --> F["cardsPlayedLastTurn = 0"]
C --> G["favoursAttack() = false"]
D --> G
E --> H["isUndefended() = false"]
This is an application of the Null Object Pattern. Because the record is immutable, the shared EMPTY instance can be safely reused by any number of enemies.
All four fields are counters, so negative values have no valid meaning. The compact record constructor rejects them:
public PlayerMemory {
if (attackCardsPlayed < 0
|| skillCardsPlayed < 0
|| consecutiveTurnsWithoutBlock < 0
|| cardsPlayedLastTurn < 0) {
throw new IllegalArgumentException(
"Player memory values cannot be negative");
}
}flowchart TD
A["new PlayerMemory(a, s, b, c)"] --> B{"Is any value negative?"}
B -- "Yes" --> C["Throw IllegalArgumentException"]
B -- "No" --> D["Create immutable PlayerMemory"]
D --> E["Expose record accessors"]
D --> F["Allow favoursAttack()"]
D --> G["Allow isUndefended()"]
Validation prevents corrupted tracking data from silently influencing AI decisions.
A PlayerMemory object represents one snapshot in time. Updating memory requires creating a new object:
PlayerMemory previous = new PlayerMemory(4, 1, 2, 3);
PlayerMemory updated = new PlayerMemory(5, 1, 3, 1); // previous is unchangedThis gives AI a stable decision snapshot, prevents one enemy from modifying memory used by another, and allows snapshots to be compared, logged, and passed between systems without defensive copying.
Records use value-based equality:
new PlayerMemory(5, 2, 3, 1).equals(new PlayerMemory(5, 2, 3, 1)); // truePlayerMemory memory = new PlayerMemory(5, 2, 3, 4);
memory.favoursAttack(); // true
memory.isUndefended(); // trueA memory-aware enemy can use the derived methods directly:
if (memory.favoursAttack()) {
increaseDefendWeight();
}
if (memory.isUndefended()) {
increaseAttackWeight();
}For a battle with no recorded behaviour:
PlayerMemory memory = PlayerMemory.empty();| System | Responsibility |
|---|---|
| Battle system | Publishes player actions and turn events |
| Card system | Identifies the type of card played |
| Memory tracker | Observes events and maintains counters |
PlayerMemory |
Stores one immutable behaviour snapshot |
EnemyAIContext |
Delivers battle data to an enemy AI |
| Enemy AI | Uses the snapshot to choose an action |
PlayerMemory does not listen to events, count cards, update itself, find the player entity, access the battle controller, modify health, armour, or status effects, generate intents, or store global service references.
The complete Enemy Memory System connects player combat events to enemy decision-making. The following architecture is planned; only PlayerMemory belongs to the current task.
classDiagram
class BattleEvents {
<<existing system>>
+cardPlayed
+blockGained
+turnEnded
}
class EnemyMemoryTracker {
<<planned>>
-int attackCardsPlayed
-int skillCardsPlayed
-int consecutiveTurnsWithoutBlock
-int cardsPlayedThisTurn
-PlayerMemory currentMemory
+onCardPlayed()
+onBlockGained()
+onTurnEnded()
+getMemory() PlayerMemory
+reset()
}
class PlayerMemory {
<<implemented record>>
+int attackCardsPlayed
+int skillCardsPlayed
+int consecutiveTurnsWithoutBlock
+int cardsPlayedLastTurn
+boolean favoursAttack()
+boolean isUndefended()
+PlayerMemory empty()
}
class EnemyAIContext {
<<existing; integration planned>>
+int getPlayerHealth()
+int getEnemyHealth()
+int getTurnNumber()
+PlayerMemory getPlayerMemory()
}
class EnemyAI {
<<interface>>
+EnemyIntent decide(EnemyAIContext context)
}
class BossAI {
+EnemyIntent decide(EnemyAIContext context)
-void applyMemoryRules(PlayerMemory memory)
}
BattleEvents --> EnemyMemoryTracker : publishes events
EnemyMemoryTracker --> PlayerMemory : creates snapshots
EnemyMemoryTracker --> EnemyAIContext : supplies memory
EnemyAIContext o-- PlayerMemory : contains
EnemyAI <|.. BossAI : implements
BossAI --> EnemyAIContext : reads
BossAI ..> PlayerMemory : evaluates
Player action
↓
Battle or card event
↓
EnemyMemoryTracker updates counters
↓
New PlayerMemory snapshot
↓
Added to EnemyAIContext
↓
Enemy AI reads snapshot
↓
AI rules / action weights adjusted
↓
EnemyIntent returned
↓
EnemyBehaviourComponent executes intent
sequenceDiagram
actor Player
participant Battle as Battle System
participant Tracker as EnemyMemoryTracker
participant Memory as PlayerMemory
participant Context as EnemyAIContext
participant Boss as BossAI
Player->>Battle: Play an attack card
Battle->>Tracker: cardPlayed(ATTACK)
Tracker->>Tracker: Increment attackCardsPlayed
Player->>Battle: End turn
Battle->>Tracker: turnEnded()
Tracker->>Memory: Create immutable snapshot
Memory-->>Tracker: Return PlayerMemory
Battle->>Context: Build AI context
Tracker-->>Context: Supply PlayerMemory
Context->>Boss: decide(context)
Boss->>Context: getPlayerMemory()
Context-->>Boss: Return PlayerMemory
Boss->>Memory: favoursAttack()
Memory-->>Boss: true or false
Boss->>Memory: isUndefended()
Memory-->>Boss: true or false
Boss->>Boss: Adjust action weights
Boss-->>Battle: Return EnemyIntent
| Event | Effect |
|---|---|
| Attack card played |
attackCardsPlayed += 1, cardsPlayedThisTurn += 1
|
| Skill card played |
skillCardsPlayed += 1, cardsPlayedThisTurn += 1
|
| Player gains block | gainedBlockThisTurn = true |
| Turn ends with block |
consecutiveTurnsWithoutBlock = 0, carry cardsPlayedThisTurn into cardsPlayedLastTurn, reset current |
| Turn ends without block |
consecutiveTurnsWithoutBlock += 1, carry cardsPlayedThisTurn into cardsPlayedLastTurn, reset current |
These updates belong to the tracker, not to the immutable PlayerMemory record.
private void applyMemoryRules(
EnumMap<BossMove, Integer> weights,
PlayerMemory memory) {
if (memory.favoursAttack()) {
adjustWeight(weights, BossMove.DEFEND, 20);
}
if (memory.isUndefended()) {
adjustWeight(weights, BossMove.ATTACK, 20);
}
if (memory.cardsPlayedLastTurn() >= 4) {
adjustWeight(weights, BossMove.DAMAGE_ON_CARD_PLAY, 15);
}
}flowchart TD
A["BossAI.decide(context)"] --> B["Read PlayerMemory"]
B --> C{"favoursAttack()?"}
C -- "Yes" --> D["Increase DEFEND weight"]
C -- "No" --> E["Keep current DEFEND weight"]
D --> F{"isUndefended()?"}
E --> F
F -- "Yes" --> G["Increase ATTACK weight"]
F -- "No" --> H["Keep current ATTACK weight"]
G --> I{"cardsPlayedLastTurn >= 4?"}
H --> I
I -- "Yes" --> J["Increase card-punishment weight"]
I -- "No" --> K["Keep current punishment weight"]
J --> L["Perform weighted action selection"]
K --> L
PlayerMemory supplies information only; how that information affects combat remains the responsibility of BossAI.
PlayerMemoryTest covers stored values, boundaries, validation, empty-state behaviour, and value equality.
| Test | Purpose |
|---|---|
shouldStorePlayerBehaviourValues |
All four accessors |
shouldFavourAttackWhenThresholdAndRatioAreMet |
Valid attack preference |
shouldNotFavourAttackBelowMinimumAttackThreshold |
Minimum attack boundary |
shouldNotFavourAttackWhenAttackCountIsExactlyTwiceSkillCount |
Strict ratio boundary |
shouldFavourAttackWhenAttackCountIsMoreThanTwiceSkillCount |
Above the ratio boundary |
shouldBeUndefendedAfterThreeTurnsWithoutBlock |
Undefended threshold |
shouldNotBeUndefendedBeforeThreeTurnsWithoutBlock |
Below the threshold |
shouldRemainUndefendedBeyondThreshold |
Above the threshold |
shouldProvideNeutralEmptyMemory |
Empty-state values |
shouldReuseEmptyMemoryInstance |
Shared instance |
shouldRejectNegativeValues |
Validation for all four fields |
shouldUseRecordValueEquality |
Generated equals() and hashCode()
|
Boundary coverage: attack preference 3/0 → false, 4/1 → true, 4/2 → false, 5/2 → true; undefended 2 → false, 3 → true, 5 → true.
.\gradlew.bat :core:test --tests "com.csse3200.game.components.enemy.PlayerMemoryTest"
.\gradlew.bat spotlessApplyWhy a Java record? The class holds data and derived queries with no mutable lifecycle. A record expresses immutability, value identity, and safe sharing; a mutable class would require manually written fields, accessors, and equality methods.
Why not a Component? Components belong to an entity and participate in create(), update(), and dispose(). A snapshot needs no entity reference or lifecycle. A future tracker may be a component, but the snapshot itself remains a value object.
Why derived methods? Without them, every AI would duplicate the calculation attackCardsPlayed() >= 4 && attackCardsPlayed() > skillCardsPlayed() * 2, which could lead to inconsistent thresholds. The derived methods provide one authoritative interpretation.
Why an empty object? It avoids scattering null checks throughout the enemy system. Under PlayerMemory.empty(), both behaviour checks return false, so AI code can read memory unconditionally.
Why reject negative values? All four values are counts, and negative counts indicate an error in event tracking or snapshot creation. Rejecting them at construction time prevents incorrect data from influencing AI decisions later in the battle.
Why separate tracking from the snapshot? Tracking is mutable and event-driven, while a decision snapshot must remain stable. Separating them guarantees that the values an AI reads cannot change halfway through a single decision.
The current implementation does not yet:
- Listen to player card events
- Distinguish card subtypes beyond attack and skill
- Detect block gain automatically
- Reset the undefended counter
- Track cards played during the current turn
- Inject memory into
EnemyAIContext - Modify Boss AI weights
- Persist memory between battles
- Apply memory decay, rolling turn windows, or per-enemy memory
These responsibilities belong to later tasks.
PlayerMemory is the immutable data foundation of the Enemy Memory System. It provides:
Four player-behaviour counters
+ Attack-preference detection
+ Undefended-player detection
+ Centralised balance thresholds
+ Non-negative validation
+ A reusable empty state
+ Record value equality
+ Boundary-focused unit tests
It separates stored knowledge from event tracking and AI decision-making:
Battle systems collect events
↓
A tracker maintains mutable counters
↓
PlayerMemory captures an immutable snapshot
↓
EnemyAIContext delivers the snapshot
↓
Enemy AI adjusts its strategy
This structure allows enemies and Bosses to evolve from reacting only to immediate health and armour values toward adapting to the player's longer-term combat strategy.