Repository navigation
Upgraded Weapons
This page covers the Weapon Upgrades feature: how a weapon becomes upgraded, what each upgrade grants, and how the work is tested.
- The shared upgrade system — per-weapon upgrade stats and the heavy attack pipeline (Weapons, Weapon Upgrades).
- The upgraded Sword: a 360° heavy spin on
K, stronger light sweep, and a fancier blade sprite. - The upgraded Knife: a three-hit heavy flurry on
K, and a fancier knife sprite. - The upgraded Bow: a three-arrow heavy volley on
K, and a fancier arrow sprite. - The QA terminal command
upgrade sword|knife|bow [off].
An upgrade never edits the player's shared weapon stats, so upgrading one weapon can never buff another. Every weapon also swaps to a fancier sprite while upgraded, so an upgrade is recognisable at a glance.
Testing is divided into automated unit tests (what the code must do) and manual gameplay tests (what the player must see and feel). Every behaviour listed in the automated plan is enforced by a test in the repository — the test class is named above each table.
An upgrade gives a weapon four things: more light-attack damage, a heavy attack on K, a longer shared cooldown when that heavy attack is used, and an upgraded sprite. Values live in WeaponUpgradeComponent on the player, keyed by weapon.
| Weapon | Light damage | Heavy damage | Heavy cooldown |
|---|---|---|---|
| Sword | ×1.2 | ×1.35 | ×2 (1.0 s) |
| Knife | ×1.2 | ×0.6 per slash, ×1.2 for the finisher | ×3 (1.5 s) |
| Bow | ×1.2 | — (3 arrows at ×1 each) | ×2 (heavy attack duration is instant) |
Every upgraded sprite is an unedited crop of the same CC0 steel-weapons.png sheet, drawn at the same angle as the base sprite, so swapping one needs no rendering changes. Grid cells are recorded in core/assets/licenses/OPENGAMEART-ATTRIBUTION.md.
| Weapon | Base sprite | Upgraded sprite |
|---|---|---|
| Sword | sword.png |
sword_upgraded.png — winged golden guard |
| Knife | knife.png |
knife_upgraded.png |
| Bow | throwing_knife.png |
throwing_knife_upgraded.png — winged blade with a blue gem |
| Property | Light (J) |
Heavy (K) |
|---|---|---|
| Arc | 90°, centred on facing | 360° around the player |
| Duration | 0.25 s | 0.8 s |
| Blade hitbox | 1.0 × 0.4 | 1.0 × 0.4 (unchanged) |
| Damage at base attack 10 | 12 | 14 |
| Sprite | Upgraded winged sword while upgraded | Same |
The heavy attack is a three-hit flurry, aimed where the player faced when K was pressed.
| Strike | Spawns at | Duration | Damage at base attack 10 | Knockback |
|---|---|---|---|---|
| Slash 1 (right → left, 70°) | 0.00 s | 0.12 s | 6 | none |
| Slash 2 (left → right, 70°) | 0.15 s | 0.12 s | 6 | none |
| Finishing stab | 0.30 s | 0.15 s | 12 | 1.5× the weapon's knockback |
Only the finisher knocks back, so enemies stay in reach of all three hits. Switching weapons mid-flurry cancels the strikes still queued. While upgraded, the stab and every flurry strike are drawn with the upgraded knife sprite.
The heavy attack fires three arrows at once instead of a stronger single one — the upgrade's power comes from arrow count, not per-arrow damage.
| Property | Light (J) |
Heavy (K) |
|---|---|---|
| Arrow count | 1 | 3 (centre + ±15°) |
| Damage at base attack 10 | 12 | 10 per arrow |
| Each arrow's path | Checked independently; a blocked shot despawns silently | Same — a wall blocking the centre arrow does not stop the two side arrows |
| Sprite | Upgraded arrow while upgraded | Same, on all three arrows |
Unlike the sword and knife, the bow's heavy attack has no separate hitbox shape or lifetime from the light shot — it is the same arrow, fired three times in a spread, each with its own wall check.
-
Unit testing: JUnit 5 and Mockito, run headlessly with the engine's
GameExtension - Game testing: Desktop application
-
Light attack:
J— Heavy attack:K— Movement:WASD -
Debug terminal (
F1) commands:weapon knife|sword|bowto equip;upgrade sword|knife|bowto apply an upgrade andupgrade sword|knife|bow offto revert;debug onto show hitbox outlines
Run all automated tests with:
cd source
./gradlew :core:testLast verified run: 17 Sept 2026 on task/126-bow-sprite-change — 1178 tests, 0 failures.
| Test | Purpose | Expected Result |
|---|---|---|
shouldNotBeUpgradedInitially |
Inspect a fresh component. | Nothing is upgraded, and every multiplier reads 1, so unupgraded weapons behave exactly as before. |
shouldApplySwordUpgradeStats / shouldApplyKnifeUpgradeStats / shouldApplyBowUpgradeStats
|
Upgrade each weapon. | The documented multipliers apply (sword 1.2 / 1.35 / 2, knife 1.2 / 0.6 / 3, bow 1.2 / 1 / 2). |
shouldStayUpgradedWhenAppliedTwice |
Apply the same upgrade twice. | Applying is idempotent — no stacking. |
shouldRevertUpgrade / shouldRevertBowUpgrade
|
Turn an upgrade off. | The weapon returns to its unupgraded multipliers. |
shouldNotAffectOtherWeapons |
Upgrade the sword, then read the knife and bow. | Other weapons are untouched — the shared weapon stats are never modified. |
shouldRejectWeaponWithoutUpgradeStats |
Upgrade a weapon with no upgrade defined (an explicit stats map with only the sword). | Rejected rather than silently doing nothing. |
shouldTriggerEventOnlyWhenUpgradeStateChanges |
Apply, re-apply, then revert. |
weaponUpgraded fires only on real changes — a hook for upgrade VFX. |
shouldRejectNullStatsMap / shouldRejectNegativeUpgradeStats
|
Construct with invalid values. |
IllegalArgumentException is thrown. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldHeavyAttackOnEventWhenUpgraded |
Fire the weaponHeavyAttack event on an upgraded weapon. |
The heavy attack runs, and the light attack does not. |
shouldIgnoreHeavyAttackEventWhenNotUpgraded / shouldNotHeavyAttackWithoutUpgradeComponent / shouldNotHeavyAttackWithoutAHeavyAttack
|
Fire the event in each unsupported case. | Nothing spawns and no cooldown starts, so K is inert until a weapon is upgraded. |
shouldIgnoreHeavyAttackEventWhenDisabled |
Fire the event on an unequipped weapon. | Only the equipped weapon responds. |
shouldTriggerScaledCooldownAndRespectItForHeavyAttacks |
Heavy attack twice in a row. | The cooldown is multiplied by the upgrade value and blocks the second attempt. |
shouldScaleLightAndHeavyDamageByUpgradeOnlyWhenUpgraded / shouldApplyExtraScaleOnTopOfHeavyUpgradeDamage
|
Resolve damage before and after upgrading. | Light and heavy damage each use their own multiplier, and combo finishers can scale further on top. |
shouldRejectNullOriginOrDirectionForHeavyAttack |
Heavy attack with null arguments. |
IllegalArgumentException is thrown. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldApplyExtraDamageScaleOnTopOfMultiplier |
Resolve damage with an upgrade scale. | Damage is round(baseAttack × multiplier × scale), and the shared multiplier is left untouched. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldUpgradeSword / shouldUpgradeAndRevertKnife / shouldUpgradeAndRevertBow
|
Run upgrade <weapon> [off]. |
QA can apply and revert any weapon's upgrade without a pickup or shop. |
shouldStayUpgradedWhenRunTwice |
Run the command twice. | Idempotent. |
shouldRevertSwordUpgradeWithOff |
Run upgrade sword off. |
The weapon returns to its Sprint 1 behaviour. |
shouldRejectInvalidArguments / shouldFailWhenPlayerHasNoUpgradeComponent
|
Run with bad arguments, or on a player with no upgrade component. | The command reports failure instead of changing state. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldTriggerHeavyAttackNotSpecialAttackOnK |
Press K. |
A dedicated heavyAttack event fires; specialAttack stays free for the abilities feature. |
shouldTriggerHeavyWeaponAttackInFacingDirectionOnHeavyAttack |
Walk, stop, then heavy attack. | The heavy attack uses the player's current facing. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldKeepSprintOneLightAttackWhenNotUpgraded / shouldRevertToSprintOneSwordWhenUpgradeRemoved
|
Attack before upgrading, and after reverting. | The unupgraded sword is unchanged in size, arc and damage. |
shouldNotHeavyAttackWhenNotUpgraded |
Press the heavy attack before upgrading. | Nothing spawns and no cooldown starts. |
shouldBoostLightDamageButKeepItsSweepWhenUpgraded |
Light attack while upgraded. | Same 90° arc and blade, more damage. |
shouldHeavyAttackWithTheSameBladeWhenUpgraded |
Heavy attack while upgraded. | The spin uses the same blade hitbox, swept by a SweepComponent. |
shouldSpinTheHeavyAttackAFullTurnAroundTheWielder |
Step the spin from start to finish. | It opens behind the player, passes through the facing direction halfway, and completes a full 360°. |
shouldSpinTheHeavyAttackSlowerThanTheLightSweep |
Compare both attacks over the same time. | The light sweep finishes while the spin is still going, so the full turn is readable. |
shouldDealHeavyUpgradeDamage |
Heavy attack while upgraded. | Damage uses the heavy multiplier, not the light one. |
shouldLengthenTheSharedCooldownAfterAHeavyAttack |
Heavy attack, then light attack. | The cooldown is doubled and shared, so the spin cannot be spammed. |
shouldKeepHeavyCooldownAtLeastAsLongAsTheSpinWithAttackSpeedBuffs |
Heavy attack with a 4× attack-speed buff. | The cooldown never drops below the spin's own length, so two spins can never overlap. |
shouldUseTheBaseSpriteWhenNotUpgraded / shouldUseTheUpgradedSpriteForBothAttacksOnceUpgraded
|
Check the sprite in each state. | The fancier blade is drawn for both attacks while upgraded, and the base sprite returns when reverted. |
| Test | Purpose | Expected Result |
|---|---|---|
updateMovesHitboxToTheNewOffsetInTheSameFrame |
Step a sweep one frame. | The blade's position updates in the same frame as its rotation, so fast sweeps never look tilted. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldNotFlurryWhenNotUpgraded |
Press the heavy attack before upgrading. | Nothing spawns and no cooldown starts. |
shouldBoostTheLightStabWhenUpgraded |
Stab while upgraded. | Same stab hitbox, more damage, normal knockback. |
shouldOpenTheFlurryWithAKnockbackFreeSlashRightToLeft |
Start a flurry. | One slash spawns immediately, sweeping 70° across the front of the player with no knockback. |
shouldFollowWithAReverseSlashThenAFinishingStab |
Step through the whole flurry. | A reverse slash follows, then a stab with double damage and the only knockback — and nothing spawns afterwards. |
shouldKeepEveryStrikeAimedWhereTheFlurryStarted |
Start a flurry facing up. | All three strikes stay aimed where the flurry began. |
shouldSpawnQueuedStrikesInFrontOfTheWielderNotOnThem |
Inspect queued strikes as they spawn. | Each appears in front of the player, never flashing on top of them for a frame. |
shouldFadeEveryFlurryStrikeWithThePlayerWhileInvisible |
Activate Invisibility mid-flurry. | Every strike fades with the player, matching the light attack's behaviour. |
shouldCancelTheRestOfTheFlurryWhenTheKnifeIsUnequipped |
Switch weapons mid-flurry. | Queued strikes are dropped, not fired later when the knife is re-equipped. |
shouldTripleTheCooldownAfterAFlurry |
Use the flurry. | The shared cooldown is tripled. |
shouldNeverCoolDownBeforeTheFlurryEndsWithAttackSpeedBuffs |
Use the flurry with a 4× attack-speed buff. | The cooldown never ends before the combo does, so two flurries cannot overlap. |
shouldUseTheBaseSpriteWhenNotUpgraded / shouldUseTheUpgradedSpriteForEveryStrikeOnceUpgraded
|
Check the sprite in each state. | The upgraded knife is drawn for the stab and every flurry strike, and the base sprite returns when reverted. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldBoostLightDamageWhenUpgraded |
Attack (J) while upgraded. |
Still one arrow, now dealing 20% more damage. |
shouldNotHeavyAttackWhenNotUpgraded |
Press the heavy attack before upgrading. | Nothing spawns and no cooldown starts. |
shouldFireThreeArrowsOnHeavyAttackWhenUpgraded |
Heavy attack (K) while upgraded. |
Three arrows spawn — centre plus ±15° — each dealing the same damage as an unupgraded shot. |
shouldFireOnlySideArrowsWhenCentreSpawnBlockedOnHeavyAttack |
Heavy attack next to a wall blocking only the centre path. | The two side arrows still fire independently. |
shouldRevertToSprintOneBowWhenUpgradeRemoved |
Revert the upgrade, then attack and heavy attack. | Light attack returns to base damage; heavy attack is inert again. |
shouldUseTheBaseSpriteWhenNotUpgraded / shouldUseTheUpgradedSpriteForEveryArrowOnceUpgraded
|
Check the sprite in each state. | The upgraded arrow is drawn for the single shot and for all three volley arrows, and the base sprite returns when reverted. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldStartOnTheOwnerOffsetRatherThanTheSpawnOrigin |
Create a hitbox that follows its wielder. | It starts on its offset before any update runs, so delayed strikes never render in the wrong place. |
Setup for every test below: run the game, open the debug terminal (F1), enter weapon sword, weapon knife or weapon bow to equip, enter debug on to see hitbox outlines, then close the terminal (F1).
| Test | Steps | Expected Result |
|---|---|---|
| Sprint 1 sword | Start a run and attack with J without upgrading. |
The original 90° sweep, blade and damage. |
| Sprint 1 knife | Equip the knife and attack with J. |
The original stab. |
| Sprint 1 bow | Equip the bow and attack with J. |
A single arrow with the original arrow sprite. |
| Heavy attack locked | Press K with each weapon. |
The attack sound plays, but nothing spawns. |
| Test | Steps | Expected Result |
|---|---|---|
| Apply the upgrade | Run upgrade sword. |
The blade changes to the fancier winged sword on the next attack. |
| Stronger light attack | Attack an enemy with J before and after upgrading. |
The same 90° sweep kills in fewer hits. |
| Heavy spin | Press K surrounded by enemies. |
The blade sweeps a full circle around the player and hits enemies on every side. |
| Readable spin | Watch the spin at normal speed. | The full turn is easy to follow, and the blade stays pointed along its arc rather than looking tilted. |
| Shared cooldown | Press K, then immediately J. |
The light attack is blocked until the longer cooldown ends. |
| Revert | Run upgrade sword off and attack. |
The Sprint 1 sword and sprite return, and K is inert again. |
| Test | Steps | Expected Result |
|---|---|---|
| Apply the upgrade | Run weapon knife, then upgrade knife. |
J still stabs, now for more damage, drawn with the upgraded knife sprite. |
| Flurry | Press K next to an enemy. |
Two slashes sweep across the front of the player in opposite directions, then a forward stab. |
| Enemies stay in reach | Flurry a single enemy. | The first two slashes do not push it away; only the final stab knocks it back. |
| Aim is locked | Press K, then turn immediately. |
All three strikes keep the direction you faced when you pressed K. |
| No sprite flash | Watch the player while the flurry plays. | No knife sprite appears on top of the player between strikes. |
| Upgraded sprite on every strike | Watch the flurry closely. | Both slashes and the finisher use the upgraded knife sprite, not just the stab. |
| Switching mid-combo | Press K, then change weapons on the hotbar straight away. |
The remaining strikes never appear, including after switching back. |
| Revert | Run upgrade knife off. |
The base knife sprite returns and K goes back to sound only. |
Setup: equip with weapon bow, apply with upgrade bow.
| Test | Steps | Expected Result |
|---|---|---|
| Apply the upgrade | Run upgrade bow. |
J still fires a single arrow, now for more damage and drawn with the upgraded arrow sprite. |
| Heavy shot | Press K facing an enemy. |
Three arrows fan out and can hit multiple enemies at once. |
| Upgraded sprite on every arrow | Watch a volley. | All three arrows use the upgraded sprite, including the two angled ones. |
| Blocked centre still fires sides | Stand next to a wall directly ahead, press K. |
The straight shot is blocked; the two angled arrows still fire. |
| Revert | Run upgrade bow off. |
J returns to a single, unboosted arrow with the base sprite; K is inert again. |
| Test | Steps | Expected Result |
|---|---|---|
| Hotbar | Switch weapons with the hotbar, upgraded and not. | Each weapon keeps its own upgrade state; only the equipped weapon attacks. |
| Player abilities | Activate Invisibility, then attack with each weapon. | Weapon sprites, including every flurry strike and volley arrow, fade with the player. |
| Stat buffs | Pick up a Strength Charm, then attack. | Upgraded damage scales on top of the buffed base attack. |
- Game feel and balance (spin and flurry timings, arrow spread, damage multipliers, cooldown lengths) is judged manually, not asserted. The values are constants and are expected to change with playtesting.
- Sprite appearance is tested by the values passed to the sprite batch, not by pixels. Whether an upgraded weapon looks like an upgrade is a manual judgement.
- Hitboxes stay axis-aligned while their sprites rotate, a pre-existing engine limitation. During the sword's spin, reach at the player's sides is slightly shorter than straight ahead. This is accepted, not tested.
- The heavy attack has no dedicated player animation; that belongs to the upgrade animations ticket.
- How upgrades are granted in normal play (pickup or shop) is not built yet. Anything granting one calls
WeaponUpgradeComponent.setUpgraded(...), the same entry point the terminal command uses.
This page was written with LLM assistance (Claude Code on Opus 5), generated from the weapon upgrade test suite and the team's pull requests, and reviewed before publishing.