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

Card Upgrade System

Team 7 ยท Issue #159

Overview

Card upgrades attach to an individual copy of a card, not to a card type. A deck holding three Strikes can have one of them upgraded while the other two stay at base values. Each copy is identified by its CardInstance.instanceId.

The feature splits into three layers:

Layer What it does Owner State
Representation How an upgraded card is described and resolved โ€” CardConfig.upgrade, CardInstance.upgradeLevel, CardResolver, ResolvedCard Team 6 On main (PR #186)
Interaction Letting the player choose which copies to upgrade โ€” this page Team 7 / Jay Built
Persistence Recording the choice on the deck โ€” PlayerDeck.upgradeCard(instanceId) via DeckUpgradeCommitter Team 7 / Jay Built

This page covers the interaction layer and the committer that connects it to the deck. The selection model never touches the deck; the display hands the chosen instance IDs to a CardUpgradeCommitter, and DeckUpgradeCommitter is the implementation that writes them to PlayerDeck.


Using It From Your Screen

For camps, encounters, rewards, or anywhere else that should let the player upgrade cards.

PlayerDeck deck = runState.getOrCreatePlayerDeck(cardService);

CardUpgradeDisplay upgradeDisplay =
    new CardUpgradeDisplay(deck, cardService, new DeckUpgradeCommitter(deck), false);
ui.addComponent(upgradeDisplay);   // must happen before open()

// later, from your own button or event:
upgradeDisplay.open(1);            // let the player upgrade up to 1 card
Parameter Meaning
deck The run's PlayerDeck. Read on every open and written on Confirm
cardService Card definitions, used to find which cards have an upgrade
committer What happens on Confirm. Use DeckUpgradeCommitter for real upgrades, LoggingCardUpgradeCommitter to test without changing the deck
showOpenButton true adds a built-in "Upgrade" button in the top-right. Pass false and call open from your own UI

Things to know

  • Add the component to an entity before calling open. The overlay is built in create(); calling open earlier throws IllegalStateException.
  • open(maxUpgrades) sets the limit for that opening. maxUpgrades below 1 throws IllegalArgumentException. Each caller chooses its own limit.
  • Every open rereads the deck and starts a fresh selection. Cards already upgraded are never shown, and closing without confirming discards the selection.
  • The display does not limit how often it is opened. If your screen allows one upgrade per visit, enforce that yourself โ€” for example, disable your button after the first open.
  • Use one PlayerDeck for both the display and the committer, so the cards shown are the cards upgraded.
  • If ServiceLocator.getCardLibrary() is null on your screen, build one locally: new CardLibrary(CardConfigLoader.loadCards()). This is the case on EndBattleScreen, because BattleScreen.dispose() calls ServiceLocator.clear().

Class Diagram

classDiagram
    class CardUpgradeSelection {
        -List~CardInstance~ deck
        -CardService cardService
        -int maxSelections
        -List~UpgradeOption~ options
        -LinkedHashSet~String~ selected
        -Set~String~ selectableIds
        +getCardUpgradeOption() List~UpgradeOption~
        +isSelected(String) boolean
        +canSelect(String) boolean
        +toggle(String) boolean
        +canConfirm() boolean
        +remainingSelectable() int
        +getSelectedInstanceIds() List~String~
        +reset() void
    }

    class UpgradeOption {
        <<record>>
        +String instanceId
        +ResolvedCard current
        +ResolvedCard preview
    }

    class CardUpgradeDisplay {
        -PlayerDeck playerDeck
        -CardService cardService
        -CardUpgradeCommitter committer
        -boolean showOpenButton
        -CardUpgradeSelection selection
        -Map~String, Table~ tilesByInstanceIds
        +open(int) void
        -rebuildGrid() void
        -refresh() void
        -createTile(UpgradeOption) Table
    }

    class CardUpgradeCommitter {
        <<interface>>
        +commitUpgrades(List~String~) void
    }

    class DeckUpgradeCommitter {
        -PlayerDeck deck
        +commitUpgrades(List~String~) void
    }

    class LoggingCardUpgradeCommitter {
        +commitUpgrades(List~String~) void
    }

    class PlayerDeck {
        +getCards() List~CardInstance~
        +upgradeCard(String) boolean
    }

    class UIComponent
    class CardResolver
    class ResolvedCard
    class CardInstance
    class CardService

    CardUpgradeDisplay --|> UIComponent
    DeckUpgradeCommitter ..|> CardUpgradeCommitter
    LoggingCardUpgradeCommitter ..|> CardUpgradeCommitter
    CardUpgradeSelection *-- UpgradeOption
    CardUpgradeDisplay --> CardUpgradeSelection : creates on open
    CardUpgradeDisplay --> CardUpgradeCommitter
    CardUpgradeDisplay --> PlayerDeck : reads
    DeckUpgradeCommitter --> PlayerDeck : writes
    CardUpgradeSelection --> CardResolver
    CardUpgradeSelection --> CardService
    CardResolver --> ResolvedCard
    CardResolver --> CardInstance
    UpgradeOption --> ResolvedCard
Loading

CardUpgradeSelection has no libGDX or deck dependency; it works on a plain List<CardInstance>. The display reads the deck to build a selection, and only the committer writes to it.


State Diagram

stateDiagram-v2
    [*] --> NotCreated
    NotCreated --> Closed : added to entity (create)
    NotCreated --> NotCreated : open(n) throws IllegalStateException

    Closed --> Open : open(n) โ€” rereads deck, fresh selection

    state Open {
        [*] --> NoneSelected
        NoneSelected --> SomeSelected : toggle eligible card
        SomeSelected --> NoneSelected : toggle last selected card off
        SomeSelected --> SomeSelected : toggle, still below cap
        SomeSelected --> AtCap : toggle, reaches cap
        AtCap --> SomeSelected : toggle a selected card off
        AtCap --> AtCap : unselected tiles disabled
    }

    Open --> Closed : Close โ€” selection discarded
    SomeSelected --> Closed : Confirm โ€” commitUpgrades(ids)
    AtCap --> Closed : Confirm โ€” commitUpgrades(ids)
Loading
State Confirm Tiles
NoneSelected Disabled, shows "Upgrade (0)" All eligible tiles clickable
SomeSelected Enabled, shows the count All eligible tiles clickable
AtCap Enabled, shows the count Only selected tiles clickable; the rest are dimmed

With open(1), the first toggle goes straight from NoneSelected to AtCap. If the deck has no upgradable copies, the overlay stays in NoneSelected with an empty grid.

Core API

CardUpgradeDisplay

Member Description
CardUpgradeDisplay(PlayerDeck, CardService, CardUpgradeCommitter, boolean showOpenButton) Throws IllegalArgumentException on a null deck, card service or committer
void open(int maxUpgrades) Builds a fresh selection from the deck, rebuilds the tiles and shows the overlay. Throws IllegalStateException before create(), IllegalArgumentException if maxUpgrades is below 1

CardUpgradeSelection

Holds all selection rules. Plain Java, no libGDX dependency.

Method Description
List<UpgradeOption> getCardUpgradeOption() Immutable snapshot, one entry per upgradable, not-yet-upgraded copy. Built once in the constructor
boolean isSelected(String instanceId) Whether this copy is currently chosen
boolean canSelect(String instanceId) Whether the tile is clickable โ€” eligible and (already selected or room remaining)
boolean toggle(String instanceId) Flips the selection. Returns the state after the call. Throws IllegalArgumentException if the ID is not an upgradable option
boolean canConfirm() At least one copy selected
int remainingSelectable() maxSelections - selected.size()
List<String> getSelectedInstanceIds() Immutable, in the order the player selected them
void reset() Clears the selection

Constructor: CardUpgradeSelection(List<CardInstance> deck, CardService cardService, int maxSelections). Throws IllegalArgumentException on a null deck, null card service, or a cap below 1.

UpgradeOption

record UpgradeOption(String instanceId, ResolvedCard current, ResolvedCard preview)

current and preview are the same copy resolved at base and upgraded level, which is what lets the UI show "Deal 6 damage." โ†’ "Deal 12 damage." without knowing anything about card data.

CardUpgradeCommitter

void commitUpgrades(List<String> instanceIds)

The seam between choosing and applying.

Implementation Behaviour
DeckUpgradeCommitter(PlayerDeck) Calls deck.upgradeCard(instanceId) for each ID. Throws IllegalArgumentException on a null deck
LoggingCardUpgradeCommitter Logs the IDs and changes nothing. For testing

PlayerDeck.upgradeCard returns false and changes nothing if the copy is already upgraded, has no upgrade defined, or is not in the deck.


Eligibility

A copy is upgradable when its card has an upgrade block (config.upgrade != null) and the copy is not already upgraded. That single check decides both what the UI displays and what may be selected โ€” there is no separate "can display" and "can upgrade" check to drift apart.

buildUpgradeOptions() is the only place that evaluates it. Everything downstream just asks whether an instance ID is in the options.


Selection Rules

Rule Reason
Every copy is shown; duplicates are never collapsed An "upgrade 2 cards" reward must allow picking two Strikes. Deduplicating makes a legal choice unrepresentable
Simultaneous multi-select, then Confirm Matches the in-combat card selection pattern rather than sequential pick-one-then-another
A fresh snapshot on every open The overlay always reflects the current deck, so upgraded copies disappear and a stale selection cannot be confirmed
Toggle-off is always allowed, including at the cap Otherwise the player cannot revise a choice once the cap is reached
At the cap, unselected tiles disable Rather than silently swapping out an earlier pick
Confirming fewer than the cap is allowed Needed anyway when the deck holds fewer eligible cards than the cap
The Confirm button carries the count โ€” "Upgrade (2)" Identical tiles make the selection ambiguous otherwise

Why Instance IDs

An earlier version keyed everything on deck index. That had two problems:

  • Tile position is not deck index. The overlay only shows eligible cards, so the fourth tile might be deck index 8. Mixing the two up upgrades the wrong card.
  • Indices shift when the deck changes. Applying several upgrades by index needed a descending sort so earlier changes didn't move later entries.

Instance IDs are stable and unique per copy, so both problems disappear. The descending sort was removed on purpose.


Testing

CardUpgradeSelectionTest โ€” 12 unit tests. The rules were deliberately kept free of libGDX so they test without a headless GDX harness; UIComponent.skin is a static field initialised from Gdx.files at class load, so anything extending UIComponent drags one in.

PlayerDeckTest covers upgradeCard: upgrading only the targeted copy, refusing a second upgrade, and rejecting unknown, null or empty IDs.

./gradlew :core:test --tests "*CardUpgradeSelectionTest*"

Not unit tested

  • CardUpgradeDisplay contains only rendering and event wiring.
  • DeckUpgradeCommitter only loops over PlayerDeck.upgradeCard, which PlayerDeckTest covers.

To verify manually:

  1. Win a battle and click Upgrade. The overlay lists each upgradable copy showing base โ†’ upgraded values.
  2. Select two. The cap is reached and the other tiles dim. Clicking a selected card deselects it and re-enables them.
  3. Confirm is disabled at zero selections and shows the count.
  4. Confirm, then reopen. The upgraded copies are gone.
  5. Start the next battle and play an upgraded Strike. It deals 12 damage, not 6.

Current Limitations

Item Current state
Temporary trigger The overlay opens from a built-in button on the victory screen, allowing 2 more upgrades on every open. It is there for testing until camps or encounters call open
Upgradable cards Only strike, sealed_pact, blood_price, doom_sigil and iron_oath have an upgrade block in cards.json
No distinct upgraded artwork CardUpgradeConfig inherits artwork from the base card, so the preview must be text โ€” the image cannot show the upgraded values
Nothing-to-upgrade state If no copy is upgradable, open shows an empty grid with Confirm disabled; there is no message yet
Not triggered by rewards RewardType has only GOLD and ITEM

Open Questions

Should rewards offer card upgrades? If so, the reward only needs to carry how many cards may be upgraded and pass that to open(n). The player decides which.


Related Pages

โš ๏ธ **GitHub.com Fallback** โš ๏ธ