Combat‐Stats‐System - UQcsse3200/2026-studio-3 GitHub Wiki

Team 7 | Health/Death: stable, Armour/Block: stable, Status Effects: framework stable, turn-wiring pending | Issues: #47

Overview

This page describes how other teams interact with the player's combat stats. The component is CombatStatsComponent — health, max health, death, armour, and status effects all live here for both the player and enemies. Method signatures below are stable; you may call your code against them.

Unlike the enemy system, there's no separate stats class for the player — the player entity gets a CombatStatsComponent attached directly by PlayerFactory, the same class NPCs use.

Quick Start

In the real game, the player is created through PlayerFactory.createPlayer(), which requires engine services to be running — don't call it from a unit test. For tests or isolated logic, construct the component directly:

Real game (inside a GameArea)

Entity player = PlayerFactory.createPlayer();

CombatStatsComponent stats = player.getComponent(CombatStatsComponent.class);

Tests / isolated usage — no factory needed

Entity entity = new Entity();

CombatStatsComponent stats = new CombatStatsComponent(/* health */ 100, /* baseAttack */ 10, /* maxHealth */ 100);

entity.addComponent(stats);

stats.takeDamage(15); // health drops, block/armour absorb first

stats.heal(5); // heal cannot exceed max health

boolean dead = stats.isDead(); // check whether the player is dead or not. Return true if player is dead.

Health & Death

Method Description
CombatStatsComponent(int health, int baseAttack) Max health defaults to starting health (used by NPCs)
CombatStatsComponent(int health, int baseAttack, int maxHealth) Independent max health (used by the player)
int getHealth() Current health
int getMaxHealth() Max health
void setHealth(int health) Sets health directly, floored at 0, capped at max — prefer takeDamage/heal unless you have a reason to bypass them
void heal(int health) Adds health, ignores non-positive amounts, capped at max
void takeDamage(int damage) Pipeline: block absorption → armour absorption → health. No longer applies status-effect damage modifiers internally — see Status Effects section. Fires the death event exactly once
public Boolean isDead() True when health is 0
void setMaxHealth(int) / void addMaxHealth(int) Floored at a minimum of 1

Usage

CombatStatsComponent stats = entity.getComponent(CombatStatsComponent.class);

stats.takeDamage(8); // a card deals 8 damage — routes through armour/status first

