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