Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,14 @@ public static boolean handle(Player player, String[] args) {
EspionageView.settings(player, faction, manager);
} else if (args[0].equalsIgnoreCase("positions")) EspionageView.positions(player, faction, manager);
else EspionageView.spymasterOffice(player, faction, manager);
} else if (args[0].equalsIgnoreCase("spymaster") && args.length >= 2 && !args[1].equalsIgnoreCase("sabotage")) {
} else if (args[0].equalsIgnoreCase("spymaster") && args.length == 4 && args[1].equalsIgnoreCase("share")) {
var partner = args[2].equalsIgnoreCase("overlord") ? SharingPartner.OVERLORD
: args[2].equalsIgnoreCase("vassals") ? SharingPartner.VASSALS : null;
var tier = args[3].equalsIgnoreCase("none") ? IntelligenceTier.UNKNOWN : IntelligenceTier.parse(args[3]);
if (partner == null || tier == IntelligenceTier.UNKNOWN && !args[3].equalsIgnoreCase("none")) usage(player);
else EspionageService.setSharing(player, faction, partner, tier);
} else if (args[0].equalsIgnoreCase("spymaster") && args.length >= 2 && !args[1].equalsIgnoreCase("sabotage")
&& !args[1].equalsIgnoreCase("share")) {
if (args[1].equalsIgnoreCase("remove")) {
if (args.length == 2) EspionageService.remove(player, faction);
else usage(player);
Expand All @@ -75,6 +82,7 @@ public static boolean handle(Player player, String[] args) {
private static void usage(Player player) {
player.sendMessage("§7/faction positions §8| §7/faction espionage §8| §7/faction spymaster <player|remove>");
player.sendMessage("§7/faction spymaster sabotage <offense|defense> <0|25|50|75|100>");
player.sendMessage("§7/faction spymaster share <overlord|vassals> <none|rumours|broad|reliable|detailed>");
}

public static List<String> complete(Player player, String[] args) {
Expand All @@ -90,9 +98,13 @@ public static List<String> complete(Player player, String[] args) {
if (Bukkit.getPlayerExact(name) != null && EspionageService.eligible(faction, name)) choices.add(name);
}
if (isHolder) choices.add("sabotage");
if (isHolder && EspionageConfig.sharingAllowed()) choices.add("share");
} else if (isHolder && args[1].equalsIgnoreCase("sabotage")) {
if (args.length == 3) choices.addAll(List.of("offense", "defense"));
if (args.length == 4) choices.addAll(List.of("0", "25", "50", "75", "100"));
} else if (isHolder && args[1].equalsIgnoreCase("share")) {
if (args.length == 3) choices.addAll(List.of("overlord", "vassals"));
if (args.length == 4) choices.addAll(List.of("none", "rumours", "broad", "reliable", "detailed"));
}
String prefix = args[args.length - 1];
choices.removeIf(choice -> !choice.regionMatches(true, 0, prefix, 0, prefix.length()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public final class EspionageConfig {
private static String reloadPermission = "simplefactions.espionage.reload";
private static double base = 50, extraPositionPenalty = .25, rollMultiplier = 1.25;
private static int center = 6, cap = 16, aptitudeSpread = 20, luckSpread = 75, luckDraws = 3, rosterLimit = 23;
private static int overlordOffenseBonus = 25, overlordDefenseBonus = 25;
private static boolean sharingAllowed = true;
private static final Map<IntelligenceTier, TierSettings> tiers = new java.util.EnumMap<>(IntelligenceTier.class);
private static final Map<String, IntelligenceTier> minimums = new LinkedHashMap<>();
private static final Map<String, IntelligenceTier> cashflows = new LinkedHashMap<>();
Expand Down Expand Up @@ -75,6 +77,9 @@ public static void load(ConfigurationSection config) {
luckSpread = (int) bounded(config, "espionage.checks.luck-spread", 75, 0, 1000);
luckDraws = (int) bounded(config, "espionage.checks.luck-draws", 3, 1, 20);
rosterLimit = (int) bounded(config, "espionage.intelligence.maximum-roster-size", 23, 0, 1000);
overlordOffenseBonus = (int) bounded(config, "espionage.vassalage.overlord-offense-bonus", 25, 0, 1000);
overlordDefenseBonus = (int) bounded(config, "espionage.vassalage.overlord-defense-bonus", 25, 0, 1000);
sharingAllowed = config.getBoolean("espionage.vassalage.allow-sharing", true);
tiers.clear();
int previous = -1;
for (var tier : IntelligenceTier.values()) {
Expand Down Expand Up @@ -145,6 +150,9 @@ public static boolean allows(IntelligenceTier tier, String metric) {
public static int luckSpread() { return luckSpread; }
public static int luckDraws() { return luckDraws; }
public static int rosterLimit() { return rosterLimit; }
public static int overlordOffenseBonus() { return overlordOffenseBonus; }
public static int overlordDefenseBonus() { return overlordDefenseBonus; }
public static boolean sharingAllowed() { return sharingAllowed; }

public static double stabilityPenalty() { return stabilityPenalty; }
public static double penaltyDays() { return penaltyDays; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,15 @@ public static int clamp(int value, int min, int max) {
}

public record Estimate(long lower, long upper) {
/** Shared information: one rounded value instead of a range. */
public static Estimate exact(double value) {
long rounded = Math.round(value);
return new Estimate(rounded, rounded);
}
public boolean isExact() { return lower == upper; }
public double midpoint() { return lower / 2.0 + upper / 2.0; }
public String display() {
return lower + " to " + upper;
return isExact() ? Long.toString(lower) : lower + " to " + upper;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import net.tfminecraft.simplefactions.database.Database;
import net.tfminecraft.simplefactions.managers.FactionManager;
import net.tfminecraft.simplefactions.managers.RelationManager;
import net.tfminecraft.simplefactions.objects.Faction;

public final class EspionageService {
Expand Down Expand Up @@ -359,6 +360,73 @@ public static boolean setSabotage(Player actor, Faction faction, boolean offense
return true;
}

/** Only the Spymaster opens their faction's information to its overlord or vassals, tier by tier. */
public static boolean setSharing(Player actor, Faction faction, SharingPartner partner, IntelligenceTier tier) {
SpecialPositionAssignment holder = spymaster(faction);
if (!isOwn(actor, faction) || holder == null || !holder.isHolder(actor.getUniqueId())) {
actor.sendMessage("§cOnly the Spymaster can choose what to share with allied courts.");
return false;
}
if (!EspionageConfig.sharingAllowed()) {
actor.sendMessage("§cSharing intelligence with overlords and vassals is disabled.");
return false;
}
var state = faction.getEspionage();
IntelligenceTier previous = state.sharing(partner);
// Partners rebuild today's report on their next menu, under the same daily rolls. Their saves come
// first, so a restart can never bring back a report built under the old choice.
for (Faction other : partners(faction, partner)) {
if (other.getEspionage() != null && other.getEspionage().forgetReport(faction.getId())
&& !new Database().saveFactionChecked(other)) {
actor.sendMessage("§cYour choice could not be saved. Your previous sharing remains in effect.");
return false;
}
}
state.share(partner, tier);
if (!new Database().saveFactionChecked(faction)) {
state.share(partner, previous);
actor.sendMessage("§cYour choice could not be saved. Your previous sharing remains in effect.");
return false;
}
actor.sendMessage(tier == IntelligenceTier.UNKNOWN ? "§7Your court no longer shares information with " + partner.label() + "."
: "§7Your court now shares everything up to §e" + tier.label() + "§7 with " + partner.label()
+ ". Those details reach them exactly.");
return true;
}

static java.util.List<Faction> partners(Faction faction, SharingPartner partner) {
if (partner == SharingPartner.VASSALS) return faction.getVassals();
Faction overlord = faction.getOverlord();
return overlord == null ? java.util.List.of() : java.util.List.of(overlord);
}

/** Former partners lose today's reports on each other, including anything shared. */
public static void forgetReports(Faction first, Faction second) {
forgetReport(first, second);
forgetReport(second, first);
}

private static void forgetReport(Faction observer, Faction target) {
if (observer == null || target == null || observer.getEspionage() == null) return;
if (observer.getEspionage().forgetReport(target.getId())) new Database().saveFaction(observer);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** The tier the target opened to the observer as its direct overlord or vassal. */
public static IntelligenceTier sharedTier(Faction target, Faction observer) {
if (!EspionageConfig.sharingAllowed() || target == null || observer == null || target.getEspionage() == null)
return IntelligenceTier.UNKNOWN;
if (RelationManager.isOverlord(target, observer)) return target.getEspionage().sharing(SharingPartner.OVERLORD);
if (RelationManager.isOverlord(observer, target)) return target.getEspionage().sharing(SharingPartner.VASSALS);
return IntelligenceTier.UNKNOWN;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Overlords spy better on every vassal below them and guard better against them. */
static int vassalageBonus(Faction observer, Faction target) {
if (RelationManager.isOnOverlordPath(target, observer)) return EspionageConfig.overlordOffenseBonus();
if (RelationManager.isOnOverlordPath(observer, target)) return -EspionageConfig.overlordDefenseBonus();
return 0;
}

/** Reading or clicking menus never generates intelligence. */
public static IntelligenceReport report(Player viewer, Faction target) {
if (viewer == null || canViewExact(viewer, target)) return null;
Expand Down Expand Up @@ -401,8 +469,9 @@ private static IntelligenceReport generateReport(Faction observer, Faction targe
IntelligenceReport report = observer.getEspionage().report(target.getId(), target.getFoundedAt(), day, () -> {
created[0] = true;
int margin = observer.getEspionage().rolls(day, effectiveAptitude(observer, attacker), random).offense()
- target.getEspionage().rolls(day, effectiveAptitude(target, defender), random).defense();
IntelligenceReport generated = createReport(metrics(target), margin, random);
- target.getEspionage().rolls(day, effectiveAptitude(target, defender), random).defense()
+ vassalageBonus(observer, target);
IntelligenceReport generated = createReport(metrics(target), margin, sharedTier(target, observer), random);
captureMembers(generated, target, margin, random);
captureOffices(generated, target, random);
ReportDetails.capture(generated, target);
Expand All @@ -416,20 +485,38 @@ private static IntelligenceReport generateReport(Faction observer, Faction targe
}

static java.util.List<String> sample(java.util.List<String> members, int margin, RandomGenerator random) {
return sampleFraction(members, rosterFraction(margin), random);
}

private static double rosterFraction(int margin) {
var tier = EspionageConfig.tier(margin);
return EspionageConfig.allows(tier, "roster") ? EspionageConfig.settings(tier).rosterFraction() : 0;
}

static java.util.List<String> sampleFraction(java.util.List<String> members, double fraction, RandomGenerator random) {
return prefix(shuffle(members, random), fraction);
}

private static java.util.List<String> shuffle(java.util.List<String> members, RandomGenerator random) {
java.util.List<String> shuffled = new java.util.ArrayList<>(members);
for (int i = shuffled.size() - 1; i > 0; i--) {
int other = random.nextInt(i + 1);
java.util.Collections.swap(shuffled, i, other);
}
var tier = EspionageConfig.tier(margin);
double fraction = EspionageConfig.allows(tier, "roster") ? EspionageConfig.settings(tier).rosterFraction() : 0;
return shuffled;
}

private static java.util.List<String> prefix(java.util.List<String> shuffled, double fraction) {
int count = (int) Math.floor(shuffled.size() * fraction);
return shuffled.subList(0, Math.min(EspionageConfig.rosterLimit(), count)).stream().sorted(String.CASE_INSENSITIVE_ORDER).toList();
}

static void captureMembers(IntelligenceReport report, Faction target, int margin, RandomGenerator random) {
var sample = sample(target.getMembers().stream().distinct()
.filter(name -> !target.isLeader(name)).toList(), margin, random);
var members = target.getMembers().stream().distinct().filter(name -> !target.isLeader(name)).toList();
var shuffled = shuffle(members, random);
// The rolled sample is a prefix of the shared roster, so it still holds if sharing is switched off.
var rolled = prefix(shuffled, rosterFraction(margin));
var sample = report.exact("roster") ? prefix(shuffled, 1) : rolled;
report.members = sample.stream().map(name -> CharacterNames.forForeign(name) + " §7— "
+ net.tfminecraft.simplefactions.utils.Represents.represents(target, name)).toList();
for (var guild : target.getGuildHandler().getGuilds()) {
Expand All @@ -447,7 +534,7 @@ static void captureMembers(IntelligenceReport report, Faction target, int margin
boolean knownLeader = guild.isLeader(name) && report.allows("guild-leader");
boolean knownOffice = !offices.isEmpty() && report.allows("office-holder");
if (!knownLeader && !knownOffice && (!report.allows("guild-members") || !sample.contains(name))) continue;
report.roster.add(new IntelligenceReport.RosterMember(CharacterNames.forForeign(name), guild.getId(), guild.getName(), sample.contains(name),
report.roster.add(new IntelligenceReport.RosterMember(CharacterNames.forForeign(name), guild.getId(), guild.getName(), rolled.contains(name),
knownLeader, knownOffice ? offices : java.util.List.of()));
}
}
Expand All @@ -461,18 +548,31 @@ public static Double visibleValue(Player viewer, Faction target, String metric,
}

static IntelligenceReport createReport(Map<String, Double> metrics, int margin, RandomGenerator random) {
return createReport(metrics, margin, IntelligenceTier.UNKNOWN, random);
}

static IntelligenceReport createReport(Map<String, Double> metrics, int margin, IntelligenceTier shared, RandomGenerator random) {
IntelligenceReport report = new IntelligenceReport();
report.quality = EspionageMath.quality(margin);
if (shared != IntelligenceTier.UNKNOWN) report.shared = shared.key();
metrics.forEach((key, value) -> {
if (!report.allows(key)) return;
if (!EspionageConfig.allows(report.tier(), key) && !report.exact(key)) return;
boolean signed = !IntelligenceRanges.nonnegative(key);
var estimate = EspionageMath.estimate(value, margin, signed, random);
estimate = IntelligenceRanges.reasonable(key, estimate, report.tier());
if (estimate != null) report.estimates.put(key, estimate);
if (report.exact(key)) share(report, key, value, estimate);
else if (estimate != null) report.estimates.put(key, estimate);
});
return report;
}

/** Keeps the rolled range too, for when sharing is switched off during the day. */
private static void share(IntelligenceReport report, String key, double value, EspionageMath.Estimate rolled) {
if (!Double.isFinite(value)) return;
report.estimates.put(key, EspionageMath.Estimate.exact(value));
if (rolled != null && EspionageConfig.allows(report.tier(), key)) report.unshared.put(key, rolled);
}

static void captureOffices(IntelligenceReport report, Faction target, RandomGenerator random) {
for (var office : SpecialPosition.values()) {
var holder = target.getEspionage().holder(office);
Expand All @@ -484,7 +584,8 @@ static void captureOffices(IntelligenceReport report, Faction target, RandomGene
: holder == null ? 0 : holder.aptitude;
var range = EspionageMath.estimate(aptitude, EspionageConfig.settings(report.tier()).minimumMargin(), false, random);
range = IntelligenceRanges.reasonable(key, range, report.tier());
if (range != null) report.estimates.put(key, range);
if (report.exact(key)) share(report, key, aptitude, range);
else if (range != null) report.estimates.put(key, range);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ public class EspionageState {
// Last deliberate appointment per office; starts the wait before the next one.
private Map<SpecialPosition, Long> lastAppointments = new EnumMap<>(SpecialPosition.class);

// Chosen by the Spymaster. Unknown shares nothing; older saves have no entry.
private Map<SharingPartner, IntelligenceTier> sharing = new EnumMap<>(SharingPartner.class);

public IntelligenceTier sharing(SharingPartner partner) {
var tier = sharing == null ? null : sharing.get(partner);
return tier == null ? IntelligenceTier.UNKNOWN : tier;
}

void share(SharingPartner partner, IntelligenceTier tier) {
if (sharing == null) sharing = new EnumMap<>(SharingPartner.class);
if (tier == null || tier == IntelligenceTier.UNKNOWN) sharing.remove(partner);
else sharing.put(partner, tier);
}

void pendingFounder(SpecialPosition office) { pendingFounders.add(office); }
void pendingFounder(SpecialPosition office, String characterId) {
pendingFounder(office);
Expand Down Expand Up @@ -141,6 +155,9 @@ public IntelligenceReport cachedReport(String targetId, long foundedAt, long day
return report != null && report.day == day && report.targetFoundedAt == foundedAt ? report : null;
}

/** Drops today's report on one target so the next menu rebuilds it under the same daily rolls. */
boolean forgetReport(String targetId) { return reports.remove(targetId) != null; }

public void resetReportsAndRolls() {
reports.clear();
dailyRolls = null;
Expand Down
Loading
Loading