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