Files
Project-M/Assets/_Project/Scripts/Simulation/Combat/BoonCatalog.cs
T
2026-07-13 18:30:41 -07:00

303 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// One authored boon in the catalog blob. A boon is EITHER a flat-stat modifier (<see cref="Kind"/>==0 —
/// <see cref="Target"/>/<see cref="Op"/>/<see cref="Value"/> map 1:1 onto a <see cref="StatModifier"/> row, the
/// original path) OR a Phase-1.7 MECHANIC-CHANGER (<see cref="Kind"/>==1 — <see cref="EffectKind"/> selects the
/// hook; for the stacking kinds Pierce/Fork/Chain <see cref="Value"/> is the per-pick count delta, else it's a
/// flag). Bytes, never enums, on the baked path. <see cref="Id"/> is the stable key the replicated
/// <c>BoonOffer</c> options + pick RPC carry. <see cref="Weight"/> is the rarity draw weight (common 100 /
/// uncommon 60 / rare 30 / epic 10). <see cref="ClassMask"/>: bit0 = Warrior (classId 0), bit1 = Ranger
/// (classId 1), 3 = both. <see cref="Family"/> tags synergy/dedup — no two same-family options in one deal.
/// </summary>
public struct BoonDefBlob
{
public byte Id;
public byte Target; // StatTarget as byte (Kind==0)
public byte Op; // ModOp as byte (Kind==0)
public float Value; // Kind==0: modifier magnitude; Kind==1 stacking: per-pick count delta
public byte Weight;
public byte ClassMask;
public byte Kind; // 0 = stat, 1 = mechanic-changer (Phase 1.7)
public byte EffectKind; // BoonEffectKind byte (Kind==1)
public byte Family; // BoonFamily byte — dedup/dominated/bias tag
public FixedString64Bytes Name;
public FixedString128Bytes Desc;
}
/// <summary>
/// Synergy/dedup tags for <see cref="BoonDefBlob.Family"/>. Bytes (Burst-safe). No two options of the same
/// family are offered in one deal (dominated-offer protection — kills the "-15% vs -25% cooldown" case); owning
/// a mechanic family biases future offers toward it (light build-bias).
/// </summary>
public static class BoonFamily
{
public const byte None = 0;
public const byte Projectile = 1;
public const byte Melee = 2;
public const byte Mobility = 3;
public const byte OnKill = 4;
public const byte StatDamage = 5;
public const byte StatHealth = 6;
public const byte StatSpeed = 7;
public const byte StatCooldown = 8;
}
/// <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 (<c>RunMapMath.Hash</c> chain, no RNG state), so
/// an offer is a reproducible function of (runSeed, room, player, ownedState-at-draw). The owned-state input is
/// safe because <c>BoonOfferSystem</c> draws each player exactly ONCE per RoomEpoch (the OfferedRoomEpoch latch)
/// — the client never re-runs it. 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 up to 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per
/// (<paramref name="offerSeed"/>, <paramref name="owned"/>). Phase 1.7: a non-stacking FLAG effect the player
/// already owns is excluded (dedup); no two options share a <see cref="BoonFamily"/> in one deal
/// (dominated-offer protection); a candidate whose family matches an owned effect's family draws at ×1.5
/// weight (light build-bias). Falls back deterministically when draws collide. Returns the number of
/// distinct ids (tail repeats the last when the legal pool has fewer than 3).
/// </summary>
public static int PickBoons(uint offerSeed, byte classId, in BoonEffects owned, ref BoonCatalogBlob pool,
out byte o0, out byte o1, out byte o2)
{
byte classBit = MaskFor(classId);
int ownedFamilies = OwnedFamilyMask(owned);
var candidates = new FixedList128Bytes<byte>(); // catalog indices
var weights = new FixedList128Bytes<byte>(); // biased draw weight per candidate (parallel)
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;
if (IsOwnedFlag(pool.Defs[i], owned)) continue; // non-stacking flag already held → dedup
int w = pool.Defs[i].Weight;
byte fam = pool.Defs[i].Family;
if (fam != 0 && (ownedFamilies & (1 << fam)) != 0)
w += w / 2; // ×1.5 build-bias (integer)
if (w > 255) w = 255;
candidates.Add((byte)i);
weights.Add((byte)w);
totalWeight += w;
}
o0 = o1 = o2 = 0;
if (candidates.Length == 0)
return 0;
var picked = new FixedList32Bytes<byte>(); // picked catalog indices
var pickedFamilies = new FixedList32Bytes<byte>(); // families used this deal (fam != 0)
uint salt = 0;
while (picked.Length < 3 && picked.Length < candidates.Length)
{
uint roll = RunMapMath.Hash(offerSeed, (uint)picked.Length, salt) % (uint)totalWeight;
int chosen = candidates.Length - 1;
int acc = 0;
for (int c = 0; c < candidates.Length; c++)
{
acc += weights[c];
if (roll < (uint)acc) { chosen = c; break; }
}
byte drawn = candidates[chosen];
byte fam = pool.Defs[drawn].Family;
bool dup = false;
for (int p = 0; p < picked.Length; p++)
if (picked[p] == drawn) { dup = true; break; }
bool famClash = false;
if (!dup && fam != 0)
for (int p = 0; p < pickedFamilies.Length; p++)
if (pickedFamilies[p] == fam) { famClash = true; break; }
if (!dup && !famClash)
{
picked.Add(drawn);
if (fam != 0) pickedFamilies.Add(fam);
salt = 0;
}
else if (++salt > 16)
{
// Rejection budget spent — deterministic linear fill (first unused, family-distinct if possible).
AddFallback(ref picked, ref pickedFamilies, candidates, ref pool);
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>Deterministic tail-fill when the weighted draw keeps colliding: take the first unused candidate
/// that is family-distinct from the deal; if none, the first unused (family clash tolerated as last resort so
/// the deal never wedges below 3 while candidates remain).</summary>
static void AddFallback(ref FixedList32Bytes<byte> picked, ref FixedList32Bytes<byte> pickedFamilies,
in FixedList128Bytes<byte> candidates, ref BoonCatalogBlob pool)
{
int firstUnused = -1;
for (int c = 0; c < candidates.Length; c++)
{
byte cand = candidates[c];
bool used = false;
for (int p = 0; p < picked.Length; p++)
if (picked[p] == cand) { used = true; break; }
if (used) continue;
if (firstUnused < 0) firstUnused = cand;
byte cfam = pool.Defs[cand].Family;
bool clash = false;
if (cfam != 0)
for (int p = 0; p < pickedFamilies.Length; p++)
if (pickedFamilies[p] == cfam) { clash = true; break; }
if (clash) continue;
picked.Add(cand);
if (cfam != 0) pickedFamilies.Add(cfam);
return;
}
if (firstUnused >= 0)
picked.Add((byte)firstUnused);
}
/// <summary>True when a candidate is a non-stacking FLAG effect the player already owns (dedup). Pierce/Fork/
/// Chain STACK, so they're never excluded. Byte switch — Burst-safe.</summary>
static bool IsOwnedFlag(in BoonDefBlob d, in BoonEffects owned)
{
if (d.Kind != 1) return false;
switch (d.EffectKind)
{
case BoonEffectKind.DashTrail: return (owned.Flags & BoonFlag.DashTrail) != 0;
case BoonEffectKind.FinisherDetonate: return (owned.Flags & BoonFlag.FinisherDetonate) != 0;
case BoonEffectKind.KnockToPull: return (owned.Flags & BoonFlag.KnockToPull) != 0;
case BoonEffectKind.Siphon: return (owned.Flags & BoonFlag.Siphon) != 0;
case BoonEffectKind.Frenzy: return (owned.Flags & BoonFlag.Frenzy) != 0;
default: return false;
}
}
/// <summary>Bitmask (indexed by <see cref="BoonFamily"/> value) of the MECHANIC families the player owns —
/// drives the ×1.5 build-bias. Stat families are never marked (build-bias is mechanic-synergy only).</summary>
static int OwnedFamilyMask(in BoonEffects owned)
{
int m = 0;
if (owned.Pierce != 0 || owned.Fork != 0 || owned.Chain != 0) m |= 1 << BoonFamily.Projectile;
if ((owned.Flags & (BoonFlag.FinisherDetonate | BoonFlag.KnockToPull)) != 0) m |= 1 << BoonFamily.Melee;
if ((owned.Flags & BoonFlag.DashTrail) != 0) m |= 1 << BoonFamily.Mobility;
if ((owned.Flags & (BoonFlag.Siphon | BoonFlag.Frenzy)) != 0) m |= 1 << BoonFamily.OnKill;
return m;
}
/// <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 Phase-1.7 boon table + the blob builder the baker AND EditMode tests share — 8 mechanic-changers
/// (<see cref="BoonDefBlob.Kind"/>==1) + 4 strong flat-stat boons (Kind==0). Ids are within-session stable (both
/// worlds bake the same code; boons never persist across saves — stripped on the Returning edge).
/// </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;
// ---- 8 mechanic-changers (Kind=1). mask: 1=Warrior, 2=Ranger, 3=both. Projectile boons are Ranger-only
// (the Warrior's Fire is a cone, not a projectile). ----
defs[i++] = Effect(1, BoonEffectKind.Pierce, 1f, 100, 2, BoonFamily.Projectile, "Piercing Shots", "Your shots pierce +1 enemy");
defs[i++] = Effect(2, BoonEffectKind.Fork, 1f, 60, 2, BoonFamily.Projectile, "Split Shot", "Fire +1 extra shot in a spread");
defs[i++] = Effect(3, BoonEffectKind.Chain, 1f, 60, 2, BoonFamily.Projectile, "Ricochet", "Your shots chain to +1 nearby enemy");
defs[i++] = Effect(4, BoonEffectKind.FinisherDetonate, 0f, 60, 1, BoonFamily.Melee, "Detonating Finisher", "Your combo finisher blasts an AoE");
defs[i++] = Effect(5, BoonEffectKind.DashTrail, 0f, 100, 3, BoonFamily.Mobility, "Blade Dash", "Dashing damages enemies you pass through");
defs[i++] = Effect(6, BoonEffectKind.KnockToPull, 0f, 30, 3, BoonFamily.Melee, "Gravity Pull", "Your knockback drags enemies IN");
defs[i++] = Effect(7, BoonEffectKind.Siphon, 0f, 60, 3, BoonFamily.OnKill, "Siphon", "Killing an enemy heals you");
defs[i++] = Effect(8, BoonEffectKind.Frenzy, 0f, 30, 3, BoonFamily.OnKill, "Frenzy", "A kill briefly speeds your abilities");
// ---- 4 strong flat-stat boons (Kind=0) ----
defs[i++] = Stat(9, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 30, 3, BoonFamily.StatDamage, "Executioner", "+50% ability damage");
defs[i++] = Stat(10, StatTarget.MaxHealth, ModOp.Flat, 60f, 100, 3, BoonFamily.StatHealth, "Titan's Vigor", "+60 max health");
defs[i++] = Stat(11, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.18f, 100, 3, BoonFamily.StatSpeed, "Fleet Foot", "+18% move speed");
defs[i++] = Stat(12, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 60, 3, BoonFamily.StatCooldown, "Berserker's Pace", "-25% ability cooldown");
var blob = builder.CreateBlobAssetReference<BoonCatalogBlob>(allocator);
builder.Dispose();
return blob;
}
/// <summary>A flat-stat boon row (Kind=0 — appends a <see cref="StatModifier"/>).</summary>
static BoonDefBlob Stat(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask, byte family,
string name, string desc)
{
return new BoonDefBlob
{
Id = id,
Target = (byte)target,
Op = (byte)op,
Value = value,
Weight = weight,
ClassMask = mask,
Kind = 0,
EffectKind = BoonEffectKind.None,
Family = family,
Name = new FixedString64Bytes(name),
Desc = new FixedString128Bytes(desc),
};
}
/// <summary>A mechanic-changer boon row (Kind=1 — mutates <see cref="BoonEffects"/>). <paramref name="value"/>
/// is the stacking count delta for Pierce/Fork/Chain (usually 1), ignored for flag effects.</summary>
static BoonDefBlob Effect(byte id, byte effectKind, float value, byte weight, byte mask, byte family,
string name, string desc)
{
return new BoonDefBlob
{
Id = id,
Target = 0,
Op = 0,
Value = value,
Weight = weight,
ClassMask = mask,
Kind = 1,
EffectKind = effectKind,
Family = family,
Name = new FixedString64Bytes(name),
Desc = new FixedString128Bytes(desc),
};
}
}
}