C#:
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core.Libraries.Covalence;
using Oxide.Core.Plugins;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("TerrustriaInstanceRaids", "Terrustria", "0.6.0")]
[Description("Private solo/duo lane raids with CopyPaste lane baselines, checkpoint-gated room spawning, boss progression, tier-keycard gating, hard prefab injection, and completion reward crates.")]
public class TerrustriaInstanceRaids : RustPlugin
{
#region Plugin refs
[PluginReference] private Plugin CopyPaste;
[PluginReference] private Plugin BotReSpawn;
[PluginReference] private Plugin BossMonster; // optional for future use
private ISpawnAdapter _spawnAdapter;
#endregion
#region Config
private PluginConfig _config;
private class PluginConfig
{
[JsonProperty("General")] public GeneralConfig General = new GeneralConfig();
[JsonProperty("Dungeons")] public Dictionary<string, DungeonConfig> Dungeons = new Dictionary<string, DungeonConfig>();
public class GeneralConfig
{
[JsonProperty("Max Party Size")] public int MaxPartySize = 2;
[JsonProperty("Lane Bounds Default Radius (meters)")] public float DefaultLaneRadius = 120f;
[JsonProperty("Instance Hard Timeout Minutes")] public float HardTimeoutMinutes = 180f;
[JsonProperty("Allow Leaving Instance Command")] public bool AllowLeaveCommand = true;
[JsonProperty("NPC Tether Tick Seconds")] public float NpcTetherTickSeconds = 1.0f;
[JsonProperty("NPC Default Leash Radius (meters)")] public float DefaultNpcLeashRadius = 25f;
[JsonProperty("NPC Default Hard Reset Radius (meters)")] public float DefaultNpcHardResetRadius = 45f;
[JsonProperty("CopyPaste: Default Paste Batch Collision Radius (blockcollision)")]
public float CopyPasteBlockCollision = 0f;
// Trigger scanning (checkpoint gates + completion)
[JsonProperty("Trigger Tick Seconds")] public float TriggerTickSeconds = 0.5f;
[JsonProperty("Checkpoint Default Trigger Radius (meters)")]
public float DefaultCheckpointRadius = 3.5f;
[JsonProperty("Checkpoint Notification Cooldown Seconds")]
public float CheckpointNotifyCooldownSeconds = 6f;
// Boss identification (profile + proximity)
[JsonProperty("Boss Spawn Detect Radius (meters)")]
public float BossDetectRadius = 6f;
// Fallback verification (anti-softlock)
[JsonProperty("Verification Tick Seconds")]
public float VerificationTickSeconds = 5f;
[JsonProperty("Verification: Re-spawn boss if missing (seconds between attempts)")]
public float BossRespawnAttemptCooldownSeconds = 20f;
[JsonProperty("Verification: Spawn room if party is in gate and it isn't spawned (seconds between attempts)")]
public float GateSpawnAttemptCooldownSeconds = 10f;
// Completion / rewards
[JsonProperty("Completion Loot Window Seconds")]
public int CompletionLootWindowSeconds = 600; // 10 minutes
[JsonProperty("Reward Crate Prefab")]
public string RewardCratePrefab = "assets/prefabs/deployable/large wood storage/box.wooden.large.prefab";
[JsonProperty("Reward Crate Offset From Completion Position")]
public Vector3Data RewardCrateOffset = new Vector3Data { X = 0f, Y = 0f, Z = 1.5f };
[JsonProperty("Reward Crates To Spawn")]
public int RewardCratesToSpawn = 1;
}
public class DungeonConfig
{
[JsonProperty("Tier")] public int Tier = 1;
[JsonProperty("Lanes")] public List<LaneConfig> Lanes = new List<LaneConfig>();
// BotReSpawn profiles
[JsonProperty("BotReSpawn Profile (Packs Default)")] public string BotProfilePacks = "dungeon_packs_t1";
[JsonProperty("BotReSpawn Profile (Boss Default)")] public string BotProfileBoss = "dungeon_boss_t1";
// Difficulty multipliers (owned by this core; actual scaling hooks to bots can come later)
[JsonProperty("Base HP Multiplier")] public float HpMult = 1f;
[JsonProperty("Base Damage Multiplier")] public float DmgMult = 1f;
[JsonProperty("Party Scalar (Solo)")] public float PartyScalarSolo = 1.00f;
[JsonProperty("Party Scalar (Duo)")] public float PartyScalarDuo = 1.45f;
// Tier keycard gating (used on room gate checkpoints)
[JsonProperty("Enable Tier Keycard Gates")] public bool EnableTierKeycardGates = true;
[JsonProperty("Consume Keycard On Gate Use")] public bool ConsumeKeycardOnUse = false;
// Tier-based reward loot (NOT tied to party size)
[JsonProperty("Reward Loot (Tier-based)")]
public List<RewardItem> RewardLoot = new List<RewardItem>();
public class RewardItem
{
[JsonProperty("Shortname")] public string Shortname;
[JsonProperty("Amount")] public int Amount = 1;
[JsonProperty("SkinId")] public ulong SkinId = 0;
}
}
public class LaneConfig
{
[JsonProperty("Lane Id")] public string LaneId = "lane_01";
[JsonProperty("Lane Center")] public Vector3Data LaneCenter = new Vector3Data();
[JsonProperty("Lane Radius (meters)")] public float LaneRadius = 0f;
// CopyPaste baseline restore
[JsonProperty("CopyPaste Baseline File")] public string BaselineFile = "containment_lane_t1";
[JsonProperty("CopyPaste Rotation Correction")] public float RotationCorrection = 0f;
[JsonProperty("CopyPaste Options")] public PasteOptions PasteOptions = new PasteOptions();
// "DungeonBases capability" (without dependency): spawn hard prefabs deterministically after baseline paste
[JsonProperty("Hard Prefabs (spawn after baseline paste)")]
public List<HardPrefabConfig> HardPrefabs = new List<HardPrefabConfig>();
// Staging / Exit
[JsonProperty("Staging Spawn")] public Vector3Data StagingSpawn = new Vector3Data();
[JsonProperty("Exit Spawn (Return)")] public Vector3Data ExitSpawn = new Vector3Data();
// Anti-cheese staging boundary (NPCs never allowed into staging sphere)
[JsonProperty("Staging Center")] public Vector3Data StagingCenter = new Vector3Data();
[JsonProperty("Staging No-NPC Radius (meters)")] public float StagingNoNpcRadius = 35f;
// Completion room trigger (after final boss)
[JsonProperty("Completion Position")] public Vector3Data CompletionPosition = new Vector3Data();
[JsonProperty("Completion Radius (meters)")] public float CompletionRadius = 6f;
// Waypoints / checkpoints
[JsonProperty("Checkpoints")]
public Dictionary<string, CheckpointConfig> Checkpoints = new Dictionary<string, CheckpointConfig>();
// Room progression
[JsonProperty("Rooms")]
public List<RoomConfig> Rooms = new List<RoomConfig>();
}
public class HardPrefabConfig
{
[JsonProperty("Enabled")] public bool Enabled = true;
[JsonProperty("Prefab")] public string Prefab = "";
[JsonProperty("Position")] public Vector3Data Position = new Vector3Data();
[JsonProperty("Yaw Degrees")] public float Yaw = 0f;
[JsonProperty("SkinId")] public ulong SkinId = 0;
}
public class RoomConfig
{
[JsonProperty("Room Id")] public string RoomId = "room_01";
// The waypoint room trigger that spawns THIS room when entered (once unlocked).
[JsonProperty("Gate Checkpoint Id")] public string GateCheckpointId = "cp_gate_01";
// Optional: when boss dies, automatically set this checkpoint as the party respawn anchor
[JsonProperty("Checkpoint To Set On Clear (optional)")] public string SetCheckpointOnClear = "";
// Trash
[JsonProperty("Pack Profile (optional override)")] public string PackProfileOverride = "";
[JsonProperty("Pack Spawns")] public List<Vector3Data> PackSpawns = new List<Vector3Data>();
// Boss
[JsonProperty("Boss Profile (optional override)")] public string BossProfileOverride = "";
[JsonProperty("Boss Spawn")] public Vector3Data BossSpawn = new Vector3Data();
}
public class CheckpointConfig
{
[JsonProperty("Position")] public Vector3Data Position = new Vector3Data();
[JsonProperty("Radius (meters)")] public float Radius = 0f;
// Optional: spawn a keycard security door prefab for aesthetics (and consistent entry points).
// Example prefabs:
// assets/bundled/prefabs/static/door.hinged.security.green.prefab
// assets/bundled/prefabs/static/door.hinged.security.blue.prefab
// assets/bundled/prefabs/static/door.hinged.security.red.prefab
[JsonProperty("Spawn Gate Door")] public bool SpawnGateDoor = false;
[JsonProperty("Gate Door Yaw Degrees")] public float GateDoorYaw = 0f;
[JsonProperty("Gate Door Offset")] public Vector3Data GateDoorOffset = new Vector3Data { X = 0f, Y = 0f, Z = 0f };
}
public class PasteOptions
{
[JsonProperty("AutoHeight")] public bool AutoHeight = true;
[JsonProperty("Height")] public float Height = 0f;
[JsonProperty("Stability")] public bool Stability = false;
[JsonProperty("Deployables")] public bool Deployables = true;
[JsonProperty("Inventories")] public bool Inventories = true;
[JsonProperty("Vending")] public bool Vending = true;
[JsonProperty("EntityOwner")] public bool EntityOwner = true;
}
public class Vector3Data
{
[JsonProperty("x")] public float X;
[JsonProperty("y")] public float Y;
[JsonProperty("z")] public float Z;
public Vector3 ToVector3() => new Vector3(X, Y, Z);
public static Vector3Data From(Vector3 v) => new Vector3Data { X = v.x, Y = v.y, Z = v.z };
}
}
protected override void LoadDefaultConfig()
{
_config = new PluginConfig();
var dungeon = new PluginConfig.DungeonConfig
{
Tier = 1,
BotProfilePacks = "containment_packs_t1",
BotProfileBoss = "containment_boss_t1",
HpMult = 1f,
DmgMult = 1f,
PartyScalarSolo = 1.0f,
PartyScalarDuo = 1.45f,
EnableTierKeycardGates = true,
ConsumeKeycardOnUse = false,
RewardLoot = new List<PluginConfig.DungeonConfig.RewardItem>
{
new PluginConfig.DungeonConfig.RewardItem{ Shortname="scrap", Amount=500 },
new PluginConfig.DungeonConfig.RewardItem{ Shortname="hq.metal", Amount=25 }
},
Lanes = new List<PluginConfig.LaneConfig>
{
new PluginConfig.LaneConfig
{
LaneId = "lane_01",
LaneCenter = PluginConfig.Vector3Data.From(new Vector3(-4000, 50, -4000)),
LaneRadius = 160f,
BaselineFile = "containment_lane_t1_lane01",
StagingSpawn = PluginConfig.Vector3Data.From(new Vector3(-4030, 50, -4020)),
ExitSpawn = PluginConfig.Vector3Data.From(new Vector3(0, 10, 0)),
StagingCenter = PluginConfig.Vector3Data.From(new Vector3(-4030, 50, -4020)),
StagingNoNpcRadius = 35f,
CompletionPosition = PluginConfig.Vector3Data.From(new Vector3(-3920, 50, -3920)),
CompletionRadius = 7f,
Checkpoints = new Dictionary<string, PluginConfig.CheckpointConfig>
{
{ "cp_gate_01", new PluginConfig.CheckpointConfig { Position = PluginConfig.Vector3Data.From(new Vector3(-4015, 50, -4015)), Radius = 4f, SpawnGateDoor=true, GateDoorYaw=90f } },
{ "cp_gate_02", new PluginConfig.CheckpointConfig { Position = PluginConfig.Vector3Data.From(new Vector3(-3970, 50, -3970)), Radius = 4f, SpawnGateDoor=true, GateDoorYaw=90f } },
{ "cp_gate_03", new PluginConfig.CheckpointConfig { Position = PluginConfig.Vector3Data.From(new Vector3(-3940, 50, -3940)), Radius = 4f, SpawnGateDoor=true, GateDoorYaw=90f } },
},
Rooms = new List<PluginConfig.RoomConfig>
{
new PluginConfig.RoomConfig
{
RoomId = "room_01",
GateCheckpointId = "cp_gate_01",
SetCheckpointOnClear = "cp_gate_02",
PackSpawns = new List<PluginConfig.Vector3Data>
{
PluginConfig.Vector3Data.From(new Vector3(-4005, 50, -4005)),
PluginConfig.Vector3Data.From(new Vector3(-3995, 50, -3995)),
},
BossSpawn = PluginConfig.Vector3Data.From(new Vector3(-3985, 50, -3985))
},
new PluginConfig.RoomConfig
{
RoomId = "room_02",
GateCheckpointId = "cp_gate_02",
SetCheckpointOnClear = "cp_gate_03",
PackSpawns = new List<PluginConfig.Vector3Data>
{
PluginConfig.Vector3Data.From(new Vector3(-3960, 50, -3960)),
PluginConfig.Vector3Data.From(new Vector3(-3950, 50, -3950)),
},
BossSpawn = PluginConfig.Vector3Data.From(new Vector3(-3945, 50, -3945))
},
new PluginConfig.RoomConfig
{
RoomId = "room_03_final",
GateCheckpointId = "cp_gate_03",
SetCheckpointOnClear = "cp_gate_03",
PackSpawns = new List<PluginConfig.Vector3Data>
{
PluginConfig.Vector3Data.From(new Vector3(-3935, 50, -3935)),
PluginConfig.Vector3Data.From(new Vector3(-3928, 50, -3928)),
},
BossSpawn = PluginConfig.Vector3Data.From(new Vector3(-3925, 50, -3925))
}
}
}
}
};
_config.Dungeons["containment_t1"] = dungeon;
SaveConfig();
}
protected override void LoadConfig()
{
base.LoadConfig();
try { _config = Config.ReadObject<PluginConfig>(); }
catch
{
PrintWarning("Config invalid; regenerating default.");
LoadDefaultConfig();
}
}
protected override void SaveConfig() => Config.WriteObject(_config, true);
#endregion
#region Runtime state
private enum InstancePhase { Staging, InProgress, Completed, Failed, Expired }
private class InstanceState
{
public string InstanceId;
public string DungeonKey;
public string LaneId;
public int Tier;
public InstancePhase Phase;
public ulong LeaderId;
public HashSet<ulong> Members = new HashSet<ulong>();
public DateTime StartedUtc;
public DateTime ExpiresUtc;
// Waypoint checkpoints (whole party)
public string CurrentCheckpointId;
public HashSet<string> ClaimedCheckpoints = new HashSet<string>();
public Dictionary<string, DateTime> LastCheckpointNotifyUtcById = new Dictionary<string, DateTime>();
// Progression
public int UnlockedRoomIndex; // highest room index allowed to spawn
public HashSet<int> SpawnedRooms = new HashSet<int>();
public HashSet<int> DefeatedRooms = new HashSet<int>();
public bool CompletionUnlocked;
// Spawning / tracking
public string BotGroupKey;
public Dictionary<uint, NpcTetherInfo> TrackedNpcs = new Dictionary<uint, NpcTetherInfo>();
// Boss tracking: netID -> roomIndex
public Dictionary<uint, int> BossNetIds = new Dictionary<uint, int>();
// Baseline + hard prefabs spawned in lane
public HashSet<uint> LaneBaselineEntities = new HashSet<uint>();
public List<uint> LaneHardPrefabEntities = new List<uint>();
public List<uint> GateDoorEntities = new List<uint>();
// Rewards
public bool RewardsSpawned;
public List<uint> RewardCrateNetIds = new List<uint>();
public DateTime AutoEndUtc;
// Verification throttles
public DateTime LastBossRespawnAttemptUtc = DateTime.MinValue;
public DateTime LastGateSpawnAttemptUtc = DateTime.MinValue;
}
private class NpcTetherInfo
{
public Vector3 SpawnPos;
public float LeashRadius;
public float HardResetRadius;
public Vector3 StagingCenter;
public float StagingNoNpcRadius;
}
private class PendingPaste
{
public string InstanceId;
public string LaneId;
public string Filename;
public Vector3 StartPos;
}
private readonly Dictionary<string, InstanceState> _instancesById = new Dictionary<string, InstanceState>();
private readonly Dictionary<string, string> _laneToInstanceId = new Dictionary<string, string>();
private readonly Dictionary<ulong, string> _playerToInstanceId = new Dictionary<ulong, string>();
private readonly Dictionary<string, PendingPaste> _pendingPasteByLane = new Dictionary<string, PendingPaste>();
private Timer _tetherTimer;
private Timer _triggerTimer;
private Timer _verifyTimer;
#endregion
#region Lifecycle
private void OnServerInitialized()
{
_spawnAdapter = BotReSpawn != null ? (ISpawnAdapter)new BotReSpawnAdapter(BotReSpawn) : new NativeSpawnAdapter(this);
Puts($"SpawnAdapter={_spawnAdapter?.Name ?? "null"} | CopyPaste={(CopyPaste != null ? "OK" : "MISSING")}");
StartLoops();
}
private void Unload()
{
_tetherTimer?.Destroy();
_triggerTimer?.Destroy();
_verifyTimer?.Destroy();
foreach (var inst in _instancesById.Values.ToList())
{
try { EndInstance(inst, InstancePhase.Expired, "Plugin unload"); }
catch { /* ignored */ }
}
}
private void StartLoops()
{
_tetherTimer?.Destroy();
_triggerTimer?.Destroy();
_verifyTimer?.Destroy();
_tetherTimer = timer.Every(Mathf.Clamp(_config.General.NpcTetherTickSeconds, 0.25f, 5f), TetherTick);
_triggerTimer = timer.Every(Mathf.Clamp(_config.General.TriggerTickSeconds, 0.1f, 2f), TriggerTick);
_verifyTimer = timer.Every(Mathf.Clamp(_config.General.VerificationTickSeconds, 1f, 30f), VerificationTick);
}
#endregion
#region Commands
[ChatCommand("raid")]
private void CmdRaid(BasePlayer player, string command, string[] args)
{
if (player == null || !player.IsConnected) return;
if (args == null || args.Length == 0)
{
Reply(player, "Usage: /raid start <dungeonKey> | /raid leave | /raid status | /raid resetlane (admin)");
return;
}
var sub = args[0].ToLowerInvariant();
if (sub == "start")
{
if (args.Length < 2) { Reply(player, "Usage: /raid start <dungeonKey>"); return; }
TryStartInstance(player, args[1]);
return;
}
if (sub == "leave")
{
if (!_config.General.AllowLeaveCommand) { Reply(player, "Leaving is disabled."); return; }
TryLeaveInstance(player);
return;
}
if (sub == "status") { ShowStatus(player); return; }
if (sub == "resetlane")
{
if (!player.IsAdmin) { Reply(player, "Admin only."); return; }
if (!_playerToInstanceId.TryGetValue(player.userID, out var id) || !_instancesById.TryGetValue(id, out var inst))
{
Reply(player, "Not in an instance.");
return;
}
ResetLaneToBaseline(inst, "Admin reset");
Reply(player, "Lane reset to baseline.");
return;
}
Reply(player, "Unknown subcommand.");
}
private void Reply(BasePlayer player, string msg) =>
SendReply(player, $"<color=#b7ffb7>[Raid]</color> {msg}");
#endregion
#region Instance start/end
private void TryStartInstance(BasePlayer leader, string dungeonKey)
{
if (_playerToInstanceId.ContainsKey(leader.userID)) { Reply(leader, "You are already in an instance."); return; }
if (!_config.Dungeons.TryGetValue(dungeonKey, out var dungeon)) { Reply(leader, $"Unknown dungeonKey '{dungeonKey}'."); return; }
var party = ResolveParty(leader);
if (party.Count == 0) { Reply(leader, "No valid party members found."); return; }
if (party.Count > _config.General.MaxPartySize) { Reply(leader, $"Party too large. Max {_config.General.MaxPartySize}."); return; }
var lane = FindFreeLane(dungeon);
if (lane == null) { Reply(leader, "All lanes are currently busy."); return; }
var instanceId = $"{dungeonKey}:{lane.LaneId}:{DateTime.UtcNow.Ticks}";
var inst = new InstanceState
{
InstanceId = instanceId,
DungeonKey = dungeonKey,
LaneId = lane.LaneId,
Tier = dungeon.Tier,
Phase = InstancePhase.Staging,
LeaderId = leader.userID,
StartedUtc = DateTime.UtcNow,
ExpiresUtc = DateTime.UtcNow.AddMinutes(_config.General.HardTimeoutMinutes),
BotGroupKey = $"raid_{leader.userID}_{lane.LaneId}_{DateTime.UtcNow.Ticks}".ToLowerInvariant(),
UnlockedRoomIndex = 0,
CompletionUnlocked = false,
RewardsSpawned = false
};
foreach (var uid in party) inst.Members.Add(uid);
_instancesById[inst.InstanceId] = inst;
_laneToInstanceId[lane.LaneId] = inst.InstanceId;
foreach (var uid in inst.Members) _playerToInstanceId[uid] = inst.InstanceId;
ResetLaneToBaseline(inst, "Instance start baseline");
var staging = lane.StagingSpawn.ToVector3();
foreach (var uid in inst.Members)
{
var p = BasePlayer.FindByID(uid);
if (p != null && p.IsConnected) SafeTeleport(p, staging);
}
Reply(leader, $"Instance created: {dungeonKey} ({lane.LaneId}) | Party={inst.Members.Count}.");
Reply(leader, "Enter the first waypoint room (Gate checkpoint) to spawn the first trash/boss.");
}
private void EndInstance(InstanceState inst, InstancePhase finalPhase, string reason)
{
if (inst == null) return;
inst.Phase = finalPhase;
var dungeon = _config.Dungeons[inst.DungeonKey];
var lane = dungeon.Lanes.First(l => l.LaneId == inst.LaneId);
_spawnAdapter?.DespawnGroup(inst);
inst.TrackedNpcs.Clear();
inst.BossNetIds.Clear();
KillEntityNetIds(inst.RewardCrateNetIds);
inst.RewardCrateNetIds.Clear();
KillEntityNetIds(inst.LaneHardPrefabEntities);
inst.LaneHardPrefabEntities.Clear();
KillEntityNetIds(inst.GateDoorEntities);
inst.GateDoorEntities.Clear();
KillEntitySet(inst.LaneBaselineEntities);
inst.LaneBaselineEntities.Clear();
var exit = lane.ExitSpawn.ToVector3();
foreach (var uid in inst.Members.ToList())
{
var p = BasePlayer.FindByID(uid);
if (p != null && p.IsConnected) SafeTeleport(p, exit);
_playerToInstanceId.Remove(uid);
}
_laneToInstanceId.Remove(inst.LaneId);
_instancesById.Remove(inst.InstanceId);
Puts($"Instance ended ({finalPhase}) {inst.InstanceId} reason={reason}");
}
private void TryLeaveInstance(BasePlayer player)
{
if (!_playerToInstanceId.TryGetValue(player.userID, out var instanceId) || !_instancesById.TryGetValue(instanceId, out var inst))
{
Reply(player, "You are not in an instance.");
return;
}
var dungeon = _config.Dungeons[inst.DungeonKey];
var lane = dungeon.Lanes.First(l => l.LaneId == inst.LaneId);
if (player.userID == inst.LeaderId)
{
EndInstance(inst, InstancePhase.Failed, "Leader left");
Reply(player, "Leader left. The run has ended.");
return;
}
inst.Members.Remove(player.userID);
_playerToInstanceId.Remove(player.userID);
SafeTeleport(player, lane.ExitSpawn.ToVector3());
Reply(player, "You left the instance.");
if (inst.Members.Count == 0)
EndInstance(inst, InstancePhase.Expired, "All members left");
}
#endregion
#region Party resolution
private HashSet<ulong> ResolveParty(BasePlayer leader)
{
var party = new HashSet<ulong> { leader.userID };
if (leader.currentTeam != 0)
{
var team = RelationshipManager.ServerInstance.FindTeam(leader.currentTeam);
if (team != null)
{
foreach (var uid in team.members)
{
if (uid == leader.userID) continue;
var p = BasePlayer.FindByID(uid);
if (p == null || !p.IsConnected) continue;
party.Add(uid);
break; // duo cap
}
}
}
return party;
}
#endregion
#region Trigger tick
private void TriggerTick()
{
if (_instancesById.Count == 0) return;
foreach (var inst in _instancesById.Values.ToList())
{
if (DateTime.UtcNow > inst.ExpiresUtc)
{
EndInstance(inst, InstancePhase.Expired, "Hard timeout");
continue;
}
if (inst.Phase == InstancePhase.Completed && DateTime.UtcNow >= inst.AutoEndUtc)
{
EndInstance(inst, InstancePhase.Completed, "Loot window elapsed");
continue;
}
if (!_config.Dungeons.TryGetValue(inst.DungeonKey, out var dungeon)) continue;
var lane = dungeon.Lanes.FirstOrDefault(l => l.LaneId == inst.LaneId);
if (lane == null) continue;
ProcessCheckpointsAndGates(inst, lane, dungeon);
if (inst.CompletionUnlocked && inst.Phase == InstancePhase.InProgress)
{
if (AnyMemberInside(inst, lane.CompletionPosition.ToVector3(), Mathf.Max(1f, lane.CompletionRadius)))
{
inst.Phase = InstancePhase.Completed;
inst.AutoEndUtc = DateTime.UtcNow.AddSeconds(_config.General.CompletionLootWindowSeconds);
TrySpawnRewardCrates(inst, dungeon, lane);
foreach (var uid in inst.Members)
{
var p = BasePlayer.FindByID(uid);
if (p != null && p.IsConnected)
Reply(p, $"Run complete. Rewards spawned. You have {_config.General.CompletionLootWindowSeconds}s to loot before the instance closes. Use /raid leave when done.");
}
}
}
}
}
private void ProcessCheckpointsAndGates(InstanceState inst, PluginConfig.LaneConfig lane, PluginConfig.DungeonConfig dungeon)
{
foreach (var uid in inst.Members)
{
var p = BasePlayer.FindByID(uid);
if (p == null || !p.IsConnected) continue;
var pos = p.transform.position;
foreach (var kvp in lane.Checkpoints)
{
var cpId = kvp.Key;
var cp = kvp.Value;
if (cp?.Position == null) continue;
var r = cp.Radius > 0 ? cp.Radius : _config.General.DefaultCheckpointRadius;
if (Vector3.Distance(pos, cp.Position.ToVector3()) > r) continue;
// Gate keycard check for unlocked rooms
if (IsGateCheckpoint(lane, cpId, out var gateRoomIndex) &&
dungeon.EnableTierKeycardGates &&
gateRoomIndex <= inst.UnlockedRoomIndex &&
!inst.SpawnedRooms.Contains(gateRoomIndex))
{
if (!CheckGateKeycard(p, dungeon.Tier, dungeon.ConsumeKeycardOnUse))
return;
}
if (!inst.ClaimedCheckpoints.Contains(cpId))
ClaimCheckpoint(inst, cpId);
TrySpawnRoomFromGate(inst, lane, dungeon, cpId);
return;
}
}
}
private bool IsGateCheckpoint(PluginConfig.LaneConfig lane, string checkpointId, out int roomIndex)
{
roomIndex = -1;
for (var i = 0; i < lane.Rooms.Count; i++)
{
if (string.Equals(lane.Rooms[i].GateCheckpointId, checkpointId, StringComparison.OrdinalIgnoreCase))
{
roomIndex = i;
return true;
}
}
return false;
}
private string GetRequiredKeycardShortnameForTier(int tier)
{
if (tier <= 1) return "keycard_green";
if (tier <= 3) return "keycard_blue";
return "keycard_red";
}
private bool CheckGateKeycard(BasePlayer player, int tier, bool consume)
{
var needed = GetRequiredKeycardShortnameForTier(tier);
var item = FindItemByShortname(player, needed);
if (item == null)
{
Reply(player, $"Access denied. Requires {needed.Replace("keycard_", "").ToUpperInvariant()} keycard for this Tier.");
return false;
}
if (!consume) return true;
if (item.amount > 1) item.amount -= 1;
else item.Remove();
player.inventory?.SendSnapshot();
Reply(player, $"Keycard consumed: {needed.Replace("keycard_", "").ToUpperInvariant()}");
return true;
}
private Item FindItemByShortname(BasePlayer player, string shortname)
{
if (player?.inventory == null) return null;
Item FindIn(ItemContainer c) =>
c?.itemList?.FirstOrDefault(i => i?.info != null && i.info.shortname == shortname);
return FindIn(player.inventory.containerMain) ?? FindIn(player.inventory.containerBelt);
}
private void TrySpawnRoomFromGate(InstanceState inst, PluginConfig.LaneConfig lane, PluginConfig.DungeonConfig dungeon, string gateCheckpointId)
{
var idx = lane.Rooms.FindIndex(r => string.Equals(r.GateCheckpointId, gateCheckpointId, StringComparison.OrdinalIgnoreCase));
if (idx < 0) return;
if (idx > inst.UnlockedRoomIndex) return;
if (inst.SpawnedRooms.Contains(idx)) return;
SpawnRoom(inst, lane, dungeon, idx);
}
private void ClaimCheckpoint(InstanceState inst, string checkpointId)
{
inst.ClaimedCheckpoints.Add(checkpointId);
inst.CurrentCheckpointId = checkpointId;
var now = DateTime.UtcNow;
inst.LastCheckpointNotifyUtcById.TryGetValue(checkpointId, out var last);
if ((now - last).TotalSeconds < _config.General.CheckpointNotifyCooldownSeconds) return;
inst.LastCheckpointNotifyUtcById[checkpointId] = now;
foreach (var uid in inst.Members)
{
var p = BasePlayer.FindByID(uid);
if (p != null && p.IsConnected)
Reply(p, $"Checkpoint claimed: {checkpointId}. Respawns will route here.");
}
}
private bool AnyMemberInside(InstanceState inst, Vector3 center, float radius)
{
foreach (var uid in inst.Members)
{
var p = BasePlayer.FindByID(uid);
if (p == null || !p.IsConnected) continue;
if (Vector3.Distance(p.transform.position, center) <= radius) return true;
}
return false;
}
#endregion
#region Spawn/progression
private class SpawnContext
{
public string BotGroupKey;
public int Tier;
public int PartySize;
public float HpMult;
public float DmgMult;
}
private void SpawnRoom(InstanceState inst, PluginConfig.LaneConfig lane, PluginConfig.DungeonConfig dungeon, int roomIndex)
{
var room = lane.Rooms[roomIndex];
var partyScalar = inst.Members.Count >= 2 ? dungeon.PartyScalarDuo : dungeon.PartyScalarSolo;
var ctx = new SpawnContext
{
BotGroupKey = inst.BotGroupKey,
Tier = inst.Tier,
PartySize = inst.Members.Count,
HpMult = dungeon.HpMult * partyScalar,
DmgMult = dungeon.DmgMult * partyScalar
};
var packProfile = !string.IsNullOrEmpty(room.PackProfileOverride) ? room.PackProfileOverride : dungeon.BotProfilePacks;
var bossProfile = !string.IsNullOrEmpty(room.BossProfileOverride) ? room.BossProfileOverride : dungeon.BotProfileBoss;
inst.SpawnedRooms.Add(roomIndex);
inst.Phase = InstancePhase.InProgress;
if (room.PackSpawns != null)
{
foreach (var sp in room.PackSpawns)
_spawnAdapter?.SpawnPackAt(inst, ctx, sp.ToVector3(), packProfile);
}
var bossPos = room.BossSpawn?.ToVector3() ?? Vector3.zero;
if (bossPos != Vector3.zero && !string.IsNullOrEmpty(bossProfile))
_spawnAdapter?.SpawnBossAt(inst, ctx, bossPos, bossProfile, roomIndex);
foreach (var uid in inst.Members)
{
var p = BasePlayer.FindByID(uid);
if (p != null && p.IsConnected)
Reply(p, $"Spawned {room.RoomId}.");
}
}
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
{
var netId = entity?.net?.ID ?? 0u;
if (netId == 0u) return;
foreach (var inst in _instancesById.Values.ToList())
{
if (!inst.BossNetIds.TryGetValue(netId, out var roomIndex)) continue;
inst.BossNetIds.Remove(netId);
inst.DefeatedRooms.Add(roomIndex);
var dungeon = _config.Dungeons[inst.DungeonKey];
var lane = dungeon.Lanes.First(l => l.LaneId == inst.LaneId);
var room = lane.Rooms[roomIndex];
var nextIndex = roomIndex + 1;
if (nextIndex < lane.Rooms.Count)
{
inst.UnlockedRoomIndex = Math.Max(inst.UnlockedRoomIndex, nextIndex);
if (!string.IsNullOrEmpty(room.SetCheckpointOnClear) && lane.Checkpoints.ContainsKey(room.SetCheckpointOnClear))
ClaimCheckpoint(inst, room.SetCheckpointOnClear);
foreach (var uid in inst.Members)
{
var p = BasePlayer.FindByID(uid);
if (p != null && p.IsConnected)
Reply(p, $"Boss down. Next waypoint unlocked. Step into the next waypoint room.");
}
}
else
{
inst.CompletionUnlocked = true;
if (!string.IsNullOrEmpty(room.SetCheckpointOnClear) && lane.Checkpoints.ContainsKey(room.SetCheckpointOnClear))
ClaimCheckpoint(inst, room.SetCheckpointOnClear);
foreach (var uid in inst.Members)
{
var p = BasePlayer.FindByID(uid);
if (p != null && p.IsConnected)
Reply(p, "Final boss down. Proceed to the completion checkpoint room for rewards.");
}
}
break;
}
}
#endregion
#region Verification tick
private void VerificationTick()
{
foreach (var inst in _instancesById.Values)
{
if (inst.Phase != InstancePhase.InProgress) continue;
var dungeon = _config.Dungeons[inst.DungeonKey];
var lane = dungeon.Lanes.First(l => l.LaneId == inst.LaneId);
if ((DateTime.UtcNow - inst.LastGateSpawnAttemptUtc).TotalSeconds >= _config.General.GateSpawnAttemptCooldownSeconds)
{
if (ForceSpawnIfInsideUnlockedGate(inst, lane, dungeon))
inst.LastGateSpawnAttemptUtc = DateTime.UtcNow;
}
if ((DateTime.UtcNow - inst.LastBossRespawnAttemptUtc).TotalSeconds >= _config.General.BossRespawnAttemptCooldownSeconds)
{
if (EnsureBossPresentForActiveRoom(inst, lane, dungeon))
inst.LastBossRespawnAttemptUtc = DateTime.UtcNow;
}
}
}
private bool ForceSpawnIfInsideUnlockedGate(InstanceState inst, PluginConfig.LaneConfig lane, PluginConfig.DungeonConfig dungeon)
{
for (var idx = 0; idx <= inst.UnlockedRoomIndex && idx < lane.Rooms.Count; idx++)
{
if (inst.SpawnedRooms.Contains(idx)) continue;
var room = lane.Rooms[idx];
if (!lane.Checkpoints.TryGetValue(room.GateCheckpointId, out var cp)) continue;
var r = cp.Radius > 0 ? cp.Radius : _config.General.DefaultCheckpointRadius;
if (!AnyMemberInside(inst, cp.Position.ToVector3(), r)) continue;
if (dungeon.EnableTierKeycardGates)
{
var first = inst.Members.Select(BasePlayer.FindByID)
.FirstOrDefault(pl => pl != null && pl.IsConnected && Vector3.Distance(pl.transform.position, cp.Position.ToVector3()) <= r);
if (first != null && !CheckGateKeycard(first, dungeon.Tier, dungeon.ConsumeKeycardOnUse))
return false;
}
SpawnRoom(inst, lane, dungeon, idx);
return true;
}
return false;
}
private bool EnsureBossPresentForActiveRoom(InstanceState inst, PluginConfig.LaneConfig lane, PluginConfig.DungeonConfig dungeon)
{
var active = inst.SpawnedRooms.Where(i => !inst.DefeatedRooms.Contains(i)).DefaultIfEmpty(-1).Max();
if (active < 0) return false;
// if we already have an alive boss tracked for this active room, OK
foreach (var kvp in inst.BossNetIds.ToList())
{
if (kvp.Value != active) continue;
var ent = BaseNetworkable.serverEntities.Find(kvp.Key) as BaseCombatEntity;
if (ent != null && !ent.IsDestroyed && ent.IsAlive()) return false;
}
var room = lane.Rooms[active];
var bossProfile = !string.IsNullOrEmpty(room.BossProfileOverride) ? room.BossProfileOverride : dungeon.BotProfileBoss;
var bossPos = room.BossSpawn.ToVector3();
if (string.IsNullOrEmpty(bossProfile)) return false;
var partyScalar = inst.Members.Count >= 2 ? dungeon.PartyScalarDuo : dungeon.PartyScalarSolo;
var ctx = new SpawnContext
{
BotGroupKey = inst.BotGroupKey,
Tier = inst.Tier,
PartySize = inst.Members.Count,
HpMult = dungeon.HpMult * partyScalar,
DmgMult = dungeon.DmgMult * partyScalar
};
_spawnAdapter?.SpawnBossAt(inst, ctx, bossPos, bossProfile, active);
return true;
}
#endregion
#region Rewards
private void TrySpawnRewardCrates(InstanceState inst, PluginConfig.DungeonConfig dungeon, PluginConfig.LaneConfig lane)
{
if (inst.RewardsSpawned) return;
inst.RewardsSpawned = true;
var prefab = _config.General.RewardCratePrefab;
if (string.IsNullOrEmpty(prefab)) return;
var basePos = lane.CompletionPosition.ToVector3();
var offset = _config.General.RewardCrateOffset.ToVector3();
var count = Mathf.Clamp(_config.General.RewardCratesToSpawn, 1, 10);
for (var i = 0; i < count; i++)
{
var spawnPos = basePos + offset + new Vector3(i * 1.2f, 0f, 0f);
var ent = GameManager.server.CreateEntity(prefab, spawnPos, Quaternion.identity, true);
if (ent == null) continue;
ent.enableSaving = false;
ent.Spawn();
var id = ent.net?.ID ?? 0u;
if (id != 0u) inst.RewardCrateNetIds.Add(id);
var storage = ent as StorageContainer;
if (storage?.inventory == null) continue;
storage.inventory.Clear();
foreach (var ri in dungeon.RewardLoot ?? new List<PluginConfig.DungeonConfig.RewardItem>())
{
if (ri == null || string.IsNullOrEmpty(ri.Shortname) || ri.Amount <= 0) continue;
var item = ItemManager.CreateByName(ri.Shortname, ri.Amount, ri.SkinId);
if (item == null) continue;
item.MoveToContainer(storage.inventory);
}
storage.SendNetworkUpdateImmediate();
}
}
#endregion
#region Lane build/reset
private PluginConfig.LaneConfig FindFreeLane(PluginConfig.DungeonConfig dungeon) =>
dungeon.Lanes.FirstOrDefault(l => !_laneToInstanceId.ContainsKey(l.LaneId));
private void ResetLaneToBaseline(InstanceState inst, string reason)
{
var dungeon = _config.Dungeons[inst.DungeonKey];
var lane = dungeon.Lanes.First(l => l.LaneId == inst.LaneId);
KillEntitySet(inst.LaneBaselineEntities);
inst.LaneBaselineEntities.Clear();
KillEntityNetIds(inst.LaneHardPrefabEntities);
inst.LaneHardPrefabEntities.Clear();
KillEntityNetIds(inst.GateDoorEntities);
inst.GateDoorEntities.Clear();
KillEntityNetIds(inst.RewardCrateNetIds);
inst.RewardCrateNetIds.Clear();
inst.RewardsSpawned = false;
inst.TrackedNpcs.Clear();
inst.BossNetIds.Clear();
inst.DefeatedRooms.Clear();
inst.SpawnedRooms.Clear();
inst.UnlockedRoomIndex = 0;
inst.ClaimedCheckpoints.Clear();
inst.CurrentCheckpointId = null;
inst.LastCheckpointNotifyUtcById.Clear();
inst.CompletionUnlocked = false;
inst.Phase = InstancePhase.Staging;
if (CopyPaste == null || string.IsNullOrEmpty(lane.BaselineFile)) return;
var pos = lane.LaneCenter.ToVector3();
var args = BuildCopyPasteArgs(lane);
_pendingPasteByLane[lane.LaneId] = new PendingPaste
{
InstanceId = inst.InstanceId,
LaneId = lane.LaneId,
Filename = lane.BaselineFile,
StartPos = pos
};
CopyPaste.Call("TryPasteFromVector3", pos, lane.RotationCorrection, lane.BaselineFile, args);
}
private string[] BuildCopyPasteArgs(PluginConfig.LaneConfig lane)
{
var o = lane.PasteOptions ?? new PluginConfig.PasteOptions();
return new[]
{
"stability", o.Stability ? "true" : "false",
"deployables", o.Deployables ? "true" : "false",
"inventories", o.Inventories ? "true" : "false",
"vending", o.Vending ? "true" : "false",
"entityowner", o.EntityOwner ? "true" : "false",
"autoheight", o.AutoHeight ? "true" : "false",
"height", o.Height.ToString("0.###"),
"blockcollision", _config.General.CopyPasteBlockCollision.ToString("0.###"),
};
}
private void OnPasteFinished(List<BaseEntity> pastedEntities, string filename, IPlayer player, Vector3 startPos)
{
if (pastedEntities == null || pastedEntities.Count == 0) return;
PendingPaste match = null;
foreach (var kvp in _pendingPasteByL
// ... [Code preview truncated due to length. Full source in attached file] ...