Card System - UQcsse3200/2026-studio-3 GitHub Wiki
This document is the canonical, project-facing description of the game's card system. It explains the feature as a complete subsystem: the card catalogue, configuration format, runtime identity, upgrade resolution, deck lifecycle, combat integration, persistence, extension process, testing strategy and the architectural decisions behind the implementation.
The source code and configuration remain authoritative. Where an integration is incomplete, this document labels it as a limitation rather than describing planned behaviour as complete.
- System overview
- Current implementation status
- Card catalogue
- Core concepts and invariants
- System architecture
- Configuration and validation
- Runtime behaviour
- Effect semantics
- Integration guide
- Card authoring and extension guide
- Testing and validation
- Architecture decisions and reflection
- Known limitations and future work
- Glossary and references
Cards are data-driven game objects that connect long-term deck ownership to combat actions. Each definition describes what a kind of card costs, targets and does. Each card owned by the player is a separate runtime instance, allowing two copies of the same definition to have different upgrade states without duplicating configuration.
The subsystem provides:
- validated JSON card definitions and artwork references;
- a read-only definition lookup API;
- stable identity for every owned copy;
- independent base and upgraded values for a copy;
- persistent and combat-scoped deck models;
- ordered effect resolution;
- target, energy and failure validation for card play;
- integration boundaries for player state, enemies, shops and Save/Load; and
- automated tests across configuration, gameplay and cross-system boundaries.
The principal data flow is:
flowchart LR
JSON["configs/cards.json"] --> Loader["CardConfigLoader"]
Loader --> Validator["CardValidator"]
Validator --> Library["CardLibrary / CardService"]
Library --> Factory["CardInstanceFactory"]
Factory --> PlayerDeck["PlayerDeck"]
PlayerDeck --> BattleDeck["BattleDeck"]
BattleDeck --> Resolver["CardResolver"]
Library --> Resolver
Resolver --> Resolved["ResolvedCard"]
Resolved --> UI["Battle hand UI"]
Resolved --> Play["CardPlayService"]
Play --> Effects["ResolvedCardEffect list"]
Effects --> Enemy["Enemy state"]
Effects --> Player["Player state"]
PlayerDeck <--> Save["Save/Load DTOs"]
PlayerDeck <--> Shop["Shop and reward adapter"]
The card subsystem deliberately separates definition, ownership and execution:
- The card library owns static definitions and their validation.
- The persistent player deck owns the exact copies collected during a run.
- The battle deck owns draw, hand and discard state for one combat.
- The play service validates and resolves an attempted play.
- The battle and entity systems apply resolved effects to real player and enemy state.
- The UI selects a card instance and target; it does not decide card rules.
- The Save/Load system serialises identity and upgrade state without serialising live objects.
| Capability | Status | Notes |
|---|---|---|
| Card configuration | Implemented | 27 definitions load from one JSON file. |
| Artwork references | Implemented | Every configured card currently points to an existing PNG asset. |
| Definition validation | Implemented | Structural and semantic errors are collected and reported. |
| Definition lookup | Implemented | Cards are retrieved by case-sensitive card ID. |
| Persistent deck | Implemented | Duplicates are allowed; every owned copy has unique identity. |
| Battle deck | Implemented | Draw, hand, discard and automatic reshuffle preserve identity. |
| Per-instance upgrades | Implemented | Upgrade level 0 or 1 is stored on each owned copy. |
| Upgrade data | Partially populated | 6 of 27 definitions currently provide an upgrade block. |
| Instance-aware card play | Implemented | Requests select one hand copy by instance ID. |
| Shop rollback | Implemented | A failed transaction removes the exact copy it created. |
| Save/Load | Implemented for the current schema | Instance ID, card ID and upgrade level are persisted. |
| Dynamic battle hand | Partially implemented | Hand buttons use resolved artwork/name and carry instance IDs. |
| Reusable card widget and tooltip | Deferred | Cost, rarity, rules text and hover details are not yet composed by a dedicated widget. |
| Status-duration scheduling | Partially integrated | Timed healing is wired into player turns; the general status tick remains externally scheduled. |
The repository currently contains 27 card definitions. Effects in the tables are shown in their configured execution order. The gameplay-role column explains the mechanical role supported by the configuration; it is not a separate source of rules.
| Card ID | Display name | Energy | Type / rarity | Target | Ordered effects | Gameplay role |
|---|---|---|---|---|---|---|
| strike | Strike | 1 | Attack / Common | Single enemy | Damage 6 | Baseline efficient single-target attack. |
| warding_sweep | Warding Sweep | 2 | Attack / Common | All enemies | Damage 4 | Low damage per enemy in exchange for full-area coverage. |
| defend | Defend | 1 | Skill / Common | Self | Block 5 | Baseline short-term defence. |
| poison_dagger | Poison Dagger | 1 | Attack / Uncommon | Single enemy | Damage 4; Poison 3 for 3 turns | Cheap hybrid attack that begins damage over time. |
| expose | Expose | 1 | Skill / Uncommon | All enemies | Vulnerable 2 for 2 turns | Low-cost area setup for later attacks. |
| inner_focus | Inner Focus | 2 | Power / Rare | Self | Strength 2 | Sacrifices immediate tempo for a combat-long damage increase. |
| bandage | Bandage | 1 | Skill / Common | Self | Heal 6 immediately | Low-cost immediate sustain. |
| sentinels_rebuke | Sentinel's Rebuke | 1 | Attack / Uncommon | Single enemy | Damage 4; Feeble 1 for 2 turns | Trades raw damage for short-term enemy weakening. |
| poison_flask | Poison Flask | 1 | Skill / Uncommon | Single enemy | Poison 5 for 3 turns | Focused, low-cost damage-over-time setup. |
| poison_blade | Poison Blade | 2 | Attack / Uncommon | Single enemy | Pierce 10; Poison 4 for 2 turns | Higher-cost attack that bypasses mitigation and adds poison. |
| poison_cloud | Poison Cloud | 2 | Skill / Uncommon | All enemies | Poison 3 for 3 turns | Area damage-over-time setup. |
| poison_mark | Poison Mark | 1 | Skill / Rare | Single enemy | Vulnerable 2 for 2 turns; Poison 2 for 2 turns | Compact dual-debuff setup for one priority target. |
| starfall | Starfall | 2 | Attack / Uncommon | All enemies | Damage 6 | Strength-scaling area attack. |
| rift_lance | Rift Lance | 1 | Attack / Uncommon | Single enemy | Damage 4; Vulnerable 1 for 2 turns | Cheap attack that prepares later attacks against the same target. |
| wardens_judgement | Warden's Judgement | 2 | Attack / Uncommon | Single enemy | Damage 9 | Focused direct damage. |
| unseal_the_breach | Unseal the Breach | 1 | Attack / Uncommon | Single enemy | Sunder 3; Damage 2 | Removes armour before applying a small hit. |
| astral_ward | Astral Ward | 2 | Skill / Uncommon | Self | Block 4; Strength 1; Energy Gain 1 | Defensive setup with a combat-long attack bonus and partial energy refund. |
| resurrection | Resurrection | 5 | Skill / Rare | Self | Heal 6 for 3 turns | High-investment delayed sustain worth up to 18 health. |
| makeshift_shelter | Makeshift Shelter | 2 | Skill / Uncommon | Self | Block 7; Heal 3 | Mixed immediate defence and recovery. |
| rubble_barricade | Rubble Barricade | 2 | Skill / Uncommon | Self | Block 12 | Dedicated high-output short-term defence. |
| emergency_salve | Emergency Salve | 2 | Skill / Uncommon | Self | Heal 10 immediately | Dedicated immediate recovery. |
| purify | Purify | 1 | Skill / Uncommon | Self | Cleanse 1 | Removes Poison, Vulnerable and Feeble from the player. |
| sealed_pact | Sealed Pact | 3 | Power / Rare | Self | Strength 3; Block 6 | Expensive permanent offence combined with immediate defence. |
| blood_price | Blood Price | 3 | Attack / Uncommon | Single enemy | Damage 20 | Expensive high-impact single-target attack. |
| doom_sigil | Doom Sigil | 2 | Skill / Rare | Single enemy | Vulnerable 1 for 2 turns; Poison 4 for 3 turns | Priority-target setup combining amplification and damage over time. |
| eclipse_decree | Eclipse Decree | 3 | Skill / Rare | All enemies | Feeble 1 for 2 turns | Expensive area-wide outgoing-damage suppression. |
| iron_oath | Iron Oath | 1 | Skill / Uncommon | Self | Fortify 4 | Low-cost permanent armour gain. |
The Astral set is designed around setup, Strength scaling, Vulnerable and delayed recovery:
- Rules: Deal 6 damage to all enemies.
- Cost rationale: Two energy buys area coverage. Its per-target base damage matches Strike, so its value rises with enemy count and with Strength.
- Tactical purpose: Converts Strength from Inner Focus or Astral Ward into damage against every living enemy.
- Resolution detail: The damage calculation is performed separately for each application by the consuming combat path. Strength is included before percentage modifiers.
- Rules: Deal 4 damage, then apply 1 Vulnerable for 2 turns.
- Cost rationale: At one energy it deals less immediate damage than Strike in exchange for improving later attacks.
- Tactical purpose: Opens a priority target for a stronger follow-up.
- Resolution detail: Effects execute in JSON order. Vulnerable is applied after damage, so it does not amplify Rift Lance's own hit.
- Rules: Gain 4 Block, gain 1 Strength for the rest of combat, then restore 1 Energy.
- Cost rationale: The card requires two energy up front because it combines defence and a combat-long bonus. A successful play refunds one energy, producing an effective net cost of one.
- Tactical purpose: Stabilises the current turn while improving later attacks, particularly Starfall.
- Resolution detail: The player must be able to pay the full cost before the refund occurs.
- Rules: Heal 6 health at the start of each of the next three player turns.
- Cost rationale: Five energy makes the potential 18 health a deliberate build-around investment rather than routine sustain.
- Tactical purpose: Rewards planning ahead in longer encounters.
- Resolution detail: It does not heal on the play that creates the effect. Duration decreases even when the player is already at maximum health, and the effect is cleared on defeat without reviving the player.
Only definitions listed below can currently produce a valid level-1 instance.
| Base card | Upgraded card | Energy | Upgraded effects |
|---|---|---|---|
| Strike | Strike+ | 1 | Damage 12 |
| Sealed Pact | Sealed Pact+ | 3 | Strength 4; Block 9 |
| Blood Price | Blood Price+ | 3 | Damage 28 |
| Doom Sigil | Doom Sigil+ | 2 | Vulnerable 2 for 2 turns; Poison 6 for 3 turns |
| Eclipse Decree | Eclipse Decree+ | 3 | Feeble 1 for 3 turns |
| Iron Oath | Iron Oath+ | 1 | Fortify 6 |
An upgrade shares the base definition's card ID, card type, target and artwork. It may replace the display name, description, cost, rarity and complete ordered effect list.
The standard starter deck contains ten independently identified base-level instances:
- 3 Strike;
- 3 Defend;
- 1 Poison Dagger;
- 1 Expose;
- 1 Bandage; and
- 1 Inner Focus.
Duplicate definitions are intentional. There is currently no persistent-deck size limit.
| Concept | Purpose | Identity | Mutability |
|---|---|---|---|
| CardConfig | Shared definition loaded from JSON | card ID | Mutable transport object; consumers must treat registered definitions as read-only. |
| CardUpgradeConfig | Optional replacement values for level 1 | Owned by its CardConfig | Mutable transport object. |
| CardInstance | One exact copy owned by the player | instance ID plus card ID | Immutable record. |
| ResolvedCard | Final base or upgraded values for one copy | instance ID plus card ID | Immutable and defensively copies effects. |
| CardInstanceSaveData | Serializable snapshot of one copy | Saved instance ID plus card ID | Mutable no-argument DTO for JSON. |
These identifiers are not interchangeable:
- cardId identifies a shared definition, such as strike. Use it to look up configuration, create a new copy, offer a card in a shop or count copies of a type.
- instanceId identifies one owned copy, such as a generated UUID. Use it to upgrade, remove, select, play or discard an exact card.
If a hand contains three Strike copies, all three have cardId = strike but have different instance IDs. Passing strike to an instance-only operation is rejected; the system never guesses which copy the caller intended.
- Card IDs are non-blank, trimmed, case-sensitive and unique in the library.
- Instance IDs are non-blank and unique within a player deck.
- Upgrade level is exactly 0 (base) or 1 (upgraded).
- A level-1 instance is valid only if its current definition contains an upgrade block.
- A CardConfig and CardInstance may be resolved only when their card IDs match.
- An upgrade changes one instance while preserving its instance ID and deck position.
- Base and upgraded definitions share type, target and artwork.
- Effects execute in configured array order.
- Public deck and resolution snapshots are immutable.
- Expected card-play failures do not spend energy or move cards.
classDiagram
class CardService {
<<interface>>
+getCard(cardId) Optional~CardConfig~
+getAllCards() List~CardConfig~
}
class CardLibrary {
-Map~String, CardConfig~ cards
+register(config)
}
class CardConfig {
+String id
+String name
+String description
+int cost
+CardType type
+Rarity rarity
+TargetType target
+EffectConfig[] effects
+String texturePath
+CardUpgradeConfig upgrade
}
class CardUpgradeConfig {
+String name
+String description
+int cost
+Rarity rarity
+EffectConfig[] effects
}
class CardInstance {
+String instanceId
+String cardId
+int upgradeLevel
+upgrade() CardInstance
}
class CardInstanceFactory {
+create(cardId) CardInstance
+createUpgraded(cardId) CardInstance
}
class CardResolver {
+resolve(config, instance) ResolvedCard
}
class ResolvedCard {
+String instanceId
+String cardId
+int cost
+List~EffectConfig~ effects
+boolean upgraded
}
class PlayerDeck {
-List~CardInstance~ cards
+addCard(instance)
+upgradeCard(instanceId)
+removeCard(instanceId)
}
class BattleDeck {
-List~CardInstance~ drawPile
-List~CardInstance~ hand
-List~CardInstance~ discardPile
+drawOne() CardInstance
+playCard(instanceId)
}
class CardPlayService {
+resolveInHand(instanceId) Optional~ResolvedCard~
+canPlay(request) boolean
+playCard(request) CardPlayResult
}
class DeckSaveData
class CardInstanceSaveData
CardService <|.. CardLibrary
CardConfig "1" o-- "0..1" CardUpgradeConfig : optional upgrade
CardConfig "1" o-- "1..*" EffectConfig : base effects
CardUpgradeConfig "1" o-- "1..*" EffectConfig : upgrade effects
CardInstanceFactory --> CardService : validates definition
CardInstance --> CardConfig : cardId lookup
CardResolver --> CardConfig
CardResolver --> CardInstance
CardResolver --> ResolvedCard
PlayerDeck "1" o-- "*" CardInstance
BattleDeck "1" o-- "*" CardInstance
CardPlayService --> BattleDeck
CardPlayService --> CardService
CardPlayService --> CardResolver
DeckSaveData "1" o-- "*" CardInstanceSaveData
CardInstanceSaveData ..> CardInstance : restore
| Package | Responsibility |
|---|---|
| cards | Loading, validation, enums, service and library. |
| cards.configs | JSON transport objects for definitions and effects. |
| cards.runtime | Instance identity and base/upgrade resolution. |
| cards.deck | Persistent and combat deck state. |
| cards.effects | Ordered effect calculation and immutable results. |
| cards.play | Unified request, validation, transaction and result model. |
| cards.play.integration | Player, enemy and battle-flow integration boundaries. |
| encounters.integration | Shop/reward deck gateway and transactional rollback. |
| save | Serializable DTOs, capture and restore validation. |
sequenceDiagram
actor Player
participant UI as Battle hand UI
participant Actions as BattleActions
participant Controller as BattleController
participant Play as CardPlayService
participant Deck as BattleDeck
participant Library as CardService
participant Resolver as CardResolver
participant Energy as EnergyComponent
participant Effects as CardEffectResolutionService
participant State as Player and enemy consumers
Player->>UI: select exact card and target
UI->>Actions: playCard(instanceId, targetId)
Actions->>Controller: resolveCardInHand(instanceId)
Controller->>Play: resolveInHand(instanceId)
Play->>Deck: getCardInHand(instanceId)
Deck-->>Play: CardInstance
Play->>Library: getCard(instance.cardId)
Library-->>Play: CardConfig
Play->>Resolver: resolve(config, instance)
Resolver-->>Play: ResolvedCard
Play-->>Actions: resolved name and target
Actions->>Controller: submitCardPlayRequest(request)
Controller->>Play: playCard(request)
Play->>Energy: canAfford and spend resolved cost
Play->>Effects: resolve ResolvedCard in effect order
Effects-->>Play: CardEffectResolution
Play->>Deck: move exact instance to discard
Play-->>Controller: successful CardPlayResult
Controller->>Deck: draw replacement
Controller->>State: apply ordered resolved effects
Controller-->>UI: publish updated hand
The JSON transport classes must be mutable for libGDX deserialisation. That mutability is contained at the configuration boundary:
- CardLibrary returns an immutable list snapshot, but it does not deep-copy each CardConfig.
- Registered configurations must therefore be treated as read-only.
- CardResolver constructs a new ResolvedCard without modifying either input.
- ResolvedCard deep-copies effect values on construction and again when effects are read.
- PlayerDeck and BattleDeck return immutable collection snapshots.
The root JSON object contains a non-empty cards array. A base definition has the following shape:
{
"id": "rift_lance",
"name": "Rift Lance",
"description": "Deal 4 damage. Apply 1 Vulnerable for 2 turns.",
"cost": 1,
"type": "ATTACK",
"rarity": "UNCOMMON",
"target": "SINGLE_ENEMY",
"effects": [
{ "type": "DAMAGE", "value": 4, "duration": 0 },
{ "type": "VULNERABLE", "value": 1, "duration": 2 }
],
"texturePath": "images/cards/rift_lance.png"
}An upgrade is nested inside the same definition:
{
"id": "strike",
"name": "Strike",
"description": "Deal 6 damage.",
"cost": 1,
"type": "ATTACK",
"rarity": "COMMON",
"target": "SINGLE_ENEMY",
"effects": [
{ "type": "DAMAGE", "value": 6, "duration": 0 }
],
"texturePath": "images/cards/strike.png",
"upgrade": {
"name": "Strike+",
"description": "Deal 12 damage.",
"cost": 1,
"rarity": "COMMON",
"effects": [
{ "type": "DAMAGE", "value": 12, "duration": 0 }
]
}
}The upgrade does not repeat id, type, target or texturePath because those fields are inherited.
| Field | Values |
|---|---|
| type | ATTACK, SKILL, POWER, STATUS, CURSE |
| rarity | COMMON, UNCOMMON, RARE |
| target | SELF, SINGLE_ENEMY, ALL_ENEMIES |
| effect type | DAMAGE, BLOCK, HEAL, POISON, VULNERABLE, FEEBLE, STRENGTH, SUNDER, ENERGY_GAIN, PIERCE, CLEANSE, FORTIFY |
Card type describes a card's deck-building role. It does not determine its target or effects. For example, an Attack may deal damage and apply a debuff, while a Skill may target all enemies.
CardConfigLoader checks the root structure, required fields, JSON types and duplicate IDs. CardValidator applies semantic rules:
- id and name must not be blank;
- id must not contain surrounding whitespace;
- cost must be zero or greater;
- type, rarity and target must be present;
- texturePath must not be blank;
- at least one non-null effect is required;
- every effect value must be positive;
- Poison, Vulnerable and Feeble require a positive duration;
- Heal may use duration 0 for immediate healing or a positive duration for timed healing;
- instant and combat-long effects must use duration 0;
- self targets accept Block, Heal, Strength, Energy Gain, Cleanse and Fortify;
- enemy targets accept Damage, Pierce, Sunder and duration-based debuffs;
- an upgrade, when present, requires a non-blank name and description, non-negative cost, rarity and a valid non-empty effect list; and
- upgrade effects must be compatible with the inherited target.
The loader aggregates all detected problems into one CardLoadingException so authors can fix a configuration in one pass. Validation does not currently check that an artwork file exists; asset existence is covered by integration tests and manual verification.
CardLibrary stores validated definitions in a map:
- duplicate IDs are rejected rather than overwritten;
- lookup of an unknown, null or blank ID returns Optional.empty();
- ID matching is case-sensitive;
- getAllCards returns an immutable snapshot; and
- catalogue order is not guaranteed because the underlying store is a HashMap.
Consumers that require display order must sort explicitly or introduce a separate ordering field.
CardInstanceFactory creates a UUID-backed base instance only after confirming that cardId is registered. createUpgraded also verifies that the definition has upgrade data.
PlayerDeck stores CardInstance values in acquisition order. It:
- permits multiple copies of the same card ID;
- rejects duplicate instance IDs, even across different card definitions;
- removes and upgrades by instance ID;
- preserves order when one copy is upgraded;
- returns immutable snapshots; and
- copies the collection without regenerating identity or upgrade state.
Eligibility rules such as currency, upgrade availability and UI choice belong to the feature that initiates an acquisition or upgrade. PlayerDeck is the state-changing boundary.
A BattleDeck copies the persistent instances into its draw pile at combat start. The persistent deck is not mutated by combat pile movement.
- The draw pile is shuffled.
- Drawing moves the original instance from draw pile to hand.
- If draw is empty, discard is automatically moved to draw and shuffled.
- Playing or discarding moves the exact selected instance from hand to discard.
- A successful play in BattleController draws one replacement.
- A rejected play leaves every pile unchanged.
Identity and upgrade level survive every move; battle start and reshuffling never generate new instance IDs.
CardInstance.upgrade returns a new immutable instance with:
- the same instance ID;
- the same card ID; and
- upgrade level changed from 0 to 1.
PlayerDeck replaces the old record in the same list position. CardResolver then combines the instance with its CardConfig:
- level 0 selects base name, description, cost, rarity and effects;
- level 1 selects those values from CardUpgradeConfig;
- type, target and texturePath always come from CardConfig; and
- the output is one copy-safe ResolvedCard for both gameplay and presentation.
Resolving mismatched IDs or a level-1 instance without an upgrade definition fails immediately.
CardPlayService is the unified gameplay entry point. It performs the following checks in order:
- The request identifies an exact instance currently in hand.
- Its card definition exists.
- The definition and resolved values pass validation.
- The selected target type matches the card and, when state views are supplied, is available.
- The player can afford the resolved cost.
- Energy is spent exactly once.
- Effects are resolved in configuration order.
- The exact instance is moved from hand to discard.
Expected failures return a CardPlayResult with a specific CardPlayFailureReason:
| Failure | Meaning |
|---|---|
| CARD_NOT_IN_HAND | The instance is not in the current hand. |
| UNKNOWN_CARD | The instance refers to a missing definition. |
| INVALID_CARD_CONFIG | The definition, upgrade or resolved snapshot is invalid. |
| INVALID_TARGET | Target type or selected enemy is invalid. |
| NOT_ENOUGH_ENERGY | The resolved cost cannot be paid. |
| RESOLUTION_FAILED | Resolution or a commit step could not complete. |
Expected failures spend no energy and move no card. If an unexpected runtime failure occurs after energy is spent, the service restores the resolved cost and propagates the exception. Battle flow draws a replacement only after a successful result.
CardPlayTarget represents one of three valid forms:
- SELF with no target ID;
- SINGLE_ENEMY with one non-blank opaque battle target ID; or
- ALL_ENEMIES with no target ID.
The active battle UI sends the card's instance ID. Self-targeting cards also send the fixed player target. Enemy cards obtain the selected entity ID from drag-and-drop. Shared enemy definition IDs must not be used as battle target IDs.
Shops offer a card type by card ID. PlayerDeckAdapter converts that request into a fresh CardInstance and records the created instance ID and insertion position as the pending addition.
- Commit clears the pending rollback metadata.
- Rollback verifies card ID, instance ID and position before removing anything.
- A failed purchase therefore cannot remove an older duplicate copy.
The adapter currently tracks one pending addition at a time and synchronises its public operations.
The current schema stores each persistent copy as:
{
"instanceId": "stable-copy-id",
"cardId": "strike",
"upgradeLevel": 1
}GameStateSnapshotProvider converts live CardInstance records to CardInstanceSaveData. Restore first validates the complete saved deck against a throwaway copy using PlayerDeck's real acceptance path. It rejects:
- null entries;
- blank or duplicate instance IDs;
- invalid upgrade levels;
- unknown card IDs; and
- a level-1 instance whose current definition has no upgrade.
Only after all save sections pass validation is the live deck cleared and rebuilt. This preserves the restore service's no-partial-mutation guarantee.
DeckSaveData.ofCardIds can synthesise base instances named legacy-0, legacy-1 and so on for tests or in-memory callers that only have definition IDs. The current JSON repository writes the instance aware cards field; a versioned migration for historical files containing only a flat cardIds field is not part of the documented production schema.
BattleScreen builds the visible hand from BattleDeck instances at 225 by 456 pixels per card. For each copy it resolves the current values and uses:
- resolved artwork;
- resolved display name;
- resolved target type; and
- the exact instance ID as the interaction payload.
The hand is rebuilt when a successful play changes it. A reusable Scene2D CardWidget, full rules text, visible cost/rarity and hover tooltip are not yet implemented.
Effects are resolved in array order and assigned a sequence number. Consumers sort by that number before applying them.
| Effect | Valid target | Duration | Meaning |
|---|---|---|---|
| DAMAGE | Enemy | 0 | Normal pre-mitigation damage. Strength and supported percentage modifiers are applied during resolution. |
| BLOCK | Self | 0 | Adds short-term Block through the canonical player adapter. |
| HEAL | Self | 0 or positive | Duration 0 heals immediately. Positive duration heals at the start of later player turns. |
| POISON | Enemy | Positive | Applies a timed damage-over-time status; ticking belongs to combat status scheduling. |
| VULNERABLE | Enemy | Positive | A positive active value applies the fixed incoming-damage multiplier. |
| FEEBLE | Enemy | Positive | A positive active value applies the fixed outgoing-damage multiplier. |
| STRENGTH | Self | 0 | Adds a flat damage bonus for the remainder of combat. |
| SUNDER | Enemy | 0 | Reduces the enemy's current armour pool. |
| ENERGY_GAIN | Self | 0 | Restores energy without exceeding maximum energy. |
| PIERCE | Enemy | 0 | Deals literal damage while bypassing Block and Armour. |
| CLEANSE | Self | 0 | Removes Poison, Vulnerable and Feeble. |
| FORTIFY | Self | 0 | Adds persistent Armour, distinct from per-turn Block. |
When external combat state is supplied, normal damage is calculated as:
damage = max(0, base damage + Strength)
multiplier = 1.0
if attacker has Feeble: multiplier *= 0.75
if target has Vulnerable: multiplier *= 1.5
final damage = floor(damage * multiplier)
The modifiers are applied once. Block, Armour, health mutation and death checks remain the responsibility of the receiving combat component. Pierce and Sunder use their configured literal values and are not increased by Strength.
Effect order is observable gameplay:
- Rift Lance damages before applying Vulnerable, so its own damage is not amplified.
- Poison Dagger damages before applying Poison.
- Astral Ward applies Block, then Strength, then the energy refund.
- Unseal the Breach is configured to Sunder before Damage.
- Doom Sigil applies Vulnerable before Poison.
Card rules text must agree with this order.
The card layer defines effect value and duration but does not own every status lifecycle rule. CombatStatsComponent currently replaces an existing status with the same key rather than stacking it. The compatibility PlayerEffectState accumulates Strength internally. Until a unified stacking contract is adopted, card authors must not assume that repeated timed effects add values or durations.
Timed healing is explicitly called at the start of each player turn. The general updateStatusEffects method exists but has no production caller in the current source tree, so Poison, Vulnerable and Feeble expiry requires completion of battle-turn scheduling.
List<CardConfig> configs = CardConfigLoader.loadCards();
CardService cards = new CardLibrary(configs);Consumers should depend on CardService instead of CardLibrary where registration is not required. Unknown lookup is handled explicitly:
CardConfig config =
cards.getCard(cardId)
.orElseThrow(() -> new IllegalArgumentException("Unknown card: " + cardId));CardInstanceFactory factory = new CardInstanceFactory(cards);
CardInstance reward = factory.create("starfall");
playerDeck.addCard(reward);Creating a new UUID is correct for acquisition. Do not generate a new ID when moving, copying, saving, loading, upgrading or entering combat.
CardInstance selected = playerDeck.upgradeCard(instanceId);The caller supplies the instance ID selected by the player. The operation fails without mutation when the copy is missing, already upgraded or has no configured upgrade.
CardInstance instance = playerDeck.getCard(instanceId).orElseThrow();
CardConfig config = cards.getCard(instance.cardId()).orElseThrow();
ResolvedCard resolved = new CardResolver().resolve(config, instance);Do not let UI and gameplay independently select base or upgraded fields. Both must consume the same ResolvedCard contract.
CardPlayRequest request =
CardPlayRequest.singleEnemy(instanceId, enemyTargetId);
CardPlayResult result = cardPlayService.playCard(request);
if (result.success()) {
enemyConsumer.applyEnemyEffects(result.target(), result.enemyEffects());
playerConsumer.applyPlayerEffects(result.playerEffects());
}Only successful results may be applied. CardPlayService resolves and commits the deck transaction; effect consumers mutate external entities.
| Operation | Required identifier |
|---|---|
| Look up a definition | cardId |
| Display a shop/reward offer | cardId |
| Create a new owned copy | cardId |
| Count copies of a definition | cardId |
| Select a hand card | instanceId |
| Upgrade an owned copy | instanceId |
| Remove an exact copy | instanceId |
| Play or discard | instanceId |
| Persist an owned copy | both IDs plus upgrade level |
BattleActions and Team3CardPlayAdapter translate UI events into CardPlayRequest. BattleController owns phase validation, successful replacement draws and distribution of resolved effects.
Team1EnemyStateAdapter:
- maps opaque battle target IDs to enemy entities;
- rejects missing and dead single targets;
- exposes current status values for damage calculation; and
- applies Damage, Pierce, Poison, Vulnerable, Feeble and Sunder in sequence.
Team7PlayerStateAdapter:
- exposes energy and player status values;
- applies Block, Heal, Strength, Energy Gain, Cleanse and Fortify in sequence; and
- preserves the distinction between Block and Armour.
UI events must carry instanceId. Display values should come from resolveInHand or another ResolvedCard source. The UI must not use cardId as a play payload even when only one copy is visible.
Shop and reward boundaries may continue to speak in cardId because each successful acquisition creates a new instance. Transactional adapters must retain the created instanceId for rollback.
Save code converts between CardInstance and CardInstanceSaveData. It must never serialise live deck or config objects directly, and restore must validate all instances before mutating run state.
- Choose a unique lower-snake-case card ID.
- Confirm its type, rarity and target.
- Choose existing effects that are compatible with that target.
- Decide effect order deliberately.
- Write rules text that matches value, duration and effect order exactly.
- Add a PNG under source/core/assets/images/cards.
- Add the definition to source/core/assets/configs/cards.json.
- Add configuration and behavioural tests.
- Run formatting, the focused suite and the full core suite.
- Verify the card in a real battle at its displayed size.
Add one nested upgrade block to the existing definition. Do not:
- create a second definition such as strike_upgraded;
- change cardId when upgrading;
- duplicate type, target or texturePath inside the upgrade; or
- mutate the shared CardConfig at runtime.
Test both base and upgraded copies together. At minimum verify:
- they share cardId but have different instance IDs;
- upgrading one copy leaves the other unchanged;
- resolved names, costs and effects differ as configured;
- gameplay spends the upgraded cost and executes upgraded effects;
- the original CardConfig was not mutated; and
- Save/Load preserves the upgraded instance.
Adding an EffectType enum constant alone is insufficient. Review and update:
- EffectType duration metadata;
- CardValidator target compatibility;
- both EffectExecutor resolution paths;
- ResolvedCardEffect consumers;
- Team1EnemyStateAdapter or Team7PlayerStateAdapter;
- the active BattleController fallback path if it remains in use;
- status ticking, reset or persistence where applicable;
- card JSON and rules text; and
- focused resolver, adapter and in-game integration tests.
New duration-based effects also require a confirmed production turn event. Do not assume that updateStatusEffects is called automatically.
- Start with an imperative verb: Deal, Gain, Apply, Heal, Restore, Remove or Reduce.
- State amount and target explicitly.
- State duration for every timed effect.
- Describe effects in the same order as the JSON array.
- Distinguish Block from Armour and normal damage from piercing damage.
- State whether healing is immediate or occurs at later turn starts.
- Avoid relying on flavour text to communicate a mechanical rule.
- Store card images under source/core/assets/images/cards.
- Use the lower-snake-case card ID as the file name.
- Keep texturePath relative to the assets directory.
- Current battle display dimensions are 225 by 456 pixels.
- Base and upgraded copies currently share artwork.
- Confirm legibility at actual hand size rather than only viewing the source image.
Before merging a card:
- configuration loads through CardConfigLoader;
- CardValidator returns no errors;
- ID is unique and trimmed;
- target accepts every configured effect;
- descriptions match execution order;
- artwork exists and loads;
- all values and durations are positive where required;
- base and upgrade remain valid independently;
- relevant card-set integration tests pass; and
- no test or production caller sends cardId to an instance-only API.
The system uses layered testing so failures are localised before full-game integration.
| Concern | Principal tests |
|---|---|
| JSON structure and loading | CardConfigLoaderTest |
| Field and semantic validation | CardValidatorTest, CardConfigTest, EffectConfigTest |
| Registration and lookup | CardLibraryTest, CardsEndToEndTest |
| Instance identity | CardInstanceTest, CardInstanceFactoryTest |
| Base/upgrade resolution | CardResolverTest, CardUpgradeConfigTest |
| Persistent and battle decks | PlayerDeckTest, BattleDeckTest, DeckIdentityTest |
| Effect calculation and ordering | EffectExecutorTest, CardEffectResolverTest, CardEffectResolutionServiceTest, TurnEffectStoreTest |
| Unified play transaction | CardPlayServiceTest, CardPlayServiceIntegrationTest, InstanceCardPlayTest |
| Cross-team state adapters | Team1EnemyStateAdapterTest, Team3CardPlayAdapterTest, Team7PlayerStateAdapterTest |
| Card collections | GuardianCardsIntegrationTest, CorruptionCardsTest, ForbiddenCardsTest, FortifyIronOathTest |
| Astral behaviour | SprintTwoCardsIntegrationTest |
| Shop exact rollback | PlayerDeckAdapterTest and shop integration tests |
| Save/Load round trip | SaveGameRestoreServiceTest, GameStateSnapshotEndToEndTest |
Regression coverage should retain these cases:
- two copies of one card receive distinct instance IDs;
- upgrading one duplicate does not change the other;
- identity survives draw, play, discard and reshuffle;
- a card definition ID is rejected as a play request;
- insufficient energy changes neither energy nor piles;
- target mismatch produces no effects;
- an unexpected post-spend failure refunds the resolved cost;
- upgraded cost and effects are used together;
- Rift Lance deals damage before applying Vulnerable;
- Astral Ward refunds energy only after its full cost is paid;
- Resurrection heals on exactly the next three player turns;
- Shop rollback removes the newly acquired duplicate;
- save round trip preserves instance ID and upgrade level; and
- invalid saved cards cause no partial live-state mutation.
Run from the source directory with Java 21:
cd source
.\gradlew.bat spotlessCheck :core:test
.\gradlew.bat :core:jacocoTestReportThe HTML coverage report is generated under source/core/build/reports/jacoco/test/html.
For focused development, use Gradle's test filter:
.\gradlew.bat :core:test --tests "com.csse3200.game.cards.*"
.\gradlew.bat :core:test --tests "com.csse3200.game.cards.play.InstanceCardPlayTest"
.\gradlew.bat :core:test --tests "com.csse3200.game.cards.SprintTwoCardsIntegrationTest"Test counts are cumulative suite metrics, not a measure of one class or contributor. Prefer naming the tested behaviours and linking the relevant test classes over quoting a count without context.
Automated tests do not validate visual quality or the complete input experience. For a release candidate:
- Start a run and enter combat.
- Confirm five distinct hand instances are shown.
- Play a self-target card and an enemy-target card.
- Confirm only the selected copy leaves the hand and a replacement is drawn.
- Confirm rejected unaffordable and invalid-target plays leave the hand unchanged.
- Verify artwork and display name at 225 by 456 pixels.
- Place a base and upgraded copy of the same definition in one hand and compare gameplay values.
- Buy a duplicate card, force a transaction failure and confirm rollback removes the new copy.
- Save and reload a deck containing base, upgraded and duplicate copies.
- Exercise multi-turn effects across their complete duration.
Problem: A card-ID-only deck cannot distinguish two copies of Strike. A global upgraded flag or an upgraded definition ID would upgrade every copy or require callers to guess which copy moved.
Decision: CardConfig remains the shared definition; CardInstance stores stable copy identity and upgrade level.
Alternatives rejected:
- separate strike and strike_upgraded definitions;
- mutating the shared CardConfig; and
- locating an owned card by the first matching cardId.
Consequences: Deck, play, UI and persistence APIs must carry instanceId for copy-specific operations. In return, duplicate copies behave predictably and the library avoids duplicated definitions and artwork.
Problem: If gameplay, UI and tooltips independently interpret upgrade state, displayed cost or rules can diverge from executed effects.
Decision: CardResolver produces one immutable ResolvedCard that contains the selected values.
Consequences: Consumers receive a safe, consistent view. Defensive effect copying adds a small allocation cost, accepted to prevent mutable JSON objects leaking into runtime state.
Problem: Regenerating IDs at battle start or load time breaks selection, exact rollback and per-copy upgrades.
Decision: Pile movement, deck copying and save round trips preserve instanceId and upgradeLevel. Only true acquisition creates a new ID.
Consequences: Restore validation is stricter and duplicate IDs are rejected before live state changes.
Problem: Runtime records validate invariants and are not designed as mutable no-argument JSON objects.
Decision: CardInstanceSaveData mirrors the three persisted fields and is converted only at the capture/restore boundary.
Consequences: Save compatibility can evolve without weakening runtime validation. It requires explicit conversion code, but makes corruption handling and schema review clearer.
Problem: A shop still offers card types by cardId, but rolling back by cardId can remove an older duplicate.
Decision: PlayerDeckAdapter records the instance and position created by the pending transaction, verifies them on rollback and removes that exact copy.
Consequences: Existing shop-facing cardId APIs remain stable while deck integrity is preserved. The current adapter supports one pending addition, which matches the transaction flow but should be revisited if concurrent purchases are introduced.
Problem: Building final card widgets before identity and resolution were stable would duplicate temporary selection logic and bake values into artwork.
Decision: First establish configuration, identity, resolution and gameplay. The current battle hand consumes resolved artwork/name and instance IDs; a reusable CardWidget and tooltip remain a separate presentation step.
Consequences: Sprint 2 gameplay is instance-aware, but the player cannot yet inspect all resolved cost, rarity and rules details in a dedicated dynamic component. This is an explicit delivery boundary rather than completed UI work.
The system began as a static definition library accessed only by card ID. Integration exposed three requirements that changed the plan:
- Duplicate owned cards required copy identity.
- Upgrade state had to survive combat piles and saved runs.
- Shop rollback had to reverse the exact acquisition rather than any matching definition.
Those findings expanded the design from a configuration-only feature to an instance-aware runtime contract. The expansion was justified because adding UI-only upgrade labels without these foundations would have produced incorrect gameplay and persistence. Final card presentation was deferred so the new runtime boundary could be integrated and tested first.
The following items are not complete and must not be assumed by new integrations:
- Upgrade coverage: Only 6 of 27 definitions have upgrade blocks.
- Card presentation: There is no reusable CardWidget or hover tooltip showing resolved cost, rarity, description and ordered effects.
- Status scheduling: updateStatusEffects and resetBlock have no production callers in the current source tree. Timed healing is handled separately by BattleController.
- Status stacking: CombatStatsComponent overwrites an existing status with the same key; stacking and duration-refresh policy are not unified across all paths.
- Block fallback: Team7PlayerStateAdapter correctly maps Block to addBlock and Fortify to Armour. BattleController's direct legacy fallback currently maps BLOCK through its armour path. New work should use the adapter contract and remove the duplicate fallback.
- Legacy enemy fallback: BattleController's direct enemy application does not handle every effect accepted by Team1EnemyStateAdapter, including Feeble.
- Legacy save files: The current schema persists instance-aware cards. A deliberate migration strategy is still needed for historical on-disk files containing only cardIds.
- Catalogue ordering: CardService does not promise definition order.
- Rules/config mismatch: Unseal the Breach's description says damage before armour reduction, while its configured effects execute Sunder before Damage. The description or effect order must be corrected.
- Config encapsulation: CardConfig remains publicly mutable for deserialisation. Consumers must treat registered definitions as read-only until an immutable definition layer is added.
Recommended next work is to resolve the rules/config inconsistency and production status scheduling before expanding the catalogue, then complete the reusable card widget and remaining upgrade data.
| Term | Meaning |
|---|---|
| Base card | Upgrade level 0 values from CardConfig. |
| Card definition | Shared, validated CardConfig identified by cardId. |
| Card instance | One exact owned copy with stable instanceId and upgradeLevel. |
| Persistent deck | PlayerDeck retained across encounters and saved with the run. |
| Battle deck | Combat-only draw, hand and discard state created from the persistent deck. |
| Resolved card | Immutable final values selected for one base or upgraded instance. |
| Resolved effect | One validated effect after card and combat modifiers are calculated. |
| Upgrade | Replacement presentation, cost, rarity and effects applied to one instance. |
| Block | Short-term damage absorption intended to reset each turn. |
| Armour | Persistent damage absorption that remains until consumed or cleared. |