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";
}