Run Re-Do

This commit is contained in:
2026-07-02 20:41:43 -07:00
parent 86575dd5bc
commit 16e396841e
188 changed files with 8291 additions and 2429 deletions
@@ -0,0 +1,180 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// One authored boon in the catalog blob: a thin wrapper over the existing stat pipeline —
/// <see cref="Target"/>/<see cref="Op"/>/<see cref="Value"/> map 1:1 onto a <see cref="StatModifier"/> row
/// (bytes, never enums, on the baked path). <see cref="Id"/> is the stable APPEND-ONLY key the replicated
/// <c>BoonOffer</c> options and pick RPC carry. <see cref="Weight"/> is the rarity draw weight
/// (common 100 / rare 30 / epic 10). <see cref="ClassMask"/> gates by class: bit0 = Warrior (classId 0),
/// bit1 = Ranger (classId 1), 3 = both.
/// </summary>
public struct BoonDefBlob
{
public byte Id;
public byte Target; // StatTarget as byte
public byte Op; // ModOp as byte
public float Value;
public byte Weight;
public byte ClassMask;
public FixedString64Bytes Name;
public FixedString128Bytes Desc;
}
/// <summary>The baked boon pool (config blob, both worlds, NOT replicated — the AbilityDatabase pattern).</summary>
public struct BoonCatalogBlob
{
public BlobArray<BoonDefBlob> Defs;
}
/// <summary>Singleton component carrying the baked catalog (place ONE BoonCatalogAuthoring in the subscene).</summary>
public struct BoonCatalog : IComponentData
{
public BlobAssetReference<BoonCatalogBlob> Value;
}
/// <summary>
/// Server-only bookkeeping for <c>BoonOfferSystem</c>, attached at runtime beside the catalog singleton (the
/// RoomFieldState idiom): the RoomEpoch offers were last drawn for — int equality, one offer set per room.
/// </summary>
public struct BoonOfferState : IComponentData
{
public int OfferedRoomEpoch;
}
/// <summary>
/// Pure, deterministic boon selection math — integer-hash only (<see cref="RunMapMath.Hash(uint,uint)"/> chain,
/// no RNG state), so an offer is a reproducible function of (runSeed, room, player). EditMode-tested.
/// </summary>
public static class BoonMath
{
/// <summary>Class-mask bit for a wire class id (0 = Warrior, 1 = Ranger).</summary>
public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1));
/// <summary>
/// Draw 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per
/// <paramref name="offerSeed"/>. If the class-legal pool has fewer than 3 entries the tail repeats the
/// last-drawn candidates (a catalog authoring smell, not a crash). Returns the number of distinct ids.
/// </summary>
public static int PickBoons(uint offerSeed, byte classId, ref BoonCatalogBlob pool,
out byte o0, out byte o1, out byte o2)
{
byte classBit = MaskFor(classId);
// Class-legal candidate indices + the total weight.
var candidates = new FixedList128Bytes<byte>();
int totalWeight = 0;
for (int i = 0; i < pool.Defs.Length && candidates.Length < candidates.Capacity; i++)
{
if ((pool.Defs[i].ClassMask & classBit) == 0) continue;
if (pool.Defs[i].Weight == 0) continue;
candidates.Add((byte)i);
totalWeight += pool.Defs[i].Weight;
}
o0 = o1 = o2 = 0;
if (candidates.Length == 0)
return 0;
var picked = new FixedList32Bytes<byte>(); // picked catalog indices
uint salt = 0;
while (picked.Length < 3 && picked.Length < candidates.Length)
{
// Weighted draw with rejection on duplicates (bounded; falls through to a linear fill).
uint roll = RunMapMath.Hash(offerSeed, (uint)picked.Length, salt) % (uint)totalWeight;
byte drawn = candidates[candidates.Length - 1];
int acc = 0;
for (int c = 0; c < candidates.Length; c++)
{
acc += pool.Defs[candidates[c]].Weight;
if (roll < (uint)acc) { drawn = candidates[c]; break; }
}
bool dup = false;
for (int p = 0; p < picked.Length; p++)
if (picked[p] == drawn) { dup = true; break; }
if (!dup)
{
picked.Add(drawn);
salt = 0;
}
else if (++salt > 16)
{
// Rejection budget spent — take the first unpicked candidate (still deterministic).
for (int c = 0; c < candidates.Length; c++)
{
bool used = false;
for (int p = 0; p < picked.Length; p++)
if (picked[p] == candidates[c]) { used = true; break; }
if (!used) { picked.Add(candidates[c]); break; }
}
salt = 0;
}
}
o0 = picked.Length > 0 ? pool.Defs[picked[0]].Id : (byte)0;
o1 = picked.Length > 1 ? pool.Defs[picked[1]].Id : o0;
o2 = picked.Length > 2 ? pool.Defs[picked[2]].Id : o1;
return picked.Length;
}
/// <summary>Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids).</summary>
public static int FindDef(ref BoonCatalogBlob pool, byte id)
{
for (int i = 0; i < pool.Defs.Length; i++)
if (pool.Defs[i].Id == id) return i;
return -1;
}
}
/// <summary>
/// The DEFAULT v1 boon table + the blob builder the baker AND EditMode tests share (single source — the
/// authoring bakes this table verbatim when its designer-row list is empty). Append-only ids.
/// </summary>
public static class BoonCatalogData
{
/// <summary>Build the default catalog blob (caller owns/disposes the reference).</summary>
public static BlobAssetReference<BoonCatalogBlob> BuildDefault(Allocator allocator = Allocator.Persistent)
{
var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<BoonCatalogBlob>();
var defs = builder.Allocate(ref root.Defs, 12);
int i = 0;
// id, target, op, value, weight, mask(1=Warrior,2=Ranger,3=both), name, desc
defs[i++] = Make(1, StatTarget.Damage, ModOp.PercentAdd, 0.20f, 100, 3, "Honed Edge", "+20% ability damage");
defs[i++] = Make(2, StatTarget.CooldownTicks, ModOp.PercentMult, -0.15f, 100, 3, "Swift Hands", "-15% ability cooldown");
defs[i++] = Make(3, StatTarget.Range, ModOp.PercentAdd, 0.25f, 100, 2, "Long Reach", "+25% projectile range");
defs[i++] = Make(4, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.12f, 100, 3, "Fleet Foot", "+12% move speed");
defs[i++] = Make(5, StatTarget.MaxHealth, ModOp.Flat, 25f, 100, 3, "Iron Constitution", "+25 max health");
defs[i++] = Make(6, StatTarget.MeleeDamage, ModOp.PercentAdd, 0.25f, 100, 1, "Heavy Blows", "+25% melee damage");
defs[i++] = Make(7, StatTarget.MeleeRange, ModOp.PercentAdd, 0.20f, 60, 1, "Extended Haft", "+20% melee reach");
defs[i++] = Make(8, StatTarget.ProjectileSpeed, ModOp.PercentAdd, 0.25f, 60, 2, "Swift Bolts", "+25% projectile speed");
defs[i++] = Make(9, StatTarget.AutoTargetRange, ModOp.PercentAdd, 0.20f, 60, 3, "Keen Instinct", "+20% auto-target range");
defs[i++] = Make(10, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 30, 3, "Berserker's Pace", "-25% ability cooldown");
defs[i++] = Make(11, StatTarget.MaxHealth, ModOp.Flat, 60f, 30, 3, "Titan's Vigor", "+60 max health");
defs[i++] = Make(12, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 10, 3, "Executioner", "+50% ability damage");
var blob = builder.CreateBlobAssetReference<BoonCatalogBlob>(allocator);
builder.Dispose();
return blob;
}
static BoonDefBlob Make(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask,
string name, string desc)
{
return new BoonDefBlob
{
Id = id,
Target = (byte)target,
Op = (byte)op,
Value = value,
Weight = weight,
ClassMask = mask,
Name = new FixedString64Bytes(name),
Desc = new FixedString128Bytes(desc),
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 00909311d983d7a43afc195595aff217
@@ -0,0 +1,17 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client → server boon pick: <see cref="Index"/> (0/1/2) into the sender's OWN replicated <c>BoonOffer</c>
/// options. Server-validated (<c>Pending==1</c>, index in range, <c>RunInfo.Lifecycle==RoomReward</c> — the
/// D-F4 gate that stops a grace-timeout straggler pick landing after the Returning-edge strip). UNCONDITIONAL
/// wire type, blittable scalar. Declared at Step 3 (wire front-load); consumed by <c>BoonApplySystem</c> from
/// Step 10.
/// </summary>
public struct BoonPickRequest : IRpcCommand
{
/// <summary>The chosen option slot: 0, 1, or 2.</summary>
public byte Index;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cc9680d4a4c9a334396b60bb97d75b3b
@@ -39,6 +39,12 @@ namespace ProjectM.Simulation
public static byte AbilityFor(byte classId)
=> classId == RangerClass ? (byte)AbilityId.Primary : (byte)AbilityId.WarriorCone;
/// <summary>The class a Fire-slot ability id implies — the exact inverse of <see cref="AbilityFor"/> (Ranger
/// 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>
public static byte ClassForAbility(byte abilityId)
=> abilityId == (byte)AbilityId.Primary ? RangerClass : WarriorClass;
/// <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;
@@ -16,7 +16,9 @@ namespace ProjectM.Simulation
/// enum-codegen hazard that already de-Bursted ProjectileClassificationSystem.
/// </summary>
[GhostComponent(OwnerSendType = SendToOwnerType.All)]
[InternalBufferCapacity(8)]
// Capacity 32 (was 8): class seeds + permanent meta rows + equip + up to ~10 run boons must stay chunk-internal
// (a chunk-layout hint only — NOT ghost serializer metadata, so this is NOT a re-bake; overflow spills to heap).
[InternalBufferCapacity(32)]
public struct StatModifier : IBufferElementData
{
/// <summary>The <see cref="StatTarget"/> this modifier applies to (stored as a byte).</summary>
@@ -31,5 +31,16 @@ namespace ProjectM.Simulation
if (mods[j].SourceId == sourceId) { mods.RemoveAtSwapBack(j); removed++; }
return removed;
}
/// <summary>Remove every <see cref="StatModifier"/> row whose SourceId lies in [lo, hiExclusive) — the
/// run-scoped BOON strip (per-pick distinct ids share the band; one call clears the whole run's boons while
/// class/meta/equip bands survive untouched). Idempotent; RemoveAtSwapBack. Returns the count removed.</summary>
public static int RemoveBySourceIdRange(DynamicBuffer<StatModifier> mods, uint lo, uint hiExclusive)
{
int removed = 0;
for (int j = mods.Length - 1; j >= 0; j--)
if (mods[j].SourceId >= lo && mods[j].SourceId < hiExclusive) { mods.RemoveAtSwapBack(j); removed++; }
return removed;
}
}
}