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
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.
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.
| 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 |
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 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.
| 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 |
// 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 -3flowchart 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]
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.
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]
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 |
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
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
| 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).
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
| 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.
-
To give an entity Armour or Block: call
addArmour(amount)oraddBlock(amount)on itsCombatStatsComponent. Do not callsetArmourwith a computed negative value; it is clamped to 0. -
To display Armour or Block: listen to the
updateArmourorupdateBlockevent on the entity, and read the current value withgetArmour()orgetBlock(). - Block resets automatically at the start of each player turn. No extra code is needed.
-
Pierce damage should use
takePiercingDamage, which skips both pools.
| 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.
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 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 |
|---|---|
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. |
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.
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 |
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
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
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 |
entity.getEvents().addListener("updateHealth", (Integer h) -> updateHealthBar(h));
entity.getEvents().addListener("entityIsDead", this::onDeath);
| 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 |
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.
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.
Currently on: main
Contact: <Jay> (Team 7)
- Enemy System — Interface Contract
- Event System
- Components