Companion Combat (Phase 1 — offense)
The key words MUST, MUST NOT, REQUIRED, SHOULD, MAY, and OPTIONAL below are used as in RFC 2119.
Phase 1 makes the companion NPC a combatant on offense only, modeled on PSO's partner NPCs (which the original client implements as player objects in a party slot, sharing the player's entity-vs-entity damage pipeline). The companion acquires targets near the player, closes, and attacks through the same hit geometry and damage entry points the player uses. Out of scope for phase 1 (deferred to phase 2): the companion taking damage, enemies targeting the companion, incapacitation/recovery, techniques, healing, and ranged weapons.
Combat states
A combat FSM (FOLLOW / ENGAGE / ATTACK / REGROUP) sits alongside the existing follow behavior:
- FOLLOW — the trail-replay behavior defined in Companion Locomotion, unchanged. Every
COMBAT_SCAN_INTERVAL(0.25 s) the companion scans for a target; acquiring one transitions toENGAGE. - ENGAGE — steer directly toward the target at
FOLLOW_SPEED. When the target is within reach (ENGAGE_REACH_FRAC(0.8) × the weapon'shit_h_dist), transition toATTACK. - ATTACK — face the target, root in place, swing once (see "Attack resolution"), then wait
ATTACK_COOLDOWN(1.2 s ÷ the weapon'sspeed_mult). After the cooldown: target still valid and in reach → swing again; valid but out of reach →ENGAGE; invalid →REGROUP. - REGROUP — steer back toward the player. Within
START_DISTANCE(3.0) of the player, transition toFOLLOW(re-seeding the resume-blend so the handoff back to trail-replay is smooth).REGROUPalways completes toFOLLOW; re-engagement happens fromFOLLOW's next scan tick.
Combat gating
- The FSM MUST NOT leave
FOLLOWwhile the companion is speaking (_speaking) or dismissed (_dismissed). A speech that starts mid-combat MUST forceREGROUPon its next combat tick. - Combat is offense-only: the companion has no Hurtbox in phase 1 and MUST NOT register in any group enemies target.
Target selection
Target selection is a pure function (CompanionCombat.select_target) of candidate data — no scene access — so the unit tests exercise it directly. Candidates are the alive members of the "enemies" group. The rules:
- Leash. A candidate is eligible only while within
LEASH_RADIUS(12.0 m, XZ-planar) of the player — the companion fights near the player, never chases across the stage. A target that leaves the leash MUST be dropped. - Assist priority. If the player's current primary target (the first entry of the player's reticle list) is eligible, the companion MUST pick it — partner-assists-the-player is the core PSO behavior. Otherwise the companion picks the eligible candidate nearest to itself.
- Re-evaluation. Selection re-runs on every scan tick and whenever the current target dies or breaks the leash.
- No progress. If
ENGAGEfails to reduce the distance to the target forENGAGE_NO_PROGRESS_TIME(8 s, e.g. blocked by geometry), the companion MUST drop the target andREGROUP. This is the self-healing path that keeps a blocked companion from wedging in combat.
Attack resolution
The companion reuses the player's hit pipeline end-to-end; it introduces no new damage path:
- Weapon config. Each companion has a weapon type (
COMPANION_WEAPON_TYPES, default SABER) resolving to the sharedCombatManager.WEAPON_TYPE_CONFIGSentry — the same cone geometry,damage_mult,knockback,max_targets, anddamaging_fractables the player uses. A type with no dedicated config falls back to SABER's cone (viaget_weapon_type_config). In phase 1 the swing is always melee and always combo step 1 (no combos), even for a gunblade — the ranged mode is phase-2 work. Kai carries the Axeon gunblade (GUN_BLADE): its type drives both the swing animation pack (shotgun_*.glb) and the held model. - Held model & swing animations. A companion listed in
COMPANION_WEAPONS(Kai →wgbr02_1_o, the Axeon) attaches that GLB to its right-hand bone (070_RArm02), mirroring the player's_attach_weapon_to_bone; psz-native models render at scale 1.0 (no PSO down-scale). Clip sourcing mirrors the player exactly (player.gd _load_weapon_animations):waitandatk1load from the equipped weapon type's pack (gunblade →shotgun_*.glb), whilewalkandrunMUST always load from the shared PSO-retargeted locomotion set (the SABER pack'spmsa/pwsa_walk/_run_pso) — the weapon packs carry only the psz_run"dash", so locomotion is never taken from them. A missing bone, GLB, or texture degrades to empty-handed rather than failing. - Stats. Companion attack/accuracy come from
ClassRegistryat the player's current level, using the companion's class (COMPANION_CLASSES). Raw damage isclass attack × damage_mult[0] × COMPANION_DAMAGE_SCALE(0.7) — the scale keeps the companion a helper, not the main DPS. No weapon-attack, mag, material, or buff terms in phase 1. - Hit test. At
damaging_frac[0]of the swing clip, the companion runs the sameConeTargeting.distance_in_conescan the player's_execute_attack_hituses (origin = companion + 1.0 y, yaw = its facing), takes the nearestmax_targetspassers, and applieshits_per_step[0]hits to each viaenemy.hurtbox.take_hit(damage, knockback, accuracy). Exactly one damaging frame per swing. - Receiver-side math unchanged. Defense, evasion, crits, status, HURT stagger, death, and drops all resolve in the enemy's
_on_hit_received→CombatManager.apply_damage_to_enemy, identically to a player hit.
Animation contract (reconciles /states/companion)
- Each behavior state sets the frame's movement intent:
ENGAGEandREGROUPare steering, sointent_moving = true; a frozenFOLLOWand theATTACKpost-swing cooldown are rooted, sointent_moving = false. The locomotion clip then follows the Companion Locomotion contract verbatim —locomotion_clip(intent_moving, measured planar speed), resolved centrally each frame. Because the companion genuinely moves inENGAGE/REGROUP,walk/runselect naturally; because intent flips to steering the same frame the state starts driving the body, the switch out ofwaitis immediate and the companion MUST NOT slide across a state transition. - In
ATTACKthe companion is rooted and plays the non-loopingatk1clip from the same animation pack its locomotion clips come from; the central locomotion update is suspended for the duration of the swing (it defers whenever a swing is in flight). Zero displacement while a locomotion clip plays is still a violation (#420's tripwire watches onlywalk/run, so a rootedatk1never trips it). A rig with no resolvableatk1MUST still resolve the swing on a fixed 0.5 s timer — damage never depends on the clip existing.
Autopilot oracles
- Checkpoint (positive): under
PSZ_AUTOPILOT, the first landed hit prints[sanity] companion-combat: first hit <enemy> dmg=<n>— the probe line a companion-bearing combat run (Search and Rescue carries Kai) is expected to produce. - Tripwire (negative): under
PSZ_AUTOPILOT, accumulating more thanCOMBAT_STUCK_TIME(60 s) continuously inENGAGE/ATTACKwithout landing a hit pushes[sanity] FAIL: companion '<id>' stuck in combat …— caught by the matrix'sFAIL:grep. The 8 s no-progress drop should make this unreachable; the tripwire is the backstop.
Implemented by
scripts/3d/elements/companion_combat.gd(CompanionCombat) — the pure decision functions:select_target,compute_attack,swing_crossed_damaging_frac,weapon_type_for, andlocomotion_clip(the intent×speed animation selector).scripts/3d/elements/companion_npc.gd— the FSM driver (_process_combatand per-state helpers), the weapon-type animation pack + held-model attach (_setup_companion_anims,_attach_companion_weapon,WEAPON_ANIM,COMPANION_WEAPONS), the central intent-driven animation update, and the autopilot checkpoint/tripwire.
Pinned by test_companion_combat_decisions in scripts/tools/test_runner.gd (target priority, leash filtering, damage math, damaging-frame crossing — all against the pure functions, no scene needed).