Repository navigation
Weapons Test Plan
This test plan covers the Weapons feature team's work across the sprint:
- The shared weapon pipeline — stats, cooldown, damage scaling, and hitbox spawning (Weapons).
- The three weapons: Knife, Sword, and Bow.
- Weapon sprite rendering (Weapon Rendering).
-
Weapon Upgrades — the shared upgrade system, each weapon's heavy attack on
K, and its upgraded sprite. - The fix for the game crashing when a weapon hitbox killed an enemy (#56).
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.
-
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,weapon sword,weapon bowto equip;upgrade sword|knife|bowto apply an upgrade andupgrade sword|knife|bow offto revert;debug onto show hitbox outlines -
In normal play weapons are equipped from the weapons hotbar; the
weaponcommand is the QA shortcut for the same state
Run all automated tests with:
cd source
./gradlew :core:testCoverage is written to source/core/build/reports/jacoco/test/html/index.html by:
cd source
./gradlew :core:jacocoTestReportLast verified run: 17 Sept 2026 on task/126-bow-sprite-change — 1178 tests, 0 failures project-wide, of which 184 tests across 17 classes cover weapons.
Reference: SonarCloud project overview · coverage by component
The badges above are project-wide. The figures below are the weapons code only.
Figures are from a local JaCoCo run of this branch, which is the same measurement SonarCloud consumes. SonarCloud's own published figures lag by one analysis until this branch is merged. Each file links to its SonarCloud measures page.
| File | Lines | Line coverage | Branch coverage |
|---|---|---|---|
weapons/WeaponComponent.java |
45 | 95.6% | 94.1% |
weapons/WeaponStatsComponent.java |
40 | 100.0% | 100.0% |
weapons/WeaponUpgradeComponent.java |
24 | 100.0% | 100.0% |
weapons/WeaponUpgradeStats.java |
4 | 100.0% | 100.0% |
weapons/SwordWeaponComponent.java |
42 | 100.0% | 60.0% |
weapons/KnifeWeaponComponent.java |
87 | 100.0% | 100.0% |
weapons/BowWeaponComponent.java |
34 | 100.0% | 100.0% |
weapons/SweepComponent.java |
25 | 100.0% | 100.0% |
weapons/FollowComponent.java |
16 | 100.0% | 100.0% |
weapons/ProjectileComponent.java |
36 | 88.9% | 75.0% |
weapons/LifetimeComponent.java |
29 | 69.0% | 50.0% |
weapons/WeaponSelectionComponent.java |
22 | 100.0% | 100.0% |
weapons/WeaponAssetsComponent.java |
10 | 100.0% | 50.0% |
factories/HitboxFactory.java |
41 | 100.0% | 100.0% |
factories/HitboxSpec.java |
49 | 100.0% | 88.9% |
rendering/RotatingTextureRenderComponent.java |
26 | 100.0% | 100.0% |
commands/UpgradeCommand.java |
17 | 100.0% | 100.0% |
commands/WeaponCommand.java |
11 | 100.0% | 75.0% |
Aggregate across all 18 classes (JaCoCo, source/core/build/reports/jacoco/test/html/index.html):
| Counter | Covered | Missed | Coverage |
|---|---|---|---|
| Instructions | 2389 | 46 | 98.1% |
| Branches | 211 | 21 | 90.9% |
| Lines | 543 | 15 | 97.3% |
| Methods | 149 | 2 | 98.7% |
| Classes | 18 | 0 | 100.0% |
Every weapon class, the upgrade system, the hitbox factory and both terminal commands are at 100% line coverage. The gaps are listed under Known coverage gaps.
| Test class | Tests |
|---|---|
WeaponStatsComponentTest |
21 |
WeaponComponentTest |
20 |
KnifeWeaponComponentTest |
20 |
SwordWeaponComponentTest |
19 |
HitboxFactoryTest |
17 |
WeaponUpgradeComponentTest |
12 |
SweepComponentTest |
11 |
BowWeaponComponentTest |
10 |
ProjectileComponentTest |
9 |
RotatingTextureRenderComponentTest |
8 |
FollowComponentTest |
8 |
UpgradeCommandTest |
7 |
LifetimeComponentTest |
7 |
HitboxSpecTest |
5 |
WeaponSelectionComponentTest |
4 |
CollisionDisposalTest |
3 |
CollisionSpawnTest |
3 |
| Total | 184 |
Every weapon shares the same stats, cooldown gate, and hitbox factory, so these tests protect all three weapons at once. Damage resolves from the wielder's effective base attack, so both item buffs (Strength Charm) and ability buffs (Last Stand) scale weapon hits.
| Test | Purpose | Expected Result |
|---|---|---|
shouldScaleBaseAttackByMultiplier / shouldRoundResolvedDamageToNearest
|
Resolve damage from the wielder's base attack. | Damage is round(baseAttack × multiplier), so Strength Charm buffs scale weapon hits. |
shouldApplyExtraDamageScaleOnTopOfMultiplier |
Resolve damage with an extra upgrade scale. | Damage is round(baseAttack × multiplier × scale), and the shared multiplier is left untouched — an upgrade can never leak onto another weapon. |
shouldResolveZeroDamageForZeroMultiplier |
Resolve damage with a 0 multiplier. | Damage resolves to 0. |
shouldShortenCooldownForFasterAttackSpeed / shouldLengthenCooldownForSlowerAttackSpeed
|
Resolve cooldown against the wielder's attack speed. | Effective cooldown is cooldown / attackSpeed in both directions. |
shouldIgnoreNonPositiveAttackSpeedWhenResolvingCooldown |
Resolve cooldown with attack speed ≤ 0. | Falls back to the base cooldown instead of dividing by zero. |
shouldGateAttackByResolvedCooldownDuration / shouldAllowAttackAfterCooldownElapses / shouldRemainGatedOnPartialTick
|
Trigger the cooldown and tick time forward. | Attacks are blocked until the full cooldown has elapsed, then allowed again. |
shouldAlwaysAllowAttackWhenCooldownIsZero |
Use a zero-cooldown weapon. | Attacks are never blocked. |
shouldClampRemainingWhenUpdateOvershoots / shouldTreatNegativeDeltaAsZero / shouldTreatNegativeTriggeredCooldownAsZero
|
Feed oversized or negative frame times and durations. | The countdown clamps at zero and never runs backwards. |
shouldRejectNegativeConstructorValues / shouldRejectNegativeSetters
|
Construct stats with invalid values. |
IllegalArgumentException is thrown. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldCallCreateAttackWhenReady / shouldNotCreateAttackWhileCoolingDown / shouldCreateAttackAgainAfterCooldown
|
Attack repeatedly through a cooldown cycle. |
createAttack fires only when the shared cooldown allows it. |
shouldFailCreateWithoutStats |
Attach a weapon to an entity with no WeaponStatsComponent. |
Creation fails fast rather than crashing mid-game. |
shouldRejectNullOriginOrDirection / shouldRejectNullOriginOrDirectionForHeavyAttack
|
Attack with null arguments. |
IllegalArgumentException is thrown for both light and heavy attacks. |
shouldScaleHitboxDamageByWielderBaseAttack / shouldFollowBaseAttackBuffs
|
Buff the wielder's base attack, then attack. | The spawned hitbox carries the newly scaled damage. |
shouldScaleCooldownByWielderAttackSpeed / shouldUseBaseCooldownWithoutCombatStats
|
Attack with and without combat stats. | Cooldown scales with attack speed, and falls back cleanly when the wielder has no stats. |
shouldUseLiveLastStandDamageAndCooldownWithoutChangingWeaponOrRawStats |
Attack while a Last Stand buff is active, then after it expires. | Damage and cooldown follow the live ability buff without permanently editing weapon or player stats. |
An upgrade grants a weapon more light-attack damage, a heavy attack on K, a longer shared cooldown when that heavy attack is used, and a fancier sprite. Upgrade state lives on the player in WeaponUpgradeComponent, never in the weapon, so upgrading one weapon cannot buff another.
Each upgraded sprite is an unedited crop of the same CC0 steel-weapons.png sheet, drawn at the same angle as the base sprite, with grid cells recorded in core/assets/licenses/OPENGAMEART-ATTRIBUTION.md.
| 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 one weapon, then read the others. | 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. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldUpgradeSword / shouldUpgradeAndRevertKnife / shouldUpgradeAndRevertBow
|
Run upgrade <weapon> [off]. |
QA can apply and revert any upgrade without a pickup or shop. |
shouldStayUpgradedWhenRunTwice / shouldRevertSwordUpgradeWithOff
|
Run the command repeatedly, then revert. | Applying is idempotent and reverting restores 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 player abilities feature. |
shouldTriggerHeavyWeaponAttackInFacingDirectionOnHeavyAttack |
Walk, stop, then heavy attack. | The heavy attack uses the player's current facing. |
The knife's heavy attack is a three-hit flurry: two 70° slashes in opposite directions, then a finishing stab. Only the finisher knocks back, so enemies stay in reach of every hit.
| Test | Purpose | Expected Result |
|---|---|---|
shouldSpawnFollowingHitboxWithoutASweep |
Attack with the knife. | One short-lived hitbox spawns, follows the wielder, and has no sweep behaviour. |
shouldSizeAndPlaceHitboxForAHorizontalAttack / shouldTransposeHitboxForAVerticalAttack
|
Attack left/right, then up/down. | The blade-shaped hitbox is transposed to match the attack axis and placed just outside the wielder. |
shouldSnapOffsetToACardinalDirection |
Attack on a diagonal facing. | The stab snaps to the nearest cardinal direction (matches WASD facing). |
shouldAttachSpriteFacingTheAttackDirection |
Attack in each direction. | The knife sprite is attached and rotated to point along the stab. |
shouldScaleDamageByWielderBaseAttack |
Buff the wielder, then stab. | Hitbox damage reflects the buffed base attack. |
shouldExpireBeforeTheWielderCanAttackAgain |
Compare stab lifetime against the cooldown. | The old stab is always gone before a new one can spawn, so an enemy can never take double hits from stacked hitboxes. |
shouldRenderSpawnedKnifeWithLivePlayerAbilitiesAndExpiry |
Attack, then activate and expire player abilities. | The stab sprite follows the player's live appearance, e.g. fading while invisible. |
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 / shouldNeverCoolDownBeforeTheFlurryEndsWithAttackSpeedBuffs
|
Use the flurry, with and without an attack-speed buff. | The cooldown is tripled, and never ends before the combo does, so two flurries cannot overlap. |
shouldUseTheBaseSpriteWhenNotUpgraded / shouldUseTheUpgradedSpriteForEveryStrikeOnceUpgraded
|
Check the sprite in each state. | The upgraded knife sprite is drawn for the stab and every flurry strike, and the base sprite returns when reverted. |
The sword's heavy attack is a full 360° spin using the same blade, slower than the light sweep so the turn reads clearly.
| Test | Purpose | Expected Result |
|---|---|---|
shouldSpawnASweepingHitboxThatFollowsTheWielder |
Attack with the sword. | The hitbox has both a FollowComponent and a SweepComponent, so it arcs while tracking the wielder. |
shouldSizeAndTransposeHitboxWithTheAttackAxis |
Attack horizontally and vertically. | The blade hitbox is transposed to match the attack axis. |
shouldStartTheArcHalfATurnBeforeTheAimDirection |
Inspect the spawn offset. | The swing starts half the arc before the aim direction, so the aim sits in the middle of the sweep. |
shouldAimTheSpriteAlongTheStartOfTheArc / shouldTurnTheBladeAsTheSweepProgresses
|
Step the sweep forward. | The sword sprite starts aligned with the arc's start and rotates through the swing. |
shouldScaleDamageByWielderBaseAttack |
Buff the wielder, then swing. | Hitbox damage reflects the buffed base attack. |
shouldRenderSpawnedSwordWithLivePlayerAbilitiesAndExpiry |
Swing, then activate and expire player abilities. | The blade sprite follows the player's live appearance. |
shouldKeepSprintOneLightAttackWhenNotUpgraded / shouldNotHeavyAttackWhenNotUpgraded
|
Attack before upgrading. | The sword keeps its original blade, arc and damage, and K spawns nothing. |
shouldBoostLightDamageButKeepItsSweepWhenUpgraded |
Light attack while upgraded. | Same 90° arc and blade, more damage. |
shouldHeavyAttackWithTheSameBladeWhenUpgraded / shouldSpinTheHeavyAttackAFullTurnAroundTheWielder
|
Heavy attack, then step it to the end. | The spin uses the same blade, 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 / shouldKeepHeavyCooldownAtLeastAsLongAsTheSpinWithAttackSpeedBuffs
|
Spin, then attack again, with and without an attack-speed buff. | The cooldown is doubled and shared, and never drops below the spin's own length, so two spins can never overlap. |
shouldUseTheBaseSpriteWhenNotUpgraded / shouldUseTheUpgradedSpriteForBothAttacksOnceUpgraded
|
Check the sprite in each state. | The upgraded blade is drawn for both attacks while upgraded, and the base sprite returns when reverted. |
shouldRevertToSprintOneSwordWhenUpgradeRemoved |
Revert the upgrade, then attack. | Sprint 1 damage, blade and sprite all return, and K goes inert. |
| Test | Purpose | Expected Result |
|---|---|---|
updateSetsOffsetAtStartAngleOnFirstFrame / updateInterpolatesAngleAtHalfDuration / updateClampsAtEndAngleOncePastDuration
|
Step a sweep from start to past its end. | The offset moves along the arc from start angle to end angle and clamps there. |
updateAccumulatesElapsedTimeAcrossCalls |
Step in many small frames. | Progress accumulates correctly regardless of frame size. |
updateMovesHitboxToTheNewOffsetInTheSameFrame |
Step a sweep one frame. | The blade's position updates in the same frame as its rotation, so fast sweeps never look tilted. |
updateIsNoOpWithoutFollowComponent |
Attach a sweep to a non-following entity. | Nothing happens rather than crashing. |
constructorRejects… (zero/negative duration, negative radius) / negativeDeltaTimeTreatedAsZero
|
Feed invalid values. | Rejected or clamped safely. |
The bow's heavy attack fires three arrows at once: one along the aim direction and one 15° to each side.
| Test | Purpose | Expected Result |
|---|---|---|
shouldSpawnTravellingHitboxOnAttack |
Fire the bow. | A world-space hitbox (no owner) spawns carrying round(baseAttack × multiplier) damage and a ProjectileComponent. |
shouldNotFireThroughAdjacentWall |
Fire while standing against a wall. | No arrow spawns — the bow refuses to fire through an obstacle the player is touching. |
shouldRenderSpawnedArrowWithLivePlayerAbilitiesAndExpiry |
Fire, then activate and expire player abilities. | The arrow sprite follows the player's live appearance. |
shouldNotHeavyAttackWhenNotUpgraded |
Press the heavy attack before upgrading. | No arrows spawn and no cooldown starts. |
shouldBoostLightDamageWhenUpgraded |
Fire a single arrow while upgraded. | The arrow carries the upgraded damage. |
shouldFireThreeArrowsOnHeavyAttackWhenUpgraded |
Heavy attack while upgraded. | Three arrows spawn: one straight ahead and one 15° to each side. |
shouldFireOnlySideArrowsWhenCentreSpawnBlockedOnHeavyAttack |
Heavy attack with the centre line blocked. | Each arrow is checked independently, so one blocked arrow does not cancel the other two. |
shouldRevertToSprintOneBowWhenUpgradeRemoved |
Revert the upgrade, then fire. | Sprint 1 damage returns and K goes inert. |
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 |
|---|---|---|
shouldTravelInGivenDirection |
Step the arrow over several frames. | It moves in a straight line at its configured speed. |
shouldDespawnOnEnemyHit |
Fly the arrow into an enemy. | It damages the first enemy it touches, then despawns — it does not pierce. |
shouldDespawnWhenWallBlocksNextStep / arrowStopsAtWallAcrossRangesAndFrameRates / arrowStopsAtWallAfterFrameHitch
|
Fly the arrow at walls across many distances, frame rates, and a simulated frame hitch. | The arrow always stops at the wall — it never tunnels through, even on a slow frame. |
arrowKeepsFlyingWhenNothingBlocksIt |
Fly through open space. | The arrow keeps going until its lifetime expires (~1.5 s, which caps its range). |
constructorRejects… (null/zero direction, non-positive speed) |
Construct with invalid values. |
IllegalArgumentException is thrown. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldSpawnSensorOnWeaponLayer |
Build a hitbox from a spec. | The entity is a kinematic sensor on PhysicsLayer.WEAPON. |
shouldDamageTargetOnCollision |
Collide the sensor with an NPC-layer target. | The target takes the spec's damage through the existing combat components. |
shouldFollowWhenOwnerProvided / shouldNotFollowWhenOwnerOmitted
|
Build with and without an owner. | Melee hitboxes get a FollowComponent; world-space hitboxes (arrows) do not. |
shouldStartOnTheOwnerOffsetRatherThanTheSpawnOrigin |
Build a hitbox that follows its wielder. | It starts on its offset before any update runs, so a delayed strike (e.g. a queued flurry slash) never renders on top of the player for a frame. |
shouldInheritLiveAppearanceWithoutFollowingOrRescalingDamage / shouldAllowVisualSourceWithoutTextureAndWithoutFollowOwner
|
Build with an appearance source. | A hitbox can inherit the player's live appearance without inheriting movement or damage scaling. |
shouldPreserveHostileHitboxAsDamageSourceAndRespectInvisibilityOnCollision |
Collide a hostile hitbox with an invisible player. | Invisibility is respected at the point of damage. |
shouldReject… (null spec, missing position/size, non-positive size, negative lifetime/damage/knockback) |
Build from invalid specs. | Each throws IllegalArgumentException. |
shouldDefaultLayerAndOffset / shouldCopyVectorsOnSetAndGet
|
Inspect spec defaults and vector handling. | Sensible defaults; vectors are defensively copied so callers cannot mutate the spec. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldTrackOwnerPlusOffset / shouldHoldOffsetWhenOwnerDoesNotMove
|
Move the owner around. | The hitbox stays at owner position + offset every frame. |
shouldAllowOffsetMutationForSweeps |
Change the offset mid-lifetime. | The hitbox moves to the new offset — this is what drives the sword's arc. |
shouldCopyOffsetSoCallerMutationDoesNotMoveFollower / shouldExposeOwnerAndOffsetCopies
|
Mutate vectors after passing them in. | The follower is unaffected; state is defensively copied. |
shouldRejectNullOwner / shouldRejectNullOffset / shouldRejectNullSetLocalOffset
|
Construct or update with nulls. |
IllegalArgumentException is thrown. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldNotDisposeBeforeLifetimeElapses / shouldDisposeOnceWhenLifetimeElapses
|
Tick a hitbox through its lifetime. | It survives until expiry, then is disposed exactly once. |
shouldDisposeImmediatelyWhenLifetimeIsZero |
Use a zero lifetime. | Disposed on the first update. |
shouldNotDisposePhysicsAgainWhenOwningRoomIsDisposed |
Expire a projectile, then dispose its room. | The physics body is disposed only once — no double-free crash. |
shouldRejectNegativeLifetime / shouldTreatNegativeDeltaAsZero
|
Feed invalid values. | Rejected or clamped safely. |
| Test | Purpose | Expected Result |
|---|---|---|
startsWithSwordAndSwitchesExclusivelyThroughEvents |
Equip each weapon through the shared selection events. | The sword starts equipped, and exactly one weapon is enabled at a time. |
repeatedAndInvalidSelectionsPreserveStateAndDoNotEmitEvents |
Re-select the current weapon, and select an invalid one. | State is unchanged and no spurious events fire. |
missingWeaponDoesNotDisableCurrentWeapon |
Select a weapon the player does not carry. | The current weapon stays equipped. |
terminalUsesSharedSelectionAndPreservesCooldown |
Equip through the weapon terminal command. |
The terminal and the hotbar share one source of truth, and switching does not reset the shared cooldown. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldDrawCentredOnEntityCentreUsingEntityScale |
Draw with no visual overrides. | The sprite is centred on the entity's centre so it pivots about its middle, not a corner. |
shouldPreferVisualScaleOverEntityScale / shouldFallBackToEntityScaleWhenVisualScaleCleared
|
Set and clear a visual scale. | The sprite can be sized independently of the hitbox, and reverts cleanly. |
shouldAddRotationOffsetToRotation / shouldReportRotationWithoutOffset
|
Rotate with a fixed art-correction offset. | The offset is applied at draw time only; logical rotation stays clean. |
shouldShiftSpriteAlongFacingWithoutMovingTheEntity / shouldRotateOffsetByFacingOnlyNotBySpriteOffset
|
Apply a facing-relative visual offset. | The sprite shifts (e.g. handle pulled to the wielder) without moving the hitbox. |
shouldCopyVisualScaleOnSetAndGet |
Mutate vectors after passing them in. | State is defensively copied. |
Box2D forbids creating or destroying physics bodies during a collision callback. These tests step a real Box2D world and confirm the fix from #56 holds: they reproduce the original crash when the fix is reverted.
| Test | Purpose | Expected Result |
|---|---|---|
shouldNotDisposeEnemyDuringPhysicsStepThatKillsIt |
A weapon sensor deals lethal damage mid-step. | The enemy is not disposed during the physics step (which would crash in DestroyFixture). |
shouldDisposeEnemyAfterPhysicsStepThatKillsIt |
Update the entity service after the step. | The enemy is disposed on the next update. |
shouldKeepSteppingAfterDisposingEnemyKilledByCollision |
Keep stepping the world afterwards. | No crash. |
| Test | Purpose | Expected Result |
|---|---|---|
shouldNotSpawnChildrenDuringPhysicsStepThatSplitsEnemy |
A weapon hit makes a split enemy split mid-step. | Children are not spawned during the physics step (which would crash in CreateBody). |
shouldSpawnChildrenAfterPhysicsStepThatSplitsEnemy |
Update the entity service after the step. | Both children appear on the next update. |
shouldKeepSteppingAfterSplittingEnemy |
Keep stepping the world afterwards. | No crash. |
Setup for every test below: run the game, open the debug terminal (F1), enter weapon knife / weapon sword / weapon bow to equip, enter debug on to see hitbox outlines, then close the terminal (F1). Upgrade tests additionally use upgrade <weapon> and upgrade <weapon> off.
| Test | Steps | Expected Result |
|---|---|---|
| Directional stab | Face each direction with WASD and press J. |
A small hitbox and knife sprite appear just in front of the player, pointing the way they face. |
| Follows the wielder | Attack while walking. | The hitbox and sprite move with the player for the stab's duration. |
| Damage | Stab an enemy. | The enemy takes damage and dies at zero health. |
| No stacked hits | Hold or spam J next to an enemy. |
Only one stab exists at a time; the enemy is never double-hit by overlapping hitboxes. |
| Heavy locked | Press K before upgrading. |
The attack sound plays, but no hitbox appears. |
| Flurry | Run upgrade knife, then 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 faced when K was pressed. |
| No sprite flash | Watch the player through the flurry. | No knife sprite appears on top of the player between strikes. |
| Switching mid-combo | Press K, then change weapons on the hotbar straight away. |
The remaining strikes never appear, including after switching back. |
| Upgraded sprite on every strike | Watch a flurry while upgraded. | Both slashes and the finisher use the upgraded knife sprite, not just the stab. |
| Upgraded sprite and revert | Compare the blade before and after upgrade knife, then run upgrade knife off. |
The upgraded knife sprite is drawn while upgraded, and the base knife and inert K return afterwards. |
| Test | Steps | Expected Result |
|---|---|---|
| Sweeping arc | Attack and watch the debug outline. | The hitbox sweeps a 90° arc around the player, centred on the facing direction. |
| Blade turns with the swing | Attack and watch the sprite. | The sword sprite rotates through the arc, handle staying near the player. |
| Follows the wielder | Attack while walking. | The whole arc moves with the player. |
| Damage | Sweep through an enemy. | The enemy takes damage anywhere along the arc. |
| Heavy locked | Press K before upgrading. |
The attack sound plays, but no hitbox appears. |
| Heavy spin | Run upgrade sword, then press K while surrounded. |
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. |
| Shared cooldown | Press K, then immediately J. |
The light attack is blocked until the longer cooldown ends. |
| Upgraded sprite and revert | Compare the blade before and after upgrading, then run upgrade sword off. |
The fancier winged sword is drawn while upgraded; the Sprint 1 sword and inert K return afterwards. |
| Test | Steps | Expected Result |
|---|---|---|
| Arrow flight | Face each direction and press J. |
An arrow spawns at the player's edge and flies straight, sprite pointing along its flight. |
| Non-piercing | Fire into a group of enemies. | The arrow damages the first enemy it touches, then disappears. |
| Blocked by obstacles | Fire at walls, rocks, and barrels. | The arrow stops instead of passing through. |
| No firing through walls | Stand against a wall and fire into it. | No arrow spawns beyond the wall. |
| Range cap | Fire into open space. | The arrow expires after roughly 1.5 seconds of flight. |
| Heavy locked | Press K before upgrading. |
The attack sound plays, but no arrow appears. |
| Three-arrow volley | Run upgrade bow, then press K. |
Three arrows fly out: one straight ahead and one angled to each side. |
| Partly blocked volley | Heavy attack with a wall directly ahead. | The blocked arrow is skipped while the others still fly. |
| Upgraded sprite on every arrow | Fire J and K while upgraded. |
The single shot and all three volley arrows use the upgraded arrow sprite, including the angled ones. |
| Revert | Run upgrade bow off. |
Single arrows with the base sprite return, and K goes back to sound only. |
| Test | Steps | Expected Result |
|---|---|---|
| Sprite vs hitbox | Attack with debug on. |
The sprite and the hitbox outline are independent: the outline is sized for collision, the sprite keeps its own square aspect ratio. |
| Crisp pixel art | Attack in every direction and mid-sweep. | Sprites stay sharp at every angle (rotation happens at draw time, not by resampling the image). |
| One sprite at a time | Spam attacks. | Old sprites disappear with their hitboxes; sprites never pile up. |
| Upgraded sprites line up | Upgrade each weapon, then attack in every direction. | Upgraded sprites sit and rotate exactly like the base ones, since each is cut from the same sheet at the same angle. |
| Abilities | Activate Invisibility, then attack with each weapon. | Weapon sprites, including every flurry strike and volley arrow, fade with the player. |
| Test | Steps | Expected Result |
|---|---|---|
| Hotbar and terminal agree | Switch with the hotbar, then with weapon <name>. |
Both change the same equipped weapon; only that weapon attacks. |
| Upgrades are per weapon | Upgrade one weapon, then switch to another. | Each weapon keeps its own upgrade state and sprite; switching never grants or removes an upgrade. |
| Cooldown is shared | Attack, then switch weapons immediately and attack again. | The shared cooldown still applies, so switching cannot be used to attack faster. |
| Test | Steps | Expected Result |
|---|---|---|
| Kill on contact | Kill many enemies with each weapon, light and heavy, with and without debug on. |
No Box2D crash when the killing blow lands. |
| Split enemies | Hit a splitting enemy with a weapon. | It splits into two children without crashing, and the children die normally. |
Fifteen lines are uncovered, all of them defensive fallbacks or one untested attack axis:
| Location | Uncovered | Why |
|---|---|---|
LifetimeComponent.removeNow() |
The whole method, plus the expired early return |
The fallback used when no EntityService is registered. Every test and the game itself register one, so only the normal runAfterUpdate path is exercised. This is the largest gap in the feature. |
ProjectileComponent.update() / onCollisionStart()
|
Null-clock and paused-frame guards, and the "not my fixture" / "not a stopping layer" early returns | Guards against a missing clock, a paused game and collisions reported for other fixtures. The flight and wall-stop behaviour they protect is covered directly. |
SwordWeaponComponent.spawnSweep() |
The vertical branch of the axis flip |
shouldSizeAndTransposeHitboxWithTheAttackAxis only asserts a horizontal swing, so the transposed blade and reach for an up or down swing are never measured. Worth closing — the knife covers the same branch in both axes. |
WeaponComponent.onWeaponAttack() |
The !enabled early return for the light attack |
The heavy attack's equivalent guard is covered; the light one is only exercised through weapon selection rather than by a direct test. |
HitboxSpec.visualScale() / visualOffset()
|
The null branches of both setters | Clearing a visual override back to null never happens in production; the non-null paths are covered. |
WeaponAssetsComponent.dispose() |
The null ResourceService branch |
Guards disposal after the service is torn down. |
WeaponCommand.action() |
Two branches of the equip guard | The missing-player and missing-selection paths; WeaponSelectionComponentTest covers equipping itself. |
- Rendering tests verify the arguments passed to the sprite batch, not actual pixels. Visual correctness (anchoring, crispness, whether the sword swing "reads" as a swing, whether an upgraded weapon looks like an upgrade) is tested manually.
- Game feel and tuning values (lifetimes, arc widths, spin and flurry timings, arrow speed and spread, damage multipliers, cooldown lengths) are judged manually, not asserted.
- 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.
- Heavy attacks have no dedicated player animation yet; 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 theupgradeterminal command uses. - The debug terminal
weaponandupgradecommands are exercised manually every session;UpgradeCommandTestcovers the command logic, but the terminal parsing around it is manual.
This page was written with LLM assistance (Cursor on Fable 5, later updated with Claude Code on Opus 5), generated from the weapons test suite and the team's pull requests, and reviewed before publishing.