diff --git a/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs b/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs index af4f90197..3f9e15a5c 100644 --- a/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Building/StructureCatalogAuthoring.cs @@ -68,7 +68,7 @@ namespace ProjectM.Authoring { Type = StructureType.Wall, Prefab = GetEntity(authoring.WallPrefab, TransformUsageFlags.Dynamic), - CostResourceId = ResourceId.Ore, + CostResourceId = ResourceId.Biomass, // DR-042 C6b: walls cost Biomass (the dead currency's only sink) CostAmount = authoring.WallCostOre, }); } diff --git a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs index 61e61ed0a..bd06f5b8a 100644 --- a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs @@ -73,6 +73,11 @@ namespace ProjectM.Authoring // CycleDirectorSpawnSystem overrides it with a persisted Victory/Loss on Continue. AddComponent(entity, new RunOutcome { Value = RunOutcomeId.InProgress }); + // DR-042 C7b: replicated expedition-objective summary (the HUD 'enemies remaining / cleared' readout). + // Born Idle; ZoneEnemyDirectorSystem is the sole writer. New [GhostField] component -> re-hashes the + // runtime-spawned director ghost (server + client bake the same prefab -> hash matches), like CoreIntegrity. + AddComponent(entity, new ExpeditionObjective { State = ExpeditionObjectiveState.Idle, Remaining = 0 }); + AddComponent(entity, new ThreatConfig { diff --git a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs index 1d88a73d1..9d91c5c54 100644 --- a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs +++ b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs @@ -15,7 +15,7 @@ namespace ProjectM.Client /// rotates a conveyor's facing. Fire is suppressed while build mode is active (PlayerInputGatherSystem reads /// ), so the place-click never also fires. Build mode is suspended while /// the pause overlay is open, and the frame a palette button changes the selection never also places. - /// (2) keyboard hotkeys (fallback, suppressed in palette mode): B/V/N/H/F/C place at the local player's cell. + /// (2) keyboard hotkeys (fallback, suppressed in palette mode): B/V/F place at the local player's cell. /// Editor-only statics (PlaceStructure / PlaceHarvester / ...) drive the same RPC path from execute_code for /// headless validation. Managed SystemBase; UnityEngine.InputSystem types are fully qualified to avoid the /// ProjectM.Simulation.PlayerInput name collision. The server re-validates legality + cost authoritatively. @@ -30,10 +30,9 @@ namespace ProjectM.Client { (UnityEngine.InputSystem.Key.B, StructureType.Turret), (UnityEngine.InputSystem.Key.V, StructureType.Wall), - (UnityEngine.InputSystem.Key.N, StructureType.Pylon), - (UnityEngine.InputSystem.Key.H, StructureType.Harvester), (UnityEngine.InputSystem.Key.F, StructureType.Fabricator), - (UnityEngine.InputSystem.Key.C, StructureType.Conveyor), + // DR-042 C6d: Pylon/Harvester/Conveyor are dead (unwired automation) — dropped from the hotkey fallback + // to match the hidden build palette; their PlaceStructure execute_code statics remain for dev. }; UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily) @@ -41,11 +40,16 @@ namespace ProjectM.Client Material _ghostMat; byte _lastSelected; // skip placing on the frame a palette click changes the selection + // DR-042 C6a: the ability-upgrade send is RUNTIME (the HUD Aether button calls UpgradeAbility); only the + // execute_code PLACE statics stay editor-gated. Mirrors EquipSendSystem's unconditional queue + drain. + static int s_PendingUpgrades = 0; + /// Runtime hook (HUD Aether button) + execute_code: queue an ability-damage upgrade. + public static void UpgradeAbility() => s_PendingUpgrades++; + #if UNITY_EDITOR struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; } static readonly System.Collections.Generic.Queue s_PendingBuild = new System.Collections.Generic.Queue(); - static int s_PendingUpgrades = 0; /// EDITOR / execute_code hook: queue a structure placement at a specific cell. public static void PlaceStructure(byte type, int cellX, int cellZ, byte direction = 0) => @@ -69,8 +73,6 @@ namespace ProjectM.Client /// EDITOR / execute_code hook: queue a conveyor placement facing direction (0=+X,1=-X,2=+Z,3=-Z). public static void PlaceConveyor(int cellX, int cellZ, byte direction) => PlaceStructure(StructureType.Conveyor, cellX, cellZ, direction); - /// EDITOR / execute_code hook: queue an ability-damage upgrade. - public static void UpgradeAbility() => s_PendingUpgrades++; #endif protected override void OnCreate() @@ -115,17 +117,19 @@ namespace ProjectM.Client SendUpgrade(connection); } + // DR-042 C6a: the ability-upgrade drain runs at RUNTIME (the HUD Aether button enqueues via UpgradeAbility); + // only the execute_code PLACE drain stays editor-gated. + while (s_PendingUpgrades > 0) + { + s_PendingUpgrades--; + SendUpgrade(connection); + } #if UNITY_EDITOR while (s_PendingBuild.Count > 0) { var b = s_PendingBuild.Dequeue(); SendBuild(connection, b.Type, b.CellX, b.CellZ, b.Direction); } - while (s_PendingUpgrades > 0) - { - s_PendingUpgrades--; - SendUpgrade(connection); - } #endif } diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 82c3ec863..1824cd303 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -67,6 +67,8 @@ namespace ProjectM.Client // END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side). VisualElement _runBanner; Label _runBannerText, _runBannerSub; + // DR-042 C6a: the Aether ability-upgrade button (was U-key only) + its live affordability tint. + Button _upgradeBtn; readonly List _pips = new(); @@ -181,6 +183,28 @@ namespace ProjectM.Client _locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : finalSiege ? new Color(1f, 0.3f, 0.25f) : siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f); + // DR-042 C7 (gate prompt) + C7b (objective readout): the expedition is the win-driver, so signpost it. + // Reads the REPLICATED ExpeditionObjective summary (cross-region safe). Lower priority than the siege / + // cold-turret / overrun overrides below, which still win. + if (SystemAPI.TryGetSingleton(out var obj)) + { + if (onExpedition) + { + if (obj.State == ExpeditionObjectiveState.Cleared) + { _locationText.text = "ZONE CLEARED - return to base to claim"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); } + else if (obj.State == ExpeditionObjectiveState.Active) + { _locationText.text = "CLEAR THE ZONE - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); } + } + else if (!siege) + { + if (obj.State == ExpeditionObjectiveState.Cleared) + { _locationText.text = "EXPEDITION CLEARED - return to claim your reward"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); } + else if (obj.State == ExpeditionObjectiveState.Active) + { _locationText.text = "EXPEDITION IN PROGRESS - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); } + else + { _locationText.text = "GO TO THE EXPEDITION GATE - clear a sortie to advance the Engine"; _locationText.style.color = new Color(0.55f, 0.85f, 1f); } + } + } // ---- Goal (hex-pip meter, or a continuous bar for large targets) ---- if (SystemAPI.TryGetSingleton(out var goal)) @@ -230,6 +254,9 @@ namespace ProjectM.Client _oreNum.text = ore.ToString(); _bioNum.text = bio.ToString(); _chargeNum.text = charge.ToString(); + // DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const). + if (_upgradeBtn != null) + _upgradeBtn.style.opacity = aether >= Tuning.AbilityUpgradeCostAmount ? 1f : 0.5f; // EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one // broken turret): a dry base during a siege tells the player to build a Fabricator. if (siege && charge == 0 && !onExpedition) @@ -446,13 +473,19 @@ namespace ProjectM.Client } } + // DR-042 C6d: Harvester/Conveyor/Pylon are dead (unwired automation) -> hidden from the build palette + // (catalog + prefabs stay baked, code-intact per DR-020). Only Turret/Wall/Fabricator are buildable in the UI. + static bool IsPaletteType(byte type) => + type != StructureType.Pylon && type != StructureType.Harvester && type != StructureType.Conveyor; + void UpdatePalette(int aether, int ore, int bio, bool onExpedition) { if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity(out var catE)) { var cat = SystemAPI.GetBuffer(catE); for (int i = 0; i < cat.Length; i++) - AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId); + if (IsPaletteType(cat[i].Type)) + AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId); _paletteBuilt = true; } if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; } @@ -809,6 +842,11 @@ namespace ProjectM.Client strip.Add(ResourceChip(theme != null ? theme.OreIcon : null, OreAmber, "0", out _oreNum, 30, 22)); strip.Add(ResourceChip(theme != null ? theme.BioIcon : null, BioGreen, "0", out _bioNum, 26, 20)); strip.Add(ResourceChip(null, ChargeViolet, "0", out _chargeNum, 26, 20)); // EB-2 turret ammo (flat violet, no icon) + // DR-042 C6a: the only Aether sink (ability-damage upgrade) gets a visible, clickable button (was U-key + // only). The Button element handles its own picking even though the HUD root Ignores clicks. + _upgradeBtn = MenuUi.Button("UPGRADE DMG (" + Tuning.AbilityUpgradeCostAmount + " AETHER)", BuildSendSystem.UpgradeAbility); + _upgradeBtn.style.marginLeft = 18; + strip.Add(_upgradeBtn); root.Add(strip); } diff --git a/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs b/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs index 37e4500d6..9e51a31a3 100644 --- a/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs @@ -20,6 +20,10 @@ namespace ProjectM.Server /// wave is fully spawned and every zone enemy is dead, it marks once — /// the gate's once-per-epoch Ore reward reads that on the player's return. /// + /// DR-042 C7b: it ALSO writes the replicated summary every tick, ABOVE the + /// presence early-return (snapshot-above-early-return), so the client HUD's "enemies remaining / cleared" readout + /// never freezes stale even when nobody is out. + /// /// Ordering: [UpdateAfter(ExpeditionFieldSystem)] ONLY. ExpeditionFieldSystem is itself /// [UpdateAfter(CyclePhaseSystem)], so ALSO declaring [UpdateBefore(CyclePhaseSystem)] here (as the /// v1 plan first sketched) would close a CyclePhase->Field->Zone->CyclePhase sort cycle that throws at Play @@ -51,26 +55,55 @@ namespace ProjectM.Server return; uint now = serverTick.TickIndexForValidTick; - // Per-player presence: only run while someone is OUT in the expedition (mirrors ExpeditionFieldSystem). + // Per-player presence: the SPAWNER only runs while someone is OUT in the expedition (mirrors + // ExpeditionFieldSystem). The objective readout below is written FIRST, every tick, even when nobody's out. int expeditionPlayers = 0; foreach (var region in SystemAPI.Query>().WithAll()) if (region.ValueRO.Region == RegionId.Expedition) expeditionPlayers++; - if (expeditionPlayers == 0) - return; // nobody out there: the field manager owns teardown, we do nothing var directorEntity = SystemAPI.GetSingletonEntity(); var dir = SystemAPI.GetComponent(directorEntity); var zs = SystemAPI.GetComponent(directorEntity); - var prefabs = SystemAPI.GetBuffer(directorEntity); - if (prefabs.Length == 0) - return; var cycleEntity = SystemAPI.GetSingletonEntity(); var cycle = SystemAPI.GetComponent(cycleEntity); var runtime = SystemAPI.GetComponent(cycleEntity); int epoch = runtime.ExpeditionEpoch; + int aliveZone = m_ZoneEnemies.CalculateEntityCount(); + + // DR-042 C7b: write the REPLICATED objective summary FIRST, above the early-returns (snapshot-above- + // early-return) so the HUD never freezes stale. Rides the untagged CycleDirector ghost (cross-region safe). + if (SystemAPI.HasComponent(cycleEntity)) + { + byte objState; + short objRemaining; + if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch) + { + objState = ExpeditionObjectiveState.Cleared; // cleared but not yet claimed -> "return to claim" + objRemaining = 0; + } + else if (expeditionPlayers > 0 && (aliveZone > 0 || zs.RemainingToSpawn > 0)) + { + objState = ExpeditionObjectiveState.Active; + objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue); + } + else + { + objState = ExpeditionObjectiveState.Idle; + objRemaining = 0; + } + SystemAPI.SetComponent(cycleEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining }); + } + + if (expeditionPlayers == 0) + return; // nobody out there: the field manager owns teardown, the spawner does nothing + + var prefabs = SystemAPI.GetBuffer(directorEntity); + if (prefabs.Length == 0) + return; + // MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base // siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts. var bands = new MixBands @@ -94,8 +127,6 @@ namespace ProjectM.Server zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick } - int aliveZone = m_ZoneEnemies.CalculateEntityCount(); - if (zs.RemainingToSpawn > 0) { // Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap. diff --git a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs index 323232dce..34404a460 100644 --- a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs @@ -63,6 +63,8 @@ namespace ProjectM.Server ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal }); // Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director + // DR-042 C6c: a NEW game seeds starting Ore below; a restored save (Continue) keeps its ledger. + bool restoredLedger = false; // ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker). if (SystemAPI.TryGetSingletonEntity(out var pendingEntity)) { @@ -79,6 +81,7 @@ namespace ProjectM.Server var srcLedger = SystemAPI.GetBuffer(pendingEntity); var destLedger = ecb.SetBuffer(director); SaveApply.WriteLedger(srcLedger, destLedger); + restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore (C6c) // END-1: born-correct the Engine Core. Max comes from the BAKED prefab (never the save); a // persisted wounded Current (>0) restores clamped to Max, else (0 = pre-v4 save) born full. @@ -99,6 +102,12 @@ namespace ProjectM.Server ecb.DestroyEntity(pendingEntity); } + // DR-042 C6c: NEW game only (no restored ledger) -> seed a little Ore so the build loop isn't a cold + // deadlock (a turret needs Charge from a Fabricator that costs Ore you haven't mined yet). Appended + // BEFORE Playback so the ghost first-serializes WITH the seed (no empty-ledger replication flicker). + if (!restoredLedger) + ecb.AppendToBuffer(director, new StorageEntry { ItemId = ResourceId.Ore, Count = Tuning.StartingOre }); + // Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint. ecb.AddComponent(director, new SaveRequest { Pending = 0 }); } diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 0b2fae13b..4698e163c 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -63,6 +63,13 @@ namespace ProjectM.Simulation /// Ore. Operator feel-fork: keep generous so turrets stay fed while you keep mining. public const int TurretChargeCostPerShot = 1; + // ---- Cold start (CycleDirectorSpawnSystem seeds the shared ledger on a NEW game) ---- + + /// DR-042 C6c: Ore deposited into the shared ledger at spawn on a NEW game ONLY (a restored save keeps + /// its persisted ledger). Bootstraps the Fabricator(30)->Charge->Turret(10) chain so a turret placed before any + /// mining isn't a silent cold deadlock. Ore-only so the 'build a Fabricator to arm turrets' lesson survives. + public const int StartingOre = 50; + // ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ---- /// Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger. diff --git a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs b/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs index 3b6bd9f14..31e371821 100644 --- a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs +++ b/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs @@ -83,4 +83,29 @@ namespace ProjectM.Simulation /// 1 once the current epoch's expedition wave has FULLY spawned and been cleared to zero live zone enemies; reset to 0 on the empty->occupied epoch bump. The reward fires only on a REAL clear. public byte ClearedThisEpoch; } + + /// + /// DR-042 C7b — a SMALL replicated summary of the current expedition objective so the client HUD can show an + /// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED CycleDirector ghost + /// (alongside / GoalProgress) so GhostRelevancy.SetIsIrrelevant never hides it + /// cross-region — a base teammate can't see the expedition's own (region-tagged, relevancy-hidden) enemy + /// ghosts. SOLE writer: ZoneEnemyDirectorSystem (server, plain group), written ABOVE its early-returns + /// (snapshot-above-early-return) so the readout never freezes stale. byte/short, never enum (writer is [BurstCompile]). + /// + public struct ExpeditionObjective : IComponentData + { + /// 0 = Idle (no sortie active), 1 = Active (wave in progress), 2 = Cleared (return to claim). + [GhostField] public byte State; + + /// Live zone enemies remaining (alive + not-yet-spawned) while Active; 0 when Idle/Cleared. + [GhostField] public short Remaining; + } + + /// State constants for (byte, not enum — Burst/serialization). + public static class ExpeditionObjectiveState + { + public const byte Idle = 0; + public const byte Active = 1; + public const byte Cleared = 2; + } }