Card Effect Resolution - UQcsse3200/2026-studio-3 GitHub Wiki
Team 5 | Card Data & Effect Resolution — CardPlay flow implemented; Team 1 / Team 7 / Team 3 adapters implemented | Branch: Team5-Team6-integration-for-team5-test
This page describes how other teams get and use the result of a played card. Team 5 reads a card's definition from Team 6's Card Library, checks/spends energy, applies Team 5's own resolution rules (Strength, Feeble, Vulnerable), and hands back a plain data object. The core resolver and CardPlayService do not directly touch player HP, enemy HP, or armour. Applying a successful result is Team 3's battle flow's responsibility, using the Team 1/Team 7 integration adapters — see Responsibility Boundaries.
Team 5 computes numbers. Team 1 and Team 7 apply them. Team 5 sends DAMAGE 8 to SINGLE_ENEMY, and it's up to the receiving system whether that turns into 8 lost health or 3 after block/armour — with one exception: Team 5 now also checks and spends the player's energy during card play validation, since that has to happen before resolution can proceed at all.
For the deck side (Draw Pile / Hand / Discard Pile, and the player's owned deck), see Player & Battle Deck (Team 5, Zilin Xia).
The recommended entry point is now CardPlayService.playCard(CardPlayRequest) — it's the unified, one-call flow for a full card-play attempt: energy check, effect resolution, and moving the card to the discard pile, all in one CardPlayResult.
List<CardConfig> cards = CardConfigLoader.loadCards(); // Team 6's card data
CardService cardService = new CardLibrary(cards);
CardPlayService playService = new CardPlayService(cardService, battleDeck, energyComponent);
// Play "Strike" against a specific enemy
CardPlayResult result = playService.playCard(CardPlayRequest.singleEnemy("strike", enemyId));
if (!result.success()) {
// e.g. CardPlayFailureReason.NOT_ENOUGH_ENERGY — nothing was resolved or spent
return;
}
for (ResolvedCardEffect effect : result.enemyEffects()) {
enemy.getComponent(CombatStatsComponent.class).takeDamage(effect.value()); // for Team 1
}
List<String> handAfterPlay = result.updatedHand(); // for Team 3's UIThe example above applies effects manually for illustration — that's exactly what Team 3's battle flow does with the real result. There's no automatic dispatcher: after a successful CardPlayResult, call Team1EnemyStateAdapter.applyEnemyEffects(result.target(), result.enemyEffects()) and Team7PlayerStateAdapter.applyPlayerEffects(result.playerEffects()) directly — both adapters implement the read-only *StateView interfaces and an *EffectConsumer interface with these apply methods, which call the real Team 1/Team 7 mutation methods (takeDamage(), addBlock(), heal(), applyStatusEffect()). On the Team 3 side, Team3CardPlayAdapter listens for Team 3's existing playCard(cardId, targetId) event, calls CardPlayService itself, and re-emits a cardPlayResult event with the full result — Team 3's battle flow owns applying that result, not Team 5.
CardPlayResult has both a simple, flat API (enemyEffects(), playerEffects(), updatedHand(), updatedDrawPile(), updatedDiscardPile() — used above) and the full underlying detail via effectResolution() (a CardEffectResolution, e.g. if you need effectsOfType()) and deckSnapshot() (a DeckSnapshot). The flat methods are just convenience delegates to these — use whichever fits. See The CardPlay API below for the full shape.
Team 1/Team 3/Team 7 adapters are available on Team5-Team6-integration-for-team5-test (PR #110). Construct CardPlayService with the 5-argument constructor including PlayerStateView/EnemyStateView to factor in Feeble/Vulnerable (see Reading Player/Enemy State) — without them, only Strength applies.
If you only need the lower-level resolver directly (e.g. for tests, or code that doesn't touch energy/deck state), see Resolving a Card Directly.
flowchart LR
subgraph "Team 3 — Battle Flow"
Event[playCard event]
Apply[Battle flow applies result]
end
subgraph "Team 5 — Card Resolution"
Adapter[Team3CardPlayAdapter]
Service[CardPlayService]
Resolver[CardEffectResolver]
Store["CardEffectResolutionService (TurnEffectStore)"]
end
subgraph "Team 6 — Card Data"
Library[CardService / CardLibrary]
end
subgraph "Team 1 — Enemy State"
T1Adapter[Team1EnemyStateAdapter]
T1Real["takeDamage, applyStatusEffect..."]
end
subgraph "Team 7 — Player State"
T7Adapter[Team7PlayerStateAdapter]
Energy[EnergyComponent]
T7Real["takeDamage, addBlock, heal, applyStatusEffect..."]
end
Event --> Adapter
Adapter -->|CardPlayRequest| Service
Service -->|read card config| Library
Service -->|check / spend| Energy
Service -->|read Strength/Feeble| T7Adapter
Service -->|read Vulnerable/Feeble| T1Adapter
Service --> Resolver
Resolver --> Store
Service -->|CardPlayResult| Adapter
Adapter -->|cardPlayResult event| Apply
Apply -->|applyEnemyEffects| T1Adapter
Apply -->|applyPlayerEffects| T7Adapter
T1Adapter --> T1Real
T7Adapter --> T7Real
CardPlayService never calls Team 1/Team 7's real mutation methods directly — it only returns a CardPlayResult. Applying that result via Team1EnemyStateAdapter/Team7PlayerStateAdapter is Team 3's battle flow's job, not Team 5's — see Responsibility Boundaries.
With Team3CardPlayAdapter (available on Team5-Team6-integration-for-team5-test):
- Team 3 fires the existing
playCard(cardId, targetId)event, same as before. -
Team3CardPlayAdapterbuilds aCardPlayRequestand callsCardPlayService.playCard(request). - Team 5 reads the card config from Team 6's card library, checks/spends energy, reads player/enemy state through
PlayerStateView/EnemyStateViewwhere available, and resolves the card into aCardPlayResult. -
Team3CardPlayAdapterre-emits acardPlayResultevent with the full result — it does not apply the effects itself. -
Team 3's battle flow owns applying the result: on success, call
Team1EnemyStateAdapter.applyEnemyEffects(result.target(), result.enemyEffects())andTeam7PlayerStateAdapter.applyPlayerEffects(result.playerEffects()), which call the real Team 1/Team 7 mutation methods (takeDamage(),addBlock(),heal(),applyStatusEffect()).
Team 3 does need to apply enemyEffects()/playerEffects() itself, via the adapters above — there's no automatic dispatcher.
A failed request (e.g. not enough energy, unknown card, invalid target) never spends energy or resolves effects — the card stays in hand.
| Member | Description |
|---|---|
String cardId() |
Card being played |
CardPlayTarget target() |
The selected target |
static CardPlayRequest self(String cardId) |
Convenience factory for a self-targeting card |
static CardPlayRequest singleEnemy(String cardId, String targetId) |
Convenience factory for a single-enemy card |
static CardPlayRequest allEnemies(String cardId) |
Convenience factory for an all-enemies card |
| Member | Description |
|---|---|
TargetType type() |
SELF / SINGLE_ENEMY / ALL_ENEMIES
|
String targetId() |
Opaque target ID — required for SINGLE_ENEMY, must be absent otherwise |
| Member | Description |
|---|---|
String cardId() |
Card that was attempted |
CardPlayTarget target() |
The target that was attempted |
boolean success() |
Whether the card was actually played |
int energyCost() |
Energy actually spent on success. On failure this is not always 0 — UNKNOWN_CARD reports 0, but INVALID_CARD_CONFIG and INVALID_TARGET report the card's cost for UI/debugging purposes; the player's real energy is unchanged either way |
CardPlayFailureReason failureReason() |
NONE on success, otherwise the reason it failed |
List<ResolvedCardEffect> enemyEffects() |
Enemy-targeting effects — for Team 1. Empty list on failure |
List<ResolvedCardEffect> playerEffects() |
Player-targeting effects — for Team 7. Empty list on failure |
List<String> updatedHand() / updatedDrawPile() / updatedDiscardPile()
|
Deck state after the attempt — for Team 3's UI |
CardEffectResolution effectResolution() |
The full underlying resolution (null on failure) — for lower-level access, e.g. effectsOfType()
|
DeckSnapshot deckSnapshot() |
The full underlying deck snapshot |
| Member | Description |
|---|---|
List<String> updatedHand() |
Hand after the play attempt |
List<String> updatedDrawPile() |
Draw pile after the play attempt |
List<String> updatedDiscardPile() |
Discard pile after the play attempt |
NONE, UNKNOWN_CARD, INVALID_CARD_CONFIG, INVALID_TARGET, CARD_NOT_IN_HAND, NOT_ENOUGH_ENERGY.
RESOLUTION_FAILED exists in the enum but is not currently returned by CardPlayService — a late internal failure (after energy is already spent) instead restores the energy and rethrows the exception, so callers don't mistake an incomplete play for a normal validation failure. Treat RESOLUTION_FAILED as reserved for now.
INVALID_CARD and INSUFFICIENT_ENERGY also exist but are @Deprecated — use INVALID_CARD_CONFIG / NOT_ENOUGH_ENERGY instead.
PlayerStateView and EnemyStateView are the read-only boundaries Team 5 uses to read player/enemy status values during card resolution. Concrete adapter implementations (Team1EnemyStateAdapter, Team7PlayerStateAdapter) are available on Team5-Team6-integration-for-team5-test. Without them supplied, CardPlayService behaves exactly as it did with Strength alone.
public interface PlayerStateView {
int currentEnergy();
int statusValue(EffectType type); // e.g. STRENGTH, FEEBLE
}
public interface EnemyStateView {
boolean isTargetAvailable(String targetId);
int statusValue(String targetId, EffectType type); // e.g. VULNERABLE, FEEBLE
}Once adapters exist, construct CardPlayService with the 5-argument constructor: CardPlayService(cardService, battleDeck, energyComponent, playerStateView, enemyStateView).
For cases that don't need energy/deck handling (tests, or lower-level code), CardEffectResolver is still available directly, now with two calculation modes:
| Method | Description |
|---|---|
resolve(CardConfig, PlayerEffectState) / resolve(String, PlayerEffectState)
|
Original, Strength-only calculation — still fully supported |
resolve(CardConfig, CardEffectResolutionContext) / resolve(String, CardEffectResolutionContext)
|
Full calculation — Strength, Feeble, Vulnerable, via CardEffectResolutionContext
|
CardEffectResolutionContext.from(playerState) builds a Strength-only context from a PlayerEffectState, for backward compatibility.
resolve() throws IllegalArgumentException if the player state/context is null, the card fails Team 6's CardValidator.validate(), or the card's effects array contains a null entry.
Available on Team5-Team6-integration-for-team5-test.
CardEffectResolutionService records every successful resolve() call into a CardEffectResultStore (implemented by TurnEffectStore, in-memory), so anything holding a reference to the service can ask what's happened so far this turn without re-deriving it. CardPlayService is built on top of this — every successful playCard() is recorded here too.
| Method | Description |
|---|---|
List<CardEffectResolution> getResolutions() |
Every card resolution so far this turn, in play order |
List<ResolvedCardEffect> getEnemyEffects() |
All enemy-targeting effects so far this turn — for Team 1 |
List<ResolvedCardEffect> getPlayerEffects() |
All self-targeting effects so far this turn — for Team 7 |
List<ResolvedCardEffect> getEffectsOfType(EffectType type) |
Filter across the turn's effects by type |
void clearTurnResults() |
Clears the recorded history — Strength and other combat-long state are untouched |
One CardEffectResolutionService (or CardPlayService wrapping it) should be created once per combat and shared by whatever code plays cards. If two separate instances get created, each has its own turn history and Strength tracking — anything reading from the "wrong" one will silently see nothing, with no error.
A backtick-toggle debug dialog (cards/debug/) shows a live, colour-coded table of every card resolved so far this turn — card, effect type, target, value, duration — read directly from CardEffectResolutionService, so it always reflects real resolver output. Read-only; it never mutates gameplay state. Useful for visually verifying the resolver (e.g. confirming Strength is applying correctly) without a full battle scene wired up. See Issue #108 for details.
| Modifier | Effect |
|---|---|
| Strength | Flat bonus added to outgoing DAMAGE
|
| Feeble | Fixed 0.75× multiplier on outgoing damage, when active on the attacker |
| Vulnerable | Fixed 1.5× multiplier on damage taken, when active on the target |
When both apply: final = floor((baseDamage + Strength) × 0.75 × 1.5). The returned DAMAGE value is already final, pre-mitigation damage — Team 1 should not apply Vulnerable (or Feeble) again on top of it.
Full effect/target/duration table:
| Type | Valid target |
value means |
Uses duration? |
|---|---|---|---|
DAMAGE |
enemy only | final resolved damage, modifiers already applied | No — always 0 |
BLOCK |
self only | block granted | No — always 0 |
HEAL |
self only | health restored | No — always 0 |
POISON |
enemy only | stacks | Yes — turns remaining |
VULNERABLE |
enemy only | stacks | Yes — turns remaining |
FEEBLE |
enemy only | stacks | Yes — turns remaining |
STRENGTH |
self only | stacks | No — lasts the whole combat |
Playing a card with the wrong type/target combination throws IllegalArgumentException — enforced in EffectExecutor.
STRENGTH tracking differs by which resolve path you use. With the legacy resolve(..., PlayerEffectState) overload, STRENGTH also updates PlayerEffectState internally (so later cards in the same combat pick it up automatically). With the full CardPlayService/external-state flow, STRENGTH is not tracked internally by Team 5 at all — it's only returned as a player effect for Team 7 to apply and own via applyStatusEffect(). If you're on the full flow, Team 5's own Strength bonus on a later DAMAGE card comes from whatever PlayerStateView.statusValue(STRENGTH) reports back from Team 7 — not from PlayerEffectState.
- Team 5 calculates resolved card effects and final, pre-mitigation outgoing damage. The resolver and
CardPlayServicethemselves do not directly mutate HP/status state. - Team 1 owns real enemy state: block/armour mitigation, HP reduction, enemy statuses, death checks.
- Team 7 owns real player state: HP, block/armour, energy, statuses, duration handling.
- The integration adapters intentionally call real Team 1/Team 7 mutation methods —
applyEnemyEffects()/applyPlayerEffects()onTeam1EnemyStateAdapter/Team7PlayerStateAdapter, which in turn calltakeDamage(),addBlock(),heal(),applyStatusEffect(). Team 3's battle flow is responsible for calling these after a successfulCardPlayResult— there's no automatic dispatcher. Team 1 and Team 7 still own the actual state and its rules; the adapters are just the wiring between Team 5's calculated numbers and Team 1/Team 7's real APIs. - Outside of the adapters, Team 5 does not directly mutate Team 1 or Team 7 entities, with one other agreed exception: spending/checking energy during card play validation (via
EnergyComponent).
PlayerEffectState is no longer the main source of Strength for the full CardPlayService flow — that now goes through PlayerStateView once a Team 7 adapter exists. It remains the backward-compatible, lower-level state used by the original CardEffectResolver.resolve(..., PlayerEffectState) overloads and by CardEffectResolutionContext.from(playerState).
| Method | Description |
|---|---|
PlayerEffectState() |
Starts at 0 Strength |
int getStrength() |
Current Strength modifier |
void addStrength(int amount) |
Adds Strength — called internally when a STRENGTH effect resolves |
This is calculation-only state, not the player's real stats. Team 7 remains the source of truth for the player's actual HP, block, and energy.
The POISON / VULNERABLE value-format mismatch from earlier Sprint 1 discussion is resolved — Team 7 now uses int stacks, matching Team 5's output, via applyStatusEffect(String, int, int).
Team 1/Team 3/Team 7 adapters are implemented and merged — Team1EnemyStateAdapter, Team7PlayerStateAdapter, and Team3CardPlayAdapter are on Team5-Team6-integration-for-team5-test (PR #110, refined by PR #112), connecting Team 3's existing playCard(cardId, targetId) event straight into CardPlayService. Applying a successful result is Team 3's battle flow's own responsibility (calling the adapters' applyEnemyEffects()/applyPlayerEffects() methods) — an earlier design used a CardPlayResultDispatcher to do this automatically, but that's been removed in favour of Team 3 owning it directly.
Remaining integration work:
-
Runtime assembly — the adapters exist and are tested, but wiring them into an actual running battle scene (constructing
CardPlayServicewith the adapters, registering enemy target IDs, etc.) is a separate step from the classes existing. -
Future
ALL_ENEMIES+DAMAGEcards — the current structure resolves one damage value per card, not per enemy. If enemies end up with different Vulnerable stacks, anALL_ENEMIESdamage card would need per-enemy resolution. No such card exists in Team 6's current real data, so this isn't a blocker yet.
Currently on: Team5-Team6-integration-for-team5-test — a temporary branch combining Team 5's effect resolution with Team 6's card data, per tutor guidance.
Relevant merged PRs:
- PR #95 — unified CardPlay API/service base
- PR #101 — resolver modifier calculation (Feeble, Vulnerable,
CardEffectResolutionContext) - PR #110 — Team 1/Team 3/Team 7 integration adapters (
Team1EnemyStateAdapter,Team7PlayerStateAdapter,Team3CardPlayAdapter, plus aCardPlayResultDispatcherlater removed) - PR #112 — moved effect-application ownership to Team 3's battle flow; removed
CardPlayResultDispatcher
Not yet on main. Please don't merge other team branches into this one directly — pull Team5-Team6-integration-for-team5-test if you need to build against it now.
Contact: Team 5 (Anran Zhang, Luqishu Wu, Manpreet Singh — card data & effects)
Parts of this page's drafting and verification were done with the assistance of Claude (Anthropic), per Team 5's Sprint Achievement Form AI Declaration. Every technical claim on this page (class names, method signatures, the damage formula, integration status) was independently checked against the actual merged source code before publishing — AI assistance was used for drafting and structuring the documentation, not as an unverified source of the technical content itself.
-
Card Library API — Team 6, where
CardConfig/EffectConfig/CardServicecome from - Initial Cards — Team 6's six Sprint 1 example cards
- Player & Battle Deck — Team 5 player-owned deck and combat draw/hand/discard state
- Enemy System — Interface Contract — Team 1