namespace Aeshnidae.AllegianceBounty;
///
/// Every term of the allegiance bounty, in one file.
///
/// The bounty (design decided 2026-09-20): when any member of an allegiance completes a
/// listed quest, the allegiance gains a bonus entry for that quest for a day - a quest is
/// worth +2%, a kill task +0.5%. The same quest completed again by anyone in the
/// allegiance while its entry is active adds nothing; different quests stack; there is
/// no cap unless MaxPercent says so. The bonus applies to the KILL experience every
/// member receives, not to quest experience, so it cannot feed itself. Only the final
/// completion of a quest counts - the stamp the last turn-in writes with the reward -
/// never a chain flag or a kill task's counter. The list of quests and their flags is
/// Quests.json beside this file; Quests.md is the same list for people.
///
public class Settings
{
public const string FileName = "Settings.json";
public bool Enabled { get; set; } = true;
///
/// What a completed quest is worth to the allegiance, in percent of kill experience,
/// for entries in Quests.json that carry no percent of their own (the shipped file
/// writes one out per entry, so a single quest can be tuned by hand; this is the
/// default for a hand-added entry that leaves it out).
///
public double QuestPercent { get; set; } = 2.0;
/// The same for a kill task. A kill task is a fraction of an evening; a quest is the evening.
public double KillTaskPercent { get; set; } = 0.5;
/// How long an entry lives from the moment of completion.
public double HoursActive { get; set; } = 24;
///
/// A ceiling on the allegiance's raw total, in percent, before the size band is
/// applied. 0 is no ceiling: an allegiance that does forty quests in a day has forty
/// entries and the sum of them.
///
public double MaxPercent { get; set; } = 0;
///
/// The bigger the allegiance, the less each completed quest is worth (2026-09-20).
/// A quest done by one of ten is a bigger deal than one of a hundred, and a huge
/// allegiance would otherwise have every quest on the list active permanently - a
/// standing bonus that nobody in it had to do anything for. The allegiance's raw
/// total is multiplied by the band its TOTAL member count falls in (the number
/// /allegiance info shows: everyone sworn under the monarch, online or not). Bands
/// are read in order; the first whose UpToMembers is at or above the count wins, and
/// the LAST band is the fallback for any allegiance past the table's end. An empty
/// list is x1 for everyone.
///
public List SizeBands { get; set; } = new()
{
new SizeBand { UpToMembers = 10, Multiplier = 1.0 },
new SizeBand { UpToMembers = 25, Multiplier = 0.75 },
new SizeBand { UpToMembers = 50, Multiplier = 0.5 },
new SizeBand { UpToMembers = 100, Multiplier = 0.33 },
new SizeBand { UpToMembers = 0, Multiplier = 0.25 }, // 0 = everything above the last bound
};
///
/// Tell every online member when an entry is gained: " completed : the
/// allegiance's bounty is now +X% for 24 hours". Nothing is said per kill - the kill
/// line already shows the total that landed.
///
public bool AnnounceToAllegiance { get; set; } = true;
///
/// Off by design: a bonus on quest experience would pay for the quests that create
/// it, and the allegiance would be farming its own multiplier. On, the bonus also
/// applies to XpType.Quest. Left as a switch for an event.
///
public bool ApplyToQuestExperience { get; set; } = false;
///
/// A fellow's slice of a shared kill arrives as XpType.Fellowship - it is kill
/// experience received, split by ACE before it lands. On, the slice is bonused by
/// the receiving member's own allegiance exactly as their own kills are; off, only
/// experience typed Kill is, so a fellowed member would be bonused on their own
/// kills and not on their fellows', which is not how a fellowship feels from inside.
///
public bool ApplyToFellowshipShares { get; set; } = true;
/// The quest list, relative to the mod folder. Reloaded by /bounty-reload.
public string QuestsFile { get; set; } = "Quests.json";
// ------------------------------------------------------------ the personal layer
///
/// The personal layer (2026-09-20): on top of the allegiance's bounty, each character
/// earns a kill-experience bonus of its own for the quest flags it stamped today -
/// PersonalPercentPerStamp for every DISTINCT flag, up to PersonalMaxPercent. Any
/// quest flag counts (PersonalAnyQuest): quest stamps are abundant by design and the
/// per-stamp value is small, so the day's questing adds up rather than one big
/// turn-in. Each flag counts ONCE per day, so re-stamping the same cheap flag is
/// worth nothing - the guard against looping one stamp to the cap.
///
public bool PersonalEnabled { get; set; } = true;
/// Percent of kill experience per distinct quest flag stamped today.
public double PersonalPercentPerStamp { get; set; } = 0.1;
/// The ceiling on the personal bonus, in percent. 0 is no ceiling.
public double PersonalMaxPercent { get; set; } = 20;
///
/// The server-local hour the personal day resets, 0-23. 17 - five in the afternoon,
/// when people get home from work - so an evening's questing counts toward that
/// evening's hunting and is not cut in half by a midnight. The day is the
/// reset-to-reset window: a stamp at 16:59 belongs to the day that began at 17:00
/// yesterday.
///
public int PersonalResetHour { get; set; } = 17;
/// True: every quest flag written counts. False: only the flags in Quests.json.
public bool PersonalAnyQuest { get; set; } = true;
///
/// Returns the multiplier for an allegiance of the given size from SizeBands.
///
public double SizeMultiplier(int members)
{
if (SizeBands is null || SizeBands.Count == 0)
return 1.0;
foreach (var band in SizeBands)
{
if (band.UpToMembers > 0 && members <= band.UpToMembers)
return band.Multiplier;
}
return SizeBands[^1].Multiplier;
}
/// The band a count falls in, for /bounty.
public string SizeBandLabel(int members)
{
if (SizeBands is null || SizeBands.Count == 0)
return "no bands";
for (var i = 0; i < SizeBands.Count; i++)
{
var band = SizeBands[i];
if (band.UpToMembers > 0 && members <= band.UpToMembers)
return i == 0 ? $"up to {band.UpToMembers}" : $"{SizeBands[i - 1].UpToMembers + 1} to {band.UpToMembers}";
}
var last = SizeBands.Count >= 2 ? SizeBands[^2].UpToMembers : 0;
return $"above {last}";
}
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);
}
}
}
/// One row of the size table: allegiances of up to this many members get this multiplier.
public class SizeBand
{
/// Upper bound on the member count, inclusive. 0 means "everything above the previous band".
public int UpToMembers { get; set; }
public double Multiplier { get; set; } = 1.0;
}