Card Upgrade System - UQcsse3200/2026-studio-3 GitHub Wiki
Team 7 ยท Issue #159
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.
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 increate(); callingopenearlier throwsIllegalStateException. -
open(maxUpgrades)sets the limit for that opening.maxUpgradesbelow 1 throwsIllegalArgumentException. Each caller chooses its own limit. -
Every
openrereads 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
PlayerDeckfor both the display and the committer, so the cards shown are the cards upgraded. - If
ServiceLocator.getCardLibrary()isnullon your screen, build one locally:new CardLibrary(CardConfigLoader.loadCards()). This is the case onEndBattleScreen, becauseBattleScreen.dispose()callsServiceLocator.clear().
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
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.
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)
| 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.
| 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 |
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.
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.
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.
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.
| 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 |
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.
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*"
-
CardUpgradeDisplaycontains only rendering and event wiring. -
DeckUpgradeCommitteronly loops overPlayerDeck.upgradeCard, whichPlayerDeckTestcovers.
To verify manually:
- Win a battle and click Upgrade. The overlay lists each upgradable copy showing base โ upgraded values.
- Select two. The cap is reached and the other tiles dim. Clicking a selected card deselects it and re-enables them.
- Confirm is disabled at zero selections and shows the count.
- Confirm, then reopen. The upgraded copies are gone.
- Start the next battle and play an upgraded Strike. It deals 12 damage, not 6.
| 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
|
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.