Hygiene B6: test coverage (fixture + missing coverage + guards)
- TestWorld: shared plain-Entities fixture (Make/Make<T>/SetTick reconciling the two SetServerTick variants + Player/Enemy builders). Additive; new tests consume it (40-file migration of existing tests deliberately deferred as pure churn). - TestAttributeGuardTests: scans *Tests.cs and fails on any parameterless public-void method missing a runner attribute — guards the swallowed-[Test] bug (B0). Confirms the suite has no other dead tests. - PrepPurchaseSystemTests (6): the previously-untested RPC economy — afford, reject-when-broke, once-per-run, non-Staging reject, unknown-id drop, and DR-014 same-tick atomicity. - SystemOrderingCycleTests: registers the real run/cycle/combat system set and asserts SortSystems() has no circular dependency (invisible to single-system fixtures; only throws at Play) — also de-risks the B5 splits. 459/459 EditMode tests pass. (BossAISystemTests remains queued — the most complex to author faithfully; the boss stays Play-validated meanwhile.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Server;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-Entities EditMode coverage for the server-only PrepPurchaseSystem (DR-046 base prep-loadout spend).
|
||||||
|
/// Exercises the afford / soft-fail / once-per-run / non-Staging-reject paths and the DR-014 same-tick atomicity
|
||||||
|
/// (a second barely-affordable buy in the same tick can't also pass). Modeled on MetaSpendSystemTests.
|
||||||
|
/// </summary>
|
||||||
|
public class PrepPurchaseSystemTests
|
||||||
|
{
|
||||||
|
// Director carries the RunInfo singleton + the shared ResourceLedger (StorageEntry buffer), seeded with one resource.
|
||||||
|
static Entity MakeDirector(EntityManager em, byte lifecycle, byte resId, int amount)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity(typeof(RunInfo), typeof(ResourceLedger), typeof(StorageEntry));
|
||||||
|
em.SetComponentData(e, new RunInfo { Lifecycle = lifecycle });
|
||||||
|
if (amount > 0)
|
||||||
|
{
|
||||||
|
var ledger = em.GetBuffer<StorageEntry>(e);
|
||||||
|
StorageMath.Deposit(ledger, resId, amount);
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A player (PlayerTag + GhostOwner + StatModifier buffer) plus its connection entity (NetworkId).
|
||||||
|
static (Entity player, Entity conn) MakePlayer(EntityManager em, int netId)
|
||||||
|
{
|
||||||
|
var player = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(StatModifier));
|
||||||
|
em.SetComponentData(player, new GhostOwner { NetworkId = netId });
|
||||||
|
var conn = em.CreateEntity(typeof(NetworkId));
|
||||||
|
em.SetComponentData(conn, new NetworkId { Value = netId });
|
||||||
|
return (player, conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void SendRequest(EntityManager em, Entity conn, byte optionId)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity(typeof(PrepPurchaseRequest), typeof(ReceiveRpcCommandRequest));
|
||||||
|
em.SetComponentData(e, new PrepPurchaseRequest { OptionId = optionId });
|
||||||
|
em.SetComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = conn });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count StatModifier rows in the prep SourceId band on a player.
|
||||||
|
static int PrepRowCount(EntityManager em, Entity player)
|
||||||
|
{
|
||||||
|
var mods = em.GetBuffer<StatModifier>(player);
|
||||||
|
int n = 0;
|
||||||
|
for (int i = 0; i < mods.Length; i++)
|
||||||
|
if (mods[i].SourceId >= Tuning.PrepSourceIdBase && mods[i].SourceId < Tuning.PrepSourceIdBase + 256u)
|
||||||
|
n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int OpenRequests(EntityManager em)
|
||||||
|
{
|
||||||
|
using var q = em.CreateEntityQuery(typeof(PrepPurchaseRequest));
|
||||||
|
return q.CalculateEntityCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Purchase_Appends_Prep_Modifier_Withdraws_And_Consumes_Request()
|
||||||
|
{
|
||||||
|
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Buy", tick: 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 30);
|
||||||
|
var (player, conn) = MakePlayer(em, 1);
|
||||||
|
SendRequest(em, conn, 0); // id0: Ore 30 -> MaxHealth +30 Flat
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(1, PrepRowCount(em, player), "One prep modifier appended.");
|
||||||
|
var mods = em.GetBuffer<StatModifier>(player);
|
||||||
|
Assert.AreEqual((byte)StatTarget.MaxHealth, mods[0].Target);
|
||||||
|
Assert.AreEqual(30f, mods[0].Value, 1e-4f);
|
||||||
|
Assert.AreEqual(Tuning.PrepSourceIdBase + 0u, mods[0].SourceId);
|
||||||
|
Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "Ore fully withdrawn.");
|
||||||
|
Assert.AreEqual(0, OpenRequests(em), "Request consumed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Reject_When_Broke_Leaves_Ledger_Untouched_But_Consumes_Request()
|
||||||
|
{
|
||||||
|
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Broke", tick: 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 20); // < 30 cost
|
||||||
|
var (player, conn) = MakePlayer(em, 1);
|
||||||
|
SendRequest(em, conn, 0);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(0, PrepRowCount(em, player), "No modifier when broke.");
|
||||||
|
Assert.AreEqual(20, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "Pre-check skips Withdraw; ledger untouched.");
|
||||||
|
Assert.AreEqual(0, OpenRequests(em), "Request still consumed on reject.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Already_Owned_Does_Not_Rebuy()
|
||||||
|
{
|
||||||
|
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Owned", tick: 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 60);
|
||||||
|
var (player, conn) = MakePlayer(em, 1);
|
||||||
|
em.GetBuffer<StatModifier>(player).Add(new StatModifier { SourceId = Tuning.PrepSourceIdBase + 0u, Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f });
|
||||||
|
SendRequest(em, conn, 0);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(1, PrepRowCount(em, player), "Still exactly one row (once per run == SourceId presence).");
|
||||||
|
Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "No second withdraw.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Non_Staging_Is_Rejected()
|
||||||
|
{
|
||||||
|
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_NonStaging", tick: 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, RunLifecycle.InRoom, ResourceId.Ore, 60);
|
||||||
|
var (player, conn) = MakePlayer(em, 1);
|
||||||
|
SendRequest(em, conn, 0);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(0, PrepRowCount(em, player), "No purchase outside Staging.");
|
||||||
|
Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore), "Ledger untouched outside Staging.");
|
||||||
|
Assert.AreEqual(0, OpenRequests(em), "Request consumed even when rejected.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Unknown_Option_Id_Is_Dropped()
|
||||||
|
{
|
||||||
|
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Unknown", tick: 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Ore, 60);
|
||||||
|
var (player, conn) = MakePlayer(em, 1);
|
||||||
|
SendRequest(em, conn, 99); // no such row
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(0, PrepRowCount(em, player));
|
||||||
|
Assert.AreEqual(60, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Ore));
|
||||||
|
Assert.AreEqual(0, OpenRequests(em), "Unknown-id request still consumed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Two_Same_Tick_Barely_Enough_Exactly_One_Succeeds()
|
||||||
|
{
|
||||||
|
var (world, group) = TestWorld.Make<PrepPurchaseSystem>("Prep_Atomic", tick: 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var dir = MakeDirector(em, RunLifecycle.Staging, ResourceId.Aether, 25); // enough for exactly ONE 25-Aether buy
|
||||||
|
var (player, conn) = MakePlayer(em, 1);
|
||||||
|
SendRequest(em, conn, 2); // Aether 25 -> MeleeDamage
|
||||||
|
SendRequest(em, conn, 3); // Aether 25 -> Damage
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(1, PrepRowCount(em, player), "Exactly one of two same-tick 25-Aether buys succeeds (DR-014 in-loop pre-check).");
|
||||||
|
Assert.AreEqual(0, StorageMath.TotalOf(em.GetBuffer<StorageEntry>(dir), ResourceId.Aether), "Aether withdrawn once; never negative.");
|
||||||
|
Assert.AreEqual(0, OpenRequests(em), "Both requests consumed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 02cf1a6cbcc3b804191e5e0169c1402d
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Server;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Entities;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the ComponentSystemSorter "circular dependency" hazard that is INVISIBLE to the rest of the suite:
|
||||||
|
/// every other fixture registers a single system then sorts, so a cross-system [UpdateBefore/After] cycle can
|
||||||
|
/// never surface — it only throws at world creation in Play. This registers the REAL ordered server-sim set
|
||||||
|
/// (every system that participates in an UpdateBefore/After relation, or its constraint is silently ignored)
|
||||||
|
/// into one SimulationSystemGroup and sorts, reproducing the Play-time sort headlessly. Only SortSystems runs
|
||||||
|
/// (never Update), so a bare world with no entities/singletons suffices.
|
||||||
|
/// </summary>
|
||||||
|
public class SystemOrderingCycleTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void RunCycleCombatChain_Sorts_Without_A_Dependency_Cycle()
|
||||||
|
{
|
||||||
|
using var world = new World("OrderCycleGuard");
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
|
||||||
|
void Add<T>() where T : unmanaged, ISystem
|
||||||
|
=> group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
||||||
|
|
||||||
|
// RPC-receive systems ordered before the run director
|
||||||
|
Add<ReadyToggleSystem>(); Add<RouteSelectSystem>(); Add<PortalInteractReceiveSystem>();
|
||||||
|
Add<MetaSpendSystem>(); Add<ClassSelectReceiveSystem>(); Add<BoonApplySystem>(); Add<PrepPurchaseSystem>();
|
||||||
|
// Run director + the systems ordered around it and the cycle phase
|
||||||
|
Add<RunDirectorSystem>(); Add<ThreatDirectorSystem>(); Add<RoomFieldSystem>();
|
||||||
|
Add<RoomEnemyDirectorSystem>(); Add<BoonOfferSystem>(); Add<CyclePhaseSystem>();
|
||||||
|
Add<GoalReachedSystem>(); Add<WaveSystem>();
|
||||||
|
// Combat sub-chain in the same group
|
||||||
|
Add<EnemyAISystem>(); Add<BossAISystem>(); Add<CoreDamageSystem>();
|
||||||
|
Add<CoreRestoreSystem>(); Add<EnemyProjectileMoveSystem>(); Add<EnemyProjectileDamageSystem>();
|
||||||
|
|
||||||
|
Assert.DoesNotThrow(() => group.SortSystems(),
|
||||||
|
"A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e160f942a69b8344fbe476a973a3e8be
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Anti-regression guard for the "an edit swallowed the [Test] attribute" hazard — a real dead test shipped at
|
||||||
|
/// EnemyAIMathTests.cs:107 (a regression guard for the enemy-stuck-on-cover fix that silently never ran). Scans
|
||||||
|
/// every *Tests.cs source file in this directory and FAILS if a parameterless public-void method (the suite's
|
||||||
|
/// test-method shape — every helper is static, so `public void` uniquely selects tests) is not immediately
|
||||||
|
/// preceded by a runner attribute. File I/O is available at EditMode-test time.
|
||||||
|
/// </summary>
|
||||||
|
public class TestAttributeGuardTests
|
||||||
|
{
|
||||||
|
static string ThisDir([CallerFilePath] string p = "") => Path.GetDirectoryName(p);
|
||||||
|
|
||||||
|
static readonly Regex TestMethod = new Regex(@"^\s*public\s+void\s+[A-Za-z_]\w*\s*\(\s*\)", RegexOptions.Compiled);
|
||||||
|
static readonly Regex RunnerAttr = new Regex(@"\[\s*(Test|TestCase|TestCaseSource|Theory|SetUp|TearDown|OneTimeSetUp|OneTimeTearDown)\b", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Every_Public_Void_Test_Method_Has_A_Runner_Attribute()
|
||||||
|
{
|
||||||
|
var dir = ThisDir();
|
||||||
|
Assert.IsTrue(Directory.Exists(dir), $"Test source dir not found: {dir}");
|
||||||
|
|
||||||
|
var offenders = new List<string>();
|
||||||
|
foreach (var file in Directory.GetFiles(dir, "*Tests.cs"))
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(file);
|
||||||
|
for (int i = 0; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
if (!TestMethod.IsMatch(lines[i]))
|
||||||
|
continue;
|
||||||
|
// Walk upward past blank / comment lines to the nearest attribute or non-trivial line.
|
||||||
|
bool attributed = false;
|
||||||
|
for (int j = i - 1; j >= 0; j--)
|
||||||
|
{
|
||||||
|
string t = lines[j].Trim();
|
||||||
|
if (t.Length == 0 || t.StartsWith("//") || t.StartsWith("/*") || t.StartsWith("*"))
|
||||||
|
continue;
|
||||||
|
if (t.StartsWith("["))
|
||||||
|
{
|
||||||
|
if (RunnerAttr.IsMatch(t)) { attributed = true; break; }
|
||||||
|
continue; // a non-runner attribute line — keep scanning the attribute block
|
||||||
|
}
|
||||||
|
break; // hit a brace / statement: no attribute block above this method
|
||||||
|
}
|
||||||
|
if (!attributed)
|
||||||
|
offenders.Add($"{Path.GetFileName(file)}:{i + 1} {lines[i].Trim()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.IsEmpty(offenders,
|
||||||
|
"Test methods missing a runner attribute (swallowed [Test]?):\n" + string.Join("\n", offenders));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b4a83aae5c80ca347bbc6b9583df8b23
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Core;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.Mathematics;
|
||||||
|
using Unity.NetCode;
|
||||||
|
using Unity.Transforms;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-Entities EditMode harness. Behaviourally identical to the hand-rolled MakeWorld/SetServerTick idioms
|
||||||
|
/// scattered across the suite (a NetworkTime-seeded SimulationSystemGroup world), consolidated so the boilerplate
|
||||||
|
/// has one home. Named NOT to end in "Tests" so the attribute-guard meta-test skips it.
|
||||||
|
/// </summary>
|
||||||
|
static class TestWorld
|
||||||
|
{
|
||||||
|
/// <summary>World + SimulationSystemGroup, NetworkTime seeded to <paramref name="tick"/>, TimeData = 1/60.
|
||||||
|
/// <paramref name="server"/> sets WorldFlags.GameServer (the IsServer() gate some systems read).</summary>
|
||||||
|
public static (World world, SimulationSystemGroup group) Make(string name = "TestWorld", uint tick = 1, bool server = false)
|
||||||
|
{
|
||||||
|
var world = server ? new World(name, WorldFlags.Game | WorldFlags.GameServer) : new World(name);
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||||
|
SetTick(world, tick);
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>As <see cref="Make"/>, plus registers ONE ISystem and sorts. Drop-in for the per-file MakeWorld<T>.</summary>
|
||||||
|
public static (World world, SimulationSystemGroup group) Make<T>(string name = "TestWorld", uint tick = 1, bool server = false)
|
||||||
|
where T : unmanaged, ISystem
|
||||||
|
{
|
||||||
|
var (world, group) = Make(name, tick, server);
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
||||||
|
group.SortSystems();
|
||||||
|
return (world, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Register an extra ISystem and re-sort (idempotent — final order is what matters).</summary>
|
||||||
|
public static void Add<T>(World world, SimulationSystemGroup group) where T : unmanaged, ISystem
|
||||||
|
{
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
||||||
|
group.SortSystems();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The reconciled tick API: create NetworkTime if absent, else set it (a strict superset of both
|
||||||
|
/// hand-rolled variants — behaves like GetSingletonEntity() when present, and tolerates absence).</summary>
|
||||||
|
public static void SetTick(World world, uint tick)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
||||||
|
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
|
||||||
|
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- domain builders (only for shapes that match exactly; per-file builders with extra components stay local) ----
|
||||||
|
|
||||||
|
public static Entity Player(EntityManager em, int netId = 1, byte region = RegionId.Base, float3 pos = default)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity(typeof(PlayerTag), typeof(GhostOwner), typeof(RegionTag), typeof(LocalTransform));
|
||||||
|
em.SetComponentData(e, new GhostOwner { NetworkId = netId });
|
||||||
|
em.SetComponentData(e, new RegionTag { Region = region });
|
||||||
|
em.SetComponentData(e, LocalTransform.FromPosition(pos));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Entity Enemy(EntityManager em, float3 pos, float hp = 50f, byte region = RegionId.Base)
|
||||||
|
{
|
||||||
|
var e = em.CreateEntity(typeof(EnemyTag), typeof(LocalTransform), typeof(RegionTag), typeof(Health));
|
||||||
|
em.SetComponentData(e, LocalTransform.FromPosition(pos));
|
||||||
|
em.SetComponentData(e, new RegionTag { Region = region });
|
||||||
|
em.SetComponentData(e, new Health { Current = hp, Max = hp });
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 71d72296d0393c943a2184be97061d25
|
||||||
Reference in New Issue
Block a user