Files
Project-M/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs
T
kronic 9f61f7c6fe LANTERN kit into the shipping scene + frame rename (audit H2/M4)
H2 was the audit's sharpest finding: in Game.unity every player spawned
with four ability sockets pointing at SparkIds the baked AbilityDatabase
did not contain, so all four resolved to Damage=0 Range=0 Cooldown=0.
Melee and dash were the only working combat verbs in the built game.
Cause: the 5 LANTERN Sparks were added to GymSub.unity and never to
Gameplay.unity.

- Gameplay.unity's AbilityDatabaseAuthoring now carries all 9 defs (the
  4 legacy ids keep their numbers; Sparks are 5-9) with their effect
  prefabs. Live-verified in Play: sockets now read
  Vortex 8dmg/6range/420cd, Blink 20range/1cd, Hook & Pull
  15dmg/25range/120cd, Light Zone 6dmg/5range/480cd.
- Removed 6 orphaned authoring GameObjects the purge left behind in the
  subscene (StorageSpawner, StructureCatalog, ItemDatabase,
  SpitterProjectileConfig, BoonCatalog, MetaCatalog) and the RoomDressing
  object in Game.unity — the latter is what scattered 47 Synty desert
  props into the seabed murk.
- Deleted 4 now-unreferenced prefabs: EnemySpit, Pylon, Storage, Wall.
- Frame rename (M4): FrameKind.Warrior/Ranger -> Bathynaut/Harpooner,
  93 identifier sites. Byte values pinned (2/3), so no ghost-hash or save
  impact. The menu said "Warrior"/"Ranger" to players three weeks after
  the frames were renamed in design.
- Menu now reads LANTERN / "Light is territory — co-op descent" instead
  of "PROJECT M" / "Frontier colony — co-op" (the Awakening-Engine
  tagline, two directions stale).
- Removed the "Replay Tutorial" button and the Settings ONBOARDING
  section: both drove coach-marks DR-051 deleted. The settings fields
  still round-trip so existing settings files load unchanged.

All four project scenes + all prefabs verified free of missing scripts.
Server measured at 59.6 ticks/s against a 60 Hz target. 295/295 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:11:58 -07:00

122 lines
7.5 KiB
C#

