namespace Aeshnidae.Expedition; /// /// The Old Ring of Tenkarrdun's knobs, read from Settings.json in the deployed mod folder /// (written with these defaults on first run). The design is the doc "The Old Ring of /// Tenkarrdun" (Tom, 2026-09-27); every number here is a starting point to tune. /// /// Enabled the switch; off = no meters, no camps, no thinning, no hooks' work /// Region / Copy which island: InstancesNoDat's region "aerlinthe", copy 1 - the /// Aerlinthe instance everyone shares. Copy 2 (the PK layer) does not count. /// Zones the island in named rectangles of landblocks, first match wins; /// MeterTarget 0 = no meter (a later act's ground) /// MeterPointsPerLevel a kill adds its level times this to its zone's meter: 0.01 makes a /// level-100 monster one point, an 80 0.8, a 115 1.15 /// ContributionPerLevel a kill credits its top damager (or a pet's owner) level times this: /// 0.1 makes an 80 worth 8, a 115 worth 11.5 (the doc's table) /// Milestones percentages told to everyone on the island; 100 goes server-wide /// ThinTo a reclaimed zone keeps this share of its monsters (each generator /// profile held to it); 1 = no thinning /// Camps what appears in a reclaimed zone: wcid and spot per object /// FlushSeconds how often meters and contribution are written to the shard database /// public class Settings { public const string FileName = "Settings.json"; public bool Enabled { get; set; } = true; /// InstancesNoDat's region name (Settings.Outdoor), for the island's landblocks. public string Region { get; set; } = "aerlinthe"; /// The island copy the quest is played on: 1, the shared Aerlinthe instance. public int Copy { get; set; } = 1; public double MeterPointsPerLevel { get; set; } = 0.01; public double ContributionPerLevel { get; set; } = 0.1; public List Milestones { get; set; } = new() { 25, 50, 75, 100 }; public double ThinTo { get; set; } = 0.33; /// /// The island's monster density (Density.cs): every generator in the island copy spawns this /// many times its retail count (monster profiles' InitCreate/MaxCreate and the generator's own /// caps). 1 = retail; 2 = twice the monsters (Tom, 2026-10-01). The real Aerlinthe is untouched. /// public double SpawnMultiplier { get; set; } = 1; public int FlushSeconds { get; set; } = 30; /// /// One line to a player arriving on the island (a portal, a recall, a login there): the act, /// its zones' progress, "every kill here counts", /expedition. Not repeated to the same player /// within ArrivalLineMinutes. /// public bool ArrivalLine { get; set; } = true; public double ArrivalLineMinutes { get; set; } = 2; /// Seconds between camp checks (spawn what is missing in open landblocks, remove what should not be there). public int CampCheckSeconds { get; set; } = 5; public DiscordSettings Discord { get; set; } = new(); /// Act 2 - Light the beacons: pitch, the braziers, the sieges, the light (Beacons.cs, Siege.cs). public Act2Settings Act2 { get; set; } = new(); /// /// The island's zones (the doc's table), first match wins - the lava floes first, since /// their box overlaps the east shore's and the north shore's. Rectangles are inclusive, /// hex landblock x and y. Act is the act whose goal the meter is (1 = Act 1). /// public List Zones { get; set; } = new() { new() { Name = "Lava Floes", Act = 4, MeterTarget = 0, Boxes = new() { "B7-BC,ED-F1" } }, new() { Name = "West Shore", Act = 1, MeterTarget = 1500, Boxes = new() { "B3-B5,E8-EE" } }, new() { Name = "Raina's Harbor", Act = 1, MeterTarget = 1500, Boxes = new() { "B6-BB,E8-EC", "B6,ED-EE" } }, new() { Name = "East Shore", Act = 1, MeterTarget = 1500, Boxes = new() { "BC-BF,E8-EE" } }, new() { Name = "North Shore", Act = 3, MeterTarget = 0, Boxes = new() { "B3-BF,EF-F4" } }, }; /// /// What a reclaimed zone gains. Spots are where the island variant removed retail quest /// NPCs (walkable ground people already stood on): the Ruined Watcher (West Shore), Smith /// Ejan in Relic Town (the Harbor), the Devastated Watcher (East Shore). /// public List Camps { get; set; } = new() { // West Shore: the Ruined Watcher's spot (31968, removed by the island variant). new() { Zone = "West Shore", Wcid = 3000122, Label = "Maren Tolle, Settler of the West Shore", Cell = "0xB3EC001D", X = 93.4f, Y = 110.7f, Z = -0.09f, W = 0.7342f, Qz = 0.6789f }, new() { Zone = "West Shore", Wcid = 4128, Label = "West Shore campfire", Cell = "0xB3EC0025", X = 96.4f, Y = 110.7f, Z = -0.09f }, new() { Zone = "West Shore", Wcid = 3000125, Label = "West Shore portal to Holtburg", Cell = "0xB3EC001D", X = 93.4f, Y = 114.7f, Z = -0.09f, W = 0.7342f, Qz = 0.6789f }, // Raina's Harbor: Smith Ejan's spot in Relic Town (7401, removed by the island variant). new() { Zone = "Raina's Harbor", Wcid = 3000123, Label = "Idra Fairlee, Settler of Raina's Harbor", Cell = "0xB8EB002B", X = 136.7f, Y = 61.4f, Z = 14.0f, W = -0.6865f, Qz = 0.7271f }, new() { Zone = "Raina's Harbor", Wcid = 4128, Label = "Relic Town campfire", Cell = "0xB8EB002B", X = 139.7f, Y = 61.4f, Z = 14.0f }, new() { Zone = "Raina's Harbor", Wcid = 3000125, Label = "Relic Town portal to Holtburg", Cell = "0xB8EB002B", X = 136.7f, Y = 65.4f, Z = 14.0f, W = -0.6865f, Qz = 0.7271f }, // East Shore: the Devastated Watcher's spot (31967, removed by the island variant). new() { Zone = "East Shore", Wcid = 3000124, Label = "Jheni Sarrow, Settler of the East Shore", Cell = "0xBFEC001C", X = 82.1f, Y = 93.4f, Z = -0.09f, W = -0.8702f, Qz = 0.4927f }, new() { Zone = "East Shore", Wcid = 4128, Label = "East Shore campfire", Cell = "0xBFEC001C", X = 85.1f, Y = 93.4f, Z = -0.09f }, new() { Zone = "East Shore", Wcid = 3000125, Label = "East Shore portal to Holtburg", Cell = "0xBFEC001D", X = 82.1f, Y = 97.4f, Z = -0.09f, W = -0.8702f, Qz = 0.4927f }, // Act 2 - the lighthouses (Beacon instead of Zone). Spots are open ground found with // /expedition-admin ground on staging (2026-10-01): the West Light's centre is about // B8E8 [120 192], the East Light's BAE8 [96 168]. The Lightkeeper and brazier stand from // Act 2 on; the flame (retail Campfire 4128, in the brazier) and the portal to the crater // rim only once that beacon burns. new() { Beacon = "West Light", Wcid = 3000128, Label = "Aldric Venn, Lightkeeper of the West Light", Cell = "0xB8E80030", X = 135.7f, Y = 186.5f, Z = 13.20f, W = 0.8156f, Qz = 0.5786f }, new() { Beacon = "West Light", Wcid = 3000127, Label = "West Light brazier", Cell = "0xB8E80030", X = 130.9f, Y = 184.9f, Z = 14.14f }, new() { Beacon = "West Light", WhenLit = true, Wcid = 4128, Label = "West Light flame", Cell = "0xB8E80030", X = 130.9f, Y = 184.9f, Z = 14.14f }, new() { Beacon = "West Light", WhenLit = true, Wcid = 3000130, Label = "West Light portal to the crater rim", Cell = "0xB8E80030", X = 120.5f, Y = 183.0f, Z = 17.85f, W = 0.8156f, Qz = 0.5786f }, new() { Beacon = "East Light", Wcid = 3000129, Label = "Sefa Marrow, Lightkeeper of the East Light", Cell = "0xBAE8001F", X = 80.9f, Y = 156.7f, Z = 17.56f, W = 0.9239f, Qz = -0.3827f }, new() { Beacon = "East Light", Wcid = 3000127, Label = "East Light brazier", Cell = "0xBAE8001F", X = 92.7f, Y = 150.6f, Z = 17.15f }, new() { Beacon = "East Light", WhenLit = true, Wcid = 4128, Label = "East Light flame", Cell = "0xBAE8001F", X = 92.7f, Y = 150.6f, Z = 17.15f }, new() { Beacon = "East Light", WhenLit = true, Wcid = 3000130, Label = "East Light portal to the crater rim", Cell = "0xBAE80027", X = 116.2f, Y = 152.9f, Z = 19.22f, W = 0.9239f, Qz = -0.3827f }, }; 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)) return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? new Settings(); 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); } } } public class ZoneSetting { public string Name { get; set; } = ""; /// The act this zone's meter belongs to. public int Act { get; set; } = 1; /// Points to reclaim it; 0 = no meter (kills there still earn contribution). public double MeterTarget { get; set; } /// "B3-B5,E8-EE": landblock x from-to, y from-to (hex, inclusive); "B6,ED-EE" for one column. public List Boxes { get; set; } = new(); } public class CampObject { /// The zone whose reclaiming puts this object up. public string Zone { get; set; } = ""; /// /// Act 2: instead of a zone, a beacon (Act2.Beacons by name). The object stands from Act 2 /// on - the Lightkeeper and the brazier - or, with WhenLit, only once that beacon burns (the /// flame and the portal to the crater rim). Zone is ignored when Beacon is set. /// public string Beacon { get; set; } = ""; public bool WhenLit { get; set; } public uint Wcid { get; set; } /// For logs and the status command. public string Label { get; set; } = ""; /// Outdoor cell, as a teleloc: "0xB3EC001D". public string Cell { get; set; } = ""; public float X { get; set; } public float Y { get; set; } public float Z { get; set; } /// Facing as a quaternion about Z: W and Z (a teleloc's first and last angle). public float W { get; set; } = 1; public float Qz { get; set; } } public class DiscordSettings { /// Webhook, set by set-webhook.sh in the deployed file; never in source. public string WebhookUrl { get; set; } = ""; public string Username { get; set; } = "Tenkarrdun"; /// notify-discord.sh channel key: island news goes to #invasions (Tom, 2026-09-27). public string Channel { get; set; } = "invasions"; public string NotifyScript { get; set; } = "~/notify-discord.sh"; } /// /// Act 2 - Light the beacons (the design doc, 2026-09-27; built 2026-10-01). Every number is a /// starting point to tune once real players fill the braziers. /// /// PitchWcid Ember Pitch (3000126), the fuel /// PitchPerBeacon what each brazier needs before its Lightkeeper will light it (200) /// ContributionPerPitch contribution per pitch handed in (the doc: 5) /// PitchDrops island fire elementals (by name) and what their corpses carry - /// only while Act 2 runs and a beacon is still dark /// SettlerWcids / SettlerPitch / SettlerHours a camp's settler spares SettlerPitch to a /// character once every SettlerHours, in Act 2 /// Beacons the two lighthouses: name, centre (map coordinates - the siege's /// heart and the light's reach), Lightkeeper wcid /// FailBurnShare a siege lost burns this share of that brazier's pitch (the doc: half) /// LightRadius / RegenPercent / RegenEverySeconds a lit beacon's light: on reclaimed ground /// within LightRadius metres, health and stamina rise by RegenPercent /// of their maximum every RegenEverySeconds /// ContributionPer100Damage siege damage counts for contribution (the doc: 1 per 100) /// public class Act2Settings { public uint PitchWcid { get; set; } = 3000126; public double PitchPerBeacon { get; set; } = 200; public double ContributionPerPitch { get; set; } = 5; public List PitchDrops { get; set; } = new() { new() { Name = "Ember", Chance = 1.0, Min = 1, Max = 1 }, new() { Name = "Fire Wisp", Chance = 1.0, Min = 1, Max = 1 }, new() { Name = "Firestorm", Chance = 1.0, Min = 1, Max = 2 }, new() { Name = "Hellfire", Chance = 1.0, Min = 2, Max = 3 }, }; public List SettlerWcids { get; set; } = new() { 3000122, 3000123, 3000124 }; public int SettlerPitch { get; set; } = 10; public double SettlerHours { get; set; } = 20; public List Beacons { get; set; } = new() { new() { Name = "West Light", Center = "84.4N 45.7E", Lightkeeper = 3000128 }, new() { Name = "East Light", Center = "84.3N 47.2E", Lightkeeper = 3000129 }, }; public double FailBurnShare { get; set; } = 0.5; public double LightRadius { get; set; } = 250; public double RegenPercent { get; set; } = 1.0; public double RegenEverySeconds { get; set; } = 5; public double ContributionPer100Damage { get; set; } = 1; public SiegeSettings Siege { get; set; } = new(); } public class BeaconSetting { public string Name { get; set; } = ""; /// Map coordinates of the lighthouse ("84.4N 45.7E"): the siege's centre and the light's reach. public string Center { get; set; } = ""; /// The NPC who takes the pitch and lights the brazier (a camp object with this Beacon). public uint Lightkeeper { get; set; } } public class PitchDrop { /// The creature's name, exactly ("Ember"). public string Name { get; set; } = ""; public double Chance { get; set; } = 1.0; public int Min { get; set; } = 1; public int Max { get; set; } = 1; } /// /// A beacon siege - Aeshnidae.Invasions' engine, copied (Siege.cs) and aimed at a lighthouse on /// the island copy: CountdownSeconds after the Lightkeeper is asked, a band every /// BandEverySeconds from a random side for SiegeMinutes, then the leader, who must die within /// LeaderMinutes. Bands grow with the players within PlayerRadius. StrengthPercent raises every /// invader's six attributes and health (the dungeon copies' rule, visible in appraisal); 0 = retail. /// public class SiegeSettings { public double CountdownSeconds { get; set; } = 30; public double SiegeMinutes { get; set; } = 8; public double BandEverySeconds { get; set; } = 20; public int MaxStanding { get; set; } = 20; public double LeaderMinutes { get; set; } = 5; public double SpawnMin { get; set; } = 40; public double SpawnMax { get; set; } = 70; public double MarchRadius { get; set; } = 12; public double BandSpreadDegrees { get; set; } = 25; public double PlayerRadius { get; set; } = 150; public double StrengthPercent { get; set; } = 0; public bool MarchRun { get; set; } = true; public double MarchSpeed { get; set; } = 0.6; public double MarchDelaySeconds { get; set; } = 2; public double LifespanMinutes { get; set; } = 30; public double UpdateEverySeconds { get; set; } = 90; public string ArrivalEffect { get; set; } = "PortalExit"; public string LeaderEffect { get; set; } = "PortalStorm"; public string DefenderEffect { get; set; } = "WeddingBliss"; /// Damage under this does not count as having held the light (for the win line). public double MinDefenderDamage { get; set; } = 25; /// What the flame draws: wisps and wraiths (retail, level 115-135). public List Bands { get; set; } = new() { new() { Weight = 1, Spawns = new() { new() { Wcid = 28055, Count = 2, PerExtraPlayer = 1 }, new() { Wcid = 7127, Count = 1, PerExtraPlayer = 0.5 } } }, new() { Weight = 1, Spawns = new() { new() { Wcid = 23091, Count = 2, PerExtraPlayer = 1 }, new() { Wcid = 28055, Count = 1 } } }, new() { Weight = 1, Spawns = new() { new() { Wcid = 7127, Count = 2, PerExtraPlayer = 1 }, new() { Wcid = 23091, Count = 1, PerExtraPlayer = 0.5 } } }, }; /// The leader and its escort: a Consumed Wraith (retail, level 160) and Shadow Wraiths. public List Leader { get; set; } = new() { new() { Wcid = 27429, Count = 1, Leader = true }, new() { Wcid = 23091, Count = 2, PerExtraPlayer = 1 }, }; } public class SiegeBand { public double Weight { get; set; } = 1; public List Spawns { get; set; } = new(); } public class SiegeSpawn { public uint Wcid { get; set; } public int Count { get; set; } = 1; public double PerExtraPlayer { get; set; } public bool Leader { get; set; } }