Skip to content

Weapons Test Plan

Faraj edited this page Sep 17, 2026 · 4 revisions

Overview

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.

Test Environment

  • 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 bow to equip; upgrade sword|knife|bow to apply an upgrade and upgrade sword|knife|bow off to revert; debug on to show hitbox outlines
  • In normal play weapons are equipped from the weapons hotbar; the weapon command is the QA shortcut for the same state

Run all automated tests with:

cd source
./gradlew :core:test

Coverage is written to source/core/build/reports/jacoco/test/html/index.html by:

cd source
./gradlew :core:jacocoTestReport

Coverage

Last 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.

Quality Gate Coverage

Reference: SonarCloud project overview · coverage by component

The badges above are project-wide. The figures below are the weapons code only.

Per-file coverage

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.

Tests per class

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

Automated Unit Test Plan

Shared Pipeline

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.

WeaponStatsComponentTest

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.

WeaponComponentTest

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.

Weapon Upgrades

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.

WeaponUpgradeComponentTest

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.

WeaponComponentTest (heavy attack pipeline)

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.

UpgradeCommandTest

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.

KeyboardPlayerInputComponentTest and PlayerActionsTest

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.

Knife

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.

KnifeWeaponComponentTest

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.

Sword

The sword's heavy attack is a full 360° spin using the same blade, slower than the light sweep so the turn reads clearly.

SwordWeaponComponentTest

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.

SweepComponentTest

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.

Bow

The bow's heavy attack fires three arrows at once: one along the aim direction and one 15° to each side.

BowWeaponComponentTest

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.

ProjectileComponentTest

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.

Hitbox Spawning

HitboxFactoryTest and HitboxSpecTest

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.

FollowComponentTest

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.

LifetimeComponentTest

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.

Weapon Selection

WeaponSelectionComponentTest

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.

Rendering

RotatingTextureRenderComponentTest

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.

Regression: Crash When a Weapon Kills an Enemy

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.

CollisionDisposalTest

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.

CollisionSpawnTest

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.

Manual Gameplay Test Plan

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.

Knife

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.

Sword

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.

Bow

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.

Rendering

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.

Weapon Selection

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.

Stability

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.

Known Coverage Gaps

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.

Areas Not Covered by Automated Tests

  • 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 the upgrade terminal command uses.
  • The debug terminal weapon and upgrade commands are exercised manually every session; UpgradeCommandTest covers the command logic, but the terminal parsing around it is manual.

AI Declaration

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.

Table of Contents

Home

Test Plan

Team 2

Features

Components
Player
Items and Inventory
Enemies
Interactable NPCs
Cutscenes and Dialogue
Music
Bosses
Rooms
Weapons
Abilities & Spells
Game Screens and Areas
Dungeons

Game Engine

Getting Started

Entities and Components

Service Locator

Loading Resources

Logging

Unit Testing

Debug Terminal

Input Handling

UI

Animations

Audio

AI

Physics

Terrain

Concurrency & Threading

Settings

Troubleshooting

MacOS Setup Guide

Clone this wiki locally