using System;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Slice 2 — pure mapping from a chosen frame (a <see cref=\"FrameKind\"/> byte) to its spawn-time setup: the
/// Fire-slot ability id + the permanent trait <see cref="StatModifier"/>s (tagged with the reserved
/// <see cref="Tuning.ClassSourceId"/> range, NEVER stripped by the generic modifier systems). Applied by
/// GoInGameServerSystem on the just-spawned player and unit-tested. Burst-safe (byte/uint only, no managed types).
/// <para>
/// Trait deltas seed onto the <see cref=\"FrameKind.Default\"/> character (no per-class blob row needed — the
/// deltas ride the replicated StatModifier buffer, OwnerSendType.All, so the owning client folds the correct
/// EffectiveCharacterStats). The DRG-asymmetry (operator-locked): <b>Bathynaut</b> = melee bruiser (tankier,
/// slower, harder + longer-reach melee via MeleeDamage/MeleeRange); <b>Harpooner</b> = ranged anchor (faster,
/// squishier, longer projectile range + a wider auto-target assist — the co-op synergy hook the Bathynaut's
/// knockback feeds). The Bathynaut's Fire = the aim-directed cone (<see cref="AbilityId.WarriorCone"/>); the
/// Harpooner's Fire = the default projectile (<see cref="AbilityId.Primary"/>).
/// </para>
/// <para>
/// <see cref="Reapply"/> swaps an already-spawned player's class IN PLACE (strip the old class seeds, re-seed
/// the new ones) — used by the editor-only class-switch dev tool. The single seed set lives in <see cref="Seeds"/>
/// so the spawn path and the swap path can never drift; <see cref="ClassSeedCount"/> couples the strip range
/// (<see cref="IsClassSeed"/>) to the number of seeds emitted.
/// </para>
/// </summary>
public static class ClassTraits
{
public const byte BathynautFrame = (byte)FrameKind.Bathynaut;
public const byte HarpoonerFrame = (byte)FrameKind.Harpooner;
/// <summary>How many trait modifiers a class seeds (each on a distinct SourceId at ClassSourceId + i).</summary>
public const int ClassSeedCount = 4;
/// <summary>Normalize a wire ClassId to a known class (0 / unknown -> Bathynaut).</summary>
public static byte Normalize(byte classId) => classId == HarpoonerFrame ? HarpoonerFrame : BathynautFrame;
/// <summary>The Fire-slot ability id for a class (Bathynaut = cone, Harpooner = the default projectile).</summary>
/// <summary>The class a Fire-slot ability id implies — the exact inverse of <see cref="AbilityFor"/> (Harpooner
/// iff Primary). Lets the CLIENT derive the local class from the replicated <see cref="AbilityRef"/> (tracks
/// the dev class-switch, unlike the menu's ClassSelection static; PlayerClass itself is server-only).</summary>
/// <summary>Default 4-socket Spark loadout per frame (Harpooner = the Harpooner slot, line-and-iron; Bathynaut =
/// the Bathynaut slot, anchor-and-crash). Tunable; drives the gym's per-frame default sockets (Build Spec step 5).</summary>
public static void FrameLoadout(byte classId, out byte s0, out byte s1, out byte s2, out byte s3)
{
if (classId == HarpoonerFrame) // Harpooner: reel primary + mobility + skillshots
{
s0 = (byte)AbilityId.HookPull; s1 = (byte)AbilityId.Blink; s2 = (byte)AbilityId.DecoyWisp; s3 = (byte)AbilityId.LightZone;
}
else // Bathynaut: pull enemies in + dash + zone control (melee via the combo chassis)
{
s0 = (byte)AbilityId.Vortex; s1 = (byte)AbilityId.Blink; s2 = (byte)AbilityId.HookPull; s3 = (byte)AbilityId.LightZone;
}
}
/// <summary>True when a modifier's SourceId is in the reserved class-seed range [ClassSourceId, +ClassSeedCount).</summary>
public static bool IsClassSeed(uint sourceId)
=> sourceId >= Tuning.ClassSourceId && sourceId < Tuning.ClassSourceId + (uint)ClassSeedCount;
/// <summary>
/// The permanent trait deltas for a class — the SINGLE source shared by both the spawn path
/// (<see cref="AppendSeeds(byte,Entity,EntityCommandBuffer)"/>) and the in-place swap path
/// (<see cref="AppendSeeds(byte,DynamicBuffer{StatModifier})"/> / <see cref="Reapply"/>), so they can't drift.
/// Fills exactly <see cref="ClassSeedCount"/> entries, each tagged ClassSourceId + i.
/// </summary>
static void Seeds(byte classId, Span<StatModifier> seeds)
{
uint src = Tuning.ClassSourceId;
if (classId == HarpoonerFrame)
{
seeds[0] = new StatModifier { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = 0.15f, SourceId = src };
seeds[1] = new StatModifier { Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.PercentMult, Value = -0.15f, SourceId = src + 1u };
seeds[2] = new StatModifier { Target = (byte)StatTarget.Range, Op = (byte)ModOp.PercentAdd, Value = 0.30f, SourceId = src + 2u };
seeds[3] = new StatModifier { Target = (byte)StatTarget.AutoTargetRange, Op = (byte)ModOp.Flat, Value = 3f, SourceId = src + 3u };
}
else
{
seeds[0] = new StatModifier { Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f, SourceId = src };
seeds[1] = new StatModifier { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = -0.15f, SourceId = src + 1u };
seeds[2] = new StatModifier { Target = (byte)StatTarget.MeleeDamage, Op = (byte)ModOp.Flat, Value = 6f, SourceId = src + 2u };
seeds[3] = new StatModifier { Target = (byte)StatTarget.MeleeRange, Op = (byte)ModOp.Flat, Value = 0.8f, SourceId = src + 3u };
}
}
/// <summary>Append a class's permanent trait modifiers onto a player's StatModifier buffer (via ECB at spawn).</summary>
public static void AppendSeeds(byte classId, Entity player, EntityCommandBuffer ecb)
{
Span<StatModifier> seeds = stackalloc StatModifier[ClassSeedCount];
Seeds(classId, seeds);
for (int i = 0; i < ClassSeedCount; i++)
ecb.AppendToBuffer(player, seeds[i]);
}
/// <summary>Append a class's permanent trait modifiers directly onto a live StatModifier buffer (in-place swap).</summary>
public static void AppendSeeds(byte classId, DynamicBuffer<StatModifier> mods)
{
Span<StatModifier> seeds = stackalloc StatModifier[ClassSeedCount];
Seeds(classId, seeds);
for (int i = 0; i < ClassSeedCount; i++)
mods.Add(seeds[i]);
}
/// <summary>
/// Swap an already-spawned player's class IN PLACE: strip whatever class seeds are present, then re-seed the
/// target class. Order-independent (StatMath folds by Target/Op), so the RemoveAtSwapBack reordering is safe,
/// and idempotent (switching to the same class still leaves exactly <see cref="ClassSeedCount"/> seeds).
/// Foreign modifiers (upgrades, equipment, timed buffs — all on disjoint SourceIds) are preserved.
/// </summary>
public static void Reapply(byte classId, DynamicBuffer<StatModifier> mods)
{
for (int i = mods.Length - 1; i >= 0; i--)
if (IsClassSeed(mods[i].SourceId))
mods.RemoveAtSwapBack(i);
AppendSeeds(classId, mods);
}
}
}