namespace Aeshnidae.Invasions; /// /// Every term of the town invasions, in one file. /// /// An invasion (design agreed with Tom 2026-09-27): every few hours the scheduler picks a /// town that has players in it and warns the world. Then the town is besieged for a fixed /// time (Tom, the same evening, after the first live one was over in two minutes: "hold the /// town"): bands of invaders keep arriving from the edge of town and walking in, more with /// more defenders, and when the siege ends their leader comes. Kill the boss inside its /// time and every player who damaged an invader is paid a bonus scaled to their own level, /// and a token; otherwise the invaders withdraw and nobody is paid. Kill experience is /// retail throughout - the invaders are retail creatures. /// /// Town tiers (Tom, 2026-09-27): a town is invaded by the wave set that fits its players - /// starter towns get drudges, not Virindi. The monsters live in the shared world, not a /// copy, so they are one strength for everyone there; the tier is the only protection. /// public class Settings { public const string FileName = "Settings.json"; /// Master switch. Off: nothing is scheduled and no invasion can be started; one running is ended. public bool Enabled { get; set; } = true; /// /// Start invasions on the timer. Off: only an admin's /invasion-start begins one. Off by /// default so a fresh deploy (staging, say) never surprises anyone - switch it on in the /// deployed Settings.json when the towns are tuned. /// public bool AutoSchedule { get; set; } = false; /// Minutes after the mod loads (a restart, a reload) before the first scheduled invasion. public double FirstDelayMinutes { get; set; } = 60; /// Minutes from the end of one invasion to the next, plus or minus JitterMinutes. public double IntervalMinutes { get; set; } = 180; public double JitterMinutes { get; set; } = 30; /// When the timer comes due and no town qualifies, look again this many minutes later. public double RetryMinutes { get; set; } = 15; /// A town qualifies only with at least this many players inside its TownRadius. public int MinPlayersInTown { get; set; } = 1; /// The same town is not invaded again (by the timer) for this many minutes. public double TownCooldownMinutes { get; set; } = 360; /// /// Minutes between the warning and the first wave. Long enough for someone across the /// map to recall in, or for a player who does not want the fight to leave. /// public double WarningMinutes { get; set; } = 10; /// "N minutes" reminders to the whole world during the warning, at these minutes left. public List WarningReminders { get; set; } = new() { 5, 1 }; /// /// How long the town is besieged before the boss comes. The length is fixed: killing fast /// kills more, it does not end the siege early. /// public double SiegeMinutes { get; set; } = 12; /// /// A band of invaders arrives every this many seconds during the siege (the first at once). /// 15 since 2026-09-27 evening (Tom: faster than the first siege's 30). /// public double BandEverySeconds { get; set; } = 15; /// /// No band arrives while this many invaders are already standing in town, so a town /// nobody defends does not fill up; the siege clock keeps running. /// public int MaxStanding { get; set; } = 25; /// Once the boss arrives, it must be dead within this many minutes or the town falls. public double BossMinutes { get; set; } = 5; /// Players in town hear how the siege stands (time to the boss, slain, standing) this often. public double UpdateEverySeconds { get; set; } = 120; /// /// How invaders come in from the edge of town to its centre: running (true) or walking /// (false), at MarchSpeed times that gait's normal speed. Tom, 2026-09-27: ACE's full run /// was too fast, then a walk too slow - "between what we had before and now" - so a run /// at a fraction of its speed, a jog. They still charge a player they see on the way at /// full speed, as any monster does; MarchRun true with MarchSpeed 1 is ACE's own run home. /// public bool MarchRun { get; set; } = true; /// Multiplier on the march gait's speed (see MarchRun). 1 = normal. public double MarchSpeed { get; set; } = 0.6; /// /// Seconds after spawning before an invader starts marching on the town centre - enough /// for every client to have been sent the creature before it moves. /// public double MarchDelaySeconds { get; set; } = 2; /// /// Safety net: every invader carries a Lifespan of this many minutes, so ACE deletes it on /// its own even if this mod is unloaded mid-invasion. Longer than siege + boss time. /// public double InvaderLifespanMinutes { get; set; } = 30; // --- Rewards (Tom, 2026-09-27: kill XP, a completion bonus, a token) -------------------- /// /// A player counts as a defender with at least this much damage dealt to invaders (their /// own or their pet's), summed over the invasion. Healing is not counted (ACE does not /// record who healed whom); a pure healer earns nothing in this version. /// public double MinContributionDamage { get; set; } = 25; /// /// Completion bonus: this fraction of the experience between the defender's level and the /// next, as ACE's AwardLevelProportionalXP emote pays it (quest experience: the server's /// xp_modifier applies, allegiance passup happens, Radiance follows through Bank). /// public double BonusPercentOfLevel { get; set; } = 0.10; /// Floor and ceiling of the bonus in experience; 0 = none. public long BonusMinXp { get; set; } = 0; public long BonusMaxXp { get; set; } = 0; /// The invasion token's weenie; 0 = no token (it does not exist yet - content step). public uint TokenWcid { get; set; } = 0; public int TokensPerDefence { get; set; } = 1; // --- Effects (Tom, 2026-09-27: "fireworks or any kind of visual effect") ------------------ // All retail: client effects (ACE's PlayScript names) and retail objects, so no dat change. // An empty name or 0 switches that one off. /invasion-fx and /invasion-fireworks preview them. /// Played on each invader as it appears at the edge of town - a portal flash. public string ArrivalEffect { get; set; } = "PortalExit"; /// Played on the boss when it arrives (instead of ArrivalEffect). public string BossEffect { get; set; } = "PortalStorm"; /// Played on each paid defender when the town holds. public string DefenderEffect { get; set; } = "WeddingBliss"; /// /// The fireworks when the town holds: this retail object, set down round the town centre. /// 8359 "Anniversary Effect" is what retail's Fireworks Generator (19457) spawned in towns /// for its holiday fireworks - the object is the display. 0 = none. /// public uint CelebrationWcid { get; set; } = 8359; /// How many displays, within CelebrationRadius metres of the centre. public int CelebrationCount { get; set; } = 6; public double CelebrationRadius { get; set; } = 30; /// How long the displays stay before they are taken away. public double CelebrationSeconds { get; set; } = 60; // --- Messages ---------------------------------------------------------------------------- /// Tell the whole server about warnings, the start, and the outcome. public bool AnnounceToWorld { get; set; } = true; public DiscordSettings Discord { get; set; } = new(); // --- Where and what ---------------------------------------------------------------------- /// /// What attacks, by tier. A town names one of these. Counts are for one player in town; /// each extra player adds PerExtraPlayer (rounded down). /// public Dictionary WaveSets { get; set; } = new(StringComparer.OrdinalIgnoreCase) { // Starter tier: the retail drudges players meet in their first hour (level 8), and a // level 15 boss. Wcids checked on the live world database 2026-09-27. ["starter"] = new WaveSet { Invaders = "Drudges", // One band every BandEverySeconds, picked by Weight. Three drudges for one defender. Bands = new() { new Band { Spawns = new() { new Spawn { Wcid = 7, Count = 2, PerExtraPlayer = 1 }, // Drudge Skulker, level 8 new Spawn { Wcid = 193, Count = 1, PerExtraPlayer = 0.5 } } }, // Drudge Slinker, level 8 new Band { Spawns = new() { new Spawn { Wcid = 940, Count = 2, PerExtraPlayer = 1 }, // Drudge Sneaker, level 8 new Spawn { Wcid = 192, Count = 1, PerExtraPlayer = 0.5 } } }, // Drudge Prowler, level 8 new Band { Spawns = new() { new Spawn { Wcid = 192, Count = 2, PerExtraPlayer = 1 }, // Drudge Prowler, level 8 new Spawn { Wcid = 1464, Count = 1, PerExtraPlayer = 0.5 } } }, // Drudge Robber, level 8 }, // When the siege ends: the leader and its escort. Boss = new() { new Spawn { Wcid = 11989, Count = 1, Boss = true }, // Drudge Vagabond, level 15 new Spawn { Wcid = 1464, Count = 4, PerExtraPlayer = 1 }, // Drudge Robber, level 8 }, }, }; /// /// The towns that can be invaded. Centre in map coordinates as players read them; nothing /// spawns within SafeRadius of it (the lifestone sits there in most towns), invaders appear /// between SpawnMin and SpawnMax metres out and march to within MarchRadius of the centre. /// TownRadius is who counts as "in town" for the player count and the local messages. /// public List Towns { get; set; } = new() { // Holtburg: the lifestone is at 42.0N 33.5E (landblock A9B4, 81.3 11.8), checked on // the live world database 2026-09-27. new Town { Name = "Holtburg", WaveSet = "starter", Center = "42.0N 33.5E" }, }; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, }; public static Settings Load(string modPath) { var path = Path.Combine(modPath, FileName); try { if (File.Exists(path)) { var loaded = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? new Settings(); // JSON gives a case-sensitive dictionary back; town entries name their set in any case. loaded.WaveSets = new Dictionary(loaded.WaveSets, StringComparer.OrdinalIgnoreCase); return loaded; } var defaults = new Settings(); defaults.Save(modPath); ModManager.Log($"[{Mod.Name}] wrote default settings to {path}"); return defaults; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not read {path}, using defaults: {ex.Message}", ModManager.LogLevel.Warn); return new Settings(); } } public void Save(string modPath) { try { File.WriteAllText(Path.Combine(modPath, FileName), JsonSerializer.Serialize(this, JsonOptions)); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not save settings: {ex.Message}", ModManager.LogLevel.Error); } } /// A town by exact name, then by prefix, then by fragment. public Town? FindTown(string text) { var wanted = (text ?? "").Trim(); if (wanted.Length == 0) return null; return Towns.FirstOrDefault(t => t.Name.Equals(wanted, StringComparison.OrdinalIgnoreCase)) ?? Towns.FirstOrDefault(t => t.Name.StartsWith(wanted, StringComparison.OrdinalIgnoreCase)) ?? Towns.FirstOrDefault(t => t.Name.Contains(wanted, StringComparison.OrdinalIgnoreCase)); } } public class Town { public string Name { get; set; } = ""; /// False: never chosen by the timer, but an admin can still start one here. public bool Enabled { get; set; } = true; /// A key of Settings.WaveSets - the town's tier. public string WaveSet { get; set; } = "starter"; /// Map coordinates, "42.0N 33.5E" (a comma between the two is fine). public string Center { get; set; } = ""; public double SafeRadius { get; set; } = 25; public double SpawnMin { get; set; } = 45; public double SpawnMax { get; set; } = 80; public double MarchRadius { get; set; } = 20; /// /// Who counts as in town, in metres from the centre: the defenders a band is sized by, /// who hears the in-town messages (half as far again), and whether the timer may pick the /// town. 400 since 2026-09-27 evening (Tom), was 150. /// public double TownRadius { get; set; } = 400; /// How widely one band spreads round its side of town, in degrees either way. public double BandSpreadDegrees { get; set; } = 20; } public class WaveSet { /// What the messages call them: "Drudges are gathering outside Holtburg". public string Invaders { get; set; } = "Monsters"; /// The bands that arrive during the siege, one at a time, picked at random by Weight. public List Bands { get; set; } = new(); /// The last assault when the siege ends: the boss (Spawn.Boss) and its escort. public List Boss { get; set; } = new(); } public class Band { public double Weight { get; set; } = 1; public List Spawns { get; set; } = new(); } public class Spawn { public uint Wcid { get; set; } public int Count { get; set; } = 1; public double PerExtraPlayer { get; set; } = 0; /// This spawn is the boss (not scaled by players; its death saves the town). public bool Boss { get; set; } } public class DiscordSettings { /// /// The notify-discord.sh channel key for the warning and the outcome. Empty: nothing is /// posted, only logged - the default, so staging never posts to a public channel. /// public string Channel { get; set; } = ""; /// ~ expands to the home directory. public string NotifyScript { get; set; } = "~/notify-discord.sh"; }