Attack Boon Changes

This commit is contained in:
2026-07-13 18:30:41 -07:00
parent 972e0d5b4f
commit 24800f4bcb
34 changed files with 1306 additions and 112 deletions
@@ -4,25 +4,48 @@ 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.
/// 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
public byte Op; // ModOp as byte
public float Value;
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
{
@@ -45,8 +68,10 @@ namespace ProjectM.Simulation
}
/// <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.
/// 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
{
@@ -54,63 +79,75 @@ namespace ProjectM.Simulation
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.
/// 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, ref BoonCatalogBlob pool,
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);
// Class-legal candidate indices + the total weight.
var candidates = new FixedList128Bytes<byte>();
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);
totalWeight += pool.Defs[i].Weight;
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 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)
{
// 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 chosen = 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; }
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)
if (!dup && !famClash)
{
picked.Add(drawn);
if (fam != 0) pickedFamilies.Add(fam);
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; }
}
// Rejection budget spent — deterministic linear fill (first unused, family-distinct if possible).
AddFallback(ref picked, ref pickedFamilies, candidates, ref pool);
salt = 0;
}
}
@@ -121,6 +158,63 @@ namespace ProjectM.Simulation
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)
{
@@ -131,8 +225,9 @@ namespace ProjectM.Simulation
}
/// <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.
/// 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
{
@@ -143,25 +238,28 @@ namespace ProjectM.Simulation
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");
// ---- 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;
}
static BoonDefBlob Make(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask,
/// <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
@@ -172,6 +270,30 @@ namespace ProjectM.Simulation
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),
};