if (stats.isDead()) { // handle death }

Armour & Block

Armour and Block are two damage-reduction pools stored in CombatStatsComponent. The same component is used by both the player and enemies, so the rules below apply to both, unless stated otherwise.

Block Armour
Lasts for The current player turn The whole battle
Resets To 0 at the start of every player turn, whether used or not Only when consumed by damage, reduced by Sunder, or a new battle starts
Who can have it Player only Player and enemies
Typical use Short-term reaction to an enemy's attack intent Long-term investment that builds up over several turns

Note: Earlier versions of this page described Armour as "permanent". This was misleading. Armour persists across turns within a battle, but does not carry over to the next battle.

API

Method Description
int getArmour() Current armour
void setArmour(int armour) Clamped to a minimum of 0
void addArmour(int amount) Adds armour; non-positive amounts are ignored
void clearArmour() Sets armour to 0
int getBlock() Current block
void setBlock(int block) Clamped to a minimum of 0
void addBlock(int amount) Adds block; non-positive amounts are ignored
void resetBlock() Sets block to 0. Called by BattleController at the start of each player turn
takeDamage(int damage) Normal damage: goes through Block, then Armour, then Health
takePiercingDamage(int damage) Pierce damage: ignores Block and Armour, goes straight to Health

Usage

// A FORTIFY card (e.g. Iron Oath) resolves
stats.addArmour(5);
// 20 health, 5 armour, takes 8 damage -> armour absorbs 5, remaining 3 reduces health to 17

// A BLOCK card (e.g. Defend) resolves
stats.addBlock(5);
// 20 health, 5 block, takes 8 damage -> block absorbs 5, remaining 3 reduces health to 17

// Both together
stats.addBlock(3);
stats.addArmour(4);
stats.takeDamage(10);
// block absorbs 3, remaining 7 -> armour absorbs 4, remaining 3 -> health -3

Damage resolution

flowchart TD
    A[Incoming damage] --> B{Pierce damage?}
    B -- Yes --> H[Reduce Health]
    B -- No --> C{Block > 0?}
    C -- Yes --> D[Block absorbs as much as it can]
    C -- No --> E{Armour > 0?}
    D --> E
    E -- Yes --> F[Armour absorbs as much as it can]
    E -- No --> G{Damage left?}
    F --> G
    G -- Yes --> H
    G -- No --> I[Health unchanged]
Loading

Example: an entity with 20 Health, 2 Block and 5 Armour takes 10 damage. Block absorbs 2 → Armour absorbs 5 → the remaining 3 reduces Health to 17.

How Block and Armour change during a battle

flowchart LR
    S[Battle starts<br/>Player: Block 0, Armour 0<br/>Enemies: Armour from config] --> T1
    T1[Player turn<br/>Block reset to 0<br/>Cards can add Block / Armour] --> E1
    E1[Enemy turn<br/>Enemy attacks hit Block first, then Armour<br/>Enemy Defend adds Armour] --> T2
    T2[Next player turn<br/>Block reset to 0<br/>Armour stays] --> E2[...]
    E2 --> N[Battle ends<br/>Next battle starts with player Armour 0]
Loading

Example across two turns:

Block Armour
Player plays Defend (Block) and Iron Oath (Armour) gains Block gains Armour
Enemy attacks used first used after Block
Next player turn starts reset to 0 stays

Where Armour and Block come from

Player

flowchart LR
    C1["BLOCK card effect<br/>e.g. Defend"] -->|adds| PB["Player Block"]
    C2["FORTIFY card effect<br/>e.g. Iron Oath"] -->|adds| PA["Player Armour"]
    I1["Iron Aegis item<br/>+5 per copy"] -->|adds| PA
Loading

Enemy

flowchart LR
    E1["Enemy config<br/>armour field"] -->|starting value| EA["Enemy Armour"]
    E2["Enemy Defend intent"] -->|adds| EA
    C3["SUNDER card effect"] -.->|reduces| EA
Loading
Source Target Type Code
BLOCK card effect (e.g. Defend) Player Block CardEffectHandler.applyPlayerEffects
FORTIFY card effect (e.g. Iron Oath) Player Armour CardEffectHandler.applyPlayerEffects
Iron Aegis item Player Armour (+5 per copy) IronAegisEffect
Enemy config (armour field) Enemy Starting Armour EnemyFactory
Enemy Defend intent Enemy Armour EnemyBehaviourComponent
SUNDER card effect Enemy Reduces Armour (not below 0) CardEffectHandler.applyEnemyEffects

Enemy starting Armour (from the enemy config):

Enemy Tier Starting Armour
Lesser Shade Normal 0
Bone Crawler Normal 2
Dark Acolyte Normal 1
Tomb Guardian Elite 5
Riven Knight Elite 0
Vitium Boss 8

Enemies use Armour rather than Block because several enemy behaviours depend on it building up across turns (e.g. Bone Crawler's defensive stance, Dark Acolyte's armour scaling, Tomb Guardian's shield-break retaliation).

UI and events

sequenceDiagram
    participant BC as BattleController
    participant CEH as CardEffectHandler
    participant CS as CombatStatsComponent
    participant UI as PlayerStatsDisplay
    BC->>CEH: applyPlayerEffects(effects, player)
    CEH->>CS: addArmour(value)
    CS-->>UI: "updateArmour" event
    UI->>UI: show Armour icon + number, health bar turns blue
Loading
Value Event Displayed by
Armour updateArmour PlayerStatsDisplay, EnemyStatsDisplay: Armour icon and number next to the health bar; health bar turns blue while Armour > 0
Block updateBlock Block display on the health bar is being added by Team 3 (#428)

On the player's health bar, the colour is: blue if Armour > 0, otherwise red if Health ≤ 40%, otherwise green. The player's Armour icon is only shown while Armour > 0.

Integration guide

  • To give an entity Armour or Block: call addArmour(amount) or addBlock(amount) on its CombatStatsComponent. Do not call setArmour with a computed negative value; it is clamped to 0.
  • To display Armour or Block: listen to the updateArmour or updateBlock event on the entity, and read the current value with getArmour() or getBlock().
  • Block resets automatically at the start of each player turn. No extra code is needed.
  • Pierce damage should use takePiercingDamage, which skips both pools.

Status Effects

Method Description
void applyStatusEffect(StatusEffect effect) Applies an effect; overwrites an existing effect with the same type (not stacked)
void applyStatusEffect(String type, int value, int duration) Convenience overload — builds the StatusEffect internally
StatusEffect getStatusEffect(String type) Returns the active effect, or null
boolean hasStatusEffect(String type) Whether an effect is active
void removeStatusEffect(String type) Removes an effect explicitly
void updateStatusEffects() Ticks all active effects down by one, removing expired ones. Effects with duration ≤ 0 are treated as permanent and never removed (e.g. Strength). Must be called exactly once per turn — not yet wired to a turn manager

StatusEffect fields are named type (String), value (int) and duration (int), aligned with Team 6's EffectConfig naming.

Usage

stats.applyStatusEffect(new StatusEffect("VULNERABLE", 2, 2));

stats.applyStatusEffect("VULNERABLE", 2, 2); // three-arg overload

stats.applyStatusEffect("STRENGTH", 2, 0); // duration 0 = permanent

Concrete Status Effect Behaviour

Concrete Status Effect calculations are handled separately by StatusEffectCalculator. It reads the active Status Effects stored in CombatStatsComponent and provides the corresponding calculation values for other combat systems.

Method Description

Method Description
getOutgoingDamageModifier(CombatStatsComponent stats) Returns the outgoing damage modifier based on Feeble. Returns 0.75 when Feeble is active and 1.0 otherwise.
getIncomingDamageModifier(CombatStatsComponent stats) Returns the incoming damage modifier based on Vulnerable. Returns 1.5 when Vulnerable is active and 1.0 otherwise.
getPoisonDamage(CombatStatsComponent stats) Returns the current Poison value as damage. Returns 0 when Poison is not active.

Status Effect Behaviour

Feeble
Feeble reduces outgoing damage by 25% while active. The current implementation uses a fixed 0.75 modifier.

Vulnerable
Vulnerable increases incoming damage while active. The current implementation uses a fixed 1.5 modifier.

Poison
Poison returns its current stored value as the damage value when queried.

Player Actions

Resolves the player's attack and defend actions against CombatStatsComponent. Lives in PlayerBehaviourComponent, attached by PlayerFactory, so every player entity has one. Self-contained — no dependency on components.combat; the battle controller calls into it, not the reverse.

Method Description
void attack(Entity target, int cardDamage) Deals cardDamage + baseAttack to the target, scaled by status-effect modifiers, then routed through takeDamage(). No-op if target is null or either entity lacks a CombatStatsComponent
void defend(int amount) Adds amount to the player's armour

Usage

PlayerBehaviourComponent behaviour = player.getComponent(PlayerBehaviourComponent.class);

behaviour.attack(enemyEntity, 5); // a 5-damage card resolves // player baseAttack 5, no status effects -> 10 damage to the enemy

behaviour.defend(5); // a defend card resolves -> player gains 5 armour

Damage calculation

attack() computes cardDamage + baseAttack, then applies the attacker's outgoing modifier (FEEBLE → 0.75×) and the target's incoming modifier (VULNERABLE → 1.5×), rounds to the nearest int, and passes the result to takeDamage() — so block and armour still absorb as normal.

playerStats.applyStatusEffect("FEEBLE", 1, 2); enemyStats.applyStatusEffect("VULNERABLE", 1, 2); behaviour.attack(enemyEntity, 5); // (5 card + 5 baseAttack) x 0.75 feeble x 1.5 vulnerable = 11.25 -> rounds to 11

Events

Entities broadcast state changes through the event system. Listen rather than poll.

Event Payload Fired when
updateHealth int current health Every health write
updateMaxHealth int max health Every max-health write
entityIsDead none Health reaches 0 — fires exactly once
updateArmour int current armour Every armour write
updateBlock int current block Every block write
statusEffectApplied String effect id Effect applied
statusEffectRemoved String effect id Effect removed or expired

Usage

entity.getEvents().addListener("updateHealth", (Integer h) -> updateHealthBar(h));

entity.getEvents().addListener("entityIsDead", this::onDeath);

Current Limitations

Item Current state
Status-effect damage formula No longer owned by CombatStatsComponent. takeDamage() does not calculate any status-effect-based damage modification internally — callers needing this should read the relevant effect via getStatusEffect() and calculate it themselves
updateStatusEffects() Written and tested, but not yet called by anything — needs Team 3's turn event (BattleController), which isn't merged yet
resetBlock() Same situation as updateStatusEffects() — written and tested, not yet auto-called each turn, depends on the same Team 3 turn event
NPC max health Always equals starting health — no independent config field for NPCs yet
NPC max health Always equals starting health — no independent config field for NPCs yet
hit(CombatStatsComponent) Legacy collision-damage entry point (used by TouchAttackComponent on Ghost King). Routes through takeDamage correctly, but the whole collision-attack mechanic is a candidate for removal now that combat is card-driven

Open Questions

Does addMaxHealth also heal?

Currently no — addMaxHealth only raises the ceiling, it doesn't add current health. Card effects that want both call addMaxHealth and heal separately. Confirm with the cards owner if a single combined effect is expected.

Who owns status effects on enemies?

They live on the shared CombatStatsComponent base class, so EnemyStatsComponent inherits them automatically — functionally shared already. Needs Qi Ding to confirm this is the intended design.

Where to Get It

Currently on: main Contact: <Jay> (Team 7)

Related Pages

  • Enemy System — Interface Contract
  • Event System
  • Components
⚠️ **GitHub.com Fallback** ⚠️