namespace Aeshnidae.Expedition;
///
/// /expedition for everyone, anywhere (people off the island can follow along), and
/// /expedition-admin for staff and the console. The client renders an embedded newline as a
/// music note, so every multi-line reply goes out one line per SendMessage.
///
public static class Commands
{
[CommandHandler("expedition", AccessLevel.Player, CommandHandlerFlag.None, 0,
"The Old Ring of Tenkarrdun: the act, each zone's progress, and your part in this cycle.",
"/expedition")]
public static void HandleExpedition(Session session, params string[] parameters)
{
if (!Mod.Settings.Enabled)
{
Reply(session, "The Old Ring of Tenkarrdun is not running.");
return;
}
var sb = new StringBuilder();
sb.AppendLine($"The Old Ring of Tenkarrdun - cycle {Expedition.Cycle}, {ActName(Expedition.Act)}");
foreach (var zone in Zones.All.Where(z => z.MeterTarget > 0))
{
var pct = Expedition.Percent(zone);
var state = Expedition.IsReclaimed(zone) ? "reclaimed - settlers' camp up" : zone.Act == Expedition.Act ? $"{pct}%" : $"{pct}% (not yet in play)";
sb.AppendLine($" {zone.Name}: {Bar(pct)} {state}");
}
if (Expedition.Act >= 2)
{
var target = Mod.Settings.Act2.PitchPerBeacon;
foreach (var beacon in Beacons.All)
{
var pitch = Expedition.PitchIn(beacon.Name);
var state = Expedition.IsLit(beacon.Name) ? "burning - its light and the way to the crater rim are open"
: Siege.Current?.Beacon == beacon ? "UNDER SIEGE - hold the light!"
: pitch >= target ? "brazier full - ask its Lightkeeper to light it"
: $"{pitch:0}/{target:0} Ember Pitch";
sb.AppendLine($" {beacon.Name}: {(Expedition.IsLit(beacon.Name) ? Bar(100) : Bar((int)(100 * pitch / Math.Max(1, target))))} {state}");
}
}
sb.AppendLine(Expedition.Act switch
{
1 => "Goal: reclaim the three shores. Every monster killed on the Aerlinthe Island (Instance) counts for the zone it falls in.",
2 => "Goal: light the two lighthouses at the harbour mouth. Embers, Fire Wisps, Firestorms and Hellfire carry Ember Pitch; the settlers spare some each day. " +
"Give it to a lighthouse's Lightkeeper; when a brazier is full, ask the Lightkeeper to light it - and hold the light.",
_ => "The next act is still being built.",
});
if (session?.Player is { } player)
{
var mine = Expedition.ContributionOf(player.Guid.Full);
sb.AppendLine(mine is null
? "You have no part in this cycle yet. The Aerlinthe Island (Instance) portal is in Holtburg square."
: $"Your part this cycle: {mine.Points:N0} contribution from {mine.Kills:N0} kill(s).");
}
var top = Expedition.Top(3);
if (top.Count > 0)
sb.AppendLine("Leading: " + string.Join(", ", top.Select(c => $"{c.Name} {c.Points:N0}")));
Reply(session, sb.ToString());
}
[CommandHandler("expedition-admin", AccessLevel.Admin, CommandHandlerFlag.None, 0,
"The Old Ring of Tenkarrdun, for staff: status, and the console tests. Console-safe.",
"/expedition-admin status: binding, cycle, meters, camps, thinning, database\n" +
"/expedition-admin set test: set a zone's meter (tells and reclaims as kills would)\n" +
"/expedition-admin newcycle test: end this cycle, start the next at Act 1, 0% everywhere\n" +
"/expedition-admin camps every camp object and whether it stands\n" +
"/expedition-admin act test: jump to an act (Act 2 = the beacons)\n" +
"/expedition-admin pitch test: set a brazier's Ember Pitch\n" +
"/expedition-admin light test: start the siege now, as the Lightkeeper would\n" +
"/expedition-admin unlight test: put a beacon out again\n" +
"/expedition-admin siege [stop|skip|smite [n]] the siege now; stop (nothing burns), send the leader now, or kill invaders (testing)\n" +
"/expedition-admin ground [min max] open ground near map coordinates (for placing things)\n" +
"/expedition-admin density each open island landblock: monsters standing, profile caps (and retail)\n" +
"/expedition-admin reload re-read Settings.json (zones, camps, numbers)")]
public static void HandleAdmin(Session session, params string[] parameters)
{
var verb = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : "";
switch (verb)
{
case "":
Reply(session, Status());
return;
case "set":
if (parameters.Length < 3 || !double.TryParse(parameters[^1], out var pct))
{
Reply(session, "/expedition-admin set , e.g. set West Shore 100");
return;
}
var name = string.Join(" ", parameters.Skip(1).Take(parameters.Length - 2));
if (Zones.Named(name) is not { } zone)
{
Reply(session, $"No zone '{name}'. Zones: {string.Join(", ", Zones.All.Select(z => z.Name))}");
return;
}
Reply(session, Expedition.SetPercent(zone, pct));
return;
case "newcycle":
Reply(session, Expedition.NewCycle());
return;
case "camps":
var sb = new StringBuilder();
foreach (var (index, spec, standing) in Camps.Report())
sb.AppendLine($" [{index}] {spec.Label} (wcid {spec.Wcid}, {spec.Zone}) at {spec.Cell} [{spec.X} {spec.Y} {spec.Z}]: " +
(standing is null ? "not standing" : $"standing, 0x{standing.Guid.Full:X8} {standing.Location?.ToLOCString()}"));
sb.AppendLine($" since load: {Camps.Spawned} spawned, {Camps.Removed} removed, {Camps.Failed} failed");
Reply(session, sb.ToString());
return;
case "act":
if (parameters.Length < 2 || !int.TryParse(parameters[1], out var act))
{
Reply(session, "/expedition-admin act <1-4>");
return;
}
Reply(session, Expedition.SetAct(act));
return;
case "pitch":
if (parameters.Length < 3 || !double.TryParse(parameters[^1], out var amount) || Beacons.Named(string.Join(" ", parameters.Skip(1).Take(parameters.Length - 2))) is not { } pb)
{
Reply(session, $"/expedition-admin pitch - beacons: {string.Join(", ", Beacons.All.Select(b => b.Name))}");
return;
}
Reply(session, Expedition.SetPitch(pb, amount));
return;
case "light":
case "unlight":
if (Beacons.Named(string.Join(" ", parameters.Skip(1))) is not { } lb)
{
Reply(session, $"/expedition-admin {verb} - beacons: {string.Join(", ", Beacons.All.Select(b => b.Name))}");
return;
}
if (verb == "unlight")
{
Reply(session, Expedition.Unlight(lb));
return;
}
Siege.Enqueue(() => Reply(session, $"light: {Siege.Begin(lb, session?.Player?.Name ?? "the console")}"));
Reply(session, "queued for the world thread");
return;
case "siege":
var what = parameters.Length > 1 ? parameters[1].ToLowerInvariant() : "";
if (what == "stop")
Siege.Enqueue(() => Reply(session, Siege.Stop(session?.Player?.Name ?? "the console")));
else if (what == "skip")
Siege.Enqueue(() => Reply(session, Siege.Skip()));
else if (what == "smite")
Siege.Enqueue(() => Reply(session, Siege.Smite(parameters.Length > 2 && int.TryParse(parameters[2], out var k) ? k : null)));
else
Reply(session, Siege.Describe());
return;
case "density":
Siege.Enqueue(() => Reply(session, $"density x{Mod.Settings.SpawnMultiplier}:\n" + Density.Report()));
return;
case "ground":
Ground(session, parameters.Skip(1).ToArray());
return;
case "reload":
Mod.ReloadSettings();
WorldManager.EnqueueAction(new ActionEventDelegate(() =>
ModManager.Log($"[{Mod.Name}] density x{Mod.Settings.SpawnMultiplier}: {Density.ApplyToOpen()} open generator(s) brought to it")));
Reply(session, $"settings re-read: {Zones.All.Count} zone(s), {Mod.Settings.Camps?.Count ?? 0} camp object(s); {Island.Status}");
return;
default:
Reply(session, "/expedition-admin [set|newcycle|camps|act|pitch|light|unlight|siege|ground|density|reload] - see /help expedition-admin");
return;
}
}
private static string Status()
{
var sb = new StringBuilder();
sb.AppendLine($"Expedition {(Mod.Settings.Enabled ? "ON" : "off")}; database {(ExpeditionDb.Ready ? "ready" : "NOT ready")}; at load: {Expedition.LoadStatus}");
sb.AppendLine($" island: region '{Mod.Settings.Region}' copy {Mod.Settings.Copy} - {Island.Status}; {Island.OpenBlocks().Count} landblock(s) open, {Island.Players().Count} player(s) on it");
sb.AppendLine($" cycle {Expedition.Cycle}, act {Expedition.Act} ({ActName(Expedition.Act)}), started {Expedition.StartedAt:yyyy-MM-dd HH:mm} UTC; {Expedition.Contributors} contributor(s)");
foreach (var zone in Zones.All)
sb.AppendLine($" {zone.Name} (act {zone.Act}): {Expedition.Meter(zone):0.#}/{zone.MeterTarget:0} = {Expedition.Percent(zone)}%{(Expedition.IsReclaimed(zone) ? ", RECLAIMED" : "")}, {Zones.LandblocksOf(zone).Count()} landblock(s)");
sb.AppendLine($" since load: {Expedition.KillsSinceLoad} kill(s) counted, {Thinning.Culled} culled, {Thinning.Held} spawn(s) held back, {Camps.Spawned} camp object(s) spawned; ticks {Patches.Ticks}");
foreach (var beacon in Beacons.All)
sb.AppendLine($" {beacon.Name} (centre {beacon.Center}, Lightkeeper {beacon.Lightkeeper}): {Expedition.PitchIn(beacon.Name):0}/{Mod.Settings.Act2.PitchPerBeacon:0} pitch, " +
$"{(Expedition.IsLit(beacon.Name) ? "LIT" : "dark")}, {Expedition.SiegesLost(beacon.Name)} siege(s) lost");
sb.AppendLine($" act 2 since load: {Beacons.PitchDropped} pitch dropped, {Beacons.PitchGiven} given to Lightkeepers, {Beacons.Handouts} settler handout(s), {Beacons.RegenTicks} light tick(s); siege: {Siege.Describe()}");
sb.AppendLine($" density: x{Mod.Settings.SpawnMultiplier} - {Density.Generators} generator(s), {Density.Profiles} monster profile(s) multiplied since load");
sb.AppendLine($" Discord: {Discord.LastResult}");
return sb.ToString();
}
///
/// /expedition-admin ground N E [min max]: open, walkable ground near map coordinates - a
/// ring min..max metres out (default 5-30), the spots ACE's own placement would accept. For
/// pinning Settings.Camps spots and a portal's destination. World thread (reads the landscape).
///
private static void Ground(Session? session, string[] args)
{
// "84.4N 45.7E 10 30": the first two words are the coordinates, the rest the ring.
var text = string.Join(" ", args.Take(2));
var rest = args.Skip(2).Select(a => double.TryParse(a, out var d) ? d : -1).Where(d => d >= 0).ToList();
if (Placement.ParseMapCoords(text) is not { } center)
{
Reply(session, "/expedition-admin ground [min max], e.g. ground 84.4N 45.7E 10 30");
return;
}
var min = rest.Count > 0 ? rest[0] : 5;
var max = rest.Count > 1 ? rest[1] : 30;
Siege.Enqueue(() =>
{
var sb = new StringBuilder($"ground near {Placement.MapText(center)}, {min:0}-{max:0} m:\n");
var seen = new HashSet();
for (var i = 0; i < 40 && seen.Count < 6; i++)
{
if (Placement.RingSpot(center, min, max, tries: 1) is not { } spot)
continue;
var line = $" 0x{spot.Cell:X8} [{spot.PositionX:0.0} {spot.PositionY:0.0} {spot.PositionZ:0.00}] - {Placement.MapText(Placement.Global(spot) ?? center)}, {Placement.Distance(spot, center):0} m out";
if (seen.Add(line))
sb.AppendLine(line);
}
if (seen.Count == 0)
sb.AppendLine(" nothing walkable there - try a wider ring");
Reply(session, sb.ToString());
});
}
private static string ActName(int act) => act switch
{
1 => "Act 1: Ashfall",
2 => "Act 2: Light the beacons",
3 => "Act 3: The Old Ring",
4 => "Act 4: Tenkarrdun wakes",
_ => $"Act {act}",
};
private static string Bar(int percent)
{
var filled = Math.Clamp(percent / 10, 0, 10);
return "[" + new string('#', filled) + new string('-', 10 - filled) + "]";
}
private static void Reply(Session? session, string message)
{
foreach (var line in (message ?? "").Split('\n'))
{
var text = line.TrimEnd('\r');
if (string.IsNullOrWhiteSpace(text))
continue;
if (session?.Player is null)
ModManager.Log(text);
else
session.Player.SendMessage(text);
}
}
}