PlayerMemory - UQcsse3200/2026-studio-3 GitHub Wiki

Player Memory System

1. Overview

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.


2. Implementation Status

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

3. File Locations

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;

4. Class Definition

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.


5. UML Class Diagram

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()
    }
Loading

Generated by Java: the four accessors, equals(), hashCode(), toString().

Implemented by the system: favoursAttack(), isUndefended(), empty().


6. Stored Information

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.


7. Balance Thresholds

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.


8. Attack Preference Detection

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.

Decision Flow

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"]
Loading

Example Results

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.


9. Undefended-Player Detection

public boolean isUndefended() {
  return consecutiveTurnsWithoutBlock
      >= UNDEFENDED_TURN_THRESHOLD;
}

Decision Flow

flowchart TD
    A["isUndefended()"] --> B{"consecutiveTurnsWithoutBlock >= 3?"}
    B -- "No" --> C["Return false"]
    B -- "Yes" --> D["Return true"]
Loading
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.


10. Empty Memory

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();  // false

Empty Object Flow

flowchart 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"]
Loading

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.


11. Input Validation

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");
  }
}

Construction Flow

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()"]
Loading

Validation prevents corrupted tracking data from silently influencing AI decisions.


12. Immutability and Value Equality

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 unchanged

This 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)); // true

13. Example Usage

PlayerMemory memory = new PlayerMemory(5, 2, 3, 4);

memory.favoursAttack(); // true
memory.isUndefended();  // true

A 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();

14. Responsibility Separation

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.


15. Planned Integration

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
Loading

Planned Runtime Flow

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

Planned Sequence

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
Loading

Planned Counter Updates

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.

Planned Boss Integration

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
Loading

PlayerMemory supplies information only; how that information affects combat remains the responsibility of BossAI.


16. Testing

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 spotlessApply

17. Design Decisions

Why 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.


18. Current Limitations

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.


19. Summary

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.

⚠️ **GitHub.com Fallback** ⚠️