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 into InventoryComponent / PlayerRunState.
  • Settlement UI, Inventory UI and Shop integration (Team 7 / Celeste): RewardDisplay, the inventory popup, the Item Library, the shop discount display, and the PlayerRunState / BattleScreen wiring. 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_CRYSTAL and MERCHANTS_FAVOR are persistent: their effect is replayed via ItemEffectApplier every time PlayerRunState.applyTo() runs on a new player entity. LUCKY_COIN is consumable at reward time: it is applied in the gold-reward-claim path, not by the replay loop. IRON_AEGIS and WARRIORS_CREST are 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: EnergyCrystalEffect updates the player's EnergyComponent and fires updateEnergy.
  • When it takes effect: Persists across the run via PlayerRunState.ownedItems, replayed on every new player entity.
  • Feedback: updateEnergy makes PlayerStatsDisplay refresh 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: MerchantsFavorEffect adds the discount to the player's InventoryComponent. The cap is enforced by PlayerRunState.getShopDiscount() (Math.min(count * MERCHANTS_FAVOR_DISCOUNT, MAX_SHOP_DISCOUNT)).
  • Applied exactly once at purchase. ShopService discounts the price and passes the already-discounted price to IntegratedShopTransactionGateway, which charges exactly the price it receives. The gateway only exposes getShopDiscount() for ShopService to 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 shows Relic of Commerce xN | Y% off all offers.

LUCKY_COIN (Shard of Fortune)

  • Effect: A consumable, one-time bonus applied only when a GOLD reward 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 GOLD reward. Choosing the ITEM reward 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 a GOLD reward 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: RewardGenerator accepts an injected Random, 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 ItemType reachable from the Library menu, styled to match the Card Library. Names and artwork come from ItemFormatting and 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.
  • claimReward takes a single selected option, not a list + index: keeps the UI layer's job to "know which option was clicked".
  • ItemInventory as a separate class: encapsulates add/remove/count/hasItem so PlayerRunState does 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: ShopService discounts, the gateway charges what it is given.

Current Limitations

Item Current state
Cross-battle persistence 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 main through 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