using Unity.Collections; using Unity.Entities; namespace ProjectM.Simulation { /// /// One authored boon in the catalog blob. A boon is EITHER a flat-stat modifier (==0 — /// // map 1:1 onto a row, the /// original path) OR a Phase-1.7 MECHANIC-CHANGER (==1 — selects the /// hook; for the stacking kinds Pierce/Fork/Chain is the per-pick count delta, else it's a /// flag). Bytes, never enums, on the baked path. is the stable key the replicated /// BoonOffer options + pick RPC carry. is the rarity draw weight (common 100 / /// uncommon 60 / rare 30 / epic 10). : bit0 = Warrior (classId 0), bit1 = Ranger /// (classId 1), 3 = both. tags synergy/dedup — no two same-family options in one deal. /// 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; } /// /// Synergy/dedup tags for . 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). /// 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; } /// The baked boon pool (config blob, both worlds, NOT replicated — the AbilityDatabase pattern). public struct BoonCatalogBlob { public BlobArray Defs; } /// Singleton component carrying the baked catalog (place ONE BoonCatalogAuthoring in the subscene). public struct BoonCatalog : IComponentData { public BlobAssetReference Value; } /// /// Server-only bookkeeping for BoonOfferSystem, attached at runtime beside the catalog singleton (the /// RoomFieldState idiom): the RoomEpoch offers were last drawn for — int equality, one offer set per room. /// public struct BoonOfferState : IComponentData { public int OfferedRoomEpoch; } /// /// Pure, deterministic boon selection math — integer-hash only (RunMapMath.Hash 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 BoonOfferSystem draws each player exactly ONCE per RoomEpoch (the OfferedRoomEpoch latch) /// — the client never re-runs it. EditMode-tested. /// public static class BoonMath { /// Class-mask bit for a wire class id (0 = Warrior, 1 = Ranger). public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1)); /// /// Draw up to 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per /// (, ). Phase 1.7: a non-stacking FLAG effect the player /// already owns is excluded (dedup); no two options share a 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). /// 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(); // catalog indices var weights = new FixedList128Bytes(); // 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(); // picked catalog indices var pickedFamilies = new FixedList32Bytes(); // 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; } /// 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). static void AddFallback(ref FixedList32Bytes picked, ref FixedList32Bytes pickedFamilies, in FixedList128Bytes 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); } /// 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. 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; } } /// Bitmask (indexed by 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). 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; } /// Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids). 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; } } /// /// The DEFAULT Phase-1.7 boon table + the blob builder the baker AND EditMode tests share — 8 mechanic-changers /// (==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). /// public static class BoonCatalogData { /// Build the default catalog blob (caller owns/disposes the reference). public static BlobAssetReference BuildDefault(Allocator allocator = Allocator.Persistent) { var builder = new BlobBuilder(Allocator.Temp); ref var root = ref builder.ConstructRoot(); 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(allocator); builder.Dispose(); return blob; } /// A flat-stat boon row (Kind=0 — appends a ). 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), }; } /// A mechanic-changer boon row (Kind=1 — mutates ). /// is the stacking count delta for Pierce/Fork/Chain (usually 1), ignored for flag effects. 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), }; } } }