diff --git a/AdvancedCoop/CoopAdvancedHardening.cs b/AdvancedCoop/CoopAdvancedHardening.cs index 0ee587a..3463b11 100644 --- a/AdvancedCoop/CoopAdvancedHardening.cs +++ b/AdvancedCoop/CoopAdvancedHardening.cs @@ -20,7 +20,8 @@ namespace DeadCellsMultiplayerMod.AdvancedCoop; /// -/// Stability and progression layer built on top of the original multiplayer base. +/// Lobby heartbeat and permanent unlock progression layer on top of the multiplayer base. +/// This is not enemy/mob sync — combat entities are owned by MobsSynchronization. /// It deliberately avoids constructing fake heroes/items during HeroInit. /// public sealed class CoopAdvancedHardening : diff --git a/FakeDeath/FakeDeath.Anchors.cs b/FakeDeath/FakeDeath.Anchors.cs new file mode 100644 index 0000000..d32fd36 --- /dev/null +++ b/FakeDeath/FakeDeath.Anchors.cs @@ -0,0 +1,560 @@ +using System; +using System.Diagnostics; +using dc.en; +using dc.tool.atk; +using DeadCellsMultiplayerMod.Ghost.GhostBase; +using ModCore.Modules; + +namespace DeadCellsMultiplayerMod +{ + public partial class ModEntry + { + // Keep enough grounded history to recover a corpse even after a long pit fall or a + // several-second trap sequence. The old 18-sample buffer only covered about two seconds, + // which was too short for many off-map deaths. + private const int SafeReviveAnchorHistorySize = 96; + private const double SafeReviveAnchorSampleSeconds = 0.12; + private const double SafeReviveAnchorPreferredAgeSeconds = 0.75; + private const double SafeReviveAnchorMaxAgeSeconds = 12.0; + private const double EnvironmentalDamageContextSeconds = 2.5; + // A sample recorded immediately before or during contact with spikes/lava is not safe. + // Quarantine new samples briefly after environmental damage and remove the recent tail. + private const double SafeAnchorEnvironmentalQuarantineSeconds = 2.5; + private const double SafeAnchorEnvironmentalPurgeSeconds = 2.25; + // Downed bodies should never remain at the lethal coordinate. Prefer an older point far + // enough away to be outside even a wide spike strip, then fall back to the oldest valid + // same-room anchor rather than the death position. + private const double HazardRecoveryPreferredAgeSeconds = 1.25; + private const double HazardRecoveryFallbackAgeSeconds = 2.25; + private const double HazardRecoveryMinDistancePx = 144.0; + private const double HazardRecoveryMinDistanceSq = HazardRecoveryMinDistancePx * HazardRecoveryMinDistancePx; + + private readonly struct SafeReviveAnchorSample + { + public readonly double X; + public readonly double Y; + public readonly long Ticks; + public readonly string LevelId; + + public SafeReviveAnchorSample(double x, double y, long ticks, string levelId) + { + X = x; + Y = y; + Ticks = ticks; + LevelId = levelId ?? string.Empty; + } + } + + private readonly SafeReviveAnchorSample[] _safeReviveAnchors = new SafeReviveAnchorSample[SafeReviveAnchorHistorySize]; + private int _safeReviveAnchorCount; + private int _safeReviveAnchorWriteIndex; + private long _nextSafeReviveAnchorSampleTicks; + private string _safeReviveAnchorLevelId = string.Empty; + private bool _hasSafeAnchorMotionProbe; + private double _lastSafeAnchorMotionProbeY; + private string _safeAnchorMotionProbeLevelId = string.Empty; + private bool _localDownedUsesRecoveryAnchor; + private bool _lastLocalDamageWasEnvironmental; + private long _lastLocalDamageContextTicks; + + + private void RecordLocalDamageContext(AttackData? attack) + { + var now = Stopwatch.GetTimestamp(); + _lastLocalDamageContextTicks = now; + _lastLocalDamageWasEnvironmental = IsEnvironmentalDamageSource(attack); + if (_lastLocalDamageWasEnvironmental) + InvalidateRecentSafeReviveAnchors(now, SafeAnchorEnvironmentalPurgeSeconds); + } + + private void InvalidateRecentSafeReviveAnchors(long now, double seconds) + { + if (_safeReviveAnchorCount <= 0 || seconds <= 0.0) + return; + + var cutoffTicks = now - (long)(Stopwatch.Frequency * seconds); + for (var i = 0; i < _safeReviveAnchors.Length; i++) + { + var sample = _safeReviveAnchors[i]; + if (sample.Ticks > 0 && sample.Ticks >= cutoffTicks) + _safeReviveAnchors[i] = default; + } + } + + private static bool IsEnvironmentalDamageSource(AttackData? attack) + { + if (attack == null) + return true; + + dc.Entity? source = null; + try { source = attack.source; } catch { } + if (source == null) + return true; + if (source is Mob || source is Hero || source is GhostKing) + return false; + + try + { + var name = source.GetType().Name ?? string.Empty; + if (name.IndexOf("Bullet", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Projectile", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Arrow", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Shot", StringComparison.OrdinalIgnoreCase) >= 0) + { + return false; + } + } + catch + { + } + + return true; + } + + private void UpdateLocalSafeReviveAnchor(Hero? hero) + { + if (hero == null || _localFakeDead || _localDeathConversionInProgress) + return; + + try + { + if (hero.destroyed || hero.life <= 0 || hero._level == null || hero.spr == null) + return; + if (hero.isOutOfGame) + return; + } + catch + { + return; + } + + var level = GetCurrentLevelId(); + if (string.IsNullOrWhiteSpace(level)) + return; + if (!string.Equals(_safeReviveAnchorLevelId, level, StringComparison.Ordinal)) + ResetSafeReviveAnchorHistory(level); + + var now = Stopwatch.GetTimestamp(); + if (_lastLocalDamageWasEnvironmental && _lastLocalDamageContextTicks > 0 && + now - _lastLocalDamageContextTicks < + (long)(Stopwatch.Frequency * SafeAnchorEnvironmentalQuarantineSeconds)) + { + return; + } + + if (_nextSafeReviveAnchorSampleTicks != 0 && now < _nextSafeReviveAnchorSampleTicks) + return; + _nextSafeReviveAnchorSampleTicks = now + + (long)(Stopwatch.Frequency * SafeReviveAnchorSampleSeconds); + + try + { + var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy); + if (!double.IsFinite(verticalMotion) || verticalMotion > 0.45) + return; + } + catch + { + } + + double x; + double y; + if (!TryGetHeroLogicalPixelPosition(hero, out x, out y)) + { + try + { + x = hero.get_targetSprPosX(); + y = hero.get_targetSprPosY(); + } + catch + { + try + { + x = hero.spr?.x ?? 0.0; + y = hero.spr?.y ?? 0.0; + } + catch + { + return; + } + } + } + + if (!TryProjectHeroPositionToSafeGround(hero, x, y, out var safeX, out var safeY)) + return; + + // Do not certify positions while a platform/elevator is carrying the hero vertically. + // Hero.dy can remain near zero on moving platforms, so compare logical floor Y across + // samples as a second independent stability check. The first stable probe after any + // vertical movement is only observed; the following stable probe becomes an anchor. + if (!_hasSafeAnchorMotionProbe || + !string.Equals(_safeAnchorMotionProbeLevelId, level, StringComparison.Ordinal)) + { + _hasSafeAnchorMotionProbe = true; + _lastSafeAnchorMotionProbeY = safeY; + _safeAnchorMotionProbeLevelId = level; + return; + } + + var floorDeltaY = Math.Abs(safeY - _lastSafeAnchorMotionProbeY); + _lastSafeAnchorMotionProbeY = safeY; + if (!double.IsFinite(floorDeltaY) || floorDeltaY > 2.0) + return; + + _safeReviveAnchors[_safeReviveAnchorWriteIndex] = + new SafeReviveAnchorSample(safeX, safeY, now, level); + _safeReviveAnchorWriteIndex = (_safeReviveAnchorWriteIndex + 1) % SafeReviveAnchorHistorySize; + if (_safeReviveAnchorCount < SafeReviveAnchorHistorySize) + _safeReviveAnchorCount++; + } + + private void ResetSafeReviveAnchorHistory(string levelId) + { + Array.Clear(_safeReviveAnchors, 0, _safeReviveAnchors.Length); + _safeReviveAnchorCount = 0; + _safeReviveAnchorWriteIndex = 0; + _nextSafeReviveAnchorSampleTicks = 0; + _safeReviveAnchorLevelId = levelId ?? string.Empty; + _hasSafeAnchorMotionProbe = false; + _lastSafeAnchorMotionProbeY = 0.0; + _safeAnchorMotionProbeLevelId = levelId ?? string.Empty; + } + + private bool TryGetSafeReviveAnchor(string levelId, long now, bool preferOlderSample, out double x, out double y) + { + x = 0.0; + y = 0.0; + if (_safeReviveAnchorCount <= 0 || string.IsNullOrWhiteSpace(levelId)) + return false; + + SafeReviveAnchorSample? newestValid = null; + for (var i = 0; i < _safeReviveAnchorCount; i++) + { + var index = (_safeReviveAnchorWriteIndex - 1 - i + SafeReviveAnchorHistorySize) % + SafeReviveAnchorHistorySize; + var sample = _safeReviveAnchors[index]; + if (sample.Ticks <= 0 || + !string.Equals(sample.LevelId, levelId, StringComparison.Ordinal)) + { + continue; + } + + var ageSeconds = (now - sample.Ticks) / (double)Stopwatch.Frequency; + if (ageSeconds < 0.0 || ageSeconds > SafeReviveAnchorMaxAgeSeconds) + continue; + + newestValid ??= sample; + if (!preferOlderSample || ageSeconds >= SafeReviveAnchorPreferredAgeSeconds) + { + x = sample.X; + y = sample.Y; + return true; + } + } + + if (newestValid.HasValue) + { + x = newestValid.Value.X; + y = newestValid.Value.Y; + return true; + } + + return false; + } + + private bool TryGetHazardRecoveryAnchor( + string levelId, + long now, + double deathX, + double deathY, + out double x, + out double y) + { + x = 0.0; + y = 0.0; + if (_safeReviveAnchorCount <= 0 || string.IsNullOrWhiteSpace(levelId)) + return false; + + SafeReviveAnchorSample? agedFallback = null; + SafeReviveAnchorSample? oldestValid = null; + var hasFiniteDeathPosition = double.IsFinite(deathX) && double.IsFinite(deathY); + + // Search from newest to oldest. Prefer a point that is both old enough to pre-date the + // hazard contact and far enough away that it is unlikely to still be inside the same + // spike bed, pit edge, lava strip, or trap volume. + for (var i = 0; i < _safeReviveAnchorCount; i++) + { + var index = (_safeReviveAnchorWriteIndex - 1 - i + SafeReviveAnchorHistorySize) % + SafeReviveAnchorHistorySize; + var sample = _safeReviveAnchors[index]; + if (sample.Ticks <= 0 || + !string.Equals(sample.LevelId, levelId, StringComparison.Ordinal)) + { + continue; + } + + var ageSeconds = (now - sample.Ticks) / (double)Stopwatch.Frequency; + if (ageSeconds < 0.0 || ageSeconds > SafeReviveAnchorMaxAgeSeconds) + continue; + + oldestValid = sample; + if (!agedFallback.HasValue && ageSeconds >= HazardRecoveryFallbackAgeSeconds) + agedFallback = sample; + + var separatedFromDeath = true; + if (hasFiniteDeathPosition) + { + var dx = sample.X - deathX; + var dy = sample.Y - deathY; + separatedFromDeath = dx * dx + dy * dy >= HazardRecoveryMinDistanceSq; + } + + if (ageSeconds >= HazardRecoveryPreferredAgeSeconds && separatedFromDeath) + { + x = sample.X; + y = sample.Y; + return true; + } + } + + // A player can die while almost stationary on a trap, so distance may not produce a + // candidate. In that case use an older grounded sample rather than leaving the corpse + // in the hazard. The final fallback is the oldest still-valid same-room sample. + var fallback = oldestValid ?? agedFallback; + if (!fallback.HasValue) + return false; + + x = fallback.Value.X; + y = fallback.Value.Y; + return true; + } + + private bool TryGetLivingTeammateSafeAnchor(Hero hero, out double x, out double y) + { + x = 0.0; + y = 0.0; + if (hero == null) + return false; + + var net = _net; + var localId = net?.id ?? 0; + for (var i = 0; i < clients.Length; i++) + { + var client = clients[i]; + if (client == null) + continue; + + try + { + if (client.destroyed || client._level == null || client.spr == null) + continue; + } + catch + { + continue; + } + + var remoteId = clientIds[i]; + if (remoteId <= 0 || (localId > 0 && remoteId == localId) || IsRemotePlayerDowned(remoteId)) + continue; + + double remoteX; + double remoteY; + if (!TryGetGhostLogicalPixelPosition(client, out remoteX, out remoteY)) + { + try + { + remoteX = client.get_targetSprPosX(); + remoteY = client.get_targetSprPosY(); + } + catch + { + try + { + remoteX = client.spr?.x ?? 0.0; + remoteY = client.spr?.y ?? 0.0; + } + catch + { + continue; + } + } + } + + if (double.IsFinite(remoteX) && double.IsFinite(remoteY)) + { + x = remoteX; + y = remoteY; + return true; + } + } + + return false; + } + + private static bool TryGetHeroLogicalPixelPosition(Hero hero, out double x, out double y) + { + x = 0.0; + y = 0.0; + if (hero == null) + return false; + + try + { + x = (hero.cx + hero.xr) * 24.0; + y = (hero.cy + hero.yr) * 24.0; + return double.IsFinite(x) && double.IsFinite(y); + } + catch + { + x = 0.0; + y = 0.0; + return false; + } + } + + private static bool TryGetGhostLogicalPixelPosition(GhostKing king, out double x, out double y) + { + x = 0.0; + y = 0.0; + if (king == null) + return false; + + try + { + x = (king.cx + king.xr) * 24.0; + y = (king.cy + king.yr) * 24.0; + return double.IsFinite(x) && double.IsFinite(y); + } + catch + { + x = 0.0; + y = 0.0; + return false; + } + } + + // Do not call LevelMap.getGroundYr from the managed per-frame update path. On the current + // DCCM/GameProxy combination that native bridge can receive the wrong HashLink receiver and + // terminate the game with "Can't cast tool.CPoint to level.LevelMap". Safe anchors are + // therefore selected only from finite, alive, same-level positions with low vertical motion. + // The history itself provides the ground/reachability guarantee without touching LevelMap. + private static bool TryProjectHeroPositionToSafeGround(Hero hero, double x, double y, out double safeX, out double safeY) + { + safeX = x; + safeY = y; + if (hero == null || !double.IsFinite(x) || !double.IsFinite(y)) + return false; + + try + { + if (hero.destroyed || hero._level == null || hero.spr == null || hero.isOutOfGame) + return false; + + var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy); + if (!double.IsFinite(verticalMotion) || verticalMotion > 0.45) + return false; + } + catch + { + return false; + } + + return true; + } + + private bool IsUnsafeLocalDeathPosition(Hero hero, double x, double y) + { + if (hero == null || !double.IsFinite(x) || !double.IsFinite(y)) + return true; + + try + { + if (hero.isOutOfGame) + return true; + } + catch + { + } + + try + { + var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy); + if (double.IsFinite(verticalMotion) && verticalMotion > 0.65) + return true; + } + catch + { + } + + // Finite, in-game positions with low vertical motion are usable. Environmental + // damage still selects an older history sample in ResolveLocalDownedAnchor. + return false; + } + + private void ResolveLocalDownedAnchor(Hero hero, double deathX, double deathY, out double downedX, out double downedY) + { + downedX = deathX; + downedY = deathY; + _localDownedUsesRecoveryAnchor = false; + + var now = Stopwatch.GetTimestamp(); + var level = GetCurrentLevelId(); + var recentEnvironmentalDamage = _lastLocalDamageWasEnvironmental && + _lastLocalDamageContextTicks > 0 && + now - _lastLocalDamageContextTicks <= + (long)(Stopwatch.Frequency * EnvironmentalDamageContextSeconds); + var unsafePosition = IsUnsafeLocalDeathPosition(hero, deathX, deathY); + + // Always prefer a confirmed earlier anchor. This is deliberately not limited to + // deaths that were correctly classified as environmental: some spike/pit kill paths + // bypass onDamage and only reach Hero.kill/onDie, which previously left the body at + // the lethal coordinate. The history selection requires age and separation first, + // then uses the oldest valid same-room sample as a guaranteed reachable fallback. + if (TryGetHazardRecoveryAnchor(level, now, deathX, deathY, out var safeX, out var safeY)) + { + downedX = safeX; + downedY = safeY; + _localDownedUsesRecoveryAnchor = true; + Logger.Information( + "[NetMod][ReviveAnchor] selected prior safe downed anchor environmental={Environmental} unsafe={Unsafe} deathX={DeathX:0.0} deathY={DeathY:0.0} safeX={SafeX:0.0} safeY={SafeY:0.0}", + recentEnvironmentalDamage, + unsafePosition, + deathX, + deathY, + downedX, + downedY); + } + else if (TryGetLivingTeammateSafeAnchor(hero, out safeX, out safeY)) + { + // Very early room deaths can occur before the local history contains a sample. + // A living teammate is then the only known reachable in-room location. + downedX = safeX; + downedY = safeY; + _localDownedUsesRecoveryAnchor = true; + Logger.Information( + "[NetMod][ReviveAnchor] used living teammate fallback safeX={SafeX:0.0} safeY={SafeY:0.0}", + downedX, + downedY); + } + else if (!recentEnvironmentalDamage && + !unsafePosition && + TryProjectHeroPositionToSafeGround(hero, deathX, deathY, out var groundX, out var groundY)) + { + // Last-resort only: no history and no living teammate. Keep a normal non-hazard + // death where it occurred rather than manufacturing an unverified coordinate. + downedX = groundX; + downedY = groundY; + } + else if (TryGetSafeReviveAnchor(level, now, preferOlderSample: true, out safeX, out safeY)) + { + downedX = safeX; + downedY = safeY; + _localDownedUsesRecoveryAnchor = true; + } + + _lastLocalDamageWasEnvironmental = false; + _lastLocalDamageContextTicks = 0; + } + } +} diff --git a/FakeDeath/FakeDeath.Cine.cs b/FakeDeath/FakeDeath.Cine.cs new file mode 100644 index 0000000..daa9bd3 --- /dev/null +++ b/FakeDeath/FakeDeath.Cine.cs @@ -0,0 +1,501 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using dc.en; +using dc.cine; +using DeadCellsMultiplayerMod.Ghost.GhostBase; +using ModCore.Modules; + +namespace DeadCellsMultiplayerMod +{ + public partial class ModEntry + { + // Cursed/environmental death hooks can run from inside attack/cooldown iteration. Constructing + // DeadBase or GameOver re-entrantly from that stack can leave HashLink in a permanent main-loop + // stall. Defer corpse-cinematic creation until a later normal frame and serialize flow updates. + private const double LocalDeadCineCreateDelaySeconds = 0.12; + private long _localDeadCineCreateAfterTicks; + private bool _hasLocalDownedAnchor; + private double _localDownedAnchorX; + private double _localDownedAnchorY; + private const double DownedCorpseMaxDriftPx = 96.0; + private const double DownedCorpseMaxDriftSq = DownedCorpseMaxDriftPx * DownedCorpseMaxDriftPx; + private readonly HashSet _scratchActiveCorpseIds = new(); + private readonly List _scratchStaleCorpseIds = new(); + // Environmental deaths can temporarily publish an invalid room marker or put the remote + // GhostKing into the engine's out-of-game state. Keep a brief revive grace period so the + // normal snapshot stream can reattach the same remote shell without requiring a sublevel + // round-trip to rebuild it. + private readonly Dictionary _remoteReviveVisibilityGraceUntilTicks = new(); + private const double RemoteReviveVisibilityGraceSeconds = 3.0; + + + private static bool IsVanillaHeroDeathCineActive() + { + try + { + var cine = dc.pr.Game.Class.ME?.curCine; + return cine is HeroDeath || + cine is HeroDeathBase || + cine is HeroDeathContinue || + cine is HeroDeathRespawn || + cine is HeroDeathDLCP; + } + catch + { + return false; + } + } + + private bool ShouldSuppressVanillaHeroDeathCinematic(Hero? lostBody) + { + return _netRole != NetRole.None && + _net != null && + _net.IsAlive && + me != null && + lostBody != null && + ReferenceEquals(lostBody, me); + } + + private bool SuppressVanillaHeroDeathCinematic(Hero? lostBody, dc.GameCinematic? cine) + { + if (!ShouldSuppressVanillaHeroDeathCinematic(lostBody)) + return false; + + if (!_localFakeDead && lostBody != null && _net != null) + EnterLocalFakeDeath(lostBody, _net); + + try + { + var game = dc.pr.Game.Class.ME; + if (game != null && cine != null && ReferenceEquals(game.curCine, cine)) + game.curCine = null; + } + catch + { + } + + // Constructor hooks run before the vanilla cinematic object is initialized. Calling + // destroy/disposeImmediately on that half-constructed object can tear down unrelated + // hero state. Simply skip the constructor; startDeathCine/kill are already redirected. + return true; + } + + private void Hook__HeroDeath__constructor__(Hook__HeroDeath.orig___constructor__ orig, HeroDeath e, Hero lostBody, bool fromMob) + { + if (SuppressVanillaHeroDeathCinematic(lostBody, e)) + return; + + orig(e, lostBody, fromMob); + } + + private void Hook__HeroDeathBase__constructor__(Hook__HeroDeathBase.orig___constructor__ orig, HeroDeathBase e, Hero lostBody, bool mob) + { + if (SuppressVanillaHeroDeathCinematic(lostBody, e)) + return; + + orig(e, lostBody, mob); + } + + private void Hook__HeroDeathContinue__constructor__(Hook__HeroDeathContinue.orig___constructor__ orig, HeroDeathContinue e, Hero lostBody, bool keepBody) + { + if (SuppressVanillaHeroDeathCinematic(lostBody, e)) + return; + + orig(e, lostBody, keepBody); + } + + private void Hook__HeroDeathRespawn__constructor__(Hook__HeroDeathRespawn.orig___constructor__ orig, HeroDeathRespawn e, Hero lostBody) + { + if (SuppressVanillaHeroDeathCinematic(lostBody, e)) + return; + + orig(e, lostBody); + } + + private void Hook__HeroDeathDLCP__constructor__(Hook__HeroDeathDLCP.orig___constructor__ orig, HeroDeathDLCP e, Hero lostBody, bool fromMob) + { + if (SuppressVanillaHeroDeathCinematic(lostBody, e)) + return; + + orig(e, lostBody, fromMob); + } + + private void ApplyRemoteDownedGhostPositions(NetNode net) + { + if (net == null) + return; + + if (_remoteDowned.Count == 0) + { + DisposeAllRemoteDownedCines(); + for (int i = 0; i < clients.Length; i++) + { + var client = clients[i]; + if (client != null) + { + try { client._targetable = true; } catch { } + } + } + return; + } + + var localId = net.id; + var localLevelId = GetCurrentLevelId(); + _scratchActiveCorpseIds.Clear(); + foreach (var state in _remoteDowned.Values) + { + if (state == null || state.UserId <= 0) + continue; + if (!TryGetClientIndex(localId, state.UserId, out var index)) + { + DisposeRemoteDownedCine(state.UserId); + continue; + } + + if (!string.IsNullOrEmpty(localLevelId) && + !string.IsNullOrEmpty(state.LevelId) && + !string.Equals(state.LevelId, localLevelId, StringComparison.Ordinal)) + { + DisposeRemoteDownedCine(state.UserId); + continue; + } + + var client = clients[index]; + if (client == null) + { + // A fall/lava snapshot can dispose the remote shell because its temporary room + // marker no longer matches. Recreate it immediately for the authoritative + // same-level downed body instead of waiting for a sublevel transition. + client = EnsureClientKingSlot(index); + } + if (client == null) + { + DisposeRemoteDownedCine(state.UserId); + continue; + } + CancelPendingClientDispose(index); + + _scratchActiveCorpseIds.Add(state.UserId); + + // Never create a second corpse cinematic once the local player is also downed. + // If this remote corpse already existed (the local player died second), keep and + // update that single cinematic; otherwise the brief all-down state needs no new one. + RemoteDownedCorpse? cine = null; + if (_remoteDownedCines.TryGetValue(state.UserId, out var existingCine) && existingCine != null) + cine = existingCine; + else if (!_localFakeDead) + cine = EnsureRemoteDownedCine(state, client); + + if (cine != null) + { + try + { + cine.UpdateTarget( + state.X, + state.Y, + client.dir, + state.HasHeadPosition ? state.HeadX : null, + state.HasHeadPosition ? state.HeadY : null, + state.HasHeadAnim ? state.HeadAnim : null); + } + catch { DisposeRemoteDownedCine(state.UserId); } + } + + try { client._targetable = false; } catch { } + try { client.setPosPixel(state.X, state.Y - DownedGhostBodyYOffsetPx); } catch { } + + rLastX[index] = state.X; + rLastY[index] = state.Y - DownedGhostBodyYOffsetPx; + } + + if (_remoteDownedCines.Count > 0) + { + _scratchStaleCorpseIds.Clear(); + foreach (var pair in _remoteDownedCines) + { + if (!_scratchActiveCorpseIds.Contains(pair.Key)) + _scratchStaleCorpseIds.Add(pair.Key); + } + + for (int i = 0; i < _scratchStaleCorpseIds.Count; i++) + DisposeRemoteDownedCine(_scratchStaleCorpseIds[i]); + } + } + + private bool IsRemoteDownedVisibleInCurrentLevel(int userId, string? localLevelId) + { + if (userId <= 0 || !_remoteDowned.TryGetValue(userId, out var state) || state == null) + return false; + + if (string.IsNullOrWhiteSpace(localLevelId) || string.IsNullOrWhiteSpace(state.LevelId)) + return true; + + return string.Equals(localLevelId, state.LevelId, StringComparison.Ordinal); + } + + private bool IsRemoteReviveVisibilityGraceActive(int userId) + { + if (userId <= 0 || !_remoteReviveVisibilityGraceUntilTicks.TryGetValue(userId, out var untilTicks)) + return false; + + if (Stopwatch.GetTimestamp() < untilTicks) + return true; + + _remoteReviveVisibilityGraceUntilTicks.Remove(userId); + return false; + } + + private void BeginRemoteReviveVisibilityRecovery( + int userId, + int slot, + GhostKing? client, + double x, + double y) + { + if (userId <= 0) + return; + + _remoteReviveVisibilityGraceUntilTicks[userId] = Stopwatch.GetTimestamp() + + (long)(Stopwatch.Frequency * RemoteReviveVisibilityGraceSeconds); + _remoteLastDoorMarkers.Remove(userId); + + if (slot < 0 || slot >= clients.Length) + return; + + CancelPendingClientDispose(slot); + clientLastDownedOffsets[slot] = false; + + if (client == null) + return; + + var unusable = false; + try { unusable = client.destroyed; } catch { } + try + { + if (!unusable && me?._level != null && client._level != null && + !ReferenceEquals(client._level, me._level)) + { + unusable = true; + } + } + catch + { + } + try + { + if (!unusable && client.spr == null) + unusable = true; + } + catch + { + } + + if (unusable) + { + DisposeClientSlot(slot, clearIdentity: false); + return; + } + + RestoreRemoteKingRenderAfterRevive(slot, client, x, y, "down-state-up"); + } + + private void RestoreRemoteKingRenderAfterRevive( + int slot, + GhostKing client, + double x, + double y, + string reason) + { + if (client == null || slot < 0 || slot >= clients.Length) + return; + + try + { + if (double.IsFinite(x) && double.IsFinite(y)) + { + client.setPosPixel(x, y); + rLastX[slot] = x; + rLastY[slot] = y; + } + } + catch + { + } + + var wasOutOfGame = false; + try { wasOutOfGame = client.isOutOfGame; } catch { } + try { client.lastOutOfGame = false; } catch { } + try { client.isOutOfGame = false; } catch { } + try { client.isOnScreen = true; } catch { } + try + { + if (client.onScreenRecent < 1200.0) + client.onScreenRecent = 1200.0; + } + catch { } + if (wasOutOfGame) + { + try { client.onOutOfGameChange(); } catch { } + } + try { client.visible = true; } catch { } + try { client.spr?.set_visible(true); } catch { } + try { client._targetable = true; } catch { } + + try { EnsureGhostKingRenderSafe(client, "remote-revive:" + reason, detachForTransition: false); } catch { } + + if (clientHeads[slot] == null || client.head == null) + ScheduleGhostHeadRecreate(slot, immediate: true); + MarkGhostHeadDirty(slot, immediate: true); + } + + private RemoteDownedCorpse? EnsureRemoteDownedCine(RemoteDownedState state, GhostKing client) + { + if (state == null || client == null || me == null) + return null; + + if (_remoteDownedCines.TryGetValue(state.UserId, out var existing)) + { + if (existing != null) + return existing; + + _remoteDownedCines.Remove(state.UserId); + } + + try + { + var previousCine = dc.pr.Game.Class.ME?.curCine; + var created = new RemoteDownedCorpse(me, client, state.X, state.Y, client.dir, previousCine); + _remoteDownedCines[state.UserId] = created; + return created; + } + catch + { + _remoteDownedCines.Remove(state.UserId); + return null; + } + } + + private void DisposeRemoteDownedCine(int userId) + { + if (!_remoteDownedCines.TryGetValue(userId, out var cine) || cine == null) + return; + + _remoteDownedCines.Remove(userId); + try { cine.destroy(); } catch { } + try { cine.disposeImmediately(); } catch { } + } + + private void DisposeAllRemoteDownedCines() + { + if (_remoteDownedCines.Count == 0) + return; + + _scratchStaleCorpseIds.Clear(); + foreach (var id in _remoteDownedCines.Keys) + _scratchStaleCorpseIds.Add(id); + + for (int i = 0; i < _scratchStaleCorpseIds.Count; i++) + DisposeRemoteDownedCine(_scratchStaleCorpseIds[i]); + } + + private void ShowReviveHintFor(int userId) + { + if (_remoteDownedCines.Count == 0) + return; + + foreach (var pair in _remoteDownedCines) + { + var cine = pair.Value; + if (cine == null) + continue; + + try + { + if (pair.Key == userId) + cine.SetInteractionLabel(Localize(ReviveHintText)); + else + cine.SetInteractionLabel(null); + } + catch + { + } + } + } + + private void ClearReviveHints() + { + if (_remoteDownedCines.Count == 0) + return; + + foreach (var cine in _remoteDownedCines.Values) + { + if (cine == null) + continue; + try { cine.SetInteractionLabel(null); } catch { } + } + } + + private void StartLocalDeadCine(Hero hero) + { + if (hero == null) + return; + + if (_localDeadCine != null) + return; + + try + { + _localDeadCine = new DeadBase(hero, ModEntry.GetPrimaryClient()); + } + catch + { + _localDeadCine = null; + } + } + + private void StopLocalDeadCine() + { + var cine = _localDeadCine; + _localDeadCine = null; + if (cine == null) + return; + + try { cine.destroy(); } catch { } + try { cine.disposeImmediately(); } catch { } + } + + private bool TryUpdateDownedPositionFromCorpse(double corpseX, double corpseY) + { + // Co-op corpses are pinned to the authoritative revive point. Never let a one-frame + // corpse physics step move the gameplay anchor downward or through floor tiles. + if (_localFakeDead && ShouldAnchorLocalDownedCorpse()) + return false; + + if (!double.IsFinite(corpseX) || !double.IsFinite(corpseY)) + return false; + + if (!_hasLocalDownedAnchor) + { + _localDownedAnchorX = _localDownedX; + _localDownedAnchorY = _localDownedY; + _hasLocalDownedAnchor = true; + } + + var dx = corpseX - _localDownedAnchorX; + var dy = corpseY - _localDownedAnchorY; + var distSq = dx * dx + dy * dy; + if (distSq > DownedCorpseMaxDriftSq) + return false; + + _localDownedX = corpseX; + _localDownedY = corpseY; + _localHeldX = _localDownedX; + _localHeldY = _localDownedY; + _localDownedAnchorX = corpseX; + _localDownedAnchorY = corpseY; + return true; + } + + } +} diff --git a/FakeDeath/FakeDeath.cs b/FakeDeath/FakeDeath.cs index 4f28cc9..0966b12 100644 --- a/FakeDeath/FakeDeath.cs +++ b/FakeDeath/FakeDeath.cs @@ -6,7 +6,6 @@ using dc.tool.atk; using dc.tool.mainSkills; using dc.ui; -using dc.cine; using DeadCellsMultiplayerMod.Ghost.GhostBase; using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; using ModCore.Modules; @@ -21,76 +20,11 @@ public partial class ModEntry private bool _allDownedRestartQueued; private long _allDownedRestartAtTicks; private const double AllDownedGameOverDelaySeconds = 0.35; - // Cursed/environmental death hooks can run from inside attack/cooldown iteration. Constructing - // DeadBase or GameOver re-entrantly from that stack can leave HashLink in a permanent main-loop - // stall. Defer corpse-cinematic creation until a later normal frame and serialize flow updates. - private const double LocalDeadCineCreateDelaySeconds = 0.12; - private long _localDeadCineCreateAfterTicks; private bool _fakeDeathFlowInProgress; - private bool _hasLocalDownedAnchor; - private double _localDownedAnchorX; - private double _localDownedAnchorY; - private const double DownedCorpseMaxDriftPx = 96.0; - private const double DownedCorpseMaxDriftSq = DownedCorpseMaxDriftPx * DownedCorpseMaxDriftPx; - // Keep enough grounded history to recover a corpse even after a long pit fall or a - // several-second trap sequence. The old 18-sample buffer only covered about two seconds, - // which was too short for many off-map deaths. - private const int SafeReviveAnchorHistorySize = 96; - private const double SafeReviveAnchorSampleSeconds = 0.12; - private const double SafeReviveAnchorPreferredAgeSeconds = 0.75; - private const double SafeReviveAnchorMaxAgeSeconds = 12.0; - private const double EnvironmentalDamageContextSeconds = 2.5; - // A sample recorded immediately before or during contact with spikes/lava is not safe. - // Quarantine new samples briefly after environmental damage and remove the recent tail. - private const double SafeAnchorEnvironmentalQuarantineSeconds = 2.5; - private const double SafeAnchorEnvironmentalPurgeSeconds = 2.25; - // Downed bodies should never remain at the lethal coordinate. Prefer an older point far - // enough away to be outside even a wide spike strip, then fall back to the oldest valid - // same-room anchor rather than the death position. - private const double HazardRecoveryPreferredAgeSeconds = 1.25; - private const double HazardRecoveryFallbackAgeSeconds = 2.25; - private const double HazardRecoveryMinDistancePx = 144.0; - private const double HazardRecoveryMinDistanceSq = HazardRecoveryMinDistancePx * HazardRecoveryMinDistancePx; - - private readonly struct SafeReviveAnchorSample - { - public readonly double X; - public readonly double Y; - public readonly long Ticks; - public readonly string LevelId; - - public SafeReviveAnchorSample(double x, double y, long ticks, string levelId) - { - X = x; - Y = y; - Ticks = ticks; - LevelId = levelId ?? string.Empty; - } - } - - private readonly SafeReviveAnchorSample[] _safeReviveAnchors = new SafeReviveAnchorSample[SafeReviveAnchorHistorySize]; - private int _safeReviveAnchorCount; - private int _safeReviveAnchorWriteIndex; - private long _nextSafeReviveAnchorSampleTicks; - private string _safeReviveAnchorLevelId = string.Empty; - private bool _hasSafeAnchorMotionProbe; - private double _lastSafeAnchorMotionProbeY; - private string _safeAnchorMotionProbeLevelId = string.Empty; - private bool _localDownedUsesRecoveryAnchor; private bool _localDownedGravityCaptured; private bool _localDownedOriginalHasGravity = true; - private bool _lastLocalDamageWasEnvironmental; - private long _lastLocalDamageContextTicks; private readonly HashSet _scratchRemoteActiveIds = new(); - private readonly HashSet _scratchActiveCorpseIds = new(); private readonly List _scratchStaleRemoteIds = new(); - private readonly List _scratchStaleCorpseIds = new(); - // Environmental deaths can temporarily publish an invalid room marker or put the remote - // GhostKing into the engine's out-of-game state. Keep a brief revive grace period so the - // normal snapshot stream can reattach the same remote shell without requiring a sublevel - // round-trip to rebuild it. - private readonly Dictionary _remoteReviveVisibilityGraceUntilTicks = new(); - private const double RemoteReviveVisibilityGraceSeconds = 3.0; private void Hook_Hero_onHeroDie(Hook_Hero.orig_onHeroDie orig, Hero self) { @@ -265,505 +199,6 @@ private void AbortLocalDiveStateForFakeDeath(Hero? hero, string reason) // map from a death hook risks a delayed HashLink null/type crash in the main loop. } - private void RecordLocalDamageContext(AttackData? attack) - { - var now = Stopwatch.GetTimestamp(); - _lastLocalDamageContextTicks = now; - _lastLocalDamageWasEnvironmental = IsEnvironmentalDamageSource(attack); - if (_lastLocalDamageWasEnvironmental) - InvalidateRecentSafeReviveAnchors(now, SafeAnchorEnvironmentalPurgeSeconds); - } - - private void InvalidateRecentSafeReviveAnchors(long now, double seconds) - { - if (_safeReviveAnchorCount <= 0 || seconds <= 0.0) - return; - - var cutoffTicks = now - (long)(Stopwatch.Frequency * seconds); - for (var i = 0; i < _safeReviveAnchors.Length; i++) - { - var sample = _safeReviveAnchors[i]; - if (sample.Ticks > 0 && sample.Ticks >= cutoffTicks) - _safeReviveAnchors[i] = default; - } - } - - private static bool IsEnvironmentalDamageSource(AttackData? attack) - { - if (attack == null) - return true; - - dc.Entity? source = null; - try { source = attack.source; } catch { } - if (source == null) - return true; - if (source is Mob || source is Hero || source is GhostKing) - return false; - - try - { - var name = source.GetType().Name ?? string.Empty; - if (name.IndexOf("Bullet", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("Projectile", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("Arrow", StringComparison.OrdinalIgnoreCase) >= 0 || - name.IndexOf("Shot", StringComparison.OrdinalIgnoreCase) >= 0) - { - return false; - } - } - catch - { - } - - return true; - } - - private void UpdateLocalSafeReviveAnchor(Hero? hero) - { - if (hero == null || _localFakeDead || _localDeathConversionInProgress) - return; - - try - { - if (hero.destroyed || hero.life <= 0 || hero._level == null || hero.spr == null) - return; - if (hero.isOutOfGame) - return; - } - catch - { - return; - } - - var level = GetCurrentLevelId(); - if (string.IsNullOrWhiteSpace(level)) - return; - if (!string.Equals(_safeReviveAnchorLevelId, level, StringComparison.Ordinal)) - ResetSafeReviveAnchorHistory(level); - - var now = Stopwatch.GetTimestamp(); - if (_lastLocalDamageWasEnvironmental && _lastLocalDamageContextTicks > 0 && - now - _lastLocalDamageContextTicks < - (long)(Stopwatch.Frequency * SafeAnchorEnvironmentalQuarantineSeconds)) - { - return; - } - - if (_nextSafeReviveAnchorSampleTicks != 0 && now < _nextSafeReviveAnchorSampleTicks) - return; - _nextSafeReviveAnchorSampleTicks = now + - (long)(Stopwatch.Frequency * SafeReviveAnchorSampleSeconds); - - try - { - var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy); - if (!double.IsFinite(verticalMotion) || verticalMotion > 0.45) - return; - } - catch - { - } - - double x; - double y; - if (!TryGetHeroLogicalPixelPosition(hero, out x, out y)) - { - try - { - x = hero.get_targetSprPosX(); - y = hero.get_targetSprPosY(); - } - catch - { - try - { - x = hero.spr?.x ?? 0.0; - y = hero.spr?.y ?? 0.0; - } - catch - { - return; - } - } - } - - if (!TryProjectHeroPositionToSafeGround(hero, x, y, out var safeX, out var safeY)) - return; - - // Do not certify positions while a platform/elevator is carrying the hero vertically. - // Hero.dy can remain near zero on moving platforms, so compare logical floor Y across - // samples as a second independent stability check. The first stable probe after any - // vertical movement is only observed; the following stable probe becomes an anchor. - if (!_hasSafeAnchorMotionProbe || - !string.Equals(_safeAnchorMotionProbeLevelId, level, StringComparison.Ordinal)) - { - _hasSafeAnchorMotionProbe = true; - _lastSafeAnchorMotionProbeY = safeY; - _safeAnchorMotionProbeLevelId = level; - return; - } - - var floorDeltaY = Math.Abs(safeY - _lastSafeAnchorMotionProbeY); - _lastSafeAnchorMotionProbeY = safeY; - if (!double.IsFinite(floorDeltaY) || floorDeltaY > 2.0) - return; - - _safeReviveAnchors[_safeReviveAnchorWriteIndex] = - new SafeReviveAnchorSample(safeX, safeY, now, level); - _safeReviveAnchorWriteIndex = (_safeReviveAnchorWriteIndex + 1) % SafeReviveAnchorHistorySize; - if (_safeReviveAnchorCount < SafeReviveAnchorHistorySize) - _safeReviveAnchorCount++; - } - - private void ResetSafeReviveAnchorHistory(string levelId) - { - Array.Clear(_safeReviveAnchors, 0, _safeReviveAnchors.Length); - _safeReviveAnchorCount = 0; - _safeReviveAnchorWriteIndex = 0; - _nextSafeReviveAnchorSampleTicks = 0; - _safeReviveAnchorLevelId = levelId ?? string.Empty; - _hasSafeAnchorMotionProbe = false; - _lastSafeAnchorMotionProbeY = 0.0; - _safeAnchorMotionProbeLevelId = levelId ?? string.Empty; - } - - private bool TryGetSafeReviveAnchor(string levelId, long now, bool preferOlderSample, out double x, out double y) - { - x = 0.0; - y = 0.0; - if (_safeReviveAnchorCount <= 0 || string.IsNullOrWhiteSpace(levelId)) - return false; - - SafeReviveAnchorSample? newestValid = null; - for (var i = 0; i < _safeReviveAnchorCount; i++) - { - var index = (_safeReviveAnchorWriteIndex - 1 - i + SafeReviveAnchorHistorySize) % - SafeReviveAnchorHistorySize; - var sample = _safeReviveAnchors[index]; - if (sample.Ticks <= 0 || - !string.Equals(sample.LevelId, levelId, StringComparison.Ordinal)) - { - continue; - } - - var ageSeconds = (now - sample.Ticks) / (double)Stopwatch.Frequency; - if (ageSeconds < 0.0 || ageSeconds > SafeReviveAnchorMaxAgeSeconds) - continue; - - newestValid ??= sample; - if (!preferOlderSample || ageSeconds >= SafeReviveAnchorPreferredAgeSeconds) - { - x = sample.X; - y = sample.Y; - return true; - } - } - - if (newestValid.HasValue) - { - x = newestValid.Value.X; - y = newestValid.Value.Y; - return true; - } - - return false; - } - - private bool TryGetHazardRecoveryAnchor( - string levelId, - long now, - double deathX, - double deathY, - out double x, - out double y) - { - x = 0.0; - y = 0.0; - if (_safeReviveAnchorCount <= 0 || string.IsNullOrWhiteSpace(levelId)) - return false; - - SafeReviveAnchorSample? agedFallback = null; - SafeReviveAnchorSample? oldestValid = null; - var hasFiniteDeathPosition = double.IsFinite(deathX) && double.IsFinite(deathY); - - // Search from newest to oldest. Prefer a point that is both old enough to pre-date the - // hazard contact and far enough away that it is unlikely to still be inside the same - // spike bed, pit edge, lava strip, or trap volume. - for (var i = 0; i < _safeReviveAnchorCount; i++) - { - var index = (_safeReviveAnchorWriteIndex - 1 - i + SafeReviveAnchorHistorySize) % - SafeReviveAnchorHistorySize; - var sample = _safeReviveAnchors[index]; - if (sample.Ticks <= 0 || - !string.Equals(sample.LevelId, levelId, StringComparison.Ordinal)) - { - continue; - } - - var ageSeconds = (now - sample.Ticks) / (double)Stopwatch.Frequency; - if (ageSeconds < 0.0 || ageSeconds > SafeReviveAnchorMaxAgeSeconds) - continue; - - oldestValid = sample; - if (!agedFallback.HasValue && ageSeconds >= HazardRecoveryFallbackAgeSeconds) - agedFallback = sample; - - var separatedFromDeath = true; - if (hasFiniteDeathPosition) - { - var dx = sample.X - deathX; - var dy = sample.Y - deathY; - separatedFromDeath = dx * dx + dy * dy >= HazardRecoveryMinDistanceSq; - } - - if (ageSeconds >= HazardRecoveryPreferredAgeSeconds && separatedFromDeath) - { - x = sample.X; - y = sample.Y; - return true; - } - } - - // A player can die while almost stationary on a trap, so distance may not produce a - // candidate. In that case use an older grounded sample rather than leaving the corpse - // in the hazard. The final fallback is the oldest still-valid same-room sample. - var fallback = oldestValid ?? agedFallback; - if (!fallback.HasValue) - return false; - - x = fallback.Value.X; - y = fallback.Value.Y; - return true; - } - - private bool TryGetLivingTeammateSafeAnchor(Hero hero, out double x, out double y) - { - x = 0.0; - y = 0.0; - if (hero == null) - return false; - - var net = _net; - var localId = net?.id ?? 0; - for (var i = 0; i < clients.Length; i++) - { - var client = clients[i]; - if (client == null) - continue; - - try - { - if (client.destroyed || client._level == null || client.spr == null) - continue; - } - catch - { - continue; - } - - var remoteId = clientIds[i]; - if (remoteId <= 0 || (localId > 0 && remoteId == localId) || IsRemotePlayerDowned(remoteId)) - continue; - - double remoteX; - double remoteY; - if (!TryGetGhostLogicalPixelPosition(client, out remoteX, out remoteY)) - { - try - { - remoteX = client.get_targetSprPosX(); - remoteY = client.get_targetSprPosY(); - } - catch - { - try - { - remoteX = client.spr?.x ?? 0.0; - remoteY = client.spr?.y ?? 0.0; - } - catch - { - continue; - } - } - } - - if (double.IsFinite(remoteX) && double.IsFinite(remoteY)) - { - x = remoteX; - y = remoteY; - return true; - } - } - - return false; - } - - private static bool TryGetHeroLogicalPixelPosition(Hero hero, out double x, out double y) - { - x = 0.0; - y = 0.0; - if (hero == null) - return false; - - try - { - x = (hero.cx + hero.xr) * 24.0; - y = (hero.cy + hero.yr) * 24.0; - return double.IsFinite(x) && double.IsFinite(y); - } - catch - { - x = 0.0; - y = 0.0; - return false; - } - } - - private static bool TryGetGhostLogicalPixelPosition(GhostKing king, out double x, out double y) - { - x = 0.0; - y = 0.0; - if (king == null) - return false; - - try - { - x = (king.cx + king.xr) * 24.0; - y = (king.cy + king.yr) * 24.0; - return double.IsFinite(x) && double.IsFinite(y); - } - catch - { - x = 0.0; - y = 0.0; - return false; - } - } - - // Do not call LevelMap.getGroundYr from the managed per-frame update path. On the current - // DCCM/GameProxy combination that native bridge can receive the wrong HashLink receiver and - // terminate the game with "Can't cast tool.CPoint to level.LevelMap". Safe anchors are - // therefore selected only from finite, alive, same-level positions with low vertical motion. - // The history itself provides the ground/reachability guarantee without touching LevelMap. - private static bool TryProjectHeroPositionToSafeGround(Hero hero, double x, double y, out double safeX, out double safeY) - { - safeX = x; - safeY = y; - if (hero == null || !double.IsFinite(x) || !double.IsFinite(y)) - return false; - - try - { - if (hero.destroyed || hero._level == null || hero.spr == null || hero.isOutOfGame) - return false; - - var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy); - if (!double.IsFinite(verticalMotion) || verticalMotion > 0.45) - return false; - } - catch - { - return false; - } - - return true; - } - - private bool IsUnsafeLocalDeathPosition(Hero hero, double x, double y) - { - if (hero == null || !double.IsFinite(x) || !double.IsFinite(y)) - return true; - - try - { - if (hero.isOutOfGame) - return true; - } - catch - { - } - - try - { - var verticalMotion = Math.Abs(hero.dy) + Math.Abs(hero.bdy); - if (double.IsFinite(verticalMotion) && verticalMotion > 0.65) - return true; - } - catch - { - } - - // Finite, in-game positions with low vertical motion are usable. Environmental - // damage still selects an older history sample in ResolveLocalDownedAnchor. - return false; - } - - private void ResolveLocalDownedAnchor(Hero hero, double deathX, double deathY, out double downedX, out double downedY) - { - downedX = deathX; - downedY = deathY; - _localDownedUsesRecoveryAnchor = false; - - var now = Stopwatch.GetTimestamp(); - var level = GetCurrentLevelId(); - var recentEnvironmentalDamage = _lastLocalDamageWasEnvironmental && - _lastLocalDamageContextTicks > 0 && - now - _lastLocalDamageContextTicks <= - (long)(Stopwatch.Frequency * EnvironmentalDamageContextSeconds); - var unsafePosition = IsUnsafeLocalDeathPosition(hero, deathX, deathY); - - // Always prefer a confirmed earlier anchor. This is deliberately not limited to - // deaths that were correctly classified as environmental: some spike/pit kill paths - // bypass onDamage and only reach Hero.kill/onDie, which previously left the body at - // the lethal coordinate. The history selection requires age and separation first, - // then uses the oldest valid same-room sample as a guaranteed reachable fallback. - if (TryGetHazardRecoveryAnchor(level, now, deathX, deathY, out var safeX, out var safeY)) - { - downedX = safeX; - downedY = safeY; - _localDownedUsesRecoveryAnchor = true; - Logger.Information( - "[NetMod][ReviveAnchor] selected prior safe downed anchor environmental={Environmental} unsafe={Unsafe} deathX={DeathX:0.0} deathY={DeathY:0.0} safeX={SafeX:0.0} safeY={SafeY:0.0}", - recentEnvironmentalDamage, - unsafePosition, - deathX, - deathY, - downedX, - downedY); - } - else if (TryGetLivingTeammateSafeAnchor(hero, out safeX, out safeY)) - { - // Very early room deaths can occur before the local history contains a sample. - // A living teammate is then the only known reachable in-room location. - downedX = safeX; - downedY = safeY; - _localDownedUsesRecoveryAnchor = true; - Logger.Information( - "[NetMod][ReviveAnchor] used living teammate fallback safeX={SafeX:0.0} safeY={SafeY:0.0}", - downedX, - downedY); - } - else if (!recentEnvironmentalDamage && - !unsafePosition && - TryProjectHeroPositionToSafeGround(hero, deathX, deathY, out var groundX, out var groundY)) - { - // Last-resort only: no history and no living teammate. Keep a normal non-hazard - // death where it occurred rather than manufacturing an unverified coordinate. - downedX = groundX; - downedY = groundY; - } - else if (TryGetSafeReviveAnchor(level, now, preferOlderSample: true, out safeX, out safeY)) - { - downedX = safeX; - downedY = safeY; - _localDownedUsesRecoveryAnchor = true; - } - - _lastLocalDamageWasEnvironmental = false; - _lastLocalDamageContextTicks = 0; - } - private bool CanConvertLocalHeroDeathToFakeDeath(Hero? self, NetNode? net) { if (_netRole == NetRole.None || net == null || !net.IsAlive) @@ -773,137 +208,46 @@ private bool CanConvertLocalHeroDeathToFakeDeath(Hero? self, NetNode? net) try { - if (self.destroyed || self._level == null || self.spr == null) - return false; - if (self.maxLife <= 0) - return false; - } - catch - { - return false; - } - - return true; - } - - private bool ShouldEnterFakeDeathFromEarlyDeathHook(Hero self, NetNode net) - { - if (self == null || net == null) - return false; - if (_localFakeDead) - return false; - if (me == null || !ReferenceEquals(self, me)) - return false; - - // Guard against spawn/initialization lifecycle where kill/onDie may fire transiently. - try - { - if (self._level == null || self.spr == null) - return false; - if (self.maxLife <= 0) - return false; - if (self.life > 0) - return false; - } - catch - { - return false; - } - - return true; - } - - private static bool IsVanillaHeroDeathCineActive() - { - try - { - var cine = dc.pr.Game.Class.ME?.curCine; - return cine is HeroDeath || - cine is HeroDeathBase || - cine is HeroDeathContinue || - cine is HeroDeathRespawn || - cine is HeroDeathDLCP; + if (self.destroyed || self._level == null || self.spr == null) + return false; + if (self.maxLife <= 0) + return false; } catch { return false; } - } - private bool ShouldSuppressVanillaHeroDeathCinematic(Hero? lostBody) - { - return _netRole != NetRole.None && - _net != null && - _net.IsAlive && - me != null && - lostBody != null && - ReferenceEquals(lostBody, me); + return true; } - private bool SuppressVanillaHeroDeathCinematic(Hero? lostBody, dc.GameCinematic? cine) + private bool ShouldEnterFakeDeathFromEarlyDeathHook(Hero self, NetNode net) { - if (!ShouldSuppressVanillaHeroDeathCinematic(lostBody)) + if (self == null || net == null) + return false; + if (_localFakeDead) + return false; + if (me == null || !ReferenceEquals(self, me)) return false; - if (!_localFakeDead && lostBody != null && _net != null) - EnterLocalFakeDeath(lostBody, _net); - + // Guard against spawn/initialization lifecycle where kill/onDie may fire transiently. try { - var game = dc.pr.Game.Class.ME; - if (game != null && cine != null && ReferenceEquals(game.curCine, cine)) - game.curCine = null; + if (self._level == null || self.spr == null) + return false; + if (self.maxLife <= 0) + return false; + if (self.life > 0) + return false; } catch { + return false; } - // Constructor hooks run before the vanilla cinematic object is initialized. Calling - // destroy/disposeImmediately on that half-constructed object can tear down unrelated - // hero state. Simply skip the constructor; startDeathCine/kill are already redirected. return true; } - private void Hook__HeroDeath__constructor__(Hook__HeroDeath.orig___constructor__ orig, HeroDeath e, Hero lostBody, bool fromMob) - { - if (SuppressVanillaHeroDeathCinematic(lostBody, e)) - return; - - orig(e, lostBody, fromMob); - } - - private void Hook__HeroDeathBase__constructor__(Hook__HeroDeathBase.orig___constructor__ orig, HeroDeathBase e, Hero lostBody, bool mob) - { - if (SuppressVanillaHeroDeathCinematic(lostBody, e)) - return; - - orig(e, lostBody, mob); - } - - private void Hook__HeroDeathContinue__constructor__(Hook__HeroDeathContinue.orig___constructor__ orig, HeroDeathContinue e, Hero lostBody, bool keepBody) - { - if (SuppressVanillaHeroDeathCinematic(lostBody, e)) - return; - - orig(e, lostBody, keepBody); - } - - private void Hook__HeroDeathRespawn__constructor__(Hook__HeroDeathRespawn.orig___constructor__ orig, HeroDeathRespawn e, Hero lostBody) - { - if (SuppressVanillaHeroDeathCinematic(lostBody, e)) - return; - - orig(e, lostBody); - } - - private void Hook__HeroDeathDLCP__constructor__(Hook__HeroDeathDLCP.orig___constructor__ orig, HeroDeathDLCP e, Hero lostBody, bool fromMob) - { - if (SuppressVanillaHeroDeathCinematic(lostBody, e)) - return; - - orig(e, lostBody, fromMob); - } - private void Hook_Hero_startDeathCine(Hook_Hero.orig_startDeathCine orig, Hero self) { if (IsDebugImmortalLocalHero(self)) @@ -1239,284 +583,6 @@ private void PruneRemoteDownedStates(NetNode net) global::DeadCellsMultiplayerMod.Mobs.MobsSynchronization.MobsSynchronization.NotifyPlayerCombatStateChanged("remote-down-pruned"); } - private void ApplyRemoteDownedGhostPositions(NetNode net) - { - if (net == null) - return; - - if (_remoteDowned.Count == 0) - { - DisposeAllRemoteDownedCines(); - for (int i = 0; i < clients.Length; i++) - { - var client = clients[i]; - if (client != null) - { - try { client._targetable = true; } catch { } - } - } - return; - } - - var localId = net.id; - var localLevelId = GetCurrentLevelId(); - _scratchActiveCorpseIds.Clear(); - foreach (var state in _remoteDowned.Values) - { - if (state == null || state.UserId <= 0) - continue; - if (!TryGetClientIndex(localId, state.UserId, out var index)) - { - DisposeRemoteDownedCine(state.UserId); - continue; - } - - if (!string.IsNullOrEmpty(localLevelId) && - !string.IsNullOrEmpty(state.LevelId) && - !string.Equals(state.LevelId, localLevelId, StringComparison.Ordinal)) - { - DisposeRemoteDownedCine(state.UserId); - continue; - } - - var client = clients[index]; - if (client == null) - { - // A fall/lava snapshot can dispose the remote shell because its temporary room - // marker no longer matches. Recreate it immediately for the authoritative - // same-level downed body instead of waiting for a sublevel transition. - client = EnsureClientKingSlot(index); - } - if (client == null) - { - DisposeRemoteDownedCine(state.UserId); - continue; - } - CancelPendingClientDispose(index); - - _scratchActiveCorpseIds.Add(state.UserId); - - // Never create a second corpse cinematic once the local player is also downed. - // If this remote corpse already existed (the local player died second), keep and - // update that single cinematic; otherwise the brief all-down state needs no new one. - RemoteDownedCorpse? cine = null; - if (_remoteDownedCines.TryGetValue(state.UserId, out var existingCine) && existingCine != null) - cine = existingCine; - else if (!_localFakeDead) - cine = EnsureRemoteDownedCine(state, client); - - if (cine != null) - { - try - { - cine.UpdateTarget( - state.X, - state.Y, - client.dir, - state.HasHeadPosition ? state.HeadX : null, - state.HasHeadPosition ? state.HeadY : null, - state.HasHeadAnim ? state.HeadAnim : null); - } - catch { DisposeRemoteDownedCine(state.UserId); } - } - - try { client._targetable = false; } catch { } - try { client.setPosPixel(state.X, state.Y - DownedGhostBodyYOffsetPx); } catch { } - - rLastX[index] = state.X; - rLastY[index] = state.Y - DownedGhostBodyYOffsetPx; - } - - if (_remoteDownedCines.Count > 0) - { - _scratchStaleCorpseIds.Clear(); - foreach (var pair in _remoteDownedCines) - { - if (!_scratchActiveCorpseIds.Contains(pair.Key)) - _scratchStaleCorpseIds.Add(pair.Key); - } - - for (int i = 0; i < _scratchStaleCorpseIds.Count; i++) - DisposeRemoteDownedCine(_scratchStaleCorpseIds[i]); - } - } - - private bool IsRemoteDownedVisibleInCurrentLevel(int userId, string? localLevelId) - { - if (userId <= 0 || !_remoteDowned.TryGetValue(userId, out var state) || state == null) - return false; - - if (string.IsNullOrWhiteSpace(localLevelId) || string.IsNullOrWhiteSpace(state.LevelId)) - return true; - - return string.Equals(localLevelId, state.LevelId, StringComparison.Ordinal); - } - - private bool IsRemoteReviveVisibilityGraceActive(int userId) - { - if (userId <= 0 || !_remoteReviveVisibilityGraceUntilTicks.TryGetValue(userId, out var untilTicks)) - return false; - - if (Stopwatch.GetTimestamp() < untilTicks) - return true; - - _remoteReviveVisibilityGraceUntilTicks.Remove(userId); - return false; - } - - private void BeginRemoteReviveVisibilityRecovery( - int userId, - int slot, - GhostKing? client, - double x, - double y) - { - if (userId <= 0) - return; - - _remoteReviveVisibilityGraceUntilTicks[userId] = Stopwatch.GetTimestamp() + - (long)(Stopwatch.Frequency * RemoteReviveVisibilityGraceSeconds); - _remoteLastDoorMarkers.Remove(userId); - - if (slot < 0 || slot >= clients.Length) - return; - - CancelPendingClientDispose(slot); - clientLastDownedOffsets[slot] = false; - - if (client == null) - return; - - var unusable = false; - try { unusable = client.destroyed; } catch { } - try - { - if (!unusable && me?._level != null && client._level != null && - !ReferenceEquals(client._level, me._level)) - { - unusable = true; - } - } - catch - { - } - try - { - if (!unusable && client.spr == null) - unusable = true; - } - catch - { - } - - if (unusable) - { - DisposeClientSlot(slot, clearIdentity: false); - return; - } - - RestoreRemoteKingRenderAfterRevive(slot, client, x, y, "down-state-up"); - } - - private void RestoreRemoteKingRenderAfterRevive( - int slot, - GhostKing client, - double x, - double y, - string reason) - { - if (client == null || slot < 0 || slot >= clients.Length) - return; - - try - { - if (double.IsFinite(x) && double.IsFinite(y)) - { - client.setPosPixel(x, y); - rLastX[slot] = x; - rLastY[slot] = y; - } - } - catch - { - } - - var wasOutOfGame = false; - try { wasOutOfGame = client.isOutOfGame; } catch { } - try { client.lastOutOfGame = false; } catch { } - try { client.isOutOfGame = false; } catch { } - try { client.isOnScreen = true; } catch { } - try - { - if (client.onScreenRecent < 1200.0) - client.onScreenRecent = 1200.0; - } - catch { } - if (wasOutOfGame) - { - try { client.onOutOfGameChange(); } catch { } - } - try { client.visible = true; } catch { } - try { client.spr?.set_visible(true); } catch { } - try { client._targetable = true; } catch { } - - try { EnsureGhostKingRenderSafe(client, "remote-revive:" + reason, detachForTransition: false); } catch { } - - if (clientHeads[slot] == null || client.head == null) - ScheduleGhostHeadRecreate(slot, immediate: true); - MarkGhostHeadDirty(slot, immediate: true); - } - - private RemoteDownedCorpse? EnsureRemoteDownedCine(RemoteDownedState state, GhostKing client) - { - if (state == null || client == null || me == null) - return null; - - if (_remoteDownedCines.TryGetValue(state.UserId, out var existing)) - { - if (existing != null) - return existing; - - _remoteDownedCines.Remove(state.UserId); - } - - try - { - var previousCine = dc.pr.Game.Class.ME?.curCine; - var created = new RemoteDownedCorpse(me, client, state.X, state.Y, client.dir, previousCine); - _remoteDownedCines[state.UserId] = created; - return created; - } - catch - { - _remoteDownedCines.Remove(state.UserId); - return null; - } - } - - private void DisposeRemoteDownedCine(int userId) - { - if (!_remoteDownedCines.TryGetValue(userId, out var cine) || cine == null) - return; - - _remoteDownedCines.Remove(userId); - try { cine.destroy(); } catch { } - try { cine.disposeImmediately(); } catch { } - } - - private void DisposeAllRemoteDownedCines() - { - if (_remoteDownedCines.Count == 0) - return; - - _scratchStaleCorpseIds.Clear(); - foreach (var id in _remoteDownedCines.Keys) - _scratchStaleCorpseIds.Add(id); - - for (int i = 0; i < _scratchStaleCorpseIds.Count; i++) - DisposeRemoteDownedCine(_scratchStaleCorpseIds[i]); - } - private bool HasAliveRemoteTeammate(NetNode net) { var localId = net.id; @@ -2026,43 +1092,6 @@ private void ResetReviveHold() _reviveHoldStartedTicks = 0; } - private void ShowReviveHintFor(int userId) - { - if (_remoteDownedCines.Count == 0) - return; - - foreach (var pair in _remoteDownedCines) - { - var cine = pair.Value; - if (cine == null) - continue; - - try - { - if (pair.Key == userId) - cine.SetInteractionLabel(Localize(ReviveHintText)); - else - cine.SetInteractionLabel(null); - } - catch - { - } - } - } - - private void ClearReviveHints() - { - if (_remoteDownedCines.Count == 0) - return; - - foreach (var cine in _remoteDownedCines.Values) - { - if (cine == null) - continue; - try { cine.SetInteractionLabel(null); } catch { } - } - } - private bool TryConsumeOneFlask(Hero hero) { if (hero == null) @@ -2158,35 +1187,6 @@ private string GetCurrentLevelId() return string.Empty; } - private void StartLocalDeadCine(Hero hero) - { - if (hero == null) - return; - - if (_localDeadCine != null) - return; - - try - { - _localDeadCine = new DeadBase(hero, ModEntry.GetPrimaryClient()); - } - catch - { - _localDeadCine = null; - } - } - - private void StopLocalDeadCine() - { - var cine = _localDeadCine; - _localDeadCine = null; - if (cine == null) - return; - - try { cine.destroy(); } catch { } - try { cine.disposeImmediately(); } catch { } - } - private void ResetFakeDeathState( bool unlockLocalHero, bool sendNetworkUpState, @@ -2365,38 +1365,6 @@ private void ResetAllDownedGameOverState() _allDownedRestartAtTicks = 0; } - private bool TryUpdateDownedPositionFromCorpse(double corpseX, double corpseY) - { - // Co-op corpses are pinned to the authoritative revive point. Never let a one-frame - // corpse physics step move the gameplay anchor downward or through floor tiles. - if (_localFakeDead && ShouldAnchorLocalDownedCorpse()) - return false; - - if (!double.IsFinite(corpseX) || !double.IsFinite(corpseY)) - return false; - - if (!_hasLocalDownedAnchor) - { - _localDownedAnchorX = _localDownedX; - _localDownedAnchorY = _localDownedY; - _hasLocalDownedAnchor = true; - } - - var dx = corpseX - _localDownedAnchorX; - var dy = corpseY - _localDownedAnchorY; - var distSq = dx * dx + dy * dy; - if (distSq > DownedCorpseMaxDriftSq) - return false; - - _localDownedX = corpseX; - _localDownedY = corpseY; - _localHeldX = _localDownedX; - _localHeldY = _localDownedY; - _localDownedAnchorX = corpseX; - _localDownedAnchorY = corpseY; - return true; - } - private static void EnsureHeroVisibilityAfterRoomChange(Hero? hero) { if (hero == null) @@ -2448,3 +1416,4 @@ internal static void ResetDownedPlayersForRestart() } } } + diff --git a/GameDataSync/GameDataSync.LevelGraph.cs b/GameDataSync/GameDataSync.LevelGraph.cs new file mode 100644 index 0000000..2feb730 --- /dev/null +++ b/GameDataSync/GameDataSync.LevelGraph.cs @@ -0,0 +1,417 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using dc.level; +using ModCore.Utilities; +using Rand = dc.libs.Rand; + +namespace DeadCellsMultiplayerMod +{ + internal partial class GameDataSync + { + private static readonly object _levelGraphLock = new(); + private static readonly Dictionary _remoteLevelGraphs = new(StringComparer.Ordinal); + private static long _nextLevelGraphSequence; + private static long _lastReceivedLevelGraphSequence; + private const int MaxRemoteLevelGraphPayloadChars = 1_000_000; + private const int MaxRemoteLevelGraphNodes = 4096; + private const int MaxCachedRemoteLevelGraphs = 8; + private const int RemoteLevelGraphTtlMs = 60_000; + + private sealed class LevelGraphSync + { + public int V { get; set; } = 1; + public long Seq { get; set; } + public string LevelId { get; set; } = string.Empty; + [JsonIgnore] + public long ReceivedAtTick { get; set; } + public string? RootUid { get; set; } + public int ZLinkId { get; set; } + public double? PostGraphRandSeed { get; set; } + public List Nodes { get; set; } = new(); + } + + private sealed class LevelGraphNodeSync + { + public string Uid { get; set; } = string.Empty; + public string? ParentUid { get; set; } + public string? SubTeleportUid { get; set; } + public bool IsZRoot { get; set; } + public string RType { get; set; } = string.Empty; + public int Group { get; set; } + public int Id { get; set; } + public int Flags { get; set; } + public string? ForcedTemplateId { get; set; } + public string? ExitLevel { get; set; } + public string? ExitName { get; set; } + public int? ExitColor { get; set; } + public int ChildPriority { get; set; } + public int X { get; set; } + public int Y { get; set; } + public int SpawnDistance { get; set; } + public double FillerWeight { get; set; } + public int? ParentLinkConstraint { get; set; } + public List? ChildrenUids { get; set; } + public List? ZChildrenUids { get; set; } + public List? Npcs { get; set; } + public List? ZLinks { get; set; } + public LevelGraphGenDataSync? GenData { get; set; } + } + + private sealed class LevelGraphZLinkSync + { + public int Id { get; set; } + public string DestUid { get; set; } = string.Empty; + public string? DoorId { get; set; } + public int? ContentClue { get; set; } + } + + private sealed class LevelGraphGenDataSync + { + public string? SpecificBiome { get; set; } + public bool? ZDoorLock { get; set; } + public bool? ForcePauseTimer { get; set; } + public bool? ShouldBeFlipped { get; set; } + public int? GenSubTeleportTo { get; set; } + public LevelGraphZDoorTypeSync? ZDoorType { get; set; } + } + + private sealed class LevelGraphZDoorTypeSync + { + public int RawIndex { get; set; } + public int? IntParam0 { get; set; } + public double? DoubleParam0 { get; set; } + } + + public static void ReceiveLevelGraph(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + return; + if (payload.Length > MaxRemoteLevelGraphPayloadChars) + { + _log?.Warning("[NetMod] Rejected oversized level graph payload ({Length} chars)", payload.Length); + return; + } + + try + { + var graph = JsonSerializer.Deserialize(payload); + if (graph == null || string.IsNullOrWhiteSpace(graph.LevelId)) + return; + if (graph.LevelId.Length > 128 || graph.Nodes == null || graph.Nodes.Count == 0 || graph.Nodes.Count > MaxRemoteLevelGraphNodes) + { + _log?.Warning( + "[NetMod] Rejected invalid level graph level={LevelId} nodes={Count}", + graph.LevelId, + graph.Nodes?.Count ?? -1); + return; + } + + graph.ReceivedAtTick = Environment.TickCount64; + lock (_levelGraphLock) + { + PruneRemoteLevelGraphsLocked(graph.ReceivedAtTick); + if (graph.Seq > 0 && graph.Seq <= _lastReceivedLevelGraphSequence) + { + _log?.Debug( + "[NetMod] Ignored stale level graph seq={Seq} last={Last} level={LevelId}", + graph.Seq, + _lastReceivedLevelGraphSequence, + graph.LevelId); + return; + } + + if (graph.Seq > 0) + _lastReceivedLevelGraphSequence = graph.Seq; + _remoteLevelGraphs[graph.LevelId] = graph; + TrimRemoteLevelGraphCacheLocked(); + Monitor.PulseAll(_levelGraphLock); + } + + _log?.Information( + "[NetMod] Received level graph for {LevelId} ({Count} nodes, seq={Seq})", + graph.LevelId, + graph.Nodes.Count, + graph.Seq); + + // Lobby auto-start waits on HasPendingRemoteLevelGraph; re-arm if seed/exec + // already arrived before this LGRAPH. + GameMenu.NotifyClientLaunchPrerequisiteProgress(); + + var reason = LevelReloadReason.GraphUpdated; + // Graph and boss-rune packets can arrive in either order. Fold a pending boss-rune + // change into the same coalesced reload so only one reloadAfterBossRuneModif runs. + if (HasPendingBossRuneReloadForLevel(graph.LevelId)) + reason |= LevelReloadReason.BossRuneChanged; + ScheduleLevelReload(graph.LevelId, reason, payload); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to parse level graph sync: {Message}", ex.Message); + } + } + + private static void PruneRemoteLevelGraphsLocked(long now) + { + if (_remoteLevelGraphs.Count == 0) + return; + + List? expired = null; + foreach (var pair in _remoteLevelGraphs) + { + var receivedAt = pair.Value?.ReceivedAtTick ?? 0; + if (receivedAt > 0 && now - receivedAt > RemoteLevelGraphTtlMs) + { + expired ??= new List(); + expired.Add(pair.Key); + } + } + + if (expired == null) + return; + foreach (var key in expired) + _remoteLevelGraphs.Remove(key); + } + + private static void TrimRemoteLevelGraphCacheLocked() + { + while (_remoteLevelGraphs.Count > MaxCachedRemoteLevelGraphs) + { + string? oldestKey = null; + long oldestTick = long.MaxValue; + foreach (var pair in _remoteLevelGraphs) + { + var tick = pair.Value?.ReceivedAtTick ?? 0; + if (oldestKey == null || tick < oldestTick) + { + oldestKey = pair.Key; + oldestTick = tick; + } + } + + if (oldestKey == null) + break; + _remoteLevelGraphs.Remove(oldestKey); + } + } + + internal static bool HasPendingRemoteLevelGraph(string? levelId) + { + if (string.IsNullOrWhiteSpace(levelId)) + return false; + + lock (_levelGraphLock) + { + return _remoteLevelGraphs.ContainsKey(levelId); + } + } + + private static bool HasRemoteLevelGraphCached(string levelId) + { + if (string.IsNullOrWhiteSpace(levelId)) + return false; + + lock (_levelGraphLock) + { + return _remoteLevelGraphs.ContainsKey(levelId); + } + } + + public static void SendLevelGraph(string levelId, RoomNode? root, LevelStruct? graph, Rand? rng, NetNode? net) + { + if (net == null || !net.IsAlive) + { + _log?.Information("[NetMod] Skip level graph send for {LevelId}: net unavailable", levelId); + return; + } + + if (graph == null || string.IsNullOrWhiteSpace(levelId)) + { + _log?.Warning("[NetMod] Skip level graph send: invalid graph/levelId (level={LevelId})", levelId); + return; + } + + try + { + var sync = CaptureLevelGraph(levelId, graph); + if (sync == null) + { + _log?.Warning("[NetMod] CaptureLevelGraph returned null for {LevelId} (allLen={AllLen})", levelId, graph.all?.length ?? -1); + return; + } + + if (sync.Nodes.Count == 0) + { + _log?.Warning("[NetMod] Captured empty level graph for {LevelId} (allLen={AllLen})", levelId, graph.all?.length ?? -1); + return; + } + + try + { + sync.RootUid = root?.uid?.ToString(); + } + catch + { + } + + try + { + if (rng != null) + sync.PostGraphRandSeed = rng.seed; + } + catch + { + } + + sync.Seq = Interlocked.Increment(ref _nextLevelGraphSequence); + var json = JsonSerializer.Serialize(sync); + net.SendLevelGraph(levelId, json); + _log?.Information("[NetNode] Sent level graph for {LevelId} ({Count} nodes, seq={Seq}, postRand={PostRand})", + levelId, + sync.Nodes.Count, + sync.Seq, + sync.PostGraphRandSeed.HasValue ? sync.PostGraphRandSeed.Value.ToString(CultureInfo.InvariantCulture) : "n/a"); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to send level graph for {LevelId}: {Message}", levelId, ex.Message); + } + } + + public static bool TryApplyRemoteLevelGraph(string levelId, LevelStruct? graph, Rand? rng, int timeoutMs, out RoomNode? appliedRoot, out string reason) + { + reason = string.Empty; + appliedRoot = null; + if (graph == null || string.IsNullOrWhiteSpace(levelId)) + { + reason = "invalid arguments"; + return false; + } + + if (!TryWaitGetRemoteLevelGraph(levelId, timeoutMs, out var remoteGraph)) + { + reason = "remote graph not received"; + return false; + } + + if (remoteGraph == null || remoteGraph.Nodes == null || remoteGraph.Nodes.Count == 0) + { + reason = "remote graph payload empty"; + return false; + } + + var applied = ApplyLevelGraph(graph, remoteGraph, out appliedRoot, out reason); + if (applied && rng != null && remoteGraph.PostGraphRandSeed.HasValue) + { + try + { + rng.seed = remoteGraph.PostGraphRandSeed.Value; + } + catch (Exception ex) + { + applied = false; + reason = "failed to apply post-graph rand seed: " + ex.Message; + } + } + + ConsumeRemoteLevelGraph(levelId); + if (!applied) + return false; + + // FTL / HL cast correlation: pair with host "Sent level graph" and client combat/restart logs. + // Repro surface: client PrisonStart (or any level) with remote graph, host_restart, then dive/combat. + try + { + var rootUid = remoteGraph.RootUid ?? "?"; + try + { + if (appliedRoot != null) + rootUid = appliedRoot.uid?.ToString() ?? rootUid; + } + catch + { + } + + _log?.Information( + "[NetMod] Remote level graph applied (FTL correlation) levelId={LevelId} nodes={Count} rootUid={RootUid} postRandSeed={PostRand} postRandApplied={PostRandApplied}", + levelId, + remoteGraph.Nodes.Count, + rootUid, + remoteGraph.PostGraphRandSeed.HasValue + ? remoteGraph.PostGraphRandSeed.Value.ToString(CultureInfo.InvariantCulture) + : "n/a", + rng != null && remoteGraph.PostGraphRandSeed.HasValue); + } + catch + { + } + + return true; + } + + private static bool TryWaitGetRemoteLevelGraph(string levelId, int timeoutMs, out LevelGraphSync? graph) + { + graph = null; + if (string.IsNullOrWhiteSpace(levelId)) + return false; + + var deadline = Environment.TickCount64 + Math.Max(0, timeoutMs); + lock (_levelGraphLock) + { + while (true) + { + var now = Environment.TickCount64; + PruneRemoteLevelGraphsLocked(now); + if (_remoteLevelGraphs.TryGetValue(levelId, out var found)) + { + graph = found; + return true; + } + + var remaining = deadline - now; + if (timeoutMs <= 0 || remaining <= 0) + return false; + + // LGRAPH is parsed by the network fast path, so waiting on the graph lock is + // enough. Processing arbitrary main-thread actions from inside LevelGen caused + // re-entrant level/dispose/ghost work while the graph was only half built. + Monitor.Wait(_levelGraphLock, (int)Math.Min(remaining, 250)); + } + } + } + + private static void ConsumeRemoteLevelGraph(string levelId) + { + if (string.IsNullOrWhiteSpace(levelId)) + return; + + lock (_levelGraphLock) + { + _remoteLevelGraphs.Remove(levelId); + } + } + + internal static void ResetTransientNetworkState() + { + lock (_levelSeedLock) + { + _remoteLevelId = null; + _remoteLevelSeed = null; + } + + lock (_levelGraphLock) + { + _remoteLevelGraphs.Clear(); + _lastReceivedLevelGraphSequence = 0; + Monitor.PulseAll(_levelGraphLock); + } + Interlocked.Exchange(ref _nextLevelGraphSequence, 0); + + ResetLevelReloadState(); + ClearPendingBossRuneReloadState(); + } + } +} diff --git a/GameDataSync/GameDataSync.Rest.cs b/GameDataSync/GameDataSync.LevelGraphCapture.cs similarity index 57% rename from GameDataSync/GameDataSync.Rest.cs rename to GameDataSync/GameDataSync.LevelGraphCapture.cs index fed4ec8..045241e 100644 --- a/GameDataSync/GameDataSync.Rest.cs +++ b/GameDataSync/GameDataSync.LevelGraphCapture.cs @@ -1,25 +1,15 @@ -using DeadCellsMultiplayerMod.Interface.ModuleInitializing; -using ModCore.Events; using dc; using dc.haxe.ds; using dc.level; -using dc.pr; -using dc.tool; using Hashlink.Virtuals; using HaxeProxy.Runtime; using ModCore.Utilities; using System; using System.Collections.Generic; using System.Globalization; -using System.Runtime.InteropServices; using System.Reflection; -using System.Text; -using System.Text.Json; -using System.Threading; using dc.haxe; -using dc.haxe.io; using dc.hl.types; -using Rand = dc.libs.Rand; namespace DeadCellsMultiplayerMod @@ -27,703 +17,6 @@ namespace DeadCellsMultiplayerMod internal partial class GameDataSync { - private static void CopyStoryVisitedLoreRoomsToSet(dynamic? map, HashSet target) - { - target.Clear(); - if (map == null) - return; - - try - { - var keys = map.keys.Invoke(); - while (keys.hasNext.Invoke()) - { - var keyObj = keys.next.Invoke(); - if (keyObj == null) - continue; - - var key = keyObj.ToString(); - if (string.IsNullOrWhiteSpace(key)) - continue; - - var raw = map.get.Invoke(keyObj); - var visited = raw is bool b ? b : ToInt(raw) != 0; - if (visited) - target.Add(key); - } - } - catch - { - } - } - - private static void CopyStoryPlannedLoresToList(ArrayBytes_Int? source, List target) - { - target.Clear(); - if (source == null) - return; - - var seen = new HashSet(); - for (var i = 0; i < source.length; i++) - { - int planned; - try - { - planned = ToInt(source.getDyn(i)); - } - catch - { - continue; - } - - if (seen.Add(planned)) - target.Add(planned); - } - } - - private static StringMap BuildCountersMap(Dictionary values) - { - var map = new StringMap(); - foreach (var kv in values) - map.set(kv.Key.AsHaxeString(), kv.Value); - return map; - } - - private static EnumValueMap BuildNpcProgressMap(Dictionary values) - { - var map = new EnumValueMap(); - foreach (var kv in values) - { - var npcId = CreateNpcIdFromIndex(kv.Key); - if (npcId == null) - continue; - - map.set(npcId, kv.Value); - } - return map; - } - - private static void ApplyStoryStringIntMap(dynamic? map, Dictionary values) - { - if (map == null) - return; - - try - { - var keysToRemove = new List(); - var keys = map.keys.Invoke(); - while (keys.hasNext.Invoke()) - { - var keyObj = keys.next.Invoke(); - if (keyObj != null) - keysToRemove.Add(keyObj); - } - - for (var i = 0; i < keysToRemove.Count; i++) - { - try { map.remove.Invoke(keysToRemove[i]); } catch { } - } - } - catch - { - } - - foreach (var kv in values) - { - try - { - map.set.Invoke(kv.Key.AsHaxeString(), kv.Value); - } - catch - { - } - } - } - - private static void ApplyStoryVisitedLoreRoomsMap(dynamic? map, HashSet values) - { - if (map == null) - return; - - try - { - var keysToRemove = new List(); - var keys = map.keys.Invoke(); - while (keys.hasNext.Invoke()) - { - var keyObj = keys.next.Invoke(); - if (keyObj != null) - keysToRemove.Add(keyObj); - } - - for (var i = 0; i < keysToRemove.Count; i++) - { - try { map.remove.Invoke(keysToRemove[i]); } catch { } - } - } - catch - { - } - - foreach (var key in values) - { - try - { - map.set.Invoke(key.AsHaxeString(), 1); - } - catch - { - } - } - } - - private static ArrayBytes_Int BuildStoryPlannedLoresArray(List values) - { - var arr = new ArrayBytes_Int(); - var seen = new HashSet(); - for (var i = 0; i < values.Count; i++) - { - var planned = values[i]; - if (!seen.Add(planned)) - continue; - - try - { - arr.push(planned); - } - catch - { - try - { - arr.pushDyn(planned); - } - catch - { - } - } - } - - return arr; - } - - private static void ApplyStoryState( - User user, - Dictionary counters, - Dictionary npcProgress, - int storyDataVersion, - Dictionary loreRoomRunIds, - HashSet visitedLoreRooms, - List plannedLores) - { - user.story = BuildStoryManager( - counters, - npcProgress, - storyDataVersion, - loreRoomRunIds, - visitedLoreRooms, - plannedLores); - user.counters = BuildCountersMap(counters); - user.npcs = BuildNpcProgressMap(npcProgress); - } - - private static void ClearUserStoryState(User user) - { - user.story = null; - user.counters = new StringMap(); - user.npcs = new EnumValueMap(); - } - - private static StoryManager BuildStoryManager( - Dictionary counters, - Dictionary npcProgress, - int storyDataVersion, - Dictionary loreRoomRunIds, - HashSet visitedLoreRooms, - List plannedLores) - { - var story = new StoryManager(); - try - { - story.onReload(); - } - catch - { - } - - dynamic dynStory = story; - dynStory.counters = BuildCountersMap(counters); - dynStory.npcProgresses = BuildNpcProgressMap(npcProgress); - ApplyStoryStringIntMap(dynStory.loreRoomRunIds, loreRoomRunIds); - ApplyStoryVisitedLoreRoomsMap(dynStory.visitedLoreRooms, visitedLoreRooms); - dynStory.plannedLores = BuildStoryPlannedLoresArray(plannedLores); - dynStory.storyDataVersion = storyDataVersion; - return story; - } - - private static ArrayObj? CloneItemProgress(ArrayObj? source) - { - if (source == null) - return null; - - var arr = ArrayUtils.CreateDyn(); - for (int i = 0; i < source.length; i++) - { - var item = source.getDyn(i) as ItemProgress; - if (item == null) - continue; - var copy = new ItemProgress(item.itemId); - copy.investedCells = item.investedCells; - copy.isNew = item.isNew; - copy.unlocked = item.unlocked; - copy.__uid = item.__uid; - arr.array.pushDyn(copy); - } - return (ArrayObj)arr.array; - } - - private static ArrayObj? CloneMetaProgress(ArrayObj? source) - { - if (source == null) - return null; - - var arr = ArrayUtils.CreateDyn(); - for (int i = 0; i < source.length; i++) - { - var item = source.getDyn(i) as MetaProgress; - if (item == null) - continue; - - var copy = new MetaProgress(item.itemId); - copy.investedCells = item.investedCells; - copy.isNew = item.isNew; - copy.unlocked = item.unlocked; - copy.upgradeLevel = item.upgradeLevel; - copy.n = item.n; - copy.done = item.done; - copy.metaLevel = item.metaLevel; - arr.array.pushDyn(copy); - } - - return (ArrayObj)arr.array; - } - - private static ArrayObj? CloneItemList(ArrayObj? source) - { - if (source == null) - return null; - - var arr = ArrayUtils.CreateDyn(); - for (int i = 0; i < source.length; i++) - { - object? item = source.getDyn(i); - arr.array.pushDyn(item); - } - return (ArrayObj)arr.array; - } - - private static IntMap? CloneIntMap(IntMap? source) - { - if (source == null) - return null; - - var map = new IntMap(); - try - { - var keys = source.keys(); - while (keys.hasNext.Invoke()) - { - var key = keys.next.Invoke(); - map.set(key, ToInt(source.get(key))); - } - } - catch - { - } - - return map; - } - - internal static bool TryBuildSafeSaveUser(User currentUser, bool onlyGameData, out User? saveUser) - { - saveUser = null; - if (currentUser == null || string.IsNullOrWhiteSpace(_origProgressPayload)) - return false; - - try - { - var cloneBytes = Save.Class.genSave.Invoke(currentUser, onlyGameData); - if (cloneBytes == null) - return false; - - var clone = Save.Class.readSave.Invoke(cloneBytes); - if (clone == null) - return false; - - if (!TryApplyProgressPayload(clone, _origProgressPayload)) - return false; - - saveUser = clone; - return true; - } - catch - { - return false; - } - } - - private static bool TryApplyProgressPayload(User target, string? payload) - { - if (target == null || string.IsNullOrWhiteSpace(payload)) - return false; - - if (!TryDeserializeProgressUser(payload, out var source) || source == null) - return false; - - ApplyProgressSnapshot(target, source); - return true; - } - - private static void ApplyProgressSnapshot(User target, User source) - { - if (target == null || source == null) - return; - - var preservedHeroSkin = !string.IsNullOrWhiteSpace(_origHeroSkin) - ? _origHeroSkin - : CleanSkin(target.heroSkin?.ToString()); - var preservedHeroHeadSkin = !string.IsNullOrWhiteSpace(_origHeroHeadSkin) - ? _origHeroHeadSkin - : CleanSkin(target.heroHeadSkin?.ToString()); - - var legacyCounters = new Dictionary(StringComparer.Ordinal); - CopyCountersToDictionary(source.counters, legacyCounters); - var legacyNpcProgress = new Dictionary(); - CopyNpcProgressToDictionary(source.npcs, legacyNpcProgress); - var sourceStory = source.story; - if (sourceStory != null) - { - if (sourceStory.counters == null) - sourceStory.counters = BuildCountersMap(legacyCounters); - if (sourceStory.npcProgresses == null) - sourceStory.npcProgresses = BuildNpcProgressMap(legacyNpcProgress); - } - - target.flags = source.flags; - target.userId = source.userId; - target.deathMoney = source.deathMoney; - target.deathCells = source.deathCells; - target.bossRuneActivated = GetEffectiveBossRune(source); - target.tutorial = source.tutorial; - target.counters = sourceStory?.counters ?? BuildCountersMap(legacyCounters); - target.npcs = sourceStory?.npcProgresses ?? BuildNpcProgressMap(legacyNpcProgress); - target.story = sourceStory; - target.itemMeta = null; - target.userStats = source.userStats; - target.activeMods = CloneItemList(source.activeMods); - target.meta = CloneMetaProgress(source.meta); - target.metaItems = CloneItemList(source.metaItems); - target.achievements = CloneItemList(source.achievements); - target.localAchievements = CloneItemList(source.localAchievements); - target.deathItem = source.deathItem; - target.consecutiveCompletedRuns = source.consecutiveCompletedRuns; - ApplyHeroCosmetics(target, preservedHeroSkin, preservedHeroHeadSkin); - MirrorStoryStateToSaveUser(target); - - try - { - target.userStats?.init(); - } - catch - { - } - - try - { - target.onReload(); - } - catch - { - } - - ApplyHeroCosmetics(target, preservedHeroSkin, preservedHeroHeadSkin); - MirrorStoryStateToSaveUser(target); - - try - { - target.story?.onReload(); - } - catch - { - } - - var targetMeta = EnsureItemMeta(target, target.itemMeta); - targetMeta._user = target; - if (source.itemMeta != null) - { - targetMeta.itemProgress = CloneItemProgress(source.itemMeta.itemProgress) ?? EnsureArray(targetMeta.itemProgress); - targetMeta.permanentItems = CloneItemList(source.itemMeta.permanentItems) ?? EnsureArray(targetMeta.permanentItems); - targetMeta.forgeInvestedCells = CloneIntMap(source.itemMeta.forgeInvestedCells) ?? new IntMap(); - } - - try - { - targetMeta.onReload(); - } - catch - { - } - - try - { - targetMeta.revealAllBaseItems(); - } - catch - { - } - - try - { - targetMeta.cleanDuplicatedItemProgress(); - } - catch - { - } - - target.itemMeta = targetMeta; - - try - { - target.br_setActivated(GetEffectiveBossRune(source)); - } - catch - { - target.bossRuneActivated = GetEffectiveBossRune(source); - } - } - - private static void MirrorStoryStateToSaveUser(User user) - { - if (user == null) - return; - - try - { - var saveUser = user.mainGameData?.sUser; - if (saveUser == null || ReferenceEquals(saveUser, user)) - return; - - saveUser.story = user.story; - saveUser.counters = user.counters; - saveUser.npcs = user.npcs; - } - catch - { - } - } - - private static void ApplyHeroCosmetics(User? user, string? heroSkin, string? heroHeadSkin) - { - if (user == null) - return; - - if (!string.IsNullOrWhiteSpace(heroSkin)) - user.heroSkin = heroSkin.AsHaxeString(); - - if (!string.IsNullOrWhiteSpace(heroHeadSkin)) - user.heroHeadSkin = heroHeadSkin.AsHaxeString(); - } - - private static string? BuildProgressPayload(User user) - { - if (user == null) - return null; - - try - { - var prepared = false; - try - { - prepared = user.prepareSave(); - } - catch (Exception ex) - { - _log?.Debug(ex, "[NetMod] user.prepareSave() threw while building progress payload"); - } - - if (!prepared) - _log?.Debug("[NetMod] user.prepareSave() returned false; trying packed progress payload anyway"); - - var saveBytes = Save.Class.genSave.Invoke(user, true); - if (saveBytes != null) - { - var saveRaw = CopyBytesToManaged(saveBytes); - return saveRaw.Length == 0 ? "P2|" : "P2|" + Convert.ToBase64String(saveRaw); - } - - _log?.Warning("[NetMod] Failed to build packed progress payload: save bytes were null"); - } - catch (Exception ex) - { - _log?.Warning(ex, "[NetMod] Failed to build packed progress payload"); - } - - if (TrySerializeUserBytes(user, out var userBytes) && userBytes != null) - { - var userRaw = CopyBytesToManaged(userBytes); - return userRaw.Length == 0 ? "P1|" : "P1|" + Convert.ToBase64String(userRaw); - } - - _log?.Warning("[NetMod] Failed to build progress payload from both packed-save and raw-user paths"); - return null; - } - - private static bool TryDeserializeProgressUser(string payload, out User? user) - { - user = null; - if (string.IsNullOrWhiteSpace(payload)) - return false; - - var isPackedSavePayload = payload.StartsWith("P2|", StringComparison.Ordinal); - var encoded = - payload.StartsWith("P2|", StringComparison.Ordinal) ? payload[3..] : - payload.StartsWith("P1|", StringComparison.Ordinal) ? payload[3..] : - payload; - byte[] raw; - try - { - raw = string.IsNullOrEmpty(encoded) ? Array.Empty() : Convert.FromBase64String(encoded); - } - catch - { - return false; - } - - if (!TryCreateHaxeBytes(raw, out var bytes) || bytes == null) - return false; - - try - { - if (isPackedSavePayload) - { - user = Save.Class.readSave.Invoke(bytes); - return user != null; - } - - var serializer = new dc.hxbit.Serializer - { - refs = new IntMap() - }; - var position = 0; - serializer.beginLoad(bytes, Ref.From(ref position)); - user = (User)(object)serializer.getRef(User.Class, User.Class.__clid); - serializer.endLoad(); - user?.onReload(); - return user != null; - } - catch (Exception ex) - { - _log?.Warning(ex, "[NetMod] Failed to deserialize progress payload"); - user = null; - return false; - } - } - - private static bool TrySerializeUserBytes(User user, out Bytes? bytes) - { - bytes = null; - if (user == null) - return false; - - try - { - var serializer = new dc.hxbit.Serializer(); - serializer.beginSave(); - var userRef = user.unnamedField0; - if (userRef == null) - userRef = user.unnamedField0 = (virtual___uid_getCLID_getSerializeSchema_serialize_unserialize_unserializeInit_)(object)user; - serializer.addKnownRef(userRef); - var position = 0; - bytes = serializer.endSave(Ref.From(ref position)); - return bytes != null; - } - catch (Exception ex) - { - _log?.Warning(ex, "[NetMod] Failed to serialize raw user progress payload"); - bytes = null; - return false; - } - } - - private static byte[] CopyBytesToManaged(Bytes bytes) - { - if (bytes == null || bytes.length <= 0 || bytes.b == IntPtr.Zero) - return Array.Empty(); - - var raw = new byte[bytes.length]; - Marshal.Copy(bytes.b, raw, 0, raw.Length); - return raw; - } - - private static bool TryCreateHaxeBytes(byte[] raw, out Bytes? bytes) - { - bytes = null; - - try - { - bytes = Bytes.Class.alloc.Invoke(raw.Length); - if (bytes == null) - return false; - - if (raw.Length > 0 && bytes.b != IntPtr.Zero) - Marshal.Copy(raw, 0, bytes.b, raw.Length); - - return true; - } - catch - { - bytes = null; - return false; - } - } - - private static int ToInt(object? value) - { - if (value == null) - return 0; - - if (value is int i) - return i; - - if (value is bool b) - return b ? 1 : 0; - - if (value is IConvertible conv) - { - try - { - return conv.ToInt32(CultureInfo.InvariantCulture); - } - catch { } - } - - return 0; - } - - private static ArrayObj EnsureArray(ArrayObj? source) - { - if (source != null) - return source; - return (ArrayObj)ArrayUtils.CreateDyn().array; - } - - private static ItemMetaManager EnsureItemMeta(User user, ItemMetaManager? meta) - { - var result = meta ?? user.itemMeta ?? new ItemMetaManager(user); - result.itemProgress = EnsureArray(result.itemProgress); - result.permanentItems = EnsureArray(result.permanentItems); - return result; - } - private static LevelGraphSync? CaptureLevelGraph(string levelId, LevelStruct graph) { var sync = new LevelGraphSync diff --git a/GameDataSync/GameDataSync.LevelGraphReload.cs b/GameDataSync/GameDataSync.LevelGraphReload.cs new file mode 100644 index 0000000..e81dc20 --- /dev/null +++ b/GameDataSync/GameDataSync.LevelGraphReload.cs @@ -0,0 +1,457 @@ +using System; +using System.Collections.Generic; +using dc.cine; +using dc.en; +using dc.en.inter; +using dc.hl.types; +using dc.level; +using dc.pr; +using ModCore.Utilities; + +namespace DeadCellsMultiplayerMod +{ + internal partial class GameDataSync + { + [Flags] + private enum LevelReloadReason + { + None = 0, + GraphUpdated = 1, + BossRuneChanged = 2, + } + + private static readonly object _levelReloadLock = new(); + private static readonly object _pendingBossRuneReloadLock = new(); + private static readonly Dictionary _pendingLevelReloadReasons = + new(StringComparer.Ordinal); + private static readonly Dictionary _pendingLevelReloadPayloads = + new(StringComparer.Ordinal); + + private static string? _lastLevelReloadLevelId; + private static string? _lastLevelReloadPayload; + private static LevelReloadReason _lastLevelReloadReason; + private static long _lastLevelReloadTick; + + private static string? _pendingBossRuneReloadLevelId; + private static int _pendingBossRuneReloadValue; + private static long _pendingBossRuneReloadTick; + private static bool _hasPendingBossRuneReload; + + private const int LevelReloadThrottleMs = 3000; + private const int PendingBossRuneReloadTtlMs = 15000; + + internal static void TryScheduleBossRuneReloadForCurrentLevel() + { + var currentLevelId = TryGetCurrentLevelId(); + if (string.IsNullOrWhiteSpace(currentLevelId)) + return; + + if (!HasRemoteLevelGraphCached(currentLevelId)) + return; + + ScheduleLevelReload(currentLevelId, LevelReloadReason.BossRuneChanged, payload: null); + } + + internal static void MarkPendingBossRuneReload(int bossRune) + { + var levelId = TryGetCurrentLevelId(); + lock (_pendingBossRuneReloadLock) + { + _pendingBossRuneReloadValue = bossRune; + _pendingBossRuneReloadLevelId = levelId; + _pendingBossRuneReloadTick = Environment.TickCount64; + _hasPendingBossRuneReload = true; + } + } + + internal static void ClearPendingBossRuneReloadState() + { + lock (_pendingBossRuneReloadLock) + { + _hasPendingBossRuneReload = false; + _pendingBossRuneReloadLevelId = null; + } + } + + private static bool HasPendingBossRuneReloadForLevel(string levelId) + { + if (string.IsNullOrWhiteSpace(levelId)) + return false; + + lock (_pendingBossRuneReloadLock) + { + if (!_hasPendingBossRuneReload) + return false; + + if (Environment.TickCount64 - _pendingBossRuneReloadTick > PendingBossRuneReloadTtlMs) + { + _hasPendingBossRuneReload = false; + _pendingBossRuneReloadLevelId = null; + return false; + } + + if (string.IsNullOrWhiteSpace(_pendingBossRuneReloadLevelId)) + return true; + + return string.Equals(_pendingBossRuneReloadLevelId, levelId, StringComparison.Ordinal); + } + } + + private static void ScheduleLevelReload(string levelId, LevelReloadReason reason, string? payload) + { + if (string.IsNullOrWhiteSpace(levelId) || reason == LevelReloadReason.None) + return; + + var net = GameMenu.NetRef; + if (net == null || !net.IsAlive || net.IsHost) + return; + + lock (_levelReloadLock) + { + if (_pendingLevelReloadReasons.TryGetValue(levelId, out var existing)) + reason |= existing; + _pendingLevelReloadReasons[levelId] = reason; + + if (!string.IsNullOrWhiteSpace(payload)) + _pendingLevelReloadPayloads[levelId] = payload; + else if (!_pendingLevelReloadPayloads.ContainsKey(levelId)) + _pendingLevelReloadPayloads[levelId] = null; + } + + GameMenu.EnqueueMainThreadCoalesced("level:reload:" + levelId, () => + { + try + { + TryTriggerLevelReload(levelId); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Failed to process level reload for {LevelId}: {Message}", levelId, ex.Message); + } + }); + } + + /// + /// In-place reloadAfterBossRuneModif keeps the current hero and only regenerates the level. It must be + /// suppressed while the local player is downed/Game Over or a full-run restart is pending, otherwise the + /// host's restart-level graph reloads the old downed run in place (no heal, Game Over stuck) or crashes + /// with a Null access .curCine — instead of letting the queued launchGame restart take over. + /// + private static bool ShouldSuppressClientLevelReload() + { + try + { + if (ModEntry.IsLocalPlayerDowned()) + return true; + if (GameMenu.IsClientRestartPending()) + return true; + } + catch + { + } + + return false; + } + + private static void TryTriggerLevelReload(string graphLevelId) + { + if (string.IsNullOrWhiteSpace(graphLevelId)) + return; + + LevelReloadReason reason; + string? payload; + lock (_levelReloadLock) + { + if (!_pendingLevelReloadReasons.TryGetValue(graphLevelId, out reason) || + reason == LevelReloadReason.None) + { + _pendingLevelReloadReasons.Remove(graphLevelId); + _pendingLevelReloadPayloads.Remove(graphLevelId); + return; + } + + _pendingLevelReloadReasons.Remove(graphLevelId); + _pendingLevelReloadPayloads.TryGetValue(graphLevelId, out payload); + _pendingLevelReloadPayloads.Remove(graphLevelId); + } + + var net = GameMenu.NetRef; + if (net == null || !net.IsAlive || net.IsHost) + return; + + if (ShouldSuppressClientLevelReload()) + { + _log?.Information("[NetMod] Skipping level reload for {LevelId}: client downed/restart pending", graphLevelId); + return; + } + + var hero = ModEntry.me; + var level = hero?._level; + if (hero == null || level == null || level.map == null) + return; + + var currentLevelId = level.map.id?.ToString(); + if (!string.Equals(currentLevelId, graphLevelId, StringComparison.Ordinal)) + return; + + var applyBoss = (reason & LevelReloadReason.BossRuneChanged) != 0; + var applyGraph = (reason & LevelReloadReason.GraphUpdated) != 0; + + dc.User? user = null; + var remoteBossRune = 0; + var bossApplies = false; + if (applyBoss) + { + user = dc.Main.Class.ME?.user ?? level.game?.user; + if (user != null && + TryGetRemoteBossRune(out remoteBossRune)) + { + var localBossRune = GetEffectiveBossRune(user); + var forceByPending = ConsumePendingBossRuneReloadIfMatch(graphLevelId, remoteBossRune); + bossApplies = forceByPending || localBossRune != remoteBossRune; + if (bossApplies) + { + _log?.Information( + "[NetMod] Boss-rune graph reload candidate level={LevelId} local={LocalBossRune} remote={RemoteBossRune} pending={Pending}", + graphLevelId, + localBossRune, + remoteBossRune, + forceByPending); + } + } + } + + if (!bossApplies) + applyBoss = false; + + if (!applyBoss && !applyGraph) + return; + + // Mid-session reload regenerates via reloadAfterBossRuneModif → generateGraph apply. + // Require a cached remote graph so Path A can bind the host layout. + if (!HasRemoteLevelGraphCached(graphLevelId)) + return; + + if (!TryBeginLevelReload(graphLevelId, payload, applyBoss ? LevelReloadReason.BossRuneChanged : LevelReloadReason.GraphUpdated)) + return; + + var targetLevelId = ResolveReloadTargetLevelId(level, graphLevelId); + int offsetCx; + int offsetCy; + if (applyBoss) + { + ApplyRemoteBossRune(user!, remoteBossRune); + (offsetCx, offsetCy) = ComputeBossRuneReloadOffsets(hero, level); + ModEntry.PrepareAndDisposeRemoteKingsForBossCellReload( + "client-boss-rune-reload:" + targetLevelId); + } + else + { + (offsetCx, offsetCy) = ComputeCurrentLevelReloadOffsets(hero, level); + } + + var reload = LevelTransition.Class.reloadAfterBossRuneModif; + if (reload == null) + { + _log?.Warning("[NetMod] Missing LevelTransition.reloadAfterBossRuneModif for {LevelId}", targetLevelId); + return; + } + + _ = reload(targetLevelId.AsHaxeString(), offsetCx, offsetCy); + if (applyBoss) + { + _log?.Information( + "[NetMod] Triggered level reload for {LevelId} reason=BossRune offset=({OffsetCx},{OffsetCy}) bossRune={BossRune}", + targetLevelId, + offsetCx, + offsetCy, + remoteBossRune); + } + else + { + _log?.Information( + "[NetMod] Triggered level reload for {LevelId} reason=Graph offset=({OffsetCx},{OffsetCy})", + targetLevelId, + offsetCx, + offsetCy); + } + } + + private static bool TryBeginLevelReload(string levelId, string? payload, LevelReloadReason effectiveReason) + { + var now = Environment.TickCount64; + lock (_levelReloadLock) + { + var sameLevel = string.Equals(_lastLevelReloadLevelId, levelId, StringComparison.Ordinal); + if (sameLevel && now - _lastLevelReloadTick < LevelReloadThrottleMs) + { + // Boss-rune path throttles by level only; pure graph also matches payload. + if ((effectiveReason & LevelReloadReason.BossRuneChanged) != 0) + return false; + + if (string.Equals(_lastLevelReloadPayload, payload, StringComparison.Ordinal) && + (_lastLevelReloadReason & LevelReloadReason.GraphUpdated) != 0) + return false; + } + + _lastLevelReloadLevelId = levelId; + _lastLevelReloadPayload = payload; + _lastLevelReloadReason = effectiveReason; + _lastLevelReloadTick = now; + return true; + } + } + + private static (int OffsetCx, int OffsetCy) ComputeBossRuneReloadOffsets(Hero hero, Level level) + { + var heroCx = 0; + var heroCy = 0; + try { heroCx = hero.cx; } catch { } + try { heroCy = hero.cy; } catch { } + + var anchorRoom = TryFindBossRuneAnchorRoom(level) ?? TryGetRoomAt(level, heroCx, heroCy); + if (anchorRoom == null) + return (0, 0); + + var roomX = 0; + var roomY = 0; + try { roomX = anchorRoom.x; } catch { } + try { roomY = anchorRoom.y; } catch { } + + return (heroCx - roomX, heroCy - roomY); + } + + private static (int OffsetCx, int OffsetCy) ComputeCurrentLevelReloadOffsets(Hero hero, Level level) + { + var heroCx = 0; + var heroCy = 0; + try { heroCx = hero.cx; } catch { } + try { heroCy = hero.cy; } catch { } + + var room = TryGetRoomAt(level, heroCx, heroCy); + if (room == null) + return (0, 0); + + var roomX = 0; + var roomY = 0; + try { roomX = room.x; } catch { } + try { roomY = room.y; } catch { } + + return (heroCx - roomX, heroCy - roomY); + } + + private static Room? TryFindBossRuneAnchorRoom(Level level) + { + try + { + var entitiesByClass = level.entitiesByClass; + if (entitiesByClass == null) + return null; + + var switchClassId = SwitchBossRune.Class.__clid; + var entries = entitiesByClass.get(switchClassId) as ArrayObj; + if (entries == null) + return null; + + for (int i = 0; i < entries.length; i++) + { + if (entries.getDyn(i) is not SwitchBossRune altar) + continue; + + var room = TryGetRoomAt(level, altar.cx, altar.cy); + if (room != null) + return room; + } + } + catch + { + } + + return null; + } + + private static Room? TryGetRoomAt(Level level, int cx, int cy) + { + try + { + return level.map?.getRoomAt(cx, cy); + } + catch + { + return null; + } + } + + private static string ResolveReloadTargetLevelId(Level level, string fallbackLevelId) + { + try + { + var levelId = level.map?.id?.ToString(); + if (!string.IsNullOrWhiteSpace(levelId)) + return levelId; + } + catch + { + } + + return string.IsNullOrWhiteSpace(fallbackLevelId) ? "PrisonStart" : fallbackLevelId; + } + + private static string? TryGetCurrentLevelId() + { + try + { + var levelId = ModEntry.me?._level?.map?.id?.ToString(); + if (!string.IsNullOrWhiteSpace(levelId)) + return levelId; + } + catch + { + } + + return null; + } + + private static bool ConsumePendingBossRuneReloadIfMatch(string graphLevelId, int remoteBossRune) + { + lock (_pendingBossRuneReloadLock) + { + if (!_hasPendingBossRuneReload) + return false; + + if (Environment.TickCount64 - _pendingBossRuneReloadTick > PendingBossRuneReloadTtlMs) + { + _hasPendingBossRuneReload = false; + _pendingBossRuneReloadLevelId = null; + return false; + } + + if (_pendingBossRuneReloadValue != remoteBossRune) + return false; + + if (!string.IsNullOrWhiteSpace(_pendingBossRuneReloadLevelId) && + !string.Equals(_pendingBossRuneReloadLevelId, graphLevelId, StringComparison.Ordinal)) + { + return false; + } + + _hasPendingBossRuneReload = false; + _pendingBossRuneReloadLevelId = null; + return true; + } + } + + private static void ResetLevelReloadState() + { + lock (_levelReloadLock) + { + _pendingLevelReloadReasons.Clear(); + _pendingLevelReloadPayloads.Clear(); + _lastLevelReloadLevelId = null; + _lastLevelReloadPayload = null; + _lastLevelReloadReason = LevelReloadReason.None; + _lastLevelReloadTick = 0; + } + } + } +} diff --git a/GameDataSync/GameDataSync.cs b/GameDataSync/GameDataSync.cs index 53ecefb..59e6271 100644 --- a/GameDataSync/GameDataSync.cs +++ b/GameDataSync/GameDataSync.cs @@ -1,7 +1,6 @@ using DeadCellsMultiplayerMod.Interface.ModuleInitializing; using ModCore.Events; using dc; -using dc.haxe.ds; using dc.level; using dc.pr; using dc.tool; @@ -12,14 +11,9 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Runtime.InteropServices; using System.Reflection; -using System.Text; -using System.Text.Json; using System.Threading; using dc.haxe; -using dc.haxe.io; -using dc.hl.types; using Rand = dc.libs.Rand; namespace DeadCellsMultiplayerMod @@ -28,10 +22,6 @@ internal partial class GameDataSync : IEventReceiver, IOnAdvancedModuleInitializ { static Serilog.ILogger? _log; static public int Seed; - private static readonly bool EnableStoryManagerSync = false; - - // When false, host does not send PROGRESS (packed user) to clients. - private static readonly bool EnableSendHostUserProgress = false; static public virtual_baseLootLevel_biome_bonusTripleScrollAfterBC_cellBonus_dlc_doubleUps_eliteRoomChance_eliteWanderChance_flagsProps_group_icon_id_index_loreDescriptions_mapDepth_minGold_mobDensity_mobs_name_nextLevels_parallax_props_quarterUpsBC3_quarterUpsBC4_specificLoots_specificSubBiome_transitionTo_tripleUps_worldDepth_ _isTwitch = default!; static public bool _isCustom; @@ -50,57 +40,19 @@ internal partial class GameDataSync : IEventReceiver, IOnAdvancedModuleInitializ private static long _nextBossRuneHudRefreshTickMs; private const long BossRuneHudRefreshDurationMs = 3000; private const long BossRuneHudRefreshIntervalMs = 150; - private static string? _remoteProgressPayload; - public static string? HostProgressPayload; - private static bool _origProgressCaptured; - private static string? _origProgressPayload; private static bool _origHeroCosmeticsCaptured; private static string? _origHeroSkin; private static string? _origHeroHeadSkin; - private static User? _cachedBuiltProgressPayloadUser; - private static string? _cachedBuiltProgressPayload; - private static NetNode? _lastProgressSyncNet; - private static string? _lastProgressSyncPayload; private static NetNode? _lastHeroSkinSyncNet; private static string? _lastHeroSkinSyncPayload; private static NetNode? _lastHeroHeadSkinSyncNet; private static string? _lastHeroHeadSkinSyncPayload; - private static string? _remoteCountersPayload; - private static double? _remoteMobsHpMult; private static double? _remoteBossesHpMult; private static bool _origHpMultipliersSaved; private static double _origMobsHpMult; private static double _origBossesHpMult; - public static string? HostCountersPayload; - private static string? _remoteBlueprintsPayload; - public static string? HostBlueprintsPayload; - private static bool _hasRemoteCounters; - private static bool _hasRemoteBlueprints; - private static bool _origStoryCaptured; - private static bool _origStoryWasNull; - private static StoryManager? _origStory; - private static StringMap? _origCounters; - private static readonly Dictionary _origCountersSnapshot = new(StringComparer.Ordinal); - private static readonly Dictionary _origNpcProgressSnapshot = new(); - private static readonly Dictionary _origLoreRoomRunIdsSnapshot = new(StringComparer.Ordinal); - private static readonly HashSet _origVisitedLoreRoomsSnapshot = new(StringComparer.Ordinal); - private static readonly List _origPlannedLoresSnapshot = new(); - private static int _origStoryDataVersion; - private static bool _sessionStoryCaptured; - private static bool _sessionStoryWasNull; - private static readonly Dictionary _sessionCountersSnapshot = new(StringComparer.Ordinal); - private static readonly Dictionary _sessionNpcProgressSnapshot = new(); - private static readonly Dictionary _sessionLoreRoomRunIdsSnapshot = new(StringComparer.Ordinal); - private static readonly HashSet _sessionVisitedLoreRoomsSnapshot = new(StringComparer.Ordinal); - private static readonly List _sessionPlannedLoresSnapshot = new(); - private static int _sessionStoryDataVersion; - private static bool _origItemMetaCaptured; - private static ItemMetaManager? _origItemMeta; - private static ArrayObj? _origItemProgress; - private static ArrayObj? _origPermanentItems; - private static bool _origItemMetaWasNull; private static bool _origBossRuneCaptured; private static int _origBossRune; private static bool _hasRemoteBossRune; @@ -116,13 +68,6 @@ internal partial class GameDataSync : IEventReceiver, IOnAdvancedModuleInitializ private static bool _localSerializerCaptured; private static int _localSerializerSeq; private static int _localSerializerUid; - private static readonly Dictionary _remoteCountersSnapshot = new(StringComparer.Ordinal); - private static readonly Dictionary _remoteNpcProgressSnapshot = new(); - private static readonly Dictionary _remoteLoreRoomRunIdsSnapshot = new(StringComparer.Ordinal); - private static readonly HashSet _remoteVisitedLoreRoomsSnapshot = new(StringComparer.Ordinal); - private static readonly HashSet _remotePlannedLoresSnapshot = new(); - private static int _remoteStoryDataVersion; - private static bool _hasRemoteStoryDataVersion; public GameDataSync(Serilog.ILogger log) { @@ -418,8 +363,6 @@ private static string GetLaunchKind(LaunchMode? launch) } } - public static void MarkProgressPayloadDirty() { } - internal static void BeginSameRunRestart(int seed) { lock (_sameRunRestartSync) @@ -506,37 +449,6 @@ internal static LaunchMode BuildSameRunRestartLaunchMode() return new LaunchMode.NewGame(ResolveCurrentRunIsCustom(), ResolveCurrentRunStreamEnabled()); } - private static string? GetCurrentProgressPayload(User user) - { - if (user == null) - return null; - - if (!string.IsNullOrWhiteSpace(_cachedBuiltProgressPayload) && - ReferenceEquals(_cachedBuiltProgressPayloadUser, user)) - { - return _cachedBuiltProgressPayload; - } - - var payload = BuildProgressPayload(user); - if (string.IsNullOrWhiteSpace(payload)) - return null; - - _cachedBuiltProgressPayloadUser = user; - _cachedBuiltProgressPayload = payload; - return payload; - } - - public static void ReceiveBlueprints(string payload, User? target = null) - { - _remoteBlueprintsPayload = null; - _hasRemoteBlueprints = false; - } - - public static void SendBlueprints(User user, NetNode? net) - { - HostBlueprintsPayload = null; - } - public static bool SwapToOriginalUserData(User user) { var swapped = false; @@ -562,20 +474,7 @@ public static bool RestoreOriginalUserState(User user, bool clearRemote) if (clearRemote) { - _remoteProgressPayload = null; - HostProgressPayload = null; - _remoteCountersPayload = null; - _remoteBlueprintsPayload = null; - _hasRemoteCounters = false; - _hasRemoteBlueprints = false; _hasRemoteBossRune = false; - _remoteCountersSnapshot.Clear(); - _remoteNpcProgressSnapshot.Clear(); - _remoteLoreRoomRunIdsSnapshot.Clear(); - _remoteVisitedLoreRoomsSnapshot.Clear(); - _remotePlannedLoresSnapshot.Clear(); - _remoteStoryDataVersion = 0; - _hasRemoteStoryDataVersion = false; _hasRemoteSerializerSync = false; _hasRemoteSerializerValues = false; _remoteSerializerSeq = 0; @@ -586,31 +485,11 @@ public static bool RestoreOriginalUserState(User user, bool clearRemote) } ClearPendingBossRuneReloadState(); RestoreLocalSerializerSyncIfCaptured(); - _origProgressCaptured = false; - _origProgressPayload = null; _origHeroCosmeticsCaptured = false; _origHeroSkin = null; _origHeroHeadSkin = null; - _origStoryCaptured = false; - _origStoryWasNull = false; - _origStory = null; - _origCounters = null; - _origCountersSnapshot.Clear(); - _origNpcProgressSnapshot.Clear(); - _origLoreRoomRunIdsSnapshot.Clear(); - _origVisitedLoreRoomsSnapshot.Clear(); - _origPlannedLoresSnapshot.Clear(); - _origStoryDataVersion = 0; - ClearSessionStory(); - _origItemMetaCaptured = false; - _origItemMeta = null; - _origItemProgress = null; - _origPermanentItems = null; - _origItemMetaWasNull = false; _origBossRuneCaptured = false; _origBossRune = 0; - _lastProgressSyncNet = null; - _lastProgressSyncPayload = null; _lastHeroSkinSyncNet = null; _lastHeroSkinSyncPayload = null; _lastHeroHeadSkinSyncNet = null; @@ -639,46 +518,6 @@ public static void CaptureOriginalUserData(User user, bool allowReplaceWhenBette } } - public static void CaptureSessionStory(User user) - { - if (!EnableStoryManagerSync || user == null) - return; - - _sessionStoryCaptured = true; - CaptureStorySnapshot( - user, - _sessionCountersSnapshot, - _sessionNpcProgressSnapshot, - _sessionLoreRoomRunIdsSnapshot, - _sessionVisitedLoreRoomsSnapshot, - _sessionPlannedLoresSnapshot, - out _sessionStoryWasNull, - out _sessionStoryDataVersion); - } - - public static void RestoreSessionStory(User user) - { - if (!EnableStoryManagerSync || !_sessionStoryCaptured || user == null) - return; - - if (_sessionStoryWasNull && _sessionCountersSnapshot.Count == 0 && _sessionNpcProgressSnapshot.Count == 0 && _sessionStoryDataVersion == 0) - { - ClearUserStoryState(user); - ClearSessionStory(); - return; - } - - ApplyStoryState( - user, - _sessionCountersSnapshot, - _sessionNpcProgressSnapshot, - _sessionStoryDataVersion, - _sessionLoreRoomRunIdsSnapshot, - _sessionVisitedLoreRoomsSnapshot, - _sessionPlannedLoresSnapshot); - ClearSessionStory(); - } - public static void RestoreRemoteUserData(User user) { if (TryGetRemoteBossRune(out var bossRune)) @@ -898,42 +737,6 @@ public static void SendSerializerSync(NetNode? net) } } - public static void ReceiveCounters(string payload, User? target = null) - { - _remoteCountersPayload = null; - _hasRemoteCounters = false; - _remoteCountersSnapshot.Clear(); - _remoteNpcProgressSnapshot.Clear(); - _remoteLoreRoomRunIdsSnapshot.Clear(); - _remoteVisitedLoreRoomsSnapshot.Clear(); - _remotePlannedLoresSnapshot.Clear(); - _remoteStoryDataVersion = 0; - _hasRemoteStoryDataVersion = false; - } - - private static void SendCounters(User user, NetNode? net) - { - HostCountersPayload = null; - } - - public static void SendHostStorySync(User user, NetNode? net) - { - HostCountersPayload = null; - } - - public static void SendProgressSync(User user, NetNode? net) - { - HostProgressPayload = null; - } - - public static void ReceiveProgressSync(string payload, User? target = null) - { - _remoteProgressPayload = null; - } - - - - internal static int GetBossRuneInt(User? user) { return user == null ? 0 : GetEffectiveBossRune(user); @@ -978,6 +781,9 @@ public static void ReceiveBossRune(string payload) if (net != null && net.IsHost) return; + // Lobby auto-start waits on HasRemoteBossRune; re-arm if seed/exec already arrived. + GameMenu.NotifyClientLaunchPrerequisiteProgress(); + RequestBossRuneHudRefresh(bossRune); // The client must rebuild only when an established remote boss-rune value changes. @@ -1513,82 +1319,6 @@ private static int GetEffectiveBossRune(User user) } } - private static void ForEachEscapedToken(string payload, Action onToken) - { - if (string.IsNullOrEmpty(payload)) - return; - - var token = new StringBuilder(); - var escaped = false; - for (var i = 0; i < payload.Length; i++) - { - var c = payload[i]; - if (escaped) - { - token.Append(c); - escaped = false; - continue; - } - - if (c == '\\') - { - escaped = true; - continue; - } - - if (c == '|') - { - onToken(token.ToString()); - token.Clear(); - continue; - } - - token.Append(c); - } - - if (escaped) - token.Append('\\'); - onToken(token.ToString()); - } - - private static string EncodeToken(string value) - { - return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(value)); - } - - private static string? DecodeToken(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return null; - - try - { - return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(value)); - } - catch - { - return null; - } - } - - private static int ParseInt(string value, int fallback) - { - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) - return parsed; - return fallback; - } - - private static bool ParseBool(string value, bool fallback) - { - if (value == "1") - return true; - if (value == "0") - return false; - if (bool.TryParse(value, out var parsed)) - return parsed; - return fallback; - } - private static void RestoreLocalSerializerSyncIfCaptured() { if (!_localSerializerCaptured) @@ -1608,636 +1338,39 @@ private static void RestoreLocalSerializerSyncIfCaptured() } } - private static void ClearSessionStory() - { - _sessionStoryCaptured = false; - _sessionStoryWasNull = false; - _sessionCountersSnapshot.Clear(); - _sessionNpcProgressSnapshot.Clear(); - _sessionLoreRoomRunIdsSnapshot.Clear(); - _sessionVisitedLoreRoomsSnapshot.Clear(); - _sessionPlannedLoresSnapshot.Clear(); - _sessionStoryDataVersion = 0; - } - - private static void RestoreOriginalStory(User user, bool preserveLocalProgress) - { - var currentStory = user.story; - Dictionary countersToApply; - Dictionary npcProgressToApply; - Dictionary loreRoomRunIdsToApply; - HashSet visitedLoreRoomsToApply; - List plannedLoresToApply; - int storyDataVersionToApply; - if (preserveLocalProgress) - { - countersToApply = MergeCountersWithLocalProgress(currentStory); - npcProgressToApply = MergeNpcProgressWithLocalProgress(currentStory); - loreRoomRunIdsToApply = MergeLoreRoomRunIdsWithLocalProgress(currentStory); - visitedLoreRoomsToApply = MergeVisitedLoreRoomsWithLocalProgress(currentStory); - plannedLoresToApply = MergePlannedLoresWithLocalProgress(currentStory); - storyDataVersionToApply = MergeStoryDataVersion(currentStory); - } - else - { - countersToApply = new Dictionary(_origCountersSnapshot, StringComparer.Ordinal); - npcProgressToApply = new Dictionary(_origNpcProgressSnapshot); - loreRoomRunIdsToApply = new Dictionary(_origLoreRoomRunIdsSnapshot, StringComparer.Ordinal); - visitedLoreRoomsToApply = new HashSet(_origVisitedLoreRoomsSnapshot, StringComparer.Ordinal); - plannedLoresToApply = new List(_origPlannedLoresSnapshot); - storyDataVersionToApply = _origStoryDataVersion; - } - - if (_origStoryWasNull && - countersToApply.Count == 0 && - npcProgressToApply.Count == 0 && - loreRoomRunIdsToApply.Count == 0 && - visitedLoreRoomsToApply.Count == 0 && - plannedLoresToApply.Count == 0 && - storyDataVersionToApply == 0) - { - ClearUserStoryState(user); - return; - } - - ApplyStoryState( - user, - countersToApply, - npcProgressToApply, - storyDataVersionToApply, - loreRoomRunIdsToApply, - visitedLoreRoomsToApply, - plannedLoresToApply); - } - - private static Dictionary MergeCountersWithLocalProgress(StoryManager? currentStory) - { - var merged = new Dictionary(_origCountersSnapshot, StringComparer.Ordinal); - var currentCounters = new Dictionary(StringComparer.Ordinal); - CopyCountersToDictionary(currentStory?.counters, currentCounters); - foreach (var kv in currentCounters) - { - if (!_remoteCountersSnapshot.TryGetValue(kv.Key, out var remoteValue) || remoteValue != kv.Value) - merged[kv.Key] = kv.Value; - } - - return merged; - } - - private static Dictionary MergeNpcProgressWithLocalProgress(StoryManager? currentStory) - { - var merged = new Dictionary(_origNpcProgressSnapshot); - var currentNpcProgress = new Dictionary(); - CopyNpcProgressToDictionary(currentStory?.npcProgresses, currentNpcProgress); - foreach (var kv in currentNpcProgress) - { - if (!_remoteNpcProgressSnapshot.TryGetValue(kv.Key, out var remoteValue) || remoteValue != kv.Value) - merged[kv.Key] = kv.Value; - } - - return merged; - } - - private static int MergeStoryDataVersion(StoryManager? currentStory) - { - var merged = _origStoryDataVersion; - var current = currentStory?.storyDataVersion ?? merged; - if (!_hasRemoteStoryDataVersion || current != _remoteStoryDataVersion) - merged = current; - return merged; - } - - private static Dictionary MergeLoreRoomRunIdsWithLocalProgress(StoryManager? currentStory) - { - var merged = new Dictionary(_origLoreRoomRunIdsSnapshot, StringComparer.Ordinal); - var currentLore = new Dictionary(StringComparer.Ordinal); - CopyStoryStringIntMapToDictionary(currentStory != null ? ((dynamic)currentStory).loreRoomRunIds : null, currentLore); - foreach (var kv in currentLore) - { - if (!_remoteLoreRoomRunIdsSnapshot.TryGetValue(kv.Key, out var remoteValue) || remoteValue != kv.Value) - merged[kv.Key] = kv.Value; - } - - return merged; - } - - private static HashSet MergeVisitedLoreRoomsWithLocalProgress(StoryManager? currentStory) - { - var merged = new HashSet(_origVisitedLoreRoomsSnapshot, StringComparer.Ordinal); - var currentVisited = new HashSet(StringComparer.Ordinal); - CopyStoryVisitedLoreRoomsToSet(currentStory != null ? ((dynamic)currentStory).visitedLoreRooms : null, currentVisited); - foreach (var key in currentVisited) - { - if (!_remoteVisitedLoreRoomsSnapshot.Contains(key)) - merged.Add(key); - } - - return merged; - } - - private static List MergePlannedLoresWithLocalProgress(StoryManager? currentStory) - { - var mergedSet = new HashSet(_origPlannedLoresSnapshot); - var currentPlanned = new List(); - CopyStoryPlannedLoresToList(currentStory?.plannedLores, currentPlanned); - for (var i = 0; i < currentPlanned.Count; i++) - { - var planned = currentPlanned[i]; - if (!_remotePlannedLoresSnapshot.Contains(planned)) - mergedSet.Add(planned); - } - - var merged = new List(mergedSet); - merged.Sort(); - return merged; - } - - private static ArrayObj MergeItemProgressWithLocalProgress(ArrayObj? currentItemProgress) - { - var merged = new Dictionary(StringComparer.Ordinal); - var orig = _origItemProgress; - if (orig != null) - { - for (int i = 0; i < orig.length; i++) - { - var p = orig.getDyn(i) as ItemProgress; - if (p != null) - { - var id = p.itemId?.ToString(); - if (!string.IsNullOrWhiteSpace(id)) - merged[id] = p; - } - } - } - if (currentItemProgress != null) - { - for (int i = 0; i < currentItemProgress.length; i++) - { - var curr = currentItemProgress.getDyn(i) as ItemProgress; - if (curr == null) - continue; - var id = curr.itemId?.ToString(); - if (string.IsNullOrWhiteSpace(id)) - continue; - if (!merged.TryGetValue(id, out var origP) || origP == null) - { - merged[id] = curr; - continue; - } - var currUnlocked = curr.unlocked; - var currInvested = ToInt(curr.investedCells); - var currIsNew = curr.isNew; - var origUnlocked = origP.unlocked; - var origInvested = ToInt(origP.investedCells); - var origIsNew = origP.isNew; - if (currUnlocked && !origUnlocked || currInvested > origInvested || currIsNew && !origIsNew) - merged[id] = curr; - } - } - var arr = ArrayUtils.CreateDyn(); - foreach (var p in merged.Values) - arr.array.pushDyn(p); - return (ArrayObj)arr.array; - } - - private static ArrayObj MergePermanentItemsWithLocalProgress(ArrayObj? currentPermanentItems) - { - var merged = new HashSet(StringComparer.Ordinal); - var orig = _origPermanentItems; - if (orig != null) - { - for (int i = 0; i < orig.length; i++) - { - var id = orig.getDyn(i)?.ToString(); - if (!string.IsNullOrWhiteSpace(id)) - merged.Add(id); - } - } - if (currentPermanentItems != null) - { - for (int i = 0; i < currentPermanentItems.length; i++) - { - var id = currentPermanentItems.getDyn(i)?.ToString(); - if (!string.IsNullOrWhiteSpace(id)) - merged.Add(id); - } - } - var arr = ArrayUtils.CreateDyn(); - foreach (var id in merged) - arr.array.pushDyn(id.AsHaxeString()); - return (ArrayObj)arr.array; - } - - private static bool TryParseCountersPayloadV4( - string payload, - Dictionary counters, - Dictionary npcProgress, - Dictionary loreRoomRunIds, - HashSet visitedLoreRooms, - List plannedLores, - out int? storyDataVersion) - { - int? parsedStoryDataVersion = null; - var isV4 = payload.Equals("V4", StringComparison.Ordinal) || - payload.StartsWith("V4|", StringComparison.Ordinal); - if (!isV4) - { - storyDataVersion = null; - return false; - } - - var plannedSet = new HashSet(); - ForEachEscapedToken(payload, token => - { - if (string.IsNullOrWhiteSpace(token) || token.Equals("V4", StringComparison.Ordinal)) - return; - - if (token.StartsWith("C:", StringComparison.Ordinal)) - { - var parts = token.Split(':', 3); - if (parts.Length < 3) - return; - - var key = DecodeToken(parts[1]); - if (string.IsNullOrWhiteSpace(key)) - return; - - counters[key] = ParseInt(parts[2], 0); - return; - } - - if (token.StartsWith("N:", StringComparison.Ordinal)) - { - var parts = token.Split(':', 3); - if (parts.Length < 3) - return; - - if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var npcIndex)) - return; - - npcProgress[npcIndex] = ParseInt(parts[2], 0); - return; - } - - if (token.StartsWith("L:", StringComparison.Ordinal)) - { - var parts = token.Split(':', 3); - if (parts.Length < 3) - return; - - var key = DecodeToken(parts[1]); - if (string.IsNullOrWhiteSpace(key)) - return; - - loreRoomRunIds[key] = ParseInt(parts[2], 0); - return; - } - - if (token.StartsWith("V:", StringComparison.Ordinal)) - { - var key = DecodeToken(token[2..]); - if (string.IsNullOrWhiteSpace(key)) - return; - - visitedLoreRooms.Add(key); - return; - } - - if (token.StartsWith("P:", StringComparison.Ordinal)) - { - if (int.TryParse(token[2..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var planned)) - { - if (plannedSet.Add(planned)) - plannedLores.Add(planned); - } - return; - } - - if (token.StartsWith("S:", StringComparison.Ordinal)) - { - if (int.TryParse(token[2..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) - parsedStoryDataVersion = parsed; - return; - } - }); - - storyDataVersion = parsedStoryDataVersion; - return true; - } - - private static bool TryParseCountersPayloadV3( - string payload, - Dictionary counters, - Dictionary npcProgress, - out int? storyDataVersion) - { - int? parsedStoryDataVersion = null; - var isV3 = payload.Equals("V3", StringComparison.Ordinal) || - payload.StartsWith("V3|", StringComparison.Ordinal); - if (!isV3) - { - storyDataVersion = null; - return false; - } - - ForEachEscapedToken(payload, token => - { - if (string.IsNullOrWhiteSpace(token) || token.Equals("V3", StringComparison.Ordinal)) - return; - - if (token.StartsWith("C:", StringComparison.Ordinal)) - { - var parts = token.Split(':', 3); - if (parts.Length < 3) - return; - - var key = DecodeToken(parts[1]); - if (string.IsNullOrWhiteSpace(key)) - return; - - counters[key] = ParseInt(parts[2], 0); - return; - } - - if (token.StartsWith("N:", StringComparison.Ordinal)) - { - var parts = token.Split(':', 3); - if (parts.Length < 3) - return; - - if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var npcIndex)) - return; - - npcProgress[npcIndex] = ParseInt(parts[2], 0); - return; - } - - if (token.StartsWith("S:", StringComparison.Ordinal)) - { - if (int.TryParse(token[2..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) - parsedStoryDataVersion = parsed; - return; - } - }); - - storyDataVersion = parsedStoryDataVersion; - return true; - } - - private static void ParseLegacyCountersPayload(string payload, Dictionary counters) - { - var key = new StringBuilder(); - var value = new StringBuilder(); - var inKey = true; - var escaped = false; - - void commitPair() - { - if (key.Length <= 0) - return; - - var keyText = key.ToString(); - var valueText = value.ToString(); - counters[keyText] = ParseInt(valueText, 0); - } - - for (var i = 0; i < payload.Length; i++) - { - var c = payload[i]; - if (escaped) - { - if (inKey) - key.Append(c); - else - value.Append(c); - escaped = false; - continue; - } - - if (c == '\\') - { - escaped = true; - continue; - } - - if (inKey && c == '=') - { - inKey = false; - continue; - } - - if (!inKey && c == '|') - { - commitPair(); - key.Clear(); - value.Clear(); - inKey = true; - continue; - } - - if (inKey) - key.Append(c); - else - value.Append(c); - } - - commitPair(); - } - - private static void CaptureStorySnapshot( - User user, - Dictionary countersTarget, - Dictionary npcProgressTarget, - Dictionary loreRoomRunIdsTarget, - HashSet visitedLoreRoomsTarget, - List plannedLoresTarget, - out bool storyWasNull, - out int storyDataVersion) + private static void ApplyHeroCosmetics(User? user, string? heroSkin, string? heroHeadSkin) { - countersTarget.Clear(); - npcProgressTarget.Clear(); - loreRoomRunIdsTarget.Clear(); - visitedLoreRoomsTarget.Clear(); - plannedLoresTarget.Clear(); - - var story = user.story; - storyWasNull = story == null; - if (story != null) - { - dynamic dynStory = story; - CopyCountersToDictionary(story.counters, countersTarget); - CopyNpcProgressToDictionary(story.npcProgresses, npcProgressTarget); - CopyStoryStringIntMapToDictionary(dynStory.loreRoomRunIds, loreRoomRunIdsTarget); - CopyStoryVisitedLoreRoomsToSet(dynStory.visitedLoreRooms, visitedLoreRoomsTarget); - CopyStoryPlannedLoresToList(story.plannedLores, plannedLoresTarget); - storyDataVersion = story.storyDataVersion; + if (user == null) return; - } - - storyDataVersion = 0; - CopyCountersToDictionary(user.counters, countersTarget); - CopyNpcProgressToDictionary(user.npcs, npcProgressTarget); - if (countersTarget.Count > 0 || npcProgressTarget.Count > 0) - storyWasNull = false; - } - - private static bool HasAnyIncomingStoryData( - Dictionary counters, - Dictionary npcProgress, - Dictionary loreRoomRunIds, - HashSet visitedLoreRooms, - List plannedLores, - int? storyDataVersion) - { - return counters.Count > 0 || - npcProgress.Count > 0 || - loreRoomRunIds.Count > 0 || - visitedLoreRooms.Count > 0 || - plannedLores.Count > 0 || - (storyDataVersion ?? 0) != 0; - } - - private static bool HasAnyStorySnapshotData( - Dictionary counters, - Dictionary npcProgress, - Dictionary loreRoomRunIds, - HashSet visitedLoreRooms, - List plannedLores, - int storyDataVersion) - { - return counters.Count > 0 || - npcProgress.Count > 0 || - loreRoomRunIds.Count > 0 || - visitedLoreRooms.Count > 0 || - plannedLores.Count > 0 || - storyDataVersion != 0; - } - - private static bool HasAnyUserStoryData(User user) - { - if (HasAnyStoryData(user.story)) - return true; - - var legacyCounters = new Dictionary(StringComparer.Ordinal); - CopyCountersToDictionary(user.counters, legacyCounters); - if (legacyCounters.Count > 0) - return true; - - var legacyNpcProgress = new Dictionary(); - CopyNpcProgressToDictionary(user.npcs, legacyNpcProgress); - return legacyNpcProgress.Count > 0; - } - - private static bool HasAnyStoryData(StoryManager? story) - { - if (story == null) - return false; - - dynamic dynStory = story; - - if (dynStory.storyDataVersion != 0) - return true; - - var counters = new Dictionary(StringComparer.Ordinal); - CopyCountersToDictionary(dynStory.counters, counters); - if (counters.Count > 0) - return true; - - var npcProgress = new Dictionary(); - CopyNpcProgressToDictionary(dynStory.npcProgresses, npcProgress); - if (npcProgress.Count > 0) - return true; - - var loreRoomRunIds = new Dictionary(StringComparer.Ordinal); - CopyStoryStringIntMapToDictionary(dynStory.loreRoomRunIds, loreRoomRunIds); - if (loreRoomRunIds.Count > 0) - return true; - var visitedLoreRooms = new HashSet(StringComparer.Ordinal); - CopyStoryVisitedLoreRoomsToSet(dynStory.visitedLoreRooms, visitedLoreRooms); - if (visitedLoreRooms.Count > 0) - return true; + if (!string.IsNullOrWhiteSpace(heroSkin)) + user.heroSkin = heroSkin.AsHaxeString(); - var plannedLores = new List(); - CopyStoryPlannedLoresToList(dynStory.plannedLores, plannedLores); - return plannedLores.Count > 0; + if (!string.IsNullOrWhiteSpace(heroHeadSkin)) + user.heroHeadSkin = heroHeadSkin.AsHaxeString(); } - private static void CopyCountersToDictionary(StringMap? map, Dictionary target) + private static int ToInt(object? value) { - target.Clear(); - if (map == null) - return; + if (value == null) + return 0; - try - { - var keys = map.keys(); - while (keys.hasNext.Invoke()) - { - var key = keys.next.Invoke(); - if (key == null) - continue; + if (value is int i) + return i; - var keyText = key.ToString(); - if (string.IsNullOrWhiteSpace(keyText)) - continue; + if (value is bool b) + return b ? 1 : 0; - target[keyText] = ToInt(map.get(key)); - } - } - catch - { - } - } - - private static void CopyNpcProgressToDictionary(EnumValueMap? map, Dictionary target) - { - target.Clear(); - if (map == null) - return; - - try + if (value is IConvertible conv) { - var keys = map.keys(); - while (keys.hasNext.Invoke()) + try { - var key = keys.next.Invoke(); - if (key is not NpcId npcId) - continue; - - target[(int)npcId.Index] = ToInt(map.get(key)); + return conv.ToInt32(CultureInfo.InvariantCulture); } + catch { } } - catch - { - } - } - private static void CopyStoryStringIntMapToDictionary(dynamic? map, Dictionary target) - { - target.Clear(); - if (map == null) - return; - - try - { - var keys = map.keys.Invoke(); - while (keys.hasNext.Invoke()) - { - var keyObj = keys.next.Invoke(); - if (keyObj == null) - continue; - - var key = keyObj.ToString(); - if (string.IsNullOrWhiteSpace(key)) - continue; - - target[key] = ToInt(map.get.Invoke(keyObj)); - } - } - catch - { - } + return 0; } } } diff --git a/Ghost/KingWeapon/KingWeaponHooksBridge.cs b/Ghost/KingWeapon/KingWeaponHooksBridge.cs index 6f790a8..39d7877 100644 --- a/Ghost/KingWeapon/KingWeaponHooksBridge.cs +++ b/Ghost/KingWeapon/KingWeaponHooksBridge.cs @@ -111,6 +111,11 @@ internal void NotifyLocalWeaponPrepareFromKingWeaponHooks(Weapon self) var ammo = GetWeaponAmmoForSync(item); _net?.SendAttack(kindId!, slot, item.permanentId, ammo); _suppressHeroAnimUntilTicks = Stopwatch.GetTimestamp() + (long)(Stopwatch.Frequency * 0.18); + // Attack poses travel via ATK, not ANIM. Invalidate the idle dedupe cache so a standing + // re-idle after the swing is actually sent to peers. + _lastAnimSent = null; + _lastAnimQueueSent = null; + _lastAnimGSent = null; } internal void NotifyLocalShieldHoldingPulseFromKingWeaponHooks(BaseShield self, double ratio) diff --git a/Ghost/KingWeapon/KingWeaponsManager.cs b/Ghost/KingWeapon/KingWeaponsManager.cs index 38f58e9..0f47868 100644 --- a/Ghost/KingWeapon/KingWeaponsManager.cs +++ b/Ghost/KingWeapon/KingWeaponsManager.cs @@ -31,6 +31,7 @@ public class KingWeaponsManager : HeroWeaponsManager private bool _shieldActive; private long _lastShieldReleaseTimestamp; private string _activeKindId = string.Empty; + private bool _meleeSwingActive; private readonly HashSet _quarantinedKinds = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _visualOnlyNoticeKinds = new(StringComparer.OrdinalIgnoreCase); @@ -100,6 +101,7 @@ private void UpdateCore() _shieldActive = false; _shieldLastPulseTicks = 0; _lastShieldReleaseTimestamp = 0; + _meleeSwingActive = false; ClearShieldAffects(); // Do not construct an idle detached weapon merely because the remote player equipped it. @@ -223,6 +225,7 @@ private void UpdateCore() KingWeaponSupport.SyncSource(activeWeapon); activeWeapon.prepare(getWeaponAttackSpeed(activeWeapon)); pendingAttacks--; + _meleeSwingActive = true; } if(pendingAttacks > 1) @@ -252,6 +255,19 @@ private void UpdateCore() try { activeWeapon.fixedUpdate(); } catch { } try { activeWeapon.postUpdate(); } catch { } } + + _meleeSwingActive = false; + RestoreRemoteIdlePose(); + } + else if(_meleeSwingActive && + !activeWeapon.destroyed && + !activeWeapon.isCharging() && + activeWeapon.isReady()) + { + // Melee/ranged ATK uses stopOnLastFrame; without a locomotion ANIM change the + // ghost stays frozen on the last attack frame while the player stands still. + _meleeSwingActive = false; + RestoreRemoteIdlePose(); } var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); @@ -294,6 +310,7 @@ internal void DisposeManagedWeapon() _shieldActive = false; _shieldLastPulseTicks = 0; _lastShieldReleaseTimestamp = 0; + _meleeSwingActive = false; _quarantinedKinds.Clear(); _visualOnlyNoticeKinds.Clear(); } @@ -431,7 +448,7 @@ private void ReleaseShield(long now) _shieldLastPulseTicks = 0; _lastShieldReleaseTimestamp = now; ClearShieldAffects(); - try { king.spr?._animManager?.play("idle".AsHaxeString(), null, null)?.loop(null); } catch { } + RestoreRemoteIdlePose(); LogKingWeaponsStepIfSlow( "KingWeaponsManager.ReleaseShield", hitchStart, @@ -440,6 +457,11 @@ private void ReleaseShield(long now) $"weapon={weapon?.GetType().Name ?? "null"}")); } + private void RestoreRemoteIdlePose() + { + try { king.spr?._animManager?.play("idle".AsHaxeString(), null, null)?.loop(null); } catch { } + } + private static void LogKingWeaponsStepIfSlow(string key, long stepStart, string? details) { var stepMs = RuntimeHitchWatch.GetElapsedMilliseconds(stepStart); diff --git a/Interaction/InterSyncTypes.cs b/Interaction/InterSyncTypes.cs index fa37736..87b9fcd 100644 --- a/Interaction/InterSyncTypes.cs +++ b/Interaction/InterSyncTypes.cs @@ -36,11 +36,6 @@ public InterElevatorEvent(int userId, double x, double y, long sequence, string Sequence = sequence; LevelId = levelId ?? string.Empty; } - - public InterElevatorEvent(double x, double y) - : this(0, x, y, 0, string.Empty) - { - } } public readonly struct InterElevatorStateEvent @@ -91,22 +86,19 @@ public InterPressurePlateEvent(int userId, double x, double y, long sequence, st Sequence = sequence; LevelId = levelId ?? string.Empty; } - - public InterPressurePlateEvent(double x, double y) - : this(0, x, y, 0, string.Empty) - { - } } public readonly struct InterTreasureChestEvent { public readonly double X; public readonly double Y; + public readonly string LevelId; - public InterTreasureChestEvent(double x, double y) + public InterTreasureChestEvent(double x, double y, string levelId = "") { X = x; Y = y; + LevelId = levelId ?? string.Empty; } } @@ -114,11 +106,13 @@ public readonly struct InterVineLadderEvent { public readonly double X; public readonly double Y; + public readonly string LevelId; - public InterVineLadderEvent(double x, double y) + public InterVineLadderEvent(double x, double y, string levelId = "") { X = x; Y = y; + LevelId = levelId ?? string.Empty; } } @@ -126,11 +120,13 @@ public readonly struct InterTeleportEvent { public readonly double X; public readonly double Y; + public readonly string LevelId; - public InterTeleportEvent(double x, double y) + public InterTeleportEvent(double x, double y, string levelId = "") { X = x; Y = y; + LevelId = levelId ?? string.Empty; } } @@ -154,11 +150,13 @@ public readonly struct InterBreakableGroundEvent { public readonly double X; public readonly double Y; + public readonly string LevelId; - public InterBreakableGroundEvent(double x, double y) + public InterBreakableGroundEvent(double x, double y, string levelId = "") { X = x; Y = y; + LevelId = levelId ?? string.Empty; } } @@ -167,12 +165,14 @@ public readonly struct InterBossRuneUpdateCellsEvent public readonly double X; public readonly double Y; public readonly bool Add; + public readonly string LevelId; - public InterBossRuneUpdateCellsEvent(double x, double y, bool add) + public InterBossRuneUpdateCellsEvent(double x, double y, bool add, string levelId = "") { X = x; Y = y; Add = add; + LevelId = levelId ?? string.Empty; } } @@ -181,11 +181,13 @@ public readonly struct InterPortalEvent public readonly double X; public readonly double Y; public readonly string Action; + public readonly string LevelId; - public InterPortalEvent(double x, double y, string action) + public InterPortalEvent(double x, double y, string action, string levelId = "") { X = x; Y = y; Action = action ?? string.Empty; + LevelId = levelId ?? string.Empty; } } diff --git a/Interaction/InteractionSync.Doors.cs b/Interaction/InteractionSync.Doors.cs new file mode 100644 index 0000000..71faf4b --- /dev/null +++ b/Interaction/InteractionSync.Doors.cs @@ -0,0 +1,485 @@ +using dc; +using dc.en; +using dc.en.inter; +using dc.hl.types; +using dc.pr; +using dc.tool.atk; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using HaxeProxy.Runtime; +using ModCore.Events; +using ModCore.Events.Interfaces.Game.Hero; +using Serilog; +using System.Reflection; + +namespace DeadCellsMultiplayerMod.Interaction; + +public partial class InteractionSync +{ + private void Hook_Door_init(Hook_Door.orig_init orig, Door self) + { + orig(self); + if (self == null) + return; + + _doorStableAnchors[self] = ComputeDoorStableAnchor(self); + var net = GameMenu.NetRef; + if (net != null && net.IsAlive) + { + _doorHadAutoClose[self] = SafeRead(() => self.autoClose, false); + self.autoClose = false; + } + } + + private void Hook_Door_open(Hook_Door.orig_open orig, Door self, int durationMs, int? finalRatio, double? _tween) + { + orig(self, durationMs, finalRatio, _tween); + _openedDoors.Add(self); + TrySendDoorEvent(self, "open"); + } + + private void Hook_Door_close(Hook_Door.orig_close orig, Door self, Ref delayMs) + { + orig(self, delayMs); + _openedDoors.Remove(self); + TrySendDoorEvent(self, "close"); + } + + private void Hook_Door_onDamage(Hook_Door.orig_onDamage orig, Door self, AttackData a) + { + orig(self, a); + TrySendDoorEvent(self, "damage"); + } + + private void Hook_Door_onDie(Hook_Door.orig_onDie orig, Door self) + { + orig(self); + _openedDoors.Remove(self); + TrySendDoorEvent(self, "die"); + } + + private void TrySendDoorEvent(Door self, string action) + { + if (_applyingRemoteDoorEvents) + return; + var net = GameMenu.NetRef; + if (!IsNetReadyForSend(net)) + return; + try + { + var (x, y) = GetDoorStableAnchor(self); + var broken = action == "die" || SafeRead(() => self.broken, false); + net!.SendInterDoor(net.id, x, y, action, broken, GetCurrentInteractionLevelId()); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Door send failed"); + } + } + + private (double X, double Y) GetDoorStableAnchor(Door door) + { + if (door == null) + return (0, 0); + + if (_doorStableAnchors.TryGetValue(door, out var anchor)) + return anchor; + + anchor = ComputeDoorStableAnchor(door); + _doorStableAnchors[door] = anchor; + return anchor; + } + + private static bool IsAnyPlayerNearby(Level level, double doorX, double doorY) + { + var hero = ModEntry.me; + if (hero != null && ReferenceEquals(hero._level, level)) + { + if (!SafeRead(() => hero.destroyed, true) && SafeRead(() => hero.life, 0) > 0) + { + var (hx, hy) = GetEntityPixelPos(hero); + var dx = hx - doorX; + var dy = hy - doorY; + if (dx * dx + dy * dy <= DoorProximityRadiusSq) + return true; + } + } + + for (var i = 0; i < ModEntry.clients.Length; i++) + { + var client = ModEntry.clients[i]; + if (client == null) + continue; + if (!ReferenceEquals(client._level, level)) + continue; + if (SafeRead(() => client.destroyed, true) || SafeRead(() => client.life, 0) <= 0) + continue; + + var (cx, cy) = GetEntityPixelPos(client); + var dx = cx - doorX; + var dy = cy - doorY; + if (dx * dx + dy * dy <= DoorProximityRadiusSq) + return true; + } + + return false; + } + + private void ApplyDoorDie(Door door) + { + _openedDoors.Remove(door); + if (!SafeRead(() => door.broken, false)) + { + door.life = 0; + door.onDie(); + } + } + + private void BroadcastAuthoritativeDoorStates(NetNode net) + { + var level = ModEntry.me?._level; + if (level == null || !net.IsHost || !IsNetReadyForSend(net)) + return; + + var now = System.Environment.TickCount64; + var cache = GetInteractionCache(level); + + for (var i = 0; i < cache.Doors.Count; i++) + { + var door = cache.Doors[i]; + if (door == null) + continue; + + var state = GetAuthoritativeDoorState(door); + var changed = !_lastAuthoritativeDoorState.TryGetValue(door, out var previous) || + !string.Equals(previous, state, StringComparison.Ordinal); + var activeHeartbeat = state != "state_closed" && + (!_lastDoorStateSentTickMs.TryGetValue(door, out var lastSent) || + now - lastSent >= DoorStateHeartbeatMs); + if (!changed && !activeHeartbeat) + continue; + + _lastAuthoritativeDoorState[door] = state; + _lastDoorStateSentTickMs[door] = now; + var (x, y) = GetDoorStableAnchor(door); + net.SendInterDoor(net.id, x, y, state, state == "state_broken", GetCurrentInteractionLevelId()); + } + } + + private string GetAuthoritativeDoorState(Door door) + { + if (SafeRead(() => door.broken, false)) + return "state_broken"; + + // A locked door is script-controlled (boss arena seals). Never advertise it as open: + // the stale _openedDoors entry from walking through it earlier otherwise made the 2s + // heartbeat force the client's sealed door back open for the whole fight. + if (TryReadBooleanMember(door, "locked", "isLocked")) + return "state_closed"; + + if (TryReadBooleanMember(door, "opened", "isOpen", "open")) + return "state_open"; + + var ratio = TryReadNumericMember(door, "ratio", "openRatio", "openingRatio", "curRatio"); + if (ratio.HasValue) + return ratio.Value > 0.45 ? "state_open" : "state_closed"; + + // Only when no native state is readable at all, fall back to the open-hook cache. + return _openedDoors.Contains(door) ? "state_open" : "state_closed"; + } + + private bool ShouldRejectRemoteDoorOpen(Door door) + { + // Script-sealed doors (boss arena locks) must never be reopened by replayed remote + // events or heartbeats; the local fight script owns them until the encounter ends. + if (TryReadBooleanMember(door, "locked", "isLocked")) + return true; + + return DeadCellsMultiplayerMod.Mobs.MobsSynchronization.MobsSynchronization.HasLivingTrackedBoss(); + } + + private static double? TryReadNumericMember(object instance, params string[] names) + { + if (instance == null) + return null; + + var type = instance.GetType(); + foreach (var name in names) + { + try + { + var property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (property?.CanRead == true) + { + var value = property.GetValue(instance); + if (value != null) + return Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); + } + + var field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var fieldValue = field?.GetValue(instance); + if (fieldValue != null) + return Convert.ToDouble(fieldValue, System.Globalization.CultureInfo.InvariantCulture); + } + catch + { + // Ignore optional/generated members that cannot be read in this game build. + } + } + + return null; + } + + private void ApplyRemoteDoorEvents(List events) + { + var level = ModEntry.me?._level; + if (level?.entities == null || events == null || events.Count == 0) + return; + + _applyingRemoteDoorEvents = true; + try + { + var localId = GameMenu.NetRef?.id ?? 0; + foreach (var ev in events) + { + if (ev.UserId == localId) + continue; + if (!IsInteractionEventForCurrentLevel(ev.LevelId)) + continue; + + var door = FindDoorByPos(level, ev.X, ev.Y); + if (door == null) + continue; + + try + { + switch (ev.Action) + { + case "open": + if (ShouldRejectRemoteDoorOpen(door)) + break; + door.open(300, null, null); + break; + case "close": + if (SafeRead(() => door.broken, false)) + break; + _openedDoors.Remove(door); + try + { + int delayMs = DoorCloseDelayMs; + door.close(Ref.From(ref delayMs)); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] close failed (door may be broken)"); + } + break; + case "damage": + if (ev.Broken) + ApplyDoorDie(door); + break; + case "die": + case "state_broken": + ApplyDoorDie(door); + break; + case "state_open": + if (!SafeRead(() => door.broken, false) && !ShouldRejectRemoteDoorOpen(door)) + { + door.open(180, null, null); + _openedDoors.Add(door); + } + break; + case "state_closed": + if (!SafeRead(() => door.broken, false)) + { + _openedDoors.Remove(door); + int stateDelayMs = 0; + door.close(Ref.From(ref stateDelayMs)); + } + break; + } + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Apply door event failed x={X} y={Y} action={Action}", ev.X, ev.Y, ev.Action); + } + } + } + finally + { + _applyingRemoteDoorEvents = false; + } + } + + private Door? FindDoorByPos(Level level, double x, double y) + { + var byAnchor = FindDoorByStableAnchor(level, x, y); + if (byAnchor != null) + return byAnchor; + + // Doors can be attached or proxy-wrapped after the early level cache is created. Refresh + // once on a miss so elevator cache changes cannot leave button doors permanently invisible + // to the interaction synchronizer. + RebuildInteractionCache(level); + byAnchor = FindDoorByStableAnchor(level, x, y); + if (byAnchor != null) + return byAnchor; + + var byPos = FindInteractByPos(level, x, y, DoorPosTolerance); + if (byPos != null) + return byPos; + return FindNearestDoor(level, x, y); + } + + private Door? FindDoorByStableAnchor(Level level, double x, double y) + { + var candidates = GetInteractionCandidates(level); + if (candidates == null || candidates.Count == 0) + return null; + + Door? nearest = null; + var nearestSq = DoorPosTolerance * DoorPosTolerance * 4.0; + for (var i = 0; i < candidates.Count; i++) + { + var door = candidates[i]; + if (door == null) + continue; + + try + { + if (!ReferenceEquals(door._level, level) || SafeRead(() => door.destroyed, true)) + continue; + var anchor = GetDoorStableAnchor(door); + var dx = anchor.X - x; + var dy = anchor.Y - y; + var distanceSq = dx * dx + dy * dy; + if (distanceSq < nearestSq) + { + nearestSq = distanceSq; + nearest = door; + } + } + catch + { + // Keep searching other doors. + } + } + + return nearest; + } + + private static Door? FindNearestDoor(Level level, double x, double y) => + FindNearestByPos(level, x, y, DoorPosTolerance * DoorPosTolerance * 4); + + + + + private void CheckAndCloseDoorsWhenNoOneNearby() + { + var level = ModEntry.me?._level; + if (level == null) + return; + + _scratchDoorsToRemove.Clear(); + _scratchDoorsToClose.Clear(); + foreach (var door in _openedDoors) + { + try + { + if (door == null || SafeRead(() => door.destroyed, true) || SafeRead(() => door.broken, false)) + { + _scratchDoorsToRemove.Add(door!); + continue; + } + if (!_doorHadAutoClose.TryGetValue(door, out var hadAutoClose) || !hadAutoClose) + continue; + if (!ReferenceEquals(door._level, level)) + continue; + + var (doorX, doorY) = GetDoorStableAnchor(door); + if (IsAnyPlayerNearby(level, doorX, doorY)) + continue; + if (SafeRead(() => door.broken, false)) + continue; + + _scratchDoorsToClose.Add(door); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Door auto-close check failed"); + } + } + + if (_scratchDoorsToClose.Count > 0) + { + for (var i = 0; i < _scratchDoorsToClose.Count; i++) + { + var door = _scratchDoorsToClose[i]; + _openedDoors.Remove(door); + try + { + int delayMs = DoorCloseDelayMs; + door.close(Ref.From(ref delayMs)); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] closeFast failed (door may be broken)"); + } + } + } + + if (_scratchDoorsToRemove.Count > 0) + { + for (var i = 0; i < _scratchDoorsToRemove.Count; i++) + _openedDoors.Remove(_scratchDoorsToRemove[i]); + } + } + + private static (double X, double Y) ComputeDoorStableAnchor(Door door) + { + if (door == null) + return (0, 0); + + // Door sprites can shift, tween, disappear, or be replaced during opening. The logical + // entity coordinates remain fixed at the doorway and are therefore safe to use as the + // cross-machine identity for button-controlled and pressure-plate doors. + try + { + var x = (door.cx + door.xr) * TileSizePx; + var y = (door.cy + door.yr) * TileSizePx; + if (IsFinitePosition(x, y)) + return (x, y); + } + catch + { + // Fall back to the render anchor for unusual scripted door variants. + } + + return GetEntityPixelPos(door); + } + private static bool TryReadBooleanMember(object instance, params string[] names) + { + if (instance == null) + return false; + + var type = instance.GetType(); + foreach (var name in names) + { + try + { + var property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (property?.CanRead == true && property.GetValue(instance) is bool propertyValue) + return propertyValue; + + var field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (field?.GetValue(instance) is bool fieldValue) + return fieldValue; + } + catch + { + // Ignore optional/generated members that cannot be read in this game build. + } + } + + return false; + } +} diff --git a/Interaction/InteractionSync.Elevators.cs b/Interaction/InteractionSync.Elevators.cs new file mode 100644 index 0000000..0dc0af1 --- /dev/null +++ b/Interaction/InteractionSync.Elevators.cs @@ -0,0 +1,304 @@ +using dc; +using dc.en; +using dc.en.inter; +using dc.hl.types; +using dc.pr; +using dc.tool.atk; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using HaxeProxy.Runtime; +using ModCore.Events; +using ModCore.Events.Interfaces.Game.Hero; +using Serilog; +using System.Reflection; + +namespace DeadCellsMultiplayerMod.Interaction; + +public partial class InteractionSync +{ + private const int ElevatorInterSendMinIntervalMs = 100; + + private void Hook_Elevator_onStep(Hook_Elevator.orig_onStep orig, Elevator self) + { + orig(self); + if (_applyingRemoteElevatorEvents || _applyingRemoteElevatorStateEvents) + return; + if (!IsNetReadyForSend(GameMenu.NetRef)) + return; + try + { + var now = System.Environment.TickCount64; + if (_elevatorLastInterSendTickMs.TryGetValue(self, out var last) && now - last < ElevatorInterSendMinIntervalMs) + return; + _elevatorLastInterSendTickMs[self] = now; + + var net = GameMenu.NetRef!; + var (x, y) = GetElevatorStableAnchor(self); + var sequence = ++_nextElevatorSequence; + var levelId = GetCurrentInteractionLevelId(); + net.SendInterElevator(net.id, x, y, sequence, levelId); + + // Host publishes platform state so clients stay aligned while the car moves. + if (net.IsHost) + { + var (px, py) = GetEntityPixelPos(self); + var moving = false; + try { moving = System.Math.Abs(self.speed) > 0.01; } catch { } + net.SendInterElevatorState(net.id, x, y, sequence, px, py, moving, levelId); + } + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Elevator send failed"); + } + } + + private static void TryApplyElevatorRemoteActivation(Elevator elevator) + { + if (elevator == null) + return; + + elevator.onStep(); + } + + private static (double x, double y) GetElevatorStableAnchor(Elevator e) + { + if (e == null) + return (0, 0); + try + { + return ((e.cx + e.xr) * TileSizePx, (e.cy + e.yr) * TileSizePx); + } + catch + { + return GetEntityPixelPos(e); + } + } + + private void ApplyRemoteElevatorEvents(List events) + { + var level = ModEntry.me?._level; + if (level == null || events == null || events.Count == 0) + return; + + var localId = GameMenu.NetRef?.id ?? 0; + _applyingRemoteElevatorEvents = true; + try + { + _scratchAppliedElevators.Clear(); + foreach (var ev in events) + { + if (ev.UserId > 0 && ev.UserId == localId) + continue; + if (!IsInteractionEventForCurrentLevel(ev.LevelId)) + continue; + + var elevator = FindElevatorByPos(level, ev.X, ev.Y); + if (elevator == null) + { + _log.Warning("[InteractionSync] No Elevator found at x={X} y={Y}", ev.X, ev.Y); + continue; + } + + if (!_scratchAppliedElevators.Add(elevator)) + continue; + + if (ev.Sequence > 0 && ev.UserId > 0) + { + var key = (elevator, ev.UserId); + if (_elevatorLastAppliedSequence.TryGetValue(key, out var lastSequence) && + ev.Sequence <= lastSequence) + { + continue; + } + _elevatorLastAppliedSequence[key] = ev.Sequence; + } + + try + { + TryApplyElevatorRemoteActivation(elevator); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Apply elevator event failed x={X} y={Y}", ev.X, ev.Y); + } + } + } + finally + { + _applyingRemoteElevatorEvents = false; + } + } + + private void ApplyRemoteElevatorStateEvents(List events) + { + var level = ModEntry.me?._level; + if (level == null || events == null || events.Count == 0) + return; + + var localId = GameMenu.NetRef?.id ?? 0; + _applyingRemoteElevatorStateEvents = true; + try + { + foreach (var ev in events) + { + if (ev.UserId > 0 && ev.UserId == localId) + continue; + if (!IsInteractionEventForCurrentLevel(ev.LevelId)) + continue; + + var elevator = FindElevatorByPos(level, ev.AnchorX, ev.AnchorY); + if (elevator == null) + continue; + + try + { + TryApplyElevatorRemoteState(elevator, ev); + } + catch (Exception ex) + { + _log.Warning( + ex, + "[InteractionSync] Apply elevator state failed anchor=({X},{Y}) platform=({PX},{PY})", + ev.AnchorX, + ev.AnchorY, + ev.PlatformX, + ev.PlatformY); + } + } + } + finally + { + _applyingRemoteElevatorStateEvents = false; + } + } + + private static void TryApplyElevatorRemoteState(Elevator elevator, InterElevatorStateEvent ev) + { + if (elevator == null) + return; + + var tileX = ev.PlatformX / TileSizePx; + var tileY = ev.PlatformY / TileSizePx; + var cx = (int)System.Math.Floor(tileX); + var cy = (int)System.Math.Floor(tileY); + elevator.cx = cx; + elevator.cy = cy; + elevator.xr = tileX - cx; + elevator.yr = tileY - cy; + if (!ev.Moving) + { + try { elevator.speed = 0; } catch { } + } + } + + private static Elevator? FindElevatorByStableAnchor(Level level, double anchorX, double anchorY) + { + var elevators = GetInteractionCandidates(level); + if (elevators == null || elevators.Count == 0) + return null; + + Elevator? nearest = null; + var nearestSq = ElevatorPosTolerance * ElevatorPosTolerance; + for (var i = 0; i < elevators.Count; i++) + { + var e = elevators[i]; + if (e == null) + continue; + try + { + var (ax, ay) = GetElevatorStableAnchor(e); + var dx = ax - anchorX; + var dy = ay - anchorY; + if (System.Math.Abs(dx) >= ElevatorPosTolerance || + System.Math.Abs(dy) >= ElevatorPosTolerance) + continue; + + var distanceSq = dx * dx + dy * dy; + if (distanceSq < nearestSq) + { + nearestSq = distanceSq; + nearest = e; + } + } + catch + { + // Keep searching other elevators. + } + } + + return nearest; + } + + private static Elevator? FindElevatorByTrackBounds(Level level, double x, double y) + { + var elevators = GetInteractionCandidates(level); + if (elevators == null || elevators.Count == 0) + return null; + + Elevator? nearest = null; + double nearestSq = double.MaxValue; + for (var i = 0; i < elevators.Count; i++) + { + var elevator = elevators[i]; + if (elevator == null) + continue; + + try + { + var leftPx = elevator.xLeft * TileSizePx - ElevatorPosTolerance; + var rightPx = (elevator.xRight + 1) * TileSizePx + ElevatorPosTolerance; + var topPx = elevator.yTop * TileSizePx - ElevatorPosTolerance; + var bottomPx = (elevator.yBottom + 1) * TileSizePx + ElevatorPosTolerance; + + if (x < leftPx || x > rightPx || y < topPx || y > bottomPx) + continue; + + var anchorX = elevator.spr?.x ?? ((elevator.cx + elevator.xr) * TileSizePx); + var anchorY = elevator.spr?.y ?? ((elevator.cy + elevator.yr) * TileSizePx); + var dx = anchorX - x; + var dy = anchorY - y; + var dSq = dx * dx + dy * dy; + if (dSq < nearestSq) + { + nearestSq = dSq; + nearest = elevator; + } + } + catch + { + // ignore bad elevator state + } + } + + return nearest; + } + + private static Elevator? FindElevatorInTriggers(Level level, double x, double y) => + FindNearestTriggerByPos(level, x, y, ElevatorPosTolerance * ElevatorPosTolerance * 4); + + private static VineLadder? FindVineLadderByPos(Level level, double x, double y) + { + return FindInteractByPos(level, x, y, PlatePosTolerance); + } + + + private static Elevator? FindElevatorByPos(Level level, double x, double y) + { + var byAnchor = FindElevatorByStableAnchor(level, x, y); + if (byAnchor != null) + return byAnchor; + + var byPos = FindInteractByPos(level, x, y, ElevatorPosTolerance); + if (byPos != null) + return byPos; + + var byTrack = FindElevatorByTrackBounds(level, x, y); + if (byTrack != null) + return byTrack; + + var nearest = FindNearestByPos(level, x, y, ElevatorPosTolerance * ElevatorPosTolerance * 4); + if (nearest != null) + return nearest; + return FindElevatorInTriggers(level, x, y); + } +} diff --git a/Interaction/InteractionSync.Lookup.cs b/Interaction/InteractionSync.Lookup.cs new file mode 100644 index 0000000..19573be --- /dev/null +++ b/Interaction/InteractionSync.Lookup.cs @@ -0,0 +1,224 @@ +using dc; +using dc.en; +using dc.en.inter; +using dc.hl.types; +using dc.pr; +using dc.tool.atk; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using HaxeProxy.Runtime; +using ModCore.Events; +using ModCore.Events.Interfaces.Game.Hero; +using Serilog; +using System.Reflection; + +namespace DeadCellsMultiplayerMod.Interaction; + +public partial class InteractionSync +{ + private static T? FindNearestByPos(Level level, double x, double y, double maxDistSq) where T : Entity + { + var candidates = GetInteractionCandidates(level); + if (candidates == null || candidates.Count == 0) + return null; + + T? nearest = null; + double nearestSq = maxDistSq; + for (var i = 0; i < candidates.Count; i++) + { + var e = candidates[i]; + if (e?.spr == null) continue; + try + { + var dx = e.spr.x - x; + var dy = e.spr.y - y; + var dSq = dx * dx + dy * dy; + if (dSq < nearestSq) + { + nearestSq = dSq; + nearest = e; + } + } + catch { } + } + return nearest; + } + + private static object? TryGetLevelTriggers(Level level) + { + try + { + var fromProperty = LevelTriggersProperty?.GetValue(level); + if (fromProperty != null) + return fromProperty; + return LevelTriggersField?.GetValue(level); + } + catch + { + return null; + } + } + + private static int GetTriggerArrayLength(object? triggers) + { + if (triggers is ArrayObj ao) + return ao.length; + if (triggers is ArrayDyn ad) + return ad.get_length(); + return 0; + } + + private static T? GetTriggerAt(object? triggers, int i) where T : class + { + if (triggers is ArrayObj ao) + return ao.getDyn(i) as T; + if (triggers is ArrayDyn ad) + return ad.getDyn(i) as T; + return null; + } + + private static T? FindNearestTriggerByPos(Level level, double x, double y, double maxDistSq) where T : Entity + { + try + { + var triggers = GetInteractionTriggerCandidates(level); + if (triggers == null || triggers.Count == 0) + return null; + + T? nearest = null; + var nearestSq = maxDistSq; + for (var i = 0; i < triggers.Count; i++) + { + var t = triggers[i]; + if (t?.spr == null) continue; + var dx = t.spr.x - x; + var dy = t.spr.y - y; + var dSq = dx * dx + dy * dy; + if (dSq < nearestSq) + { + nearestSq = dSq; + nearest = t; + } + } + return nearest; + } + catch + { + return null; + } + } + + private static LevelInteractionCache GetInteractionCache(Level level) + { + var entityCount = level.entities?.length ?? 0; + var triggerCount = GetTriggerArrayLength(TryGetLevelTriggers(level)); + if (!ReferenceEquals(_cachedInteractionLevel, level) || + entityCount != _cachedInteractionEntityCount || + triggerCount != _cachedInteractionTriggerCount) + { + RebuildInteractionCache(level); + } + + return CachedInteractionLevelData; + } + + private static void RebuildInteractionCache(Level? level) + { + CachedInteractionLevelData.Clear(); + _cachedInteractionLevel = level; + _cachedInteractionEntityCount = -1; + _cachedInteractionTriggerCount = -1; + + if (level == null) + return; + + var entities = level.entities; + _cachedInteractionEntityCount = entities?.length ?? 0; + if (entities != null) + { + for (var i = 0; i < entities.length; i++) + { + switch (entities.getDyn(i)) + { + case Door door: + CachedInteractionLevelData.Doors.Add(door); + break; + case Elevator elevator: + CachedInteractionLevelData.Elevators.Add(elevator); + break; + case VineLadder vineLadder: + CachedInteractionLevelData.VineLadders.Add(vineLadder); + break; + case Teleport teleport: + CachedInteractionLevelData.Teleports.Add(teleport); + break; + case Portal portal: + CachedInteractionLevelData.Portals.Add(portal); + break; + case PressurePlate pressurePlate: + CachedInteractionLevelData.PressurePlates.Add(pressurePlate); + break; + case TreasureChest treasureChest: + CachedInteractionLevelData.TreasureChests.Add(treasureChest); + break; + case SwitchBossRune switchBossRune: + CachedInteractionLevelData.SwitchBossRunes.Add(switchBossRune); + break; + } + } + } + + var triggers = TryGetLevelTriggers(level); + var triggerCount = GetTriggerArrayLength(triggers); + _cachedInteractionTriggerCount = triggerCount; + for (var i = 0; i < triggerCount; i++) + { + switch (GetTriggerAt(triggers, i)) + { + case Elevator elevator: + CachedInteractionLevelData.TriggerElevators.Add(elevator); + break; + case Teleport teleport: + CachedInteractionLevelData.TriggerTeleports.Add(teleport); + break; + case Portal portal: + CachedInteractionLevelData.TriggerPortals.Add(portal); + break; + } + } + } + + private static IReadOnlyList? GetInteractionCandidates(Level level) where T : Entity + { + var cache = GetInteractionCache(level); + if (typeof(T) == typeof(Door)) + return (IReadOnlyList)(object)cache.Doors; + if (typeof(T) == typeof(Elevator)) + return (IReadOnlyList)(object)cache.Elevators; + if (typeof(T) == typeof(VineLadder)) + return (IReadOnlyList)(object)cache.VineLadders; + if (typeof(T) == typeof(Teleport)) + return (IReadOnlyList)(object)cache.Teleports; + if (typeof(T) == typeof(Portal)) + return (IReadOnlyList)(object)cache.Portals; + if (typeof(T) == typeof(PressurePlate)) + return (IReadOnlyList)(object)cache.PressurePlates; + if (typeof(T) == typeof(TreasureChest)) + return (IReadOnlyList)(object)cache.TreasureChests; + if (typeof(T) == typeof(SwitchBossRune)) + return (IReadOnlyList)(object)cache.SwitchBossRunes; + return null; + } + + private static IReadOnlyList? GetInteractionTriggerCandidates(Level level) where T : Entity + { + var cache = GetInteractionCache(level); + if (typeof(T) == typeof(Elevator)) + return (IReadOnlyList)(object)cache.TriggerElevators; + if (typeof(T) == typeof(Teleport)) + return (IReadOnlyList)(object)cache.TriggerTeleports; + if (typeof(T) == typeof(Portal)) + return (IReadOnlyList)(object)cache.TriggerPortals; + return null; + } + +} diff --git a/Interaction/InteractionSync.Misc.cs b/Interaction/InteractionSync.Misc.cs new file mode 100644 index 0000000..3aabae5 --- /dev/null +++ b/Interaction/InteractionSync.Misc.cs @@ -0,0 +1,476 @@ +using dc; +using dc.en; +using dc.en.inter; +using dc.hl.types; +using dc.pr; +using dc.tool.atk; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using HaxeProxy.Runtime; +using ModCore.Events; +using ModCore.Events.Interfaces.Game.Hero; +using Serilog; +using System.Reflection; + +namespace DeadCellsMultiplayerMod.Interaction; + +public partial class InteractionSync +{ + private bool Hook_SwitchBossRune_canBeActivated(Hook_SwitchBossRune.orig_canBeActivated orig, SwitchBossRune self, Hero by) + { + var net = GameMenu.NetRef; + if(net != null && !net.IsHost) + return false; + return orig(self, by); + } + + private void Hook_SwitchBossRune_close(Hook_SwitchBossRune.orig_close orig, SwitchBossRune self) + { + orig(self); + + var net = GameMenu.NetRef; + if (!IsNetReadyForSend(net) || !net!.IsHost) + return; + + try + { + // updateCells already sends the visual +/- edge. close publishes only the final + // authoritative value so the peer cannot process duplicate boss-cell changes or + // schedule overlapping reloads. + var user = self?._level?.game?.user ?? dc.Main.Class.ME?.user; + if (user != null) + GameDataSync.SendBossRune(user, net); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Failed to send boss rune after SwitchBossRune.close"); + } + } + + private void Hook_SwitchBossRune_updateCells(Hook_SwitchBossRune.orig_updateCells orig, SwitchBossRune self, bool add) + { + var net = GameMenu.NetRef; + + // updateCells performs a native main-level rebuild. Dispose the old remote render shells + // before that rebuild starts; otherwise Boot.tryRender can visit a GhostKing whose sprite + // group has already been destroyed and crash Game.loadMainLevel with Null access .groupName. + if (IsNetReadyForSend(net) && net!.IsHost) + { + ModEntry.PrepareAndDisposeRemoteKingsForBossCellReload( + add ? "boss-rune-update:add" : "boss-rune-update:remove"); + } + + orig(self, add); + + if (!IsNetReadyForSend(net) || !net!.IsHost) + return; + + try + { + var (x, y) = GetEntityPixelPos(self); + net.SendInterBossRuneUpdateCells(x, y, add, GetCurrentInteractionLevelId()); + var user = self?._level?.game?.user ?? dc.Main.Class.ME?.user; + if (user != null) + GameDataSync.SendBossRune(user, net); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Failed to send boss rune updateCells"); + } + } + + private void Hook_TreasureChest_open(Hook_TreasureChest.orig_open orig, TreasureChest self, Hero by) + { + orig(self, by); + if (!_applyingRemoteChestEvents) + TrySendTreasureChestEvent(self); + } + + private void TrySendTreasureChestEvent(TreasureChest self) + { + TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterTreasureChest(x, y, GetCurrentInteractionLevelId()), "TreasureChest"); + } + + private void Hook_VineLadder_activate(Hook_VineLadder.orig_activate orig, VineLadder self) + { + orig(self); + TrySendVineLadderEvent(self); + } + + private void TrySendVineLadderEvent(VineLadder self) + { + if (_applyingRemoteVineLadderEvents) + return; + TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterVineLadder(x, y, GetCurrentInteractionLevelId()), "VineLadder"); + } + + private void Hook_Teleport_open(Hook_Teleport.orig_open orig, Teleport self) + { + orig(self); + TrySendTeleportEvent(self); + } + + private void Hook_Hero_breakBreakableGround(Hook_Hero.orig_breakBreakableGround orig, Hero self, int x, int y) + { + orig(self, x, y); + if (_applyingRemoteBreakableGroundEvents) + return; + var net = GameMenu.NetRef; + if (!IsNetReadyForSend(net) || ModEntry.me == null || !ReferenceEquals(self, ModEntry.me)) + return; + try + { + net!.SendInterBreakableGround(x, y, GetCurrentInteractionLevelId()); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] BreakableGround send failed"); + } + } + + private void TrySendTeleportEvent(Teleport self) + { + if (_applyingRemoteTeleportEvents) + return; + TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterTeleport(x, y, GetCurrentInteractionLevelId()), "Teleport"); + } + + private void Hook_Portal_show(Hook_Portal.orig_show orig, Portal self) + { + orig(self); + TrySendPortalEvent(self, "show"); + } + + private void Hook_Portal_close(Hook_Portal.orig_close orig, Portal self) + { + orig(self); + TrySendPortalEvent(self, "close"); + } + + private void TrySendPortalEvent(Portal self, string action) + { + if (_applyingRemotePortalEvents) + return; + if (!IsNetReadyForSend(GameMenu.NetRef)) + return; + try + { + var (x, y) = GetEntityPixelPos(self); + GameMenu.NetRef!.SendInterPortal(x, y, action, GetCurrentInteractionLevelId()); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Portal send failed action={Action}", action); + } + } + + private void ApplyRemoteBossRuneUpdateCells(List events) + { + var level = ModEntry.me?._level; + if (level?.entities == null || events == null || events.Count == 0) + return; + + foreach (var ev in events) + { + if (!IsInteractionEventForCurrentLevel(ev.LevelId)) + continue; + + var altar = FindSwitchBossRuneByPos(level, ev.X, ev.Y); + if (altar == null) + { + _log.Warning("[InteractionSync] No SwitchBossRune found at x={X} y={Y}", ev.X, ev.Y); + continue; + } + try + { + altar.updateCells(ev.Add); + GameDataSync.RequestBossRuneHudRefreshFromRemoteState(); + GameDataSync.PumpBossRuneHudRefresh(); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] updateCells(add={Add}) failed", ev.Add); + } + } + } + + private static SwitchBossRune? FindSwitchBossRuneByPos(Level level, double x, double y) + { + var found = FindInteractByPos(level, x, y, SwitchBossRunePosTolerance); + if (found != null) + return found; + + RebuildInteractionCache(level); + found = FindInteractByPos(level, x, y, SwitchBossRunePosTolerance * 2.0); + if (found != null) + return found; + + return FindNearestByPos(level, x, y, 96.0 * 96.0); + } + + private void ApplyRemoteVineLadderEvents(List events) + { + var level = ModEntry.me?._level; + if (level?.entities == null || events == null || events.Count == 0) + return; + + _applyingRemoteVineLadderEvents = true; + try + { + foreach (var ev in events) + { + if (!IsInteractionEventForCurrentLevel(ev.LevelId)) + continue; + + var vineLadder = FindVineLadderByPos(level, ev.X, ev.Y); + if (vineLadder == null) + continue; + + try + { + vineLadder.activate(); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Apply vine ladder event failed x={X} y={Y}", ev.X, ev.Y); + } + } + } + finally + { + _applyingRemoteVineLadderEvents = false; + } + } + + private void ApplyRemotePortalEvents(List events) + { + var level = ModEntry.me?._level; + if (level == null || events == null || events.Count == 0) + return; + + _applyingRemotePortalEvents = true; + try + { + foreach (var ev in events) + { + if (!IsInteractionEventForCurrentLevel(ev.LevelId)) + continue; + + var portal = FindPortalByPos(level, ev.X, ev.Y); + if (portal == null) + continue; + + try + { + if (ev.Action == "show") + portal.show(); + else if (ev.Action == "close") + portal.close(); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Apply portal event failed x={X} y={Y} action={Action}", ev.X, ev.Y, ev.Action); + } + } + } + finally + { + _applyingRemotePortalEvents = false; + } + } + + private void ApplyRemoteTeleportEvents(List events) + { + var level = ModEntry.me?._level; + if (level?.entities == null || events == null || events.Count == 0) + return; + + _applyingRemoteTeleportEvents = true; + try + { + foreach (var ev in events) + { + if (!IsInteractionEventForCurrentLevel(ev.LevelId)) + continue; + + var teleport = FindTeleportByPos(level, ev.X, ev.Y); + if (teleport == null) + { + continue; + } + + try + { + teleport.open(); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Apply teleport event failed x={X} y={Y}", ev.X, ev.Y); + } + } + } + finally + { + _applyingRemoteTeleportEvents = false; + } + } + + private void ApplyRemoteBreakableGroundEvents(List events) + { + var hero = ModEntry.me; + if (hero == null || events == null || events.Count == 0) + return; + + _applyingRemoteBreakableGroundEvents = true; + try + { + _scratchAppliedBreakableGround.Clear(); + foreach (var ev in events) + { + if (!IsInteractionEventForCurrentLevel(ev.LevelId)) + continue; + + var alreadyNearby = false; + for (var i = 0; i < _scratchAppliedBreakableGround.Count; i++) + { + var (ax, ay) = _scratchAppliedBreakableGround[i]; + if (System.Math.Abs(ax - ev.X) <= BreakableGroundPosTolerance && System.Math.Abs(ay - ev.Y) <= BreakableGroundPosTolerance) + { + alreadyNearby = true; + break; + } + } + if (alreadyNearby) + continue; + + var cx = (int)System.Math.Round(ev.X); + var cy = (int)System.Math.Round(ev.Y); + _scratchAppliedBreakableGround.Add((ev.X, ev.Y)); + + try + { + hero.breakBreakableGround(cx, cy); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Apply breakable ground failed x={X} y={Y}", cx, cy); + } + } + } + finally + { + _applyingRemoteBreakableGroundEvents = false; + } + } + + private Teleport? FindTeleportByPos(Level level, double x, double y) + { + var byPos = FindInteractByPos(level, x, y, TeleportPosTolerance); + if (byPos != null) + return byPos; + var nearest = FindNearestByPos(level, x, y, TeleportPosTolerance * TeleportPosTolerance * 4); + if (nearest != null) + return nearest; + return FindTeleportInTriggers(level, x, y); + } + + private static Portal? FindPortalByPos(Level level, double x, double y) + { + var byPos = FindInteractByPos(level, x, y, PortalPosTolerance); + if (byPos != null) + return byPos; + var nearest = FindNearestByPos(level, x, y, PortalPosTolerance * PortalPosTolerance * 4); + if (nearest != null) + return nearest; + return FindPortalInTriggers(level, x, y); + } + + private static Portal? FindPortalInTriggers(Level level, double x, double y) => + FindNearestTriggerByPos(level, x, y, PortalPosTolerance * PortalPosTolerance * 4); + + private static Teleport? FindTeleportInTriggers(Level level, double x, double y) => + FindNearestTriggerByPos(level, x, y, TeleportPosTolerance * TeleportPosTolerance * 4); + + private static PressurePlate? FindPressurePlateByPos(Level level, double x, double y) + { + return FindInteractByPos(level, x, y, PlatePosTolerance); + } + + private void ApplyRemoteTreasureChestEvents(List events) + { + var level = ModEntry.me?._level; + if (level?.entities == null || events == null || events.Count == 0) + return; + + var localHero = ModEntry.me; + if (localHero == null) + return; + + _applyingRemoteChestEvents = true; + try + { + foreach (var ev in events) + { + if (!IsInteractionEventForCurrentLevel(ev.LevelId)) + continue; + + var chest = FindTreasureChestByPos(level, ev.X, ev.Y); + if (chest == null) + continue; + + try + { + chest.open(localHero); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Apply treasure chest event failed x={X} y={Y}", ev.X, ev.Y); + } + } + } + finally + { + _applyingRemoteChestEvents = false; + } + } + + private static TreasureChest? FindTreasureChestByPos(Level level, double x, double y) + { + var byPos = FindInteractByPos(level, x, y, ChestPosTolerance); + if (byPos != null) + return byPos; + return FindNearestTreasureChest(level, x, y); + } + + private static TreasureChest? FindNearestTreasureChest(Level level, double x, double y) => + FindNearestByPos(level, x, y, ChestPosTolerance * ChestPosTolerance * 4); + + private static T? FindInteractByPos(Level level, double x, double y, double tolerance = PosTolerance) where T : Entity + { + var candidates = GetInteractionCandidates(level); + if (candidates == null || candidates.Count == 0) + return null; + + for (var i = 0; i < candidates.Count; i++) + { + var e = candidates[i]; + if (e == null) + continue; + try + { + if (e.spr != null && + System.Math.Abs(e.spr.x - x) < tolerance && + System.Math.Abs(e.spr.y - y) < tolerance) + { + return e; + } + } + catch + { + // ignore + } + } + + return null; + } + +} diff --git a/Interaction/InteractionSync.Plates.cs b/Interaction/InteractionSync.Plates.cs new file mode 100644 index 0000000..a453072 --- /dev/null +++ b/Interaction/InteractionSync.Plates.cs @@ -0,0 +1,96 @@ +using dc; +using dc.en; +using dc.en.inter; +using dc.hl.types; +using dc.pr; +using dc.tool.atk; +using DeadCellsMultiplayerMod.Interface.ModuleInitializing; +using HaxeProxy.Runtime; +using ModCore.Events; +using ModCore.Events.Interfaces.Game.Hero; +using Serilog; +using System.Reflection; + +namespace DeadCellsMultiplayerMod.Interaction; + +public partial class InteractionSync +{ + private void Hook_PressurePlate_trigger(Hook_PressurePlate.orig_trigger orig, PressurePlate self, Entity by) + { + orig(self, by); + TrySendPressurePlateEvent(self); + } + + private void TrySendPressurePlateEvent(PressurePlate self) + { + if (_applyingRemotePressurePlateEvents) + return; + + var net = GameMenu.NetRef; + if (!IsNetReadyForSend(net)) + return; + + TrySendInteractEvent( + self, + (x, y) => net!.SendInterPressurePlate( + net.id, + x, + y, + ++_nextPressurePlateSequence, + GetCurrentInteractionLevelId()), + "PressurePlate"); + } + + private void ApplyRemotePressurePlateEvents(List events) + { + var level = ModEntry.me?._level; + if (level?.entities == null || events == null || events.Count == 0) + return; + + var localHero = ModEntry.me as Entity; + var localId = GameMenu.NetRef?.id ?? 0; + if (localHero == null) + return; + + _applyingRemotePressurePlateEvents = true; + try + { + foreach (var ev in events) + { + if (ev.UserId > 0 && ev.UserId == localId) + continue; + if (!IsInteractionEventForCurrentLevel(ev.LevelId)) + continue; + + var plate = FindPressurePlateByPos(level, ev.X, ev.Y); + if (plate == null) + continue; + + if (ev.Sequence > 0 && ev.UserId > 0) + { + var key = (plate, ev.UserId); + if (_pressurePlateLastAppliedSequence.TryGetValue(key, out var lastSequence) && + ev.Sequence <= lastSequence) + { + continue; + } + _pressurePlateLastAppliedSequence[key] = ev.Sequence; + } + + try + { + plate.trigger(localHero); + } + catch (Exception ex) + { + _log.Warning(ex, "[InteractionSync] Apply pressure plate event failed x={X} y={Y}", ev.X, ev.Y); + } + } + } + finally + { + _applyingRemotePressurePlateEvents = false; + } + } + +} diff --git a/Interaction/InteractionSync.cs b/Interaction/InteractionSync.cs index 6b6b724..383bcf7 100644 --- a/Interaction/InteractionSync.cs +++ b/Interaction/InteractionSync.cs @@ -13,7 +13,7 @@ namespace DeadCellsMultiplayerMod.Interaction; -public class InteractionSync : +public partial class InteractionSync : IEventReceiver, IOnAdvancedModuleInitializing, IOnHeroUpdate @@ -49,48 +49,94 @@ public void Clear() } private const double PosTolerance = 1.0; + private const double PlatePosTolerance = 8.0; + private const double ChestPosTolerance = 16.0; + private const double DoorPosTolerance = 16.0; + private const double TeleportPosTolerance = 48.0; + private const double BreakableGroundPosTolerance = 24.0; + private const double SwitchBossRunePosTolerance = 32.0; + private const double ElevatorPosTolerance = 48.0; + private const double PortalPosTolerance = 48.0; + private const double TileSizePx = 24.0; + private const double DoorProximityRadiusPx = 100.0; + private static readonly double DoorProximityRadiusSq = DoorProximityRadiusPx * DoorProximityRadiusPx; + private const int DoorCloseDelayMs = 250; + private const int DoorStateHeartbeatMs = 2000; private readonly ILogger _log; + private readonly HashSet _openedDoors = new(); + private readonly Dictionary _doorHadAutoClose = new(); + private readonly Dictionary _doorStableAnchors = new(); + private readonly List _scratchDoorsToRemove = new(); + private readonly List _scratchDoorsToClose = new(); + private readonly HashSet _scratchAppliedElevators = new(); + private readonly List<(double X, double Y)> _scratchAppliedBreakableGround = new(); + private static readonly PropertyInfo? LevelTriggersProperty = typeof(Level).GetProperty("triggers", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + private static readonly FieldInfo? LevelTriggersField = typeof(Level).GetField("triggers", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + private static readonly LevelInteractionCache CachedInteractionLevelData = new(); + private static Level? _cachedInteractionLevel; + private static int _cachedInteractionEntityCount = -1; + private static int _cachedInteractionTriggerCount = -1; + private bool _applyingRemoteDoorEvents; + private bool _applyingRemoteChestEvents; + private bool _applyingRemotePressurePlateEvents; + private bool _applyingRemoteVineLadderEvents; + private bool _applyingRemoteTeleportEvents; + private bool _applyingRemoteBreakableGroundEvents; + private bool _applyingRemotePortalEvents; + private bool _applyingRemoteElevatorEvents; + + private bool _applyingRemoteElevatorStateEvents; + /// Throttle elevator activation pulses — onStep can fire every frame while riding. private readonly Dictionary _elevatorLastInterSendTickMs = new(); + + private readonly Dictionary<(Elevator Elevator, int UserId), long> _elevatorLastAppliedSequence = new(); + private readonly Dictionary<(PressurePlate Plate, int UserId), long> _pressurePlateLastAppliedSequence = new(); + private readonly Dictionary _lastAuthoritativeDoorState = new(); + private readonly Dictionary _lastDoorStateSentTickMs = new(); + private Level? _interactionRuntimeLevel; + + private long _nextElevatorSequence; + private long _nextPressurePlateSequence; public InteractionSync(ModEntry entry) @@ -122,288 +168,6 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) Hook_SwitchBossRune.updateCells += Hook_SwitchBossRune_updateCells; } - - private bool Hook_SwitchBossRune_canBeActivated(Hook_SwitchBossRune.orig_canBeActivated orig, SwitchBossRune self, Hero by) - { - var net = GameMenu.NetRef; - if(net != null && !net.IsHost) - return false; - return orig(self, by); - } - - private void Hook_SwitchBossRune_close(Hook_SwitchBossRune.orig_close orig, SwitchBossRune self) - { - orig(self); - - var net = GameMenu.NetRef; - if (!IsNetReadyForSend(net) || !net!.IsHost) - return; - - try - { - // updateCells already sends the visual +/- edge. close publishes only the final - // authoritative value so the peer cannot process duplicate boss-cell changes or - // schedule overlapping reloads. - var user = self?._level?.game?.user ?? dc.Main.Class.ME?.user; - if (user != null) - GameDataSync.SendBossRune(user, net); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Failed to send boss rune after SwitchBossRune.close"); - } - } - - private void Hook_SwitchBossRune_updateCells(Hook_SwitchBossRune.orig_updateCells orig, SwitchBossRune self, bool add) - { - var net = GameMenu.NetRef; - - // updateCells performs a native main-level rebuild. Dispose the old remote render shells - // before that rebuild starts; otherwise Boot.tryRender can visit a GhostKing whose sprite - // group has already been destroyed and crash Game.loadMainLevel with Null access .groupName. - if (IsNetReadyForSend(net) && net!.IsHost) - { - ModEntry.PrepareAndDisposeRemoteKingsForBossCellReload( - add ? "boss-rune-update:add" : "boss-rune-update:remove"); - } - - orig(self, add); - - if (!IsNetReadyForSend(net) || !net!.IsHost) - return; - - try - { - var (x, y) = GetEntityPixelPos(self); - net.SendInterBossRuneUpdateCells(x, y, add); - var user = self?._level?.game?.user ?? dc.Main.Class.ME?.user; - if (user != null) - GameDataSync.SendBossRune(user, net); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Failed to send boss rune updateCells"); - } - } - - private void Hook_Door_init(Hook_Door.orig_init orig, Door self) - { - orig(self); - if (self == null) - return; - - _doorStableAnchors[self] = ComputeDoorStableAnchor(self); - var net = GameMenu.NetRef; - if (net != null && net.IsAlive) - { - _doorHadAutoClose[self] = SafeRead(() => self.autoClose, false); - self.autoClose = false; - } - } - - private void Hook_Door_open(Hook_Door.orig_open orig, Door self, int durationMs, int? finalRatio, double? _tween) - { - orig(self, durationMs, finalRatio, _tween); - _openedDoors.Add(self); - TrySendDoorEvent(self, "open"); - } - - private void Hook_Door_close(Hook_Door.orig_close orig, Door self, Ref delayMs) - { - orig(self, delayMs); - _openedDoors.Remove(self); - TrySendDoorEvent(self, "close"); - } - - private void Hook_Door_onDamage(Hook_Door.orig_onDamage orig, Door self, AttackData a) - { - orig(self, a); - TrySendDoorEvent(self, "damage"); - } - - private void Hook_Door_onDie(Hook_Door.orig_onDie orig, Door self) - { - orig(self); - _openedDoors.Remove(self); - TrySendDoorEvent(self, "die"); - } - - private void TrySendDoorEvent(Door self, string action) - { - if (_applyingRemoteDoorEvents) - return; - var net = GameMenu.NetRef; - if (!IsNetReadyForSend(net)) - return; - try - { - var (x, y) = GetDoorStableAnchor(self); - var broken = action == "die" || SafeRead(() => self.broken, false); - net!.SendInterDoor(net.id, x, y, action, broken, GetCurrentInteractionLevelId()); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Door send failed"); - } - } - - private void Hook_Elevator_onStep(Hook_Elevator.orig_onStep orig, Elevator self) - { - orig(self); - if (_applyingRemoteElevatorEvents) - return; - if (!IsNetReadyForSend(GameMenu.NetRef)) - return; - try - { - var now = System.Environment.TickCount64; - if (_elevatorLastInterSendTickMs.TryGetValue(self, out var last) && now - last < ElevatorInterSendMinIntervalMs) - return; - _elevatorLastInterSendTickMs[self] = now; - - var (x, y) = GetElevatorStableAnchor(self); - GameMenu.NetRef!.SendInterElevator(x, y); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Elevator send failed"); - } - } - - private const int ElevatorInterSendMinIntervalMs = 100; - private static void TryApplyElevatorRemoteActivation(Elevator elevator) - { - if (elevator == null) - return; - - elevator.onStep(); - } - - private static (double x, double y) GetElevatorStableAnchor(Elevator e) - { - if (e == null) - return (0, 0); - try - { - return ((e.cx + e.xr) * TileSizePx, (e.cy + e.yr) * TileSizePx); - } - catch - { - return GetEntityPixelPos(e); - } - } - - private void Hook_PressurePlate_trigger(Hook_PressurePlate.orig_trigger orig, PressurePlate self, Entity by) - { - orig(self, by); - TrySendPressurePlateEvent(self); - } - - private void TrySendPressurePlateEvent(PressurePlate self) - { - if (_applyingRemotePressurePlateEvents) - return; - - var net = GameMenu.NetRef; - if (!IsNetReadyForSend(net)) - return; - - TrySendInteractEvent( - self, - (x, y) => net!.SendInterPressurePlate( - net.id, - x, - y, - ++_nextPressurePlateSequence, - GetCurrentInteractionLevelId()), - "PressurePlate"); - } - - private void Hook_TreasureChest_open(Hook_TreasureChest.orig_open orig, TreasureChest self, Hero by) - { - orig(self, by); - if (!_applyingRemoteChestEvents) - TrySendTreasureChestEvent(self); - } - - private void TrySendTreasureChestEvent(TreasureChest self) - { - TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterTreasureChest(x, y), "TreasureChest"); - } - - private void Hook_VineLadder_activate(Hook_VineLadder.orig_activate orig, VineLadder self) - { - orig(self); - TrySendVineLadderEvent(self); - } - - private void TrySendVineLadderEvent(VineLadder self) - { - if (_applyingRemoteVineLadderEvents) - return; - TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterVineLadder(x, y), "VineLadder"); - } - - private void Hook_Teleport_open(Hook_Teleport.orig_open orig, Teleport self) - { - orig(self); - TrySendTeleportEvent(self); - } - - private void Hook_Hero_breakBreakableGround(Hook_Hero.orig_breakBreakableGround orig, Hero self, int x, int y) - { - orig(self, x, y); - if (_applyingRemoteBreakableGroundEvents) - return; - var net = GameMenu.NetRef; - if (!IsNetReadyForSend(net) || ModEntry.me == null || !ReferenceEquals(self, ModEntry.me)) - return; - try - { - net!.SendInterBreakableGround(x, y); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] BreakableGround send failed"); - } - } - - private void TrySendTeleportEvent(Teleport self) - { - if (_applyingRemoteTeleportEvents) - return; - TrySendInteractEvent(self, (x, y) => GameMenu.NetRef!.SendInterTeleport(x, y), "Teleport"); - } - - private void Hook_Portal_show(Hook_Portal.orig_show orig, Portal self) - { - orig(self); - TrySendPortalEvent(self, "show"); - } - - private void Hook_Portal_close(Hook_Portal.orig_close orig, Portal self) - { - orig(self); - TrySendPortalEvent(self, "close"); - } - - private void TrySendPortalEvent(Portal self, string action) - { - if (_applyingRemotePortalEvents) - return; - if (!IsNetReadyForSend(GameMenu.NetRef)) - return; - try - { - var (x, y) = GetEntityPixelPos(self); - GameMenu.NetRef!.SendInterPortal(x, y, action); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Portal send failed action={Action}", action); - } - } - private static (double x, double y) GetEntityPixelPos(Entity e) { if (e?.spr == null) @@ -423,42 +187,6 @@ private static bool IsFinitePosition(double x, double y) return double.IsFinite(x) && double.IsFinite(y); } - private static (double X, double Y) ComputeDoorStableAnchor(Door door) - { - if (door == null) - return (0, 0); - - // Door sprites can shift, tween, disappear, or be replaced during opening. The logical - // entity coordinates remain fixed at the doorway and are therefore safe to use as the - // cross-machine identity for button-controlled and pressure-plate doors. - try - { - var x = (door.cx + door.xr) * TileSizePx; - var y = (door.cy + door.yr) * TileSizePx; - if (IsFinitePosition(x, y)) - return (x, y); - } - catch - { - // Fall back to the render anchor for unusual scripted door variants. - } - - return GetEntityPixelPos(door); - } - - private (double X, double Y) GetDoorStableAnchor(Door door) - { - if (door == null) - return (0, 0); - - if (_doorStableAnchors.TryGetValue(door, out var anchor)) - return anchor; - - anchor = ComputeDoorStableAnchor(door); - _doorStableAnchors[door] = anchor; - return anchor; - } - private static string GetCurrentInteractionLevelId() { try @@ -533,10 +261,8 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) if (net.TryConsumeInterElevatorEvents(out var elevEvents)) ApplyAndRelease(elevEvents, ApplyRemoteElevatorEvents); - // Legacy elevator mode deliberately ignores authoritative position snapshots. Consume and - // release any stale packet from a mismatched peer, but never move elevator/entity coordinates. if (net.TryConsumeInterElevatorStateEvents(out var elevatorStateEvents)) - NetNode.ReleaseConsumedList(elevatorStateEvents); + ApplyAndRelease(elevatorStateEvents, ApplyRemoteElevatorStateEvents); if (net.TryConsumeInterPressurePlateEvents(out var plateEvents)) ApplyAndRelease(plateEvents, ApplyRemotePressurePlateEvents); @@ -566,112 +292,6 @@ void IOnHeroUpdate.OnHeroUpdate(double dt) ApplyAndRelease(updateCellsEvents, ApplyRemoteBossRuneUpdateCells); } - private void CheckAndCloseDoorsWhenNoOneNearby() - { - var level = ModEntry.me?._level; - if (level == null) - return; - - _scratchDoorsToRemove.Clear(); - _scratchDoorsToClose.Clear(); - foreach (var door in _openedDoors) - { - try - { - if (door == null || SafeRead(() => door.destroyed, true) || SafeRead(() => door.broken, false)) - { - _scratchDoorsToRemove.Add(door!); - continue; - } - if (!_doorHadAutoClose.TryGetValue(door, out var hadAutoClose) || !hadAutoClose) - continue; - if (!ReferenceEquals(door._level, level)) - continue; - - var (doorX, doorY) = GetDoorStableAnchor(door); - if (IsAnyPlayerNearby(level, doorX, doorY)) - continue; - if (SafeRead(() => door.broken, false)) - continue; - - _scratchDoorsToClose.Add(door); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Door auto-close check failed"); - } - } - - if (_scratchDoorsToClose.Count > 0) - { - for (var i = 0; i < _scratchDoorsToClose.Count; i++) - { - var door = _scratchDoorsToClose[i]; - _openedDoors.Remove(door); - try - { - int delayMs = DoorCloseDelayMs; - door.close(Ref.From(ref delayMs)); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] closeFast failed (door may be broken)"); - } - } - } - - if (_scratchDoorsToRemove.Count > 0) - { - for (var i = 0; i < _scratchDoorsToRemove.Count; i++) - _openedDoors.Remove(_scratchDoorsToRemove[i]); - } - } - - private static bool IsAnyPlayerNearby(Level level, double doorX, double doorY) - { - var hero = ModEntry.me; - if (hero != null && ReferenceEquals(hero._level, level)) - { - if (!SafeRead(() => hero.destroyed, true) && SafeRead(() => hero.life, 0) > 0) - { - var (hx, hy) = GetEntityPixelPos(hero); - var dx = hx - doorX; - var dy = hy - doorY; - if (dx * dx + dy * dy <= DoorProximityRadiusSq) - return true; - } - } - - for (var i = 0; i < ModEntry.clients.Length; i++) - { - var client = ModEntry.clients[i]; - if (client == null) - continue; - if (!ReferenceEquals(client._level, level)) - continue; - if (SafeRead(() => client.destroyed, true) || SafeRead(() => client.life, 0) <= 0) - continue; - - var (cx, cy) = GetEntityPixelPos(client); - var dx = cx - doorX; - var dy = cy - doorY; - if (dx * dx + dy * dy <= DoorProximityRadiusSq) - return true; - } - - return false; - } - - private void ApplyDoorDie(Door door) - { - _openedDoors.Remove(door); - if (!SafeRead(() => door.broken, false)) - { - door.life = 0; - door.onDie(); - } - } - private void EnsureInteractionRuntimeLevel(Level? level) { if (ReferenceEquals(_interactionRuntimeLevel, level)) @@ -712,960 +332,8 @@ private void EnsureInteractionRuntimeLevel(Level? level) _lastAuthoritativeDoorState.Clear(); _lastDoorStateSentTickMs.Clear(); _elevatorLastInterSendTickMs.Clear(); + _elevatorLastAppliedSequence.Clear(); _pressurePlateLastAppliedSequence.Clear(); } - private void BroadcastAuthoritativeDoorStates(NetNode net) - { - var level = ModEntry.me?._level; - if (level == null || !net.IsHost || !IsNetReadyForSend(net)) - return; - - var now = System.Environment.TickCount64; - var cache = GetInteractionCache(level); - - for (var i = 0; i < cache.Doors.Count; i++) - { - var door = cache.Doors[i]; - if (door == null) - continue; - - var state = GetAuthoritativeDoorState(door); - var changed = !_lastAuthoritativeDoorState.TryGetValue(door, out var previous) || - !string.Equals(previous, state, StringComparison.Ordinal); - var activeHeartbeat = state != "state_closed" && - (!_lastDoorStateSentTickMs.TryGetValue(door, out var lastSent) || - now - lastSent >= DoorStateHeartbeatMs); - if (!changed && !activeHeartbeat) - continue; - - _lastAuthoritativeDoorState[door] = state; - _lastDoorStateSentTickMs[door] = now; - var (x, y) = GetDoorStableAnchor(door); - net.SendInterDoor(net.id, x, y, state, state == "state_broken", GetCurrentInteractionLevelId()); - } - } - - private string GetAuthoritativeDoorState(Door door) - { - if (SafeRead(() => door.broken, false)) - return "state_broken"; - - // A locked door is script-controlled (boss arena seals). Never advertise it as open: - // the stale _openedDoors entry from walking through it earlier otherwise made the 2s - // heartbeat force the client's sealed door back open for the whole fight. - if (TryReadBooleanMember(door, "locked", "isLocked")) - return "state_closed"; - - if (TryReadBooleanMember(door, "opened", "isOpen", "open")) - return "state_open"; - - var ratio = TryReadNumericMember(door, "ratio", "openRatio", "openingRatio", "curRatio"); - if (ratio.HasValue) - return ratio.Value > 0.45 ? "state_open" : "state_closed"; - - // Only when no native state is readable at all, fall back to the open-hook cache. - return _openedDoors.Contains(door) ? "state_open" : "state_closed"; - } - - private bool ShouldRejectRemoteDoorOpen(Door door) - { - // Script-sealed doors (boss arena locks) must never be reopened by replayed remote - // events or heartbeats; the local fight script owns them until the encounter ends. - if (TryReadBooleanMember(door, "locked", "isLocked")) - return true; - - return DeadCellsMultiplayerMod.Mobs.MobsSynchronization.MobsSynchronization.HasLivingTrackedBoss(); - } - - private static bool TryReadBooleanMember(object instance, params string[] names) - { - if (instance == null) - return false; - - var type = instance.GetType(); - foreach (var name in names) - { - try - { - var property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (property?.CanRead == true && property.GetValue(instance) is bool propertyValue) - return propertyValue; - - var field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (field?.GetValue(instance) is bool fieldValue) - return fieldValue; - } - catch - { - // Ignore optional/generated members that cannot be read in this game build. - } - } - - return false; - } - - private static double? TryReadNumericMember(object instance, params string[] names) - { - if (instance == null) - return null; - - var type = instance.GetType(); - foreach (var name in names) - { - try - { - var property = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (property?.CanRead == true) - { - var value = property.GetValue(instance); - if (value != null) - return Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture); - } - - var field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - var fieldValue = field?.GetValue(instance); - if (fieldValue != null) - return Convert.ToDouble(fieldValue, System.Globalization.CultureInfo.InvariantCulture); - } - catch - { - // Ignore optional/generated members that cannot be read in this game build. - } - } - - return null; - } - - private void ApplyRemoteBossRuneUpdateCells(List events) - { - var level = ModEntry.me?._level; - if (level?.entities == null || events == null || events.Count == 0) - return; - - foreach (var ev in events) - { - var altar = FindSwitchBossRuneByPos(level, ev.X, ev.Y); - if (altar == null) - { - _log.Warning("[InteractionSync] No SwitchBossRune found at x={X} y={Y}", ev.X, ev.Y); - continue; - } - try - { - altar.updateCells(ev.Add); - GameDataSync.RequestBossRuneHudRefreshFromRemoteState(); - GameDataSync.PumpBossRuneHudRefresh(); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] updateCells(add={Add}) failed", ev.Add); - } - } - } - - private static SwitchBossRune? FindSwitchBossRuneByPos(Level level, double x, double y) - { - var found = FindInteractByPos(level, x, y, SwitchBossRunePosTolerance); - if (found != null) - return found; - - RebuildInteractionCache(level); - found = FindInteractByPos(level, x, y, SwitchBossRunePosTolerance * 2.0); - if (found != null) - return found; - - var candidates = GetInteractionCandidates(level); - if (candidates != null && candidates.Count == 1) - return candidates[0]; - - return FindNearestByPos(level, x, y, 256.0 * 256.0); - } - - private void ApplyRemoteDoorEvents(List events) - { - var level = ModEntry.me?._level; - if (level?.entities == null || events == null || events.Count == 0) - return; - - _applyingRemoteDoorEvents = true; - try - { - var localId = GameMenu.NetRef?.id ?? 0; - foreach (var ev in events) - { - if (ev.UserId == localId) - continue; - if (!IsInteractionEventForCurrentLevel(ev.LevelId)) - continue; - - var door = FindDoorByPos(level, ev.X, ev.Y); - if (door == null) - continue; - - try - { - switch (ev.Action) - { - case "open": - if (ShouldRejectRemoteDoorOpen(door)) - break; - door.open(300, null, null); - break; - case "close": - if (SafeRead(() => door.broken, false)) - break; - _openedDoors.Remove(door); - try - { - int delayMs = DoorCloseDelayMs; - door.close(Ref.From(ref delayMs)); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] close failed (door may be broken)"); - } - break; - case "damage": - if (ev.Broken) - ApplyDoorDie(door); - break; - case "die": - case "state_broken": - ApplyDoorDie(door); - break; - case "state_open": - if (!SafeRead(() => door.broken, false) && !ShouldRejectRemoteDoorOpen(door)) - { - door.open(180, null, null); - _openedDoors.Add(door); - } - break; - case "state_closed": - if (!SafeRead(() => door.broken, false)) - { - _openedDoors.Remove(door); - int stateDelayMs = 0; - door.close(Ref.From(ref stateDelayMs)); - } - break; - } - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Apply door event failed x={X} y={Y} action={Action}", ev.X, ev.Y, ev.Action); - } - } - } - finally - { - _applyingRemoteDoorEvents = false; - } - } - - private void ApplyRemoteElevatorEvents(List events) - { - var level = ModEntry.me?._level; - if (level == null || events == null || events.Count == 0) - return; - - _applyingRemoteElevatorEvents = true; - try - { - _scratchAppliedElevators.Clear(); - foreach (var ev in events) - { - var elevator = FindElevatorByPos(level, ev.X, ev.Y); - if (elevator == null) - { - _log.Warning("[InteractionSync] No Elevator found at x={X} y={Y}", ev.X, ev.Y); - continue; - } - - if (!_scratchAppliedElevators.Add(elevator)) - continue; - - try - { - TryApplyElevatorRemoteActivation(elevator); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Apply elevator event failed x={X} y={Y}", ev.X, ev.Y); - } - } - } - finally - { - _applyingRemoteElevatorEvents = false; - } - } - - private void ApplyRemoteVineLadderEvents(List events) - { - var level = ModEntry.me?._level; - if (level?.entities == null || events == null || events.Count == 0) - return; - - _applyingRemoteVineLadderEvents = true; - try - { - foreach (var ev in events) - { - var vineLadder = FindVineLadderByPos(level, ev.X, ev.Y); - if (vineLadder == null) - continue; - - try - { - vineLadder.activate(); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Apply vine ladder event failed x={X} y={Y}", ev.X, ev.Y); - } - } - } - finally - { - _applyingRemoteVineLadderEvents = false; - } - } - - private void ApplyRemotePortalEvents(List events) - { - var level = ModEntry.me?._level; - if (level == null || events == null || events.Count == 0) - return; - - _applyingRemotePortalEvents = true; - try - { - foreach (var ev in events) - { - var portal = FindPortalByPos(level, ev.X, ev.Y); - if (portal == null) - continue; - - try - { - if (ev.Action == "show") - portal.show(); - else if (ev.Action == "close") - portal.close(); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Apply portal event failed x={X} y={Y} action={Action}", ev.X, ev.Y, ev.Action); - } - } - } - finally - { - _applyingRemotePortalEvents = false; - } - } - - private void ApplyRemoteTeleportEvents(List events) - { - var level = ModEntry.me?._level; - if (level?.entities == null || events == null || events.Count == 0) - return; - - _applyingRemoteTeleportEvents = true; - try - { - foreach (var ev in events) - { - var teleport = FindTeleportByPos(level, ev.X, ev.Y); - if (teleport == null) - { - continue; - } - - try - { - teleport.open(); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Apply teleport event failed x={X} y={Y}", ev.X, ev.Y); - } - } - } - finally - { - _applyingRemoteTeleportEvents = false; - } - } - - private void ApplyRemoteBreakableGroundEvents(List events) - { - var hero = ModEntry.me; - if (hero == null || events == null || events.Count == 0) - return; - - _applyingRemoteBreakableGroundEvents = true; - try - { - _scratchAppliedBreakableGround.Clear(); - foreach (var ev in events) - { - var alreadyNearby = false; - for (var i = 0; i < _scratchAppliedBreakableGround.Count; i++) - { - var (ax, ay) = _scratchAppliedBreakableGround[i]; - if (System.Math.Abs(ax - ev.X) <= BreakableGroundPosTolerance && System.Math.Abs(ay - ev.Y) <= BreakableGroundPosTolerance) - { - alreadyNearby = true; - break; - } - } - if (alreadyNearby) - continue; - - var cx = (int)System.Math.Round(ev.X); - var cy = (int)System.Math.Round(ev.Y); - _scratchAppliedBreakableGround.Add((ev.X, ev.Y)); - - try - { - hero.breakBreakableGround(cx, cy); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Apply breakable ground failed x={X} y={Y}", cx, cy); - } - } - } - finally - { - _applyingRemoteBreakableGroundEvents = false; - } - } - - private void ApplyRemotePressurePlateEvents(List events) - { - var level = ModEntry.me?._level; - if (level?.entities == null || events == null || events.Count == 0) - return; - - var localHero = ModEntry.me as Entity; - var localId = GameMenu.NetRef?.id ?? 0; - if (localHero == null) - return; - - _applyingRemotePressurePlateEvents = true; - try - { - foreach (var ev in events) - { - if (ev.UserId > 0 && ev.UserId == localId) - continue; - if (!IsInteractionEventForCurrentLevel(ev.LevelId)) - continue; - - var plate = FindPressurePlateByPos(level, ev.X, ev.Y); - if (plate == null) - continue; - - if (ev.Sequence > 0 && ev.UserId > 0) - { - var key = (plate, ev.UserId); - if (_pressurePlateLastAppliedSequence.TryGetValue(key, out var lastSequence) && - ev.Sequence <= lastSequence) - { - continue; - } - _pressurePlateLastAppliedSequence[key] = ev.Sequence; - } - - try - { - plate.trigger(localHero); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Apply pressure plate event failed x={X} y={Y}", ev.X, ev.Y); - } - } - } - finally - { - _applyingRemotePressurePlateEvents = false; - } - } - - private Door? FindDoorByPos(Level level, double x, double y) - { - var byAnchor = FindDoorByStableAnchor(level, x, y); - if (byAnchor != null) - return byAnchor; - - // Doors can be attached or proxy-wrapped after the early level cache is created. Refresh - // once on a miss so elevator cache changes cannot leave button doors permanently invisible - // to the interaction synchronizer. - RebuildInteractionCache(level); - byAnchor = FindDoorByStableAnchor(level, x, y); - if (byAnchor != null) - return byAnchor; - - var byPos = FindInteractByPos(level, x, y, DoorPosTolerance); - if (byPos != null) - return byPos; - return FindNearestDoor(level, x, y); - } - - private Door? FindDoorByStableAnchor(Level level, double x, double y) - { - var candidates = GetInteractionCandidates(level); - if (candidates == null || candidates.Count == 0) - return null; - - Door? nearest = null; - var nearestSq = DoorPosTolerance * DoorPosTolerance * 4.0; - for (var i = 0; i < candidates.Count; i++) - { - var door = candidates[i]; - if (door == null) - continue; - - try - { - if (!ReferenceEquals(door._level, level) || SafeRead(() => door.destroyed, true)) - continue; - var anchor = GetDoorStableAnchor(door); - var dx = anchor.X - x; - var dy = anchor.Y - y; - var distanceSq = dx * dx + dy * dy; - if (distanceSq < nearestSq) - { - nearestSq = distanceSq; - nearest = door; - } - } - catch - { - // Keep searching other doors. - } - } - - return nearest; - } - - private static T? FindNearestByPos(Level level, double x, double y, double maxDistSq) where T : Entity - { - var candidates = GetInteractionCandidates(level); - if (candidates == null || candidates.Count == 0) - return null; - - T? nearest = null; - double nearestSq = maxDistSq; - for (var i = 0; i < candidates.Count; i++) - { - var e = candidates[i]; - if (e?.spr == null) continue; - try - { - var dx = e.spr.x - x; - var dy = e.spr.y - y; - var dSq = dx * dx + dy * dy; - if (dSq < nearestSq) - { - nearestSq = dSq; - nearest = e; - } - } - catch { } - } - return nearest; - } - - private static Door? FindNearestDoor(Level level, double x, double y) => - FindNearestByPos(level, x, y, DoorPosTolerance * DoorPosTolerance * 4); - - private static Elevator? FindElevatorByPos(Level level, double x, double y) - { - var byAnchor = FindElevatorByStableAnchor(level, x, y); - if (byAnchor != null) - return byAnchor; - - var byPos = FindInteractByPos(level, x, y, ElevatorPosTolerance); - if (byPos != null) - return byPos; - - var byTrack = FindElevatorByTrackBounds(level, x, y); - if (byTrack != null) - return byTrack; - - var nearest = FindNearestByPos(level, x, y, ElevatorPosTolerance * ElevatorPosTolerance * 4); - if (nearest != null) - return nearest; - return FindElevatorInTriggers(level, x, y); - } - - private static Elevator? FindElevatorByStableAnchor(Level level, double anchorX, double anchorY) - { - var elevators = GetInteractionCandidates(level); - if (elevators == null || elevators.Count == 0) - return null; - - for (var i = 0; i < elevators.Count; i++) - { - var e = elevators[i]; - if (e == null) - continue; - try - { - var (ax, ay) = GetElevatorStableAnchor(e); - if (System.Math.Abs(ax - anchorX) < ElevatorPosTolerance && - System.Math.Abs(ay - anchorY) < ElevatorPosTolerance) - return e; - } - catch - { - // ignore - } - } - - return null; - } - - private static Elevator? FindElevatorByTrackBounds(Level level, double x, double y) - { - var elevators = GetInteractionCandidates(level); - if (elevators == null || elevators.Count == 0) - return null; - - Elevator? nearest = null; - double nearestSq = double.MaxValue; - for (var i = 0; i < elevators.Count; i++) - { - var elevator = elevators[i]; - if (elevator == null) - continue; - - try - { - var leftPx = elevator.xLeft * TileSizePx - ElevatorPosTolerance; - var rightPx = (elevator.xRight + 1) * TileSizePx + ElevatorPosTolerance; - var topPx = elevator.yTop * TileSizePx - ElevatorPosTolerance; - var bottomPx = (elevator.yBottom + 1) * TileSizePx + ElevatorPosTolerance; - - if (x < leftPx || x > rightPx || y < topPx || y > bottomPx) - continue; - - var anchorX = elevator.spr?.x ?? ((elevator.cx + elevator.xr) * TileSizePx); - var anchorY = elevator.spr?.y ?? ((elevator.cy + elevator.yr) * TileSizePx); - var dx = anchorX - x; - var dy = anchorY - y; - var dSq = dx * dx + dy * dy; - if (dSq < nearestSq) - { - nearestSq = dSq; - nearest = elevator; - } - } - catch - { - // ignore bad elevator state - } - } - - return nearest; - } - - private static object? TryGetLevelTriggers(Level level) - { - try - { - var fromProperty = LevelTriggersProperty?.GetValue(level); - if (fromProperty != null) - return fromProperty; - return LevelTriggersField?.GetValue(level); - } - catch - { - return null; - } - } - - private static int GetTriggerArrayLength(object? triggers) - { - if (triggers is ArrayObj ao) - return ao.length; - if (triggers is ArrayDyn ad) - return ad.get_length(); - return 0; - } - - private static T? GetTriggerAt(object? triggers, int i) where T : class - { - if (triggers is ArrayObj ao) - return ao.getDyn(i) as T; - if (triggers is ArrayDyn ad) - return ad.getDyn(i) as T; - return null; - } - - private static T? FindNearestTriggerByPos(Level level, double x, double y, double maxDistSq) where T : Entity - { - try - { - var triggers = GetInteractionTriggerCandidates(level); - if (triggers == null || triggers.Count == 0) - return null; - - T? nearest = null; - var nearestSq = maxDistSq; - for (var i = 0; i < triggers.Count; i++) - { - var t = triggers[i]; - if (t?.spr == null) continue; - var dx = t.spr.x - x; - var dy = t.spr.y - y; - var dSq = dx * dx + dy * dy; - if (dSq < nearestSq) - { - nearestSq = dSq; - nearest = t; - } - } - return nearest; - } - catch - { - return null; - } - } - - private static Elevator? FindElevatorInTriggers(Level level, double x, double y) => - FindNearestTriggerByPos(level, x, y, ElevatorPosTolerance * ElevatorPosTolerance * 4); - - private static VineLadder? FindVineLadderByPos(Level level, double x, double y) - { - return FindInteractByPos(level, x, y, PlatePosTolerance); - } - - private Teleport? FindTeleportByPos(Level level, double x, double y) - { - var byPos = FindInteractByPos(level, x, y, TeleportPosTolerance); - if (byPos != null) - return byPos; - var nearest = FindNearestByPos(level, x, y, 200.0 * 200.0); - if (nearest != null) - return nearest; - return FindTeleportInTriggers(level, x, y); - } - - private static Portal? FindPortalByPos(Level level, double x, double y) - { - var byPos = FindInteractByPos(level, x, y, PortalPosTolerance); - if (byPos != null) - return byPos; - var nearest = FindNearestByPos(level, x, y, PortalPosTolerance * PortalPosTolerance * 4); - if (nearest != null) - return nearest; - return FindPortalInTriggers(level, x, y); - } - - private static Portal? FindPortalInTriggers(Level level, double x, double y) => - FindNearestTriggerByPos(level, x, y, PortalPosTolerance * PortalPosTolerance * 4); - - private static Teleport? FindTeleportInTriggers(Level level, double x, double y) => - FindNearestTriggerByPos(level, x, y, TeleportPosTolerance * TeleportPosTolerance * 4); - - private static PressurePlate? FindPressurePlateByPos(Level level, double x, double y) - { - return FindInteractByPos(level, x, y, PlatePosTolerance); - } - - private void ApplyRemoteTreasureChestEvents(List events) - { - var level = ModEntry.me?._level; - if (level?.entities == null || events == null || events.Count == 0) - return; - - var localHero = ModEntry.me; - if (localHero == null) - return; - - _applyingRemoteChestEvents = true; - try - { - foreach (var ev in events) - { - var chest = FindTreasureChestByPos(level, ev.X, ev.Y); - if (chest == null) - continue; - - try - { - chest.open(localHero); - } - catch (Exception ex) - { - _log.Warning(ex, "[InteractionSync] Apply treasure chest event failed x={X} y={Y}", ev.X, ev.Y); - } - } - } - finally - { - _applyingRemoteChestEvents = false; - } - } - - private static TreasureChest? FindTreasureChestByPos(Level level, double x, double y) - { - var byPos = FindInteractByPos(level, x, y, ChestPosTolerance); - if (byPos != null) - return byPos; - return FindNearestTreasureChest(level, x, y); - } - - private static TreasureChest? FindNearestTreasureChest(Level level, double x, double y) => - FindNearestByPos(level, x, y, ChestPosTolerance * ChestPosTolerance * 4); - - private static T? FindInteractByPos(Level level, double x, double y, double tolerance = PosTolerance) where T : Entity - { - var candidates = GetInteractionCandidates(level); - if (candidates == null || candidates.Count == 0) - return null; - - for (var i = 0; i < candidates.Count; i++) - { - var e = candidates[i]; - if (e == null) - continue; - try - { - if (e.spr != null && - System.Math.Abs(e.spr.x - x) < tolerance && - System.Math.Abs(e.spr.y - y) < tolerance) - { - return e; - } - } - catch - { - // ignore - } - } - - return null; - } - - private static LevelInteractionCache GetInteractionCache(Level level) - { - var entityCount = level.entities?.length ?? 0; - var triggerCount = GetTriggerArrayLength(TryGetLevelTriggers(level)); - if (!ReferenceEquals(_cachedInteractionLevel, level) || - entityCount != _cachedInteractionEntityCount || - triggerCount != _cachedInteractionTriggerCount) - { - RebuildInteractionCache(level); - } - - return CachedInteractionLevelData; - } - - private static void RebuildInteractionCache(Level? level) - { - CachedInteractionLevelData.Clear(); - _cachedInteractionLevel = level; - _cachedInteractionEntityCount = -1; - _cachedInteractionTriggerCount = -1; - - if (level == null) - return; - - var entities = level.entities; - _cachedInteractionEntityCount = entities?.length ?? 0; - if (entities != null) - { - for (var i = 0; i < entities.length; i++) - { - switch (entities.getDyn(i)) - { - case Door door: - CachedInteractionLevelData.Doors.Add(door); - break; - case Elevator elevator: - CachedInteractionLevelData.Elevators.Add(elevator); - break; - case VineLadder vineLadder: - CachedInteractionLevelData.VineLadders.Add(vineLadder); - break; - case Teleport teleport: - CachedInteractionLevelData.Teleports.Add(teleport); - break; - case Portal portal: - CachedInteractionLevelData.Portals.Add(portal); - break; - case PressurePlate pressurePlate: - CachedInteractionLevelData.PressurePlates.Add(pressurePlate); - break; - case TreasureChest treasureChest: - CachedInteractionLevelData.TreasureChests.Add(treasureChest); - break; - case SwitchBossRune switchBossRune: - CachedInteractionLevelData.SwitchBossRunes.Add(switchBossRune); - break; - } - } - } - - var triggers = TryGetLevelTriggers(level); - var triggerCount = GetTriggerArrayLength(triggers); - _cachedInteractionTriggerCount = triggerCount; - for (var i = 0; i < triggerCount; i++) - { - switch (GetTriggerAt(triggers, i)) - { - case Elevator elevator: - CachedInteractionLevelData.TriggerElevators.Add(elevator); - break; - case Teleport teleport: - CachedInteractionLevelData.TriggerTeleports.Add(teleport); - break; - case Portal portal: - CachedInteractionLevelData.TriggerPortals.Add(portal); - break; - } - } - } - - private static IReadOnlyList? GetInteractionCandidates(Level level) where T : Entity - { - var cache = GetInteractionCache(level); - if (typeof(T) == typeof(Door)) - return (IReadOnlyList)(object)cache.Doors; - if (typeof(T) == typeof(Elevator)) - return (IReadOnlyList)(object)cache.Elevators; - if (typeof(T) == typeof(VineLadder)) - return (IReadOnlyList)(object)cache.VineLadders; - if (typeof(T) == typeof(Teleport)) - return (IReadOnlyList)(object)cache.Teleports; - if (typeof(T) == typeof(Portal)) - return (IReadOnlyList)(object)cache.Portals; - if (typeof(T) == typeof(PressurePlate)) - return (IReadOnlyList)(object)cache.PressurePlates; - if (typeof(T) == typeof(TreasureChest)) - return (IReadOnlyList)(object)cache.TreasureChests; - if (typeof(T) == typeof(SwitchBossRune)) - return (IReadOnlyList)(object)cache.SwitchBossRunes; - return null; - } - - private static IReadOnlyList? GetInteractionTriggerCandidates(Level level) where T : Entity - { - var cache = GetInteractionCache(level); - if (typeof(T) == typeof(Elevator)) - return (IReadOnlyList)(object)cache.TriggerElevators; - if (typeof(T) == typeof(Teleport)) - return (IReadOnlyList)(object)cache.TriggerTeleports; - if (typeof(T) == typeof(Portal)) - return (IReadOnlyList)(object)cache.TriggerPortals; - return null; - } } diff --git a/LevelSync.cs b/LevelSync.cs deleted file mode 100644 index 4f4820d..0000000 --- a/LevelSync.cs +++ /dev/null @@ -1,840 +0,0 @@ -using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using dc.cine; -using dc.en; -using dc.en.inter; -using dc.hl.types; -using dc.level; -using dc.pr; -using ModCore.Utilities; -using Rand = dc.libs.Rand; - -namespace DeadCellsMultiplayerMod -{ - internal partial class GameDataSync - { - private static readonly object _levelGraphLock = new(); - private static readonly object _levelGraphReloadLock = new(); - private static readonly object _bossRuneReloadLock = new(); - private static readonly object _pendingBossRuneReloadLock = new(); - private static readonly Dictionary _remoteLevelGraphs = new(StringComparer.Ordinal); - private static long _nextLevelGraphSequence; - private static long _lastReceivedLevelGraphSequence; - private static long _lastAppliedLevelGraphSequence; - private const int MaxRemoteLevelGraphPayloadChars = 1_000_000; - private const int MaxRemoteLevelGraphNodes = 4096; - private const int MaxCachedRemoteLevelGraphs = 8; - private const int RemoteLevelGraphTtlMs = 60_000; - private static string? _lastLevelGraphReloadLevelId; - private static string? _lastLevelGraphReloadPayload; - private static long _lastLevelGraphReloadTick; - private static string? _lastBossRuneReloadLevelId; - private static long _lastBossRuneReloadTick; - private static string? _pendingBossRuneReloadLevelId; - private static int _pendingBossRuneReloadValue; - private static long _pendingBossRuneReloadTick; - private static bool _hasPendingBossRuneReload; - private const int LevelGraphReloadThrottleMs = 3000; - private const int BossRuneReloadThrottleMs = 3000; - private const int PendingBossRuneReloadTtlMs = 15000; - - private sealed class LevelGraphSync - { - public int V { get; set; } = 2; - public long Seq { get; set; } - public string LevelId { get; set; } = string.Empty; - [JsonIgnore] - public long ReceivedAtTick { get; set; } - public string? RootUid { get; set; } - public int ZLinkId { get; set; } - public double? PostGraphRandSeed { get; set; } - public List Nodes { get; set; } = new(); - } - - private sealed class LevelGraphNodeSync - { - public string Uid { get; set; } = string.Empty; - public string? ParentUid { get; set; } - public string? SubTeleportUid { get; set; } - public bool IsZRoot { get; set; } - public string RType { get; set; } = string.Empty; - public int Group { get; set; } - public int Id { get; set; } - public int Flags { get; set; } - public string? ForcedTemplateId { get; set; } - public string? ExitLevel { get; set; } - public string? ExitName { get; set; } - public int? ExitColor { get; set; } - public int ChildPriority { get; set; } - public int X { get; set; } - public int Y { get; set; } - public int SpawnDistance { get; set; } - public double FillerWeight { get; set; } - public int? ParentLinkConstraint { get; set; } - public List? ChildrenUids { get; set; } - public List? ZChildrenUids { get; set; } - public List? Npcs { get; set; } - public List? ZLinks { get; set; } - public LevelGraphGenDataSync? GenData { get; set; } - } - - private sealed class LevelGraphZLinkSync - { - public int Id { get; set; } - public string DestUid { get; set; } = string.Empty; - public string? DoorId { get; set; } - public int? ContentClue { get; set; } - } - - private sealed class LevelGraphGenDataSync - { - public string? SpecificBiome { get; set; } - public bool? ZDoorLock { get; set; } - public bool? ForcePauseTimer { get; set; } - public bool? ShouldBeFlipped { get; set; } - public int? GenSubTeleportTo { get; set; } - public LevelGraphZDoorTypeSync? ZDoorType { get; set; } - } - - private sealed class LevelGraphZDoorTypeSync - { - public int RawIndex { get; set; } - public int? IntParam0 { get; set; } - public double? DoubleParam0 { get; set; } - } - - public static void ReceiveLevelGraph(string payload) - { - if (string.IsNullOrWhiteSpace(payload)) - return; - if (payload.Length > MaxRemoteLevelGraphPayloadChars) - { - _log?.Warning("[NetMod] Rejected oversized level graph payload ({Length} chars)", payload.Length); - return; - } - - try - { - var graph = JsonSerializer.Deserialize(payload); - if (graph == null || string.IsNullOrWhiteSpace(graph.LevelId)) - return; - if (graph.LevelId.Length > 128 || graph.Nodes == null || graph.Nodes.Count == 0 || graph.Nodes.Count > MaxRemoteLevelGraphNodes) - { - _log?.Warning( - "[NetMod] Rejected invalid level graph level={LevelId} nodes={Count}", - graph.LevelId, - graph.Nodes?.Count ?? -1); - return; - } - - graph.ReceivedAtTick = Environment.TickCount64; - lock (_levelGraphLock) - { - PruneRemoteLevelGraphsLocked(graph.ReceivedAtTick); - if (graph.Seq > 0 && graph.Seq <= _lastReceivedLevelGraphSequence) - { - _log?.Debug( - "[NetMod] Ignored stale level graph seq={Seq} last={Last} level={LevelId}", - graph.Seq, - _lastReceivedLevelGraphSequence, - graph.LevelId); - return; - } - - if (graph.Seq > 0) - _lastReceivedLevelGraphSequence = graph.Seq; - _remoteLevelGraphs[graph.LevelId] = graph; - TrimRemoteLevelGraphCacheLocked(); - Monitor.PulseAll(_levelGraphLock); - } - - _log?.Information( - "[NetMod] Received level graph for {LevelId} ({Count} nodes, seq={Seq})", - graph.LevelId, - graph.Nodes.Count, - graph.Seq); - TryScheduleLevelGraphReloadForLevel(graph.LevelId, payload); - // Graph and boss-rune packets can arrive in either order. This path is coalesced, - // throttled, and suppressed during downed/restart transitions by the reload guard. - TryScheduleBossRuneReloadForLevel(graph.LevelId); - } - catch (Exception ex) - { - _log?.Warning("[NetMod] Failed to parse level graph sync: {Message}", ex.Message); - } - } - - private static void PruneRemoteLevelGraphsLocked(long now) - { - if (_remoteLevelGraphs.Count == 0) - return; - - List? expired = null; - foreach (var pair in _remoteLevelGraphs) - { - var receivedAt = pair.Value?.ReceivedAtTick ?? 0; - if (receivedAt > 0 && now - receivedAt > RemoteLevelGraphTtlMs) - { - expired ??= new List(); - expired.Add(pair.Key); - } - } - - if (expired == null) - return; - foreach (var key in expired) - _remoteLevelGraphs.Remove(key); - } - - private static void TrimRemoteLevelGraphCacheLocked() - { - while (_remoteLevelGraphs.Count > MaxCachedRemoteLevelGraphs) - { - string? oldestKey = null; - long oldestTick = long.MaxValue; - foreach (var pair in _remoteLevelGraphs) - { - var tick = pair.Value?.ReceivedAtTick ?? 0; - if (oldestKey == null || tick < oldestTick) - { - oldestKey = pair.Key; - oldestTick = tick; - } - } - - if (oldestKey == null) - break; - _remoteLevelGraphs.Remove(oldestKey); - } - } - - internal static void TryScheduleBossRuneReloadForCurrentLevel() - { - var currentLevelId = TryGetCurrentLevelId(); - if (string.IsNullOrWhiteSpace(currentLevelId)) - return; - - lock (_levelGraphLock) - { - if (!_remoteLevelGraphs.ContainsKey(currentLevelId)) - return; - } - - TryScheduleBossRuneReloadForLevel(currentLevelId); - } - - internal static void MarkPendingBossRuneReload(int bossRune) - { - var levelId = TryGetCurrentLevelId(); - lock (_pendingBossRuneReloadLock) - { - _pendingBossRuneReloadValue = bossRune; - _pendingBossRuneReloadLevelId = levelId; - _pendingBossRuneReloadTick = Environment.TickCount64; - _hasPendingBossRuneReload = true; - } - } - - internal static void ClearPendingBossRuneReloadState() - { - lock (_pendingBossRuneReloadLock) - { - _hasPendingBossRuneReload = false; - _pendingBossRuneReloadLevelId = null; - } - } - - internal static bool HasPendingRemoteLevelGraph(string? levelId) - { - if (string.IsNullOrWhiteSpace(levelId)) - return false; - - lock (_levelGraphLock) - { - return _remoteLevelGraphs.ContainsKey(levelId); - } - } - - private static void TryScheduleBossRuneReloadForLevel(string levelId) - { - if (string.IsNullOrWhiteSpace(levelId)) - return; - - var net = GameMenu.NetRef; - if (net == null || !net.IsAlive || net.IsHost) - return; - - GameMenu.EnqueueMainThreadCoalesced("level:boss-rune-reload:" + levelId, () => - { - try - { - TryTriggerBossRuneReload(levelId); - } - catch (Exception ex) - { - _log?.Warning("[NetMod] Failed to process boss-rune reload for {LevelId}: {Message}", levelId, ex.Message); - } - }); - } - - private static void TryScheduleLevelGraphReloadForLevel(string levelId, string payload) - { - if (string.IsNullOrWhiteSpace(levelId) || string.IsNullOrWhiteSpace(payload)) - return; - - var net = GameMenu.NetRef; - if (net == null || !net.IsAlive || net.IsHost) - return; - - GameMenu.EnqueueMainThreadCoalesced("level:graph-reload:" + levelId, () => - { - try - { - TryTriggerLevelGraphReload(levelId, payload); - } - catch (Exception ex) - { - _log?.Warning("[NetMod] Failed to process level-graph reload for {LevelId}: {Message}", levelId, ex.Message); - } - }); - } - - /// - /// In-place reloadAfterBossRuneModif keeps the current hero and only regenerates the level. It must be - /// suppressed while the local player is downed/Game Over or a full-run restart is pending, otherwise the - /// host's restart-level graph reloads the old downed run in place (no heal, Game Over stuck) or crashes - /// with a Null access .curCine — instead of letting the queued launchGame restart take over. - /// - private static bool ShouldSuppressClientLevelReload() - { - try - { - if (ModEntry.IsLocalPlayerDowned()) - return true; - if (GameMenu.IsClientRestartPending()) - return true; - } - catch - { - } - - return false; - } - - private static void TryTriggerLevelGraphReload(string graphLevelId, string payload) - { - if (string.IsNullOrWhiteSpace(graphLevelId) || string.IsNullOrWhiteSpace(payload)) - return; - - var net = GameMenu.NetRef; - if (net == null || !net.IsAlive || net.IsHost) - return; - - if (ShouldSuppressClientLevelReload()) - { - _log?.Information("[NetMod] Skipping level-graph reload for {LevelId}: client downed/restart pending", graphLevelId); - return; - } - - var hero = ModEntry.me; - var level = hero?._level; - if (hero == null || level == null || level.map == null) - return; - - var currentLevelId = level.map.id?.ToString(); - if (!string.Equals(currentLevelId, graphLevelId, StringComparison.Ordinal)) - return; - - lock (_levelGraphLock) - { - if (!_remoteLevelGraphs.ContainsKey(graphLevelId)) - return; - } - - if (!TryBeginLevelGraphReload(graphLevelId, payload)) - return; - - var targetLevelId = ResolveBossRuneReloadTargetLevelId(level, graphLevelId); - var (offsetCx, offsetCy) = ComputeCurrentLevelReloadOffsets(hero, level); - var reload = LevelTransition.Class.reloadAfterBossRuneModif; - if (reload == null) - { - _log?.Warning("[NetMod] Missing LevelTransition.reloadAfterBossRuneModif for graph reload {LevelId}", targetLevelId); - return; - } - - _ = reload(targetLevelId.AsHaxeString(), offsetCx, offsetCy); - _log?.Information( - "[NetMod] Triggered level-graph reload for {LevelId} offset=({OffsetCx},{OffsetCy})", - targetLevelId, - offsetCx, - offsetCy); - } - - private static bool TryBeginLevelGraphReload(string levelId, string payload) - { - var now = Environment.TickCount64; - lock (_levelGraphReloadLock) - { - if (string.Equals(_lastLevelGraphReloadLevelId, levelId, StringComparison.Ordinal) && - string.Equals(_lastLevelGraphReloadPayload, payload, StringComparison.Ordinal) && - now - _lastLevelGraphReloadTick < LevelGraphReloadThrottleMs) - { - return false; - } - - _lastLevelGraphReloadLevelId = levelId; - _lastLevelGraphReloadPayload = payload; - _lastLevelGraphReloadTick = now; - return true; - } - } - - private static void TryTriggerBossRuneReload(string graphLevelId) - { - if (string.IsNullOrWhiteSpace(graphLevelId)) - return; - - var net = GameMenu.NetRef; - if (net == null || !net.IsAlive || net.IsHost) - return; - - if (ShouldSuppressClientLevelReload()) - { - _log?.Information("[NetMod] Skipping boss-rune reload for {LevelId}: client downed/restart pending", graphLevelId); - return; - } - - var hero = ModEntry.me; - var level = hero?._level; - var user = dc.Main.Class.ME?.user ?? level?.game?.user; - if (hero == null || level == null || level.map == null || user == null) - return; - - var currentLevelId = level.map.id?.ToString(); - if (!string.Equals(currentLevelId, graphLevelId, StringComparison.Ordinal)) - return; - - if (!TryGetRemoteBossRune(out var remoteBossRune)) - return; - - var localBossRune = GetEffectiveBossRune(user); - var forceByPending = ConsumePendingBossRuneReloadIfMatch(graphLevelId, remoteBossRune); - if (!forceByPending && localBossRune == remoteBossRune) - return; - - _log?.Information( - "[NetMod] Boss-rune graph reload candidate level={LevelId} local={LocalBossRune} remote={RemoteBossRune} pending={Pending}", - graphLevelId, - localBossRune, - remoteBossRune, - forceByPending); - - if (!TryBeginBossRuneReload(graphLevelId)) - return; - - ApplyRemoteBossRune(user, remoteBossRune); - - var (offsetCx, offsetCy) = ComputeBossRuneReloadOffsets(hero, level); - var targetLevelId = ResolveBossRuneReloadTargetLevelId(level, graphLevelId); - - var reload = LevelTransition.Class.reloadAfterBossRuneModif; - if (reload == null) - { - _log?.Warning("[NetMod] Missing LevelTransition.reloadAfterBossRuneModif for {LevelId}", targetLevelId); - return; - } - - ModEntry.PrepareAndDisposeRemoteKingsForBossCellReload( - "client-boss-rune-reload:" + targetLevelId); - _ = reload(targetLevelId.AsHaxeString(), offsetCx, offsetCy); - _log?.Information( - "[NetMod] Triggered boss-rune reload for {LevelId} offset=({OffsetCx},{OffsetCy}) bossRune={BossRune}", - targetLevelId, - offsetCx, - offsetCy, - remoteBossRune); - } - - private static bool TryBeginBossRuneReload(string levelId) - { - var now = Environment.TickCount64; - lock (_bossRuneReloadLock) - { - if (string.Equals(_lastBossRuneReloadLevelId, levelId, StringComparison.Ordinal) && - now - _lastBossRuneReloadTick < BossRuneReloadThrottleMs) - { - return false; - } - - _lastBossRuneReloadLevelId = levelId; - _lastBossRuneReloadTick = now; - return true; - } - } - - private static (int OffsetCx, int OffsetCy) ComputeBossRuneReloadOffsets(Hero hero, Level level) - { - var heroCx = 0; - var heroCy = 0; - try { heroCx = hero.cx; } catch { } - try { heroCy = hero.cy; } catch { } - - var anchorRoom = TryFindBossRuneAnchorRoom(level) ?? TryGetRoomAt(level, heroCx, heroCy); - if (anchorRoom == null) - return (0, 0); - - var roomX = 0; - var roomY = 0; - try { roomX = anchorRoom.x; } catch { } - try { roomY = anchorRoom.y; } catch { } - - return (heroCx - roomX, heroCy - roomY); - } - - private static (int OffsetCx, int OffsetCy) ComputeCurrentLevelReloadOffsets(Hero hero, Level level) - { - var heroCx = 0; - var heroCy = 0; - try { heroCx = hero.cx; } catch { } - try { heroCy = hero.cy; } catch { } - - var room = TryGetRoomAt(level, heroCx, heroCy); - if (room == null) - return (0, 0); - - var roomX = 0; - var roomY = 0; - try { roomX = room.x; } catch { } - try { roomY = room.y; } catch { } - - return (heroCx - roomX, heroCy - roomY); - } - - private static Room? TryFindBossRuneAnchorRoom(Level level) - { - try - { - var entitiesByClass = level.entitiesByClass; - if (entitiesByClass == null) - return null; - - var switchClassId = SwitchBossRune.Class.__clid; - var entries = entitiesByClass.get(switchClassId) as ArrayObj; - if (entries == null) - return null; - - for (int i = 0; i < entries.length; i++) - { - if (entries.getDyn(i) is not SwitchBossRune altar) - continue; - - var room = TryGetRoomAt(level, altar.cx, altar.cy); - if (room != null) - return room; - } - } - catch - { - } - - return null; - } - - private static Room? TryGetRoomAt(Level level, int cx, int cy) - { - try - { - return level.map?.getRoomAt(cx, cy); - } - catch - { - return null; - } - } - - private static string ResolveBossRuneReloadTargetLevelId(Level level, string fallbackLevelId) - { - try - { - var levelId = level.map?.id?.ToString(); - if (!string.IsNullOrWhiteSpace(levelId)) - return levelId; - } - catch - { - } - - return string.IsNullOrWhiteSpace(fallbackLevelId) ? "PrisonStart" : fallbackLevelId; - } - - private static string? TryGetCurrentLevelId() - { - try - { - var levelId = ModEntry.me?._level?.map?.id?.ToString(); - if (!string.IsNullOrWhiteSpace(levelId)) - return levelId; - } - catch - { - } - - return null; - } - - private static bool ConsumePendingBossRuneReloadIfMatch(string graphLevelId, int remoteBossRune) - { - lock (_pendingBossRuneReloadLock) - { - if (!_hasPendingBossRuneReload) - return false; - - if (Environment.TickCount64 - _pendingBossRuneReloadTick > PendingBossRuneReloadTtlMs) - { - _hasPendingBossRuneReload = false; - _pendingBossRuneReloadLevelId = null; - return false; - } - - if (_pendingBossRuneReloadValue != remoteBossRune) - return false; - - if (!string.IsNullOrWhiteSpace(_pendingBossRuneReloadLevelId) && - !string.Equals(_pendingBossRuneReloadLevelId, graphLevelId, StringComparison.Ordinal)) - { - return false; - } - - _hasPendingBossRuneReload = false; - _pendingBossRuneReloadLevelId = null; - return true; - } - } - - public static void SendLevelGraph(string levelId, RoomNode? root, LevelStruct? graph, Rand? rng, NetNode? net) - { - if (net == null || !net.IsAlive) - { - _log?.Information("[NetMod] Skip level graph send for {LevelId}: net unavailable", levelId); - return; - } - - if (graph == null || string.IsNullOrWhiteSpace(levelId)) - { - _log?.Warning("[NetMod] Skip level graph send: invalid graph/levelId (level={LevelId})", levelId); - return; - } - - try - { - var sync = CaptureLevelGraph(levelId, graph); - if (sync == null) - { - _log?.Warning("[NetMod] CaptureLevelGraph returned null for {LevelId} (allLen={AllLen})", levelId, graph.all?.length ?? -1); - return; - } - - if (sync.Nodes.Count == 0) - { - _log?.Warning("[NetMod] Captured empty level graph for {LevelId} (allLen={AllLen})", levelId, graph.all?.length ?? -1); - return; - } - - try - { - sync.RootUid = root?.uid?.ToString(); - } - catch - { - } - - try - { - if (rng != null) - sync.PostGraphRandSeed = rng.seed; - } - catch - { - } - - sync.Seq = Interlocked.Increment(ref _nextLevelGraphSequence); - var json = JsonSerializer.Serialize(sync); - net.SendLevelGraph(levelId, json); - _log?.Information("[NetNode] Sent level graph for {LevelId} ({Count} nodes, seq={Seq}, postRand={PostRand})", - levelId, - sync.Nodes.Count, - sync.Seq, - sync.PostGraphRandSeed.HasValue ? sync.PostGraphRandSeed.Value.ToString(CultureInfo.InvariantCulture) : "n/a"); - } - catch (Exception ex) - { - _log?.Warning("[NetMod] Failed to send level graph for {LevelId}: {Message}", levelId, ex.Message); - } - } - - public static bool TryApplyRemoteLevelGraph(string levelId, LevelStruct? graph, Rand? rng, int timeoutMs, out RoomNode? appliedRoot, out string reason) - { - reason = string.Empty; - appliedRoot = null; - if (graph == null || string.IsNullOrWhiteSpace(levelId)) - { - reason = "invalid arguments"; - return false; - } - - if (!TryWaitGetRemoteLevelGraph(levelId, timeoutMs, out var remoteGraph)) - { - reason = "remote graph not received"; - return false; - } - - if (remoteGraph == null || remoteGraph.Nodes == null || remoteGraph.Nodes.Count == 0) - { - reason = "remote graph payload empty"; - return false; - } - - var applied = ApplyLevelGraph(graph, remoteGraph, out appliedRoot, out reason); - if (applied && rng != null && remoteGraph.PostGraphRandSeed.HasValue) - { - try - { - rng.seed = remoteGraph.PostGraphRandSeed.Value; - } - catch (Exception ex) - { - applied = false; - reason = "failed to apply post-graph rand seed: " + ex.Message; - } - } - - ConsumeRemoteLevelGraph(levelId); - if (!applied) - return false; - - // FTL / HL cast correlation: pair with host "Sent level graph" and client combat/restart logs. - // Repro surface: client PrisonStart (or any level) with remote graph, host_restart, then dive/combat. - try - { - var rootUid = remoteGraph.RootUid ?? "?"; - try - { - if (appliedRoot != null) - rootUid = appliedRoot.uid?.ToString() ?? rootUid; - } - catch - { - } - - _log?.Information( - "[NetMod] Remote level graph applied (FTL correlation) levelId={LevelId} nodes={Count} rootUid={RootUid} postRandSeed={PostRand} postRandApplied={PostRandApplied}", - levelId, - remoteGraph.Nodes.Count, - rootUid, - remoteGraph.PostGraphRandSeed.HasValue - ? remoteGraph.PostGraphRandSeed.Value.ToString(CultureInfo.InvariantCulture) - : "n/a", - rng != null && remoteGraph.PostGraphRandSeed.HasValue); - } - catch - { - } - - return true; - } - - private static bool TryWaitGetRemoteLevelGraph(string levelId, int timeoutMs, out LevelGraphSync? graph) - { - graph = null; - if (string.IsNullOrWhiteSpace(levelId)) - return false; - - var deadline = Environment.TickCount64 + Math.Max(0, timeoutMs); - lock (_levelGraphLock) - { - while (true) - { - var now = Environment.TickCount64; - PruneRemoteLevelGraphsLocked(now); - if (_remoteLevelGraphs.TryGetValue(levelId, out var found)) - { - graph = found; - return true; - } - - var remaining = deadline - now; - if (timeoutMs <= 0 || remaining <= 0) - return false; - - // LGRAPH is parsed by the network fast path, so waiting on the graph lock is - // enough. Processing arbitrary main-thread actions from inside LevelGen caused - // re-entrant level/dispose/ghost work while the graph was only half built. - Monitor.Wait(_levelGraphLock, (int)Math.Min(remaining, 250)); - } - } - } - - private static bool TryGetRemoteLevelGraph(string levelId, out LevelGraphSync? graph) - { - lock (_levelGraphLock) - { - PruneRemoteLevelGraphsLocked(Environment.TickCount64); - if (_remoteLevelGraphs.TryGetValue(levelId, out var found)) - { - graph = found; - return true; - } - } - - graph = null; - return false; - } - - private static void ConsumeRemoteLevelGraph(string levelId) - { - if (string.IsNullOrWhiteSpace(levelId)) - return; - - lock (_levelGraphLock) - { - if (_remoteLevelGraphs.TryGetValue(levelId, out var graph) && graph.Seq > 0) - _lastAppliedLevelGraphSequence = Math.Max(_lastAppliedLevelGraphSequence, graph.Seq); - _remoteLevelGraphs.Remove(levelId); - } - } - - internal static void ResetTransientNetworkState() - { - lock (_levelSeedLock) - { - _remoteLevelId = null; - _remoteLevelSeed = null; - } - - lock (_levelGraphLock) - { - _remoteLevelGraphs.Clear(); - _lastReceivedLevelGraphSequence = 0; - _lastAppliedLevelGraphSequence = 0; - Monitor.PulseAll(_levelGraphLock); - } - Interlocked.Exchange(ref _nextLevelGraphSequence, 0); - - lock (_levelGraphReloadLock) - { - _lastLevelGraphReloadLevelId = null; - _lastLevelGraphReloadPayload = null; - _lastLevelGraphReloadTick = 0; - } - lock (_bossRuneReloadLock) - { - _lastBossRuneReloadLevelId = null; - _lastBossRuneReloadTick = 0; - } - ClearPendingBossRuneReloadState(); - } - } -} diff --git a/Mobs/Levelinit.cs b/Mobs/Levelinit.cs index 11cc3d7..a9d19dc 100644 --- a/Mobs/Levelinit.cs +++ b/Mobs/Levelinit.cs @@ -1,28 +1,8 @@ using DeadCellsMultiplayerMod.Interface.ModuleInitializing; using ModCore.Events; using dc; -using dc.h2d; -using dc.libs; -using dc.level; using dc.pr; -using dc.tool; -using dc.critter; -using dc.light; -using dc.shader; -using dc.haxe.ds; -using dc.hl.types; using ModCore.Mods; -using dc.libs.misc; -using dc.level.disp; -using dc.tool.signals; -using Math = dc.Math; -using dc.libs.heaps.slib; -using HaxeProxy.Runtime; -using ModCore.Utilities; -using dc.tool.quadTree; -using Hashlink.Virtuals; -using System.Reflection; - namespace DeadCellsMultiplayerMod.Mobs.Levelinit; @@ -66,11 +46,9 @@ private void Levelinit_OnDispose(Hook_Level.orig_onDispose orig, Level self) orig(self); } - - // Compatibility path for Dead Cells v35.9+ (June 2026). // Do not replace the game's complete Level.init implementation: the game now - // owns render/UI initialization details that this legacy copied routine cannot + // owns render/UI initialization details that a legacy copied routine cannot // safely reproduce. MobSync attaches later through entitiesPostCreate. private void Levelinit_Main(Hook_Level.orig_init orig, Level self) { @@ -79,674 +57,4 @@ private void Levelinit_Main(Hook_Level.orig_init orig, Level self) self.map?.id?.ToString() ?? string.Empty); orig(self); } - - // Kept only as reference for older game builds. It is no longer hooked. - private void LegacyLevelinit_Main(Hook_Level.orig_init orig, Level self) - { - initprocess(self); - - self.permanentTW = new Tweenie(self.getDefaultFrameRate()); - self.levelSignals = new LevelSignals(); - - - virtual_baseLootLevel_biome_bonusTripleScrollAfterBC_cellBonus_dlc_doubleUps_eliteRoomChance_eliteWanderChance_flagsProps_group_icon_id_index_loreDescriptions_mapDepth_minGold_mobDensity_mobs_name_nextLevels_parallax_props_quarterUpsBC3_quarterUpsBC4_specificLoots_specificSubBiome_transitionTo_tripleUps_worldDepth_ - Levelvirtual_ = new virtual_baseLootLevel_biome_bonusTripleScrollAfterBC_cellBonus_dlc_doubleUps_eliteRoomChance_eliteWanderChance_flagsProps_group_icon_id_index_loreDescriptions_mapDepth_minGold_mobDensity_mobs_name_nextLevels_parallax_props_quarterUpsBC3_quarterUpsBC4_specificLoots_specificSubBiome_transitionTo_tripleUps_worldDepth_(); - - object? mapRaw = Data.Class.level.byId.get(self.map.id); - Levelvirtual_ = ((HaxeDynObj)mapRaw!).ToVirtual(); - - - if (self.viewport == null) - { - self.viewport = new Viewport(self); - } - - self.entitiesByClass = new IntMap(); - - - - int Index = 0; - ArrayBytes_Int arrayBytes_Int; - if (Level.Class.ENTITIES_CLIDS != null) - { - arrayBytes_Int = Level.Class.ENTITIES_CLIDS; - } - else - { - object? hlTypeObj = dc.haxe.rtti.Meta.Class.getType(Level.Class); - var entitiesProp = hlTypeObj?.GetType().GetProperty("entitiesByClassUsed", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (entitiesProp?.GetValue(hlTypeObj) is not ArrayDyn arrayDyn) - throw new InvalidOperationException("Level hl.Type.entitiesByClassUsed unavailable"); - arrayBytes_Int = ArrayUtils.CreateInt(); - - int len = arrayDyn.get_length(); - for (; Index < len; Index++) - { - int dyn = arrayDyn.getDyn(Index); - arrayBytes_Int.push(dyn); - } - - Level.Class.ENTITIES_CLIDS = arrayBytes_Int; - Index = 0; - } - - - - ArrayObj arrayObj; - while (Index < arrayBytes_Int.length) - { - int clid = arrayBytes_Int.getDyn(Index); - Index++; - IntMap entitiesByClassMap = self.entitiesByClass; - arrayObj = (ArrayObj)ArrayUtils.CreateDyn().array; - entitiesByClassMap.set(clid, arrayObj); - } - - - - - Index = 0; - arrayObj = self.entities; - ArrayObj obj; - int entitiesCount = arrayObj.length; - for (; Index < entitiesCount; Index++) - { - var arrayobj = arrayObj.getDyn(Index); - if (arrayobj == null) continue; - - Entity entity = (Entity)arrayobj; - arrayBytes_Int = entity.getEntityCLIDS(); - - int clidsCount = arrayBytes_Int.length; - for (int Index3 = 0; Index3 < clidsCount; Index3++) - { - int clid = arrayBytes_Int.getDyn(Index3); - obj = (ArrayObj)self.entitiesByClass.get(clid); - if (obj != null) - { - obj.push(entity); - } - } - } - - self.splatters = (ArrayObj)ArrayUtils.CreateDyn().array; - self.entityLights = (ArrayObj)ArrayUtils.CreateDyn().array; - self.uiProcesses = (ArrayObj)ArrayUtils.CreateDyn().array; - self.entitiesGC = (ArrayObj)ArrayUtils.CreateDyn().array; - self.accu = 0.0; - - - - Index = Level.Class.cirColBufferMaxCount; - arrayObj = (ArrayObj)ArrayUtils.CreateDyn().array; - if (Index > 0) - { - int i = Index - 1; - Entity entity = null!; - - if (i >= arrayObj.length) - { - arrayObj.__expand(i); - } - arrayObj.array[i] = entity; - } - - self.cirColEntitiesBuffer = arrayObj; - Boot.Class.tryRender(); - - - Layers root = new Layers(null); - self.root = root; - - - FlashLight.Class.alloc(self, 64); - - - int? generationFlagIndex = null; - int? lootFlagIndex = null; - int? gameplayFlagIndex = null; - int? metaFlagIndex = null; - int? visualFlagIndex = 0; - - virtual_gameplayFlags_genFlags_lootFlags_metaFlags_visualFlags_ flagsProps = Levelvirtual_.flagsProps; - bool hasFlag = true; - - if (generationFlagIndex.HasValue) - { - hasFlag = CheckFlag(flagsProps.genFlags, generationFlagIndex.Value); - } - else if (lootFlagIndex.HasValue) - { - hasFlag = CheckFlag(flagsProps.lootFlags, lootFlagIndex.Value); - } - else if (gameplayFlagIndex.HasValue) - { - hasFlag = CheckFlag(flagsProps.gameplayFlags, gameplayFlagIndex.Value); - } - else if (metaFlagIndex.HasValue) - { - hasFlag = CheckFlag(flagsProps.metaFlags, metaFlagIndex.Value); - } - else if (visualFlagIndex.HasValue) - { - hasFlag = CheckFlag(flagsProps.visualFlags, visualFlagIndex.Value); - } - - self.scroller = new LightedLayers(self, Ref.From(ref hasFlag)); - - int backgroundColorAlpha = (int)255.0 << 24; - int backgroundColor = backgroundColorAlpha | 11265535; - self.scroller.backgroundColor = backgroundColor; - - self.root.addChildAt(self.scroller, Const.Class.ROOT_DP_MAIN); - - - OnionSkin.Class.alloc(self, 128); - Boot.Class.tryRender(); - - - self.controller = Boot.Class.ME.controller.createAccess("level".AsHaxeString(), null); - - self.cm = new Cinematic((int)self.getDefaultFrameRate()); - - - dc.h2d.Object @object = new dc.h2d.Object(null); - LightedLayers scroller = self.scroller; - self.scroller.addChildAt(@object, Const.Class.DP_ROOM_BACK_FX); - - - dc.h2d.Object object2 = new dc.h2d.Object(null); - self.scroller.addChildAt(object2, Const.Class.DP_ROOM_MAIN_FX); - - - TopFx topFx = new TopFx(null); - self.scroller.addChildAt(topFx, Const.Class.DP_FOREGROUND_FX); - - - self.fx = new Fx(self, @object, object2, topFx); - Boot.Class.tryRender(); - self.map.init(); - Boot.Class.tryRender(); - self.map.buildBlurredCols(); - Boot.Class.tryRender(); - - dc.String id = self.map.biome.id; - var hasatlasname = (HaxeDynObj)Data.Class.biome.byId.get(id); - dc.String atlasname = hasatlasname.ToVirtual().atlasName; - - - virtual_norm_slib_used_ level = Assets.Class.lib.getLevel(atlasname, new HlAction(self.onLevelAssetsReloaded)); - self.slib = level.slib; - self.norm = level.norm; - self.lAudio = new LevelAudio(self); - - Boot.Class.tryRender(); - - - LevelDisp lDisp; - Dictionary> levelDispMappings = new Dictionary>() - { - ["AncientTemple"] = () => new AncientTemple(self, self.map, id), - ["CastleAlchemy"] = () => new CastleAlchemy(self, self.map, id), - ["CastleTorture"] = () => new CastleTorture(self, self.map, id), - ["GardenerStage"] = () => new GardenerStage(self, self.map, id, "Gardener_outside".AsHaxeString()), - ["LighthouseTop"] = () => new Lighthouse(self, self.map, id, "LighthouseTop".AsHaxeString()), - ["PrisonCorrupt"] = () => new Prison(self, self.map, id), - ["RichterCastle"] = () => new RichterCastle(self, self.map), - ["SkinningBiome"] = () => new Prison(self, self.map, id), - ["TopClockTower"] = () => new TopClockTower(self, self.map, id), - ["Astrolab"] = () => new Astrolab(self, self.map), - ["Cemetery"] = () => new Cemetery(self, self.map, id), - ["SewerOld"] = () => - { - bool flag2 = true; - return new Sewer(self, self.map, Ref.From(ref flag2)); - }, - ["Bank"] = () => new Bank(self, self.map), - ["BeholderPit"] = () => new BeholderPit(self, self.map, id), - ["CastleVegan"] = () => new CastleVegan(self, self.map, id), - ["CemeteryInt"] = () => new Cemetery(self, self.map, id), - ["DookuCastle"] = () => new DookuCastle(self, self.map), - ["Observatory"] = () => new Observatory(self, self.map), - ["PrisonStart"] = () => new Prison(self, self.map, id), - ["SecretRooms"] = () => new SecretRooms(self, self.map), - ["BossRushZone"] = () => new BossRushZone(self, self.map), - ["PrisonDepths"] = () => new Prison(self, self.map, id), - ["PurpleGarden"] = () => new PurpleGarden(self, self.map), - ["StiltVillage"] = () => new StiltVillage(self, self.map), - ["Bridge"] = () => new Bridge(self, self.map), - ["Castle"] = () => new Castle(self, self.map, id), - ["Cavern"] = () => new Cavern(self, self.map, id), - ["Throne"] = () => new Throne(self, self.map), - ["BridgeBoatDock"] = () => new Docks(self, self.map), - ["Cliff"] = () => new Cliff(self, self.map, id, "Cliff_outside".AsHaxeString()), - ["Crypt"] = () => new Crypt(self, self.map), - ["Giant"] = () => new Cavern(self, self.map, id), - ["Sewer"] = () => new Sewer(self, self.map, Ref.Null), - ["Swamp"] = () => new Swamp(self, self.map), - ["ClockTower"] = () => new ClockTower(self, self.map, id), - ["DeathArena"] = () => new DeathArena(self, self.map), - ["Distillery"] = () => new Distillery(self, self.map), - ["DookuArena"] = () => new DookuArena(self, self.map, id, "DookuBeastArena".AsHaxeString()), - ["Greenhouse"] = () => new Greenhouse(self, self.map, id, "Greenhouse_underground".AsHaxeString()), - ["PrisonRoof"] = () => new PrisonRoof(self, self.map), - ["QueenArena"] = () => new QueenArena(self, self.map, id), - ["SwampHeart"] = () => new SwampHeart(self, self.map), - ["LighthouseBottom"] = () => new Lighthouse(self, self.map, id, "LighthouseTop".AsHaxeString()), - ["PrisonCourtyard2"] = () => new PrisonCourtyard(self, self.map, id), - ["Ossuary"] = () => new Ossuary(self, self.map), - ["Tumulus"] = () => new Tumulus(self, self.map, id), - ["PhotoRoom"] = () => new PhotoRoom(self, self.map, null), - ["PrisonHub"] = () => new Prison(self, self.map, id), - ["Shipwreck"] = () => new Shipwreck(self, self.map, id, "Shipwreck_underground".AsHaxeString()), - ["PrisonCourtyard"] = () => new PrisonCourtyard(self, self.map, id), - ["StiltVillageInt"] = () => new StiltVillage(self, self.map), - ["PrisonRoofCorrupt"] = () => new PrisonRoof(self, self.map), - ["Shipwreck_underground"] = () => new Shipwreck(self, self.map, id, "Shipwreck_underground".AsHaxeString()), - ["Template"] = () => new Template(self, self.map), - ["TumulusInt"] = () => new Tumulus(self, self.map, id), - - }; - - if (levelDispMappings.TryGetValue(id.ToString(), out var createLevelDispFunc)) - { - lDisp = createLevelDispFunc(); - self.lDisp = lDisp; - Boot.Class.tryRender(); - } - - QtRectangle boundary = new QtRectangle(0, 0, (int)Math.Class.max((double)self.map.wid, (double)self.map.hei), (int)Math.Class.max((double)self.map.wid, (double)self.map.hei)); - self.qTree = new QuadTree(boundary, 4, 1, self.lDisp.debug); - - - SpriteLib gameElements = Assets.Class.gameElements; - Tile tile = (Tile)gameElements.pages.getDyn(0)!; - - - self.sbUi = self.createStandardBatch(tile, Const.Class.DP_CTX_UI); - self.sbCritters = self.createStandardBatch(tile, Const.Class.DP_ROOM_BACK_FX); - self.sbCritters.blendMode = new BlendMode.Alpha(); - self.lDisp.applyLayerConf(self.sbCritters, "MainFrontWalls".AsHaxeString(), Ref.Null, Ref.Null); - - SplatterCont splatterCont = new SplatterCont(null); - self.scroller.addChildAt(splatterCont, Const.Class.DP_ROOM_BACK_FX); - - - self.sbSplatters = new HSpriteBatch(tile, null); - self.sbSplatters.hasRotationScale = true; - splatterCont.addChild(self.sbSplatters); - - - self.sbBodyPart = new HSpriteBatch(tile, null); - self.sbBodyPart.hasRotationScale = true; - self.scroller.addChildAt(self.sbBodyPart, Const.Class.DP_ROOM_BACK); - - - - self.sbBodyPartFront = new HSpriteBatch(tile, null); - self.sbBodyPartFront.hasRotationScale = true; - self.scroller.addChildAt(self.sbBodyPartFront, Const.Class.DP_ROOM_FRONT_HERO); - - - - self.sbPendulum_ChainBack = self.createStandardBatch(tile, Const.Class.DP_ROOM_BACK); - NormalMap normalMap = (NormalMap)self.sbPendulum_ChainBack.addShader(new NormalMap(self.norm)); - self.sbPendulum_ChainBack.blendMode = new BlendMode.Alpha(); - self.lDisp.applyLayerConf(self.sbPendulum_ChainBack, "MainAction".AsHaxeString(), Ref.Null, Ref.Null); - - - gameElements = self.slib; - tile = (Tile)gameElements.pages.getDyn(0)!; - self.sbPendulum_ChainFront = self.createStandardBatch(tile, Const.Class.DP_ROOM_FRONT); - normalMap = (NormalMap)self.sbPendulum_ChainFront.addShader(new NormalMap(self.norm)); - self.sbPendulum_ChainFront.blendMode = new BlendMode.Alpha(); - self.lDisp.applyLayerConf(self.sbPendulum_ChainBack, "MainBackProps".AsHaxeString(), Ref.Null, Ref.Null); - - - - - obj = CdbTypeConverter.Class.getGlowData(self.map.biome); - if (0 < obj.length) - { - GlowKey s = new GlowKey(obj); - GlowKey glowKey = (GlowKey)self.sbPendulum_ChainBack.addShader(s); - glowKey = (GlowKey)self.sbPendulum_ChainFront.addShader(s); - } - - - self.mask = new Bitmap(Tile.Class.fromColor(0, 1, 1, null, null), null); - - double tileWidth = (double)self.mask.tile.width; - int halfTileWidth = -(int)(0.5 * tileWidth); - tile.dx = halfTileWidth; - - double tileHeight = (double)tile.height; - int halfTileHeight = -(int)(0.5 * tileHeight); - tile.dy = halfTileHeight; - - - int backgroundDarkenerColor = Main.Class.ME.options.backgroundDarkenerColor; - double? backgroundDarkenerAlpha = Main.Class.ME.options.backgroundDarkenerAlpha; - self.bgDarkener = new Bitmap(Tile.Class.fromColor(backgroundDarkenerColor, 1, 1, backgroundDarkenerAlpha, null), null); - - - tile = self.bgDarkener.tile; - tileWidth = (double)tile.width; - halfTileWidth = -(int)(0.5 * tileWidth); - tile.dx = halfTileWidth; - - tileHeight = (double)tile.height; - halfTileHeight = -(int)(0.5 * tileHeight); - tile.dy = halfTileHeight; - - self.scroller.addChildAt(self.bgDarkener, Const.Class.DP_ROOM_BACK_DECO); - - self.critters = (ArrayObj)ArrayUtils.CreateDyn().array; - - virtual_gameplayFlags_genFlags_lootFlags_metaFlags_visualFlags_ flagsProps2 = self.map.infos.flagsProps; - hasFlag = ((flagsProps2.visualFlags & 1 << 1) != 0); - - - - dc.libs.Rand rand; - if (!hasFlag) - { - CritterGen critterGen = new CritterGen(self); - Boot.Class.tryRender(); - } - rand = new dc.libs.Rand(self.map.seed); - obj = self.map.rooms; - - - int roomIndex = 0; - int markerIndex = 0; - while (true) - { - int roomsCount = obj.length; - if (roomIndex >= roomsCount) - { - break; - } - - roomsCount = obj.length; - Room room; - ArrayObj markers; - - if (roomIndex >= roomsCount) - { - room = null!; - roomIndex++; - markerIndex = 0; - markers = room!.markers; - } - else - { - var roommakers = obj.getDyn(roomIndex); - room = (Room)roommakers!; - roomIndex++; - markerIndex = 0; - markers = room.markers; - } - - while (true) - { - int markersCount = markers.length; - if (markerIndex >= markersCount) - { - break; - } - - markersCount = markers.length; - Marker marker; - if (markerIndex >= markersCount) - { - marker = null!; - } - else - { - var markerdy = markers.getDyn(markerIndex); - marker = (Marker)markerdy!; - } - markerIndex++; - - if (marker!.kind == "Critters".AsHaxeString()) - { - dc.String customId = marker.customId; - if (customId != null) - { - - if (customId.ToString() == "bats") - { - GenerateBats(rand, room, marker, self); - continue; - } - - if (customId.ToString() == "crow") - { - GenerateCrows(rand, room, marker, self); - } - - } - } - } - Boot.Class.tryRender(); - } - self.onResize(); - self.onApplyOptions(); - virtual_xMax_xMin_yMax_yMin_ viewportBounds = new virtual_xMax_xMin_yMax_yMin_(); - viewportBounds.xMin = 0; - viewportBounds.yMin = 0; - viewportBounds.xMax = 99999; - viewportBounds.yMax = 99999; - self.newViewportRect = viewportBounds; - } - - - private bool CheckFlag(int flags, int flagIndex) - { - return (flags & (1 << flagIndex)) != 0; - } - - private int RoundUp(double value) - { - int rounded = (int)value; - return (double)rounded < value ? rounded + 1 : rounded; - } - - private int RoundDown(double value) - { - int rounded = (int)value; - return (double)rounded > value ? rounded - 1 : rounded; - } - - private void GenerateBats(Rand rand, Room room, Marker marker, dc.pr.Level level) - { - double batCountDouble = (double)marker.width / 10.0; - double zero = 0.0; - int batCountRounded; - - if (zero < batCountDouble) - { - batCountRounded = RoundUp(batCountDouble + 0.5); - } - else if (batCountDouble < 0.0) - { - batCountRounded = RoundDown(batCountDouble - 0.5); - } - else - { - batCountRounded = 0; - } - - for (int i = 0; i < batCountRounded; i++) - { - int widthRange = marker.width - 2 - 1; - - - double seedResult = rand.seed * 16807.0 % 2147483647.0; - rand.seed = seedResult; - int batX = room.x + marker.cx; - int randomValue = (int)seedResult & 1073741823; - int range = widthRange - 2 + 1; - randomValue %= range; - int xOffset = 2 + randomValue; - batX += xOffset; - - - int clusterCount = 0; - seedResult = rand.seed * 16807.0 % 2147483647.0; - rand.seed = seedResult; - range = ((int)seedResult & 1073741823) % 5; - clusterCount = 3 + range; - - - for (int j = 0; j < clusterCount; j++) - { - bool useRandomDirection = true; - bool? directionFlag = useRandomDirection; - - if (directionFlag == null) - { - useRandomDirection = false; - directionFlag = useRandomDirection; - } - - - seedResult = rand.seed * 16807.0 % 2147483647.0; - rand.seed = seedResult; - int horizontalOffset = ((int)seedResult & 1073741823) % 3; - - if (directionFlag != null) - { - - seedResult = rand.seed * 16807.0 % 2147483647.0; - rand.seed = seedResult; - int direction = ((int)seedResult & 1073741823) % 2 * 2 - 1; - int xAdjustment = horizontalOffset * direction; - int finalX = batX + xAdjustment; - int finalY = room.y + marker.cy + 1; - - - dc.critter.Bat bat = new dc.critter.Bat(level, finalX, finalY); - } - else - { - - int xAdjustment = horizontalOffset; - int finalX = batX + xAdjustment; - int finalY = room.y + marker.cy + 1; - - - dc.critter.Bat bat = new dc.critter.Bat(level, finalX, finalY); - } - } - } - } - - - private void GenerateCrows(Rand rand, Room room, Marker marker, dc.pr.Level level) - { - double crowCountDouble = (double)marker.width / 10.0; - double zero = 0.0; - int crowCountRounded; - - if (zero < crowCountDouble) - { - crowCountRounded = RoundUp(crowCountDouble + 0.5); - } - else if (crowCountDouble < 0.0) - { - crowCountRounded = RoundDown(crowCountDouble - 0.5); - } - else - { - crowCountRounded = 0; - } - - for (int i = 0; i < crowCountRounded; i++) - { - int widthRange = marker.width - 2 - 1; - - - double seedResult = rand.seed * 16807.0 % 2147483647.0; - rand.seed = seedResult; - int crowX = room.x + marker.cx; - int randomValue = (int)seedResult & 1073741823; - int range = widthRange - 2 + 1; - randomValue %= range; - int xOffset = 2 + randomValue; - crowX += xOffset; - - int clusterCount = 0; - seedResult = rand.seed * 16807.0 % 2147483647.0; - rand.seed = seedResult; - range = ((int)seedResult & 1073741823) % 5; - clusterCount = 3 + range; - - - for (int j = 0; j < clusterCount; j++) - { - bool useRandomDirection = true; - bool? directionFlag = useRandomDirection; - - if (directionFlag == null) - { - useRandomDirection = false; - directionFlag = useRandomDirection; - } - - - seedResult = rand.seed * 16807.0 % 2147483647.0; - rand.seed = seedResult; - int horizontalOffset = ((int)seedResult & 1073741823) % 3; - - if (directionFlag != null) - { - - seedResult = rand.seed * 16807.0 % 2147483647.0; - rand.seed = seedResult; - int direction = ((int)seedResult & 1073741823) % 2 * 2 - 1; - int xAdjustment = horizontalOffset * direction; - int finalX = crowX + xAdjustment; - int finalY = room.y + marker.cy; - - - Crow crow = new Crow(level, finalX, finalY); - } - else - { - - int xAdjustment = horizontalOffset; - int finalX = crowX + xAdjustment; - int finalY = room.y + marker.cy; - - - Crow crow = new Crow(level, finalX, finalY); - } - } - } - } - - - public void initprocess(dc.pr.Level level) - { - level.name = "process".AsHaxeString(); - - dc.libs._Process @class = dc.libs.Process.Class; - level.uniqId = @class.UNIQ_ID++; - - level.children = (ArrayObj)ArrayUtils.CreateDyn().array; - level.paused = false; - level.destroyed = false; - level.ftime = 0.0; - level.tmod = 1.0; - level.speedMod = 1.0; - - double frameRate = level.getDefaultFrameRate(); - level.delayer = new Delayer(frameRate); - level.cd = new dc.libs.Cooldown(frameRate); - level.tw = new Tweenie(frameRate); - } - - } diff --git a/Mobs/MobWireCodec.cs b/Mobs/MobWireCodec.cs index 0b65e66..ff63afc 100644 --- a/Mobs/MobWireCodec.cs +++ b/Mobs/MobWireCodec.cs @@ -63,32 +63,6 @@ public static string BuildMobMovesLine(IReadOnlyList mo return sb.ToString(); } - public static string BuildMobChargesLine(IReadOnlyList charges) - { - var sb = MobLineBuilder.Value!; - sb.Clear(); - sb.Append("MOBCHARGE|"); - if (charges != null) - { - for (int i = 0; i < charges.Count; i++) - { - if (i > 0) - sb.Append(EntrySep); - - var c = charges[i]; - AppendInvariant(sb, c.Index); - sb.Append(','); - AppendInvariant(sb, c.Generation); - sb.Append(','); - sb.Append(c.SkillId ?? string.Empty); - sb.Append(','); - AppendInvariant(sb, c.Ratio); - } - } - sb.Append('\n'); - return sb.ToString(); - } - public static string BuildMobAttackLine(NetNode.MobAttack attack) { string encodedSkill; @@ -188,6 +162,48 @@ public static string BuildMobDrawLine(IReadOnlyList draws) return sb.ToString(); } + /// + /// MOBREG|<generation>|<netId>,<escapedType>,<x>,<y>;... + /// Type is Uri-escaped so commas/pipes in signatures cannot break the table. + /// + public static string BuildMobRegistryLine(int generation, IReadOnlyList entries) + { + var sb = MobLineBuilder.Value!; + sb.Clear(); + sb.Append("MOBREG|"); + AppendInvariant(sb, generation); + sb.Append('|'); + if (entries != null) + { + for (int i = 0; i < entries.Count; i++) + { + if (i > 0) + sb.Append(EntrySep); + + var e = entries[i]; + AppendInvariant(sb, e.NetId); + sb.Append(','); + string encodedType; + try + { + encodedType = Uri.EscapeDataString(e.Type ?? string.Empty); + } + catch + { + encodedType = e.Type ?? string.Empty; + } + + sb.Append(encodedType); + sb.Append(','); + AppendInvariant(sb, e.X); + sb.Append(','); + AppendInvariant(sb, e.Y); + } + } + sb.Append('\n'); + return sb.ToString(); + } + public static string BuildMobDieLine(NetNode.MobDie die) { var encodedType = string.IsNullOrWhiteSpace(die.Type) diff --git a/Mobs/MonsterSynchronization.Attacks.cs b/Mobs/MonsterSynchronization.Attacks.cs index 123f64e..36f56a8 100644 --- a/Mobs/MonsterSynchronization.Attacks.cs +++ b/Mobs/MonsterSynchronization.Attacks.cs @@ -198,15 +198,9 @@ private static bool RebuildMobArray(Level? level) { ResetMobTrackingLocked("rebuild_prepare"); currentLevel = level; - for (int i = 0; i < candidateTrackedMobs.Count; i++) - { - var mob = candidateTrackedMobs[i]; - MobToId[mob] = i; - IdToMob[i] = mob; - trackedMobs.Add(mob); - trackedMobIndices[mob] = i; - } - nextRuntimeSyncId = candidateTrackedMobs.Count; + // Host-owned NetIds: only the host assigns identity. Clients track unbound + // locals and bind from MOBREG / first authoritative state (type + spawn). + AssignHostNetIdsForRebuildLocked(candidateTrackedMobs); trackedAfterRebuild = trackedMobs.Count; s_levelIdentityToken = candidateIdentityToken; @@ -314,6 +308,7 @@ private static bool RebuildMobArray(Level? level) } ClearSyncQuiesceAfterRebuild(); + QueueHostMobRegistryAfterRebuild(); for (int i = 0; i < s_batchMobsScratch.Count; i++) QueueInitialMobSync(s_batchMobsScratch[i]); @@ -633,6 +628,9 @@ private static void ResetMobTrackingStateLocked() s_lastHostAuthoritativeFullResyncFrame = -99999.0; s_lastHostAuthoritativeFullResyncToken = 0; s_hostAuthoritativeBootstrapResyncsRemaining = 0; + s_lastHostMobRegistryToken = 0; + s_lastHostMobRegistrySendFrame = -99999.0; + s_hostMobRegistryResendsRemaining = 0; hostDetectedTargets.Clear(); // Scratch collections can retain destroyed Haxe proxy references across levels when an @@ -1351,12 +1349,14 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) if (!IsLevelIdentityReadyLocked(mob._level)) return false; + // Clients never invent NetIds — host is the sole authority (native ids diverge). if (GameMenu.NetRef?.IsHost != true) return false; syncId = nextRuntimeSyncId++; MobToId[mob] = syncId; IdToMob[syncId] = mob; + StampHostBossNetIdLocked(mob, syncId); // Dynamic/runtime-spawned mobs must be in the canonical tracked list immediately; // otherwise the first dirty packet creates an IdToMob entry that is rejected as // untracked_mob on the next dequeue. @@ -1395,7 +1395,7 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) private static Mob? ResolveTrackedMobForIncomingStateLocked(NetNode.MobStateSnapshot state, HashSet? reservedMobs) { - // Phase 2: stable boss identity carried in the boss state payload ("bid:"). 0 => none. + // Boss identity (bid:) folded into NetId space; still used across phase/proxy rebuilds. var bossEntityId = BossStateSync.TryGetEntityId(state.StatePayload); var mappedMob = ResolveTrackedMobBySyncIdLocked(state.Index); @@ -1404,8 +1404,6 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) var reserved = reservedMobs != null && reservedMobs.Contains(mappedMob); if (!reserved && DoesMobMatchStateType(mappedMob, state.Type)) { - // Learn the identity on the deterministic (load-time) sync-id hit so later - // rebuilds can rebind by identity even in a multi-boss arena. if (bossEntityId > 0) RememberClientBossEntityIdLocked(mappedMob, bossEntityId); return mappedMob; @@ -1423,41 +1421,30 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) } } - // Phase 2 identity-primary rebind: once a boss id has been learned, follow it across - // native phase/proxy rebuilds and sync-id changes. This never uses proximity and - // disambiguates any number of same-type bosses (Boss Rush duos, Servants, etc.). + // Boss phase/proxy rebuild: follow learned EntityId without proximity. if (bossEntityId > 0 && TryResolveClientBossByEntityIdLocked(bossEntityId, state.Index, reservedMobs, out var identityBoss) && identityBoss != null) { RememberClientBossEntityIdLocked(identityBoss, bossEntityId); + TryRebindTrackedMobSyncIdLocked(identityBoss, state.Index); MobSyncTrace.LogBindSyncId( "boss_identity_rebind", state.Index, state.Type ?? string.Empty, state.X, state.Y); - BossSyncDiag.Trace( - "client boss identity rebind entityId={EntityId} syncId={SyncId} type={Type}", - bossEntityId, - state.Index, - state.Type ?? string.Empty); return identityBoss; } - // Bosses are commonly rebuilt behind a new HashLink proxy during phase changes. The - // ordinary recovery path intentionally requires close coordinates and matching HP, - // which is too strict here: the whole purpose of this packet is to repair divergent - // boss HP/position. Rebind only a unique, explicitly-marked compatible boss (skipping - // any boss already claimed by a different living identity, so the newly rebuilt boss is - // the sole candidate for a not-yet-learned id). + // Unique authoritative boss (payload marked) when identity not yet learned. if (TryResolveUniqueAuthoritativeBossLocked( state.Type, state.StatePayload, reservedMobs, bossEntityId, out var authoritativeBoss, - out var bossCandidateCount) && + out _) && authoritativeBoss != null) { TryRebindTrackedMobSyncIdLocked(authoritativeBoss, state.Index); @@ -1472,39 +1459,28 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) return authoritativeBoss; } - if (TryResolveSingleUnboundTrackedMobForFirstStateLocked(state, reservedMobs, out var unresolvedMob, out var candidateCount) && - unresolvedMob != null) - { - TryRebindTrackedMobSyncIdLocked(unresolvedMob, state.Index); - if (bossEntityId > 0) - RememberClientBossEntityIdLocked(unresolvedMob, bossEntityId); - MobSyncTrace.LogBindSyncId("state_first_snapshot", state.Index, state.Type ?? string.Empty, state.X, state.Y); - return unresolvedMob; - } - - if (TryResolveNearestAuthoritativeStateMobLocked(state, reservedMobs, out var nearestMob, out var nearestCandidates) && - nearestMob != null) + // One-shot unbound bind: type + spawn position. No ongoing proximity combat rebind. + if (TryBindUnboundMobByTypeAndSpawnLocked( + state.Index, + state.Type, + state.X, + state.Y, + reservedMobs, + out var unboundMob) && + unboundMob != null) { - TryRebindTrackedMobSyncIdLocked(nearestMob, state.Index); if (bossEntityId > 0) - RememberClientBossEntityIdLocked(nearestMob, bossEntityId); - MobSyncTrace.LogFallbackMatchResolved( - "state_authoritative_repair", + RememberClientBossEntityIdLocked(unboundMob, bossEntityId); + MobSyncTrace.LogBindSyncId( + "state_oneshot_bind", state.Index, state.Type ?? string.Empty, state.X, - state.Y, - nearestCandidates, - rebound: true); - return nearestMob; + state.Y); + return unboundMob; } - // Last-resort anchor for encounter bosses: whatever churned the id table (dynamic - // add spawns reshuffling bindings, distance caps after drift), the arena's - // Level.boss of the matching type IS this state's subject. Proximity-free and - // type-checked so it can never steal an add's binding; identity-gated so duo - // arenas stay correct. Without this, an evicted boss binding could stay unbound - // for the whole fight (KingsHand syncId=0 -> Worm/Archer churn). + // Level.boss anchor for encounter bosses only (proximity-free, type-checked). if (TryResolveLevelBossAnchorForStateLocked(state, reservedMobs, bossEntityId, out var anchoredBoss) && anchoredBoss != null) { @@ -1520,25 +1496,6 @@ private static bool TryGetMobSyncId(Mob mob, out int syncId) return anchoredBoss; } - if (candidateCount > 1 || nearestCandidates > 1 || bossCandidateCount > 1) - { - lock (Sync) - { - var currentFrame = (int)GetCurrentFrame(null); - if (currentFrame - s_lastAmbiguousFallbackLogFrame >= AmbiguousFallbackLogCooldownFrames) - { - MobSyncTrace.LogAmbiguousMatchRejected( - "state", - state.Index, - state.Type ?? string.Empty, - state.X, - state.Y, - System.Math.Max(System.Math.Max(candidateCount, nearestCandidates), bossCandidateCount)); - s_lastAmbiguousFallbackLogFrame = currentFrame; - } - } - } - return null; } @@ -1669,190 +1626,6 @@ private static bool IsBossRelatedEntity(string? type) lowerType.Contains("ttcl"); } - private static bool TryResolveNearestAuthoritativeStateMobLocked( - NetNode.MobStateSnapshot state, - HashSet? reservedMobs, - out Mob? uniqueMob, - out int candidateCount) - { - uniqueMob = null; - candidateCount = 0; - if (trackedMobs.Count == 0 || string.IsNullOrWhiteSpace(state.Type)) - return false; - if (!double.IsFinite(state.X) || !double.IsFinite(state.Y)) - return false; - - var maxDistanceSq = ClientStateRebindMaxDistancePx * ClientStateRebindMaxDistancePx; - var bestDistanceSq = double.MaxValue; - var secondBestDistanceSq = double.MaxValue; - Mob? best = null; - - for (var i = 0; i < trackedMobs.Count; i++) - { - var mob = trackedMobs[i]; - if (mob == null || (reservedMobs != null && reservedMobs.Contains(mob))) - continue; - if (!IsStateRebindCandidateLocked(mob) || !DoesMobMatchStateType(mob, state.Type)) - continue; - - // Do not steal a healthy authoritative mapping from another sync id. A mapping whose - // reverse entry disappeared is already stale and may be repaired. - if (MobToId.TryGetValue(mob, out var existingId) && existingId != state.Index && - IdToMob.TryGetValue(existingId, out var existingMapped) && - existingMapped != null && ReferenceEquals(existingMapped, mob)) - { - continue; - } - - double dx; - double dy; - try - { - dx = GetWorldX(mob) - state.X; - dy = GetWorldY(mob) - state.Y; - } - catch - { - continue; - } - - if (!double.IsFinite(dx) || !double.IsFinite(dy)) - continue; - var distanceSq = dx * dx + dy * dy; - if (distanceSq > maxDistanceSq) - continue; - - candidateCount++; - if (distanceSq < bestDistanceSq) - { - secondBestDistanceSq = bestDistanceSq; - bestDistanceSq = distanceSq; - best = mob; - } - else if (distanceSq < secondBestDistanceSq) - { - secondBestDistanceSq = distanceSq; - } - } - - if (best == null) - return false; - - if (candidateCount > 1 && secondBestDistanceSq < double.MaxValue) - { - var gap = System.Math.Sqrt(secondBestDistanceSq) - System.Math.Sqrt(bestDistanceSq); - - // Boss parts (Conjunctivius tentacles, hands, claws) spawn in same-type clusters. - // Rejecting ambiguous candidates meant that whenever two parts stood close - // together NONE of them ever bound, leaving the whole cluster unsynchronized. - // Parts of one type are interchangeable actors, and the caller reserves each - // bound mob before resolving the next state, so greedy nearest binding is - // deterministic across the batch and strictly better than binding nothing. - if (!IsBossRelatedEntity(state.Type) && gap < ClientStateRebindMinimumGapPx) - return false; - } - - uniqueMob = best; - return true; - } - - private static bool TryResolveSingleUnboundTrackedMobForFirstStateLocked( - NetNode.MobStateSnapshot state, - HashSet? reservedMobs, - out Mob? uniqueMob, - out int candidateCount) - { - uniqueMob = null; - candidateCount = 0; - if (trackedMobs.Count == 0) - return false; - - if (clientAuthoritativeStateSeenSyncIds.Contains(state.Index)) - return false; - - if (string.IsNullOrWhiteSpace(state.Type)) - return false; - - if (!TryGetCurrentLevelIdentityTokenLocked(out _)) - return false; - - QuantizeWorldPositionToPixelsInt32(state.X, state.Y, out var qRefX, out var qRefY); - var preferredStateSignature = ExtractAffectPresenceSignature(state.StatePayload); - - for (int i = 0; i < trackedMobs.Count; i++) - { - var mob = trackedMobs[i]; - if (mob == null) - continue; - if (reservedMobs != null && reservedMobs.Contains(mob)) - continue; - if (!IsStateRebindCandidateLocked(mob)) - continue; - if (MobToId.TryGetValue(mob, out _)) - continue; - if (!DoesMobMatchStateType(mob, state.Type)) - continue; - - QuantizeWorldPositionToPixelsInt32(GetWorldX(mob!), GetWorldY(mob), out var qMobX, out var qMobY); - if (qMobX != qRefX || qMobY != qRefY) - continue; - - var normalizedPreferredDir = NormalizeDir(state.Dir); - if (normalizedPreferredDir != 0) - { - try - { - if (NormalizeDir(mob.dir) != normalizedPreferredDir) - continue; - } - catch - { - continue; - } - } - - if (state.Life != int.MinValue || state.MaxLife != int.MinValue) - { - try - { - if (state.Life != int.MinValue && mob.life != state.Life) - continue; - if (state.MaxLife != int.MinValue && mob.maxLife != state.MaxLife) - continue; - } - catch - { - continue; - } - } - - if (!string.IsNullOrWhiteSpace(preferredStateSignature)) - { - try - { - var stateSignature = BuildMobAffectPresencePayload(mob); - if (!string.Equals(stateSignature, preferredStateSignature, StringComparison.Ordinal)) - continue; - } - catch - { - continue; - } - } - - candidateCount++; - uniqueMob = mob; - } - - if (candidateCount != 1) - { - uniqueMob = null; - return false; - } - - return uniqueMob != null; - } - /// Rounds world coordinates to int32 pixels so host/client hit routing agrees despite float drift. private static void QuantizeWorldPositionToPixelsInt32(double x, double y, out int qx, out int qy) { @@ -2815,6 +2588,11 @@ private static void TryApplyHostMobHitCombatRefresh(Mob mob, int attackerUserId, if (mob == null || attackerUserId <= 0 || currentLife <= 0) return; + // Threat refresh can interruptSkills mid-charge when aTarget is invalid, stranding the + // host mob with no attack/move until a full reset. Skip while a skill is in flight. + if (HasLocalQueuedOrChargingSkill(mob)) + return; + var attacker = ResolveHostPlayerCombatEntity(attackerUserId); if (attacker == null || !IsPreservablePlayerCombatTargetForMob(mob, attacker)) return; diff --git a/Mobs/MonsterSynchronization.BossIdentity.cs b/Mobs/MonsterSynchronization.BossIdentity.cs index 7b035a5..c890d64 100644 --- a/Mobs/MonsterSynchronization.BossIdentity.cs +++ b/Mobs/MonsterSynchronization.BossIdentity.cs @@ -46,7 +46,7 @@ private sealed class HostBossIdentity private static readonly Dictionary s_hostNextAttackSeqByEntityId = new(); /// - /// Host-only. Returns the stable EntityId for a boss (assigning one on first sight), or 0 + /// Host-only. Returns the stable EntityId for a boss (NetId+1 when mapped, else assign), or 0 /// for non-bosses / when not hosting. Safe to call while holding /// (Monitor is re-entrant). /// @@ -67,6 +67,23 @@ internal static int GetOrAssignHostBossEntityId(Mob mob) lock (Sync) { + // Prefer the host NetId space so bid: and sync Index stay aligned. + if (MobToId.TryGetValue(mob, out var netId) && netId >= 0) + { + var fromNetId = netId + 1; + if (s_hostBossIdentities.TryGetValue(mob, out var existingFromNet) && + existingFromNet.EntityId == fromNetId) + { + TrackHostPrimaryBossLocked(mob, fromNetId); + return fromNetId; + } + + s_hostBossIdentities.Remove(mob); + s_hostBossIdentities.Add(mob, new HostBossIdentity { EntityId = fromNetId }); + TrackHostPrimaryBossLocked(mob, fromNetId); + return fromNetId; + } + if (s_hostBossIdentities.TryGetValue(mob, out var existing) && existing.EntityId > 0) { TrackHostPrimaryBossLocked(mob, existing.EntityId); diff --git a/Mobs/MonsterSynchronization.ClientReceive.cs b/Mobs/MonsterSynchronization.ClientReceive.cs index 9bba090..ad50c4e 100644 --- a/Mobs/MonsterSynchronization.ClientReceive.cs +++ b/Mobs/MonsterSynchronization.ClientReceive.cs @@ -181,27 +181,9 @@ private static void ApplyClientOwnedAffectPayloadOnHost(int mobSyncId, Mob mob, } } - foreach (var affectId in desired) - { - if (previousOwned.Contains(affectId)) - continue; - - var alreadyPresent = false; - try { alreadyPresent = mob.hasAffect(affectId); } catch { } - - // Never claim/remove an affect that already existed on the authoritative host. - if (alreadyPresent) - continue; - - try - { - mob.setAffectS(affectId, AuthoritativeAffectPresenceSeconds, HaxeProxy.Runtime.Ref.Null, null); - nextOwned.Add(affectId); - } - catch - { - } - } + // Do not create new host affects from client presence reports. Client combat prediction + // previously called setAffectS(..., 99999) here and permanently froze mobs after hits + // during charge/attack (both peers). Host already applies damage via MOBHIT. lock (Sync) { @@ -2134,9 +2116,20 @@ private static void ApplyIncomingMobHits(IReadOnlyList hits, int var appliedLife = update.TargetLife; if (update.ReplaySpecialHit) { - TryWakeMobForForcedSimulation(mob); - TryReplayIncomingSpecialHitReaction(mob, update.DamageHint); - appliedLife = GetMobLifeOrFallback(mob, update.TargetLife); + // Mid-charge/attack special-hit replay interrupts skills and can leave the + // authoritative mob AI-locked with no clean unlock. Prefer HP-only apply while + // a skill is queued/charging; vanilla will finish or recover on the host. + if (isHost && HasLocalQueuedOrChargingSkill(mob)) + { + ApplyAuthoritativeLifeState(mob, update.TargetLife, update.TargetMaxLife); + appliedLife = GetMobLifeOrFallback(mob, update.TargetLife); + } + else + { + TryWakeMobForForcedSimulation(mob); + TryReplayIncomingSpecialHitReaction(mob, update.DamageHint); + appliedLife = GetMobLifeOrFallback(mob, update.TargetLife); + } } else if (update.ForceDie) { diff --git a/Mobs/MonsterSynchronization.Constants.cs b/Mobs/MonsterSynchronization.Constants.cs index ddb37ed..f2cc36d 100644 --- a/Mobs/MonsterSynchronization.Constants.cs +++ b/Mobs/MonsterSynchronization.Constants.cs @@ -26,15 +26,17 @@ public partial class MobsSynchronization private const double MobHitTrustedSyncIdDistancePx = 24.0 * 64.0; /// Host-side fallback radius for client kill reports whose sync id was pruned locally while the mob is still alive. private const double MobHitMissingSyncIdRebindDistancePx = 24.0 * 48.0; - /// How often the host sends a full authoritative snapshot for all tracked mobs. This heals missed dirty packets after level loads. - private const double HostAuthoritativeFullResyncIntervalFrames = 30.0; - /// Reliable position/state keyframe interval for mobs actively fighting or visible to either player. - private const double HostActiveReliableKeyframeIntervalFrames = 6.0; + /// How often the host catch-up pass covers remaining tracked mobs. + private const double HostAuthoritativeFullResyncIntervalFrames = 45.0; + /// Reliable keyframe interval for mobs actively fighting or visible to either player. + private const double HostActiveReliableKeyframeIntervalFrames = 8.0; /// Bosses get a tighter reliable state cadence without increasing traffic for every normal mob. - private const double HostBossReliableKeyframeIntervalFrames = 2.0; - /// After a level registry rebuild, send a few quick full snapshots so clients that finish loading slightly later still catch the mob table. - private const int HostAuthoritativeBootstrapResyncCount = 12; + private const double HostBossReliableKeyframeIntervalFrames = 3.0; + /// After a level registry rebuild, resend MOBREG + catch-up a few times for late-loading clients. + private const int HostAuthoritativeBootstrapResyncCount = 6; private const double HostAuthoritativeBootstrapResyncIntervalFrames = 5.0; + /// Max mobs included in one catch-up pass (byte budget still applies). + private const int HostPriorityResyncCatchUpBudgetPerFlush = 48; /// Keep a short host-side tombstone for dead mobs so clients that miss the one death packet still clean up 0-HP ghosts. private const int HostAuthoritativeDeathTombstoneResendCount = 18; private const double HostAuthoritativeDeathTombstoneResendIntervalFrames = 8.0; @@ -83,7 +85,6 @@ public partial class MobsSynchronization private const double ClientJumpVelocityEpsilon = 0.03; private const double ClientJumpVelocityMaxRawMagnitude = 4.0; private const double ClientAiAuthorityLockDurationSeconds = 99999.0; - private const double HostBossIntroReadyBarrierLockSeconds = 0.5; /// Boss replicas converge much more tightly than ordinary grounded mobs. private const double ClientBossHardSnapDistancePx = 24.0 * 3.0; private const double ClientBossMinimumInterpolationAlpha = 0.82; diff --git a/Mobs/MonsterSynchronization.DirtyQueue.cs b/Mobs/MonsterSynchronization.DirtyQueue.cs index 10e0c88..82f414b 100644 --- a/Mobs/MonsterSynchronization.DirtyQueue.cs +++ b/Mobs/MonsterSynchronization.DirtyQueue.cs @@ -509,7 +509,11 @@ private static bool TryBuildHostDirtySnapshotForQueue( } - private static void FlushHostBossReliableKeyframes(NetNode net) + /// + /// Single priority resync scheduler: bosses first, then active/visible mobs, then budgeted + /// catch-up for the rest. Replaces the old boss-2f / active-6f / full-30f triple flush. + /// + private static void FlushHostPriorityResync(NetNode net) { if (!IsHost(net) || IsSyncQuiescedForTransition()) return; @@ -517,6 +521,9 @@ private static void FlushHostBossReliableKeyframes(NetNode net) return; var frame = GetCurrentFrame(null); + var sendBoss = false; + var sendActive = false; + var sendCatchUp = false; lock (Sync) { if (s_lastHostBossReliableKeyframeToken != identityToken) @@ -525,86 +532,47 @@ private static void FlushHostBossReliableKeyframes(NetNode net) s_lastHostBossReliableKeyframeFrame = -99999.0; } - if (frame - s_lastHostBossReliableKeyframeFrame < HostBossReliableKeyframeIntervalFrames) - return; - - s_lastHostBossReliableKeyframeFrame = frame; - s_batchMobsScratch.Clear(); - for (var i = 0; i < trackedMobs.Count; i++) + if (s_lastHostActiveReliableKeyframeToken != identityToken) { - var mob = trackedMobs[i]; - if (mob != null) - s_batchMobsScratch.Add(mob); + s_lastHostActiveReliableKeyframeToken = identityToken; + s_lastHostActiveReliableKeyframeFrame = -99999.0; } - } - if (s_batchMobsScratch.Count == 0) - return; - - s_batchSnapshotsScratch.Clear(); - var stateBytes = GetWireLineBaseBytes("MOBSTATE|"); - for (var i = 0; i < s_batchMobsScratch.Count; i++) - { - var boss = s_batchMobsScratch[i]; - if (boss == null || !BossSyncHelpers.IsBossMob(boss)) - continue; - if (!TryGetMobSyncId(boss, out var syncId) || syncId < 0) - continue; - if (!TryBuildHostMobDeltaSnapshot( - boss, - syncId, - forceFullState: true, - out var sendState, - out var stateSnapshot, - out _, - priorityHint: HostMobSyncPriority.Active) || !sendState) + if (s_lastHostAuthoritativeFullResyncToken != identityToken) { - continue; + s_lastHostAuthoritativeFullResyncToken = identityToken; + s_lastHostAuthoritativeFullResyncFrame = -99999.0; + s_hostAuthoritativeBootstrapResyncsRemaining = trackedMobs.Count > 0 + ? HostAuthoritativeBootstrapResyncCount + : 0; } - var entryBytes = EstimateMobStateWireBytes(stateSnapshot, s_batchSnapshotsScratch.Count); - if (s_batchSnapshotsScratch.Count > 0 && stateBytes + entryBytes > MobWirePacketByteBudget) + if (frame - s_lastHostBossReliableKeyframeFrame >= HostBossReliableKeyframeIntervalFrames) { - TrySendHostStatesBatchAsync(net, s_batchSnapshotsScratch); - s_batchSnapshotsScratch.Clear(); - stateBytes = GetWireLineBaseBytes("MOBSTATE|"); + s_lastHostBossReliableKeyframeFrame = frame; + sendBoss = true; } - RecordHostMobSendFrame(syncId); - s_batchSnapshotsScratch.Add(stateSnapshot); - stateBytes += entryBytes; - } - - if (s_batchSnapshotsScratch.Count > 0) - { - TrySendHostStatesBatchAsync(net, s_batchSnapshotsScratch); - s_batchSnapshotsScratch.Clear(); - } - - s_batchMobsScratch.Clear(); - } - - - private static void FlushHostActiveReliableKeyframes(NetNode net) - { - if (!IsHost(net) || IsSyncQuiescedForTransition()) - return; - if (!TryGetCurrentLevelIdentityToken(out var identityToken)) - return; + if (frame - s_lastHostActiveReliableKeyframeFrame >= HostActiveReliableKeyframeIntervalFrames) + { + s_lastHostActiveReliableKeyframeFrame = frame; + sendActive = true; + } - var frame = GetCurrentFrame(null); - lock (Sync) - { - if (s_lastHostActiveReliableKeyframeToken != identityToken) + var catchUpInterval = s_hostAuthoritativeBootstrapResyncsRemaining > 0 + ? HostAuthoritativeBootstrapResyncIntervalFrames + : HostAuthoritativeFullResyncIntervalFrames; + if (frame - s_lastHostAuthoritativeFullResyncFrame >= catchUpInterval) { - s_lastHostActiveReliableKeyframeToken = identityToken; - s_lastHostActiveReliableKeyframeFrame = -99999.0; + s_lastHostAuthoritativeFullResyncFrame = frame; + if (s_hostAuthoritativeBootstrapResyncsRemaining > 0) + s_hostAuthoritativeBootstrapResyncsRemaining--; + sendCatchUp = true; } - if (frame - s_lastHostActiveReliableKeyframeFrame < HostActiveReliableKeyframeIntervalFrames) + if (!sendBoss && !sendActive && !sendCatchUp) return; - s_lastHostActiveReliableKeyframeFrame = frame; s_batchMobsScratch.Clear(); for (var i = 0; i < trackedMobs.Count; i++) { @@ -617,96 +585,56 @@ private static void FlushHostActiveReliableKeyframes(NetNode net) if (s_batchMobsScratch.Count == 0) return; - s_batchSnapshotsScratch.Clear(); - var stateBytes = GetWireLineBaseBytes("MOBSTATE|"); - for (var i = 0; i < s_batchMobsScratch.Count; i++) - { - var mob = s_batchMobsScratch[i]; - if (mob == null || BossSyncHelpers.IsBossMob(mob) || - GetHostMobSyncPriority(mob) != HostMobSyncPriority.Active) - continue; - if (!TryGetMobSyncId(mob, out var syncId) || syncId < 0) - continue; - if (!TryBuildHostMobDeltaSnapshot( - mob, - syncId, - forceFullState: true, - out var sendState, - out var stateSnapshot, - out _, - priorityHint: HostMobSyncPriority.Active) || !sendState) - { - continue; - } - - var entryBytes = EstimateMobStateWireBytes(stateSnapshot, s_batchSnapshotsScratch.Count); - if (s_batchSnapshotsScratch.Count > 0 && stateBytes + entryBytes > MobWirePacketByteBudget) - { - TrySendHostStatesBatchAsync(net, s_batchSnapshotsScratch); - s_batchSnapshotsScratch.Clear(); - stateBytes = GetWireLineBaseBytes("MOBSTATE|"); - } + // Pass 1: bosses + if (sendBoss) + FlushHostPriorityResyncPass(net, preferBoss: true, preferActive: false, includeAll: false); - RecordHostMobSendFrame(syncId); - s_batchSnapshotsScratch.Add(stateSnapshot); - stateBytes += entryBytes; - } + // Pass 2: active/visible non-boss + if (sendActive) + FlushHostPriorityResyncPass(net, preferBoss: false, preferActive: true, includeAll: false); - if (s_batchSnapshotsScratch.Count > 0) - { - TrySendHostStatesBatchAsync(net, s_batchSnapshotsScratch); - s_batchSnapshotsScratch.Clear(); - } + // Pass 3: budgeted catch-up for remaining tracked mobs + if (sendCatchUp) + FlushHostPriorityResyncPass(net, preferBoss: false, preferActive: false, includeAll: true); s_batchMobsScratch.Clear(); } - private static void FlushHostAuthoritativeFullResync(NetNode net) + private static void FlushHostPriorityResyncPass( + NetNode net, + bool preferBoss, + bool preferActive, + bool includeAll) { - if (!IsHost(net)) - return; - - if (!TryGetCurrentLevelIdentityToken(out var identityToken)) - return; + s_batchSnapshotsScratch.Clear(); + var stateBytes = GetWireLineBaseBytes("MOBSTATE|"); + var sent = 0; - var frame = GetCurrentFrame(null); - var due = false; - lock (Sync) + for (var i = 0; i < s_batchMobsScratch.Count; i++) { - if (s_lastHostAuthoritativeFullResyncToken != identityToken) + var mob = s_batchMobsScratch[i]; + if (mob == null) + continue; + + var isBoss = BossSyncHelpers.IsBossMob(mob); + if (preferBoss && !isBoss) + continue; + if (preferActive) { - s_lastHostAuthoritativeFullResyncToken = identityToken; - s_lastHostAuthoritativeFullResyncFrame = -99999.0; - s_hostAuthoritativeBootstrapResyncsRemaining = trackedMobs.Count > 0 - ? HostAuthoritativeBootstrapResyncCount - : 0; + if (isBoss) + continue; + if (GetHostMobSyncPriority(mob) != HostMobSyncPriority.Active) + continue; } - - var interval = s_hostAuthoritativeBootstrapResyncsRemaining > 0 - ? HostAuthoritativeBootstrapResyncIntervalFrames - : HostAuthoritativeFullResyncIntervalFrames; - - if (frame - s_lastHostAuthoritativeFullResyncFrame >= interval) + else if (!preferBoss && !includeAll) { - s_lastHostAuthoritativeFullResyncFrame = frame; - if (s_hostAuthoritativeBootstrapResyncsRemaining > 0) - s_hostAuthoritativeBootstrapResyncsRemaining--; - due = true; - s_batchMobsScratch.Clear(); - s_batchMobsScratch.AddRange(trackedMobs); + continue; + } + else if (includeAll && !preferBoss && !preferActive) + { + // Catch-up pass includes everyone not already covered this flush by dirty queue. } - } - - if (!due || s_batchMobsScratch.Count == 0) - return; - s_batchSnapshotsScratch.Clear(); - var stateBytes = GetWireLineBaseBytes("MOBSTATE|"); - for (int i = 0; i < s_batchMobsScratch.Count; i++) - { - var mob = s_batchMobsScratch[i]; - if (mob == null) - continue; if (!TryGetMobSyncId(mob, out var syncId) || syncId < 0) continue; if (!TryBuildHostMobDeltaSnapshot( @@ -716,14 +644,11 @@ private static void FlushHostAuthoritativeFullResync(NetNode net) out var sendState, out var stateSnapshot, out _, - priorityHint: GetHostMobSyncPriority(mob))) + priorityHint: GetHostMobSyncPriority(mob)) || !sendState) { continue; } - if (!sendState) - continue; - var entryBytes = EstimateMobStateWireBytes(stateSnapshot, s_batchSnapshotsScratch.Count); if (s_batchSnapshotsScratch.Count > 0 && stateBytes + entryBytes > MobWirePacketByteBudget) { @@ -732,9 +657,13 @@ private static void FlushHostAuthoritativeFullResync(NetNode net) stateBytes = GetWireLineBaseBytes("MOBSTATE|"); } + if (includeAll && sent >= HostPriorityResyncCatchUpBudgetPerFlush) + break; + RecordHostMobSendFrame(syncId); s_batchSnapshotsScratch.Add(stateSnapshot); stateBytes += entryBytes; + sent++; } if (s_batchSnapshotsScratch.Count > 0) @@ -742,8 +671,6 @@ private static void FlushHostAuthoritativeFullResync(NetNode net) TrySendHostStatesBatchAsync(net, s_batchSnapshotsScratch); s_batchSnapshotsScratch.Clear(); } - - s_batchMobsScratch.Clear(); } private static void FlushClientDirtyMobQueue(NetNode net) diff --git a/Mobs/MonsterSynchronization.FrameConsume.cs b/Mobs/MonsterSynchronization.FrameConsume.cs index eff600d..26ed80f 100644 --- a/Mobs/MonsterSynchronization.FrameConsume.cs +++ b/Mobs/MonsterSynchronization.FrameConsume.cs @@ -22,6 +22,8 @@ private static void RunClientIncomingFrameConsume(NetNode net) if (!IsIncomingMobIdentityReady()) return; + // Bind NetIds from the host spawn table before applying state/move/die packets. + ConsumeIncomingMobRegistry(net); ConsumeIncomingHostMobStates(net); ConsumeIncomingHostMobMoves(net); ConsumeIncomingHostMobAttacks(net); diff --git a/Mobs/MonsterSynchronization.Registry.cs b/Mobs/MonsterSynchronization.Registry.cs new file mode 100644 index 0000000..2bd51b5 --- /dev/null +++ b/Mobs/MonsterSynchronization.Registry.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using dc.en; +using DeadCellsMultiplayerMod.Mobs.Bosses; + +namespace DeadCellsMultiplayerMod.Mobs.MobsSynchronization +{ + /// + /// Host-owned NetId registry. Native game entity ids and client entity-list indexes are never + /// identities. The host assigns monotonic NetIds per level generation; clients only bind local + /// references to those NetIds via MOBREG or a one-shot type+spawn state bind. + /// Conceptual portable form: . + /// + public partial class MobsSynchronization + { + /// One-shot bind radius for unbound locals matching a host registry/state entry. + private const double ClientRegistryBindMaxDistancePx = 24.0 * 8.0; + + private static readonly List s_mobRegistryScratch = new(); + private static int s_lastHostMobRegistryToken; + private static double s_lastHostMobRegistrySendFrame = -99999.0; + private static int s_hostMobRegistryResendsRemaining; + + private static bool IsHostAuthorityForNetIds() => + GameMenu.NetRef?.IsHost == true; + + /// + /// Host assigns NetIds in walk order (assignment order only). Clients track unbound mobs + /// and wait for MOBREG / state packets — they never invent NetIds from list index. + /// + private static void AssignHostNetIdsForRebuildLocked(IReadOnlyList candidateTrackedMobs) + { + nextRuntimeSyncId = 0; + for (var i = 0; i < candidateTrackedMobs.Count; i++) + { + var mob = candidateTrackedMobs[i]; + if (mob == null) + continue; + + trackedMobs.Add(mob); + trackedMobIndices[mob] = trackedMobs.Count - 1; + + if (!IsHostAuthorityForNetIds()) + continue; + + var netId = nextRuntimeSyncId++; + MobToId[mob] = netId; + IdToMob[netId] = mob; + StampHostBossNetIdLocked(mob, netId); + } + } + + private static void StampHostBossNetIdLocked(Mob mob, int netId) + { + if (mob == null || netId < 0) + return; + + try + { + if (!BossSyncHelpers.IsBossMob(mob)) + return; + } + catch + { + return; + } + + // Fold boss bid into the same NetId space for this level generation. + // EntityId is 1-based on the wire (0 = none). NetId remains 0-based in maps. + var entityId = netId + 1; + s_hostBossIdentities.Remove(mob); + s_hostBossIdentities.Add(mob, new HostBossIdentity { EntityId = entityId }); + TrackHostPrimaryBossLocked(mob, entityId); + } + + private static void QueueHostMobRegistryAfterRebuild() + { + if (!IsHostAuthorityForNetIds()) + return; + + lock (Sync) + { + s_hostMobRegistryResendsRemaining = HostAuthoritativeBootstrapResyncCount; + s_lastHostMobRegistryToken = s_levelIdentityToken; + s_lastHostMobRegistrySendFrame = -99999.0; + } + } + + private static void FlushHostMobRegistry(NetNode net) + { + if (!IsHost(net) || IsSyncQuiescedForTransition()) + return; + if (!TryGetCurrentLevelIdentityToken(out var identityToken)) + return; + + var frame = GetCurrentFrame(null); + lock (Sync) + { + if (s_lastHostMobRegistryToken != identityToken) + { + s_lastHostMobRegistryToken = identityToken; + s_hostMobRegistryResendsRemaining = HostAuthoritativeBootstrapResyncCount; + s_lastHostMobRegistrySendFrame = -99999.0; + } + + if (s_hostMobRegistryResendsRemaining <= 0) + return; + + if (frame - s_lastHostMobRegistrySendFrame < HostAuthoritativeBootstrapResyncIntervalFrames) + return; + + s_lastHostMobRegistrySendFrame = frame; + s_hostMobRegistryResendsRemaining--; + BuildHostMobRegistryEntriesLocked(identityToken, s_mobRegistryScratch); + } + + if (s_mobRegistryScratch.Count == 0) + return; + + net.SendMobRegistry(identityToken, s_mobRegistryScratch); + s_mobRegistryScratch.Clear(); + } + + private static void BuildHostMobRegistryEntriesLocked(int generation, List dst) + { + dst.Clear(); + for (var i = 0; i < trackedMobs.Count; i++) + { + var mob = trackedMobs[i]; + if (mob == null || !MobToId.TryGetValue(mob, out var netId) || netId < 0) + continue; + + double x; + double y; + try + { + x = GetWorldX(mob); + y = GetWorldY(mob); + } + catch + { + continue; + } + + var type = BuildMobStateTypeSignature(mob); + dst.Add(new NetNode.MobRegistryEntry(netId, generation, type, x, y)); + } + } + + private static void ConsumeIncomingMobRegistry(NetNode net) + { + if (!net.TryConsumeMobRegistry(out var entries) || entries == null || entries.Count == 0) + return; + + try + { + var rejectedCount = 0; + var rejectedGeneration = 0; + lock (Sync) + { + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + if (!ShouldAcceptPacketGenerationLocked(entry.Generation, ref rejectedCount, ref rejectedGeneration)) + continue; + + if (TryGetTrackedMobBySyncIdLocked(entry.NetId, out var already) && + already != null && + DoesMobMatchStateType(already, entry.Type)) + { + continue; + } + + if (TryBindUnboundMobByTypeAndSpawnLocked( + entry.NetId, + entry.Type, + entry.X, + entry.Y, + reservedMobs: null, + out var bound) && + bound != null) + { + MobSyncTrace.LogBindSyncId( + "mobreg_bind", + entry.NetId, + entry.Type ?? string.Empty, + entry.X, + entry.Y); + } + } + } + } + finally + { + NetNode.ReleaseConsumedList(entries); + } + } + + /// + /// One-shot bind for an unbound local mob: matching type + nearest spawn within distance. + /// Never steals a healthy NetId mapping from another living mob. + /// + private static bool TryBindUnboundMobByTypeAndSpawnLocked( + int netId, + string? type, + double x, + double y, + HashSet? reservedMobs, + out Mob? bound) + { + bound = null; + if (netId < 0 || string.IsNullOrWhiteSpace(type)) + return false; + if (!double.IsFinite(x) || !double.IsFinite(y)) + return false; + + var maxDistanceSq = ClientRegistryBindMaxDistancePx * ClientRegistryBindMaxDistancePx; + var bestDistanceSq = double.MaxValue; + var secondBestDistanceSq = double.MaxValue; + Mob? best = null; + var candidateCount = 0; + + for (var i = 0; i < trackedMobs.Count; i++) + { + var mob = trackedMobs[i]; + if (mob == null || (reservedMobs != null && reservedMobs.Contains(mob))) + continue; + if (!IsStateRebindCandidateLocked(mob)) + continue; + if (MobToId.TryGetValue(mob, out _)) + continue; + if (!DoesMobMatchStateType(mob, type)) + continue; + + double dx; + double dy; + try + { + dx = GetWorldX(mob) - x; + dy = GetWorldY(mob) - y; + } + catch + { + continue; + } + + if (!double.IsFinite(dx) || !double.IsFinite(dy)) + continue; + + var distanceSq = dx * dx + dy * dy; + if (distanceSq > maxDistanceSq) + continue; + + candidateCount++; + if (distanceSq < bestDistanceSq) + { + secondBestDistanceSq = bestDistanceSq; + bestDistanceSq = distanceSq; + best = mob; + } + else if (distanceSq < secondBestDistanceSq) + { + secondBestDistanceSq = distanceSq; + } + } + + if (best == null) + return false; + + if (candidateCount > 1 && secondBestDistanceSq < double.MaxValue) + { + var gap = Math.Sqrt(secondBestDistanceSq) - Math.Sqrt(bestDistanceSq); + if (!IsBossRelatedEntity(type) && gap < ClientStateRebindMinimumGapPx) + return false; + } + + TryRebindTrackedMobSyncIdLocked(best, netId); + bound = best; + return true; + } + } +} diff --git a/Mobs/MonsterSynchronization.cs b/Mobs/MonsterSynchronization.cs index aa99894..92a644e 100644 --- a/Mobs/MonsterSynchronization.cs +++ b/Mobs/MonsterSynchronization.cs @@ -576,9 +576,8 @@ private void OnFrameUpdateCore(double dt) var flushStart = RuntimeHitchWatch.Start(); FlushHostDirtyMobQueue(net); ScanHostBossPartDespawns(net); - FlushHostBossReliableKeyframes(net); - FlushHostActiveReliableKeyframes(net); - FlushHostAuthoritativeFullResync(net); + FlushHostMobRegistry(net); + FlushHostPriorityResync(net); FlushHostDeathTombstoneResends(net); var flushMs = RuntimeHitchWatch.GetElapsedMilliseconds(flushStart); if (flushMs >= RuntimeHitchWatch.MobSyncFlushSlowThresholdMs) @@ -1257,11 +1256,7 @@ private static void Hook_Mob_onDie(Hook_Mob.orig_onDie orig, Mob self) RememberHostDeathTombstoneLocked(self, dieSyncId, dieX, dieY, identityToken); } - var update = new NetNode.MobEventUpdate(dieSyncId, dieX, dieY, 0, SingleEvent("die"), dieType, identityToken); - MobSyncTrace.LogSendMobEvents(MobSyncNetRoleForTrace(dieNet), SingleUpdate(update)); - dieNet.SendMobEvents(SingleUpdate(update)); - // Redundant typed death packet: unlike the old untyped fallback, this can safely - // recover a phase-rebuilt boss mapping if the MOBEVENT packet was missed. + // Authoritative death: one reliable MOBDIE path (no dual MOBEVENT|die). dieNet.SendMobDie(dieSyncId, dieX, dieY, identityToken, dieType); } } diff --git a/ModEntry/ModEntry.GhostSync.cs b/ModEntry/ModEntry.GhostSync.cs index b041226..f4232c8 100644 --- a/ModEntry/ModEntry.GhostSync.cs +++ b/ModEntry/ModEntry.GhostSync.cs @@ -354,8 +354,9 @@ private void SendHeroCoords() if (_netRole == NetRole.None) return; if (_net == null || me == null) return; int dir = me.dir; - if (me.spr.x == last_x && me.spr.y == last_y && lastDir == dir) return; + // Always send X/Y/dir. Skipping unchanged frames let peer GhostKing physics drift + // the remote Y while the local player stood still (no correction packets). _net.TickSend(me.spr.x, me.spr.y, dir); last_x = me.spr.x; last_y = me.spr.y; @@ -592,13 +593,14 @@ private void ReceiveGhostCoords() wasUsingDownedOffset ? "snapshot-transition" : "snapshot-grace"); } - if (rLastX[index] != drawX || rLastY[index] != drawY) - { - client.setPosPixel(drawX, drawY); - rLastX[index] = drawX; - rLastY[index] = drawY; + // Always re-apply remote Y (and X). GhostKing physics can drift between + // snapshots; skipping unchanged coords left peers floating/sinking. + var posChanged = rLastX[index] != drawX || rLastY[index] != drawY; + client.setPosPixel(drawX, drawY); + rLastX[index] = drawX; + rLastY[index] = drawY; + if (posChanged) headDirty = true; - } if (clientLastDirs[index] != remote.Dir) { @@ -1128,9 +1130,19 @@ private void ReceiveGhostAttacks() var client = clients[index]; if (client?.kingWeaponsManager == null) continue; if (attack.Action == RemoteAttackAction.Interrupt) + { client.kingWeaponsManager.queueInterrupt(attack.Slot); + } else + { client.kingWeaponsManager.queueAttack(attack.Slot); + } + + // Remote ATK changes GhostKing.spr outside the ANIM path. Drop the body-anim + // cache so a standing re-idle is not treated as a no-op. + clientLastBodyAnims[index] = null; + clientLastBodyAnimQueues[index] = null; + clientLastBodyAnimGs[index] = null; queuedAttacks++; LogGhostRuntimeStepIfSlow( diff --git a/ModEntry/ModEntry.ReviveDowned.cs b/ModEntry/ModEntry.ReviveDowned.cs index 2ef0d38..524ab6e 100644 --- a/ModEntry/ModEntry.ReviveDowned.cs +++ b/ModEntry/ModEntry.ReviveDowned.cs @@ -1,5 +1,4 @@ using dc; -using System.Diagnostics; namespace DeadCellsMultiplayerMod @@ -14,136 +13,6 @@ internal static bool IsLocalPlayerDowned() return Instance != null && Instance._localFakeDead; } - /// - /// Boss victory is already host-authoritative through the mob death pipeline. A downed - /// local player is restored as part of that same confirmed event, before reward pickup. - /// Duplicate death tombstones are harmless because revive is idempotent. - /// - internal static void ReviveLocalPlayerAfterBossVictory() - { - var instance = Instance; - if (instance == null || !instance._localFakeDead) - return; - - var net = _net; - if (net == null || !net.IsAlive) - return; - - instance.ReviveLocalPlayer(net); - } - - /// - /// A host-confirmed victory is also the client's presentation barrier: no stale local boss - /// death cinematic or spectator target may retain the camera after rewards become available. - /// - internal static void RecoverLocalPresentationAfterBossVictory() - { - var instance = Instance; - var net = _net; - if (instance == null || net == null || !net.IsAlive || net.IsHost) - return; - - instance._clientBossVictoryRecoveryPending = true; - instance._clientBossVictoryRecoveryStartedTick = Stopwatch.GetTimestamp(); - instance._clientBossVictoryRecoveryLevelId = instance.GetCurrentLevelId(); - - GameMenu.EnqueueCriticalMainThreadCoalesced("game:boss-victory-presentation", () => - { - if (_net == null || !_net.IsAlive || _net.IsHost) - return; - - instance.ApplyClientBossVictoryPresentationRecovery(releaseUnknownCinematic: false); - }); - } - - private void MaintainClientBossVictoryPresentationRecovery() - { - if (!_clientBossVictoryRecoveryPending) - return; - if (_netRole != NetRole.Client || _net == null || !_net.IsAlive) - { - ResetClientBossVictoryPresentationRecovery(); - return; - } - - var currentLevelId = GetCurrentLevelId(); - if (!string.IsNullOrWhiteSpace(_clientBossVictoryRecoveryLevelId) && - !string.Equals(currentLevelId, _clientBossVictoryRecoveryLevelId, StringComparison.OrdinalIgnoreCase)) - { - ResetClientBossVictoryPresentationRecovery(); - return; - } - - var elapsedSeconds = _clientBossVictoryRecoveryStartedTick > 0 - ? (Stopwatch.GetTimestamp() - _clientBossVictoryRecoveryStartedTick) / (double)Stopwatch.Frequency - : 0.0; - - var hasRetainedCinematic = false; - try - { - var cine = dc.pr.Game.Class.ME?.curCine; - hasRetainedCinematic = cine != null; - } - catch - { - } - - ApplyClientBossVictoryPresentationRecovery( - releaseUnknownCinematic: elapsedSeconds >= ClientBossVictoryUnknownCineReleaseSeconds); - - if (elapsedSeconds >= ClientBossVictoryRecoveryMaxSeconds || - (!hasRetainedCinematic && elapsedSeconds >= ClientBossVictoryNoCineGraceSeconds)) - { - ResetClientBossVictoryPresentationRecovery(); - } - } - - private void ApplyClientBossVictoryPresentationRecovery(bool releaseUnknownCinematic) - { - try - { - var game = dc.pr.Game.Class.ME; - var cine = game?.curCine; - if (cine != null && cine is not DeadBase && cine is not RemoteDownedCorpse) - { - var typeName = cine.GetType().Name ?? string.Empty; - var isKnownBossDeath = BossDeathCineTypeNames.Contains(typeName); - var isHeroDeath = typeName.Contains("HeroDeath", StringComparison.OrdinalIgnoreCase); - var isConfirmedBossLevel = IsBossLevel(GetCurrentLevelId()); - var isDestroyed = false; - try { isDestroyed = cine.destroyed; } catch { } - if (isKnownBossDeath || - (isDestroyed && isConfirmedBossLevel && !isHeroDeath) || - (releaseUnknownCinematic && isConfirmedBossLevel && !isHeroDeath)) - { - // Unknown DLC/Boss Rush death cinematics are allowed to finish naturally. - // If one still owns Game.curCine eight seconds after host-confirmed victory, - // it is stale and must not strand only the client behind a letterbox/camera lock. - SuppressRemoteBossDeathCineState(cine); - } - } - } - catch - { - } - - SuppressRemoteBossDeathCineIfNeeded(); - _automaticDownedSpectateActive = false; - _spectatedRemoteCameraId = 0; - _spectatedCameraOrderIndex = 0; - try { me?.cancelSkillControlLock(); } catch { } - try { me?.unlockControls(); } catch { } - EnsureHeroVisibilityAfterRoomChange(me); - RequestLocalCameraRefollow("host-boss-victory"); - } - - private void ResetClientBossVictoryPresentationRecovery() - { - _clientBossVictoryRecoveryPending = false; - _clientBossVictoryRecoveryStartedTick = 0; - _clientBossVictoryRecoveryLevelId = null; - } - internal static bool ShouldAnchorLocalDownedCorpse() { return Instance != null && Instance._localFakeDead; diff --git a/ModEntry/ModEntry.cs b/ModEntry/ModEntry.cs index 517cc1b..7a73dba 100644 --- a/ModEntry/ModEntry.cs +++ b/ModEntry/ModEntry.cs @@ -331,12 +331,6 @@ private static bool IsBossRoomEventIdCandidate(string? eventId) private const double BossHeroTeleportEchoSuppressSeconds = 1.5; private int _suppressBossCineSendDepth; private long _suppressBossTriggerNetSendUntilTick; - private bool _clientBossVictoryRecoveryPending; - private long _clientBossVictoryRecoveryStartedTick; - private string? _clientBossVictoryRecoveryLevelId; - private const double ClientBossVictoryNoCineGraceSeconds = 2.0; - private const double ClientBossVictoryUnknownCineReleaseSeconds = 8.0; - private const double ClientBossVictoryRecoveryMaxSeconds = 12.0; void IOnAfterLoadingCDB.OnAfterLoadingCDB(dc._Data_ cdb) @@ -517,8 +511,6 @@ void IOnAdvancedModuleInitializing.OnAdvancedModuleInitializing(ModEntry entry) BuildInfo.Version); entry.Logger.Information("[NetMod][FlintGuard] mode=local-vanilla-no-remote-runtime-preflight-powered-feedback-scan"); entry.Logger.Information("[NetMod][BossCells] mode=working-v0.8.68-selector-reload-and-render-guard"); - entry.Logger.Information("[NetMod][BossSafety] mode=legacy-native-death-no-custom-encounter-victory"); - entry.Logger.Information("[NetMod][BossIntro] mode=legacy-native-trigger-no-ready-barrier"); entry.Logger.Information("[NetMod][BossRushLoad] mode=host-door-precommit-structured-seed-barrier"); entry.Logger.Information("[NetMod][CurseGuard] mode=fake-death-revive-dive-safe"); entry.Logger.Information("[NetMod][VanillaTransitions] mode=working-v0.8.68-typed-activateSubLevel-stack"); diff --git a/PortableCore/AuthorityRules.cs b/PortableCore/AuthorityRules.cs deleted file mode 100644 index c1c9ef8..0000000 --- a/PortableCore/AuthorityRules.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace DeadCellsMultiplayerMod.PortableCore; - -internal enum AuthorityDomain -{ - RunLaunch, - LevelGeneration, - EntitySpawn, - EnemyAi, - BossAi, - DamageResolution, - WorldInteraction, - EncounterLifecycle, - LevelTransition, - Progression, - LocalInput, - Camera, - Presentation, -} - -internal enum AuthorityOwner -{ - Host, - LocalClient, -} - -/// -/// One explicit authority policy shared by every integration layer. -/// -internal static class AuthorityRules -{ - public static AuthorityOwner OwnerOf(AuthorityDomain domain) => domain switch - { - AuthorityDomain.LocalInput => AuthorityOwner.LocalClient, - AuthorityDomain.Camera => AuthorityOwner.LocalClient, - AuthorityDomain.Presentation => AuthorityOwner.LocalClient, - _ => AuthorityOwner.Host, - }; - - public static bool CanMutateAuthoritativeState(AuthorityDomain domain, bool isHost) => - OwnerOf(domain) == AuthorityOwner.Host ? isHost : true; -} diff --git a/PortableCore/IDeadCellsGameBridge.cs b/PortableCore/IDeadCellsGameBridge.cs deleted file mode 100644 index 476c458..0000000 --- a/PortableCore/IDeadCellsGameBridge.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace DeadCellsMultiplayerMod.PortableCore; - -/// -/// Boundary between portable multiplayer policy and Dead Cells-specific code. -/// The current DCCM implementation and a future official source implementation -/// should satisfy the same conceptual contract. -/// -internal interface IDeadCellsGameBridge -{ - bool ValidateLaunch(RunLaunchDescriptor descriptor, out string error); - void CommitLaunch(RunLaunchDescriptor descriptor); - void LoadCommittedLevel(RunLaunchDescriptor descriptor, int levelGeneration); - void ApplyAuthoritativeSpawn(NetEntityId entityId, string payload); - void ApplyAuthoritativeEvent(string eventType, long sequence, string payload); - void EndRun(string reason); -} diff --git a/PortableCore/NetEntityId.cs b/PortableCore/NetEntityId.cs index 5b21916..452dcd0 100644 --- a/PortableCore/NetEntityId.cs +++ b/PortableCore/NetEntityId.cs @@ -1,8 +1,10 @@ namespace DeadCellsMultiplayerMod.PortableCore; /// -/// Stable identity assigned by the authority when an entity is spawned. -/// Runtime object addresses, list indexes, names, and positions are not identities. +/// Stable identity assigned by the host authority when an entity is spawned. +/// Runtime object addresses, native game ids, list indexes, names, and positions are not identities. +/// Wire traffic uses a compact int NetId + level generation; this struct is the conceptual form +/// (generation + spawn sequence + archetype) used by host registry bookkeeping. /// internal readonly record struct NetEntityId( int LevelGeneration, diff --git a/PortableCore/SequencedEvent.cs b/PortableCore/SequencedEvent.cs deleted file mode 100644 index 90a00f6..0000000 --- a/PortableCore/SequencedEvent.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace DeadCellsMultiplayerMod.PortableCore; - -/// -/// Reliable event envelope. Consumers process an event at most once for a session -/// and level generation. -/// -internal sealed record SequencedEvent( - Guid SessionId, - int LevelGeneration, - long Sequence, - int SenderPlayerId, - TPayload Payload) -{ - public bool IsNewerThan(long lastAppliedSequence) => Sequence > lastAppliedSequence; -} diff --git a/README.md b/README.md index e041686..b6168fc 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,11 @@ On the first launch, required configuration files will be generated automaticall - [x] Level graph reload (boss cells, transitions) - [x] Multiplayer save slots and continue - [x] Camera spectate mode -- [ ] Custom mode +- [x] Custom mode - [x] Steam P2P connectivity +**Note:** Enemy sync uses host-owned NetIds (not native game entity ids). `AdvancedCoop` is lobby heartbeat + permanent unlock progression only — it does not sync enemies. + --- ## 📜 Credits diff --git a/README_ru.md b/README_ru.md index 5ee83e9..5249894 100644 --- a/README_ru.md +++ b/README_ru.md @@ -106,9 +106,11 @@ - [x] Синхронизация загрузки графа уровня (босс-клетки, переходы) - [x] Слоты сохранения мультиплеера и продолжение - [x] Камера-спектатор -- [ ] Кастомный режим +- [x] Кастомный режим - [x] Steam P2P подключение +**Примечание:** синхронизация врагов использует host-owned NetId (не нативные id игры). `AdvancedCoop` — только lobby heartbeat и постоянный прогресс, не синкает врагов. + --- ## 📜 Благодарности diff --git a/UI/GameMenu.ClientLaunchSession.cs b/UI/GameMenu.ClientLaunchSession.cs new file mode 100644 index 0000000..03b994a --- /dev/null +++ b/UI/GameMenu.ClientLaunchSession.cs @@ -0,0 +1,148 @@ +namespace DeadCellsMultiplayerMod; + +/// +/// Single client auto-start arming path. Call sites signal progress; only +/// sets lobby _pendingAutoStart. +/// +internal static partial class GameMenu +{ + private enum ClientLaunchPhase + { + Lobby, + IntentReceived, + AwaitingPrereqs, + Armed, + Starting, + InRun, + RestartPending + } + + private static ClientLaunchPhase _clientLaunchPhase = ClientLaunchPhase.Lobby; + + private static void ResetClientLaunchSessionLocked() + { + _clientLaunchPhase = ClientLaunchPhase.Lobby; + } + + private static void MarkClientLaunchInRunLocked() + { + _clientLaunchPhase = ClientLaunchPhase.InRun; + } + + private static void MarkClientLaunchRestartPendingLocked() + { + _clientLaunchPhase = ClientLaunchPhase.RestartPending; + _pendingAutoStart = false; + } + + /// + /// After gen/seed/commit/exec/custom-data/level-desc progress, recompute whether + /// the client lobby auto-start may arm. + /// + private static void SignalClientLaunchProgressLocked() + { + ReevaluateClientLaunchArmLocked(); + } + + /// + /// Network/main-thread entry for launch prereqs that arrive outside GameMenu + /// (remote level graph, boss rune). Safe to call from receive paths. + /// + internal static void NotifyClientLaunchPrerequisiteProgress() + { + lock (Sync) + { + if (_role == NetRole.Client && !_inActualRun) + SignalClientLaunchProgressLocked(); + } + } + + private static void ReevaluateClientLaunchArmLocked() + { + if (_role != NetRole.Client) + { + _clientLaunchPhase = ClientLaunchPhase.Lobby; + return; + } + + if (_pendingClientRestartSeed.HasValue) + { + _clientLaunchPhase = ClientLaunchPhase.RestartPending; + return; + } + + if (_inActualRun) + { + _clientLaunchPhase = ClientLaunchPhase.InRun; + return; + } + + if (_autoStartTriggered || _clientLaunchPhase == ClientLaunchPhase.Starting) + return; + + var hasIntent = _genArrived || + _seedArrived || + _structuredLaunchCommitArrived || + _structuredLaunchExecuteSequence > 0 || + _remoteCustomGameDataReady; + if (!hasIntent) + { + _clientLaunchPhase = ClientLaunchPhase.Lobby; + return; + } + + _clientLaunchPhase = ClientLaunchPhase.IntentReceived; + + if (!IsPendingLaunchReadyForAutoStartLocked()) + { + _clientLaunchPhase = ClientLaunchPhase.AwaitingPrereqs; + _pendingAutoStart = false; + return; + } + + // Fresh NewGame still requires the structured commit/execute barrier. + if (_pendingLaunchAction != PendingLaunchAction.LoadSave && + !CanAutoStartStructuredClientLaunchLocked()) + { + _clientLaunchPhase = ClientLaunchPhase.AwaitingPrereqs; + _pendingAutoStart = false; + return; + } + + _pendingAutoStart = true; + _clientLaunchPhase = ClientLaunchPhase.Armed; + } + + /// + /// TickMenu claim: Armed → Starting. Returns false if another pump already claimed. + /// + private static bool TryClaimClientAutoStartLocked() + { + if (_role != NetRole.Client || + _inActualRun || + _pendingClientRestartSeed.HasValue || + !_pendingAutoStart || + _autoStartTriggered || + !IsPendingLaunchReadyForAutoStartLocked()) + { + return false; + } + + if (_pendingLaunchAction != PendingLaunchAction.LoadSave && + !CanAutoStartStructuredClientLaunchLocked()) + { + return false; + } + + _autoStartTriggered = true; + _clientLaunchPhase = ClientLaunchPhase.Starting; + return true; + } + + private static void ReleaseClientAutoStartClaimLocked() + { + _autoStartTriggered = false; + _pendingAutoStart = true; + _clientLaunchPhase = ClientLaunchPhase.Armed; + } +} diff --git a/UI/GameMenu.Connection.cs b/UI/GameMenu.Connection.cs index 38e7377..7379e7d 100644 --- a/UI/GameMenu.Connection.cs +++ b/UI/GameMenu.Connection.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Reflection; using System.Runtime.InteropServices; using dc.pr; @@ -184,7 +184,6 @@ private static void ShowLobbyNotFoundPopup(TitleScreen screen) private static void DisconnectFromMenu(TitleScreen screen) { StopNetworkFromMenu(); - _waitingForHost = false; ResetClientConnectState(); _menuSelection = NetRole.None; ResetSteamState(); @@ -231,7 +230,6 @@ public static void NotifyRemoteConnected(NetRole role) if (role == NetRole.Host) { - _waitingForHost = false; SendCachedDataToRemote(); lock (Sync) { @@ -248,9 +246,6 @@ public static void NotifyRemoteConnected(NetRole role) } else if (role == NetRole.Client) { - _waitingForHost = false; - _clientConnecting = false; - _clientConnectAttempt = 0; ConnectionUI.NotifyConnectionsChanged(); if (_menuSelection == NetRole.Client) { @@ -264,13 +259,6 @@ public static void NotifyRemoteConnected(NetRole role) internal static void NotifyClientConnectAttempt(int attempt) { - lock (Sync) - { - _clientConnectAttempt = attempt; - _clientConnecting = true; - _waitingForHost = true; - } - if (_menuSelection == NetRole.Client) { var ts = GetTitleScreen(); @@ -282,7 +270,6 @@ internal static void NotifyClientConnectFailed() { StopNetworkFromMenu(); ResetClientConnectState(); - _waitingForHost = false; _menuSelection = NetRole.Client; var ts = GetTitleScreen(); @@ -329,7 +316,6 @@ public static void NotifyRemoteDisconnected(NetRole role) SetRole(NetRole.None); NetRef = null; - _waitingForHost = false; _menuSelection = NetRole.None; ResetSteamState(); ClearNetworkCaches(); @@ -405,7 +391,6 @@ private static void ResetSteamState() try { SteamConnect.LeaveLobby(lobbyId); } catch { } } try { SteamConnect.StopHostLobbyWorker(); } catch { } - _steamLobbyActive = false; _steamLobbyId = 0; _steamLobbyCode = string.Empty; _steamHostSteamId = 0UL; @@ -678,7 +663,7 @@ public static void ReceiveGeneratePayload(string json) if (_role == NetRole.Client && !_inActualRun) { _genArrived = true; - _pendingAutoStart = true; + SignalClientLaunchProgressLocked(); } } @@ -954,8 +939,6 @@ private static void ResetClientConnectState() { lock (Sync) { - _clientConnectAttempt = 0; - _clientConnecting = false; _pendingClientRestartSeed = null; _pendingClientRestartReason = string.Empty; } diff --git a/UI/GameMenu.MainThread.cs b/UI/GameMenu.MainThread.cs new file mode 100644 index 0000000..89794af --- /dev/null +++ b/UI/GameMenu.MainThread.cs @@ -0,0 +1,339 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Reflection; +using System.Threading; +using System.Threading.Channels; +using DeadCellsMultiplayerMod.Tools; + +namespace DeadCellsMultiplayerMod; + +/// +/// Unified main-thread pump: Critical coalesce, then Network Channel, then Normal UI queue. +/// +internal static partial class GameMenu +{ + private readonly struct MainThreadWorkItem + { + public readonly Action? Action; + public readonly string? CoalesceKey; + + public MainThreadWorkItem(Action action) + { + Action = action; + CoalesceKey = null; + } + + public MainThreadWorkItem(string coalesceKey) + { + Action = null; + CoalesceKey = coalesceKey; + } + } + + private static readonly ConcurrentQueue _mainThreadQueue = new(); + private static readonly ConcurrentDictionary _mainThreadActionLabelCache = new(); + private static readonly object MainThreadCoalesceSync = new(); + private static readonly Dictionary _coalescedMainThreadActions = new(StringComparer.Ordinal); + private static readonly HashSet _pendingCoalescedMainThreadKeys = new(StringComparer.Ordinal); + private static int _mainThreadQueueDepth; + private const int MainThreadQueueMaxActionsPerPump = 64; + private const double MainThreadQueueBudgetMs = 4.0; + + private static readonly Channel _networkMainThreadQueue = Channel.CreateBounded( + new BoundedChannelOptions(2048) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait, + AllowSynchronousContinuations = false + }); + + private static readonly object CriticalMainThreadCoalesceSync = new(); + private static readonly Dictionary _criticalCoalescedActions = new(StringComparer.Ordinal); + private static readonly ConcurrentQueue _criticalCoalescedKeys = new(); + private const int MainThreadQueueMaxPendingCritical = 64; + private const int MainThreadQueueBurstActionsPerPump = 768; + private const int MainThreadQueueBurstBacklogThreshold = 96; + private static long _lastMainThreadCoalescedDropLogTicks; + + private static void ResetMainThreadQueuesLocked() + { + while (_networkMainThreadQueue.Reader.TryRead(out _)) { } + while (_criticalCoalescedKeys.TryDequeue(out _)) { } + lock (CriticalMainThreadCoalesceSync) + _criticalCoalescedActions.Clear(); + + _mainThreadQueueDepth = _mainThreadQueue.Count; + lock (MainThreadCoalesceSync) + { + _coalescedMainThreadActions.Clear(); + _pendingCoalescedMainThreadKeys.Clear(); + } + } + + internal static void EnqueueMainThread(Action action) + { + if (action == null) + return; + + _mainThreadQueue.Enqueue(new MainThreadWorkItem(action)); + Interlocked.Increment(ref _mainThreadQueueDepth); + } + + internal static void EnqueueMainThreadCoalesced(string coalesceKey, Action action) + { + if (action == null) + return; + + if (string.IsNullOrWhiteSpace(coalesceKey)) + { + EnqueueMainThread(action); + return; + } + + var shouldEnqueue = false; + lock (MainThreadCoalesceSync) + { + _coalescedMainThreadActions[coalesceKey] = action; + if (_pendingCoalescedMainThreadKeys.Add(coalesceKey)) + shouldEnqueue = true; + } + + if (!shouldEnqueue) + return; + + _mainThreadQueue.Enqueue(new MainThreadWorkItem(coalesceKey)); + Interlocked.Increment(ref _mainThreadQueueDepth); + } + + internal static ValueTask EnqueueNetworkMainThreadAsync(Action action, CancellationToken cancellationToken) + { + if (action == null) + return ValueTask.CompletedTask; + + return _networkMainThreadQueue.Writer.WriteAsync(action, cancellationToken); + } + + internal static void ClearPendingNetworkMainThreadActions() + { + while (_networkMainThreadQueue.Reader.TryRead(out _)) { } + } + + internal static void EnqueueCriticalMainThreadCoalesced(string coalesceKey, Action action) + { + if (action == null || string.IsNullOrWhiteSpace(coalesceKey)) + return; + + bool isNewKey; + lock (CriticalMainThreadCoalesceSync) + { + isNewKey = !_criticalCoalescedActions.ContainsKey(coalesceKey); + if (isNewKey && _criticalCoalescedActions.Count >= MainThreadQueueMaxPendingCritical) + { + LogCriticalMainThreadCoalescedDropRateLimited(coalesceKey); + return; + } + + _criticalCoalescedActions[coalesceKey] = action; + } + + if (isNewKey) + _criticalCoalescedKeys.Enqueue(coalesceKey); + } + + private static void LogCriticalMainThreadCoalescedDropRateLimited(string key) + { + var now = System.Diagnostics.Stopwatch.GetTimestamp(); + var minTicks = System.Diagnostics.Stopwatch.Frequency * 5L; + var previous = Interlocked.Read(ref _lastMainThreadCoalescedDropLogTicks); + if (previous != 0 && now - previous < minTicks) + return; + if (Interlocked.CompareExchange(ref _lastMainThreadCoalescedDropLogTicks, now, previous) != previous) + return; + + _log?.Warning( + "[NetMod] Rejected critical coalesced main-thread work because its queue is full (key={Key})", + key); + } + + /// + /// Drain critical coalesced work first, then reliable network protocol actions. + /// + private static int DrainCriticalAndNetworkMainThreadQueues(int budget) + { + if (budget <= 0) + return 0; + + var processed = 0; + var networkBacklog = 0; + if (_networkMainThreadQueue.Reader.CanCount) + networkBacklog = _networkMainThreadQueue.Reader.Count; + + var effectiveBudget = networkBacklog >= MainThreadQueueBurstBacklogThreshold + ? Math.Max(budget, MainThreadQueueBurstActionsPerPump) + : budget; + + while (processed < effectiveBudget) + { + Action? action = null; + + if (_criticalCoalescedKeys.TryDequeue(out var criticalKey)) + { + lock (CriticalMainThreadCoalesceSync) + { + _criticalCoalescedActions.TryGetValue(criticalKey, out action); + _criticalCoalescedActions.Remove(criticalKey); + } + } + else if (_networkMainThreadQueue.Reader.TryRead(out var networkAction)) + { + action = networkAction; + } + else + { + break; + } + + if (action == null) + continue; + + processed++; + try + { + action(); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Main thread task failed: {Message}", ex.Message); + } + } + + return processed; + } + + internal static void ProcessMainThreadQueue() + { + var hitchStart = RuntimeHitchWatch.Start(); + var perfEnabled = RuntimeHitchWatch.Enabled; + var startDepth = Volatile.Read(ref _mainThreadQueueDepth); + var processed = 0; + var slowActions = 0; + var maxActionMs = 0.0; + var maxActionLabel = string.Empty; + var actionsStart = RuntimeHitchWatch.Start(); + + // Critical + reliable network protocol work must drain even when the UI queue is busy. + processed += DrainCriticalAndNetworkMainThreadQueues(MainThreadQueueMaxActionsPerPump); + + while (_mainThreadQueue.TryDequeue(out var workItem)) + { + Interlocked.Decrement(ref _mainThreadQueueDepth); + Action? action = workItem.Action; + var actionLabel = workItem.CoalesceKey; + if (actionLabel != null) + { + lock (MainThreadCoalesceSync) + { + _pendingCoalescedMainThreadKeys.Remove(actionLabel); + _coalescedMainThreadActions.TryGetValue(actionLabel, out action); + _coalescedMainThreadActions.Remove(actionLabel); + } + } + + if (action == null) + continue; + + processed++; + var actionStart = perfEnabled ? RuntimeHitchWatch.Start() : 0; + try + { + action(); + } + catch (Exception ex) + { + _log?.Warning("[NetMod] Main thread task failed: {Message}", ex.Message); + } + finally + { + if (perfEnabled) + { + var actionMs = RuntimeHitchWatch.GetElapsedMilliseconds(actionStart); + if (actionMs > maxActionMs) + { + actionLabel ??= DescribeMainThreadAction(action); + maxActionMs = actionMs; + maxActionLabel = actionLabel; + } + + if (actionMs >= RuntimeHitchWatch.MainThreadQueueActionSlowThresholdMs) + { + slowActions++; + actionLabel ??= DescribeMainThreadAction(action); + RuntimeHitchWatch.LogSlow( + _log, + $"GameMenu.MainThreadQueueAction:{actionLabel}", + actionMs, + string.Create( + CultureInfo.InvariantCulture, + $"action={actionLabel} processed={processed} startDepth={startDepth}")); + } + } + } + + if (processed >= MainThreadQueueMaxActionsPerPump) + break; + if (RuntimeHitchWatch.GetElapsedMilliseconds(actionsStart) >= MainThreadQueueBudgetMs) + break; + } + + var actionsMs = RuntimeHitchWatch.GetElapsedMilliseconds(actionsStart); + var remainingDepth = Volatile.Read(ref _mainThreadQueueDepth); + var observedDepth = System.Math.Max(startDepth, remainingDepth); + if (perfEnabled && observedDepth >= RuntimeHitchWatch.MainThreadQueueDepthThreshold) + { + RuntimeHitchWatch.LogCount( + _log, + "GameMenu.MainThreadQueueDepth", + observedDepth, + RuntimeHitchWatch.MainThreadQueueDepthThreshold, + string.Create(CultureInfo.InvariantCulture, $"processed={processed} remaining={remainingDepth}")); + } + + if (actionsMs >= RuntimeHitchWatch.MainThreadQueueActionsSlowThresholdMs) + { + RuntimeHitchWatch.LogSlow( + _log, + "GameMenu.ExecuteMainThreadActions", + actionsMs, + string.Create( + CultureInfo.InvariantCulture, + $"processed={processed} slowActions={slowActions} maxAction={maxActionLabel} maxMs={maxActionMs:0.00} remaining={remainingDepth}")); + } + + var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); + if (hitchMs >= RuntimeHitchWatch.MainThreadQueueSlowThresholdMs) + { + RuntimeHitchWatch.LogSlow( + _log, + "GameMenu.ProcessMainThreadQueue", + hitchMs, + string.Create(CultureInfo.InvariantCulture, $"processed={processed} startDepth={startDepth} remaining={remainingDepth}")); + } + } + + private static string DescribeMainThreadAction(Action? action) + { + if (action == null) + return "null"; + + var method = action.Method; + return _mainThreadActionLabelCache.GetOrAdd(method, static m => + { + var declaringType = m.DeclaringType?.FullName; + if (!string.IsNullOrWhiteSpace(declaringType)) + return $"{declaringType}.{m.Name}"; + + return m.Name; + }); + } +} diff --git a/UI/GameMenu.MultiplayerLaunch.cs b/UI/GameMenu.MultiplayerLaunch.cs index bb6f1c2..692d751 100644 --- a/UI/GameMenu.MultiplayerLaunch.cs +++ b/UI/GameMenu.MultiplayerLaunch.cs @@ -504,7 +504,7 @@ public static void ReceiveCustomGameData(string? payload) lock (Sync) { _remoteCustomGameDataReady = true; - _pendingAutoStart = true; + SignalClientLaunchProgressLocked(); } _log?.Information("[NetMod] Client applied host customGameData ({Length} chars)", pending.Length); diff --git a/UI/GameMenu.Ready.cs b/UI/GameMenu.Ready.cs index c9af501..f9c5138 100644 --- a/UI/GameMenu.Ready.cs +++ b/UI/GameMenu.Ready.cs @@ -24,7 +24,6 @@ private static void ResetLobbyReadyStateLocked() private static void ResetLobbyLaunchStateLocked() { _inActualRun = false; - _levelDescArrived = false; _pendingAutoStart = false; _autoStartTriggered = false; _pendingClientRestartSeed = null; @@ -38,6 +37,7 @@ private static void ResetLobbyLaunchStateLocked() _receivedNewCoopWorldPrepared = false; _remoteCustomGameDataReady = false; _pendingRemoteCustomGameDataJson = null; + ResetClientLaunchSessionLocked(); } private static void PrepareLobbyForNewNetworkSession(bool clearRemoteCoopState = false) @@ -187,26 +187,6 @@ private static void RefreshPlayersDisplayFromNetwork() }); } - internal static bool IsLocalReadyForUi() - { - return _localReady; - } - - internal static string BuildConnectionPlayerDisplayLine(string? name, bool isHost, bool isLocal, bool ready) - { - var safeName = string.IsNullOrWhiteSpace(name) ? "Guest" : name.Trim(); - var tags = string.Empty; - if (isHost) - tags += "(Host)"; - if (isLocal) - tags += "(you)"; - - var readyLabel = ready ? "Ready" : "Not ready"; - return string.Create( - CultureInfo.InvariantCulture, - $"{safeName}{tags} - {readyLabel}"); - } - private static string GetReadyButtonLabel() { return _localReady ? "Ready: On" : "Ready: Off"; diff --git a/UI/GameMenu.RunLaunch.cs b/UI/GameMenu.RunLaunch.cs index 56c91b7..625b13d 100644 --- a/UI/GameMenu.RunLaunch.cs +++ b/UI/GameMenu.RunLaunch.cs @@ -9,16 +9,8 @@ namespace DeadCellsMultiplayerMod; internal static partial class GameMenu { - private const int InitialLaunchAckWaitMs = 2600; - private const int InitialLaunchAckRetryWaitMs = 1800; - // Protocol 17: after granting execute, the host waits (on a worker) for the client's RUNQUEUED - // confirmation before it starts its own native loader, so both consume the same seed/config. - private const int InitialLaunchQueuedWaitMs = 4000; - private const int InitialLaunchQueuedRetryWaitMs = 3000; - private static bool _structuredLaunchCommitArrived; private static int _structuredLaunchExecuteSequence; - private static int _initialHostLaunchPendingSequence; private static void InitializeRunLaunchHandshake(ILogger logger) { @@ -31,7 +23,6 @@ private static void ClearStructuredLaunchFlagsLocked() { _structuredLaunchCommitArrived = false; _structuredLaunchExecuteSequence = 0; - _initialHostLaunchPendingSequence = 0; } private static RunLaunchDescriptor BuildHostRunLaunchDescriptor( @@ -79,208 +70,6 @@ private static int ReadBossCellsForLaunch() } } - private static bool TryBeginInitialHostLaunch( - TitleScreen screen, - RunLaunchDescriptor descriptor, - out string error) - { - var net = NetRef; - if (net == null || !net.IsAlive || !net.IsHost) - { - error = "host network is not available"; - return false; - } - - lock (Sync) - { - if (_initialHostLaunchPendingSequence > 0) - { - error = $"launch sequence {_initialHostLaunchPendingSequence} is already waiting for the client"; - return false; - } - - _initialHostLaunchPendingSequence = descriptor.Sequence; - } - - net.SendRunLaunchCommit(descriptor, flush: true); - - // Steam P2P packet polling happens from the game/update side. Waiting synchronously in the - // Start button callback prevents the host from polling the client's RUNACK, so the old - // barrier could always time out and make the button appear to do nothing. Wait on a worker - // and return control to Dead Cells immediately; all game/UI work is queued back to the main - // thread after the acknowledgement arrives. - if (!net.HasRemote) - { - QueueInitialHostLaunchExecution(screen, descriptor); - error = string.Empty; - return true; - } - - _ = Task.Run(() => - { - var acknowledged = RunLaunchCoordinator.WaitForHostAck( - descriptor, - InitialLaunchAckWaitMs, - out var ackError); - - if (!acknowledged) - { - // One reliable resend covers a packet queued at the exact handshake/menu boundary. - net.SendRunLaunchCommit(descriptor, flush: true); - acknowledged = RunLaunchCoordinator.WaitForHostAck( - descriptor, - InitialLaunchAckRetryWaitMs, - out ackError); - } - - if (!acknowledged) - { - QueueInitialHostLaunchFailure(screen, descriptor.Sequence, ackError); - return; - } - - // Grant execute now so the client can queue the identical native launch. This worker never - // touches Dead Cells objects; it only sends control packets and waits on Monitor, so the - // game main thread keeps polling and processing the client's RUNQUEUED. - RunLaunchExecute execute; - try - { - execute = RunLaunchCoordinator.MarkHostExecute(descriptor); - } - catch (Exception ex) - { - QueueInitialHostLaunchFailure(screen, descriptor.Sequence, "host execute failed: " + ex.Message); - return; - } - - net.SendRunLaunchExecute(execute, flush: true); - - // Host starts only after the client confirms it queued the same launch (required order). - var queued = RunLaunchCoordinator.WaitForClientQueued( - descriptor, - InitialLaunchQueuedWaitMs, - out var queuedError); - if (!queued) - { - net.SendRunLaunchExecute(execute, flush: true); - queued = RunLaunchCoordinator.WaitForClientQueued( - descriptor, - InitialLaunchQueuedRetryWaitMs, - out queuedError); - } - - if (queued) - QueueInitialHostLaunchExecution(screen, descriptor); - else - QueueInitialHostLaunchFailure(screen, descriptor.Sequence, queuedError); - }); - - error = string.Empty; - return true; - } - - private static void QueueInitialHostLaunchExecution( - TitleScreen screen, - RunLaunchDescriptor descriptor) - { - EnqueueCriticalMainThreadCoalesced( - $"game:initial-run-launch:{descriptor.Sequence}", - () => - { - lock (Sync) - { - if (_initialHostLaunchPendingSequence != descriptor.Sequence) - return; - } - - var net = NetRef; - var current = RunLaunchCoordinator.GetCurrentHostDescriptor(); - if (net == null || !net.IsAlive || !net.IsHost || - current == null || !current.HasSameIdentity(descriptor)) - { - QueueInitialHostLaunchFailure( - screen, - descriptor.Sequence, - "host launch state changed before execution"); - return; - } - - try - { - var execute = RunLaunchCoordinator.MarkHostExecute(descriptor); - net.SendRunLaunchExecute(execute, flush: true); - - lock (Sync) - { - if (_initialHostLaunchPendingSequence == descriptor.Sequence) - _initialHostLaunchPendingSequence = 0; - } - - bool custom; - bool streamEnabled; - lock (Sync) - { - custom = _pendingLaunchCustom; - streamEnabled = _pendingLaunchStreamEnabled; - } - - SetAuthoritativePendingNewGameLaunch(custom, streamEnabled); - if (custom && !EnsureCustomModeScreenUser(screen)) - { - CancelPrecommittedHostRunSeed("custom_mode_user_unready"); - CancelHostStructuredLaunch(descriptor.Sequence, "custom_mode_user_unready"); - MultiplayerUI.PushSystemMessage(Localize("Custom Mode could not prepare the save user.")); - ShowHostStatusMenu(screen); - return; - } - - screen.startNewGame(custom); - } - catch (Exception ex) - { - lock (Sync) - { - if (_initialHostLaunchPendingSequence == descriptor.Sequence) - _initialHostLaunchPendingSequence = 0; - } - - CancelPrecommittedHostRunSeed("native_startNewGame_failed"); - CancelHostStructuredLaunch(descriptor.Sequence, "native_startNewGame_failed_after_consume"); - _log?.Warning("[NetMod] Failed to start host run: {Message}", ex.Message); - MultiplayerUI.PushSystemMessage(Localize("Dead Cells could not start the co-op run.")); - } - }); - } - - private static void QueueInitialHostLaunchFailure( - TitleScreen screen, - int sequence, - string reason) - { - EnqueueCriticalMainThreadCoalesced( - $"game:initial-run-launch-failed:{sequence}", - () => - { - lock (Sync) - { - if (_initialHostLaunchPendingSequence != sequence) - return; - _initialHostLaunchPendingSequence = 0; - } - - CancelPrecommittedHostRunSeed("initial_ack_barrier_failed"); - _log?.Warning( - "[NetMod][RunLaunch] Initial launch aborted seq={Sequence}: {Error}", - sequence, - reason); - MultiplayerUI.PushSystemMessage( - Localize("Friend did not acknowledge the run start. Both players must use the same build."), - 7.0, - 1.0); - ShowHostStatusMenu(screen); - }); - } - internal static RunLaunchDescriptor CommitHostRunLaunchFromHook( int seed, int sequence, @@ -380,6 +169,7 @@ internal static void ReceiveRunLaunchCommitPayload(string payload) _pendingAutoStart = false; _autoStartTriggered = false; } + SignalClientLaunchProgressLocked(); Monitor.PulseAll(Sync); } } @@ -422,7 +212,7 @@ internal static void ReceiveRunLaunchExecutePayload(string payload) if (_inActualRun) scheduleInRunReconcile = execute.Sequence > _consumedRemoteSeedSequence; else - _pendingAutoStart = true; + SignalClientLaunchProgressLocked(); } Monitor.PulseAll(Sync); } diff --git a/UI/GameMenu.RunLaunchCompat.cs b/UI/GameMenu.RunLaunchCompat.cs index ef08483..c9867fa 100644 --- a/UI/GameMenu.RunLaunchCompat.cs +++ b/UI/GameMenu.RunLaunchCompat.cs @@ -1,7 +1,4 @@ -using System.Collections.Concurrent; -using System.Globalization; -using System.Threading; -using System.Threading.Channels; +using System.Globalization; using DeadCellsMultiplayerMod.MultiplayerModUI.Connection; using DeadCellsMultiplayerMod.MultiplayerModUI.lifeUI; using DeadCellsMultiplayerMod.PortableCore; @@ -10,8 +7,7 @@ namespace DeadCellsMultiplayerMod; /// -/// Restores features-continue RunLaunch / main-thread network APIs on top of the -/// checked-out dev GameMenu lobby/UI base. +/// Sequenced seed / precommit / protocol-mismatch helpers for run launch. /// internal static partial class GameMenu { @@ -23,23 +19,6 @@ internal static partial class GameMenu private const int RemoteRunSeedWaitMs = 2000; private const int RunSeedTransitionGraceMs = 2000; - private static readonly Channel _networkMainThreadQueue = Channel.CreateBounded( - new BoundedChannelOptions(2048) - { - SingleReader = true, - SingleWriter = false, - FullMode = BoundedChannelFullMode.Wait, - AllowSynchronousContinuations = false - }); - - private static readonly object CriticalMainThreadCoalesceSync = new(); - private static readonly Dictionary _criticalCoalescedActions = new(StringComparer.Ordinal); - private static readonly ConcurrentQueue _criticalCoalescedKeys = new(); - private const int MainThreadQueueMaxPendingCritical = 64; - private const int MainThreadQueueBurstActionsPerPump = 768; - private const int MainThreadQueueBurstBacklogThreshold = 96; - private static long _lastMainThreadCoalescedDropLogTicks; - private static long _clientRestartPendingUntilTicks; private const int ClientRestartPendingTtlMs = 12000; @@ -50,7 +29,6 @@ internal static partial class GameMenu private const int PrecommittedHostSeedTtlMs = 300000; private static DateTime _lastRoomStatusAutoRefresh = DateTime.MinValue; - private static bool _protocolMismatchPending; private static void ResetRunLaunchCompatStateLocked() { @@ -61,117 +39,6 @@ private static void ResetRunLaunchCompatStateLocked() ClearStructuredLaunchFlagsLocked(); ClearPrecommittedHostRunSeedLocked(); _clientRestartPendingUntilTicks = 0; - _protocolMismatchPending = false; - while (_networkMainThreadQueue.Reader.TryRead(out _)) { } - while (_criticalCoalescedKeys.TryDequeue(out _)) { } - lock (CriticalMainThreadCoalesceSync) - _criticalCoalescedActions.Clear(); - } - - internal static ValueTask EnqueueNetworkMainThreadAsync(Action action, CancellationToken cancellationToken) - { - if (action == null) - return ValueTask.CompletedTask; - - return _networkMainThreadQueue.Writer.WriteAsync(action, cancellationToken); - } - - internal static void ClearPendingNetworkMainThreadActions() - { - while (_networkMainThreadQueue.Reader.TryRead(out _)) { } - } - - internal static void EnqueueCriticalMainThreadCoalesced(string coalesceKey, Action action) - { - if (action == null || string.IsNullOrWhiteSpace(coalesceKey)) - return; - - bool isNewKey; - lock (CriticalMainThreadCoalesceSync) - { - isNewKey = !_criticalCoalescedActions.ContainsKey(coalesceKey); - if (isNewKey && _criticalCoalescedActions.Count >= MainThreadQueueMaxPendingCritical) - { - LogCriticalMainThreadCoalescedDropRateLimited(coalesceKey); - return; - } - - _criticalCoalescedActions[coalesceKey] = action; - } - - if (isNewKey) - _criticalCoalescedKeys.Enqueue(coalesceKey); - } - - private static void LogCriticalMainThreadCoalescedDropRateLimited(string key) - { - var now = System.Diagnostics.Stopwatch.GetTimestamp(); - var minTicks = System.Diagnostics.Stopwatch.Frequency * 5L; - var previous = Interlocked.Read(ref _lastMainThreadCoalescedDropLogTicks); - if (previous != 0 && now - previous < minTicks) - return; - if (Interlocked.CompareExchange(ref _lastMainThreadCoalescedDropLogTicks, now, previous) != previous) - return; - - _log?.Warning( - "[NetMod] Rejected critical coalesced main-thread work because its queue is full (key={Key})", - key); - } - - /// - /// Drain critical coalesced work first, then reliable network protocol actions. - /// Called from so receive-loop back-pressure stays healthy. - /// - private static int DrainCriticalAndNetworkMainThreadQueues(int budget) - { - if (budget <= 0) - return 0; - - var processed = 0; - var networkBacklog = 0; - if (_networkMainThreadQueue.Reader.CanCount) - networkBacklog = _networkMainThreadQueue.Reader.Count; - - var effectiveBudget = networkBacklog >= MainThreadQueueBurstBacklogThreshold - ? Math.Max(budget, MainThreadQueueBurstActionsPerPump) - : budget; - - while (processed < effectiveBudget) - { - Action? action = null; - - if (_criticalCoalescedKeys.TryDequeue(out var criticalKey)) - { - lock (CriticalMainThreadCoalesceSync) - { - _criticalCoalescedActions.TryGetValue(criticalKey, out action); - _criticalCoalescedActions.Remove(criticalKey); - } - } - else if (_networkMainThreadQueue.Reader.TryRead(out var networkAction)) - { - action = networkAction; - } - else - { - break; - } - - if (action == null) - continue; - - processed++; - try - { - action(); - } - catch (Exception ex) - { - _log?.Warning("[NetMod] Main thread task failed: {Message}", ex.Message); - } - } - - return processed; } internal static void MarkClientRestartPending() @@ -209,41 +76,6 @@ public static int RegisterHostRunSeed(int seed, string launchKind, string reason return sequence; } - internal static bool PrecommitInitialHostRunSeed(out int seed, out int sequence, out RunLaunchDescriptor? descriptor) - { - seed = 0; - sequence = 0; - descriptor = null; - - var net = NetRef; - if (net == null || !net.IsAlive || !net.IsHost) - return false; - - const string launchKind = "dc.LaunchMode+NewGame"; - - seed = ForceGenerateServerSeed("title.startNewGame_precommit"); - sequence = RegisterHostRunSeed(seed, launchKind, "title.startNewGame_precommit"); - - lock (Sync) - { - _precommittedHostSeed = seed; - _precommittedHostSeedSequence = sequence; - _precommittedHostLaunchKind = launchKind; - _precommittedHostSeedExpiresAtTicks = Environment.TickCount64 + PrecommittedHostSeedTtlMs; - } - - descriptor = BuildHostRunLaunchDescriptor(seed, sequence, launchKind); - net.SendRunLaunchCommit(descriptor, flush: true); - net.SendSeed(sequence, seed, launchKind); - net.SendControlAndFlush($"SEED|{sequence}|{seed}|{launchKind}", 500); - _log?.Information( - "[NetMod] Precommitted initial host run seq={Sequence} seed={Seed} launch={LaunchKind}", - sequence, - seed, - launchKind); - return true; - } - internal static bool PrecommitHostBossRushRunSeed( string bossRushType, int doorCx, @@ -444,8 +276,7 @@ public static bool TryGetKnownSeed(out int seed) } /// - /// Protocol 17 seed receive path used by the text NetNode. Keeps the legacy 1-arg - /// for older same-run restart flows. + /// Protocol seed receive path used by the text NetNode (SEED|seq|seed|kind). /// public static void ReceiveHostRunSeed(int sequence, int seed, string launchKind) { @@ -478,8 +309,7 @@ public static void ReceiveHostRunSeed(int sequence, int seed, string launchKind) else { _seedArrived = true; - if (!isBossRushSeed && CanAutoStartStructuredClientLaunchLocked()) - _pendingAutoStart = true; + SignalClientLaunchProgressLocked(); } } @@ -596,13 +426,6 @@ internal static void NotifyProtocolMismatch( if (localRole != NetRole.Client) return; - lock (Sync) - { - _protocolMismatchPending = true; - _clientConnecting = false; - _waitingForHost = false; - } - EnqueueMainThreadCoalesced("ui:protocol-mismatch", () => { var screen = GetTitleScreen(); diff --git a/UI/GameMenu.cs b/UI/GameMenu.cs index 3ea4090..7d26690 100644 --- a/UI/GameMenu.cs +++ b/UI/GameMenu.cs @@ -1,5 +1,4 @@ -using System.Runtime.InteropServices; -using System.Collections.Concurrent; +using System.Runtime.InteropServices; using System.Globalization; using System.Reflection; using dc.pr; @@ -27,32 +26,6 @@ internal static partial class GameMenu private static string _pendingClientRestartReason = string.Empty; private const int MaxSeed = 999_999; public static NetNode? NetRef { get; set; } - private readonly struct MainThreadWorkItem - { - public readonly Action? Action; - public readonly string? CoalesceKey; - - public MainThreadWorkItem(Action action) - { - Action = action; - CoalesceKey = null; - } - - public MainThreadWorkItem(string coalesceKey) - { - Action = null; - CoalesceKey = coalesceKey; - } - } - - private static readonly ConcurrentQueue _mainThreadQueue = new(); - private static readonly ConcurrentDictionary _mainThreadActionLabelCache = new(); - private static readonly object MainThreadCoalesceSync = new(); - private static readonly Dictionary _coalescedMainThreadActions = new(StringComparer.Ordinal); - private static readonly HashSet _pendingCoalescedMainThreadKeys = new(StringComparer.Ordinal); - private static int _mainThreadQueueDepth; - private const int MainThreadQueueMaxActionsPerPump = 64; - private const double MainThreadQueueBudgetMs = 4.0; private static bool _menuHooksAttached; private static bool _addMenuHookRegistered; @@ -66,18 +39,13 @@ private enum ConnectionTransport Steam } private static ConnectionTransport _menuTransport = ConnectionTransport.Lan; - private static bool _steamLobbyActive; private static ulong _steamLobbyId; private static string _steamLobbyCode = string.Empty; private static ulong _steamHostSteamId; private static bool _steamJoinLobbyResolvePending; private static ulong? _pendingOverlayJoinLobbyId; - private static bool _waitingForHost; internal const int ClientConnectMaxAttempts = 3; - private static int _clientConnectAttempt; - private static bool _clientConnecting; private static bool _pendingAutoStart; - private static bool _levelDescArrived; private static bool _autoStartTriggered; private static bool _continueLaunchInProgress; private static DateTime _continueLaunchStartedAt = DateTime.MinValue; @@ -183,15 +151,12 @@ public static void Initialize(ILogger logger) _remoteSeed = null; _pendingClientRestartSeed = null; _pendingClientRestartReason = string.Empty; - _levelDescArrived = false; _pendingAutoStart = false; _autoStartTriggered = false; _continueLaunchInProgress = false; _continueLaunchStartedAt = DateTime.MinValue; _genArrived = false; _seedArrived = false; - _clientConnectAttempt = 0; - _clientConnecting = false; _deathRestartCooldownUntil = DateTime.MinValue; _cachedLevelDescSync = null; _hostDisconnectCountdownActive = false; @@ -202,7 +167,6 @@ public static void Initialize(ILogger logger) _hostDisconnectSaveRetryAt = DateTime.MinValue; _hostDisconnectSaveDeadline = DateTime.MinValue; _menuTransport = ConnectionTransport.Lan; - _steamLobbyActive = false; _steamLobbyId = 0; _steamLobbyCode = string.Empty; _steamHostSteamId = 0UL; @@ -217,176 +181,13 @@ public static void Initialize(ILogger logger) ResetLobbyReadyStateLocked(); InvalidateGeneratePayloadCacheLocked(); ResetRunLaunchCompatStateLocked(); - _mainThreadQueueDepth = _mainThreadQueue.Count; - lock (MainThreadCoalesceSync) - { - _coalescedMainThreadActions.Clear(); - _pendingCoalescedMainThreadKeys.Clear(); - } + ResetMainThreadQueuesLocked(); + ResetClientLaunchSessionLocked(); } InitializeMenuUiHooks(); } - internal static void EnqueueMainThread(Action action) - { - if (action == null) return; - _mainThreadQueue.Enqueue(new MainThreadWorkItem(action)); - Interlocked.Increment(ref _mainThreadQueueDepth); - } - - internal static void EnqueueMainThreadCoalesced(string coalesceKey, Action action) - { - if (action == null) - return; - - if (string.IsNullOrWhiteSpace(coalesceKey)) - { - EnqueueMainThread(action); - return; - } - - var shouldEnqueue = false; - lock (MainThreadCoalesceSync) - { - _coalescedMainThreadActions[coalesceKey] = action; - if (_pendingCoalescedMainThreadKeys.Add(coalesceKey)) - shouldEnqueue = true; - } - - if (!shouldEnqueue) - return; - - _mainThreadQueue.Enqueue(new MainThreadWorkItem(coalesceKey)); - Interlocked.Increment(ref _mainThreadQueueDepth); - } - - internal static void ProcessMainThreadQueue() - { - var hitchStart = RuntimeHitchWatch.Start(); - var perfEnabled = RuntimeHitchWatch.Enabled; - var startDepth = Volatile.Read(ref _mainThreadQueueDepth); - var processed = 0; - var slowActions = 0; - var maxActionMs = 0.0; - var maxActionLabel = string.Empty; - var actionsStart = RuntimeHitchWatch.Start(); - - // Critical + reliable network protocol work must drain even when the UI queue is busy. - processed += DrainCriticalAndNetworkMainThreadQueues(MainThreadQueueMaxActionsPerPump); - - while (_mainThreadQueue.TryDequeue(out var workItem)) - { - Interlocked.Decrement(ref _mainThreadQueueDepth); - Action? action = workItem.Action; - var actionLabel = workItem.CoalesceKey; - if (actionLabel != null) - { - lock (MainThreadCoalesceSync) - { - _pendingCoalescedMainThreadKeys.Remove(actionLabel); - _coalescedMainThreadActions.TryGetValue(actionLabel, out action); - _coalescedMainThreadActions.Remove(actionLabel); - } - } - - if (action == null) - continue; - - processed++; - var actionStart = perfEnabled ? RuntimeHitchWatch.Start() : 0; - try - { - action(); - } - catch (Exception ex) - { - _log?.Warning("[NetMod] Main thread task failed: {Message}", ex.Message); - } - finally - { - if (perfEnabled) - { - var actionMs = RuntimeHitchWatch.GetElapsedMilliseconds(actionStart); - if (actionMs > maxActionMs) - { - actionLabel ??= DescribeMainThreadAction(action); - maxActionMs = actionMs; - maxActionLabel = actionLabel; - } - - if (actionMs >= RuntimeHitchWatch.MainThreadQueueActionSlowThresholdMs) - { - slowActions++; - actionLabel ??= DescribeMainThreadAction(action); - RuntimeHitchWatch.LogSlow( - _log, - $"GameMenu.MainThreadQueueAction:{actionLabel}", - actionMs, - string.Create( - CultureInfo.InvariantCulture, - $"action={actionLabel} processed={processed} startDepth={startDepth}")); - } - } - } - - if (processed >= MainThreadQueueMaxActionsPerPump) - break; - if (RuntimeHitchWatch.GetElapsedMilliseconds(actionsStart) >= MainThreadQueueBudgetMs) - break; - } - var actionsMs = RuntimeHitchWatch.GetElapsedMilliseconds(actionsStart); - - var remainingDepth = Volatile.Read(ref _mainThreadQueueDepth); - var observedDepth = System.Math.Max(startDepth, remainingDepth); - if (perfEnabled && observedDepth >= RuntimeHitchWatch.MainThreadQueueDepthThreshold) - { - RuntimeHitchWatch.LogCount( - _log, - "GameMenu.MainThreadQueueDepth", - observedDepth, - RuntimeHitchWatch.MainThreadQueueDepthThreshold, - string.Create(CultureInfo.InvariantCulture, $"processed={processed} remaining={remainingDepth}")); - } - - if (actionsMs >= RuntimeHitchWatch.MainThreadQueueActionsSlowThresholdMs) - { - RuntimeHitchWatch.LogSlow( - _log, - "GameMenu.ExecuteMainThreadActions", - actionsMs, - string.Create( - CultureInfo.InvariantCulture, - $"processed={processed} slowActions={slowActions} maxAction={maxActionLabel} maxMs={maxActionMs:0.00} remaining={remainingDepth}")); - } - - var hitchMs = RuntimeHitchWatch.GetElapsedMilliseconds(hitchStart); - if (hitchMs >= RuntimeHitchWatch.MainThreadQueueSlowThresholdMs) - { - RuntimeHitchWatch.LogSlow( - _log, - "GameMenu.ProcessMainThreadQueue", - hitchMs, - string.Create(CultureInfo.InvariantCulture, $"processed={processed} startDepth={startDepth} remaining={remainingDepth}")); - } - } - - private static string DescribeMainThreadAction(Action? action) - { - if (action == null) - return "null"; - - var method = action.Method; - return _mainThreadActionLabelCache.GetOrAdd(method, static m => - { - var declaringType = m.DeclaringType?.FullName; - if (!string.IsNullOrWhiteSpace(declaringType)) - return $"{declaringType}.{m.Name}"; - - return m.Name; - }); - } - public static void MarkInRun() { lock (Sync) @@ -394,6 +195,7 @@ public static void MarkInRun() _inActualRun = true; _continueLaunchInProgress = false; _continueLaunchStartedAt = DateTime.MinValue; + MarkClientLaunchInRunLocked(); } ClearClientRestartPending(); } @@ -462,45 +264,6 @@ public static bool TryGetHostRunSeed(out int seed) return false; } - public static void ReceiveHostRunSeed(int seed) - { - int? previousSeed = null; - lock (Sync) - { - previousSeed = _remoteSeed; - _remoteSeed = seed; - if (_role == NetRole.Client) - { - var firstSeedForClient = !previousSeed.HasValue; - var seedChanged = previousSeed.HasValue && previousSeed.Value != seed; - if (_pendingClientRestartSeed.HasValue) - { - _pendingClientRestartSeed = seed; - _pendingClientRestartReason = "host_restart"; - _pendingAutoStart = false; - _autoStartTriggered = false; - } - else if (_inActualRun) - { - if (firstSeedForClient || seedChanged) - { - _inActualRun = false; - _pendingAutoStart = false; - _autoStartTriggered = false; - _pendingClientRestartSeed = seed; - _pendingClientRestartReason = "host_restart"; - } - } - else - { - _seedArrived = true; - _pendingAutoStart = true; - } - } - } - _log?.Information("[NetMod] Client received host seed {Seed}", seed); - } - public static void ReceiveHostRunRestart(int seed) { lock (Sync) @@ -525,7 +288,7 @@ public static void ReceiveHostRunRestart(int seed) } else { - _pendingAutoStart = true; + SignalClientLaunchProgressLocked(); } } } @@ -645,8 +408,8 @@ private static void QueueClientRestartFromHostSeed(int seed, string reason) lock (Sync) { _seedArrived = true; - _pendingAutoStart = true; _autoStartTriggered = false; + SignalClientLaunchProgressLocked(); } return; } @@ -803,13 +566,13 @@ public static void TickMenu(double dt) { if (_role == NetRole.Client && !_inActualRun && - !_pendingClientRestartSeed.HasValue && - _pendingAutoStart && - IsPendingLaunchReadyForAutoStartLocked() && - !_autoStartTriggered) + !_pendingClientRestartSeed.HasValue) { - _autoStartTriggered = true; - shouldStart = true; + // Re-arm when late prereqs (LGRAPH / BOSSRUNE) arrive after seed/exec. + // Arming is sole-writer via Reevaluate; claim still requires full readiness. + ReevaluateClientLaunchArmLocked(); + if (TryClaimClientAutoStartLocked()) + shouldStart = true; } } @@ -839,8 +602,7 @@ public static void TickMenu(double dt) { lock (Sync) { - _autoStartTriggered = false; - _pendingAutoStart = true; + ReleaseClientAutoStartClaimLocked(); } _autoStartRetryAt = DateTime.UtcNow.AddMilliseconds(250); return; @@ -861,8 +623,7 @@ public static void TickMenu(double dt) _log?.Warning("[NetMod] Auto-start blocked by config lock: {Message}", ioEx.Message); lock (Sync) { - _autoStartTriggered = false; - _pendingAutoStart = true; + ReleaseClientAutoStartClaimLocked(); } _autoStartRetryAt = DateTime.UtcNow.AddSeconds(1.5); } @@ -871,8 +632,7 @@ public static void TickMenu(double dt) _log?.Warning("[NetMod] Failed to auto-start new game: {Message}", ex.Message); lock (Sync) { - _autoStartTriggered = false; - _pendingAutoStart = true; + ReleaseClientAutoStartClaimLocked(); } } } @@ -880,8 +640,7 @@ public static void TickMenu(double dt) { lock (Sync) { - _autoStartTriggered = false; - _pendingAutoStart = true; + ReleaseClientAutoStartClaimLocked(); } } } @@ -891,10 +650,7 @@ private static void NotifyLevelDescReceived() lock (Sync) { if (_role == NetRole.Client && !_inActualRun) - { - _levelDescArrived = true; - _pendingAutoStart = true; - } + SignalClientLaunchProgressLocked(); } } @@ -1037,8 +793,6 @@ private static void ShowConnectionMenu(TitleScreen screen, NetRole role) { _menuSelection = role; _menuTransport = ConnectionTransport.Lan; - if (role == NetRole.Client) - _waitingForHost = true; var prevSuppress = _suppressAutoButton; _suppressAutoButton = true; @@ -1138,7 +892,6 @@ private static void StartSteamHost(TitleScreen screen) { _menuSelection = NetRole.Host; _menuTransport = ConnectionTransport.Steam; - _steamLobbyActive = false; _steamLobbyId = 0; _steamLobbyCode = string.Empty; _steamHostSteamId = 0UL; @@ -1173,7 +926,6 @@ private static void StartSteamHost(TitleScreen screen) if (!string.IsNullOrWhiteSpace(lobby.PersonaName)) ApplySteamPersonaUsername(lobby.PersonaName); - _steamLobbyActive = true; _steamLobbyId = lobby.LobbyId; _steamLobbyCode = SteamConnect.BuildLobbyCodeFromLobbyId(_steamLobbyId); ConnectionUI.NotifyConnectionsChanged(); @@ -1193,7 +945,6 @@ private static void StartSteamJoin(TitleScreen screen) { _menuSelection = NetRole.Client; _menuTransport = ConnectionTransport.Steam; - _steamLobbyActive = false; _steamLobbyId = 0; _steamLobbyCode = string.Empty; _steamHostSteamId = 0UL; @@ -1222,7 +973,6 @@ internal static void HandleSteamOverlayJoinRequest(ulong lobbyId) _menuSelection = NetRole.Client; _menuTransport = ConnectionTransport.Steam; - _steamLobbyActive = false; _steamLobbyId = 0; _steamLobbyCode = string.Empty; _steamHostSteamId = 0UL; @@ -1387,7 +1137,6 @@ private static void StartNetwork(NetRole role, TitleScreen screen) ModEntry.Instance.StartSteamHostFromMenu(_mpPort); else ModEntry.Instance.StartHostFromMenu(_mpIp, _mpPort); - _waitingForHost = false; SetAuthoritativePendingNewGameLaunch(custom: false, streamEnabled); RememberPendingLaunch(PendingLaunchAction.NewGame, custom: false, streamEnabled, sendToRemote: true); TryLaunchNewGame(screen, custom: false, streamEnabled); @@ -1409,13 +1158,6 @@ private static void StartNetwork(NetRole role, TitleScreen screen) } } - lock (Sync) - { - _clientConnectAttempt = 0; - _clientConnecting = true; - _waitingForHost = true; - } - if (_menuTransport == ConnectionTransport.Steam) ModEntry.Instance.StartSteamClientFromMenu(_steamHostSteamId); else @@ -1441,7 +1183,6 @@ private static void StartHostServerOnly(bool bindAnyAddress = false) if (NetRef != null && NetRef.IsAlive && NetRef.IsHost) { PrepareLobbyForNewNetworkSession(); - _waitingForHost = false; return; } @@ -1456,7 +1197,6 @@ private static void StartHostServerOnly(bool bindAnyAddress = false) ModEntry.Instance.StartHostFromMenu(hostIp, _mpPort); } - _waitingForHost = false; } catch (Exception ex) { @@ -1464,15 +1204,6 @@ private static void StartHostServerOnly(bool bindAnyAddress = false) } } - private static void StartHostRun(TitleScreen screen) - { - var streamEnabled = TryGetStreamEnabled(screen); - StartHostServerOnly(); - SetAuthoritativePendingNewGameLaunch(custom: false, streamEnabled); - RememberPendingLaunch(PendingLaunchAction.NewGame, custom: false, streamEnabled, sendToRemote: true); - TryLaunchNewGame(screen, custom: false, streamEnabled); - } - // private static void GameDisposeHook(Hook_Game.orig_onDispose orig, Game self) // { // try @@ -1510,7 +1241,6 @@ private static void HandleWorldExit(bool isDisposeHook = false) SetRole(NetRole.None); NetRef = null; - _waitingForHost = false; ResetClientConnectState(); ResetLobbyReadyState(); _menuSelection = NetRole.None; diff --git a/UI/GameMenuHooks.cs b/UI/GameMenuHooks.cs index de31a83..1cd79fb 100644 --- a/UI/GameMenuHooks.cs +++ b/UI/GameMenuHooks.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using dc.pr; using Hashlink.Virtuals; using HaxeProxy.Runtime; @@ -37,7 +37,6 @@ private static void MainMenuHook(Hook_TitleScreen.orig_mainMenu orig, TitleScree Hook_TitleScreen.addMenu += AddMenuHook; _addMenuHookRegistered = true; } - MainThreadDispatcher.SetMainMenuReady(); TryDisconnectWhenReturningToMainMenu(); StoreTitleScreen(self); _mainMenuButtonAdded = false; @@ -58,9 +57,6 @@ private static void ResetOriginalMainMenuUiState() _inHostStatusMenu = false; _inClientWaitingMenu = false; _menuSelection = NetRole.None; - _waitingForHost = false; - _clientConnecting = false; - _clientConnectAttempt = 0; ConnectionUI.set_visible = false; } @@ -153,8 +149,8 @@ private static bool IsQuitMenuLabel(string label) var text = label.Trim(); if (text.IndexOf("quit", StringComparison.OrdinalIgnoreCase) >= 0) return true; if (text.IndexOf("exit", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (text.IndexOf("выйт", StringComparison.OrdinalIgnoreCase) >= 0) return true; - if (text.IndexOf("выход", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (text.IndexOf("выйт", StringComparison.OrdinalIgnoreCase) >= 0) return true; + if (text.IndexOf("выход", StringComparison.OrdinalIgnoreCase) >= 0) return true; try { var localizedQuit = GetText.Instance.GetString("Quitter le jeu"); diff --git a/UI/MainThreadDispatcher.cs b/UI/MainThreadDispatcher.cs deleted file mode 100644 index 30e484a..0000000 --- a/UI/MainThreadDispatcher.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Serilog; - -namespace DeadCellsMultiplayerMod.UI -{ - /// - /// Thin adapter used by the dev GameMenuHooks. Forwards to GameMenu's queue. - /// - internal static class MainThreadDispatcher - { - public static void Enqueue(Action? action) - { - if (action == null) - return; - - GameMenu.EnqueueMainThread(action); - } - - public static void Process(ILogger? log) - { - _ = log; - GameMenu.ProcessMainThreadQueue(); - } - - public static void SetMainMenuReady() - { - } - } -} diff --git a/server/server.NetNode.Build.cs b/server/server.NetNode.Build.cs index 2ee085d..36dbe62 100644 --- a/server/server.NetNode.Build.cs +++ b/server/server.NetNode.Build.cs @@ -72,27 +72,6 @@ private static string BuildHpLine(int id, int life, int maxLife, int lif, int bo return $"HP|{id}|{life}|{maxLife}|{lif}|{bonusLife}|{recover}\n"; } - private static string BuildChatLine(int id, string message) - { - var safe = SanitizeChatMessage(message); - return $"CHAT|{id}|{safe}\n"; - } - - private static string SanitizeChatMessage(string? message) - { - var safe = (message ?? string.Empty) - .Replace("\r", " ", StringComparison.Ordinal) - .Replace("\n", " ", StringComparison.Ordinal) - .Replace("|", "/", StringComparison.Ordinal) - .Trim(); - - const int maxLength = 256; - if (safe.Length > maxLength) - safe = safe[..maxLength]; - - return safe; - } - private static string SanitizeProtocolToken(string? value, int maxLength) { var safe = (value ?? string.Empty) @@ -164,13 +143,6 @@ private static string BuildPlayerReviveLine(PlayerReviveRequest request) $"PREVIVE|{request.ReviverId}|{request.TargetId}\n"); } - private static string BuildBossVictoryLine(BossVictoryState state) - { - return string.Create( - CultureInfo.InvariantCulture, - $"BOSSVICTORY|{state.Generation}|{state.EncounterId}\n"); - } - private static string BuildPosLine(int id, double cx, double cy, int dir) { return string.Create( diff --git a/server/server.NetNode.Cleanup.cs b/server/server.NetNode.Cleanup.cs index a601ad2..94c0321 100644 --- a/server/server.NetNode.Cleanup.cs +++ b/server/server.NetNode.Cleanup.cs @@ -12,19 +12,15 @@ private void CleanupClient() _remotes.Clear(); _primaryRemoteId = 0; _pendingAttacks.Clear(); - _pendingChatMessages.Clear(); _pendingMobStates.Clear(); _pendingMobMoves.Clear(); - _pendingMobCharges.Clear(); _pendingMobHits.Clear(); _pendingMobDies.Clear(); - _pendingBossVictories.Clear(); _pendingMobAttacks.Clear(); _pendingMobDraws.Clear(); + _pendingMobRegistry.Clear(); _pendingExitReadyStates.Clear(); _pendingBossCineLevelIds.Clear(); - _pendingBossIntroEnds.Clear(); - _pendingBossIntroReadyStates.Clear(); _pendingBossHeroTeleports.Clear(); _pendingPlayerDownStates.Clear(); _pendingPlayerReviveRequests.Clear(); diff --git a/server/server.NetNode.Consume.cs b/server/server.NetNode.Consume.cs index d3d1435..713ea28 100644 --- a/server/server.NetNode.Consume.cs +++ b/server/server.NetNode.Consume.cs @@ -161,26 +161,17 @@ public bool TryConsumeRemoteAttacks(out List attacks) } } - public bool TryConsumeChatMessages(out List messages) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingChatMessages, out messages); - } - } - public void ClearMobSyncQueues() { lock (_sync) { _pendingMobStates.Clear(); _pendingMobMoves.Clear(); - _pendingMobCharges.Clear(); _pendingMobHits.Clear(); _pendingMobDies.Clear(); - _pendingBossVictories.Clear(); _pendingMobAttacks.Clear(); _pendingMobDraws.Clear(); + _pendingMobRegistry.Clear(); } } @@ -219,14 +210,6 @@ public bool TryConsumeMobMoves(out List moves) } } - public bool TryConsumeMobCharges(out List charges) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingMobCharges, out charges); - } - } - public bool TryConsumeMobHits(out List hits) { lock (_sync) @@ -243,27 +226,27 @@ public bool TryConsumeMobDies(out List dies) } } - public bool TryConsumeBossVictories(out List victories) + public bool TryConsumeMobAttacks(out List attacks) { lock (_sync) { - return TryConsumePendingListLocked(ref _pendingBossVictories, out victories); + return TryConsumePendingListLocked(ref _pendingMobAttacks, out attacks); } } - public bool TryConsumeMobAttacks(out List attacks) + public bool TryConsumeMobDraws(out List draws) { lock (_sync) { - return TryConsumePendingListLocked(ref _pendingMobAttacks, out attacks); + return TryConsumePendingListLocked(ref _pendingMobDraws, out draws); } } - public bool TryConsumeMobDraws(out List draws) + public bool TryConsumeMobRegistry(out List entries) { lock (_sync) { - return TryConsumePendingListLocked(ref _pendingMobDraws, out draws); + return TryConsumePendingListLocked(ref _pendingMobRegistry, out entries); } } @@ -296,22 +279,6 @@ public bool TryConsumeBossCineLevelIds(out List levelIds) } } - public bool TryConsumeBossIntroEnds(out List completions) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingBossIntroEnds, out completions); - } - } - - public bool TryConsumeBossIntroReadyStates(out List readyStates) - { - lock (_sync) - { - return TryConsumePendingListLocked(ref _pendingBossIntroReadyStates, out readyStates); - } - } - public bool TryConsumeBossHeroTeleportEvents(out List events) { lock (_sync) diff --git a/server/server.NetNode.Dispose.cs b/server/server.NetNode.Dispose.cs index 3f619ac..5061f18 100644 --- a/server/server.NetNode.Dispose.cs +++ b/server/server.NetNode.Dispose.cs @@ -95,19 +95,15 @@ public void Dispose() _hasRemote = false; _connectedClientCount = 0; _pendingAttacks.Clear(); - _pendingChatMessages.Clear(); _pendingMobStates.Clear(); _pendingMobMoves.Clear(); - _pendingMobCharges.Clear(); _pendingMobHits.Clear(); _pendingMobDies.Clear(); - _pendingBossVictories.Clear(); _pendingMobAttacks.Clear(); _pendingMobDraws.Clear(); + _pendingMobRegistry.Clear(); _pendingExitReadyStates.Clear(); _pendingBossCineLevelIds.Clear(); - _pendingBossIntroEnds.Clear(); - _pendingBossIntroReadyStates.Clear(); _pendingBossHeroTeleports.Clear(); _pendingPlayerDownStates.Clear(); _pendingPlayerReviveRequests.Clear(); diff --git a/server/server.NetNode.Parse.cs b/server/server.NetNode.Parse.cs index 0146357..00d693d 100644 --- a/server/server.NetNode.Parse.cs +++ b/server/server.NetNode.Parse.cs @@ -196,25 +196,6 @@ private static void ParseHpPayload(string payload, out int? parsedId, out int li recover = parsedRecover; } - private static void ParseChatPayload(string payload, out int? parsedId, out string message) - { - parsedId = null; - message = string.Empty; - if (string.IsNullOrWhiteSpace(payload)) - return; - - var parts = payload.Split(new[] { '|' }, 2); - if (parts.Length == 2 && - int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var idValue)) - { - parsedId = idValue; - message = parts[1]; - return; - } - - message = payload; - } - private static void ParseCoopStatePayload(string payload, out string coopId, out bool hasContinueSave) { coopId = string.Empty; @@ -229,6 +210,55 @@ private static void ParseCoopStatePayload(string payload, out string coopId, out string.Equals(parts[1], "true", StringComparison.OrdinalIgnoreCase); } + /// + /// MOBREG payload: generation|netId,escapedType,x,y;... + /// + private static bool TryParseMobRegistryPayload(string payload, out List entries) + { + entries = new List(); + if (string.IsNullOrWhiteSpace(payload)) + return false; + + var genSep = payload.IndexOf('|'); + if (genSep <= 0) + return false; + + if (!int.TryParse(payload.AsSpan(0, genSep), NumberStyles.Integer, CultureInfo.InvariantCulture, out var generation)) + return false; + + var table = payload[(genSep + 1)..]; + if (string.IsNullOrWhiteSpace(table)) + return true; + + var chunks = table.Split(';', StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < chunks.Length; i++) + { + var parts = chunks[i].Split(','); + if (parts.Length < 4) + continue; + if (!int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var netId)) + continue; + if (!double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) + continue; + if (!double.TryParse(parts[3], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) + continue; + + string type; + try + { + type = Uri.UnescapeDataString(parts[1] ?? string.Empty); + } + catch + { + type = parts[1] ?? string.Empty; + } + + entries.Add(new MobRegistryEntry(netId, generation, type, x, y)); + } + + return true; + } + private static List ParseMobStatesPayload(string payload) { var states = new List(); @@ -305,40 +335,6 @@ private static List ParseMobMovesPayload(string payload) return moves; } - private static List ParseMobChargesPayload(string payload) - { - var charges = new List(); - if (string.IsNullOrWhiteSpace(payload)) - return charges; - - var entries = payload.Split(';', StringSplitOptions.RemoveEmptyEntries); - foreach (var entry in entries) - { - var parts = entry.Split(','); - if (parts.Length < 3) - continue; - - if (!int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) - continue; - var generation = 0; - var valueOffset = 1; - if (parts.Length > 3 && - int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedGeneration)) - { - generation = parsedGeneration; - valueOffset = 2; - } - - var skillId = parts.Length > valueOffset ? parts[valueOffset] : string.Empty; - if (!double.TryParse(parts.Length > valueOffset + 1 ? parts[valueOffset + 1] : "0", NumberStyles.Float, CultureInfo.InvariantCulture, out var ratio)) - ratio = 0; - - charges.Add(new MobChargeSnapshot(index, skillId, ratio, generation)); - } - - return charges; - } - private static bool TryParseMobHitPayload(string payload, int? senderId, bool forceSenderId, out MobHit hit) { hit = default; @@ -424,30 +420,6 @@ private static bool TryParseMobDiePayload(string payload, int? senderId, bool fo return true; } - private static bool TryParseBossVictoryPayload(string payload, out BossVictoryState state) - { - state = default; - if (string.IsNullOrWhiteSpace(payload)) - return false; - - var parts = payload.Split('|'); - if (parts.Length != 2) - return false; - if (!int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var generation) || - generation <= 0) - { - return false; - } - if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var encounterId) || - encounterId <= 0) - { - return false; - } - - state = new BossVictoryState(generation, encounterId); - return true; - } - private static bool TryParseMobAttackPayload(string payload, out MobAttack attack) { attack = default; @@ -994,7 +966,8 @@ private static bool TryParseInterTreasureChestPayload(string payload, out InterT if (!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) return false; - ev = new InterTreasureChestEvent(x, y); + var levelId = parts.Length >= 3 ? (parts[2] ?? string.Empty) : string.Empty; + ev = new InterTreasureChestEvent(x, y, levelId); return true; } @@ -1013,7 +986,8 @@ private static bool TryParseInterVineLadderPayload(string payload, out InterVine if (!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) return false; - ev = new InterVineLadderEvent(x, y); + var levelId = parts.Length >= 3 ? (parts[2] ?? string.Empty) : string.Empty; + ev = new InterVineLadderEvent(x, y, levelId); return true; } @@ -1032,7 +1006,8 @@ private static bool TryParseInterTeleportPayload(string payload, out InterTelepo if (!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) return false; - ev = new InterTeleportEvent(x, y); + var levelId = parts.Length >= 3 ? (parts[2] ?? string.Empty) : string.Empty; + ev = new InterTeleportEvent(x, y, levelId); return true; } @@ -1080,7 +1055,8 @@ private static bool TryParseInterBreakableGroundPayload(string payload, out Inte if (!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) return false; - ev = new InterBreakableGroundEvent(x, y); + var levelId = parts.Length >= 3 ? (parts[2] ?? string.Empty) : string.Empty; + ev = new InterBreakableGroundEvent(x, y, levelId); return true; } @@ -1103,7 +1079,30 @@ private static bool TryParseInterPortalPayload(string payload, out InterPortalEv if (!double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) return false; - ev = new InterPortalEvent(x, y, action); + var levelId = parts.Length >= 4 ? (parts[3] ?? string.Empty) : string.Empty; + ev = new InterPortalEvent(x, y, action, levelId); + return true; + } + + private static bool TryParseInterBossRuneUpdateCellsPayload(string payload, out InterBossRuneUpdateCellsEvent ev) + { + ev = default; + if (string.IsNullOrWhiteSpace(payload)) + return false; + + var parts = payload.Split('|'); + if (parts.Length < 3) + return false; + + if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) + return false; + if (!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) + return false; + if (!int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var addInt)) + return false; + + var levelId = parts.Length >= 4 ? (parts[3] ?? string.Empty) : string.Empty; + ev = new InterBossRuneUpdateCellsEvent(x, y, addInt != 0, levelId); return true; } diff --git a/server/server.NetNode.Protocol.Incoming.cs b/server/server.NetNode.Protocol.Incoming.cs index 4e9bd9f..a65bbdc 100644 --- a/server/server.NetNode.Protocol.Incoming.cs +++ b/server/server.NetNode.Protocol.Incoming.cs @@ -12,14 +12,12 @@ public sealed partial class NetNode // the correct failure mode for state/move snapshots. private const int PendingMobStateLimit = 8192; private const int PendingMobMoveLimit = 8192; - private const int PendingMobChargeLimit = 2048; private const int PendingMobHitLimit = 2048; private const int PendingMobDieLimit = 2048; private const int PendingMobAttackLimit = 2048; private const int PendingMobDrawLimit = 4096; private const int PendingNetworkLineLimit = 1024; private const int PendingAttackLimit = 1024; - private const int PendingChatLimit = 256; private const int PendingControlStateLimit = 256; private const int PendingInteractionLimit = 512; private const int PendingBossCineLimit = 64; @@ -301,17 +299,19 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) if (line.StartsWith("BOSSRUNE_UPDATE_CELLS|", StringComparison.OrdinalIgnoreCase)) { var payload = line["BOSSRUNE_UPDATE_CELLS|".Length..].Trim(); - var parts = payload.Split('|'); - if (parts.Length >= 3 && - double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x) && - double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y) && - int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var addInt)) + if (TryParseInterBossRuneUpdateCellsPayload(payload, out var ev)) { lock (_sync) { - AddBoundedLocked(_pendingBossRuneUpdateCells, new InterBossRuneUpdateCellsEvent(x, y, addInt != 0), PendingInteractionLimit); + AddBoundedLocked(_pendingBossRuneUpdateCells, ev, PendingInteractionLimit); _hasRemote = true; } + + if (_role == NetRole.Host && senderId.HasValue) + { + forwardLine = + $"BOSSRUNE_UPDATE_CELLS|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}|{(ev.Add ? 1 : 0)}|{ev.LevelId}\n"; + } } return true; } @@ -439,37 +439,6 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } - if (line.StartsWith("CHAT|", StringComparison.OrdinalIgnoreCase)) - { - var payload = line["CHAT|".Length..]; - ParseChatPayload(payload, out var parsedId, out var message); - var effectiveId = parsedId ?? senderId; - if (forceSenderId) - effectiveId = senderId; - - message = SanitizeChatMessage(message); - if (effectiveId.HasValue && !string.IsNullOrWhiteSpace(message)) - { - string? username; - lock (_sync) - { - var state = GetOrCreateRemoteLocked(effectiveId.Value); - state.HasRemote = true; - _hasRemote = true; - if (_primaryRemoteId == 0) - _primaryRemoteId = effectiveId.Value; - username = state.Username; - AddBoundedLocked(_pendingChatMessages, new RemoteChatMessage(effectiveId.Value, username, message), PendingChatLimit); - } - - if (_role == NetRole.Host && senderId.HasValue) - forwardLine = BuildChatLine(effectiveId.Value, message); - } - - return true; - } - - if (line.StartsWith("LOBBYSTATE|", StringComparison.OrdinalIgnoreCase)) { var payload = line["LOBBYSTATE|".Length..]; @@ -870,35 +839,34 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } - if (line.StartsWith("MOBMOVE|", StringComparison.OrdinalIgnoreCase)) + if (line.StartsWith("MOBREG|", StringComparison.OrdinalIgnoreCase)) { - if (_role != NetRole.Host) + if (_role == NetRole.Host) + return true; + + var payload = line["MOBREG|".Length..]; + if (TryParseMobRegistryPayload(payload, out var parsedRegistry) && parsedRegistry.Count > 0) { - var payload = line["MOBMOVE|".Length..]; - var parsedMoves = ParseMobMovesPayload(payload); lock (_sync) { - if (parsedMoves.Count > 0) - { - AppendBoundedLocked(_pendingMobMoves, parsedMoves, PendingMobMoveLimit); - _hasRemote = true; - } + AppendBoundedLocked(_pendingMobRegistry, parsedRegistry, PendingMobStateLimit); + _hasRemote = true; } } return true; } - if (line.StartsWith("MOBCHARGE|", StringComparison.OrdinalIgnoreCase)) + if (line.StartsWith("MOBMOVE|", StringComparison.OrdinalIgnoreCase)) { if (_role != NetRole.Host) { - var payload = line["MOBCHARGE|".Length..]; - var parsedCharges = ParseMobChargesPayload(payload); + var payload = line["MOBMOVE|".Length..]; + var parsedMoves = ParseMobMovesPayload(payload); lock (_sync) { - if (parsedCharges.Count > 0) + if (parsedMoves.Count > 0) { - AppendBoundedLocked(_pendingMobCharges, parsedCharges, PendingMobChargeLimit); + AppendBoundedLocked(_pendingMobMoves, parsedMoves, PendingMobMoveLimit); _hasRemote = true; } } @@ -943,25 +911,6 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } - if (line.StartsWith("BOSSVICTORY|", StringComparison.OrdinalIgnoreCase)) - { - // Victory and reward revival are host-authoritative. Silently consume a spoofed - // client packet on the host, but never enqueue or forward it. - if (_role == NetRole.Host) - return true; - - var payload = line["BOSSVICTORY|".Length..]; - if (TryParseBossVictoryPayload(payload, out var state)) - { - lock (_sync) - { - AddBoundedLocked(_pendingBossVictories, state, PendingControlStateLimit); - _hasRemote = true; - } - } - return true; - } - if (line.StartsWith("MOBDRAW|", StringComparison.OrdinalIgnoreCase)) { if (_role != NetRole.Host) @@ -1055,54 +1004,6 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) return true; } - if (line.StartsWith("BOSSINTROEND|", StringComparison.OrdinalIgnoreCase)) - { - // Intro completion is host-authoritative for the same reason as intro start. A client - // cannot release or advance another peer's arena presentation. - if (_role == NetRole.Host) - return true; - - var payload = line["BOSSINTROEND|".Length..] - .Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal) - .Trim(); - if (!string.IsNullOrWhiteSpace(payload)) - { - lock (_sync) - { - AddBoundedLocked(_pendingBossIntroEnds, payload, PendingBossCineLimit); - _hasRemote = true; - } - } - return true; - } - - if (line.StartsWith("BOSSINTROREADY|", StringComparison.OrdinalIgnoreCase)) - { - // Readiness travels client-to-host only, and the sender id always comes from the - // authenticated TCP/Steam connection. Never relay it back to clients. - if (_role != NetRole.Host || !senderId.HasValue || - !IsHostClientHandshakeComplete(senderId.Value)) - return true; - - var payload = line["BOSSINTROREADY|".Length..] - .Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal) - .Trim(); - if (!string.IsNullOrWhiteSpace(payload)) - { - lock (_sync) - { - AddBoundedLocked( - _pendingBossIntroReadyStates, - new BossIntroReadyState(senderId.Value, payload), - PendingBossCineLimit); - _hasRemote = true; - } - } - return true; - } - if (line.StartsWith("INTERDOOR|", StringComparison.OrdinalIgnoreCase)) { var payload = line["INTERDOOR|".Length..]; @@ -1189,7 +1090,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) } if (_role == NetRole.Host && senderId.HasValue) - forwardLine = $"INTERCHEST|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}\n"; + forwardLine = $"INTERCHEST|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}|{ev.LevelId}\n"; } return true; } @@ -1206,7 +1107,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) } if (_role == NetRole.Host && senderId.HasValue) - forwardLine = $"INTERVINELADDER|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}\n"; + forwardLine = $"INTERVINELADDER|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}|{ev.LevelId}\n"; } return true; } @@ -1223,7 +1124,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) } if (_role == NetRole.Host && senderId.HasValue) - forwardLine = $"INTERTELEPORT|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}\n"; + forwardLine = $"INTERTELEPORT|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}|{ev.LevelId}\n"; } return true; } @@ -1258,7 +1159,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) } if (_role == NetRole.Host && senderId.HasValue) - forwardLine = $"INTERBREAK|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}\n"; + forwardLine = $"INTERBREAK|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}|{ev.LevelId}\n"; } return true; } @@ -1275,7 +1176,7 @@ private bool HandleLine(string line, int? senderId, out string? forwardLine) } if (_role == NetRole.Host && senderId.HasValue) - forwardLine = $"INTERPORTAL|{ev.Action}|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}\n"; + forwardLine = $"INTERPORTAL|{ev.Action}|{ev.X.ToString(CultureInfo.InvariantCulture)}|{ev.Y.ToString(CultureInfo.InvariantCulture)}|{ev.LevelId}\n"; } return true; } diff --git a/server/server.NetNode.SendPublic.cs b/server/server.NetNode.SendPublic.cs index 10bccec..c215384 100644 --- a/server/server.NetNode.SendPublic.cs +++ b/server/server.NetNode.SendPublic.cs @@ -218,21 +218,6 @@ public void SendSerializerSync(int seq, int uid) _ = SendLineSafe(line); } - public void SendCounters(string countersPayload) - { - return; - } - - public void SendProgress(string progressPayload) - { - return; - } - - public void SendBlueprints(string blueprintsPayload) - { - return; - } - public void SendUsername(string username) { if (!HasAnyConnection()) @@ -476,19 +461,6 @@ public void SendHP(double life, double maxLife, double lif, double bonusLife, do SendRaw($"HP|{idPart}{life}|{maxLife}|{lif}|{bonusLife}|{recover}"); } - public void SendChatMessage(string message) - { - if (!HasAnyConnection()) - return; - - var safe = SanitizeChatMessage(message); - if (string.IsNullOrWhiteSpace(safe)) - return; - - var idPart = ID > 0 ? $"{ID}|" : string.Empty; - SendRaw($"CHAT|{idPart}{safe}"); - } - public void SendLevelId(int senderId, string levelId) { if (!HasAnyConnection()) @@ -517,12 +489,6 @@ public void SendRoomTarget(string levelId, int roomId) SendRaw($"ZROOM|{ID}|{safe}|{roomId}"); } - public void SendKick() - { - if (!HasAnyConnection()) return; - SendRaw("KICK"); - } - public void SendControlAndFlush(string payload, int timeoutMs = 250) { if (!HasAnyConnection()) @@ -705,7 +671,11 @@ public void SendMobStates(IReadOnlyList states) if (states == null || states.Count == 0) return; - if (MobWireBinary.UseBinaryWire && MobWireBinary.TryBuildMobStatesBinary(states, out var bin) && bin != null) + // Prefer binary MOBSTATE2; text MOBSTATE only when disabled via DCCM_MOB_WIRE_TEXT=1 + // or when binary encoding fails. + if (MobWireBinary.UseBinaryWire && + MobWireBinary.TryBuildMobStatesBinary(states, out var bin) && + bin != null) { var line = "MOBSTATE2|" + Convert.ToBase64String(bin) + "\n"; _ = SendLineSafe(line); @@ -729,19 +699,6 @@ public void SendMobMoves(IReadOnlyList moves) _ = SendLineSafe(line); } - public void SendMobCharges(IReadOnlyList charges) - { - if (_role != NetRole.Host) - return; - if (!HasAnyConnection()) - return; - if (charges == null || charges.Count == 0) - return; - - var line = MobWireCodec.BuildMobChargesLine(charges); - _ = SendLineSafe(line); - } - public void SendMobAttack(int mobIndex, string skillId, bool requiresTargetInArea, int? data, double x, double y, int targetUserId, int dir = 0, int generation = 0) { if (_role != NetRole.Host) @@ -799,19 +756,18 @@ public void SendMobDie(int mobIndex, double x, double y, int generation = 0, str } /// - /// Broadcast a host-confirmed encounter victory. Clients never have authority to originate - /// this packet; receiving it is what permits boss-reward revival. + /// Host spawn table: NetId + type + spawn position so clients bind without using native/list ids. /// - public void SendBossVictory(int generation, int encounterId) + public void SendMobRegistry(int generation, IReadOnlyList entries) { if (_role != NetRole.Host) return; if (!HasAnyConnection()) return; - if (generation <= 0 || encounterId <= 0) + if (entries == null || entries.Count == 0) return; - var line = BuildBossVictoryLine(new BossVictoryState(generation, encounterId)); + var line = MobWireCodec.BuildMobRegistryLine(generation, entries); _ = SendLineSafe(line); } @@ -871,38 +827,6 @@ public void SendBossCine(string levelId) SendRaw($"BOSSCINE|{safe}"); } - public void SendBossIntroEnd(string payload) - { - if (_role != NetRole.Host || !HasAnyConnection()) - return; - if (string.IsNullOrWhiteSpace(payload)) - return; - - var safe = payload.Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal) - .Trim(); - if (safe.Length == 0) - return; - - SendRaw($"BOSSINTROEND|{safe}"); - } - - public void SendBossIntroReady(string payload) - { - if (_role != NetRole.Client || !HasAnyConnection() || ID <= 0) - return; - if (string.IsNullOrWhiteSpace(payload)) - return; - - var safe = payload.Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace("\n", string.Empty, StringComparison.Ordinal) - .Trim(); - if (safe.Length == 0) - return; - - SendRaw($"BOSSINTROREADY|{safe}"); - } - public void SendBossHeroTeleport(double x, double y, int dir) { if (!HasAnyConnection()) @@ -981,57 +905,62 @@ public void SendInterPressurePlate(int userId, double x, double y, long sequence $"INTERPLATE|{userId.ToString(CultureInfo.InvariantCulture)}|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{sequence.ToString(CultureInfo.InvariantCulture)}|{safeLevel}"); } - public void SendInterTreasureChest(double x, double y) + public void SendInterTreasureChest(double x, double y, string levelId = "") { if (!HasAnyConnection()) return; if (ID <= 0) return; - SendRaw($"INTERCHEST|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}"); + var safeLevel = (levelId ?? string.Empty).Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); + SendRaw($"INTERCHEST|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{safeLevel}"); } - public void SendInterVineLadder(double x, double y) + public void SendInterVineLadder(double x, double y, string levelId = "") { if (!HasAnyConnection()) return; if (ID <= 0) return; - SendRaw($"INTERVINELADDER|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}"); + var safeLevel = (levelId ?? string.Empty).Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); + SendRaw($"INTERVINELADDER|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{safeLevel}"); } - public void SendInterTeleport(double x, double y) + public void SendInterTeleport(double x, double y, string levelId = "") { if (!HasAnyConnection()) return; if (ID <= 0) return; - SendRaw($"INTERTELEPORT|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}"); + var safeLevel = (levelId ?? string.Empty).Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); + SendRaw($"INTERTELEPORT|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{safeLevel}"); } - public void SendInterBreakableGround(double x, double y) + public void SendInterBreakableGround(double x, double y, string levelId = "") { if (!HasAnyConnection()) return; if (ID <= 0) return; - SendRaw($"INTERBREAK|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}"); + var safeLevel = (levelId ?? string.Empty).Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); + SendRaw($"INTERBREAK|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{safeLevel}"); } - public void SendInterBossRuneUpdateCells(double x, double y, bool add) + public void SendInterBossRuneUpdateCells(double x, double y, bool add, string levelId = "") { if (!HasAnyConnection()) return; if (ID <= 0) return; - SendRaw($"BOSSRUNE_UPDATE_CELLS|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{(add ? 1 : 0)}"); + var safeLevel = (levelId ?? string.Empty).Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); + SendRaw($"BOSSRUNE_UPDATE_CELLS|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{(add ? 1 : 0)}|{safeLevel}"); } - public void SendInterPortal(double x, double y, string action) + public void SendInterPortal(double x, double y, string action, string levelId = "") { if (!HasAnyConnection()) return; @@ -1040,7 +969,8 @@ public void SendInterPortal(double x, double y, string action) if (string.IsNullOrWhiteSpace(action)) return; - SendRaw($"INTERPORTAL|{action}|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}"); + var safeLevel = (levelId ?? string.Empty).Replace("|", "/").Replace("\r", string.Empty).Replace("\n", string.Empty); + SendRaw($"INTERPORTAL|{action}|{x.ToString(CultureInfo.InvariantCulture)}|{y.ToString(CultureInfo.InvariantCulture)}|{safeLevel}"); } diff --git a/server/server.NetNode.SendRouting.cs b/server/server.NetNode.SendRouting.cs index 335880a..c07705a 100644 --- a/server/server.NetNode.SendRouting.cs +++ b/server/server.NetNode.SendRouting.cs @@ -32,7 +32,6 @@ private static bool IsRealtimeSteamLine(string line) trimmed.StartsWith("MOBSTATE|", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("MOBSTATE2|", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("MOBMOVE|", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("MOBCHARGE|", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("MOBDRAW|", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("INTERELEVSTATE|", StringComparison.OrdinalIgnoreCase); } @@ -55,7 +54,6 @@ private static bool IsDroppableTcpRealtimeLine(string line) return trimmed.StartsWith("ANIM|", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("HEADANIM|", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("MOBMOVE|", StringComparison.OrdinalIgnoreCase) || - trimmed.StartsWith("MOBCHARGE|", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("MOBDRAW|", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("INTERELEVSTATE|", StringComparison.OrdinalIgnoreCase); } @@ -85,7 +83,9 @@ private static EP2PSend ResolveSteamSendType(string line) // subset of enemies even though ordinary movement packets continue to arrive. if (line.StartsWith("MOBEVENT|", StringComparison.OrdinalIgnoreCase) || line.StartsWith("MOBSTATE|", StringComparison.OrdinalIgnoreCase) || - line.StartsWith("MOBSTATE2|", StringComparison.OrdinalIgnoreCase)) + line.StartsWith("MOBSTATE2|", StringComparison.OrdinalIgnoreCase) || + line.StartsWith("MOBREG|", StringComparison.OrdinalIgnoreCase) || + line.StartsWith("MOBDIE|", StringComparison.OrdinalIgnoreCase)) { return EP2PSend.k_EP2PSendReliable; } @@ -119,7 +119,7 @@ private bool HasAnyConnection() return _stream != null && _client != null && _client.Connected; } - /// Sends a pre-encoded mob protocol line (used by MobSyncWorker). Line must start with MOB. + /// Sends a pre-encoded mob protocol line. Line must start with MOB. public Task SendMobWireLine(string line) { if (string.IsNullOrEmpty(line)) diff --git a/server/server.NetNode.Steam.cs b/server/server.NetNode.Steam.cs index 260df73..5cb246f 100644 --- a/server/server.NetNode.Steam.cs +++ b/server/server.NetNode.Steam.cs @@ -684,7 +684,6 @@ private void CleanupHostSteamClient(SteamClientConnection sender) { RemoveRemoteLocked(sender.AssignedId); _pendingAttacks.RemoveAll(a => a.Id == sender.AssignedId); - _pendingChatMessages.RemoveAll(m => m.Id == sender.AssignedId); _pendingMobHits.RemoveAll(h => h.UserId == sender.AssignedId); _pendingMobDies.RemoveAll(d => d.UserId == sender.AssignedId); _pendingExitReadyStates.RemoveAll(s => s.UserId == sender.AssignedId); diff --git a/server/server.Tcp.cs b/server/server.Tcp.cs index 7b6fe3d..b800e10 100644 --- a/server/server.Tcp.cs +++ b/server/server.Tcp.cs @@ -386,7 +386,6 @@ private void CleanupHostClient(ClientConnection sender) { RemoveRemoteLocked(sender.AssignedId); _pendingAttacks.RemoveAll(a => a.Id == sender.AssignedId); - _pendingChatMessages.RemoveAll(m => m.Id == sender.AssignedId); _pendingMobHits.RemoveAll(h => h.UserId == sender.AssignedId); _pendingMobDies.RemoveAll(d => d.UserId == sender.AssignedId); _pendingExitReadyStates.RemoveAll(s => s.UserId == sender.AssignedId); diff --git a/server/server.cs b/server/server.cs index f854dda..c2e655d 100644 --- a/server/server.cs +++ b/server/server.cs @@ -251,20 +251,6 @@ public RemoteUserSnapshot(int id, string? username) } } - public readonly struct RemoteChatMessage - { - public readonly int Id; - public readonly string? Username; - public readonly string Message; - - public RemoteChatMessage(int id, string? username, string message) - { - Id = id; - Username = username; - Message = message ?? string.Empty; - } - } - public readonly struct MobStateSnapshot { public readonly int Index; @@ -325,22 +311,6 @@ public MobMoveSnapshot(int index, double x, double y, int dir, string animPayloa } } - public readonly struct MobChargeSnapshot - { - public readonly int Index; - public readonly int Generation; - public readonly string SkillId; - public readonly double Ratio; - - public MobChargeSnapshot(int index, string skillId, double ratio, int generation = 0) - { - Index = index; - Generation = generation; - SkillId = skillId ?? string.Empty; - Ratio = ratio; - } - } - public readonly struct MobHit { public readonly int UserId; @@ -389,36 +359,23 @@ public MobDie(int userId, int mobIndex, double x, double y, int generation = 0, } /// - /// Host-authoritative confirmation that the current boss encounter is over. This is - /// deliberately separate from MOBDIE: multipart and phase-replacing bosses can destroy one - /// mob wrapper without completing the encounter. + /// Host-authored spawn table entry. NetId is host-owned identity; Type+X+Y are bind hints for clients. /// - public readonly struct BossVictoryState + public readonly struct MobRegistryEntry { + public readonly int NetId; public readonly int Generation; - public readonly int EncounterId; + public readonly string Type; + public readonly double X; + public readonly double Y; - public BossVictoryState(int generation, int encounterId) + public MobRegistryEntry(int netId, int generation, string type, double x, double y) { + NetId = netId; Generation = generation; - EncounterId = encounterId; - } - } - - /// - /// Client acknowledgement that its native boss introduction reached the real combat handoff. - /// The host derives from the authenticated connection rather than trusting - /// any id supplied by the peer. - /// - public readonly struct BossIntroReadyState - { - public readonly int UserId; - public readonly string Payload; - - public BossIntroReadyState(int userId, string payload) - { - UserId = userId; - Payload = payload ?? string.Empty; + Type = type ?? string.Empty; + X = x; + Y = y; } } @@ -639,21 +596,17 @@ private bool IsSupersededNetworkSession() private readonly Dictionary _steamClientIdsBySteam = new(); private readonly Dictionary _remotes = new(); private List _pendingAttacks = new(); - private List _pendingChatMessages = new(); private List _pendingMobStates = new(); private List _pendingMobMoves = new(); - private List _pendingMobCharges = new(); private List _pendingMobHits = new(); private List _pendingMobDies = new(); - private List _pendingBossVictories = new(); private List _pendingMobAttacks = new(); private List _pendingMobDraws = new(); + private List _pendingMobRegistry = new(); private List _pendingExitReadyStates = new(); private List _pendingPlayerDownStates = new(); private List _pendingPlayerReviveRequests = new(); private List _pendingBossCineLevelIds = new(); - private List _pendingBossIntroEnds = new(); - private List _pendingBossIntroReadyStates = new(); private List _pendingBossHeroTeleports = new(); private List _pendingInterDoorEvents = new(); private List _pendingInterElevatorEvents = new();