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
28 changes: 23 additions & 5 deletions ESPIONAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ uses exact values. Other figures are hidden or shown as intelligence ranges.
Members retain exact information about their own faction and guilds. This covers
in-game menus; website exports and staff administration commands are unchanged.

A faction without an eligible, living Spymaster reveals exact information to
everyone, including its guild menus, complete rosters, ledgers and wealth rankings.
No daily estimates are generated for an unguarded faction. This takes effect when
menus are reopened, including after removal, permanent character death or a solo
leader becoming ineligible. Appointing an eligible Spymaster restores the usual
intelligence checks, even if their aptitude is 0. Public viewing does not grant
management authority or access to private sabotage settings. Staff alone retain
the additional Minecraft account names provided by their bypass permission.

Without an eligible Spymaster, a faction also receives no daily reports about
protected foreign factions. Previously cached reports become unreadable immediately;
private fields remain Unknown and the menu says "Report quality: Absent" with
"The Spymaster's office stands vacant; no findings reach your court."
Unguarded foreign factions and normally public information remain visible.

## Staff viewing permission

Set the permission node in `plugins/SimpleFactions/special-positions.yml`:
Expand All @@ -31,8 +46,8 @@ For the staff track (`staff_player` → `staff_inactive` → `staff`), grant the
explicitly to `staff` and deny it for `staff_inactive`; explicit denials inherited
from lower ranks override a generic `*` grant. On dev the active grant uses
`server=dev` so it does not change permissions on other servers.
Test promotion/demotion on a foreign faction: own faction information stays
exact at every rank. Reopen the menu after a rank change.
Test promotion/demotion on a foreign faction with an eligible Spymaster: own and
unguarded faction information stays exact at every rank. Reopen the menu after a rank change.

## Spymaster appointment

Expand Down Expand Up @@ -120,13 +135,14 @@ existing permanent aptitudes are retained.
## Daily intelligence and rankings

Any `/faction` or `/guild` command that opens a GUI requests the faction's
reports for every foreign faction, including list, menu, positions, espionage
reports for protected foreign factions when your own Spymaster is eligible, including list, menu, positions, espionage
and Spymaster settings commands.
The first member to open a GUI by command that UTC day triggers the checks; all faction members
share the reports. Faction/guild clicks, sorting and periodic GUI refreshes only read cached
reports. They never roll or send intelligence messages. Commands that do not
open a menu never gather intelligence.
Uncached information stays hidden until a faction member opens a GUI by command.
Protected uncached information stays hidden until a faction member with an eligible
Spymaster opens a GUI by command. Unguarded faction information is always exact.
One roleplay notice goes to the requesting player when new reports are created.

Days run from midnight to midnight **UTC**. Each faction rolls offense and defense
Expand All @@ -140,7 +156,9 @@ margin = observer.offense - target.defense

Averaged luck makes a full 0-versus-100 upset exceptionally rare (roughly 0.002% with defaults), while smaller aptitude gaps can still be overcome. Luck spread, draw count and aptitude multiplier are configurable. Each ordered
observer/target pair has one snapshot per day. Reopening, replacing Spymasters,
sabotage changes and restarts do not reroll the day's intelligence. Changes affect
sabotage changes and restarts do not reroll the day's intelligence. A vacant office
immediately disables its faction's reports and exposes its own information;
refilling it restores access to any still-current cached reports. Other changes affect
the next uncached rolls. A recreated target faction receives a fresh report.

