303 lines
16 KiB
C#
303 lines
16 KiB
C#
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),
|
||
};
|
||
}
|
||
}
|
||
}
|