Reward System - UQcsse3200/2026-studio-3 GitHub Wiki
Team 7 | Issues: #143, #133, #144
Overview
This page describes the post-battle reward system: how rewards are generated, displayed to the player, claimed, applied, and how owned items carry through a run. The work is split across two owners:
- Rewards Logic & Data (Team 7 / Keira):
RewardService,RewardGenerator,GoldReward,RewardOption,RewardType,ItemEffectApplier,ItemInventory. Decides what gets rewarded and how the selected reward is written intoInventoryComponent/PlayerRunState. - Settlement UI, Inventory UI and Shop integration (Team 7 / Celeste):
RewardDisplay, the inventory popup, the Item Library, the shop discount display, and thePlayerRunState/BattleScreenwiring. Decides how rewards and items are shown, claimed, and carried across screens.
Quick Start
Real game (from EndBattleScreen)
RewardService rewardService = new RewardService();
ui.addComponent(new RewardDisplay(rewardRecord, rewardService, game.getRunState()));
Tests / isolated usage: apply an item effect directly
Entity player = new Entity().addComponent(new EnergyComponent(3));
player.create();
new EnergyCrystalEffect().apply(player); // +1 max energy (capped at 5), fires updateEnergy
Tests / isolated usage: reward claiming without a screen flow
Entity player = new Entity();
player.addComponent(new InventoryComponent(0));
RewardService rewardService = new RewardService(new RewardGenerator(new Random(1)));
RewardOption option = new RewardOption(RewardType.GOLD);
option.goldAmount = 25;
rewardService.claimReward(player, option);
Core Reward API
| Method | Description |
|---|---|
List<RewardOption> RewardService.generateRewardOptions() |
Returns one GOLD option and one ITEM option. No-arg, not a random count |
void RewardService.claimReward(Entity player, RewardOption selected) |
Applies the chosen option to a live player entity. GOLD adds gold (with bonus applied) via InventoryComponent; ITEM dispatches to ItemEffectApplier |
RewardOption RewardGenerator.generateGoldRewardOption() |
Rolls a random gold amount within a fixed range (20 to 30, seeded and reproducible) |
RewardOption RewardGenerator.generateItemRewardOption() |
Draws from a weighted pool (see Drop Weights) |
int GoldReward.calculateFinalGold(Entity player, int baseAmount) |
subtotal = currentGold + baseAmount; if the player owns a LUCKY_COIN, one copy is consumed and bonus = min(subtotal * 0.10, 20) is added, giving finalGold = subtotal + bonus. Without a LUCKY_COIN, finalGold = subtotal |
void ItemEffectApplier.applyItemEffect(ItemType id, Entity player) |
Dispatches to the matching ItemEffect implementation |
Data Structures
| Type | Fields |
|---|---|
RewardOption |
type (GOLD / ITEM), goldAmount (GOLD only), itemId (ITEM only) |
RewardType |
GOLD, ITEM |
ItemType |
LUCKY_COIN, ENERGY_CRYSTAL, MERCHANTS_FAVOR, IRON_AEGIS, WARRIORS_CREST |
Display names. Players see the names below. Code, enum values and test names keep the original identifiers. All screens (reward cards, inventory, shop, Item Library) get the name from one lookup, ItemFormatting.formatItemName(ItemType).
| Display name | Code name |
|---|---|
| Shard of Fortune | LUCKY_COIN |
| Ember of the Divine | ENERGY_CRYSTAL |
| Relic of Commerce | MERCHANTS_FAVOR |
| Aegis Fragment | IRON_AEGIS |
| Blessed Crest | WARRIORS_CREST |
Item Categories & Effects
All items implement the shared ItemEffect interface (void apply(Entity player)), dispatched by ItemEffectApplier.applyItemEffect(ItemType, Entity). They fall into three categories.
How items persist.
ENERGY_CRYSTALandMERCHANTS_FAVORare persistent: their effect is replayed viaItemEffectApplierevery timePlayerRunState.applyTo()runs on a new player entity.LUCKY_COINis consumable at reward time: it is applied in the gold-reward-claim path, not by the replay loop.IRON_AEGISandWARRIORS_CRESTare consumable in battle: they stay in the inventory until the player uses them.
Category: Combat Resource (persistent)
ENERGY_CRYSTAL (Ember of the Divine)
- Effect: Increases the player's maximum energy by +1 per copy.
- Cap: Total maximum energy is capped at 5 (
Math.min(maxEnergy + 1, 5)), so extra crystals beyond the cap have no effect. - Implementation:
EnergyCrystalEffectupdates the player'sEnergyComponentand firesupdateEnergy. - When it takes effect: Persists across the run via
PlayerRunState.ownedItems, replayed on every new player entity. - Feedback:
updateEnergymakesPlayerStatsDisplayrefresh the HUD immediately.
Category: Battle Consumables (used manually in combat)
IRON_AEGIS (Aegis Fragment)
- Effect: A consumable battle item. Using it during battle grants +5 Armour.
- Stacking and persistence: Each copy is used separately and stays owned until explicitly used.
- Not part of the replay loop: It is not applied automatically at the start of a battle.
WARRIORS_CREST (Blessed Crest)
- Effect: A consumable battle item. Using it during battle grants +1 Strength.
- Stacking and persistence: Same as Iron Aegis.
Players use them from the inventory popup, which shows a USE button per item. The button is enabled only in battle: on the map screen the popup is read-only, because there is no live player entity.
Category: Economy (gold and shop pricing)
MERCHANTS_FAVOR (Relic of Commerce)
- Effect: Permanently increases the player's shop discount by +10% per copy, capped at 50%.
- Implementation:
MerchantsFavorEffectadds the discount to the player'sInventoryComponent. The cap is enforced byPlayerRunState.getShopDiscount()(Math.min(count * MERCHANTS_FAVOR_DISCOUNT, MAX_SHOP_DISCOUNT)). - Applied exactly once at purchase.
ShopServicediscounts the price and passes the already-discounted price toIntegratedShopTransactionGateway, which charges exactly the price it receives. The gateway only exposesgetShopDiscount()forShopServiceto read. An earlier version discounted in both places, so players were charged less than the displayed price (for example 40 instead of 44); this was caught in code review and fixed, and an end-to-end test now guards it. - Shop UI: each offer shows
X GOLD (-Y%), and a gold line under the shop subtitle showsRelic of Commerce xN | Y% off all offers.
LUCKY_COIN (Shard of Fortune)
- Effect: A consumable, one-time bonus applied only when a
GOLDreward is claimed:subtotal = currentGold + baseGoldReward;bonus = min(subtotal * 0.10, 20);finalGold = subtotal + bonus.- Example: current gold 50, base reward 25 gives subtotal 75, bonus 8, final 83.
- Example: current gold 200, base reward 25 gives subtotal 225, bonus 20 (capped), final 245.
- Consumption: One copy is removed only when the player chooses the
GOLDreward. Choosing theITEMreward does not consume it. - Stacking: At most one Lucky Coin is used per claim.
- Persistence: It is not replayed by
applyTo(); it only acts at the moment aGOLDreward is claimed.
Item Pool Summary
| Item (display name) | Category | Persistence | Cap | Drop weight |
|---|---|---|---|---|
LUCKY_COIN (Shard of Fortune) |
Economy | Consumed on a GOLD claim |
+20 gold on the bonus | 2 of 9 |
ENERGY_CRYSTAL (Ember of the Divine) |
Combat Resource | Replayed every battle | Max energy 5 | 1 of 9 |
MERCHANTS_FAVOR (Relic of Commerce) |
Economy | Replayed every battle | 50% discount | 2 of 9 |
IRON_AEGIS (Aegis Fragment) |
Battle Consumable | Stays owned until used | None | 2 of 9 |
WARRIORS_CREST (Blessed Crest) |
Battle Consumable | Stays owned until used | None | 2 of 9 |
Drop Weights
RewardGenerator draws items from a fixed pool array. Weighting is done by repeating entries: Energy Crystal appears once and every other item twice, giving about 11% for Energy Crystal and about 22% for each other item. The draw method itself did not change. The lower Energy Crystal rate was agreed with the design committee.
Gold Generation Details
- Range: 20 to 30, weighted-random, currently Normal encounters only
- Seeding:
RewardGeneratoraccepts an injectedRandom, so tests can assert reproducible output (new RewardGenerator(new Random(seed))) - Elite/Boss weighting: not implemented; the architecture leaves the range extensible by encounter type once Team 1 confirms that data (#136)
Settlement Flow (PlayerRunState-based)
| Step | What happens | Where |
|---|---|---|
| 1. Battle ends | BattleController fires BATTLE_ENDED_EVENT |
BattleController.enterVictory() |
| 2. Screen switches | BattleActions.onBattleEnd() calls game.setScreen(VICTORY), constructing EndBattleScreen |
BattleActions |
| 3. Options shown | RewardDisplay.create() calls generateRewardOptions() and renders the reward cards |
RewardDisplay |
| 4. Player claims | claimOption() writes the chosen reward into PlayerRunState (gold or owned item) |
RewardDisplay |
| 5. Next battle starts | The new player entity calls playerState.applyTo(player), which replays every persistent owned item from ItemInventory |
PlayerFactory / BattleScreen |
On a victory with rewards, the player must choose a reward before leaving: the old click-anywhere-to-return behaviour of EndBattleDisplay is disabled while a choice is required, and the old plain-text VICTORY heading is hidden so it does not overlap the reward panel. The defeat screen still uses the old UI.
This replaces the earlier RunState.pendingReward single-slot bridge, which only reapplied the most recently claimed reward. PlayerRunState.ownedItems (backed by ItemInventory) stores every collected item.
Events
Reward-side components do not emit their own events; they write into existing player components, which broadcast the change.
| Event | Payload | Fired when |
|---|---|---|
updateEnergy |
current, max energy | EnergyCrystalEffect changes max energy |
updateGold |
current gold | GOLD rewards call addGold() |
REWARD_CLAIMED_EVENT |
none | Fired on the settlement UI entity once a reward is claimed |
EndBattleDisplay.RETURN_TO_MENU_EVENT |
none | Fired by RewardDisplay after claiming, to return to the map |
Inventory and Item Library (UI)
- Inventory popup: aggregates owned items by type with counts and a per-item description computed from the count. Opens from the battle screen and from the map screen (button below the map legend; its position is recalculated on window resize).
- Item Library: a read-only catalogue of every
ItemTypereachable from the Library menu, styled to match the Card Library. Names and artwork come fromItemFormattingand the item icons, so it always matches the reward cards.
Testing
| Test Class | Coverage |
|---|---|
GoldRewardTest |
Base gold without bonus, bonus calculation, base amount retrieval |
RewardGeneratorTest |
Gold within the configured range; reproducible with a seeded Random; the item pool contains every item; Energy Crystal drops less often than every other item |
RewardServiceTest |
Option generation; claiming applies only the selected option |
PlayerRunStateTest |
Item persistence across battles, stacking, shop discount cap, immutability of getOwnedItems(), Lucky Coin bonus preview and cap, consumables stay owned until used |
ItemInventoryTest |
Add/remove/count operations on the durable item list |
EnergyCrystalEffectTest |
+1 max energy, stacking, the cap of 5, updateEnergy fired |
MerchantsFavorEffectTest |
Discount stacking and the 50% cap |
ItemFormattingTest |
Each item maps to its display name |
IntegratedShopTransactionGatewayTest |
The gateway charges the price it is given and does not discount again; rollback paths; an end-to-end test through ShopService.purchaseWithGateway asserts the discount is applied exactly once |
ShopDisplayTest |
Shop item states |
ItemLibraryScreenTest |
Texture preload list and distinct display names |
Design Decisions
- Fixed pair (1 GOLD + 1 ITEM), not a random count: originally a configurable N-option draw; changed to a fixed pair after team discussion because card/relic pools were not ready.
claimRewardtakes a single selected option, not a list + index: keeps the UI layer's job to "know which option was clicked".ItemInventoryas a separate class: encapsulates add/remove/count/hasItem soPlayerRunStatedoes not manipulate a raw list.- Display names in one lookup, identifiers unchanged: renaming items touches only
ItemFormatting; enum values, tests and saved data are unaffected. - Drop rates through repeated pool entries: changes the probability without touching the draw method.
- Persistent vs consumable items: persistent items are replayed by
applyTo(); consumables stay in the inventory until their moment of use. - Discount applied in one place:
ShopServicediscounts, the gateway charges what it is given.
Current Limitations
| Item | Current state |
|---|---|
Resolved. Items, gold and shop discount persist across the whole run via PlayerRunState.ownedItems / ItemInventory. |
|
| Elite/Boss weighting | Not implemented; pending encounter-type data from Team 1 (#136) |
| Card/relic rewards | |
spendGold vs subtractGold |
InventoryComponent.spendGold() (used by the shop's purchaseCard()) duplicates subtractGold() but does not guard against negative input the same way. Pre-existing; flagged, not addressed |
| Map HUD position after a battle | After returning to the map from a battle, HUD elements were observed to shift on some window sizes. The map inventory button now repositions itself on resize; the underlying cause of the shift was not investigated |
Contact & Location
- Branch: merged into
mainthrough the Team 7 branch (Animation&item&cardupgrade) - Contact: Keira0206 (Logic & Data), Celeste116 (Settlement UI, Inventory UI, Shop integration)
Related Pages
- Victory Screen and Reward Feedback
- Defeat Screen and Return Flow
- Economy System
- Energy System
- Game Screens
- Event System