| Margin | Intelligence | Approximate range width | Known non-leader members |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,15 @@ public static boolean bypasses(Player viewer) {

/** Viewing permission does not grant membership or authority over faction offices. */
public static boolean canViewExact(Player viewer, Faction target) {
return target != null && (bypasses(viewer) || isOwn(viewer, target));
return viewer != null && target != null
&& (bypasses(viewer) || isOwn(viewer, target) || !hasSpymaster(target));
}

/** A vacant, ineligible or deceased holder leaves every guild's information unguarded. */
public static boolean hasSpymaster(Faction faction) {
if (faction == null || faction.getEspionage() == null) return false;
var holder = faction.getEspionage().getSpymaster();
return holder != null && eligible(faction, holder.playerName) && !deadCharacter(holder);
}

public static SpecialPositionAssignment spymaster(Faction faction) {
Expand Down Expand Up @@ -310,7 +318,7 @@ public static boolean setSabotage(Player actor, Faction faction, boolean offense
public static IntelligenceReport report(Player viewer, Faction target) {
if (viewer == null || canViewExact(viewer, target)) return null;
Faction observer = FactionManager.getByMember(viewer.getName());
if (observer == null) return null;
if (!hasSpymaster(observer)) return null;
return observer.getEspionage().cachedReport(target.getId(), target.getFoundedAt(), day());
}

Expand All @@ -323,11 +331,14 @@ public static void refreshReports(Player viewer) {
if (observer == null) return;
boolean updated = false;
java.util.Set<Faction> dirty = new java.util.LinkedHashSet<>();
if (spymaster(observer, dirty) == null) {
for (Faction faction : dirty) new Database().saveFaction(faction);
return;
}
for (Faction target : FactionManager.getCopy()) {
if (target == observer || target.getId().equals(observer.getId())) continue;
if (observer.getEspionage().cachedReport(target.getId(), target.getFoundedAt(), day()) == null) {
generateReport(observer, target, dirty);
updated = true;
if (generateReport(observer, target, dirty) != null) updated = true;
}
}
for (Faction faction : dirty) new Database().saveFaction(faction);
Expand All @@ -336,8 +347,10 @@ public static void refreshReports(Player viewer) {

private static IntelligenceReport generateReport(Faction observer, Faction target, java.util.Set<Faction> dirty) {
long day = LocalDate.now(ZoneOffset.UTC).toEpochDay();
var attacker = spymaster(observer, dirty);
var defender = spymaster(target, dirty);
if (defender == null) return null; // Exact public information needs no daily estimate.
var attacker = spymaster(observer, dirty);
if (attacker == null) return null; // A vacant office cannot deliver foreign findings.
boolean[] created = {false};
RandomGenerator random = ThreadLocalRandom.current();
IntelligenceReport report = observer.getEspionage().report(target.getId(), target.getFoundedAt(), day, () -> {
Expand Down Expand Up @@ -438,8 +451,7 @@ public static int regenerateReports() {
java.util.Set<Faction> dirty = new java.util.LinkedHashSet<>(factions);
for (var observer : factions) {
for (var target : factions) if (!observer.getId().equals(target.getId())) {
generateReport(observer, target, dirty);
count++;
if (generateReport(observer, target, dirty) != null) count++;
}
}
// Persist every reset/report once, including a lone faction with no foreign targets.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@ public static void foreign(Inventory inventory, Player viewer, Faction target, I
private static ItemStack reportHeader(Player viewer, IntelligenceReport report) {
var observer = FactionManager.getByMember(viewer.getName());
var spy = observer == null ? null : observer.getEspionage().getSpymaster();
String last = spy == null || !EspionageService.eligible(observer, spy.playerName) ? "\u00a77Without a Spymaster, scattered whispers reach your court."
boolean missingSpymaster = !EspionageService.hasSpymaster(observer);
String last = missingSpymaster ? "\u00a77The Spymaster's office stands vacant; no findings reach your court."
: "\u00a77These are today\u2019s findings, delivered by your spymaster, " + CharacterNames.of(spy.playerName) + ".";
return item(Material.SPYGLASS, "Foreign intelligence",
"\u00a77Report quality: \u00a7e" + (report == null ? net.tfminecraft.simplefactions.espionage.IntelligenceTier.UNKNOWN.label() : report.qualityLabel()),
"\u00a77Report quality: \u00a7e" + (missingSpymaster ? "Absent"
: report == null ? net.tfminecraft.simplefactions.espionage.IntelligenceTier.UNKNOWN.label() : report.qualityLabel()),
report == null ? "\u00a77No dated account has yet reached your court." : "\u00a77Dated " + report.loreDate(), last);
}

Expand Down Expand Up @@ -267,6 +269,8 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana
holder != null && faction.isLeader(holder.playerName) ? "§7Solo leader: " + Math.round(EspionageConfig.soloMultiplier() * 100)
+ "% aptitude retained (base " + holder.aptitude + ")." : "§7Faction leaders are ineligible while other members belong.",
"§7Gathers foreign intelligence and guards your secrets.",
holder == null ? "\u00a7cWithout a Spymaster, all faction and guild information is public."
: "\u00a77An eligible Spymaster protects your faction and guild information.",
holder != null && holder.isHolder(viewer.getUniqueId())
? "§aClick your own head to inspect your private conduct." : "§8Private conduct is known only to the office holder.");
if (holder != null) {
Expand All @@ -283,7 +287,8 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana
faction.getEspionage().appointmentCount(SpecialPosition.SPYMASTER) == 0
? "\u00a77The founder's assignment does not use your free appointment."
: "\u00a77Unrest: -" + EspionageConfig.stabilityPenalty() + " points, fading over " + EspionageConfig.penaltyDays() + " days."));
inventory.setItem(15, item(Material.REDSTONE, "Remove Spymaster", "§7Leaves this office with 0 aptitude."));
inventory.setItem(15, item(Material.REDSTONE, "Remove Spymaster",
"\u00a7cMakes all faction and guild information public."));
}
inventory.setItem(26, manager.createBackButton(SFGUI.SPYMASTER_VIEW));
viewer.openInventory(inventory);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class CharacterNameResolutionTest {
var account = "NoActiveCharacterAccount";
var viewer = player("ForeignViewer");
var faction = mock(net.tfminecraft.simplefactions.objects.Faction.class);
EspionageTestFixtures.protect(faction);
var plugins = mock(org.bukkit.plugin.PluginManager.class);
var roleplay = mock(org.bukkit.plugin.Plugin.class);
var offline = mock(org.bukkit.OfflinePlayer.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,22 @@
import net.tfminecraft.simplefactions.utils.FactionRanker;

class EspionageBypassTest {
private void protect(Faction faction) {
var state = new EspionageState();
var holder = new SpecialPositionAssignment();
holder.playerName = "Spy";
state.appoint(holder, 50);
when(faction.getEspionage()).thenReturn(state);
when(faction.isMemberIgnoreCase("Spy")).thenReturn(true);
}

@Test
void configuredPermissionChangesOnReloadAndOwnViewsRemainExact() {
Player viewer = mock(Player.class);
when(viewer.getName()).thenReturn("Viewer");
when(viewer.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true);
Faction own = mock(Faction.class), foreign = mock(Faction.class);
protect(foreign);
when(own.isMemberIgnoreCase("Viewer")).thenReturn(true);
var config = new org.bukkit.configuration.file.YamlConfiguration();
try {
Expand Down Expand Up @@ -75,6 +85,7 @@ void revocationClosesPrivateInformationMenusAndBypassDoesNotGrantOfficeAuthority
when(staff.getName()).thenReturn("Staff");
when(staff.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true);
Faction faction = mock(Faction.class);
protect(faction);
try (var factions = mockStatic(FactionManager.class)) {
factions.when(() -> FactionManager.getByString("target")).thenReturn(faction);
assertTrue(EspionageService.canViewExact(staff, faction));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class EspionagePermissionsTest {
void outsidersOpenMaskedSubmenusWithoutBuildingExactInformation() {
Player outsider = mock(Player.class);
Faction faction = mock(Faction.class);
EspionageTestFixtures.protect(faction);
Guild guild = mock(Guild.class);
when(guild.getFaction()).thenReturn(faction);
InventoryManager manager = mock(InventoryManager.class);
Expand Down Expand Up @@ -104,10 +105,11 @@ void onlyFactionLeaderCanAppointOrRemove() {
void unaffiliatedAndOwnMembersDoNotConsumeForeignRolls() {
Player viewer = mock(Player.class);
Faction target = mock(Faction.class);
EspionageTestFixtures.protect(target);
when(viewer.getName()).thenReturn("Visitor");
try (var factions = mockStatic(FactionManager.class)) {
assertNull(EspionageService.report(viewer, target));
verify(target, never()).getEspionage();
assertNull(target.getEspionage().cachedReport("unused", 0, 0));
when(target.isMemberIgnoreCase("Visitor")).thenReturn(true);
assertNull(EspionageService.report(viewer, target));
factions.verify(() -> FactionManager.getByMember("Visitor"), times(1));
Expand All @@ -120,7 +122,7 @@ void expiredMembershipAndReplacedOfficeCloseExistingPrivateScreens() {
Faction faction = mock(Faction.class);
when(member.getName()).thenReturn("FormerSpy");
when(member.getUniqueId()).thenReturn(UUID.randomUUID());
when(faction.getEspionage()).thenReturn(new EspionageState());
EspionageTestFixtures.protect(faction);
try (var factions = mockStatic(FactionManager.class)) {
factions.when(() -> FactionManager.getByString("faction")).thenReturn(faction);
assertTrue(EspionageAccess.denied(member, new SFInventoryHolder("faction", SFGUI.MILITARY_VIEW)));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package net.tfminecraft.simplefactions.espionage;

import static org.mockito.Mockito.when;
import net.tfminecraft.simplefactions.objects.Faction;

final class EspionageTestFixtures {
private EspionageTestFixtures() {}

static void protect(Faction faction) {
var state = new EspionageState();
var holder = new SpecialPositionAssignment();
holder.playerName = "Spy";
state.appoint(holder, 50);
when(faction.getEspionage()).thenReturn(state);
when(faction.isMemberIgnoreCase("Spy")).thenReturn(true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ private Faction faction(String id) {
when(faction.getId()).thenReturn(id);
when(faction.getFoundedAt()).thenReturn(1L);
when(faction.getEspionage()).thenReturn(new EspionageState());
var holder = new SpecialPositionAssignment();
holder.playerName = "Spy";
faction.getEspionage().appoint(holder, 50);
when(faction.isMemberIgnoreCase("Spy")).thenReturn(true);
when(faction.getMembers()).thenReturn(List.of());
when(faction.getGuildHandler().getGuilds()).thenReturn(List.of());
when(faction.getMilitary().getRegiments()).thenReturn(List.of());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class ReportPresentationTest {

@Test void onlyMaskedHoldersAllowForeignAccessToPreviouslyPrivateMenus() {
var outsider = mock(Player.class); var faction = mock(Faction.class);
EspionageTestFixtures.protect(faction);
try (var factions = mockStatic(net.tfminecraft.simplefactions.managers.FactionManager.class)) {
factions.when(() -> net.tfminecraft.simplefactions.managers.FactionManager.getByString("f")).thenReturn(faction);
var holder = new net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder("f", net.tfminecraft.simplefactions.enums.SFGUI.MILITARY_VIEW);
Expand Down
Loading
Loading