Card System - UQcsse3200/2026-studio-3 GitHub Wiki

Card System Documentation

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.

Contents

  1. System overview
  2. Current implementation status
  3. Card catalogue
  4. Core concepts and invariants
  5. System architecture
  6. Configuration and validation
  7. Runtime behaviour
  8. Effect semantics
  9. Integration guide
  10. Card authoring and extension guide
  11. Testing and validation
  12. Architecture decisions and reflection
  13. Known limitations and future work
  14. Glossary and references

System overview

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

Responsibilities within the game

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.

Current implementation status

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.

Card catalogue

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.

Complete base-card catalogue

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.

Astral card set

The Astral set is designed around setup, Strength scaling, Vulnerable and delayed recovery:

Starfall

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

Rift Lance

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

Astral Ward

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

Resurrection

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

Available upgrades

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.

Starter deck

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.

Core concepts and invariants

Definition, instance and resolved value

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.

cardId and instanceId

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.

System invariants

  1. Card IDs are non-blank, trimmed, case-sensitive and unique in the library.
  2. Instance IDs are non-blank and unique within a player deck.
  3. Upgrade level is exactly 0 (base) or 1 (upgraded).
  4. A level-1 instance is valid only if its current definition contains an upgrade block.
  5. A CardConfig and CardInstance may be resolved only when their card IDs match.
  6. An upgrade changes one instance while preserving its instance ID and deck position.
  7. Base and upgraded definitions share type, target and artwork.
  8. Effects execute in configured array order.
  9. Public deck and resolution snapshots are immutable.
  10. Expected card-play failures do not spend energy or move cards.

System architecture

Primary classes

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
Loading

Package ownership

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.

Card-play sequence

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
Loading

Immutability boundaries

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.

Configuration and validation

File format

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.

Supported enum values

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.

Validation rules

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.

Library behaviour

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.

Runtime behaviour

Acquisition and persistent ownership

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.

Battle deck lifecycle

A BattleDeck copies the persistent instances into its draw pile at combat start. The persistent deck is not mutated by combat pile movement.

  1. The draw pile is shuffled.
  2. Drawing moves the original instance from draw pile to hand.
  3. If draw is empty, discard is automatically moved to draw and shuffled.
  4. Playing or discarding moves the exact selected instance from hand to discard.
  5. A successful play in BattleController draws one replacement.
  6. A rejected play leaves every pile unchanged.

Identity and upgrade level survive every move; battle start and reshuffling never generate new instance IDs.

Upgrade resolution

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.

Card-play transaction

CardPlayService is the unified gameplay entry point. It performs the following checks in order:

  1. The request identifies an exact instance currently in hand.
  2. Its card definition exists.
  3. The definition and resolved values pass validation.
  4. The selected target type matches the card and, when state views are supplied, is available.
  5. The player can afford the resolved cost.
  6. Energy is spent exactly once.
  7. Effects are resolved in configuration order.
  8. 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.

Targeting

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.

Shop and reward transactions

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.

Save and restore

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.

Current presentation

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.

Effect semantics

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.

Damage calculation

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.

Ordered multi-effect behaviour

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.

Status ownership and repetition

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.

Integration guide

Load and expose definitions

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

Acquire a new copy

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.

Upgrade one owned copy

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.

Resolve values for gameplay or UI

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.

Submit a card play

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.

Identifier decision table

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

External integration contracts

Battle flow

BattleActions and Team3CardPlayAdapter translate UI events into CardPlayRequest. BattleController owns phase validation, successful replacement draws and distribution of resolved effects.

Enemy state

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.

Player state

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

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.

Acquisition

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.

Persistence

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.

Card authoring and extension guide

Add a new card

  1. Choose a unique lower-snake-case card ID.
  2. Confirm its type, rarity and target.
  3. Choose existing effects that are compatible with that target.
  4. Decide effect order deliberately.
  5. Write rules text that matches value, duration and effect order exactly.
  6. Add a PNG under source/core/assets/images/cards.
  7. Add the definition to source/core/assets/configs/cards.json.
  8. Add configuration and behavioural tests.
  9. Run formatting, the focused suite and the full core suite.
  10. Verify the card in a real battle at its displayed size.

Add an upgrade

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.

Add a new effect type

Adding an EffectType enum constant alone is insufficient. Review and update:

  1. EffectType duration metadata;
  2. CardValidator target compatibility;
  3. both EffectExecutor resolution paths;
  4. ResolvedCardEffect consumers;
  5. Team1EnemyStateAdapter or Team7PlayerStateAdapter;
  6. the active BattleController fallback path if it remains in use;
  7. status ticking, reset or persistence where applicable;
  8. card JSON and rules text; and
  9. 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.

Rules-text conventions

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

Artwork conventions

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

Review checklist

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.

Testing and validation

The system uses layered testing so failures are localised before full-game integration.

Automated test map

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

Critical behavioural scenarios

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.

Commands

Run from the source directory with Java 21:

cd source
.\gradlew.bat spotlessCheck :core:test
.\gradlew.bat :core:jacocoTestReport

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

Manual validation

Automated tests do not validate visual quality or the complete input experience. For a release candidate:

  1. Start a run and enter combat.
  2. Confirm five distinct hand instances are shown.
  3. Play a self-target card and an enemy-target card.
  4. Confirm only the selected copy leaves the hand and a replacement is drawn.
  5. Confirm rejected unaffordable and invalid-target plays leave the hand unchanged.
  6. Verify artwork and display name at 225 by 456 pixels.
  7. Place a base and upgraded copy of the same definition in one hand and compare gameplay values.
  8. Buy a duplicate card, force a transaction failure and confirm rollback removes the new copy.
  9. Save and reload a deck containing base, upgraded and duplicate copies.
  10. Exercise multi-turn effects across their complete duration.

Architecture decisions and reflection

Decision 1: upgrades belong to owned instances

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.

Decision 2: resolve once into a shared snapshot

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.

Decision 3: preserve identity through every boundary

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.

Decision 4: separate runtime objects from save DTOs

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.

Decision 5: make acquisition rollback instance-aware

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.

Decision 6: stage presentation after the data/runtime contract

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.

Design evolution

The system began as a static definition library accessed only by card ID. Integration exposed three requirements that changed the plan:

  1. Duplicate owned cards required copy identity.
  2. Upgrade state had to survive combat piles and saved runs.
  3. 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.

Known limitations and future work

The following items are not complete and must not be assumed by new integrations:

  1. Upgrade coverage: Only 6 of 27 definitions have upgrade blocks.
  2. Card presentation: There is no reusable CardWidget or hover tooltip showing resolved cost, rarity, description and ordered effects.
  3. Status scheduling: updateStatusEffects and resetBlock have no production callers in the current source tree. Timed healing is handled separately by BattleController.
  4. Status stacking: CombatStatsComponent overwrites an existing status with the same key; stacking and duration-refresh policy are not unified across all paths.
  5. 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.
  6. Legacy enemy fallback: BattleController's direct enemy application does not handle every effect accepted by Team1EnemyStateAdapter, including Feeble.
  7. 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.
  8. Catalogue ordering: CardService does not promise definition order.
  9. 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.
  10. 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.

Glossary and references

Glossary

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.

Source references

Change references

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