From 2681d16f1567074398f7a6581f15d6567bdacd40 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:31:55 +0200 Subject: [PATCH 01/13] Add faction and guild espionage with configurable special offices --- ESPIONAGE.md | 257 +++++++++++ README.md | 1 + .../simplefactions/SimpleFactions.java | 9 + .../simplefactions/database/Database.java | 2 + .../simplefactions/database/FactionData.java | 2 + .../simplefactions/enums/SFGUI.java | 6 + .../espionage/CharacterAptitudes.java | 56 +++ .../espionage/CharacterNames.java | 75 ++++ .../espionage/CommandIntelligence.java | 33 ++ .../espionage/EspionageAccess.java | 46 ++ .../espionage/EspionageCommands.java | 98 +++++ .../espionage/EspionageConfig.java | 136 ++++++ .../espionage/EspionageMath.java | 60 +++ .../espionage/EspionageService.java | 386 +++++++++++++++++ .../espionage/EspionageState.java | 120 ++++++ .../espionage/IntelligenceLedger.java | 46 ++ .../espionage/IntelligenceRanges.java | 64 +++ .../espionage/IntelligenceReport.java | 56 +++ .../espionage/IntelligenceTier.java | 19 + .../espionage/SpecialPosition.java | 10 + .../espionage/SpecialPositionAssignment.java | 18 + .../espionage/SpecialPositionsConfigFile.java | 72 ++++ .../simplefactions/government/Government.java | 7 +- .../managers/CommandManager.java | 32 +- .../managers/FactionManager.java | 1 + .../managers/InventoryManager.java | 15 +- .../managers/holder/SFInventoryHolder.java | 25 +- .../managers/inventory/EspionageView.java | 398 ++++++++++++++++++ .../managers/inventory/FactionCreator.java | 75 +++- .../managers/inventory/FactionView.java | 48 ++- .../managers/inventory/GovernmentCreator.java | 6 +- .../managers/inventory/GovernmentView.java | 4 +- .../managers/inventory/GuildCreator.java | 55 ++- .../managers/inventory/GuildView.java | 16 +- .../managers/inventory/InstallationView.java | 3 + .../managers/inventory/InventoryUpdater.java | 11 + .../managers/inventory/LawView.java | 3 + .../managers/inventory/MilitaryView.java | 2 + .../simplefactions/objects/Faction.java | 11 + .../simplefactions/utils/FactionRanker.java | 56 +++ .../simplefactions/utils/TabCompletion.java | 20 +- src/main/resources/plugin.yml | 11 +- src/main/resources/special-positions.yml | 88 ++++ .../espionage/CharacterAptitudesTest.java | 60 +++ .../CharacterNameResolutionTest.java | 45 ++ .../espionage/CommandIntelligenceTest.java | 48 +++ .../espionage/EspionageBypassTest.java | 98 +++++ .../espionage/EspionageMathTest.java | 124 ++++++ .../espionage/EspionagePermissionsTest.java | 184 ++++++++ .../espionage/EspionageReloadTest.java | 53 +++ .../espionage/EspionageStateTest.java | 108 +++++ .../espionage/FactionTooltipPrivacyTest.java | 137 ++++++ .../espionage/GuildIntelligenceTest.java | 99 +++++ .../espionage/IntelligenceBrowsingTest.java | 132 ++++++ .../espionage/IntelligencePolicyTest.java | 108 +++++ .../espionage/IntelligenceRangesTest.java | 102 +++++ .../espionage/OfficeAppointmentsTest.java | 125 ++++++ .../SpecialPositionsConfigFileTest.java | 71 ++++ .../inventory/MilitaryViewDowngradeTest.java | 1 + 59 files changed, 3855 insertions(+), 99 deletions(-) create mode 100644 ESPIONAGE.md create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/CharacterAptitudes.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/CommandIntelligence.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceRanges.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceTier.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPosition.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java create mode 100644 src/main/resources/special-positions.yml create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/CharacterAptitudesTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/CommandIntelligenceTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMathTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/EspionageReloadTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/EspionageStateTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/FactionTooltipPrivacyTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/GuildIntelligenceTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java diff --git a/ESPIONAGE.md b/ESPIONAGE.md new file mode 100644 index 00000000..497ff9d0 --- /dev/null +++ b/ESPIONAGE.md @@ -0,0 +1,257 @@ +# Espionage (development) + +Foreign faction and guild menus always show public identity and flavor details: +character names of leaders, government, rank, tier, titles, settlements, culture, +religion, guild types, allies and subjects. Prestige is public and its ranking +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. + +## Staff viewing permission + +Set the permission node in `plugins/SimpleFactions/config.yml`: + +```yaml +espionage: + bypass-permission: simplefactions.espionage.bypass +``` + +After editing this setting, use `/faction reloadconfigs` in game. Missing or blank +values use the default node above. Changing the node does not assign permissions; +grant the configured node through LuckPerms. + +Grant `simplefactions.espionage.bypass` in LuckPerms to staff who need the original exact +faction and guild menus, complete rosters, ledgers and rankings. It defaults to +false and must be assigned explicitly. These viewers skip daily intelligence +gathering and its notices; their views do not generate or change faction reports. +Removing the permission restores the usual membership and intelligence checks. +The permission grants information access; faction management and private +Spymaster conduct retain their existing leader and office-holder requirements. +For the staff track (`staff_player` → `staff_inactive` → `staff`), grant the node +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 or wait for its periodic refresh after a +rank change. + +## Spymaster appointment + +Open **Special Positions** in your faction menu or use `/faction positions`, then +select **Spymaster**. This is an office directory, ready for additional positions. +Only the faction leader can appoint/remove the Spymaster. The appointee must be +an online member with an active RPCharacters character. The faction leader is +ineligible unless the faction has only one member across all its guilds. A solo +leader uses 25% of their permanent base aptitude, rounded down. If another member +joins, the leader's office becomes vacant when it is next checked. Missing or +ineligible Spymasters have aptitude 0. + +- `/faction spymaster ` appoints a member. +- `/faction spymaster remove` leaves the office vacant. +- `/faction espionage` opens the holder's private settings, or the position menu + for other members. + +Only the appointee receives the roleplay appointment letter and their aptitude +in chat. Member and candidate entries use character names and guild affiliations; +staff bypass additionally displays account names. Invitations and appointment +commands accept either full character names (including spaces) or Minecraft +account names. Only online players can be invited; matching is exact and ignores +case. Duplicate character names require the account name. + +New solo factions automatically assign the founder to the office, subject to the +existing solo-leader aptitude penalty. This automatic assignment does not consume +the first deliberate appointment. The first deliberate appointment is free and +causes no unrest. Every later appointment costs **250d** from the faction treasury +and causes **−10 stability points**, fading linearly over **7 elapsed real days**. +Removing an office holder does not reset the history. Unrest persists across +restarts, progresses during downtime, and repeated replacements stack their own +decaying penalties. Existing dev offices count as having used their first appointment. +These defaults are configurable (zero disables the corresponding cost or penalty): + +```yaml +espionage: + appointments: + repeat-cost: 250.0 + stability-penalty: 10.0 + penalty-days: 7.0 +``` + +Aptitude rolls **once per character**, independent of faction membership. Leaving, +rejoining, transferring factions, removing/reappointing, deleting a faction and +restarting do not reroll it. A server-wide registry persists these values in +`plugins/SimpleFactions/Cache/character-aptitudes.json`. Existing aptitude history +from the first dev build is imported on startup without rerolling. If the first +build gave a character multiple faction-specific values, the existing registry +wins, otherwise the first faction ID alphabetically supplies the permanent value. +Offices and daily reports remain in the existing faction JSON. + +## Configurable aptitude weights + +Edit `espionage.aptitude.attribute-weights` in SimpleFactions `special-positions.yml`, then +use `/faction reloadconfigs` in game or restart. Weights can be positive, zero or +negative. Defaults: + +```yaml +espionage: + aptitude: + attribute-weights: + intelligence: 3.0 + wisdom: 2.5 + charisma: 1.5 + dexterity: 1.0 + constitution: -2.0 + strength: -2.0 +``` + +Use permanent MMOCore attribute bases (creation, traits and allocated points; +equipment bonuses excluded), capped to 0-16 and centred at 6: + +``` +base aptitude = clamp(round(50 + sum(weight * (attribute - 6))) + + uniformInteger(-20, 20), 0, 100) +``` + +Low mental/social scores and high strength/constitution penalise aptitude. +Physical specialists with mental attributes at 6 and strength/constitution at +16 roll 0-30. Strong mental/social builds with low physical attributes can reach +100; physical builds with poor judgment can reach 0. Non-finite config weights +fall back to defaults. Config changes affect new characters' first rolls only; +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 +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. +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 +once per day: + +``` +offense = round(1.25 * effective aptitude + mean(3 uniformInteger(-75, 75))) - offensiveSabotage +defense = round(1.25 * effective aptitude + mean(3 uniformInteger(-75, 75))) - defensiveSabotage +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 +the next uncached rolls. A recreated target faction receives a fresh report. + +| Margin | Intelligence | Approximate range width | Known non-leader members | +| --- | --- | --- | --- | +| <= 0 | Unknown | None | 0% | +| 1-29 | Rumours | 300% of magnitude | 20% | +| 30-64 | Broad estimates | 150% | 40% | +| 65-99 | Reliable estimates | 40% | 60% | +| >= 100 | Detailed estimates | 20% | 80% | + +Samples are rounded down, capped at 23, and saved with the report so reopening +cannot reveal extra names. They contain character names and guild affiliations. +The faction leader is always displayed separately alongside the member-count +range, regardless of quality. Guild rosters use the same sample, filtered by +guild, and always show their guild leader. Own rosters put the leader first. + +Ranges are rounded, asymmetric snapshots containing the true value when generated. +They never collapse to an exact number, including zero. Reports cover members, +wealth, prosperity, daily net income, professional army, levies, mercenaries, +installations, stability and administrative power. Professional army counts filled +professional soldier slots, excluding equipment and mercenaries. Guild wealth, +member count, net income and trade power have separate estimates. + +Missing or unusably broad estimates display as gray **Unknown**. Counts, prosperity +and trade power cannot be negative; guild member estimates respect the configured +guild capacity. Stability estimates are limited to 0–100% and become Unknown if +they exceed their tier's configured maximum span (65 points at Broad, 50 at Reliable/Detailed). The faction tooltip shows the stability +state at the bounded range's midpoint, using the original state colors; the +Government item shows the range in that same color. Unbounded metrics become +Unknown when their interval is too broad relative to its midpoint. Wealth and +income retain meaningful negative estimates for debt and deficits. These rules +also apply when reading older cached reports, without rerolling or changing them. + +Prestige ranking uses public exact values. Wealth/member rankings and guild +rankings compare known range midpoints, with exact values for the viewer's own +faction. Unknown entries appear alphabetically after ranked known entries and have +no numeric rank. Guild income leaderboards use the same intelligence policy. +Foreign tooltips retain the same colors, spacing and field order as own entries; +foreign faction and guild views retain their familiar slot layouts. Foreign +menus allow public diplomacy and guild flavor browsing while exact +ledgers, military, government, laws, installations and upgrade menus stay private. +Private menus recheck membership before interacting or refreshing. + +Foreign ledgers show daily income, expenses, net income, and a reported accounts +menu for individual cashflows. Trade breakdowns and dividends use the same +snapshot estimates. Dividend percentages stay within 0–100%; income and expense +cashflows respect their valid signs. Missing new fields in an older report stay +Unknown until the next day's report; opening a ledger never regenerates it. +Report headings use RPCharacters' lore calendar (year offset and era), and name +the observing faction's Spymaster in their roleplay closing line. Daily report +refresh boundaries remain UTC. + +## Private sabotage + +The Spymaster can select their office and click their own head to inspect private +conduct. Offensive and defensive sabotage are separate voluntary settings, both +**disabled (0)** on appointment. Click to cycle through reductions of 0, 25, 50, +75 and 100 roll points, or use: + +``` +/faction spymaster sabotage offense <0|25|50|75|100> +/faction spymaster sabotage defense <0|25|50|75|100> +``` + +Only the office holder can see/change these controls. Preferences persist through +restarts and reset on a new appointment. Selecting 0 disables that side again. +Existing daily rolls and reports remain unchanged. + +## Development verification + +Run `mvn verify` with Java 21. Tests cover attribute weights and extremes, daily +luck, estimate bounds, caching, character aptitude transfer/persistence/migration, +leader eligibility, solo penalties, sampled rosters, rank privacy and permissions. +On dev, use `/faction menu` then compare reports from two members, test prestige +and wealth sorting, browse foreign guilds and inspect private conduct. This work +is deployed only to TFMCDev01; production changes follow the separate live process. + +## Dedicated configuration and disclosure policy + +All office/espionage settings live in `plugins/SimpleFactions/special-positions.yml`. +On first startup, old `config.yml` espionage values migrate, preserving custom +weights, costs and permission nodes. The old main file is backed up before its +espionage section is removed. Existing dedicated values always win; missing +settings are populated from defaults on startup or reload. + +`espionage.intelligence.tiers` controls margin thresholds, uncertainty, roster +fractions and useful range widths. Lower tiers show broader ranges when useful. +`espionage.intelligence.minimum-tiers` controls each field's minimum tier. +Professional army and mercenary counts, administrative power, individual +cashflows and dividends require Reliable by default. Stability, levies, +installations and ledger totals require Broad. Members and wealth can appear at +Rumours. Office identities require Reliable and office aptitude requires Detailed. +An `unknown` threshold disables that field. Invalid or unconfigured fields fail +closed. `cashflows` accepts individual enum-category overrides such as `TRADE`. +These gates apply when generating **and reading** reports, including old caches. +Roster names also obey their own tier gate; leaders remain public. + +Foreign Special Positions is a read-only report using the same daily comparison. +Office names/vacancy and aptitude ranges are saved snapshots; holder-only sabotage +choices are never included. Staff bypass can see exact foreign offices without +receiving appointment authority. All faction/guild leader labels use character +names, with Minecraft names in parentheses for bypass viewers. + +## Staff test refresh + +`/faction reloadespionage` reloads `special-positions.yml`, clears every faction's +daily offensive/defensive rolls and reports, then immediately rebuilds all foreign +reports. It works in game and from console, requires +`simplefactions.espionage.reload` (configurable via `espionage.reload-permission`, +default false), and intentionally overrides the once-per-day rule for testing. +Reopen menus afterward to see refreshed accounts. It preserves permanent aptitude, +office holders, sabotage preferences, appointment counts, treasury and unrest. +`/faction reloadconfigs` reloads settings without regenerating reports. diff --git a/README.md b/README.md index 08869b34..fd1d4976 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Players shape a world of provinces and titles, organise settlements and guilds, - **National economies** — manage wealth and taxation, including income moving through relationships between members, nations, and overlords. - **Settlements and guilds** — establish named cities and capitals and organise groups within a nation. - **Military infrastructure** — construct forts, ports, airports, and train stations with `/faction construct `. +- **Espionage** — appoint a Spymaster, protect faction secrets, and obtain shared daily intelligence estimates. See [the development espionage guide](ESPIONAGE.md). - **Campaign warfare** — pursue war goals through scheduled battles, player voting, warband participation, and campaign progression. ## A shared political world diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 6a513ac2..4761e2e5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -276,6 +276,14 @@ public void onEnable() { } net.tfminecraft.simplefactions.inactivity.InactivityService.load(); db.loadFactions(); + try { + net.tfminecraft.simplefactions.espionage.EspionageService.loadAptitudes( + getDataFolder().toPath().resolve("Cache/character-aptitudes.json")); + } catch (Exception error) { + getLogger().log(java.util.logging.Level.SEVERE, "Cannot load permanent character aptitudes; disabling SimpleFactions", error); + getServer().getPluginManager().disablePlugin(this); + return; + } getCommand(commands.cmd1).setExecutor(commands); getCommand(commands.cmd2).setExecutor(commands); getCommand(commands.cmd1).setTabCompleter(new TabCompletion()); @@ -379,6 +387,7 @@ public void onDisable() { } } public void loadConfigs() { + net.tfminecraft.simplefactions.espionage.SpecialPositionsConfigFile.load(this); configLoader.loadConfig(new File(getDataFolder(), "config.yml")); configLoader.loadWar(new File(getDataFolder(), "war.yml")); VehiclesConfigLoader.load(new File(getDataFolder(), "vehicles.yml")); diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index ff7d71aa..0f354b95 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -184,6 +184,7 @@ public void loadFactions() { } f.setFoundedAt(data.foundedAt != null ? data.foundedAt : System.currentTimeMillis()/1000L); f.setCapitalMoves(data.capitalMoves != null ? data.capitalMoves : 0); + f.setEspionage(data.espionage); if (data.settlements != null) { f.getSettlementHandler().load(data.settlements); @@ -389,6 +390,7 @@ public void saveFaction(Faction f) { data.rank = f.getRank() != null ? f.getRank().getId() : null; data.foundedAt = f.getFoundedAt(); data.capitalMoves = f.getCapitalMoves(); + data.espionage = f.getEspionage(); // --- Guild --- for (Guild g : f.getGuildHandler().getGuilds()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java b/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java index 827ec68d..e87fa243 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.database; +import net.tfminecraft.simplefactions.espionage.EspionageState; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -8,6 +9,7 @@ import com.google.gson.annotations.SerializedName; public class FactionData { + public EspionageState espionage; public String id; public String name; public String rgb; diff --git a/src/main/java/net/tfminecraft/simplefactions/enums/SFGUI.java b/src/main/java/net/tfminecraft/simplefactions/enums/SFGUI.java index b2a57843..b59c92ce 100644 --- a/src/main/java/net/tfminecraft/simplefactions/enums/SFGUI.java +++ b/src/main/java/net/tfminecraft/simplefactions/enums/SFGUI.java @@ -9,6 +9,10 @@ public enum SFGUI { FACTION_VIEW, FACTION_LIST, FACTION_GUILDS, + SPECIAL_POSITIONS, + SPYMASTER_VIEW, + SPYMASTER_SETTINGS, + SPYMASTER_SELECT, TIER_VIEW, TITLE_VIEW, TITLE_TYPE_VIEW, @@ -46,6 +50,8 @@ public enum SFGUI { FEE_CATEGORY_VIEW, FEE_VEHICLE_VIEW, LEDGER_VIEW, + FOREIGN_LEDGER_VIEW, + FOREIGN_POSITIONS_VIEW, COUNCIL_VIEW, COUNCIL_SELECT, ELECTION_VIEW, diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterAptitudes.java b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterAptitudes.java new file mode 100644 index 00000000..da957089 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterAptitudes.java @@ -0,0 +1,56 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Supplier; +import com.google.gson.reflect.TypeToken; +import net.tfminecraft.simplefactions.database.JsonUtil; + +/** Character identity, rather than faction membership, owns the permanent roll. */ +public final class CharacterAptitudes { + private final Path file; + private final Map aptitudes = new LinkedHashMap<>(); + + public CharacterAptitudes(Path file) { this.file = file; } + + public void load() throws IOException { + aptitudes.clear(); + if (!Files.exists(file)) return; + Map loaded = JsonUtil.GSON.fromJson(Files.readString(file), + new TypeToken>() {}.getType()); + if (loaded == null) throw new IOException("Empty character aptitude registry: " + file); + loaded.forEach((key, value) -> { + if (key != null && value != null) aptitudes.put(key, EspionageMath.clamp(value, 0, 100)); + }); + } + + public void remember(String characterId, int aptitude) { + if (characterId != null && !characterId.isBlank()) + aptitudes.putIfAbsent(characterId, EspionageMath.clamp(aptitude, 0, 100)); + } + + public int aptitude(String characterId, Supplier roll) throws IOException { + if (characterId == null || characterId.isBlank()) throw new IllegalArgumentException("Missing character identity"); + Integer existing = aptitudes.get(characterId); + if (existing != null) return existing; + int result = EspionageMath.clamp(roll.get(), 0, 100); + aptitudes.put(characterId, result); + try { save(); } + catch (IOException error) { aptitudes.remove(characterId); throw error; } + return result; + } + + public void save() throws IOException { + Files.createDirectories(file.getParent()); + Path pending = file.resolveSibling(file.getFileName() + ".tmp"); + Files.writeString(pending, JsonUtil.GSON.toJson(aptitudes)); + try { Files.move(pending, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } + catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(pending, file, StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java new file mode 100644 index 00000000..c028f66d --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java @@ -0,0 +1,75 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import org.bukkit.Bukkit; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.simplefactions.database.JsonUtil; +import com.google.gson.JsonObject; + +/** Offline names are read without loading or modifying RPCharacters player state. */ +public final class CharacterNames { + private record Cached(String name, long expires) {} + private static final Map cache = new HashMap<>(); + private CharacterNames() {} + + public static String display(org.bukkit.entity.Player viewer, String account) { + String character = of(account); + return EspionageService.bypasses(viewer) ? character + " \u00a77(" + account + ")" : character; + } + + /** Exact account names take precedence; ambiguous character names never pick a player arbitrarily. */ + public static org.bukkit.entity.Player resolveOnline(org.bukkit.entity.Player sender, String input) { + String query = input.strip(); + if (query.length() >= 2 && query.startsWith("\"") && query.endsWith("\"")) + query = query.substring(1, query.length() - 1).strip(); + var result = resolve(query, Bukkit.getOnlinePlayers(), player -> of(player.getName())); + if (result.ambiguous()) sender.sendMessage("\u00a7cSeveral characters share that name. Use their Minecraft name."); + else if (result.player() == null) sender.sendMessage("\u00a7cNo online player found by that character or Minecraft name."); + return result.player(); + } + + static Resolution resolve(String query, java.util.Collection players, + java.util.function.Function names) { + for (var player : players) if (player.getName().equalsIgnoreCase(query)) return new Resolution(player, false); + var matches = players.stream().filter(player -> org.bukkit.ChatColor.stripColor(names.apply(player)) + .equalsIgnoreCase(query)).toList(); + return new Resolution(matches.size() == 1 ? matches.getFirst() : null, matches.size() > 1); + } + + record Resolution(org.bukkit.entity.Player player, boolean ambiguous) {} + + public static String of(String playerName) { + if (playerName == null) return "Unknown"; + if (Bukkit.getServer() == null || !Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) return playerName; + var player = Bukkit.getPlayerExact(playerName); + if (player != null) { + var data = PlayerManager.get(player); + var character = data == null ? null : data.getActiveCharacter(); + if (character != null) return character.getName(); + } + Cached cached = cache.get(playerName); + if (cached != null && cached.expires > System.currentTimeMillis()) return cached.name; + String result = playerName; + var plugin = Bukkit.getPluginManager().getPlugin("RPCharacters"); + Path folder = plugin.getDataFolder().toPath().resolve("data/characterdata") + .resolve(Bukkit.getOfflinePlayer(playerName).getUniqueId().toString()); + if (Files.isDirectory(folder)) { + try (var files = Files.list(folder)) { + for (Path file : files.filter(path -> path.toString().endsWith(".json")).sorted().toList()) { + JsonObject json = JsonUtil.GSON.fromJson(Files.readString(file), JsonObject.class); + if (json != null && json.has("active") && json.get("active").getAsBoolean() && json.has("name")) { + result = json.get("name").getAsString(); + break; + } + } + } catch (java.io.IOException | com.google.gson.JsonParseException | IllegalStateException ignored) { + // No readable character: keep the account name as a useful fallback. + } + } + cache.put(playerName, new Cached(result, System.currentTimeMillis() + 30_000)); + return result; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/CommandIntelligence.java b/src/main/java/net/tfminecraft/simplefactions/espionage/CommandIntelligence.java new file mode 100644 index 00000000..12bdfef1 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/CommandIntelligence.java @@ -0,0 +1,33 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.function.Supplier; +import org.bukkit.entity.Player; + +/** Only a command creating a faction/guild menu can request new daily intelligence. */ +public final class CommandIntelligence { + private static final ThreadLocal current = new ThreadLocal<>(); + private static final class Context { + final Player player; + boolean gathered; + Context(Player player) { this.player = player; } + } + private CommandIntelligence() {} + + public static boolean execute(Player player, Supplier command) { + Context previous = current.get(); + current.set(new Context(player)); + try { return command.get(); } + finally { + if (previous == null) current.remove(); + else current.set(previous); + } + } + + public static void beforeMenu() { + Context context = current.get(); + if (context != null && context.player != null && !context.gathered) { + context.gathered = true; + EspionageService.refreshReports(context.player); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java new file mode 100644 index 00000000..0d5ae2f6 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java @@ -0,0 +1,46 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.EnumSet; +import java.util.Set; + +import org.bukkit.entity.Player; + +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; + +/** Recheck membership and viewing permission while a private menu is open. */ +public final class EspionageAccess { + private static final Set PRIVATE_MENUS = EnumSet.of( + SFGUI.UPGRADE_VIEW, SFGUI.LEDGER_VIEW, + SFGUI.MILITARY_VIEW, SFGUI.INSTALLATIONS_VIEW, SFGUI.INSTALLATION_DETAIL_VIEW, + SFGUI.GOVERNMENT_VIEW, SFGUI.STABILITY_VIEW, SFGUI.COUNCIL_VIEW, SFGUI.COUNCIL_SELECT, + SFGUI.LAW_VIEW, SFGUI.LAW_SELECT, SFGUI.TAX_VIEW, SFGUI.TAX_VIEW_SPECIFIC, + SFGUI.SPECIAL_POSITIONS, SFGUI.SPYMASTER_VIEW, SFGUI.SPYMASTER_SETTINGS, SFGUI.SPYMASTER_SELECT); + + private EspionageAccess() {} + + public static boolean requiresOwn(SFGUI type) { + return type != null && PRIVATE_MENUS.contains(type); + } + + public static Faction owner(SFInventoryHolder holder) { + var guild = FactionManager.getGuildByString(holder.getId()); + return guild != null ? guild.getFaction() : FactionManager.getByString(holder.getId()); + } + + public static boolean denied(Player player, SFInventoryHolder holder) { + if (!requiresOwn(holder.getType())) return false; + Faction faction = owner(holder); + boolean office = holder.getType() == SFGUI.SPECIAL_POSITIONS + || holder.getType() == SFGUI.SPYMASTER_VIEW + || holder.getType() == SFGUI.SPYMASTER_SETTINGS || holder.getType() == SFGUI.SPYMASTER_SELECT; + if (office ? !EspionageService.isOwn(player, faction) : !EspionageService.canViewExact(player, faction)) return true; + if (holder.getType() == SFGUI.SPYMASTER_SETTINGS) { + var spymaster = EspionageService.spymaster(faction); + return spymaster == null || !spymaster.isHolder(player.getUniqueId()); + } + return holder.getType() == SFGUI.SPYMASTER_SELECT && !faction.isLeader(player.getName()); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java new file mode 100644 index 00000000..0b16004c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java @@ -0,0 +1,98 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.inventory.EspionageView; + +public final class EspionageCommands { + private EspionageCommands() {} + + public static boolean reload(org.bukkit.command.CommandSender sender, String[] args) { + if (!sender.hasPermission(EspionageConfig.reloadPermission())) { + sender.sendMessage("\u00a7cYou do not have permission to refresh intelligence reports."); + return true; + } + if (args.length != 1) { + sender.sendMessage("\u00a77/faction reloadespionage"); + return true; + } + try { + SpecialPositionsConfigFile.load(net.tfminecraft.simplefactions.SimpleFactions.plugin); + int count = EspionageService.regenerateReports(); + sender.sendMessage("\u00a7aEspionage settings reloaded and " + count + " intelligence reports regenerated."); + } catch (IllegalStateException exception) { + sender.sendMessage("\u00a7cCould not reload espionage settings. Check the server log."); + net.tfminecraft.simplefactions.SimpleFactions.plugin.getLogger().log(java.util.logging.Level.SEVERE, + "Espionage reload failed", exception); + } + return true; + } + + public static boolean matches(String subcommand) { + return subcommand.equalsIgnoreCase("espionage") || subcommand.equalsIgnoreCase("positions") + || subcommand.equalsIgnoreCase("spymaster"); + } + + public static boolean handle(Player player, String[] args) { + var faction = FactionManager.getByMember(player.getName()); + if (faction == null) { + player.sendMessage("§cYou need to belong to a faction."); + return true; + } + InventoryManager manager = new InventoryManager(); + if (args.length == 1) { + var holder = EspionageService.spymaster(faction); + if (!args[0].equalsIgnoreCase("positions") && holder != null && holder.isHolder(player.getUniqueId())) { + 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")) { + if (args[1].equalsIgnoreCase("remove")) EspionageService.remove(player, faction); + else { + var candidate = CharacterNames.resolveOnline(player, String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length))); + if (candidate != null) EspionageService.appoint(player, faction, candidate); + } + } else if (args[0].equalsIgnoreCase("spymaster") && args.length == 4 && args[1].equalsIgnoreCase("sabotage") + && (args[2].equalsIgnoreCase("offense") || args[2].equalsIgnoreCase("defense"))) { + try { + EspionageService.setSabotage(player, faction, args[2].equalsIgnoreCase("offense"), Integer.parseInt(args[3])); + } catch (NumberFormatException exception) { + usage(player); + } + } else usage(player); + return true; + } + + private static void usage(Player player) { + player.sendMessage("§7/faction positions §8| §7/faction espionage §8| §7/faction spymaster "); + player.sendMessage("§7/faction spymaster sabotage <0|25|50|75|100>"); + } + + public static List complete(Player player, String[] args) { + List choices = new ArrayList<>(); + var faction = FactionManager.getByMember(player.getName()); + if (faction == null || !args[0].equalsIgnoreCase("spymaster")) return choices; + var holder = EspionageService.spymaster(faction); + boolean isHolder = holder != null && holder.isHolder(player.getUniqueId()); + if (args.length == 2) { + if (faction.isLeader(player.getName())) { + choices.add("remove"); + for (String name : faction.getMembers()) + if (Bukkit.getPlayerExact(name) != null && EspionageService.eligible(faction, name)) choices.add(name); + } + if (isHolder) choices.add("sabotage"); + } 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")); + } + String prefix = args[args.length - 1]; + choices.removeIf(choice -> !choice.regionMatches(true, 0, prefix, 0, prefix.length())); + return choices; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java new file mode 100644 index 00000000..0f550626 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java @@ -0,0 +1,136 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.bukkit.configuration.ConfigurationSection; + +public final class EspionageConfig { + public static final String DEFAULT_BYPASS_PERMISSION = "simplefactions.espionage.bypass"; + private static String bypassPermission = DEFAULT_BYPASS_PERMISSION; + public static final Map DEFAULT_WEIGHTS = Map.of( + "intelligence", 3.0, "wisdom", 2.5, "charisma", 1.5, + "dexterity", 1.0, "constitution", -2.0, "strength", -2.0); + private static Map weights = DEFAULT_WEIGHTS; + private static double repeatCost = 250; + private static double stabilityPenalty = 10; + private static double penaltyDays = 7; + private static String reloadPermission = "simplefactions.espionage.reload"; + private static double base = 50, soloMultiplier = .25, rollMultiplier = 1.25; + private static int center = 6, cap = 16, aptitudeSpread = 20, luckSpread = 75, luckDraws = 3, rosterLimit = 23; + private static final Map tiers = new java.util.EnumMap<>(IntelligenceTier.class); + private static final Map minimums = new LinkedHashMap<>(); + private static final Map cashflows = new LinkedHashMap<>(); + private static final Map DEFAULT_MINIMUMS = Map.ofEntries( + Map.entry("members", IntelligenceTier.RUMOURS), Map.entry("roster", IntelligenceTier.RUMOURS), + Map.entry("wealth", IntelligenceTier.RUMOURS), Map.entry("prosperity", IntelligenceTier.BROAD), + Map.entry("stability", IntelligenceTier.BROAD), Map.entry("administrative-power", IntelligenceTier.RELIABLE), + Map.entry("professional-army", IntelligenceTier.RELIABLE), Map.entry("levies", IntelligenceTier.BROAD), + Map.entry("mercenaries", IntelligenceTier.RELIABLE), Map.entry("installations", IntelligenceTier.BROAD), + Map.entry("net-income", IntelligenceTier.BROAD), Map.entry("trade-power", IntelligenceTier.BROAD), + Map.entry("income-total", IntelligenceTier.BROAD), Map.entry("expense-total", IntelligenceTier.BROAD), + Map.entry("cashflow", IntelligenceTier.RELIABLE), Map.entry("dividend-rate", IntelligenceTier.RELIABLE), + Map.entry("dividend-pool", IntelligenceTier.RELIABLE), Map.entry("dividend-tax", IntelligenceTier.RELIABLE), + Map.entry("dividend-per-member", IntelligenceTier.RELIABLE), Map.entry("office-holder", IntelligenceTier.RELIABLE), + Map.entry("office-aptitude", IntelligenceTier.DETAILED)); + static { load(new org.bukkit.configuration.file.YamlConfiguration()); } + public record TierSettings(int minimumMargin, double uncertainty, double rosterFraction, + double boundedFraction, double relativeWidth) {} + + private EspionageConfig() {} + + public static void load(ConfigurationSection config) { + String configuredPermission = config.getString("espionage.bypass-permission", DEFAULT_BYPASS_PERMISSION); + bypassPermission = configuredPermission == null || configuredPermission.isBlank() + ? DEFAULT_BYPASS_PERMISSION : configuredPermission.strip(); + Map loaded = new LinkedHashMap<>(); + DEFAULT_WEIGHTS.forEach((attribute, fallback) -> { + double value = config.getDouble("espionage.aptitude.attribute-weights." + attribute, fallback); + loaded.put(attribute, Double.isFinite(value) && Math.abs(value) <= 1000 ? value : fallback); + }); + weights = Map.copyOf(loaded); + repeatCost = nonnegative(config, "espionage.appointments.repeat-cost", 250); + stabilityPenalty = Math.min(100, nonnegative(config, "espionage.appointments.stability-penalty", 10)); + penaltyDays = Math.min(3650, nonnegative(config, "espionage.appointments.penalty-days", 7)); + reloadPermission = config.getString("espionage.reload-permission", "simplefactions.espionage.reload"); + if (reloadPermission == null || reloadPermission.isBlank()) reloadPermission = "simplefactions.espionage.reload"; + reloadPermission = reloadPermission.strip(); + base = bounded(config, "espionage.aptitude.base", 50, 0, 100); + cap = (int) bounded(config, "espionage.aptitude.attribute-cap", 16, 1, 1000); + center = (int) bounded(config, "espionage.aptitude.attribute-center", 6, 0, cap); + aptitudeSpread = (int) bounded(config, "espionage.aptitude.random-spread", 20, 0, 100); + soloMultiplier = bounded(config, "espionage.aptitude.solo-leader-multiplier", .25, 0, 1); + rollMultiplier = bounded(config, "espionage.checks.aptitude-multiplier", 1.25, .01, 10); + 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); + tiers.clear(); + int previous = 0; + for (var tier : IntelligenceTier.values()) { + if (tier == IntelligenceTier.UNKNOWN) continue; + int index = tier.ordinal() - 1; + String path = "espionage.intelligence.tiers." + tier.key() + "."; + int margin = Math.max(previous + 1, (int) bounded(config, path + "minimum-margin", new int[]{1,30,65,100}[index], 1, 10000)); + tiers.put(tier, new TierSettings(margin, + bounded(config, path + "uncertainty", new double[]{1.5,.75,.2,.1}[index], .01, 10), + bounded(config, path + "roster-fraction", new double[]{.2,.4,.6,.8}[index], 0, 1), + bounded(config, path + "maximum-bounded-fraction", new double[]{.75,.65,.5,.5}[index], .01, .99), + bounded(config, path + "maximum-relative-width", new double[]{4,3,2,2}[index], .1, 10))); + previous = margin; + } + minimums.clear(); + DEFAULT_MINIMUMS.forEach((field, fallback) -> minimums.put(field, IntelligenceTier.parse( + config.getString("espionage.intelligence.minimum-tiers." + field, fallback.key())))); + cashflows.clear(); + var overrides = config.getConfigurationSection("espionage.intelligence.minimum-tiers.cashflows"); + if (overrides != null) for (String category : overrides.getKeys(false)) + cashflows.put(category, IntelligenceTier.parse(overrides.getString(category))); + } + + private static double nonnegative(ConfigurationSection config, String key, double fallback) { + double value = config.getDouble(key, fallback); + return Double.isFinite(value) && value >= 0 ? value : fallback; + } + + private static double bounded(ConfigurationSection config, String key, double fallback, double min, double max) { + double value = config.getDouble(key, fallback); + return Double.isFinite(value) && value >= min && value <= max ? value : Math.max(min, Math.min(max, fallback)); + } + + public static IntelligenceTier tier(int margin) { + IntelligenceTier result = IntelligenceTier.UNKNOWN; + for (var tier : IntelligenceTier.values()) + if (tier != IntelligenceTier.UNKNOWN && margin >= tiers.get(tier).minimumMargin()) result = tier; + return result; + } + public static TierSettings settings(IntelligenceTier tier) { return tiers.get(tier); } + public static String metricKey(String metric) { + if (metric.startsWith("Position:")) return "office-aptitude"; + if (metric.contains(":Cashflow:")) return "cashflow"; + String leaf = metric.startsWith("Guild:") ? metric.substring(metric.indexOf(':', 6) + 1) : metric; + if (leaf.equals("Daily net income") || leaf.equals("Income")) return "net-income"; + return leaf.toLowerCase(java.util.Locale.ROOT).replace(' ', '-'); + } + public static boolean allows(IntelligenceTier tier, String metric) { + var minimum = minimums.getOrDefault(metricKey(metric), IntelligenceTier.UNKNOWN); + if (metric.contains(":Cashflow:")) minimum = cashflows.getOrDefault( + metric.substring(metric.indexOf(":Cashflow:") + 10), minimum); + return minimum != IntelligenceTier.UNKNOWN && tier != IntelligenceTier.UNKNOWN && tier.ordinal() >= minimum.ordinal(); + } + public static String reloadPermission() { return reloadPermission; } + public static double base() { return base; } + public static int center() { return center; } + public static int cap() { return cap; } + public static int aptitudeSpread() { return aptitudeSpread; } + public static double soloMultiplier() { return soloMultiplier; } + public static double rollMultiplier() { return rollMultiplier; } + public static int luckSpread() { return luckSpread; } + public static int luckDraws() { return luckDraws; } + public static int rosterLimit() { return rosterLimit; } + + public static double repeatCost() { return repeatCost; } + public static double stabilityPenalty() { return stabilityPenalty; } + public static double penaltyDays() { return penaltyDays; } + + public static Map weights() { return weights; } + public static String bypassPermission() { return bypassPermission; } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java new file mode 100644 index 00000000..e41c70a1 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java @@ -0,0 +1,60 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.Map; +import java.util.random.RandomGenerator; + +public final class EspionageMath { + private EspionageMath() {} + + public static int aptitude(Map attributes, RandomGenerator random) { + return aptitude(attributes, EspionageConfig.weights(), random); + } + + public static int aptitude(Map attributes, Map weights, RandomGenerator random) { + double score = EspionageConfig.base(); + for (var weight : weights.entrySet()) score += centered(attributes, weight.getKey()) * weight.getValue(); + score = Math.max(-1000, Math.min(1000, score)); + return clamp((int) Math.round(score) + random.nextInt(-EspionageConfig.aptitudeSpread(), EspionageConfig.aptitudeSpread() + 1), 0, 100); + } + + private static int centered(Map attributes, String key) { + // Permanent character attributes; configurable centering and cap prevent runaway scaling. + return clamp(attributes.getOrDefault(key, 0), 0, EspionageConfig.cap()) - EspionageConfig.center(); + } + + public static int dailyRoll(int aptitude, int reduction, RandomGenerator random) { + // Several independent draws concentrate luck around zero while retaining rare extremes. + int luck = 0; + for (int draw = 0; draw < EspionageConfig.luckDraws(); draw++) + luck += random.nextInt(-EspionageConfig.luckSpread(), EspionageConfig.luckSpread() + 1); + return (int) Math.round(clamp(aptitude, 0, 100) * EspionageConfig.rollMultiplier() + + luck / (double) EspionageConfig.luckDraws()) - clamp(reduction, 0, 100); + } + + public static String quality(int margin) { return EspionageConfig.tier(margin).label(); } + + /** Rounded, asymmetric ranges; their midpoint cannot reveal the exact value. */ + public static Estimate estimate(double value, int margin, boolean signed, RandomGenerator random) { + var tier = EspionageConfig.tier(margin); + if (tier == IntelligenceTier.UNKNOWN || !Double.isFinite(value)) return null; + double uncertainty = EspionageConfig.settings(tier).uncertainty(); + double width = Math.max(2, Math.abs(value) * uncertainty * 2); + double step = Math.pow(10, Math.floor(Math.log10(Math.max(1, width / 4)))); + double below = width * random.nextDouble(0.2, 0.8); + long lower = (long) (Math.floor((value - below) / step) * step); + long upper = (long) (Math.ceil((value + width - below) / step) * step); + if (!signed) lower = Math.max(0, lower); + return new Estimate(lower, Math.max(lower + 1, upper)); + } + + public static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + public record Estimate(long lower, long upper) { + public double midpoint() { return lower / 2.0 + upper / 2.0; } + public String display() { + return lower + " to " + upper; + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java new file mode 100644 index 00000000..cba955d1 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -0,0 +1,386 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import java.util.random.RandomGenerator; + +import org.bukkit.entity.Player; + +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.objects.attributes.AttributeModifier; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; + +public final class EspionageService { + public static final String BYPASS_PERMISSION = EspionageConfig.DEFAULT_BYPASS_PERMISSION; + private static CharacterAptitudes characterAptitudes; + private EspionageService() {} + + public static void loadAptitudes(java.nio.file.Path file) throws java.io.IOException { + CharacterAptitudes registry = new CharacterAptitudes(file); + registry.load(); + // Preserve existing rolls from the first dev version, including removed offices. + for (Faction faction : FactionManager.getCopy().stream() + .sorted(java.util.Comparator.comparing(Faction::getId)).toList()) { + faction.getEspionage().legacyAptitudes().forEach((key, value) -> { + int separator = key.indexOf(':'); + if (separator >= 0) registry.remember(key.substring(separator + 1), value); + }); + var holder = faction.getEspionage().getSpymaster(); + if (holder != null) registry.remember(holder.characterId, holder.aptitude); + } + for (Faction faction : FactionManager.getCopy()) { + var holder = faction.getEspionage().getSpymaster(); + if (holder != null && holder.characterId != null) + holder.aptitude = registry.aptitude(holder.characterId, () -> holder.aptitude); + } + registry.save(); + characterAptitudes = registry; + } + + public static boolean eligible(Faction faction, String playerName) { + if (!faction.isMemberIgnoreCase(playerName) && !faction.isLeader(playerName)) return false; + return !faction.isLeader(playerName) || faction.getMembers().stream() + .allMatch(name -> name.equalsIgnoreCase(playerName)); + } + + /** Only new factions pass through addFaction; loading saves never reassigns offices. */ + public static void initializeFounder(Faction faction) { + if (faction.getEspionage() == null || org.bukkit.Bukkit.getServer() == null) return; + var founder = org.bukkit.Bukkit.getPlayerExact(faction.getLeader()); + var data = founder == null ? null : PlayerManager.get(founder); + RPCharacter character = data == null ? null : data.getActiveCharacter(); + for (SpecialPosition office : SpecialPosition.values()) { + if (faction.getEspionage().holder(office) != null) continue; + if (office == SpecialPosition.SPYMASTER && !eligible(faction, faction.getLeader())) continue; + var assignment = new SpecialPositionAssignment(); + assignment.playerName = faction.getLeader(); + assignment.playerId = founder == null ? org.bukkit.Bukkit.getOfflinePlayer(faction.getLeader()).getUniqueId() : founder.getUniqueId(); + int aptitude = 0; + try { + if (character != null && characterAptitudes != null) { + aptitude = characterAptitude(founder, character); + assignment.characterId = character.getId(); + } + } catch (java.io.IOException exception) { + net.tfminecraft.simplefactions.SimpleFactions.plugin.getLogger().log(java.util.logging.Level.SEVERE, + "Could not save founder aptitude", exception); + } + faction.getEspionage().assignFounder(office, assignment, aptitude); + if (founder != null) founder.sendMessage("\u00a78\u00a7oUntil a trusted hand is appointed, you hold the keys to the " + + office.label() + "'s office. Visit Special Positions in your faction menu."); + } + } + + public static double appointmentCost(Faction faction) { + return faction.getEspionage().appointmentCount(SpecialPosition.SPYMASTER) == 0 ? 0 : EspionageConfig.repeatCost(); + } + + static boolean canAffordAppointment(Faction faction) { + double cost = appointmentCost(faction); + if (cost == 0) return true; + return faction.getBank() != null && Double.isFinite(faction.getBank().getWealth()) && faction.getBank().getWealth() >= cost; + } + + static boolean completeAppointment(Faction faction, SpecialPositionAssignment assignment, int aptitude) { + if (!canAffordAppointment(faction)) return false; + boolean repeat = faction.getEspionage().appointmentCount(SpecialPosition.SPYMASTER) > 0; + double cost = appointmentCost(faction); + if (cost > 0) faction.getBank().withdraw(cost); + faction.getEspionage().appoint(assignment, aptitude); + if (repeat) faction.getEspionage().addUnrest(SpecialPosition.SPYMASTER, + EspionageConfig.stabilityPenalty(), EspionageConfig.penaltyDays(), System.currentTimeMillis()); + return true; + } + + public static int effectiveAptitude(Faction faction, SpecialPositionAssignment holder) { + if (holder == null || !eligible(faction, holder.playerName)) return 0; + return faction.isLeader(holder.playerName) ? (int) Math.floor(holder.aptitude * EspionageConfig.soloMultiplier()) : holder.aptitude; + } + + public static boolean isOwn(Player viewer, Faction target) { + return viewer != null && target != null + && (target.isMemberIgnoreCase(viewer.getName()) || target.isLeader(viewer.getName())); + } + + public static boolean bypasses(Player viewer) { + return viewer != null && viewer.hasPermission(EspionageConfig.bypassPermission()); + } + + /** 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)); + } + + public static SpecialPositionAssignment spymaster(Faction faction) { + SpecialPositionAssignment holder = faction.getEspionage().getSpymaster(); + if (holder != null && !eligible(faction, holder.playerName)) { + faction.getEspionage().removeSpymaster(); + new Database().saveFaction(faction); + return null; + } + return holder; + } + + public static boolean appoint(Player actor, Faction faction, Player candidate) { + if (!isOwn(actor, faction) || !faction.isLeader(actor.getName())) { + actor.sendMessage("§cOnly your faction leader can appoint a Spymaster."); + return false; + } + if (candidate == null || !candidate.isOnline() || !isOwn(candidate, faction)) { + actor.sendMessage("§cChoose an online member of your own faction."); + return false; + } + if (!eligible(faction, candidate.getName())) { + actor.sendMessage("§cThe faction leader cannot be Spymaster unless the faction has only one member."); + return false; + } + var playerData = PlayerManager.get(candidate); + RPCharacter character = playerData == null ? null : playerData.getActiveCharacter(); + if (character == null) { + actor.sendMessage("§cThat member needs an active roleplay character."); + return false; + } + SpecialPositionAssignment current = spymaster(faction); + if (current != null && !current.automatic && current.isHolder(candidate.getUniqueId()) + && character.getId().equals(current.characterId)) { + actor.sendMessage("§7That member is already your Spymaster."); + return false; + } + if (!canAffordAppointment(faction)) { + actor.sendMessage("\u00a7cThe faction treasury needs " + appointmentCost(faction) + "d for this appointment."); + return false; + } + double cost = appointmentCost(faction); + boolean repeat = faction.getEspionage().appointmentCount(SpecialPosition.SPYMASTER) > 0; + SpecialPositionAssignment assignment = new SpecialPositionAssignment(); + assignment.playerId = candidate.getUniqueId(); + assignment.playerName = candidate.getName(); + assignment.characterId = character.getId(); + try { + if (characterAptitudes == null) throw new java.io.IOException("Character aptitude registry is unavailable"); + int aptitude = characterAptitude(candidate, character); + if (!completeAppointment(faction, assignment, aptitude)) return false; + } catch (java.io.IOException exception) { + actor.sendMessage("§cThe aptitude could not be saved. The appointment has not been made."); + net.tfminecraft.simplefactions.SimpleFactions.plugin.getLogger().log(java.util.logging.Level.SEVERE, + "Could not save character aptitude", exception); + return false; + } + new Database().saveFaction(faction); + // Address only the appointee. Sabotage preferences are never broadcast. + candidate.sendMessage("§8§oA sealed letter reaches your hands. The keys to " + faction.getName() + + "§8§o's unseen network are now yours. You have been appointed Spymaster."); + candidate.sendMessage("§7Your aptitude for this office is §e" + effectiveAptitude(faction, assignment) + + "/100§7. Inspect Special Positions in the faction menu, or use §a/faction espionage§7."); + if (faction.isLeader(candidate.getName())) + candidate.sendMessage("§7Leading a one-person faction retains " + Math.round(EspionageConfig.soloMultiplier() * 100) + "% aptitude (base: " + assignment.aptitude + ")."); + if (!actor.getUniqueId().equals(candidate.getUniqueId())) actor.sendMessage("§aSpymaster appointed."); + if (repeat) actor.sendMessage("\u00a77Appointment: " + cost + "d from the treasury. The change of office brings " + + EspionageConfig.stabilityPenalty() + " points of unrest, fading over " + EspionageConfig.penaltyDays() + " days."); + return true; + } + + private static int characterAptitude(Player player, RPCharacter character) throws java.io.IOException { + if (characterAptitudes == null) throw new java.io.IOException("Character aptitude registry is unavailable"); + return characterAptitudes.aptitude(character.getId(), () -> { + Map attributes = new HashMap<>(); + var mmoAttributes = net.Indyuce.mmocore.api.player.PlayerData.get(player).getAttributes(); + for (String attribute : EspionageConfig.DEFAULT_WEIGHTS.keySet()) { + var instance = mmoAttributes.getInstance(attribute); + attributes.put(attribute, instance == null + ? character.getAttributeData().getAmount(new AttributeModifier(attribute, 0)) : instance.getBase()); + } + return EspionageMath.aptitude(attributes, ThreadLocalRandom.current()); + }); + } + + public static boolean remove(Player actor, Faction faction) { + if (!isOwn(actor, faction) || !faction.isLeader(actor.getName())) { + actor.sendMessage("§cOnly your faction leader can remove the Spymaster."); + return false; + } + faction.getEspionage().removeSpymaster(); + new Database().saveFaction(faction); + actor.sendMessage("§aThe Spymaster office is now vacant."); + return true; + } + + public static boolean setSabotage(Player actor, Faction faction, boolean offense, int reduction) { + SpecialPositionAssignment holder = spymaster(faction); + if (!isOwn(actor, faction) || holder == null || !holder.isHolder(actor.getUniqueId())) { + actor.sendMessage("§cOnly the appointed Spymaster can change their private conduct."); + return false; + } + if (reduction < 0 || reduction > 100 || reduction % 25 != 0) { + actor.sendMessage("§cChoose a reduction of 0, 25, 50, 75, or 100. Zero disables sabotage."); + return false; + } + if (offense) holder.offenseReduction = reduction; + else holder.defenseReduction = reduction; + new Database().saveFaction(faction); + actor.sendMessage("§7Private " + (offense ? "offensive" : "defensive") + " sabotage: " + + (reduction == 0 ? "§adisabled" : "§c-" + reduction + " to your roll") + + "§7. This affects your next daily rolls; existing reports stay unchanged."); + return true; + } + + /** Reading or clicking menus never generates intelligence. */ + 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; + return observer.getEspionage().cachedReport(target.getId(), target.getFoundedAt(), day()); + } + + private static long day() { return LocalDate.now(ZoneOffset.UTC).toEpochDay(); } + + /** GUI-opening commands request one faction-wide report per foreign target each UTC day. */ + public static void refreshReports(Player viewer) { + if (viewer == null || bypasses(viewer)) return; + Faction observer = FactionManager.getByMember(viewer.getName()); + if (observer == null) return; + boolean updated = false; + 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); + updated = true; + } + } + if (updated) viewer.sendMessage("§8§oYour faction's unseen network delivers today's sealed intelligence reports."); + } + + private static IntelligenceReport generateReport(Faction observer, Faction target) { + return generateReport(observer, target, true); + } + + private static IntelligenceReport generateReport(Faction observer, Faction target, boolean persist) { + long day = LocalDate.now(ZoneOffset.UTC).toEpochDay(); + var attacker = spymaster(observer); + var defender = spymaster(target); + boolean[] created = {false}; + RandomGenerator random = ThreadLocalRandom.current(); + 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(margin <= 0 ? Map.of() : metrics(target), margin, random); + captureMembers(generated, target, margin, random); + captureOffices(generated, target, random); + return generated; + }); + if (created[0] && persist) { + Database database = new Database(); + database.saveFaction(target); + database.saveFaction(observer); + } + return report; + } + + static java.util.List sample(java.util.List members, int margin, RandomGenerator random) { + java.util.List 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; + 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); + report.members = sample.stream().map(name -> CharacterNames.of(name) + " §7— " + + net.tfminecraft.simplefactions.utils.Represents.represents(target, name)).toList(); + for (var guild : target.getGuildHandler().getGuilds()) { + report.guildMembers.put(guild.getId(), sample.stream().filter(guild::isMember) + .filter(name -> !guild.isLeader(name)).map(CharacterNames::of).toList()); + } + } + + public static Double visibleValue(Player viewer, Faction target, String metric, java.util.function.DoubleSupplier exact) { + if (metric.equals("Prestige") || canViewExact(viewer, target)) return exact.getAsDouble(); + var report = report(viewer, target); + var estimate = report == null ? null : report.estimate(metric); + return estimate == null ? null : estimate.midpoint(); + } + + static IntelligenceReport createReport(Map metrics, int margin, RandomGenerator random) { + IntelligenceReport report = new IntelligenceReport(); + report.quality = EspionageMath.quality(margin); + metrics.forEach((key, value) -> { + if (!report.allows(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); + }); + return report; + } + + static void captureOffices(IntelligenceReport report, Faction target, RandomGenerator random) { + for (var office : SpecialPosition.values()) { + var holder = target.getEspionage().holder(office); + if (report.allows("office-holder")) report.officeHolders.put(office, + holder == null ? "Vacant" : CharacterNames.of(holder.playerName)); + String key = IntelligenceReport.officeAptitudeKey(office); + if (!report.allows(key)) continue; + double aptitude = office == SpecialPosition.SPYMASTER ? effectiveAptitude(target, holder) + : 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); + } + } + + /** Staff testing explicitly invalidates both sides before rebuilding any report. */ + public static int regenerateReports() { + var factions = FactionManager.getCopy(); + for (var faction : factions) faction.getEspionage().resetReportsAndRolls(); + int count = 0; + for (var observer : factions) { + for (var target : factions) if (!observer.getId().equals(target.getId())) { + generateReport(observer, target, false); + count++; + } + // Also persist resets for a lone faction, which has no foreign targets. + new Database().saveFaction(observer); + } + return count; + } + + private static Map metrics(Faction faction) { + Map values = new LinkedHashMap<>(); + values.put("Members", (double) faction.getMembers().size()); + values.put("Wealth", faction.getWealth()); + values.put("Prosperity", faction.getProsperity()); + values.put("Daily net income", faction.getOrCreateMainGuild().getLedger().getNetIncome()); + values.put("Professional army", (double) faction.getMilitary().getRegiments().stream() + .filter(regiment -> regiment.isProfessional() && !regiment.isEquipment()) + .mapToInt(regiment -> regiment.getCurrentSlots()).sum()); + values.put("Levies", (double) (faction.getMilitary().getManpower(false) - faction.getMilitary().getManpowerNoLevy(false))); + values.put("Mercenaries", (double) faction.getMilitary().getMercenaryManpower()); + values.put("Installations", (double) faction.getInstallationHandler().getAll().size()); + for (var guild : faction.getGuildHandler().getGuilds()) { + values.put("Guild:" + guild.getId() + ":Wealth", guild.getWealth()); + values.put("Guild:" + guild.getId() + ":Members", (double) guild.getMembers().size()); + values.put("Guild:" + guild.getId() + ":Income", guild.getLedger().getNetIncome()); + IntelligenceLedger.capture(values, guild); + if (guild.hasCapital()) values.put("Guild:" + guild.getId() + ":Trade power", guild.getTradeBreakdown().getTradePower()); + } + if (faction.getGovernment() != null) { + values.put("Stability", faction.getGovernment().getStability()); + values.put("Administrative power", faction.getGovernment().getPower()); + } + return values; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java new file mode 100644 index 00000000..9508bd04 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java @@ -0,0 +1,120 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; + +/** Stored inside faction JSON; no scheduler or player-specific roll cache is needed. */ +public class EspionageState { + private Map positions = new EnumMap<>(SpecialPosition.class); + private Map appointmentAptitudes = new HashMap<>(); + private DailyRolls dailyRolls; + private Map reports = new HashMap<>(); + private Map appointments = new EnumMap<>(SpecialPosition.class); + private java.util.List unrest = new java.util.ArrayList<>(); + + public SpecialPositionAssignment holder(SpecialPosition office) { return positions.get(office); } + + public int appointmentCount(SpecialPosition office) { + if (appointments.containsKey(office)) return appointments.get(office); + var holder = positions.get(office); + // Existing dev offices already used their first appointment. Old removed holders leave a legacy roll. + return holder != null && !holder.automatic || office == SpecialPosition.SPYMASTER && !appointmentAptitudes.isEmpty() ? 1 : 0; + } + + public void assignFounder(SpecialPosition office, SpecialPositionAssignment assignment, int aptitude) { + if (positions.containsKey(office)) return; + assignment.automatic = true; + assignment.aptitude = EspionageMath.clamp(aptitude, 0, 100); + positions.put(office, assignment); + } + + public void addUnrest(SpecialPosition office, double points, double days, long now) { + unrest.removeIf(entry -> entry.endsAt <= now); + if (points > 0 && days > 0) unrest.add(new OfficeUnrest(office, points, now, now + Math.max(1, Math.round(days * 86_400_000)))); + } + + public java.util.List unrestModifiers(long now) { + return unrest.stream().filter(entry -> entry.valueAt(now) < 0) + .map(entry -> new net.tfminecraft.simplefactions.government.StabilityModifier( + "Replaced " + entry.office.label(), entry.valueAt(now), 0)).toList(); + } + + public record OfficeUnrest(SpecialPosition office, double points, long startedAt, long endsAt) { + public double valueAt(long now) { + if (endsAt <= startedAt || now >= endsAt) return 0; + return -points * Math.min(1, (endsAt - (double) now) / (endsAt - (double) startedAt)); + } + } + + public SpecialPositionAssignment getSpymaster() { + return positions.get(SpecialPosition.SPYMASTER); + } + + public void removeSpymaster() { + appointments.put(SpecialPosition.SPYMASTER, appointmentCount(SpecialPosition.SPYMASTER)); + positions.remove(SpecialPosition.SPYMASTER); + } + + /** Legacy format helper retained for migration tests. Runtime appointments use the global registry. */ + @Deprecated + void appoint(SpecialPositionAssignment assignment, Supplier aptitudeRoll) { + int previousAppointments = appointmentCount(SpecialPosition.SPYMASTER); + String key = assignment.playerId + ":" + assignment.characterId; + assignment.aptitude = appointmentAptitudes.computeIfAbsent(key, + ignored -> EspionageMath.clamp(aptitudeRoll.get(), 0, 100)); + appointments.put(SpecialPosition.SPYMASTER, previousAppointments); + appoint(assignment, assignment.aptitude); + } + + public void appoint(SpecialPositionAssignment assignment, int permanentAptitude) { + appointments.put(SpecialPosition.SPYMASTER, appointmentCount(SpecialPosition.SPYMASTER) + 1); + assignment.automatic = false; + assignment.aptitude = EspionageMath.clamp(permanentAptitude, 0, 100); + positions.put(SpecialPosition.SPYMASTER, assignment); + } + + public Map legacyAptitudes() { return Map.copyOf(appointmentAptitudes); } + + public IntelligenceReport cachedReport(String targetId, long foundedAt, long day) { + IntelligenceReport report = reports.get(targetId); + return report != null && report.day == day && report.targetFoundedAt == foundedAt ? report : null; + } + + public void resetReportsAndRolls() { + reports.clear(); + dailyRolls = null; + } + + public DailyRolls rolls(long day, RandomGenerator random) { + SpecialPositionAssignment holder = getSpymaster(); + return rolls(day, holder == null ? 0 : holder.aptitude, random); + } + + public DailyRolls rolls(long day, int aptitude, RandomGenerator random) { + if (dailyRolls == null || dailyRolls.day != day) { + SpecialPositionAssignment holder = getSpymaster(); + int offense = holder == null ? 0 : holder.offenseReduction; + int defense = holder == null ? 0 : holder.defenseReduction; + dailyRolls = new DailyRolls(day, EspionageMath.dailyRoll(aptitude, offense, random), + EspionageMath.dailyRoll(aptitude, defense, random)); + } + return dailyRolls; + } + + public IntelligenceReport report(String targetId, long foundedAt, long day, + Supplier create) { + reports.entrySet().removeIf(entry -> entry.getValue().day != day); + IntelligenceReport existing = reports.get(targetId); + if (existing != null && existing.targetFoundedAt == foundedAt) return existing; + IntelligenceReport report = create.get(); + report.day = day; + report.targetFoundedAt = foundedAt; + reports.put(targetId, report); + return report; + } + + public record DailyRolls(long day, int offense, int defense) {} +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java new file mode 100644 index 00000000..a2cb10d1 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java @@ -0,0 +1,46 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.Cashflow; + +/** Financial estimates are captured with the daily report, never read live in foreign menus. */ +public final class IntelligenceLedger { + private IntelligenceLedger() {} + public static String key(Guild guild, String metric) { return "Guild:" + guild.getId() + ":" + metric; } + + static void capture(Map values, Guild guild) { + double income = 0, expenses = 0; + for (Cashflow flow : Cashflow.values()) { + double value = guild.getLedger().getIncome(flow); + values.put(key(guild, "Cashflow:" + flow.name()), value); + income += Math.max(0, value); + expenses += Math.max(0, -value); + } + values.put(key(guild, "Income total"), income); + values.put(key(guild, "Expense total"), expenses); + if (!guild.isBase()) { + values.put(key(guild, "Dividend rate"), guild.getDividendPercent()); + var dividends = guild.getLedger().getDividendBreakdown(); + values.put(key(guild, "Dividend pool"), dividends.pool()); + values.put(key(guild, "Dividend tax"), dividends.tax()); + values.put(key(guild, "Dividend per member"), dividends.perMember()); + } + } + + public static String value(IntelligenceReport report, Guild guild, String metric, String units) { + var range = report == null ? null : report.estimate(key(guild, metric)); + if (range == null) return "\u00a77Unknown"; + String color = range.midpoint() < 0 || metric.equals("Expense total") || metric.equals("Dividend tax") ? "#cf493a" : "#7fbd73"; + return color + range.display() + units; + } + + public static List summary(IntelligenceReport report, Guild guild) { + return List.of("#4c5250\u00a7oAdded to the bank at the", "#4c5250\u00a7ostart of a new day", "", + "#4fd945Income", "#d6cf69Total: " + value(report, guild, "Income total", "d/day"), "", + "#cf493aExpenses", "#d6cf69Total: " + value(report, guild, "Expense total", "d/day"), "", + "#f2e5c2Net Income", "#d6cf69Total: " + value(report, guild, "Income", "d/day"), "", + "\u00a77Click to inspect the reported accounts."); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceRanges.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceRanges.java new file mode 100644 index 00000000..67550ca4 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceRanges.java @@ -0,0 +1,64 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.Set; +import net.tfminecraft.simplefactions.Cache; + +/** Uses public metric limits and cached estimates only, never a target's exact values. */ +public final class IntelligenceRanges { + private static final Set NONNEGATIVE = Set.of("Members", "Professional army", "Levies", + "Mercenaries", "Installations", "Stability", "Prosperity"); + private IntelligenceRanges() {} + + public static boolean nonnegative(String metric) { + return NONNEGATIVE.contains(metric) || metric.endsWith(":Members") || metric.endsWith(":Trade power") + || metric.endsWith(":Income total") || metric.endsWith(":Expense total") + || metric.contains(":Dividend ") || metric.startsWith("Position:") || cashflow(metric) != null + && cashflow(metric) != net.tfminecraft.simplefactions.guild.income.Cashflow.VEHICLE_FEES && !nonpositive(metric); + } + + private static net.tfminecraft.simplefactions.guild.income.Cashflow cashflow(String metric) { + int start = metric.indexOf(":Cashflow:"); + if (start < 0) return null; + try { return net.tfminecraft.simplefactions.guild.income.Cashflow.valueOf(metric.substring(start + 10)); } + catch (IllegalArgumentException ignored) { return null; } + } + + private static boolean nonpositive(String metric) { + var flow = cashflow(metric); + if (flow == null) return false; + return switch (flow) { + case GUILD_PAYMENTS, DIVIDEND_PAYMENT, DIVIDEND_PAYOUT, TARIFF_PAYMENTS, HUB_TAX_PAYMENTS, + LOAN_PAYMENTS, INTEREST_PAYMENTS, TRIBUTE_PAYMENTS, OVERLORD_TAX, WAR_REPARATIONS_PAYMENT, + MERCENARY_PAYMENTS, REFUND_PAYMENTS, WAGE_PAYMENTS, TRADE_UPKEEP, UPGRADES_UPKEEP, + PENALTIES, INSTALLATIONS, VEHICLE_UPKEEP, MILITARY_UPKEEP, NODES, SUPPLY_HUBS -> true; + default -> false; + }; + } + + public static EspionageMath.Estimate reasonable(String metric, EspionageMath.Estimate raw) { + return reasonable(metric, raw, IntelligenceTier.RELIABLE); + } + + public static EspionageMath.Estimate reasonable(String metric, EspionageMath.Estimate raw, IntelligenceTier tier) { + if (tier == IntelligenceTier.UNKNOWN) return null; + if (raw == null || raw.lower() >= raw.upper()) return null; + long minimum = nonnegative(metric) ? 0 : Long.MIN_VALUE; + Long maximum = null; + if (metric.equals("Stability") || metric.endsWith(":Dividend rate") || metric.startsWith("Position:")) maximum = 100L; + else if (metric.startsWith("Guild:") && metric.endsWith(":Members") && Cache.maxMembers > 0) + maximum = (long) Cache.maxMembers; + boolean boundedDomain = maximum != null; + if (nonpositive(metric)) maximum = 0L; + long lower = Math.max(minimum, raw.lower()); + long upper = maximum == null ? raw.upper() : Math.min(maximum, raw.upper()); + if (lower >= upper) return null; // Bounds must not invent an exact value. + var range = new EspionageMath.Estimate(lower, upper); + double width = (double) upper - lower; + if (boundedDomain) { + if (width > (maximum - (double) minimum) * EspionageConfig.settings(tier).boundedFraction()) return null; + } else if (lower < 0 && upper > 0 || width >= EspionageConfig.settings(tier).relativeWidth() * Math.max(2, Math.abs(range.midpoint()))) { + return null; // An effectively unbounded or sign-ambiguous guess is not useful intelligence. + } + return range; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java new file mode 100644 index 00000000..aa73b415 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java @@ -0,0 +1,56 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** A snapshot, shared by every member of the observing faction. */ +public class IntelligenceReport { + public static final String UNKNOWN = "Unknown"; + public Map officeHolders = new LinkedHashMap<>(); + public long day; + public long targetFoundedAt; + public String quality; + public Map estimates = new LinkedHashMap<>(); + public java.util.List members = new java.util.ArrayList<>(); + public Map> guildMembers = new LinkedHashMap<>(); + + public String loreDate() { + return net.tfminecraft.rpcharacters.calendar.FantasyCalendar.formatDate(java.time.LocalDate.ofEpochDay(day) + .atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()); + } + + public String display(String metric) { + EspionageMath.Estimate estimate = estimate(metric); + return estimate == null ? UNKNOWN : estimate.display(); + } + + public EspionageMath.Estimate estimate(String metric) { + return !EspionageConfig.allows(tier(), metric) ? null + : IntelligenceRanges.reasonable(metric, estimates == null ? null : estimates.get(metric), tier()); + } + + public String qualityLabel() { + return tier().label(); + } + + public IntelligenceTier tier() { return IntelligenceTier.parse(quality); } + public boolean allows(String field) { return EspionageConfig.allows(tier(), field); } + public String officeHolder(SpecialPosition office) { + return !allows("office-holder") || officeHolders == null ? UNKNOWN : officeHolders.getOrDefault(office, UNKNOWN); + } + public static String officeAptitudeKey(SpecialPosition office) { return "Position:" + office.name() + ":Aptitude"; } + + public static String stabilityState(IntelligenceReport report) { + var range = report == null ? null : report.estimate("Stability"); + if (range == null) return "\u00a77Unknown State"; + var state = net.tfminecraft.simplefactions.government.stability.StabilityStatus.fromStability(range.midpoint()); + return state.getListColor() + state.getLabel(); + } + + public static String stabilityRange(IntelligenceReport report) { + var range = report == null ? null : report.estimate("Stability"); + if (range == null) return "\u00a77" + UNKNOWN; + var state = net.tfminecraft.simplefactions.government.stability.StabilityStatus.fromStability(range.midpoint()); + return state.getListColor() + range.display() + "%"; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceTier.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceTier.java new file mode 100644 index 00000000..ed6bce87 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceTier.java @@ -0,0 +1,19 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.Locale; + +/** Stable identifiers for saved reports and configurable disclosure rules. */ +public enum IntelligenceTier { + UNKNOWN("Unknown"), RUMOURS("Rumours"), BROAD("Broad estimates"), + RELIABLE("Reliable estimates"), DETAILED("Detailed estimates"); + + private final String label; + IntelligenceTier(String label) { this.label = label; } + public String label() { return label; } + public String key() { return name().toLowerCase(Locale.ROOT); } + public static IntelligenceTier parse(String value) { + for (var tier : values()) + if (tier.name().equalsIgnoreCase(value) || tier.label.equalsIgnoreCase(value)) return tier; + return UNKNOWN; // Invalid or legacy Hidden values never disclose information. + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPosition.java b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPosition.java new file mode 100644 index 00000000..4269e2d8 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPosition.java @@ -0,0 +1,10 @@ +package net.tfminecraft.simplefactions.espionage; + +/** Offices are independent of membership, guild leadership, and council seats. */ +public enum SpecialPosition { + SPYMASTER("Spymaster"); + + private final String label; + SpecialPosition(String label) { this.label = label; } + public String label() { return label; } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java new file mode 100644 index 00000000..f7716b7b --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java @@ -0,0 +1,18 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.UUID; + +public class SpecialPositionAssignment { + public UUID playerId; + public String playerName; + public String characterId; + public int aptitude; + public boolean automatic; + // Private, voluntary penalties. Zero means sabotage is disabled. + public int offenseReduction; + public int defenseReduction; + + public boolean isHolder(UUID id) { + return id != null && id.equals(playerId); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java new file mode 100644 index 00000000..734e0d70 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java @@ -0,0 +1,72 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +/** Migrates the old espionage section once; existing dedicated settings always win. */ +public final class SpecialPositionsConfigFile { + private SpecialPositionsConfigFile() {} + + public static void load(JavaPlugin plugin) { + try (var resource = plugin.getResource("special-positions.yml")) { + if (resource == null) throw new IOException("Missing special-positions.yml resource"); + var defaults = YamlConfiguration.loadConfiguration(new InputStreamReader(resource, StandardCharsets.UTF_8)); + var file = new File(plugin.getDataFolder(), "special-positions.yml"); + var legacy = new File(plugin.getDataFolder(), "config.yml"); + boolean existed = file.exists(); + var settings = prepare(file, legacy, defaults); + EspionageConfig.load(settings); + if (!existed && legacy.exists()) { + // Preserve comments and unrelated settings. Never rewrite the entire main YAML. + String before = Files.readString(legacy.toPath()); + String after = before.replaceFirst("(?m)^espionage:[^\\r\\n]*(?:\\r?\\n(?:[ \\t]+[^\\r\\n]*|#[^\\r\\n]*|))*", ""); + if (!before.equals(after)) { + Files.copy(legacy.toPath(), new File(plugin.getDataFolder(), "config.yml.before-special-positions").toPath(), + StandardCopyOption.REPLACE_EXISTING); + atomicWrite(legacy, after); + plugin.getLogger().info("Moved espionage settings from config.yml to special-positions.yml"); + } + } + } catch (IOException | InvalidConfigurationException exception) { + throw new IllegalStateException("Could not load special-positions.yml; existing settings have been preserved", exception); + } + } + + static YamlConfiguration prepare(File file, File legacy, YamlConfiguration defaults) + throws IOException, InvalidConfigurationException { + var settings = new YamlConfiguration(); + if (file.exists()) settings.load(file); + else if (legacy.exists()) { + var old = new YamlConfiguration(); + old.load(legacy); + for (var entry : old.getValues(true).entrySet()) + if (entry.getKey().startsWith("espionage.") && !(entry.getValue() instanceof org.bukkit.configuration.ConfigurationSection)) + settings.set(entry.getKey(), entry.getValue()); + } + boolean changed = !file.exists(); + if (changed) settings.options().setHeader(defaults.options().getHeader()); + for (var entry : defaults.getValues(true).entrySet()) { + if (!(entry.getValue() instanceof org.bukkit.configuration.ConfigurationSection) && !settings.contains(entry.getKey())) { + settings.set(entry.getKey(), entry.getValue()); + settings.setComments(entry.getKey(), defaults.getComments(entry.getKey())); + changed = true; + } + } + if (changed) atomicWrite(file, settings.saveToString()); + return settings; + } + + private static void atomicWrite(File file, String contents) throws IOException { + Files.createDirectories(file.toPath().toAbsolutePath().getParent()); + var temporary = file.toPath().resolveSibling(file.getName() + ".pending"); + Files.writeString(temporary, contents, StandardCharsets.UTF_8); + Files.move(temporary, file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Government.java b/src/main/java/net/tfminecraft/simplefactions/government/Government.java index 46ebf507..4d9ea792 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/Government.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/Government.java @@ -229,7 +229,12 @@ public void addStabilityModifier(StabilityModifier modifier) { } public List getStabilityModifiers() { - return stabilityModifiers; + if (f == null || f.getEspionage() == null) return stabilityModifiers; + var active = f.getEspionage().unrestModifiers(System.currentTimeMillis()); + if (active.isEmpty()) return stabilityModifiers; + List result = new ArrayList<>(stabilityModifiers); + result.addAll(active); + return result; } // Government diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java index 93fc3529..8925c034 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.espionage.EspionageCommands; import net.tfminecraft.simplefactions.utils.BankAmount; import net.tfminecraft.simplefactions.vehicles.berth.VehicleFindMessages; import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceMessages; @@ -62,12 +63,24 @@ public class CommandManager implements Listener, CommandExecutor{ @Override public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + return net.tfminecraft.simplefactions.espionage.CommandIntelligence.execute( + sender instanceof Player player ? player : null, + () -> dispatchCommand(sender, cmd, label, args)); + } + + private boolean dispatchCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (cmd.getName().equalsIgnoreCase(cmd1) && args.length >= 1 && args[0].equalsIgnoreCase("reloadespionage")) + return EspionageCommands.reload(sender, args); // Title editing is staff-only and also works from the console. if(cmd.getName().equalsIgnoreCase(cmd1) && args.length >= 1 && args[0].equalsIgnoreCase(TitleAdminCommand.SUBCOMMAND)) { return TitleAdminCommand.handle(sender, args); } if(sender instanceof Player) { Player p = (Player) sender; + if (cmd.getName().equalsIgnoreCase(cmd1) && args.length >= 1 + && EspionageCommands.matches(args[0])) { + return EspionageCommands.handle(p, args); + } if((cmd.getName().equalsIgnoreCase(cmd1) || cmd.getName().equalsIgnoreCase(cmd2)) && args.length < 1) { p.sendMessage("§a[SimpleFactions]§c Error with command format, use the gameplay guide for a list of commands"); return true; @@ -165,7 +178,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("hub")) { return net.tfminecraft.simplefactions.guild.hub.SupplyHubCommands.guild(p, args); - } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("invite") && args.length == 2) { + } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("invite") && args.length >= 2) { Guild guild = FactionManager.getGuildByLeader(p.getName()); if(guild == null) { p.sendMessage("§cYou are not the leader of a guild"); @@ -179,16 +192,13 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String p.sendMessage("§cThis is the base guild, use /faction invite instead"); return true; } - String invitee = args[1]; - if(invitee.equalsIgnoreCase(p.getName())) { + String invitee = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length)); + Player invited = net.tfminecraft.simplefactions.espionage.CharacterNames.resolveOnline(p, invitee); + if(invited == null) return true; + if(invited.getUniqueId().equals(p.getUniqueId())) { p.sendMessage("§cCannot invite yourself"); return true; } - Player invited = Bukkit.getPlayer(invitee); - if(invited == null) { - p.sendMessage("§cCould not find the player "+invitee); - return true; - } String name = invited.getName(); if(guild.isInvited(name)) { p.sendMessage("§cPlayer is already invited"); @@ -919,7 +929,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String } } return true; - } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("invite") && args.length == 2) { + } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("invite") && args.length >= 2) { Faction f = FactionManager.getByMember(p.getName()); if(f == null) { p.sendMessage("§cYou need to have a faction to invite someone"); @@ -929,9 +939,9 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String p.sendMessage("§cOnly the leader can invite new members!"); return true; } - Player invited = Bukkit.getPlayer(args[1]); + Player invited = net.tfminecraft.simplefactions.espionage.CharacterNames.resolveOnline(p, + String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length))); if(invited == null) { - p.sendMessage("§cNo player found by that IGN"); return true; } String name = invited.getName(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 857d7d19..ed3e1ff1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -701,6 +701,7 @@ public void start(List l) { factions = l; } public static void addFaction(Faction f) { + net.tfminecraft.simplefactions.espionage.EspionageService.initializeFounder(f); factions.add(f); if (f.getProvinces() == null || f.getProvinces().isEmpty()) { return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java index 470cb44a..9f3dbca8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.espionage.EspionageAccess; +import net.tfminecraft.simplefactions.managers.inventory.EspionageView; import net.tfminecraft.simplefactions.war.civilwar.CivilWarCopy; import java.util.ArrayList; @@ -983,7 +985,8 @@ public void dragInWarGui(InventoryDragEvent e) { e.setCancelled(true); } if (inv.getHolder() instanceof SFInventoryHolder - && ((SFInventoryHolder) inv.getHolder()).getType() == SFGUI.PLAYER_LEDGER_VIEW) { + && (((SFInventoryHolder) inv.getHolder()).getType() == SFGUI.PLAYER_LEDGER_VIEW + || EspionageView.handles(((SFInventoryHolder) inv.getHolder()).getType()))) { e.setCancelled(true); } // Confirm screens have no holder; an item dragged into one is lost when it closes. @@ -1040,6 +1043,16 @@ public void clickButton(InventoryClickEvent e) { // Every menu button is in the top inventory. A click in the player's own inventory // carries a slot number too, so it must never reach the views as a button press. if (isPluginMenu(inv) && !isTopInventoryClick(e.getRawSlot(), inv.getSize())) return; + if (inv.getHolder() instanceof SFInventoryHolder holder + && EspionageAccess.denied(p, holder)) { + p.closeInventory(); + return; + } + if (inv.getHolder() instanceof SFInventoryHolder holder + && EspionageView.handles(holder.getType())) { + EspionageView.click(e, p, holder, this); + return; + } if(e.getCurrentItem() == null) return; if(inv.getHolder() instanceof SFInventoryHolder) { e.setCancelled(true); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/holder/SFInventoryHolder.java b/src/main/java/net/tfminecraft/simplefactions/managers/holder/SFInventoryHolder.java index de27602d..8b172e42 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/holder/SFInventoryHolder.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/holder/SFInventoryHolder.java @@ -13,38 +13,23 @@ public class SFInventoryHolder implements InventoryHolder { private final String secondaryId; public SFInventoryHolder(String id, SFGUI type) { - this.id = id; - this.type = type; - this.page = 0; - this.flag = false; - this.secondaryId = null; + this(id, type, 0, false, null); } public SFInventoryHolder(String id, SFGUI type, int page) { - this.id = id; - this.type = type; - this.page = page; - this.flag = false; - this.secondaryId = null; + this(id, type, page, false, null); } public SFInventoryHolder(String id, SFGUI type, int page, boolean flag) { - this.id = id; - this.type = type; - this.page = page; - this.flag = flag; - this.secondaryId = null; + this(id, type, page, flag, null); } public SFInventoryHolder(String id, SFGUI type, String secondaryId) { - this.id = id; - this.type = type; - this.page = 0; - this.flag = false; - this.secondaryId = secondaryId; + this(id, type, 0, false, secondaryId); } public SFInventoryHolder(String id, SFGUI type, int page, boolean flag, String secondaryId) { + net.tfminecraft.simplefactions.espionage.CommandIntelligence.beforeMenu(); this.id = id; this.type = type; this.page = page; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java new file mode 100644 index 00000000..99ac9d53 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -0,0 +1,398 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import net.tfminecraft.simplefactions.espionage.EspionageService; +import net.tfminecraft.simplefactions.espionage.CharacterNames; +import net.tfminecraft.simplefactions.espionage.IntelligenceLedger; +import net.tfminecraft.simplefactions.espionage.SpecialPosition; +import net.tfminecraft.simplefactions.espionage.EspionageConfig; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.Cashflow; +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.SkullMeta; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.enums.MenuItemType; +import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; +import net.tfminecraft.simplefactions.espionage.IntelligenceReport; +import net.tfminecraft.simplefactions.espionage.SpecialPositionAssignment; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; + +/** Foreign reports deliberately have no links to exact military or economic submenus. */ +@SuppressWarnings("deprecation") +public final class EspionageView { + private EspionageView() {} + + public static ItemStack item(Material material, String title, String... lore) { + ItemStack item = new ItemStack(material); + var meta = item.getItemMeta(); + meta.setDisplayName(net.tfminecraft.tlibs.objects.api.subapi.StringFormatter.formatHex("§e" + title)); + meta.setLore(java.util.Arrays.stream(lore) + .map(net.tfminecraft.tlibs.objects.api.subapi.StringFormatter::formatHex).toList()); + item.setItemMeta(meta); + return item; + } + + public static ItemStack directoryItem(Player viewer, Faction target) { + return new FactionCreator().createListItem(viewer, target); + } + + public static void foreign(Inventory inventory, Player viewer, Faction target, InventoryManager manager) { + IntelligenceReport report = EspionageService.report(viewer, target); + inventory.clear(); + FactionCreator creator = new FactionCreator(); + inventory.setItem(4, reportHeader(viewer, report)); + int[] slots = {10, 11, 12, 13, 14, 16, 23, 25, 28, 29, 31, 32, 33, 34}; + MenuItemType[] types = {MenuItemType.BANNER, MenuItemType.GOVERNMENT, MenuItemType.WEALTH, + MenuItemType.PRESTIGE, MenuItemType.MEMBERS, MenuItemType.MODIFIERS, MenuItemType.GUILDS, + MenuItemType.TAX, MenuItemType.LAWS, MenuItemType.MILITARY, MenuItemType.DIPLOMACY, + MenuItemType.INSTALLATIONS, MenuItemType.TIER, MenuItemType.TITLES}; + for (int index = 0; index < slots.length; index++) inventory.setItem(slots[index], creator.createMenuItem(viewer, target, types[index])); + inventory.setItem(15, ledgerItem(report, target.getOrCreateMainGuild())); + inventory.setItem(20, foreignPositionsItem(viewer, target)); + inventory.setItem(53, manager.createBackButton(SFGUI.FACTION_VIEW)); + } + + 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." + : "\u00a77These are today\u2019s findings, delivered by your spymaster, " + CharacterNames.of(spy.playerName) + "."; + return item(Material.SPYGLASS, "Foreign intelligence", + "\u00a77Report quality: \u00a7e" + (report == null ? "Unknown" : report.qualityLabel()), + report == null ? "\u00a77No dated account has yet reached your court." : "\u00a77Dated " + report.loreDate(), last); + } + + public static ItemStack ledgerItem(IntelligenceReport report, Guild guild) { + return styled(Material.WRITABLE_BOOK, "#d6cf69Ledger", IntelligenceLedger.summary(report, guild).toArray(String[]::new)); + } + + public static void guild(Inventory inventory, Player viewer, Guild guild, InventoryManager manager) { + var report = EspionageService.report(viewer, guild.getFaction()); + var creator = new GuildCreator(); + inventory.clear(); + inventory.setItem(4, reportHeader(viewer, report)); + inventory.setItem(10, creator.createPublicListItem(viewer, guild)); + inventory.setItem(11, creator.createMenuItem(viewer, guild, MenuItemType.LEADER)); + inventory.setItem(12, styled(Material.GOLD_NUGGET, "#d1b43fWealth: " + IntelligenceLedger.value(report, guild, "Wealth", "d"))); + inventory.setItem(13, styled(Material.EMERALD, "#338651Trade Breakdown", + "#d4c9aeIncome from trade: " + IntelligenceLedger.value(report, guild, "Cashflow:TRADE", "d/day"), + "#d4c9aeUpkeep from trade: " + IntelligenceLedger.value(report, guild, "Cashflow:TRADE_UPKEEP", "d/day"), + "#d4c9aeTariffs Paid: " + IntelligenceLedger.value(report, guild, "Cashflow:TARIFF_PAYMENTS", "d/day"), + "#d4c9aeHub Tax Paid: " + IntelligenceLedger.value(report, guild, "Cashflow:HUB_TAX_PAYMENTS", "d/day"), + "#d4c9aeTotal Trade Power: " + IntelligenceLedger.value(report, guild, "Trade power", ""))); + inventory.setItem(14, ledgerItem(report, guild)); + List roster = new ArrayList<>(); + roster.add("#d4c9aeLeader: " + CharacterNames.display(viewer, guild.getLeader())); + if (report != null && report.allows("roster") && report.guildMembers != null) + report.guildMembers.getOrDefault(guild.getId(), List.of()).forEach(name -> roster.add("#d4c9ae" + name)); + inventory.setItem(15, styled(Material.PLAYER_HEAD, "#b8ae61Members: " + + IntelligenceLedger.value(report, guild, "Members", ""), roster.toArray(String[]::new))); + inventory.setItem(16, styled(Material.ENCHANTED_BOOK, "#a6659fUpgrades", "\u00a77Upgrades: Unknown")); + if (!guild.isBase()) inventory.setItem(17, styled(Material.GOLD_INGOT, "#c49e5cDividends", + "#d4c9aeRate: " + IntelligenceLedger.value(report, guild, "Dividend rate", "%"), + "#d4c9aePool: " + IntelligenceLedger.value(report, guild, "Dividend pool", "d"), + "#d4c9aeTax withheld: " + IntelligenceLedger.value(report, guild, "Dividend tax", "d"), + "#d4c9aePer member: " + IntelligenceLedger.value(report, guild, "Dividend per member", "d"))); + inventory.setItem(25, styled(Material.GOLD_INGOT, "#d6cf69Loans", "\u00a77Loans: Unknown")); + inventory.setItem(26, styled(Material.CHEST, "#b5835aSupply Hubs", "\u00a77Supply hubs: Unknown")); + inventory.setItem(GuildView.HOST_FACTION_SLOT, creator.createHostFactionItem(guild)); + inventory.setItem(53, manager.createBackButton(SFGUI.GUILD_VIEW)); + for (var entry : java.util.Map.of(12, MenuItemType.WEALTH, 13, MenuItemType.TRADE_BREAKDOWN, 15, MenuItemType.MEMBERS).entrySet()) + inventory.setItem(entry.getKey(), FactionCreator.applyIcon(inventory.getItem(entry.getKey()), entry.getValue())); + } + + public static void foreignLedger(Player viewer, Guild guild, InventoryManager manager) { + Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(guild.getId(), SFGUI.FOREIGN_LEDGER_VIEW), + 54, "\u00a77Reported Ledger for " + org.bukkit.ChatColor.stripColor(guild.getName())); + var report = EspionageService.report(viewer, guild.getFaction()); + inventory.setItem(4, ledgerItem(report, guild)); + int slot = 9; + for (Cashflow flow : Cashflow.values()) inventory.setItem(slot++, styled(Material.PAPER, flow.getDisplay(), + "#d6cf69Reported daily amount: " + IntelligenceLedger.value(report, guild, "Cashflow:" + flow.name(), "d/day"))); + inventory.setItem(53, manager.createBackButton(SFGUI.FOREIGN_LEDGER_VIEW)); + viewer.openInventory(inventory); + } + + private static String value(IntelligenceReport report, String metric, String units) { + String value = report == null ? "Unknown" : report.display(metric); + return value.equals("Unknown") ? "\u00a77" + value : value + units; + } + + private static ItemStack styled(Material material, String title, String... lore) { + ItemStack result = item(material, title, lore); + var meta = result.getItemMeta(); + meta.setDisplayName(StringFormatter.formatHex(title)); + if (material == Material.PLAYER_HEAD) meta.addItemFlags(org.bukkit.inventory.ItemFlag.HIDE_ADDITIONAL_TOOLTIP); + result.setItemMeta(meta); + return result; + } + + public static ItemStack factionItem(Player viewer, Faction target, MenuItemType type) { + IntelligenceReport report = EspionageService.report(viewer, target); + return switch (type) { + case GOVERNMENT -> { + ItemStack head = styled(Material.PLAYER_HEAD, "#93c9a7Government:", + "#9c9775§l" + target.getRulerTitle() + ": #c2bea7" + CharacterNames.display(viewer, target.getLeader()), + "#85c265Administrative Power§7: §e" + value(report, "Administrative power", ""), + "#85c265Stability: " + IntelligenceReport.stabilityRange(report), " ", + "#b8ae61Ruling System: #d4c9ae" + target.getGovernmentString()); + SkullMeta meta = (SkullMeta) head.getItemMeta(); + meta.setOwningPlayer(Bukkit.getOfflinePlayer(target.getLeader())); + head.setItemMeta(meta); + yield head; + } + case WEALTH -> { + Integer rank = new net.tfminecraft.simplefactions.utils.FactionRanker() + .getVisibleRank(viewer, target, net.tfminecraft.simplefactions.enums.RankType.WEALTH); + yield styled(Material.GOLD_NUGGET, "#d1b43fWealth: #ccbb76" + value(report, "Wealth", "d"), + rank == null ? "§7Ranking: Unknown" : "#7fbd73Estimated ranking: #" + rank); + } + case PRESTIGE -> styled(Material.DIAMOND, "#4793bfPrestige: #6eafba" + target.getPrestige(), + "#7fbd73Current Rank: " + target.getRank().getName(), + "#7fbd73Prestige ranking: #" + new net.tfminecraft.simplefactions.utils.FactionRanker().getPrestigeRank(target)); + case MEMBERS -> { + List lore = new ArrayList<>(); + lore.add("#d4c9aeLeader: " + CharacterNames.display(viewer, target.getLeader()) + " " + + net.tfminecraft.simplefactions.utils.Represents.represents(target, target.getLeader())); + if (report != null && report.allows("roster") && report.members != null) report.members.forEach(member -> lore.add("#d4c9ae" + member)); + yield styled(Material.PLAYER_HEAD, "#b8ae61Members: #7fbd73" + value(report, "Members", ""), lore.toArray(String[]::new)); + } + case MODIFIERS -> styled(Material.GOLDEN_APPLE, "#c49760Modifiers", + "#4bb244Prosperity: #4fd945" + value(report, "Prosperity", ""), "§7Faction modifiers: Unknown"); + case TAX -> styled(Material.GOLD_INGOT, "#c77c32Tax Rates", "§7Tax rates: Unknown"); + case LAWS -> styled(Material.WRITABLE_BOOK, "#9c64b0Laws", "§7Laws: Unknown"); + case MILITARY -> styled(Material.IRON_SWORD, "#a6659fMilitary", + "#d4c9aeProfessional army: §e" + value(report, "Professional army", ""), + "#d4c9aeLevies: §e" + value(report, "Levies", ""), + "#d4c9aeMercenaries: §e" + value(report, "Mercenaries", "")); + case INSTALLATIONS -> styled(Material.GREEN_CONCRETE, "#706964Installations", + "#d4c9aeInstallations: §e" + value(report, "Installations", "")); + case DIPLOMACY -> { + ItemStack item = styled(Material.WRITABLE_BOOK, "#35f2bdDiplomacy", + "#7fbd73Diplomatic Capacity: §7Unknown", "§7Click to view Diplomacy"); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(new NamespacedKey(SimpleFactions.plugin, "id"), PersistentDataType.STRING, target.getId()); + item.setItemMeta(meta); + yield item; + } + default -> throw new IllegalArgumentException("No private faction item for " + type); + }; + } + + public static ItemStack positionButton() { + return item(Material.ENDER_EYE, "Special Positions", "§7Inspect your faction's special offices.", + "§aClick to view offices."); + } + + public static ItemStack foreignPositionsItem(Player viewer, Faction faction) { + List lore = new ArrayList<>(); + var report = EspionageService.report(viewer, faction); + for (var office : SpecialPosition.values()) { + var holder = EspionageService.canViewExact(viewer, faction) ? faction.getEspionage().holder(office) : null; + String name = EspionageService.canViewExact(viewer, faction) + ? holder == null ? "Vacant" : CharacterNames.display(viewer, holder.playerName) + : report == null ? "Unknown" : report.officeHolder(office); + String aptitude = EspionageService.canViewExact(viewer, faction) + ? Integer.toString(office == SpecialPosition.SPYMASTER ? EspionageService.effectiveAptitude(faction, holder) + : holder == null ? 0 : holder.aptitude) + : report == null ? "Unknown" : report.display(IntelligenceReport.officeAptitudeKey(office)); + lore.add("\u00a77" + office.label() + ": " + name); + lore.add("\u00a77Aptitude: " + aptitude + (aptitude.equals("Unknown") ? "" : "/100")); + } + lore.add("\u00a7aClick to inspect offices."); + return item(Material.ENDER_EYE, "Special Positions", lore.toArray(String[]::new)); + } + + public static void foreignPositions(Player viewer, Faction faction, InventoryManager manager) { + Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(faction.getId(), SFGUI.FOREIGN_POSITIONS_VIEW), + 27, "\u00a77Foreign Special Positions"); + inventory.setItem(4, EspionageService.canViewExact(viewer, faction) + ? item(Material.PAPER, "Court offices", "\u00a77Exact information") : reportHeader(viewer, EspionageService.report(viewer, faction))); + inventory.setItem(13, foreignPositionsItem(viewer, faction)); + inventory.setItem(26, manager.createBackButton(SFGUI.FOREIGN_POSITIONS_VIEW)); + viewer.openInventory(inventory); + } + + public static void positions(Player viewer, Faction faction, InventoryManager manager) { + if (!EspionageService.isOwn(viewer, faction)) { foreignPositions(viewer, faction, manager); return; } + Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(faction.getId(), SFGUI.SPECIAL_POSITIONS), + 27, "\u00a77Special Positions"); + int slot = 10; + for (SpecialPosition office : SpecialPosition.values()) { + var holder = office == SpecialPosition.SPYMASTER ? EspionageService.spymaster(faction) : faction.getEspionage().holder(office); + ItemStack option = item(Material.ENDER_EYE, office.label(), + "\u00a77Office holder: " + (holder == null ? "Vacant" : CharacterNames.display(viewer, holder.playerName)), + "\u00a7aClick to inspect this office."); + var meta = option.getItemMeta(); + meta.getPersistentDataContainer().set(new NamespacedKey(SimpleFactions.plugin, "special-office"), + PersistentDataType.STRING, office.name()); + option.setItemMeta(meta); + inventory.setItem(slot++, option); + } + inventory.setItem(26, manager.createBackButton(SFGUI.SPECIAL_POSITIONS)); + viewer.openInventory(inventory); + } + + public static void spymasterOffice(Player viewer, Faction faction, InventoryManager manager) { + if (!EspionageService.isOwn(viewer, faction)) return; + Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(faction.getId(), SFGUI.SPYMASTER_VIEW), + 27, "\u00a77Spymaster's Office"); + SpecialPositionAssignment holder = EspionageService.spymaster(faction); + ItemStack head = item(Material.PLAYER_HEAD, "Spymaster: " + (holder == null ? "Vacant" : CharacterNames.display(viewer, holder.playerName)), + "§7Aptitude: §e" + EspionageService.effectiveAptitude(faction, holder) + "/100", + 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 && 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) { + SkullMeta meta = (SkullMeta) head.getItemMeta(); + meta.setOwningPlayer(Bukkit.getOfflinePlayer(holder.playerId)); + head.setItemMeta(meta); + } + inventory.setItem(13, head); + if (faction.isLeader(viewer.getName())) { + double cost = EspionageService.appointmentCost(faction); + inventory.setItem(11, item(Material.NAME_TAG, "Appoint Spymaster", "§7Choose a member of your faction.", + faction.getEspionage().appointmentCount(SpecialPosition.SPYMASTER) == 0 + ? "\u00a7aFirst appointment: free" : "\u00a77Treasury cost: \u00a7e" + cost + "d", + 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(26, manager.createBackButton(SFGUI.SPYMASTER_VIEW)); + viewer.openInventory(inventory); + } + + public static void settings(Player viewer, Faction faction, InventoryManager manager) { + SpecialPositionAssignment holder = EspionageService.spymaster(faction); + if (!EspionageService.isOwn(viewer, faction) || holder == null || !holder.isHolder(viewer.getUniqueId())) return; + Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(faction.getId(), SFGUI.SPYMASTER_SETTINGS), + 27, "§7Spymaster's Private Conduct"); + inventory.setItem(4, item(Material.PAPER, "Your sealed instructions", + "§7Aptitude: §e" + EspionageService.effectiveAptitude(faction, holder) + "/100", + "§7Sabotage is voluntary and disabled on appointment.", + "§7Only you can see or change these choices.", + "§7Changes affect your next daily rolls.", "§7Existing daily rolls and reports never reroll.")); + inventory.setItem(11, conduct("Offensive sabotage", holder.offenseReduction)); + inventory.setItem(15, conduct("Defensive sabotage", holder.defenseReduction)); + inventory.setItem(26, manager.createBackButton(SFGUI.SPYMASTER_SETTINGS)); + viewer.openInventory(inventory); + } + + private static ItemStack conduct(String title, int reduction) { + return item(reduction == 0 ? Material.LIME_DYE : Material.RED_DYE, title, + reduction == 0 ? "§aDisabled" : "§cRoll reduced by " + reduction, + "§7Click to cycle: disabled, -25, -50, -75, -100.", "§8A private choice for roleplay betrayal."); + } + + private static void candidates(Player viewer, Faction faction, InventoryManager manager, int page) { + if (!EspionageService.isOwn(viewer, faction) || !faction.isLeader(viewer.getName())) return; + List members = faction.getMembers().stream().distinct() + .filter(name -> EspionageService.eligible(faction, name)).sorted(String.CASE_INSENSITIVE_ORDER).toList(); + int safePage = Math.max(0, Math.min(page, Math.max(0, (members.size() - 1) / 45))); + Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(faction.getId(), SFGUI.SPYMASTER_SELECT, safePage), + 54, "§7Appoint Spymaster"); + int start = safePage * 45; + for (int index = start; index < Math.min(start + 45, members.size()); index++) { + String name = members.get(index); + ItemStack head = item(Material.PLAYER_HEAD, CharacterNames.display(viewer, name), + "§7Guild: " + net.tfminecraft.simplefactions.utils.Represents.represents(faction, name), + "\u00a77Treasury cost: " + EspionageService.appointmentCost(faction) + "d", + Bukkit.getPlayerExact(name) == null ? "§8Must be online with an active character." : "§aClick to appoint as Spymaster."); + var meta = head.getItemMeta(); + meta.getPersistentDataContainer().set(new NamespacedKey(SimpleFactions.plugin, "spy-candidate"), + PersistentDataType.STRING, name); + head.setItemMeta(meta); + inventory.setItem(index - start, head); + } + if (safePage > 0) inventory.setItem(45, item(Material.ARROW, "Previous page")); + if (start + 45 < members.size()) inventory.setItem(52, item(Material.ARROW, "Next page")); + inventory.setItem(53, manager.createBackButton(SFGUI.SPYMASTER_SELECT)); + viewer.openInventory(inventory); + } + + public static boolean handles(SFGUI type) { + return type == SFGUI.SPECIAL_POSITIONS || type == SFGUI.SPYMASTER_VIEW + || type == SFGUI.SPYMASTER_SETTINGS || type == SFGUI.SPYMASTER_SELECT || type == SFGUI.FOREIGN_LEDGER_VIEW || type == SFGUI.FOREIGN_POSITIONS_VIEW; + } + + public static void click(InventoryClickEvent event, Player viewer, SFInventoryHolder menu, InventoryManager manager) { + event.setCancelled(true); + if (menu.getType() == SFGUI.FOREIGN_POSITIONS_VIEW) { + var faction = FactionManager.getByString(menu.getId()); + if (faction == null) viewer.closeInventory(); + else if (event.getRawSlot() == 26) manager.factionView(viewer, faction); + else foreignPositions(viewer, faction, manager); // Recheck current tier and viewing permission. + return; + } + if (menu.getType() == SFGUI.FOREIGN_LEDGER_VIEW) { + if (event.getRawSlot() == 53) { + var guild = FactionManager.getGuildByString(menu.getId()); + if (guild != null) { + if (guild.isBase()) manager.factionView(viewer, guild.getFaction()); + else manager.guildView(viewer, guild); + } + } + return; + } + Faction faction = FactionManager.getByString(menu.getId()); + if (faction == null || !EspionageService.isOwn(viewer, faction)) { + viewer.closeInventory(); + return; + } + int slot = event.getRawSlot(); + if (menu.getType() == SFGUI.SPECIAL_POSITIONS) { + if (slot == 26) manager.factionView(viewer, faction); + else if (event.getCurrentItem() != null && event.getCurrentItem().hasItemMeta()) { + String office = event.getCurrentItem().getItemMeta().getPersistentDataContainer() + .get(new NamespacedKey(SimpleFactions.plugin, "special-office"), PersistentDataType.STRING); + if (SpecialPosition.SPYMASTER.name().equals(office)) spymasterOffice(viewer, faction, manager); + } + } else if (menu.getType() == SFGUI.SPYMASTER_VIEW) { + if (slot == 26) positions(viewer, faction, manager); + else if (slot == 13) settings(viewer, faction, manager); + else if (slot == 11) candidates(viewer, faction, manager, 0); + else if (slot == 15 && EspionageService.remove(viewer, faction)) spymasterOffice(viewer, faction, manager); + } else if (menu.getType() == SFGUI.SPYMASTER_SETTINGS) { + if (slot == 26) spymasterOffice(viewer, faction, manager); + else if (slot == 11 || slot == 15) { + SpecialPositionAssignment holder = EspionageService.spymaster(faction); + if (holder == null || !holder.isHolder(viewer.getUniqueId())) { + viewer.closeInventory(); + return; + } + int current = slot == 11 ? holder.offenseReduction : holder.defenseReduction; + if (EspionageService.setSabotage(viewer, faction, slot == 11, (current + 25) % 125)) { + settings(viewer, faction, manager); + } + } + } else { + if (!faction.isLeader(viewer.getName())) { viewer.closeInventory(); return; } + if (slot == 53) spymasterOffice(viewer, faction, manager); + else if (slot == 45 || slot == 52) candidates(viewer, faction, manager, menu.getPage() + (slot == 45 ? -1 : 1)); + else if (event.getCurrentItem() != null && event.getCurrentItem().hasItemMeta()) { + String candidate = event.getCurrentItem().getItemMeta().getPersistentDataContainer() + .get(new NamespacedKey(SimpleFactions.plugin, "spy-candidate"), PersistentDataType.STRING); + if (candidate != null && EspionageService.appoint(viewer, faction, Bukkit.getPlayerExact(candidate))) { + spymasterOffice(viewer, faction, manager); + } + } + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java index 7daef7b2..6db234af 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.espionage.EspionageService; +import net.tfminecraft.simplefactions.espionage.CharacterNames; import net.tfminecraft.simplefactions.util.LegacyModelData; import java.util.ArrayList; @@ -60,12 +62,30 @@ public ItemStack createListItem(Player p, Faction f) { ItemStack i = new ItemStack(f.getBanner()); ItemMeta meta = i.getItemMeta(); meta.setDisplayName("§f"+f.getName()); + List lore = listLore(p, f); + meta.setLore(lore); + NamespacedKey id = new NamespacedKey(SimpleFactions.plugin, "id"); + meta.getPersistentDataContainer().set(id, PersistentDataType.STRING, f.getId()); + i.setItemMeta(meta); + return i; + } + + private static String intelligence(net.tfminecraft.simplefactions.espionage.IntelligenceReport report, String metric, String units) { + String value = report == null ? "Unknown" : report.display(metric); + return value.equals("Unknown") ? "\u00a77" + value : value + units; + } + + public List listLore(Player p, Faction f) { + boolean exact = EspionageService.canViewExact(p, f); + var report = exact ? null : EspionageService.report(p, f); List lore = new ArrayList(); lore.add(f.getRank().getName()); - if (f.getGovernment() != null) { - net.tfminecraft.simplefactions.government.stability.StabilityStatus status = f.getGovernment().stateReport().status; - lore.add(StringFormatter.formatHex(status.getListColor() + status.getLabel())); - } + if (exact && f.getGovernment() != null) { + var status = f.getGovernment().stateReport().status; + lore.add(StringFormatter.formatHex(status.getListColor() + status.getLabel())); + } else if (!exact) { + lore.add(StringFormatter.formatHex(net.tfminecraft.simplefactions.espionage.IntelligenceReport.stabilityState(report))); + } if(f.getTitles().size() > 0) lore.add(StringFormatter.formatHex("#b84c44§lPrimary Title: #7a706a"+f.getHighestTitle().getName())); lore.add(StringFormatter.formatHex("#c45749§lTier: "+f.getTier().getFormattedName())); lore.add(StringFormatter.formatHex("#b8ae61Based in: #d4c9ae" + HomeSettlementNames.of(f))); @@ -73,18 +93,25 @@ public ItemStack createListItem(Player p, Faction f) { if(realmSize > 0 && realmSize-f.getProvinces().size() > 0) lore.add(StringFormatter.formatHex("#d4c9aeRealm Size: #7a706a"+realmSize+" #a39ba8("+(realmSize-f.getProvinces().size())+" from subjects)")); else if(realmSize > 0) lore.add(StringFormatter.formatHex("#d4c9aeRealm Size: #7a706a"+realmSize)); lore.add(" "); - lore.add(StringFormatter.formatHex("#9c9775"+f.getRulerTitle()+": #c2bea7"+f.getLeader())); + lore.add(StringFormatter.formatHex("#9c9775"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader()))); lore.add(StringFormatter.formatHex("#b8ae61Ruling System: #d4c9ae"+f.getGovernmentString())); lore.add(StringFormatter.formatHex("#b8ae61Culture: #d4c9ae"+f.getCulture())); lore.add(StringFormatter.formatHex("#b8ae61Religion: #d4c9ae"+f.getReligion())); - int subjectMembers = f.getCompleteMemberList().size()-f.getMembers().size(); - lore.add(StringFormatter.formatHex("#b8ae61Members: #7fbd73"+f.getCompleteMemberList().size()+((subjectMembers > 0) ? " #a39ba8("+subjectMembers+" from subjects)" : ""))); + if (exact) { + int subjectMembers = f.getCompleteMemberList().size() - f.getMembers().size(); + lore.add(StringFormatter.formatHex("#b8ae61Members: #7fbd73" + f.getCompleteMemberList().size() + + (subjectMembers > 0 ? " #a39ba8(" + subjectMembers + " from subjects)" : ""))); + } else lore.add(StringFormatter.formatHex("#b8ae61Members: #7fbd73" + intelligence(report, "Members", ""))); lore.add(" "); lore.add(StringFormatter.formatHex("#4793bfPrestige: #6eafba"+f.getPrestige()+" #7a706a("+r.getPrestigeRank(f)+")")); - lore.add(StringFormatter.formatHex("#d1b43fWealth: #ccbb76"+f.getWealth()+"d #7a706a("+r.getWealthRank(f)+")")); - double prosperity = f.getProsperity(); - if(prosperity > 0) lore.add(StringFormatter.formatHex("#4bb244Prosperity: #4fd945"+f.getProsperity())); - if(!f.getMembers().contains(p.getName())) { + Integer wealthRank = r.getVisibleRank(p, f, RankType.WEALTH); + String wealth = exact ? f.getWealth() + "d" : intelligence(report, "Wealth", "d"); + lore.add(StringFormatter.formatHex("#d1b43fWealth: #ccbb76" + wealth + + (wealthRank == null ? "" : " #7a706a(" + (exact ? "" : "estimated ") + wealthRank + ")"))); + if (exact) { + if (f.getProsperity() > 0) lore.add(StringFormatter.formatHex("#4bb244Prosperity: #4fd945" + f.getProsperity())); + } else lore.add(StringFormatter.formatHex("#4bb244Prosperity: #4fd945" + intelligence(report, "Prosperity", ""))); + if(p != null && !EspionageService.isOwn(p, f)) { Faction origin = FactionManager.getByMember(p.getName()); if(origin != null) { Relation r = origin.getRelation(f.getId()); @@ -125,12 +152,8 @@ public ItemStack createListItem(Player p, Faction f) { lore.add(StringFormatter.formatHex("#bccbd1- "+ally.getName())); } } - meta.setLore(lore); - NamespacedKey id = new NamespacedKey(SimpleFactions.plugin, "id"); - meta.getPersistentDataContainer().set(id, PersistentDataType.STRING, f.getId()); - i.setItemMeta(meta); - return i; - } + return lore; + } // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") @@ -233,6 +256,10 @@ public ItemStack createDissolveItem(Faction f) { @SuppressWarnings("deprecation") public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { + if (!EspionageService.canViewExact(p, f) && java.util.Set.of(MenuItemType.GOVERNMENT, + MenuItemType.WEALTH, MenuItemType.PRESTIGE, MenuItemType.MEMBERS, MenuItemType.MODIFIERS, + MenuItemType.TAX, MenuItemType.LAWS, MenuItemType.MILITARY, MenuItemType.INSTALLATIONS, + MenuItemType.DIPLOMACY).contains(t)) return applyIcon(EspionageView.factionItem(p, f, t), t); ItemStack i = new ItemStack(Material.DIRT, 1); if(t.equals(MenuItemType.BANNER)) { i = new ItemStack(f.getBanner()); @@ -264,7 +291,7 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { i = new ItemStack(Material.PLAYER_HEAD, 1); SkullMeta m = (SkullMeta) i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - m.setDisplayName(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+f.getLeader())); + m.setDisplayName(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader()))); m.setOwningPlayer(Bukkit.getOfflinePlayer(f.getLeader())); List lore = new ArrayList(); lore.add(StringFormatter.formatHex("#b8ae61Ruling System: #d4c9ae"+f.getGovernmentString())); @@ -278,7 +305,7 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { m.setOwningPlayer(Bukkit.getOfflinePlayer(f.getLeader())); List lore = new ArrayList(); Government gov = f.getGovernment(); - lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+f.getLeader())); + lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader()))); double power = Formatter.formatDouble(gov.getPower()); double maxPower = Formatter.formatDouble(gov.getMaxPower()); String powerString = ((power < 0) ? "§c" : "") + power+"/"+((maxPower < 0) ? "§c" : "") + maxPower; @@ -377,13 +404,15 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { i = new ItemStack(Material.PLAYER_HEAD, 1); ItemMeta m = i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - List memberNames = f.getCompleteMemberList(); + List memberNames = new ArrayList<>(f.getCompleteMemberList()); + memberNames.removeIf(name -> name.equalsIgnoreCase(f.getLeader())); + memberNames.add(0, f.getLeader()); m.setDisplayName(StringFormatter.formatHex("#b8ae61Members: #7fbd73"+memberNames.size())); List lore = new ArrayList(); int count = 0; for(String s : memberNames) { if(count == 24) break; - lore.add(StringFormatter.formatHex("#d4c9ae"+s+" "+Represents.represents(f, s))); + lore.add(StringFormatter.formatHex("#d4c9ae"+CharacterNames.display(p, s)+" "+Represents.represents(f, s))); count++; } if(count < memberNames.size()) { @@ -555,6 +584,10 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { m.getPersistentDataContainer().set(key, PersistentDataType.STRING, f.getId()); i.setItemMeta(m); } + return applyIcon(i, t); + } + + public static ItemStack applyIcon(ItemStack i, MenuItemType t) { if(IconGetter.hasIcon(t.toString())) { ItemStack icon = IconGetter.getIcon(t.toString()); i.setType(icon.getType()); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java index c6451fdb..face7b64 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java @@ -1,11 +1,13 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.espionage.EspionageService; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import org.bukkit.NamespacedKey; +import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.event.inventory.InventoryAction; @@ -73,11 +75,9 @@ public void factionList(Player player, Inventory inv) { public void populateFactionList(Inventory inv, Player player) { currentRanking.putIfAbsent(player, RankType.PRESTIGE); currentPage.putIfAbsent(player, 0); - RankType rankType = currentRanking.get(player); int page = currentPage.get(player); - List factions = r.getRankedList(rankType); - Collections.reverse(factions); + List factions = r.getVisibleRankedList(player, currentRanking.get(player)); List usableSlots = new ArrayList<>(); for (int i = 0; i < INVENTORY_SIZE; i++) { @@ -97,7 +97,7 @@ public void populateFactionList(Inventory inv, Player player) { inv.setItem(slot, creator.createListItem(player, f)); } - inv.setItem(8, DefaultCreator.createRankButton(rankType)); + inv.setItem(8, DefaultCreator.createRankButton(currentRanking.get(player))); if (page > 0) { inv.setItem(PREV_PAGE_SLOT, DefaultCreator.createPreviousPageButton()); @@ -116,7 +116,13 @@ public void factionView(Player player, Faction f) { public void factionView(Player player, Faction f, Inventory i) { boolean open = i == null; if(open) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.FACTION_VIEW), 54, "§7Faction View"); + if (!EspionageService.canViewExact(player, f)) { + EspionageView.foreign(i, player, f, inv); + if (open) player.openInventory(i); + return; + } i.clear(); + i.setItem(20, EspionageService.isOwn(player, f) ? EspionageView.positionButton() : EspionageView.foreignPositionsItem(player, f)); if(f.getMembers().contains(player.getName())) i.setItem(1, creator.createMenuItem(player, f, MenuItemType.BANNER_GET)); i.setItem(10, creator.createMenuItem(player, f, MenuItemType.BANNER)); if(f.getLeader().equalsIgnoreCase(player.getName())) i.setItem(19, creator.createMenuItem(player, f, MenuItemType.BANNER_RANDOM)); @@ -153,7 +159,7 @@ public void factionGuildsView(Player player, Faction f, Inventory i) { INVENTORY_SIZE, "§7Faction Guilds"); } - populateFactionGuilds(i, f, page); + populateFactionGuilds(i, player, f, page); if (open) { player.openInventory(i); } @@ -166,11 +172,11 @@ public void factionGuildsView(Player player, Faction f, int page) { new SFInventoryHolder(f.getId(), SFGUI.FACTION_GUILDS, page), INVENTORY_SIZE, "§7Faction Guilds"); - populateFactionGuilds(i, f, page); + populateFactionGuilds(i, player, f, page); player.openInventory(i); } - public void populateFactionGuilds(Inventory inv, Faction f, int page) { + public void populateFactionGuilds(Inventory inv, Player player, Faction f, int page) { List guilds = new ArrayList<>(f.getGuildHandler().getGuilds()); List usableSlots = new ArrayList<>(); for (int i = 0; i < INVENTORY_SIZE; i++) { @@ -183,7 +189,7 @@ public void populateFactionGuilds(Inventory inv, Faction f, int page) { int start = page * perPage; int end = Math.min(start + perPage, guilds.size()); for (int i = start; i < end; i++) { - inv.setItem(usableSlots.get(i - start), guildCreator.createListItem(null, guilds.get(i))); + inv.setItem(usableSlots.get(i - start), guildCreator.createListItem(player, guilds.get(i))); } if (page > 0) { inv.setItem(PREV_PAGE_SLOT, DefaultCreator.createPreviousPageButton()); @@ -234,21 +240,12 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { return; } if(e.getSlot() == 8) { - RankType t = currentRanking.get(p); - switch (t) { - case PRESTIGE: - currentRanking.put(p, RankType.WEALTH); - break; - case WEALTH: - currentRanking.put(p, RankType.MEMBERS); - break; - default: - currentRanking.put(p, RankType.PRESTIGE); - break; - } + RankType ranking = currentRanking.getOrDefault(p, RankType.PRESTIGE); + currentRanking.put(p, ranking == RankType.PRESTIGE ? RankType.WEALTH + : ranking == RankType.WEALTH ? RankType.MEMBERS : RankType.PRESTIGE); currentPage.put(p, 0); - p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); factionList(p); + return; } else{ ItemStack i = e.getCurrentItem(); if(i == null) return; @@ -264,6 +261,15 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { } } else if(e.getView().getTitle().equalsIgnoreCase("§7Faction View")) { e.setCancelled(true); + if (!(inventory.getHolder() instanceof SFInventoryHolder holder)) return; + Faction viewed = FactionManager.getByString(holder.getId()); + if (viewed == null) return; + if (!EspionageService.canViewExact(p, viewed) + && e.getSlot() != 23 && e.getSlot() != 31 && e.getSlot() != 53 && e.getSlot() != 15 && e.getSlot() != 20) return; + if (e.getSlot() == 20) { + EspionageView.positions(p, viewed, inv); + return; + } if(e.getSlot() == 28) { ItemStack item = e.getCurrentItem(); ItemMeta m = item.getItemMeta(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java index c31c9876..3ff737c3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java @@ -59,13 +59,15 @@ public class GovernmentCreator { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - public ItemStack createGovernmentItem(Faction f) { + public ItemStack createGovernmentItem(Faction f) { return createGovernmentItem(null, f); } + + public ItemStack createGovernmentItem(Player p, Faction f) { ItemStack item = new ItemStack(Material.WRITABLE_BOOK); ItemMeta m = item.getItemMeta(); m.setDisplayName(StringFormatter.formatHex("#93c9a7Government:")); List lore = new ArrayList(); Government gov = f.getGovernment(); - lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+f.getLeader())); + lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+net.tfminecraft.simplefactions.espionage.CharacterNames.display(p, f.getLeader()))); double power = Formatter.formatDouble(gov.getPower()); double maxPower = Formatter.formatDouble(gov.getMaxPower()); String powerString = ((power < 0) ? "§c" : "") + power+"/"+((maxPower < 0) ? "§c" : "") + maxPower; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java index 25d3ba5b..490c176e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.espionage.EspionageService; import java.util.List; import org.bukkit.Sound; @@ -54,10 +55,11 @@ public GovernmentView(InventoryManager inv) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void governmentView(Player player, Faction f, Inventory i) { + if (!EspionageService.canViewExact(player, f)) { inv.factionView(player, f); return; } boolean open = i == null; if(i == null) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.GOVERNMENT_VIEW), 54, "§7Government View"); i.clear(); - i.setItem(10, creator.createGovernmentItem(f)); + i.setItem(10, creator.createGovernmentItem(player, f)); i.setItem(11, creator.createStabilityItem(f)); i.setItem(12, creator.createLegitimacyItem(f)); i.setItem(13, creator.createCouncilItem(f)); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java index 1022037e..2e44485e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.espionage.EspionageService; +import net.tfminecraft.simplefactions.espionage.CharacterNames; import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; import net.tfminecraft.simplefactions.util.LegacyModelData; @@ -55,6 +57,9 @@ import net.tfminecraft.tlibs.TLibs; public class GuildCreator { + public ItemStack createPublicListItem(Player viewer, Guild guild) { + return createListItem(viewer, guild); + } FactionRanker r = new FactionRanker(); @@ -74,21 +79,29 @@ public ItemStack createListItem(Player p, Guild guild) { ItemStack i = new ItemStack(guild.getBanner()); ItemMeta meta = i.getItemMeta(); meta.setDisplayName("§f"+guild.getName()); + boolean own = EspionageService.canViewExact(p, guild.getFaction()); + var report = own ? null : EspionageService.report(p, guild.getFaction()); List lore = new ArrayList(); lore.add(StringFormatter.formatHex("#7a706a§lType: "+guild.getType().getName())); if(guild.hasCapital()) lore.add(StringFormatter.formatHex("#c45749§lSize: #d4c9ae"+guild.getSize())); lore.add(StringFormatter.formatHex("#b8ae61Part of: "+guild.getFaction().getName())); lore.add(StringFormatter.formatHex("#b8ae61Based in: #d4c9ae" + HomeSettlementNames.of(guild))); lore.add(" "); - lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7"+guild.getLeader())); - lore.add(StringFormatter.formatHex("#b8ae61Members: #7fbd73"+guild.getMembers().size())); + lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7"+CharacterNames.display(p, guild.getLeader()))); + lore.add(StringFormatter.formatHex("#b8ae61Members: #7fbd73"+(own ? String.valueOf(guild.getMembers().size()) : guildIntelligence(report, guild, "Members", "")))); lore.add(" "); - if(guild.hasCapital()) { - lore.add(StringFormatter.formatHex("#41b541Trade Power: #a4bc5c"+guild.getTradeBreakdown().getTradePower())); - double income = guild.getLedger().getNetIncome(); - lore.add(StringFormatter.formatHex("#74ba74Estimated Income: "+(income > 0 ? "#5cbc5c" : "#c45749")+income+"d/day")); - } - lore.add(StringFormatter.formatHex("#d1b43fWealth: #ccbb76"+guild.getWealth()+"d #7a706a("+r.getWealthRank(guild)+")")); + if (guild.hasCapital()) { + lore.add(StringFormatter.formatHex("#41b541Trade Power: #a4bc5c" + + (own ? String.valueOf(guild.getTradeBreakdown().getTradePower()) : guildIntelligence(report, guild, "Trade power", "")))); + String income = own ? guild.getLedger().getNetIncome() + "d/day" : guildIntelligence(report, guild, "Income", "d/day"); + Double midpoint = r.visibleGuildValue(p, guild, RankType.INCOME); + lore.add(StringFormatter.formatHex("#74ba74Estimated Income: " + + (midpoint != null && midpoint <= 0 ? "#c45749" : "#5cbc5c") + income)); + } + Integer rank = r.getVisibleGuildRank(p, guild, RankType.WEALTH); + String wealth = own ? guild.getWealth() + "d" : guildIntelligence(report, guild, "Wealth", "d"); + lore.add(StringFormatter.formatHex("#d1b43fWealth: #ccbb76" + wealth + + (rank == null ? "" : " #7a706a(" + (own ? "" : "estimated ") + rank + ")"))); meta.setLore(lore); NamespacedKey id = new NamespacedKey(SimpleFactions.plugin, "id"); meta.getPersistentDataContainer().set(id, PersistentDataType.STRING, guild.getId()); @@ -96,6 +109,12 @@ public ItemStack createListItem(Player p, Guild guild) { return i; } + private static String guildIntelligence(net.tfminecraft.simplefactions.espionage.IntelligenceReport report, + Guild guild, String metric, String units) { + String value = report == null ? "Unknown" : report.display("Guild:" + guild.getId() + ":" + metric); + return value.equals("Unknown") ? "\u00a77" + value : value + units; + } + // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public ItemStack createHostFactionItem(Guild guild) { @@ -146,7 +165,7 @@ public ItemStack createMenuItem(Player p, Guild guild, MenuItemType t) { i = new ItemStack(Material.PLAYER_HEAD, 1); SkullMeta m = (SkullMeta) i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - m.setDisplayName(StringFormatter.formatHex("#9c9775§lLeader: #c2bea7"+guild.getLeader())); + m.setDisplayName(StringFormatter.formatHex("#9c9775§lLeader: #c2bea7"+CharacterNames.display(p, guild.getLeader()))); m.setOwningPlayer(Bukkit.getOfflinePlayer(guild.getLeader())); i.setItemMeta(m); } else if(t.equals(MenuItemType.WEALTH)) { @@ -197,11 +216,14 @@ public ItemStack createMenuItem(Player p, Guild guild, MenuItemType t) { lore.add(""); lore.add(StringFormatter.formatHex("#c95644Top 5 Guilds:")); x = 0; - List top = (new FactionRanker()).getRankedGuildList(RankType.INCOME); - Collections.reverse(top); + List top = r.getVisibleRankedGuildList(p, RankType.INCOME); for(Guild g : top) { + if (r.visibleGuildValue(p, g, RankType.INCOME) == null) continue; x++; - lore.add(StringFormatter.formatHex("§f - §e"+x+". "+g.getName()+" §7["+g.getSize()+"§7]#d4c9ae: #7fbd73"+SimpleFactions.getInstance().getProvinceManager().getIncome(g)+"d/day")); + var report = EspionageService.report(p, g.getFaction()); + String income = EspionageService.canViewExact(p, g.getFaction()) ? String.valueOf(g.getLedger().getNetIncome()) + : report.display("Guild:" + g.getId() + ":Income"); + lore.add(StringFormatter.formatHex("§f - §e"+x+". "+g.getName()+" §7["+g.getSize()+"§7]#d4c9ae: #7fbd73"+income+"d/day")); if(x > 4) break; } m.setLore(lore); @@ -216,10 +238,13 @@ public ItemStack createMenuItem(Player p, Guild guild, MenuItemType t) { if (outputPenalty > 0) { lore.add(StringFormatter.formatHex("#c95644Inactive output -" + outputPenalty + "%. Upgrades stay, but they are weakened.")); } - for(String s : guild.getMembers()) { + List members = new ArrayList<>(guild.getMembers()); + members.removeIf(name -> name.equalsIgnoreCase(guild.getLeader())); + members.addFirst(guild.getLeader()); + for(String s : members) { String mark = net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(s) ? " #c95644(inactive)" : ""; - lore.add(StringFormatter.formatHex("#d4c9ae"+s+mark)); + lore.add(StringFormatter.formatHex("#d4c9ae"+CharacterNames.display(p, s)+mark)); } m.setLore(lore); i.setItemMeta(m); @@ -412,6 +437,8 @@ private String incomeChangeLine(Double delta) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public ItemStack createLedgerItem(Player p, Guild g) { + if (!EspionageService.canViewExact(p, g.getFaction())) + return EspionageView.ledgerItem(EspionageService.report(p, g.getFaction()), g); Ledger ledger = g.getLedger(); ItemStack i = new ItemStack(Material.WRITABLE_BOOK, 1); ItemMeta meta = i.getItemMeta(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java index 51d9c97d..b12b5f0e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.espionage.EspionageService; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -90,11 +91,9 @@ public void guildList(Player p, Inventory inv) { public void populateGuildList(Inventory inv, Player p) { currentRanking.putIfAbsent(p, RankType.WEALTH); currentPage.putIfAbsent(p, 0); - RankType rank = currentRanking.get(p); int page = currentPage.get(p); - List guilds = new FactionRanker().getRankedGuildList(rank); - Collections.reverse(guilds); + List guilds = new FactionRanker().getVisibleRankedGuildList(p, currentRanking.get(p)); List usableSlots = new ArrayList<>(); for (int i = 0; i < INVENTORY_SIZE; i++) { @@ -113,7 +112,7 @@ public void populateGuildList(Inventory inv, Player p) { inv.setItem(usableSlots.get(i - start), creator.createListItem(p, g)); } - inv.setItem(8, DefaultCreator.createRankButton(rank)); + inv.setItem(8, DefaultCreator.createRankButton(currentRanking.get(p))); if (page > 0) inv.setItem(PREV_PAGE_SLOT, DefaultCreator.createPreviousPageButton()); if (end < guilds.size()) inv.setItem(NEXT_PAGE_SLOT, DefaultCreator.createNextPageButton()); } @@ -121,6 +120,7 @@ public void populateGuildList(Inventory inv, Player p) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void ledgerView(Player player, Guild guild, Inventory i) { + if (!EspionageService.canViewExact(player, guild.getFaction())) { EspionageView.foreignLedger(player, guild, inv); return; } boolean open = i == null; if(i == null) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(guild.getId(), SFGUI.LEDGER_VIEW), 27, "§7Ledger for "+guild.getName()); i.clear(); @@ -155,6 +155,10 @@ public void guildView(Player player, Guild guild, boolean fromFaction) { } public void guildView(Player player, Guild guild, Inventory i) { + if (!EspionageService.canViewExact(player, guild.getFaction())) { + EspionageView.guild(i, player, guild, inv); + return; + } if(manager == null) setProvinceManager(SimpleFactions.getInstance().getProvinceManager()); if(guild.hasCapital()) { if(guild.getTradeBreakdown().getIncome() == 0) manager.recalculate(); @@ -223,6 +227,7 @@ public void upgradeView(Player player, Guild guild) { } public void upgradeView(Player player, Guild guild, Inventory i) { + if (!EspionageService.canViewExact(player, guild.getFaction())) { inv.factionView(player, guild.getFaction()); return; } i.clear(); List upgrades = guild.getUpgrades(); @@ -320,6 +325,9 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if (guild == null) { return; } + if (!EspionageService.canViewExact(p, guild.getFaction()) + && e.getSlot() != HOST_FACTION_SLOT && e.getSlot() != 53 && e.getSlot() != 14) return; + if (e.getSlot() == 14) { ledgerView(p, guild, null); return; } if (e.getSlot() == HOST_FACTION_SLOT) { Faction faction = guild.getFaction(); if (faction != null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java index 02146017..ee36dbb9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.espionage.EspionageService; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -41,6 +42,7 @@ public InstallationView(InventoryManager inv) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void installationsView(Inventory inventory, Player player, Faction f, boolean open) { + if (!EspionageService.canViewExact(player, f)) { inv.factionView(player, f); return; } if (open) { inventory = SimpleFactions.plugin @@ -90,6 +92,7 @@ public void installationDetailView(Player player, Faction f, String installation // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void installationDetailView(Player player, Faction f, String installationId, Inventory inventory) { + if (!EspionageService.canViewExact(player, f)) { inv.factionView(player, f); return; } InstallationHandler handler = f.getInstallationHandler(); InstallationConstruction pending = handler.getPendingConstruction(); boolean isPending = isPendingConstruction(pending, installationId); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java index 25781bd8..4cf7ac0c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.espionage.EspionageAccess; import java.util.EnumSet; import java.util.Set; @@ -45,6 +46,12 @@ public InventoryUpdater(InventoryManager inv) { private static final Set SKIP_PERIODIC_REFRESH = EnumSet.of( // Ledger projections and countdowns are snapshots refreshed on opening. SFGUI.FACTION_VIEW, + SFGUI.SPECIAL_POSITIONS, + SFGUI.SPYMASTER_VIEW, + SFGUI.FOREIGN_LEDGER_VIEW, + SFGUI.FOREIGN_POSITIONS_VIEW, + SFGUI.SPYMASTER_SETTINGS, + SFGUI.SPYMASTER_SELECT, SFGUI.PLAYER_LEDGER_VIEW, SFGUI.GUILD_VIEW, SFGUI.GUILD_LIST, @@ -107,6 +114,10 @@ public void updateInventory() { } private void refreshSFHolder(Player p, Inventory i, SFInventoryHolder h) { + if (EspionageAccess.denied(p, h)) { + p.closeInventory(); + return; + } SFGUI type = h.getType(); if (skipsPeriodicRefresh(type)) { return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java index cf9dfc5e..a1cdba31 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.espionage.EspionageService; import java.util.List; import org.bukkit.Sound; @@ -36,6 +37,7 @@ public LawView(InventoryManager inv) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void lawView(Player player, Faction f, Inventory i) { + if (!EspionageService.canViewExact(player, f)) { inv.factionView(player, f); return; } boolean open = i == null; if(open) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.LAW_VIEW), 54, "§7Laws"); i.clear(); @@ -50,6 +52,7 @@ public void lawView(Player player, Faction f, Inventory i) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void lawSelect(Player player, Faction f, LawGroup group, Inventory i) { + if (!EspionageService.canViewExact(player, f)) { inv.factionView(player, f); return; } boolean open = i == null; if(open) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.LAW_SELECT, group.getId()), 27, "§7Law View"); i.clear(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java index 167ce58e..e07e18a5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.espionage.EspionageService; import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.entity.Player; @@ -32,6 +33,7 @@ public MilitaryView(InventoryManager inv) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void militaryView(Inventory i, Player player, Faction f, boolean open) { + if (!EspionageService.canViewExact(player, f)) { inv.factionView(player, f); return; } if(open) { i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.MILITARY_VIEW), 54, "§7Military View"); } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index f69a542e..feb53c51 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.objects; +import net.tfminecraft.simplefactions.espionage.EspionageState; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; @@ -81,6 +82,16 @@ import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; public class Faction { + private EspionageState espionage = new EspionageState(); + + public EspionageState getEspionage() { + return espionage; + } + + public void setEspionage(EspionageState espionage) { + this.espionage = espionage != null ? espionage : new EspionageState(); + } + private Formatter format = new Formatter(); private String id; private String name; diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/FactionRanker.java b/src/main/java/net/tfminecraft/simplefactions/utils/FactionRanker.java index c97cada8..70368ba6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/FactionRanker.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/FactionRanker.java @@ -10,6 +10,62 @@ import net.tfminecraft.simplefactions.enums.RankType; public class FactionRanker { + public Double visibleGuildValue(org.bukkit.entity.Player viewer, Guild guild, RankType type) { + String metric = type == RankType.MEMBERS ? "Members" : type == RankType.INCOME ? "Income" + : type == RankType.TRADE_POWER ? "Trade power" : "Wealth"; + return net.tfminecraft.simplefactions.espionage.EspionageService.visibleValue(viewer, guild.getFaction(), + "Guild:" + guild.getId() + ":" + metric, + () -> type == RankType.MEMBERS ? guild.getMembers().size() : type == RankType.INCOME ? guild.getLedger().getNetIncome() + : type == RankType.TRADE_POWER ? guild.getTradeBreakdown().getTradePower() : guild.getWealth()); + } + + public List getVisibleRankedGuildList(org.bukkit.entity.Player viewer, RankType type) { + List guilds = new java.util.ArrayList<>(FactionManager.getAllGuilds()); + java.util.Map values = new java.util.HashMap<>(); + for (Guild guild : guilds) values.put(guild, visibleGuildValue(viewer, guild, type)); + guilds.sort((first, second) -> { + Double a = values.get(first), b = values.get(second); + if (a == null && b != null) return 1; + if (a != null && b == null) return -1; + int comparison = a == null ? 0 : Double.compare(b, a); + return comparison != 0 ? comparison : first.getId().compareToIgnoreCase(second.getId()); + }); + return guilds; + } + + public Integer getVisibleGuildRank(org.bukkit.entity.Player viewer, Guild target, RankType type) { + if (visibleGuildValue(viewer, target, type) == null) return null; + var ranked = getVisibleRankedGuildList(viewer, type); + for (int index = 0; index < ranked.size(); index++) if (ranked.get(index).getId().equals(target.getId())) return index + 1; + return null; + } + public Double visibleValue(org.bukkit.entity.Player viewer, Faction faction, RankType type) { + String metric = type == RankType.PRESTIGE ? "Prestige" : type == RankType.MEMBERS ? "Members" : "Wealth"; + return net.tfminecraft.simplefactions.espionage.EspionageService.visibleValue(viewer, faction, metric, + () -> type == RankType.PRESTIGE ? faction.getPrestige() + : type == RankType.MEMBERS ? faction.getMembers().size() : faction.getWealth()); + } + + public List getVisibleRankedList(org.bukkit.entity.Player viewer, RankType type) { + List factions = FactionManager.getCopy(); + java.util.Map values = new java.util.HashMap<>(); + for (Faction faction : factions) values.put(faction, visibleValue(viewer, faction, type)); + factions.sort((first, second) -> { + Double a = values.get(first), b = values.get(second); + if (a == null && b != null) return 1; + if (a != null && b == null) return -1; + int comparison = a == null ? 0 : Double.compare(b, a); + return comparison != 0 ? comparison : first.getId().compareToIgnoreCase(second.getId()); + }); + return factions; + } + + public Integer getVisibleRank(org.bukkit.entity.Player viewer, Faction target, RankType type) { + if (visibleValue(viewer, target, type) == null) return null; + List ranked = getVisibleRankedList(viewer, type); + for (int index = 0; index < ranked.size(); index++) if (ranked.get(index).getId().equals(target.getId())) return index + 1; + return null; + } public Integer getPrestigeRank(Faction f) { List factions = getRankedList(RankType.PRESTIGE); Collections.reverse(factions); diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java index e264cffe..43815a1f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.utils; +import net.tfminecraft.simplefactions.espionage.EspionageCommands; import net.tfminecraft.simplefactions.vehicles.VehicleFactionCommands.VehicleTabCompletions; import java.util.ArrayList; @@ -163,6 +164,15 @@ private List completeInstallationIds(Player p, String prefix, boolean in @Override public List onTabComplete (CommandSender sender, Command cmd, String label, String[] args){ + if (cmd.getName().equalsIgnoreCase("faction") && args.length == 1 + && sender.hasPermission(net.tfminecraft.simplefactions.espionage.EspionageConfig.reloadPermission()) + && "reloadespionage".regionMatches(true, 0, args[0], 0, args[0].length()) && !args[0].isEmpty()) + return List.of("reloadespionage"); + if (cmd.getName().equalsIgnoreCase("faction") && args.length >= 2 + && EspionageCommands.matches(args[0])) { + return sender instanceof Player player + ? EspionageCommands.complete(player, args) : List.of(); + } if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 2 && args[0].equalsIgnoreCase(TitleAdminCommand.SUBCOMMAND)) { if(!Permissions.isAdmin(sender) || !Cache.provincesEnabled) { return new ArrayList<>(); @@ -316,6 +326,8 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le List completions = new ArrayList<>(); completions.add("list"); if(FactionManager.getByMember(p.getName()) != null) completions.add("menu"); + if(FactionManager.getByMember(p.getName()) != null) completions.addAll(List.of("positions", "spymaster", "espionage")); + if (p.hasPermission(net.tfminecraft.simplefactions.espionage.EspionageConfig.reloadPermission())) completions.add("reloadespionage"); completions.add("create"); completions.add("accept"); completions.add("decline"); @@ -439,13 +451,17 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le } return completions; } - } else if(eitherCommand(cmd) && args.length == 2 && args[0].equalsIgnoreCase("invite")){ + } else if(eitherCommand(cmd) && args.length >= 2 && args[0].equalsIgnoreCase("invite")){ if(sender instanceof Player){ List completions = new ArrayList(); for(Player p : Bukkit.getOnlinePlayers()) { completions.add(p.getName()); + completions.add(net.tfminecraft.simplefactions.espionage.CharacterNames.of(p.getName())); } - return completions; + String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length)); + return completions.stream().distinct().filter(name -> name.regionMatches(true, 0, prefix, 0, prefix.length())) + .map(name -> String.join(" ", java.util.Arrays.copyOfRange(name.split(" "), args.length - 2, name.split(" ").length))) + .toList(); } } else if(cmd.getName().equalsIgnoreCase("guild") && args.length == 2 && args[0].equalsIgnoreCase("kick")){ if(sender instanceof Player){ diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b8395129..414c4934 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,6 +7,15 @@ author: Drefvelin depend: [DenarEconomy, TLibs] softdepend: [MMOItems, MythicLib, MMOCore, TFMCWeb, VehicleFramework, VFBuilders, ConditionalEvents, RPCharacters, ItemsAdder] +permissions: + simplefactions.espionage.bypass: + description: View exact faction and guild information through the original menus and rankings. + default: false + + simplefactions.espionage.reload: + description: Reload office settings and regenerate all daily intelligence reports for testing. + default: false + commands: faction: useage: / @@ -37,4 +46,4 @@ commands: description: Mercenary company command mercenaries: useage: / - description: Browse and hire mercenary companies \ No newline at end of file + description: Browse and hire mercenary companies diff --git a/src/main/resources/special-positions.yml b/src/main/resources/special-positions.yml new file mode 100644 index 00000000..7077ad0c --- /dev/null +++ b/src/main/resources/special-positions.yml @@ -0,0 +1,88 @@ +# Special offices and their intelligence network. Reload settings with /faction reloadconfigs. +# For testing: /faction reloadespionage reloads this file, rerolls daily checks and regenerates reports. +# Character aptitude rolls, appointment history and unrest are never reset by that command. +espionage: + # Assign these nodes through LuckPerms; bypass only grants viewing access. + bypass-permission: simplefactions.espionage.bypass + reload-permission: simplefactions.espionage.reload + appointments: + # First deliberate appointment is free. The automatic founding appointment does not count. + repeat-cost: 250.0 + stability-penalty: 10.0 + penalty-days: 7.0 + # Each character rolls once for life. Attribute/formula changes affect only new characters. + aptitude: + base: 50.0 + attribute-center: 6 + attribute-cap: 16 + random-spread: 20 + # A leader can only hold this office in a one-person faction; they retain 25% aptitude. + solo-leader-multiplier: 0.25 + attribute-weights: + intelligence: 3.0 + wisdom: 2.5 + charisma: 1.5 + dexterity: 1.0 + constitution: -2.0 + strength: -2.0 + checks: + # Daily roll = aptitude * multiplier + averaged luck - private sabotage reduction. + # Averaging three uniform draws makes extreme luck (and major upsets) rare but possible. + aptitude-multiplier: 1.25 + luck-spread: 75 + luck-draws: 3 + intelligence: + # Margin = observing faction's offense minus target's defense. Nonpositive margins are Unknown. + tiers: + rumours: + minimum-margin: 1 + uncertainty: 1.5 + roster-fraction: 0.2 + maximum-bounded-fraction: 0.75 + maximum-relative-width: 4.0 + broad: + minimum-margin: 30 + uncertainty: 0.75 + roster-fraction: 0.4 + maximum-bounded-fraction: 0.65 + maximum-relative-width: 3.0 + reliable: + minimum-margin: 65 + uncertainty: 0.2 + roster-fraction: 0.6 + maximum-bounded-fraction: 0.5 + maximum-relative-width: 2.0 + detailed: + minimum-margin: 100 + uncertainty: 0.1 + roster-fraction: 0.8 + maximum-bounded-fraction: 0.5 + maximum-relative-width: 2.0 + maximum-roster-size: 23 + # Fields stay Unknown below their minimum tier, even if an old report contains an estimate. + # Valid values: rumours, broad, reliable, detailed. Unknown disables disclosure entirely. + # These rules apply to both faction and guild metrics; flavours, leaders, guilds and prestige stay public. + minimum-tiers: + members: rumours + roster: rumours + wealth: rumours + prosperity: broad + stability: broad + administrative-power: reliable + professional-army: reliable + levies: broad + mercenaries: reliable + installations: broad + net-income: broad + trade-power: broad + income-total: broad + expense-total: broad + cashflow: reliable + dividend-rate: reliable + dividend-pool: reliable + dividend-tax: reliable + dividend-per-member: reliable + office-holder: reliable + office-aptitude: detailed + # Optional per-category overrides, e.g. TRADE: broad. All other categories use cashflow above. + cashflows: {} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterAptitudesTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterAptitudesTest.java new file mode 100644 index 00000000..f8a0f869 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterAptitudesTest.java @@ -0,0 +1,60 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; + +class CharacterAptitudesTest { + @TempDir Path directory; + + @Test + void characterKeepsAptitudeAcrossFactionsDeletionAndServerRestart() throws Exception { + Path file = directory.resolve("character-aptitudes.json"); + var registry = new CharacterAptitudes(file); + assertEquals(88, registry.aptitude("character-a", () -> 88)); + Faction first = mock(Faction.class), second = mock(Faction.class); + when(first.getEspionage()).thenReturn(new EspionageState()); + when(second.getEspionage()).thenReturn(new EspionageState()); + var assignment = new SpecialPositionAssignment(); + assignment.characterId = "character-a"; + first.getEspionage().appoint(assignment, registry.aptitude("character-a", () -> fail("Reroll"))); + first.getEspionage().removeSpymaster(); + var transferred = new SpecialPositionAssignment(); + transferred.characterId = "character-a"; + second.getEspionage().appoint(transferred, registry.aptitude("character-a", () -> fail("Faction swap reroll"))); + assertEquals(88, transferred.aptitude); + var restarted = new CharacterAptitudes(file); + restarted.load(); + assertEquals(88, restarted.aptitude("character-a", () -> fail("Restart reroll"))); + assertEquals(7, restarted.aptitude("character-b", () -> 7)); + } + + @Test + void firstDevRollMigratesAndSurvivesAfterItsFactionDisappears() throws Exception { + Faction old = mock(Faction.class); + when(old.getId()).thenReturn("old"); + var state = new EspionageState(); + var holder = new SpecialPositionAssignment(); + holder.characterId = "legacy-character"; + holder.playerId = UUID.randomUUID(); + state.appoint(holder, () -> 41); + state.removeSpymaster(); + when(old.getEspionage()).thenReturn(state); + Path file = directory.resolve("migrated.json"); + try (var factions = mockStatic(FactionManager.class)) { + factions.when(FactionManager::getCopy).thenReturn(List.of(old)); + EspionageService.loadAptitudes(file); + factions.when(FactionManager::getCopy).thenReturn(List.of()); + EspionageService.loadAptitudes(file); + } + var registry = new CharacterAptitudes(file); + registry.load(); + assertEquals(41, registry.aptitude("legacy-character", () -> fail("Migration reroll"))); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java new file mode 100644 index 00000000..2d30799e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java @@ -0,0 +1,45 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.List; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class CharacterNameResolutionTest { + private Player player(String name) { + var player = mock(Player.class); + when(player.getName()).thenReturn(name); + return player; + } + @Test + void resolvesFullCharacterNamesAndExactAccountsWithoutPrefixMatches() { + var first = player("FirstAccount"); + var second = player("SecondAccount"); + var players = List.of(first, second); + java.util.function.Function names = player -> player == first ? "\u00a7aLady Rowan Ash" : "Lord Vale"; + assertSame(first, CharacterNames.resolve("lady rowan ash", players, names).player()); + assertSame(second, CharacterNames.resolve("secondaccount", players, names).player()); + assertNull(CharacterNames.resolve("Lady Rowan", players, names).player()); + assertNull(CharacterNames.resolve("First", players, names).player()); + } + @Test + void duplicateCharactersRequireAnAccountAndAccountsTakePrecedence() { + var first = player("Rowan"); + var second = player("OtherAccount"); + var players = List.of(first, second); + assertTrue(CharacterNames.resolve("Shared Name", players, ignored -> "Shared Name").ambiguous()); + assertNull(CharacterNames.resolve("Shared Name", players, ignored -> "Shared Name").player()); + assertSame(first, CharacterNames.resolve("Rowan", players, ignored -> "Rowan").player()); + } + @Test + void bypassDisplaysBothNamesWhileNormalViewsDisplayCharacterOnly() { + var viewer = player("Viewer"); + try (var names = mockStatic(CharacterNames.class, CALLS_REAL_METHODS)) { + names.when(() -> CharacterNames.of("Account")).thenReturn("Lady Rowan Ash"); + assertEquals("Lady Rowan Ash", CharacterNames.display(viewer, "Account")); + when(viewer.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true); + assertEquals("Lady Rowan Ash \u00a77(Account)", CharacterNames.display(viewer, "Account")); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CommandIntelligenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CommandIntelligenceTest.java new file mode 100644 index 00000000..c4f39e77 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CommandIntelligenceTest.java @@ -0,0 +1,48 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; + +class CommandIntelligenceTest { + @Test + void everyFactionAndGuildMenuConstructorGathersOnceWithinACommand() { + Player player = mock(Player.class); + try (var service = mockStatic(EspionageService.class)) { + for (SFGUI type : new SFGUI[]{SFGUI.FACTION_LIST, SFGUI.FACTION_VIEW, SFGUI.GUILD_LIST, + SFGUI.GUILD_VIEW, SFGUI.SPECIAL_POSITIONS, SFGUI.SPYMASTER_SETTINGS}) { + assertTrue(CommandIntelligence.execute(player, () -> { + new SFInventoryHolder("id", type); + new SFInventoryHolder("id", type, 1); + new SFInventoryHolder("id", type, 1, true); + new SFInventoryHolder("id", type, "other"); + new SFInventoryHolder("id", type, 1, true, "other"); + return true; + })); + } + service.verify(() -> EspionageService.refreshReports(player), times(6)); + new SFInventoryHolder("id", SFGUI.FACTION_VIEW); + service.verifyNoMoreInteractions(); + } + } + + @Test + void commandsWithoutGuiAndClicksCannotGatherAndExceptionClearsContext() { + Player player = mock(Player.class); + try (var service = mockStatic(EspionageService.class)) { + assertTrue(CommandIntelligence.execute(player, () -> true)); + assertThrows(IllegalStateException.class, () -> CommandIntelligence.execute(player, () -> { + throw new IllegalStateException("Denied command"); + })); + new SFInventoryHolder("id", SFGUI.FACTION_VIEW); + assertTrue(CommandIntelligence.execute(null, () -> { + new SFInventoryHolder("id", SFGUI.FACTION_LIST); + return true; + })); + service.verifyNoInteractions(); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java new file mode 100644 index 00000000..3c346765 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java @@ -0,0 +1,98 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.enums.RankType; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.utils.FactionRanker; + +class EspionageBypassTest { + @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); + when(own.isMemberIgnoreCase("Viewer")).thenReturn(true); + var config = new org.bukkit.configuration.file.YamlConfiguration(); + try { + assertTrue(EspionageService.canViewExact(viewer, foreign)); + config.set("espionage.bypass-permission", " custom.staff.intelligence "); + EspionageConfig.load(config); + assertEquals("custom.staff.intelligence", EspionageConfig.bypassPermission()); + assertFalse(EspionageService.canViewExact(viewer, foreign)); + assertTrue(EspionageService.canViewExact(viewer, own)); + when(viewer.hasPermission("custom.staff.intelligence")).thenReturn(true); + assertTrue(EspionageService.canViewExact(viewer, foreign)); + config.set("espionage.bypass-permission", " "); + EspionageConfig.load(config); + assertEquals(EspionageService.BYPASS_PERMISSION, EspionageConfig.bypassPermission()); + assertTrue(EspionageService.bypasses(viewer)); + } finally { EspionageConfig.load(new org.bukkit.configuration.file.YamlConfiguration()); } + } + + @Test + void bypassRestoresExactFactionAndGuildRankingsWithoutGatheringReports() { + Player staff = mock(Player.class); + when(staff.getName()).thenReturn("Staff"); + when(staff.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true); + Faction rich = mock(Faction.class), poor = mock(Faction.class); + when(rich.getId()).thenReturn("rich"); + when(poor.getId()).thenReturn("poor"); + when(rich.getWealth()).thenReturn(250.0); + when(poor.getWealth()).thenReturn(12.0); + Guild guild = mock(Guild.class); + when(guild.getFaction()).thenReturn(rich); + when(guild.getWealth()).thenReturn(76.0); + try (var factions = mockStatic(FactionManager.class)) { + factions.when(FactionManager::getCopy).thenAnswer(ignored -> new ArrayList<>(List.of(poor, rich))); + var ranker = new FactionRanker(); + assertEquals(250.0, ranker.visibleValue(staff, rich, RankType.WEALTH)); + assertEquals(76.0, ranker.visibleGuildValue(staff, guild, RankType.WEALTH)); + assertEquals(List.of(rich, poor), ranker.getVisibleRankedList(staff, RankType.WEALTH)); + assertEquals(1, ranker.getVisibleRank(staff, rich, RankType.WEALTH)); + assertNull(EspionageService.report(staff, rich)); + EspionageService.refreshReports(staff); + verify(rich, never()).getEspionage(); + verify(poor, never()).getEspionage(); + factions.verify(() -> FactionManager.getByMember("Staff"), never()); + verify(staff, never()).sendMessage(anyString()); + } + } + + @Test + void revocationClosesPrivateInformationMenusAndBypassDoesNotGrantOfficeAuthority() { + Player staff = mock(Player.class); + when(staff.getName()).thenReturn("Staff"); + when(staff.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true); + Faction faction = mock(Faction.class); + try (var factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getByString("target")).thenReturn(faction); + assertTrue(EspionageService.canViewExact(staff, faction)); + assertFalse(EspionageService.isOwn(staff, faction)); + for (SFGUI type : new SFGUI[]{SFGUI.MILITARY_VIEW, SFGUI.LEDGER_VIEW, + SFGUI.GOVERNMENT_VIEW, SFGUI.LAW_VIEW, SFGUI.INSTALLATIONS_VIEW}) { + assertFalse(EspionageAccess.denied(staff, new SFInventoryHolder("target", type))); + } + for (SFGUI type : new SFGUI[]{SFGUI.SPECIAL_POSITIONS, SFGUI.SPYMASTER_SETTINGS, SFGUI.SPYMASTER_SELECT}) { + assertTrue(EspionageAccess.denied(staff, new SFInventoryHolder("target", type))); + } + assertFalse(EspionageService.appoint(staff, faction, mock(Player.class))); + assertFalse(EspionageService.remove(staff, faction)); + when(staff.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(false); + assertFalse(EspionageService.canViewExact(staff, faction)); + assertTrue(EspionageAccess.denied(staff, new SFInventoryHolder("target", SFGUI.MILITARY_VIEW))); + assertFalse(EspionageService.bypasses(null)); + assertFalse(EspionageService.canViewExact(staff, null)); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMathTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMathTest.java new file mode 100644 index 00000000..4d36710b --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMathTest.java @@ -0,0 +1,124 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; +import java.util.Random; +import java.util.random.RandomGenerator; + +import org.junit.jupiter.api.Test; + +class EspionageMathTest { + private Map attributes(int value) { + return Map.of("intelligence", value, "wisdom", value, "charisma", value, + "dexterity", value, "constitution", value, "strength", value); + } + + @Test + void fullAptitudeScaleIsReachableAndBounded() { + RandomGenerator random = new Random(11); + var weak = new java.util.HashMap<>(attributes(0)); + weak.put("strength", 16); + weak.put("constitution", 16); + var strong = new java.util.HashMap<>(attributes(16)); + strong.put("strength", 0); + strong.put("constitution", 0); + assertEquals(0, EspionageMath.aptitude(weak, EspionageConfig.DEFAULT_WEIGHTS, random)); + assertEquals(100, EspionageMath.aptitude(strong, EspionageConfig.DEFAULT_WEIGHTS, random)); + for (int score = -5; score < 100; score++) { + int result = EspionageMath.aptitude(attributes(score), EspionageConfig.DEFAULT_WEIGHTS, random); + assertTrue(result >= 0 && result <= 100); + } + } + + @Test + void mentalAndSocialAttributesMatterMoreThanBruteStrength() { + int baseline = EspionageMath.aptitude(attributes(6), EspionageConfig.DEFAULT_WEIGHTS, new Random(3)); + var intelligence = new java.util.HashMap<>(attributes(6)); + intelligence.put("intelligence", 10); + var strength = new java.util.HashMap<>(attributes(6)); + strength.put("strength", 10); + var poorJudgment = new java.util.HashMap<>(attributes(6)); + poorJudgment.put("wisdom", 0); + assertTrue(EspionageMath.aptitude(intelligence, EspionageConfig.DEFAULT_WEIGHTS, new Random(3)) > baseline + 10); + assertTrue(EspionageMath.aptitude(strength, EspionageConfig.DEFAULT_WEIGHTS, new Random(3)) < baseline - 7); + assertTrue(EspionageMath.aptitude(poorJudgment, EspionageConfig.DEFAULT_WEIGHTS, new Random(3)) < baseline - 10); + } + + @Test + void physicalSpecialistsStayLowWhileMentalSpecialistsCanReachMaximum() { + var physical = new java.util.HashMap<>(attributes(6)); + physical.put("strength", 16); + physical.put("constitution", 16); + var mental = new java.util.HashMap<>(attributes(6)); + mental.put("intelligence", 16); + mental.put("wisdom", 16); + for (int seed = 0; seed < 100; seed++) { + assertTrue(EspionageMath.aptitude(physical, EspionageConfig.DEFAULT_WEIGHTS, new Random(seed)) <= 30); + assertTrue(EspionageMath.aptitude(mental, EspionageConfig.DEFAULT_WEIGHTS, new Random(seed)) >= 85); + } + } + + @Test + void attributeWeightsAreConfigurableAndNonFiniteValuesUseDefaults() { + var config = new org.bukkit.configuration.file.YamlConfiguration(); + config.set("espionage.aptitude.attribute-weights.intelligence", 7.0); + config.set("espionage.aptitude.attribute-weights.strength", -5.0); + config.set("espionage.aptitude.attribute-weights.wisdom", Double.NaN); + try { + EspionageConfig.load(config); + assertEquals(7.0, EspionageConfig.weights().get("intelligence")); + assertEquals(-5.0, EspionageConfig.weights().get("strength")); + assertEquals(2.5, EspionageConfig.weights().get("wisdom")); + var stats = new java.util.HashMap<>(attributes(6)); + stats.put("strength", 10); + assertTrue(EspionageMath.aptitude(stats, new Random(1)) + < EspionageMath.aptitude(stats, EspionageConfig.DEFAULT_WEIGHTS, new Random(1))); + } finally { EspionageConfig.load(new org.bukkit.configuration.file.YamlConfiguration()); } + } + + @Test + void weakestCanBeatStrongestButStrongestUsuallyWins() { + RandomGenerator random = new Random(111); + int upset = 0; + for (int day = 0; day < 1_000_000; day++) { + if (EspionageMath.dailyRoll(0, 0, random) > EspionageMath.dailyRoll(100, 0, random)) upset++; + } + assertTrue(upset > 0 && upset < 1000, "An exceptionally rare upset must remain possible"); + } + + @Test + void sabotageLowersBothRollTypesByTheChosenAmount() { + assertEquals(EspionageMath.dailyRoll(60, 0, new Random(1)) - 100, + EspionageMath.dailyRoll(60, 100, new Random(1))); + } + + @Test + void hiddenAndFiniteChecksFailClosed() { + assertNull(EspionageMath.estimate(100, 0, false, new Random(1))); + assertNull(EspionageMath.estimate(100, -10, false, new Random(1))); + assertNull(EspionageMath.estimate(Double.NaN, 100, false, new Random(1))); + } + + @Test + void rangesContainTheTruthWithoutRevealingExactZeroOrNegativeIncome() { + RandomGenerator random = new Random(21); + for (double value : new double[]{0, 0.25, 5, 103.75, 10_000, -125.5}) { + for (int margin : new int[]{1, 29, 30, 64, 65, 99, 100, 250}) { + for (int sample = 0; sample < 100; sample++) { + var range = EspionageMath.estimate(value, margin, value < 0, random); + assertTrue(range.lower() <= value && range.upper() >= value, range.toString()); + assertTrue(range.lower() < range.upper()); + if (value >= 0) assertTrue(range.lower() >= 0); + } + } + } + } + + @Test + void strongerIntelligenceProducesNarrowerRanges() { + var rumours = EspionageMath.estimate(1000, 1, false, new Random(5)); + var detailed = EspionageMath.estimate(1000, 100, false, new Random(5)); + assertTrue(detailed.upper() - detailed.lower() < rumours.upper() - rumours.lower()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java new file mode 100644 index 00000000..4a6b998e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java @@ -0,0 +1,184 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.managers.inventory.GovernmentView; +import net.tfminecraft.simplefactions.managers.inventory.GuildView; +import net.tfminecraft.simplefactions.managers.inventory.InstallationView; +import net.tfminecraft.simplefactions.managers.inventory.LawView; +import net.tfminecraft.simplefactions.managers.inventory.MilitaryView; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; + +class EspionagePermissionsTest { + @Test + void outsidersAreRedirectedBeforeAnyExactSubmenuIsBuilt() { + Player outsider = mock(Player.class); + Faction faction = mock(Faction.class); + Guild guild = mock(Guild.class); + when(guild.getFaction()).thenReturn(faction); + InventoryManager manager = mock(InventoryManager.class); + new MilitaryView(manager).militaryView(null, outsider, faction, true); + new GovernmentView(manager).governmentView(outsider, faction, null); + new LawView(manager).lawView(outsider, faction, null); + new InstallationView(manager).installationsView(null, outsider, faction, true); + try (var views = mockStatic(net.tfminecraft.simplefactions.managers.inventory.EspionageView.class)) { + new GuildView(manager).ledgerView(outsider, guild, null); + views.verify(() -> net.tfminecraft.simplefactions.managers.inventory.EspionageView.foreignLedger(outsider, guild, manager)); + } + new GuildView(manager).upgradeView(outsider, guild, null); + verify(manager, times(5)).factionView(outsider, faction); + verify(faction, never()).getMilitary(); + verify(faction, never()).getGovernment(); + verify(guild, never()).getLedger(); + } + + @Test + void sabotageRequiresTheOfficeHoldersUuidEvenForLeadersAndMembers() { + Player leader = mock(Player.class); + Player holder = mock(Player.class); + Faction faction = mock(Faction.class); + var state = new EspionageState(); + var assignment = new SpecialPositionAssignment(); + assignment.playerId = UUID.randomUUID(); + assignment.playerName = "Spy"; + assignment.characterId = "character"; + state.appoint(assignment, () -> 60); + when(faction.getEspionage()).thenReturn(state); + when(faction.isMemberIgnoreCase(anyString())).thenReturn(true); + when(faction.isLeader("Leader")).thenReturn(true); + when(leader.getName()).thenReturn("Leader"); + when(leader.getUniqueId()).thenReturn(UUID.randomUUID()); + when(holder.getName()).thenReturn("Spy"); + when(holder.getUniqueId()).thenReturn(assignment.playerId); + try (var databases = mockConstruction(Database.class)) { + assertFalse(EspionageService.setSabotage(leader, faction, true, 100)); + assertEquals(0, assignment.offenseReduction); + assertFalse(EspionageService.setSabotage(holder, faction, true, 24)); + assertTrue(EspionageService.setSabotage(holder, faction, true, 75)); + assertEquals(75, assignment.offenseReduction); + assertEquals(0, assignment.defenseReduction); + assertTrue(EspionageService.setSabotage(holder, faction, false, 100)); + assertTrue(EspionageService.setSabotage(holder, faction, true, 0)); + assertEquals(0, assignment.offenseReduction); + verify(leader, times(1)).sendMessage(anyString()); + } + } + + @Test + void onlyFactionLeaderCanAppointOrRemove() { + Player member = mock(Player.class); + Player candidate = mock(Player.class); + Faction faction = mock(Faction.class); + when(member.getName()).thenReturn("Member"); + when(faction.isMemberIgnoreCase("Member")).thenReturn(true); + try (var databases = mockConstruction(Database.class)) { + assertFalse(EspionageService.appoint(member, faction, candidate)); + assertFalse(EspionageService.remove(member, faction)); + assertTrue(databases.constructed().isEmpty()); + verifyNoInteractions(candidate); + } + } + + @Test + void unaffiliatedAndOwnMembersDoNotConsumeForeignRolls() { + Player viewer = mock(Player.class); + Faction target = mock(Faction.class); + when(viewer.getName()).thenReturn("Visitor"); + try (var factions = mockStatic(FactionManager.class)) { + assertNull(EspionageService.report(viewer, target)); + verify(target, never()).getEspionage(); + when(target.isMemberIgnoreCase("Visitor")).thenReturn(true); + assertNull(EspionageService.report(viewer, target)); + factions.verify(() -> FactionManager.getByMember("Visitor"), times(1)); + } + } + + @Test + void expiredMembershipAndReplacedOfficeCloseExistingPrivateScreens() { + Player member = mock(Player.class); + Faction faction = mock(Faction.class); + when(member.getName()).thenReturn("FormerSpy"); + when(member.getUniqueId()).thenReturn(UUID.randomUUID()); + when(faction.getEspionage()).thenReturn(new EspionageState()); + try (var factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getByString("faction")).thenReturn(faction); + assertTrue(EspionageAccess.denied(member, new SFInventoryHolder("faction", SFGUI.MILITARY_VIEW))); + when(faction.isMemberIgnoreCase("FormerSpy")).thenReturn(true); + assertFalse(EspionageAccess.denied(member, new SFInventoryHolder("faction", SFGUI.MILITARY_VIEW))); + assertTrue(EspionageAccess.denied(member, new SFInventoryHolder("faction", SFGUI.SPYMASTER_SETTINGS))); + assertFalse(EspionageAccess.denied(member, new SFInventoryHolder("faction", SFGUI.SPECIAL_POSITIONS))); + } + } + + @Test + void departedSpymasterLosesOfficeAndCannotSabotage() { + Player former = mock(Player.class); + Faction faction = mock(Faction.class); + var state = new EspionageState(); + var assignment = new SpecialPositionAssignment(); + assignment.playerName = "FormerSpy"; + assignment.playerId = UUID.randomUUID(); + assignment.characterId = "character"; + state.appoint(assignment, () -> 90); + when(faction.getEspionage()).thenReturn(state); + when(former.getName()).thenReturn("FormerSpy"); + when(former.getUniqueId()).thenReturn(assignment.playerId); + try (var databases = mockConstruction(Database.class)) { + assertNull(EspionageService.spymaster(faction)); + assertNull(state.getSpymaster()); + assertFalse(EspionageService.setSabotage(former, faction, true, 100)); + verify(databases.constructed().getFirst()).saveFaction(faction); + } + } + + @Test + void leaderIsIneligibleUnlessAloneAndSoloAptitudeIsReducedBySeventyFivePercent() { + Faction faction = mock(Faction.class); + when(faction.isLeader("Leader")).thenReturn(true); + when(faction.isMemberIgnoreCase(anyString())).thenReturn(true); + when(faction.getMembers()).thenReturn(java.util.List.of("Leader", "Spy")); + assertFalse(EspionageService.eligible(faction, "Leader")); + assertTrue(EspionageService.eligible(faction, "Spy")); + var holder = new SpecialPositionAssignment(); + holder.playerName = "Leader"; + holder.aptitude = 100; + assertEquals(0, EspionageService.effectiveAptitude(faction, holder)); + when(faction.getMembers()).thenReturn(java.util.List.of("Leader")); + assertTrue(EspionageService.eligible(faction, "Leader")); + assertEquals(25, EspionageService.effectiveAptitude(faction, holder)); + assertEquals(100, holder.aptitude, "Permanent base aptitude must remain unchanged"); + holder.aptitude = 79; + assertEquals(19, EspionageService.effectiveAptitude(faction, holder)); + } + + @Test + void soloLeaderLosesOfficeWhenAnotherMemberJoins() { + Faction faction = mock(Faction.class); + when(faction.isLeader("Leader")).thenReturn(true); + when(faction.getMembers()).thenReturn(java.util.List.of("Leader")); + var state = new EspionageState(); + var holder = new SpecialPositionAssignment(); + holder.playerName = "Leader"; + state.appoint(holder, 100); + when(faction.getEspionage()).thenReturn(state); + assertSame(holder, EspionageService.spymaster(faction)); + when(faction.getMembers()).thenReturn(java.util.List.of("Leader", "GuildMember")); + try (var databases = mockConstruction(Database.class)) { + assertNull(EspionageService.spymaster(faction)); + assertNull(state.getSpymaster()); + verify(databases.constructed().getFirst()).saveFaction(faction); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageReloadTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageReloadTest.java new file mode 100644 index 00000000..57443214 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageReloadTest.java @@ -0,0 +1,53 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.Map; +import java.util.Random; +import org.bukkit.command.CommandSender; +import org.junit.jupiter.api.Test; + +class EspionageReloadTest { + @Test void unauthorizedCommandCannotReloadConfigOrResetReports() { + var sender = mock(CommandSender.class); + try (var files = mockStatic(SpecialPositionsConfigFile.class); var service = mockStatic(EspionageService.class)) { + assertTrue(EspionageCommands.reload(sender, new String[]{"reloadespionage"})); + verify(sender).hasPermission(EspionageConfig.reloadPermission()); + verify(sender).sendMessage(contains("permission")); + files.verifyNoInteractions(); + service.verifyNoInteractions(); + } + } + + @Test void staffCommandReloadsSettingsBeforeRegeneratingAndSupportsConsole() { + var sender = mock(CommandSender.class); + when(sender.hasPermission(EspionageConfig.reloadPermission())).thenReturn(true); + try (var files = mockStatic(SpecialPositionsConfigFile.class); var service = mockStatic(EspionageService.class)) { + service.when(EspionageService::regenerateReports).thenReturn(12); + assertTrue(EspionageCommands.reload(sender, new String[]{"reloadespionage"})); + files.verify(() -> SpecialPositionsConfigFile.load(net.tfminecraft.simplefactions.SimpleFactions.plugin)); + service.verify(EspionageService::regenerateReports); + verify(sender).sendMessage(contains("12 intelligence reports regenerated")); + } + } + + @Test void resetOnlyReplacesDailyReportsAndRollsPreservingOfficeHistoryAndUnrest() { + var state = new EspionageState(); + var holder = new SpecialPositionAssignment(); + holder.characterId = "character"; + state.appoint(holder, 88); + holder.offenseReduction = 50; + state.addUnrest(SpecialPosition.SPYMASTER, 10, 7, 1000); + var report = state.report("target", 100, 1, () -> EspionageService.createReport(Map.of("Wealth", 100.0), 100, new Random(1))); + var rolls = state.rolls(1, new Random(1)); + state.resetReportsAndRolls(); + assertNull(state.cachedReport("target", 100, 1)); + assertNotSame(report, state.report("target", 100, 1, IntelligenceReport::new)); + assertNotSame(rolls, state.rolls(1, new Random(2))); + assertSame(holder, state.getSpymaster()); + assertEquals(88, holder.aptitude); + assertEquals(50, holder.offenseReduction); + assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + assertFalse(state.unrestModifiers(1000).isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageStateTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageStateTest.java new file mode 100644 index 00000000..daeb1a6a --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageStateTest.java @@ -0,0 +1,108 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.database.FactionData; +import net.tfminecraft.simplefactions.database.JsonUtil; + +class EspionageStateTest { + private SpecialPositionAssignment holder() { + var holder = new SpecialPositionAssignment(); + holder.playerId = UUID.fromString("e8601e44-34d9-44f8-a62a-2628f81d338e"); + holder.playerName = "Spy"; + holder.characterId = "character-a"; + return holder; + } + + private IntelligenceReport report() { + return EspionageService.createReport(Map.of("Professional army", 5.0, "Daily net income", -10.0), + 60, new Random(5)); + } + + @Test + void noSpymasterHasZeroAptitudeAndIndependentOffenseDefenseLuck() { + var state = new EspionageState(); + var actual = state.rolls(10, new Random(12)); + var random = new Random(12); + assertEquals(EspionageMath.dailyRoll(0, 0, random), actual.offense()); + assertEquals(EspionageMath.dailyRoll(0, 0, random), actual.defense()); + } + + @Test + void dailyRollsCannotBeRefreshedByAppointmentRemovalOrSabotage() { + var state = new EspionageState(); + var first = state.rolls(10, new Random(1)); + var holder = holder(); + state.appoint(holder, () -> 100); + holder.offenseReduction = 100; + holder.defenseReduction = 50; + assertSame(first, state.rolls(10, new Random(2))); + var next = state.rolls(11, new Random(3)); + var random = new Random(3); + assertEquals(EspionageMath.dailyRoll(100, 100, random), next.offense()); + assertEquals(EspionageMath.dailyRoll(100, 50, random), next.defense()); + state.removeSpymaster(); + assertSame(next, state.rolls(11, new Random(4))); + } + + @Test + void reappointingSameCharacterKeepsAptitudeAndResetsSabotage() { + var state = new EspionageState(); + var first = holder(); + state.appoint(first, () -> 12); + first.offenseReduction = 100; + state.removeSpymaster(); + var second = holder(); + state.appoint(second, () -> { fail("An office cannot be used to reroll aptitude"); return 100; }); + assertEquals(12, second.aptitude); + assertEquals(0, second.offenseReduction); + second = holder(); + second.characterId = "character-b"; + state.appoint(second, () -> 80); + assertEquals(80, second.aptitude); + } + + @Test + void reportIsSharedSnapshotAndRefreshesOnlyNextDayOrNewFactionIncarnation() { + var state = new EspionageState(); + AtomicInteger created = new AtomicInteger(); + var supplier = (java.util.function.Supplier) () -> { created.incrementAndGet(); return report(); }; + var first = state.report("target", 100, 10, supplier); + assertSame(first, state.report("target", 100, 10, supplier)); + assertEquals(1, created.get()); + assertNotSame(first, state.report("target", 100, 11, supplier)); + state.report("other", 200, 11, supplier); + assertNotSame(first, state.report("target", 300, 11, supplier)); + assertEquals(4, created.get()); + } + + @Test + void factionJsonRoundTripKeepsOfficeSabotageAptitudeAndBothDailyCaches() { + FactionData data = new FactionData(); + data.espionage = new EspionageState(); + var holder = holder(); + data.espionage.appoint(holder, () -> 82); + holder.defenseReduction = 75; + var rolls = data.espionage.rolls(42, new Random(1)); + var report = data.espionage.report("target", 123, 42, this::report); + FactionData restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(data), FactionData.class); + assertEquals(holder.playerId, restored.espionage.getSpymaster().playerId); + assertEquals(82, restored.espionage.getSpymaster().aptitude); + assertEquals(75, restored.espionage.getSpymaster().defenseReduction); + assertEquals(rolls, restored.espionage.rolls(42, new Random(99))); + var cached = restored.espionage.report("target", 123, 42, () -> { fail("Restart cannot reroll"); return null; }); + assertEquals(report.estimates, cached.estimates); + restored.espionage.removeSpymaster(); + var replacement = holder(); + restored.espionage.appoint(replacement, () -> { fail("Restart cannot reroll appointment"); return 0; }); + assertEquals(82, replacement.aptitude); + assertNull(JsonUtil.GSON.fromJson("{\"id\":\"legacy\"}", FactionData.class).espionage); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/FactionTooltipPrivacyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/FactionTooltipPrivacyTest.java new file mode 100644 index 00000000..932cfcb5 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/FactionTooltipPrivacyTest.java @@ -0,0 +1,137 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.List; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.managers.inventory.FactionCreator; +import net.tfminecraft.simplefactions.objects.Faction; + +class FactionTooltipPrivacyTest { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void foreignContextRemainsPublicWithAndWithoutBypass(boolean bypass) { + Player viewer = mock(Player.class); + when(viewer.getName()).thenReturn("Visitor"); + Faction origin = mock(Faction.class, RETURNS_DEEP_STUBS); + Faction target = mock(Faction.class, RETURNS_DEEP_STUBS); + when(origin.getId()).thenReturn("origin"); + when(target.getId()).thenReturn("target"); + when(target.getRank().getName()).thenReturn("Obscure Faction"); + when(target.getTier().getFormattedName()).thenReturn("Landless"); + when(target.getLeader()).thenReturn("Leader"); + when(target.getRulerTitle()).thenReturn("Leader"); + when(target.getCulture()).thenReturn("Multicultural"); + when(target.getReligion()).thenReturn("Religious Diversity"); + when(target.getGovernmentString()).thenReturn("Community"); + when(target.getTitles()).thenReturn(List.of(mock(net.tfminecraft.simplefactions.tiers.Title.class))); + when(target.getHighestTitle().getName()).thenReturn("Primary title"); + var guild = mock(net.tfminecraft.simplefactions.guild.Guild.class, RETURNS_DEEP_STUBS); + when(guild.getName()).thenReturn("Foreign Guild"); + when(guild.getType().getName()).thenReturn("Guild"); + when(target.getGuildHandler().getGuilds()).thenReturn(List.of(guild)); + var subject = mock(Faction.class); + var ally = mock(Faction.class); + when(subject.getName()).thenReturn("Subject"); + when(ally.getName()).thenReturn("Ally"); + when(target.getMembers()).thenReturn(List.of("Leader", "Member")); + when(target.getCompleteMemberList()).thenReturn(List.of("Leader", "Member")); + when(target.getWealth()).thenReturn(123.45); + when(target.getGovernment().stateReport()).thenReturn( + new net.tfminecraft.simplefactions.government.stability.StabilityReport()); + var type = mock(net.tfminecraft.simplefactions.diplomacy.RelationType.class); + when(type.getName()).thenReturn("Neutral"); + when(origin.getRelation("target").getType()).thenReturn(type); + when(target.getRelation("origin").getType()).thenReturn(type); + when(origin.getRelation("target").getOpinion()).thenReturn(12); + when(target.getRelation("origin").getOpinion()).thenReturn(-8); + try (var service = mockStatic(EspionageService.class); + var factions = mockStatic(FactionManager.class); + var relations = mockStatic(RelationManager.class); + var titles = mockStatic(TitleManager.class)) { + service.when(() -> EspionageService.canViewExact(viewer, target)).thenReturn(bypass); + factions.when(() -> FactionManager.getByMember("Visitor")).thenReturn(origin); + factions.when(FactionManager::getCopy).thenAnswer(ignored -> new java.util.ArrayList<>(List.of(target))); + relations.when(() -> RelationManager.getSubjects(target)).thenReturn(List.of(subject)); + relations.when(() -> RelationManager.getAllies(target)).thenReturn(List.of(ally)); + List plain = new FactionCreator().listLore(viewer, target).stream() + .map(line -> line.replaceAll("\u00a7[0-9a-fk-orx]", "")).toList(); + assertTrue(plain.contains("Diplomacy:")); + assertTrue(plain.contains("Relation: Neutral (mutual)")); + assertTrue(plain.contains("Our opinion of them: 12")); + assertTrue(plain.contains("Their opinion of us: -8")); + assertTrue(plain.contains("Primary Title: Primary title")); + assertTrue(plain.contains("Tier: Landless")); + assertTrue(plain.contains("Based in: None")); + assertTrue(plain.contains("Leader: Leader")); + assertTrue(plain.contains("Ruling System: Community")); + assertTrue(plain.contains("Culture: Multicultural")); + assertTrue(plain.contains("Religion: Religious Diversity")); + assertTrue(plain.contains("Guilds:")); + assertTrue(plain.contains("- Foreign Guild (Guild)")); + assertTrue(plain.contains("Subjects:")); + assertTrue(plain.contains("- Subject")); + assertTrue(plain.contains("Allies:")); + assertTrue(plain.contains("- Ally")); + assertTrue(plain.stream().anyMatch(line -> line.startsWith( + bypass ? "Wealth: 123.45d" : "Wealth: Unknown"))); + service.verify(() -> EspionageService.refreshReports(viewer), never()); + } + } + + @Test + void foreignTooltipRetainsOriginalLayoutWhileNeverReadingPrivateValues() { + Player viewer = mock(Player.class); + when(viewer.getName()).thenReturn("Visitor"); + Faction faction = mock(Faction.class, RETURNS_DEEP_STUBS); + when(faction.getId()).thenReturn("target"); + when(faction.getRank().getName()).thenReturn("Obscure Faction"); + when(faction.getTier().getFormattedName()).thenReturn("Landless"); + when(faction.getLeader()).thenReturn("Leader"); + when(faction.getRulerTitle()).thenReturn("Leader"); + when(faction.getCulture()).thenReturn("Multicultural"); + when(faction.getReligion()).thenReturn("Religious Diversity"); + when(faction.getPrestige()).thenReturn(42.0); + when(faction.getTitles()).thenReturn(List.of()); + when(faction.getGuildHandler().getGuilds()).thenReturn(List.of()); + try (var service = mockStatic(EspionageService.class); + var factions = mockStatic(FactionManager.class); + var relations = mockStatic(RelationManager.class); + var titles = mockStatic(TitleManager.class)) { + factions.when(FactionManager::getCopy).thenAnswer(ignored -> new java.util.ArrayList<>(List.of(faction))); + relations.when(() -> RelationManager.getSubjects(faction)).thenReturn(List.of()); + relations.when(() -> RelationManager.getAllies(faction)).thenReturn(List.of()); + var report = new IntelligenceReport(); + report.quality = "Detailed estimates"; + report.estimates.put("Wealth", new EspionageMath.Estimate(20, 70)); + report.estimates.put("Members", new EspionageMath.Estimate(2, 8)); + report.estimates.put("Stability", new EspionageMath.Estimate(80, 200)); + service.when(() -> EspionageService.report(viewer, faction)).thenReturn(report); + service.when(() -> EspionageService.visibleValue(eq(viewer), eq(faction), eq("Wealth"), any())) + .thenReturn(45.0); + List colored = new FactionCreator().listLore(viewer, faction); + List plain = colored.stream().map(line -> line.replaceAll("§[0-9a-fk-orx]", "")).toList(); + assertEquals("Obscure Faction", plain.getFirst()); + assertTrue(plain.contains("Leader: Leader")); + assertTrue(plain.contains("Members: 2 to 8")); + assertTrue(plain.contains("Stable State")); + assertFalse(plain.stream().anyMatch(line -> line.contains("200%"))); + assertTrue(plain.contains("Wealth: 20 to 70d (estimated 1)")); + assertTrue(plain.stream().anyMatch(line -> line.startsWith("Prestige: 42.0"))); + assertTrue(plain.indexOf("Leader: Leader") < plain.indexOf("Members: 2 to 8")); + assertTrue(plain.indexOf("Members: 2 to 8") < plain.indexOf("Wealth: 20 to 70d (estimated 1)")); + assertTrue(colored.stream().filter(line -> line.contains("Wealth:")).allMatch(line -> line.contains("§x"))); + verify(faction, never()).getWealth(); + verify(faction, never()).getProsperity(); + verify(faction, never()).getMembers(); + verify(faction, never()).getCompleteMemberList(); + verify(faction.getGovernment(), never()).stateReport(); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/GuildIntelligenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/GuildIntelligenceTest.java new file mode 100644 index 00000000..c68a5c62 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/GuildIntelligenceTest.java @@ -0,0 +1,99 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.Cashflow; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.utils.Represents; + +class GuildIntelligenceTest { + @Test + void financialDetailsAreCapturedOnceAndForeignDisplayDoesNotReadLiveLedger() { + var guild = mock(Guild.class, RETURNS_DEEP_STUBS); + when(guild.getId()).thenReturn("guild"); + when(guild.isBase()).thenReturn(true); + when(guild.getLedger().getIncome(Cashflow.TRADE)).thenReturn(100.0); + when(guild.getLedger().getIncome(Cashflow.CITIZENS)).thenReturn(30.0); + when(guild.getLedger().getIncome(Cashflow.MILITARY_UPKEEP)).thenReturn(-20.0); + Map values = new HashMap<>(); + IntelligenceLedger.capture(values, guild); + assertEquals(130, values.get("Guild:guild:Income total")); + assertEquals(20, values.get("Guild:guild:Expense total")); + values.put("Guild:guild:Income", 110.0); + var report = EspionageService.createReport(values, 120, new Random(5)); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(report), IntelligenceReport.class); + clearInvocations(guild.getLedger()); + when(guild.getLedger().getIncome(Cashflow.TRADE)).thenReturn(1_000_000.0); + clearInvocations(guild.getLedger()); + assertEquals(IntelligenceLedger.summary(report, guild), IntelligenceLedger.summary(restored, guild)); + assertFalse(IntelligenceLedger.value(restored, guild, "Cashflow:TRADE", "d/day").contains("1000000")); + verifyNoInteractions(guild.getLedger()); + assertTrue(IntelligenceLedger.value(null, guild, "Income", "d/day").endsWith("Unknown")); + } + + @Test + void dividendPercentUsesValidBoundedRangeOrUnknown() { + var guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild"); + var report = new IntelligenceReport(); + report.quality = "Detailed estimates"; + report.estimates.put("Guild:guild:Dividend rate", new EspionageMath.Estimate(80, 200)); + assertEquals("#7fbd7380 to 100%", IntelligenceLedger.value(report, guild, "Dividend rate", "%")); + report.estimates.put("Guild:guild:Dividend rate", new EspionageMath.Estimate(0, 200)); + assertEquals("\u00a77Unknown", IntelligenceLedger.value(report, guild, "Dividend rate", "%")); + report.estimates.put("Guild:guild:Cashflow:MILITARY_UPKEEP", new EspionageMath.Estimate(-3, 2)); + assertEquals("#cf493a-3 to 0d", IntelligenceLedger.value(report, guild, "Cashflow:MILITARY_UPKEEP", "d")); + report.estimates.put("Guild:guild:Cashflow:TRADE", new EspionageMath.Estimate(-3, 3)); + assertEquals("#7fbd730 to 3d", IntelligenceLedger.value(report, guild, "Cashflow:TRADE", "d")); + } + + @Test + void guildRostersReuseTheFactionSampleAndRemainStableAcrossSerialization() { + var target = mock(Faction.class, RETURNS_DEEP_STUBS); + var guild = mock(Guild.class); + var members = java.util.stream.IntStream.range(0, 10).mapToObj(index -> "Account" + index).toList(); + when(target.getMembers()).thenReturn(members); + when(target.getGuildHandler().getGuilds()).thenReturn(List.of(guild)); + when(guild.getId()).thenReturn("guild"); + when(guild.isMember(anyString())).thenReturn(true); + when(guild.isLeader("Account0")).thenReturn(true); + var report = new IntelligenceReport(); + report.quality = "Detailed estimates"; + try (var names = mockStatic(CharacterNames.class); var represents = mockStatic(Represents.class)) { + names.when(() -> CharacterNames.of(anyString())).thenAnswer(call -> "Character " + call.getArgument(0).toString().substring(7)); + represents.when(() -> Represents.represents(eq(target), anyString())).thenReturn("Guild"); + EspionageService.captureMembers(report, target, 65, new Random(2)); + } + assertEquals(6, report.members.size()); + assertTrue(report.guildMembers.get("guild").stream().allMatch(name -> report.members.contains(name + " \u00a77— Guild"))); + assertFalse(report.guildMembers.get("guild").contains("Character 0")); + assertFalse(report.members.stream().anyMatch(name -> name.contains("Account"))); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(report), IntelligenceReport.class); + assertEquals(report.guildMembers, restored.guildMembers); + } + + @Test + void reportDateUsesTheLoreYearAndEraForTheSavedDay() { + int offset = net.tfminecraft.rpcharacters.Cache.calendarYearOffset; + String era = net.tfminecraft.rpcharacters.Cache.calendarEraSuffix; + try { + net.tfminecraft.rpcharacters.Cache.calendarYearOffset = 1675; + net.tfminecraft.rpcharacters.Cache.calendarEraSuffix = "AE"; + var report = new IntelligenceReport(); + report.quality = "Detailed estimates"; + report.day = LocalDate.of(2026, 10, 3).toEpochDay(); + assertEquals("03/10/351 AE", report.loreDate()); + } finally { + net.tfminecraft.rpcharacters.Cache.calendarYearOffset = offset; + net.tfminecraft.rpcharacters.Cache.calendarEraSuffix = era; + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java new file mode 100644 index 00000000..00ea5346 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java @@ -0,0 +1,132 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.enums.RankType; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.utils.FactionRanker; + +class IntelligenceBrowsingTest { + private Faction faction(String id) { + Faction faction = mock(Faction.class, RETURNS_DEEP_STUBS); + when(faction.getId()).thenReturn(id); + when(faction.getFoundedAt()).thenReturn(1L); + when(faction.getEspionage()).thenReturn(new EspionageState()); + when(faction.getMembers()).thenReturn(List.of()); + when(faction.getGuildHandler().getGuilds()).thenReturn(List.of()); + when(faction.getMilitary().getRegiments()).thenReturn(List.of()); + when(faction.getInstallationHandler().getAll()).thenReturn(List.of()); + return faction; + } + + @Test + void browsingOnlyReadsCacheAndMenuRefreshIsSharedOncePerDay() { + Player first = mock(Player.class), second = mock(Player.class); + when(first.getName()).thenReturn("First"); + when(second.getName()).thenReturn("Second"); + Faction observer = faction("observer"), target = faction("target"); + when(observer.isMemberIgnoreCase(anyString())).thenReturn(true); + try (var factions = mockStatic(FactionManager.class); var databases = mockConstruction(Database.class)) { + factions.when(() -> FactionManager.getByMember("First")).thenReturn(observer); + factions.when(() -> FactionManager.getByMember("Second")).thenReturn(observer); + factions.when(FactionManager::getCopy).thenAnswer(ignored -> new ArrayList<>(List.of(observer, target))); + assertNull(EspionageService.report(first, target)); + assertTrue(databases.constructed().isEmpty(), "Clicking must not save or roll anything"); + verify(target, never()).getWealth(); + EspionageService.refreshReports(first); + var report = EspionageService.report(first, target); + assertNotNull(report); + assertSame(report, EspionageService.report(second, target)); + assertEquals(1, databases.constructed().size()); + EspionageService.refreshReports(second); + assertSame(report, EspionageService.report(second, target)); + assertEquals(1, databases.constructed().size(), "A second member cannot reroll the faction's day"); + verify(first, times(1)).sendMessage(anyString()); + verify(second, never()).sendMessage(anyString()); + } + } + + @Test + void prestigeIsPublicAndWealthRankingUsesOnlyKnownMidpoints() { + Player viewer = mock(Player.class); + when(viewer.getName()).thenReturn("Viewer"); + Faction observer = faction("own"), estimated = faction("estimated"), hidden = faction("hidden"); + when(observer.isMemberIgnoreCase("Viewer")).thenReturn(true); + when(observer.getWealth()).thenReturn(100.0); + when(observer.getPrestige()).thenReturn(1.0); + when(estimated.getPrestige()).thenReturn(5.0); + when(hidden.getPrestige()).thenReturn(10.0); + long day = LocalDate.now(ZoneOffset.UTC).toEpochDay(); + observer.getEspionage().report("estimated", 1, day, () -> { + var report = new IntelligenceReport(); + report.quality = "Detailed estimates"; + report.estimates.put("Wealth", new EspionageMath.Estimate(200, 800)); + return report; + }); + var broad = observer.getEspionage().report("hidden", 1, day, () -> { + var report = new IntelligenceReport(); + report.quality = "Detailed estimates"; + report.estimates.put("Wealth", new EspionageMath.Estimate(0, 1000)); + return report; + }); + try (var factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getByMember("Viewer")).thenReturn(observer); + factions.when(FactionManager::getCopy).thenAnswer(ignored -> new ArrayList<>(List.of(observer, estimated, hidden))); + var ranker = new FactionRanker(); + assertEquals(500.0, ranker.visibleValue(viewer, estimated, RankType.WEALTH)); + assertNull(ranker.visibleValue(viewer, hidden, RankType.WEALTH)); + assertEquals(List.of(estimated, observer, hidden), ranker.getVisibleRankedList(viewer, RankType.WEALTH)); + assertEquals(1, ranker.getVisibleRank(viewer, estimated, RankType.WEALTH)); + assertNull(ranker.getVisibleRank(viewer, hidden, RankType.WEALTH)); + assertEquals(new EspionageMath.Estimate(0, 1000), broad.estimates.get("Wealth")); + assertEquals(List.of(hidden, estimated, observer), ranker.getVisibleRankedList(viewer, RankType.PRESTIGE)); + verify(estimated, never()).getWealth(); + verify(hidden, never()).getWealth(); + } + } + + @Test + void staffRefreshRebuildsAllDirectionsAndPersistsEachFactionOnce() { + Faction first = faction("first"), second = faction("second"); + long day = LocalDate.now(ZoneOffset.UTC).toEpochDay(); + var oldFirst = first.getEspionage().report("second", 1, day, IntelligenceReport::new); + var oldSecond = second.getEspionage().report("first", 1, day, IntelligenceReport::new); + var oldRolls = first.getEspionage().rolls(day, new Random(1)); + try (var factions = mockStatic(FactionManager.class); var databases = mockConstruction(Database.class)) { + factions.when(FactionManager::getCopy).thenAnswer(ignored -> new ArrayList<>(List.of(first, second))); + assertEquals(2, EspionageService.regenerateReports()); + assertNotSame(oldFirst, first.getEspionage().cachedReport("second", 1, day)); + assertNotSame(oldSecond, second.getEspionage().cachedReport("first", 1, day)); + assertNotSame(oldRolls, first.getEspionage().rolls(day, new Random(1))); + assertEquals(2, databases.constructed().size()); + verify(databases.constructed().get(0)).saveFaction(first); + verify(databases.constructed().get(1)).saveFaction(second); + } + } + + @Test + void sampledRostersImproveWithQualityAndPersistAsSnapshots() { + List members = java.util.stream.IntStream.range(0, 10).mapToObj(i -> "Character " + i + " — Guild").toList(); + assertTrue(EspionageService.sample(members, 0, new Random(1)).isEmpty()); + assertEquals(2, EspionageService.sample(members, 1, new Random(1)).size()); + assertEquals(4, EspionageService.sample(members, 30, new Random(1)).size()); + assertEquals(6, EspionageService.sample(members, 65, new Random(1)).size()); + var report = new IntelligenceReport(); + report.quality = "Detailed estimates"; + report.members = EspionageService.sample(members, 100, new Random(1)); + assertEquals(8, report.members.size()); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(report), IntelligenceReport.class); + assertEquals(report.members, restored.members); + assertNotEquals(report.members, EspionageService.sample(members, 100, new Random(10))); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java new file mode 100644 index 00000000..bbea38fc --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java @@ -0,0 +1,108 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.List; +import java.util.Map; +import java.util.Random; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.objects.Faction; + +class IntelligencePolicyTest { + @AfterEach void defaults() { EspionageConfig.load(new YamlConfiguration()); } + + @Test void armyAndCashflowsAreGatedAtGenerationAndWhenReadingOldSnapshots() { + var values = Map.of("Professional army", 100.0, "Guild:guild:Cashflow:TRADE", 100.0, "Members", 100.0); + var broad = EspionageService.createReport(values, 40, new Random(1)); + assertEquals("Unknown", broad.display("Professional army")); + assertEquals("Unknown", broad.display("Guild:guild:Cashflow:TRADE")); + assertFalse(broad.estimates.containsKey("Professional army")); + // Old data cannot bypass a disclosure rule after reloading configuration. + broad.estimates.put("Professional army", new EspionageMath.Estimate(90, 110)); + assertEquals("Unknown", broad.display("Professional army")); + var reliable = EspionageService.createReport(values, 65, new Random(1)); + assertNotEquals("Unknown", reliable.display("Professional army")); + var config = new YamlConfiguration(); + config.set("espionage.intelligence.minimum-tiers.professional-army", "detailed"); + config.set("espionage.intelligence.minimum-tiers.cashflows.TRADE", "broad"); + EspionageConfig.load(config); + assertEquals("Unknown", reliable.display("Professional army")); + assertTrue(broad.allows("Guild:guild:Cashflow:TRADE")); + config.set("espionage.intelligence.minimum-tiers.professional-army", "typo"); + EspionageConfig.load(config); + assertFalse(EspionageConfig.allows(IntelligenceTier.DETAILED, "Professional army")); + assertFalse(EspionageConfig.allows(IntelligenceTier.DETAILED, "Unconfigured private metric")); + } + + @Test void lowerTiersGiveBroaderUsefulRangesAndThresholdsAreConfigurable() { + var rumours = EspionageService.createReport(Map.of("Members", 100.0), 1, new Random(8)); + var detailed = EspionageService.createReport(Map.of("Members", 100.0), 100, new Random(8)); + assertNotNull(rumours.estimate("Members")); + var broad = rumours.estimate("Members"); + var narrow = detailed.estimate("Members"); + assertTrue(broad.upper() - broad.lower() > narrow.upper() - narrow.lower()); + assertTrue(broad.lower() >= 0 && broad.lower() <= 100 && broad.upper() >= 100); + var config = new YamlConfiguration(); + config.set("espionage.intelligence.tiers.reliable.minimum-margin", 80); + EspionageConfig.load(config); + assertEquals(IntelligenceTier.BROAD, EspionageConfig.tier(79)); + assertEquals(IntelligenceTier.RELIABLE, EspionageConfig.tier(80)); + config.set("espionage.intelligence.minimum-tiers.wealth", "unknown"); + EspionageConfig.load(config); + assertEquals("Unknown", EspionageService.createReport(Map.of("Wealth", 100.0), 500, new Random(1)).display("Wealth")); + } + + @Test void foreignOfficeIdentityAndAptitudeHaveIndependentThresholdsAndAreSnapshots() { + var target = mock(Faction.class); + var state = new EspionageState(); + when(target.getEspionage()).thenReturn(state); + when(target.isMemberIgnoreCase("Account")).thenReturn(true); + when(target.getMembers()).thenReturn(List.of("Account")); + var holder = new SpecialPositionAssignment(); + holder.playerName = "Account"; + state.appoint(holder, 80); + try (var names = mockStatic(CharacterNames.class)) { + names.when(() -> CharacterNames.of("Account")).thenReturn("Lady Raven"); + for (int margin : new int[]{30, 65, 100}) { + var report = EspionageService.createReport(Map.of(), margin, new Random(1)); + EspionageService.captureOffices(report, target, new Random(1)); + assertEquals(margin < 65 ? "Unknown" : "Lady Raven", report.officeHolder(SpecialPosition.SPYMASTER)); + String key = IntelligenceReport.officeAptitudeKey(SpecialPosition.SPYMASTER); + if (margin < 100) assertEquals("Unknown", report.display(key)); + else { + var range = report.estimate(key); + assertNotNull(range); + assertTrue(range.lower() <= 80 && range.upper() >= 80 && range.upper() <= 100); + holder.aptitude = 1; + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(report), IntelligenceReport.class); + assertEquals(range, restored.estimate(key)); + assertEquals("Lady Raven", restored.officeHolder(SpecialPosition.SPYMASTER)); + } + } + } + var missingTier = new IntelligenceReport(); + missingTier.officeHolders.put(SpecialPosition.SPYMASTER, "Secret"); + assertEquals("Unknown", missingTier.officeHolder(SpecialPosition.SPYMASTER)); + } + + @Test void malformedNumericSettingsCannotCreateNonFiniteRollsOrInvalidTiers() { + var config = new YamlConfiguration(); + config.set("espionage.checks.aptitude-multiplier", Double.POSITIVE_INFINITY); + config.set("espionage.checks.luck-draws", 0); + config.set("espionage.intelligence.tiers.rumours.minimum-margin", 500); + config.set("espionage.intelligence.tiers.broad.minimum-margin", 1); + config.set("espionage.intelligence.tiers.detailed.uncertainty", -1); + EspionageConfig.load(config); + assertEquals(1.25, EspionageConfig.rollMultiplier()); + assertEquals(3, EspionageConfig.luckDraws()); + int last = 0; + for (var tier : IntelligenceTier.values()) if (tier != IntelligenceTier.UNKNOWN) { + assertTrue(EspionageConfig.settings(tier).minimumMargin() > last); + last = EspionageConfig.settings(tier).minimumMargin(); + } + assertEquals(.1, EspionageConfig.settings(IntelligenceTier.DETAILED).uncertainty()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java new file mode 100644 index 00000000..91d462e6 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java @@ -0,0 +1,102 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; +import java.util.Random; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.Cache; + +class IntelligenceRangesTest { + private IntelligenceReport report(String metric, long lower, long upper) { + var report = new IntelligenceReport(); + report.quality = "Detailed estimates"; + report.estimates.put(metric, new EspionageMath.Estimate(lower, upper)); + return report; + } + + @Test + void cachedStabilityUsesBoundedMidpointForBothStateAndRangeColor() { + var report = report("Stability", 80, 200); + assertEquals(new EspionageMath.Estimate(80, 100), report.estimate("Stability")); + assertEquals("#45c46fStable State", IntelligenceReport.stabilityState(report)); + assertEquals("#45c46f80 to 100%", IntelligenceReport.stabilityRange(report)); + assertEquals(new EspionageMath.Estimate(80, 200), report.estimates.get("Stability"), + "Display sanitization must not rewrite the daily snapshot"); + var struggling = report("Stability", 40, 70); + assertEquals("#c76734Struggling State", IntelligenceReport.stabilityState(struggling)); + assertEquals("#c7673440 to 70%", IntelligenceReport.stabilityRange(struggling)); + } + + @Test + void uninformativeOrInvalidStabilityIsUnknownRatherThanAnInventedState() { + for (long[] bounds : new long[][]{{0, 200}, {0, 100}, {201, 300}, {-100, -1}, {100, 100}, {100, 200}, {80, 20}}) { + var report = report("Stability", bounds[0], bounds[1]); + assertEquals("Unknown", report.display("Stability")); + assertEquals("\u00a77Unknown State", IntelligenceReport.stabilityState(report)); + assertEquals("\u00a77Unknown", IntelligenceReport.stabilityRange(report)); + } + assertEquals("\u00a77Unknown State", IntelligenceReport.stabilityState(null)); + assertEquals("\u00a77Unknown", IntelligenceReport.stabilityRange(null)); + } + + @Test + void generatedStabilityRangesAreBoundedUsefulAndContainTheActualValue() { + var random = new Random(41); + int known = 0; + int unknown = 0; + for (int stability : new int[]{0, 1, 19, 40, 60, 75, 99, 100}) { + for (int margin : new int[]{1, 30, 65, 100}) { + for (int sample = 0; sample < 30; sample++) { + var report = EspionageService.createReport(Map.of("Stability", (double) stability), margin, random); + var range = report.estimate("Stability"); + if (range == null) { unknown++; continue; } + known++; + assertTrue(range.lower() >= 0 && range.upper() <= 100); + assertTrue(range.lower() <= stability && range.upper() >= stability); + assertTrue(range.lower() < range.upper() && range.upper() - range.lower() <= 100 * EspionageConfig.settings(report.tier()).boundedFraction()); + } + } + } + assertTrue(known > 0 && unknown > 0); + } + + @Test + void countAndOutputRangesCannotBeNegativeOrExceedPublicGuildCapacity() { + for (String metric : new String[]{"Members", "Professional army", "Levies", "Mercenaries", "Installations", "Prosperity", "Guild:test:Trade power"}) { + assertEquals(new EspionageMath.Estimate(0, 3), report(metric, -5, 3).estimate(metric)); + assertEquals("Unknown", report(metric, -10, -2).display(metric)); + assertEquals("Unknown", report(metric, 0, 1000).display(metric)); + } + int previous = Cache.maxMembers; + try { + Cache.maxMembers = 64; + assertEquals(new EspionageMath.Estimate(50, 64), report("Guild:test:Members", 50, 100).estimate("Guild:test:Members")); + assertEquals("Unknown", report("Guild:test:Members", 0, 100).display("Guild:test:Members")); + assertEquals("Unknown", report("Guild:test:Members", 80, 100).display("Guild:test:Members")); + } finally { Cache.maxMembers = previous; } + } + + @Test + void legitimateDebtAndDeficitsRemainSignedButBroadGuessesAreUnknown() { + for (String metric : new String[]{"Wealth", "Daily net income", "Administrative power", "Guild:test:Wealth", "Guild:test:Income"}) { + assertEquals("-100 to -50", report(metric, -100, -50).display(metric)); + assertEquals("Unknown", report(metric, -500, 500).display(metric)); + assertEquals("Unknown", report(metric, 0, 1000).display(metric)); + assertEquals("20 to 70", report(metric, 20, 70).display(metric)); + } + } + + @Test + void missingAndLegacyReportsUseUnknownWithoutChangingTheirData() { + var report = new IntelligenceReport(); + report.quality = "Detailed estimates"; + report.quality = "Hidden"; + assertEquals("Unknown", report.qualityLabel()); + assertEquals("Hidden", report.quality); + assertEquals("Unknown", report.display("Wealth")); + report.estimates = null; + assertEquals("Unknown", report.display("Wealth")); + assertEquals("Unknown", EspionageMath.quality(0)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java new file mode 100644 index 00000000..d2a78477 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java @@ -0,0 +1,125 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; + +class OfficeAppointmentsTest { + @Test + void newSoloFactionAssignsFounderWithoutChargingOrUsingTheFreeAppointment() { + var faction = mock(Faction.class); + var state = new EspionageState(); + when(faction.getEspionage()).thenReturn(state); + when(faction.getLeader()).thenReturn("Founder"); + when(faction.isLeader("Founder")).thenReturn(true); + when(faction.getMembers()).thenReturn(java.util.List.of("Founder")); + var offline = mock(org.bukkit.OfflinePlayer.class); + when(offline.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(mock(org.bukkit.Server.class)); + bukkit.when(() -> org.bukkit.Bukkit.getOfflinePlayer("Founder")).thenReturn(offline); + EspionageService.initializeFounder(faction); + assertEquals("Founder", state.getSpymaster().playerName); + assertTrue(state.getSpymaster().automatic); + assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); + verify(faction, never()).getBank(); + state.removeSpymaster(); + when(faction.getMembers()).thenReturn(java.util.List.of("Founder", "Member")); + EspionageService.initializeFounder(faction); + assertNull(state.getSpymaster()); + } + } + + private SpecialPositionAssignment assignment(String name) { + var assignment = new SpecialPositionAssignment(); + assignment.playerName = name; + assignment.characterId = name; + return assignment; + } + + @Test + void founderDoesNotConsumeFreeAppointmentAndRemovalCannotResetPaidHistory() { + var state = new EspionageState(); + state.assignFounder(SpecialPosition.SPYMASTER, assignment("Founder"), 80); + assertTrue(state.getSpymaster().automatic); + assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); + var faction = mock(Faction.class); + var bank = mock(Bank.class); + var balance = new AtomicReference<>(1000.0); + when(faction.getEspionage()).thenReturn(state); + when(faction.getBank()).thenReturn(bank); + when(bank.getWealth()).thenAnswer(ignored -> balance.get()); + doAnswer(call -> { balance.updateAndGet(value -> value - (Double) call.getArgument(0)); return null; }).when(bank).withdraw(anyDouble()); + assertTrue(EspionageService.completeAppointment(faction, assignment("First"), 90)); + assertEquals(1000.0, balance.get()); + assertTrue(state.unrestModifiers(System.currentTimeMillis()).isEmpty()); + state.removeSpymaster(); + assertEquals(250.0, EspionageService.appointmentCost(faction)); + assertTrue(EspionageService.completeAppointment(faction, assignment("Second"), 65)); + assertEquals(750.0, balance.get()); + assertEquals(-10, state.unrestModifiers(System.currentTimeMillis()).getFirst().getModifier(), 0.001); + state.removeSpymaster(); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(state), EspionageState.class); + assertEquals(2, restored.appointmentCount(SpecialPosition.SPYMASTER)); + assertEquals(-10, restored.unrestModifiers(System.currentTimeMillis()).getFirst().getModifier(), 0.001); + verify(bank, times(1)).withdraw(250.0); + } + + @Test + void unaffordableAppointmentDoesNotChangeOfficeBalanceHistoryOrStability() { + var state = new EspionageState(); + var first = assignment("First"); + state.appoint(first, 82); + var faction = mock(Faction.class); + var bank = mock(Bank.class); + when(faction.getEspionage()).thenReturn(state); + when(faction.getBank()).thenReturn(bank); + when(bank.getWealth()).thenReturn(249.0); + assertFalse(EspionageService.completeAppointment(faction, assignment("Second"), 20)); + assertSame(first, state.getSpymaster()); + assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + assertTrue(state.unrestModifiers(System.currentTimeMillis()).isEmpty()); + verify(bank, never()).withdraw(anyDouble()); + } + + @Test + void unrestFadesOverRealTimeAndExpiresEvenAcrossDowntime() { + long week = 7 * 86_400_000L; + var state = new EspionageState(); + state.addUnrest(SpecialPosition.SPYMASTER, 10, 7, 1000); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(state), EspionageState.class); + assertEquals(-10, restored.unrestModifiers(1000).getFirst().getModifier()); + assertEquals(-5, restored.unrestModifiers(1000 + week / 2).getFirst().getModifier()); + assertTrue(restored.unrestModifiers(1000 + week).isEmpty()); + assertTrue(restored.unrestModifiers(1000 + week * 2).isEmpty()); + } + + @Test + void existingSavedOfficeCountsAsAlreadyAppointedAndConfigRejectsInvalidCosts() { + var state = JsonUtil.GSON.fromJson("{\"positions\":{\"SPYMASTER\":{\"playerName\":\"OldSpy\",\"aptitude\":80}}}", EspionageState.class); + assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + state.removeSpymaster(); + assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + var config = new org.bukkit.configuration.file.YamlConfiguration(); + try { + config.set("espionage.appointments.repeat-cost", 500.0); + config.set("espionage.appointments.stability-penalty", 12.0); + config.set("espionage.appointments.penalty-days", 3.0); + EspionageConfig.load(config); + assertEquals(500, EspionageConfig.repeatCost()); + assertEquals(12, EspionageConfig.stabilityPenalty()); + assertEquals(3, EspionageConfig.penaltyDays()); + config.set("espionage.appointments.repeat-cost", -1.0); + config.set("espionage.appointments.stability-penalty", Double.NaN); + config.set("espionage.appointments.penalty-days", 0.0); + EspionageConfig.load(config); + assertEquals(250, EspionageConfig.repeatCost()); + assertEquals(10, EspionageConfig.stabilityPenalty()); + assertEquals(0, EspionageConfig.penaltyDays()); + } finally { EspionageConfig.load(new org.bukkit.configuration.file.YamlConfiguration()); } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java new file mode 100644 index 00000000..8ab4811b --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java @@ -0,0 +1,71 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Files; +import java.nio.file.Path; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SpecialPositionsConfigFileTest { + @TempDir Path directory; + + @Test void completeMigrationRemovesOnlyTheLegacySectionAndBacksUpTheOriginal() throws Exception { + var legacy = directory.resolve("config.yml"); + String original = "# Keep this comment\nunrelated: value\nespionage:\n aptitude:\n attribute-weights:\n strength: -4.0\n\nafter: preserved\n"; + Files.writeString(legacy, original); + var plugin = org.mockito.Mockito.mock(net.tfminecraft.simplefactions.SimpleFactions.class); + org.mockito.Mockito.when(plugin.getDataFolder()).thenReturn(directory.toFile()); + org.mockito.Mockito.when(plugin.getResource("special-positions.yml")).thenAnswer(ignored -> + getClass().getResourceAsStream("/special-positions.yml")); + org.mockito.Mockito.when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + try { + SpecialPositionsConfigFile.load(plugin); + assertEquals("# Keep this comment\nunrelated: value\nafter: preserved\n", Files.readString(legacy)); + assertEquals(original, Files.readString(directory.resolve("config.yml.before-special-positions"))); + assertEquals(-4, EspionageConfig.weights().get("strength")); + SpecialPositionsConfigFile.load(plugin); + assertEquals(original, Files.readString(directory.resolve("config.yml.before-special-positions"))); + } finally { EspionageConfig.load(new YamlConfiguration()); } + } + + @Test void migrationPreservesCustomWeightsPermissionCostsAndMainConfiguration() throws Exception { + var legacy = directory.resolve("config.yml"); + String original = "unrelated: value\nespionage:\n bypass-permission: staff.spy\n aptitude:\n attribute-weights:\n strength: -4.0\n appointments:\n repeat-cost: 333.0\n"; + Files.writeString(legacy, original); + var file = directory.resolve("special-positions.yml"); + var defaults = defaults(); + var migrated = SpecialPositionsConfigFile.prepare(file.toFile(), legacy.toFile(), defaults); + assertEquals("staff.spy", migrated.getString("espionage.bypass-permission")); + assertEquals(-4, migrated.getDouble("espionage.aptitude.attribute-weights.strength")); + assertEquals(333, migrated.getDouble("espionage.appointments.repeat-cost")); + assertEquals("reliable", migrated.getString("espionage.intelligence.minimum-tiers.professional-army")); + assertEquals(original, Files.readString(legacy)); + // Existing dedicated settings always win over stale legacy values; missing new fields are added. + migrated.set("espionage.appointments.repeat-cost", 250); + migrated.set("espionage.intelligence.minimum-tiers.professional-army", null); + migrated.save(file.toFile()); + var reloaded = SpecialPositionsConfigFile.prepare(file.toFile(), legacy.toFile(), defaults); + assertEquals(250, reloaded.getDouble("espionage.appointments.repeat-cost")); + assertEquals(-4, reloaded.getDouble("espionage.aptitude.attribute-weights.strength")); + assertEquals("reliable", reloaded.getString("espionage.intelligence.minimum-tiers.professional-army")); + assertEquals(original, Files.readString(legacy)); + } + + @Test void malformedDedicatedYamlIsRejectedWithoutOverwritingEitherFile() throws Exception { + var file = directory.resolve("special-positions.yml"); + Files.writeString(file, "espionage: [invalid\n"); + var legacy = directory.resolve("config.yml"); + Files.writeString(legacy, "unrelated: value\n"); + assertThrows(org.bukkit.configuration.InvalidConfigurationException.class, + () -> SpecialPositionsConfigFile.prepare(file.toFile(), legacy.toFile(), defaults())); + assertEquals("espionage: [invalid\n", Files.readString(file)); + assertEquals("unrelated: value\n", Files.readString(legacy)); + } + + private YamlConfiguration defaults() throws Exception { + var yaml = new YamlConfiguration(); + yaml.load(new java.io.InputStreamReader(getClass().getResourceAsStream("/special-positions.yml"), java.nio.charset.StandardCharsets.UTF_8)); + return yaml; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryViewDowngradeTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryViewDowngradeTest.java index 396a4308..27ea5a3f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryViewDowngradeTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryViewDowngradeTest.java @@ -170,6 +170,7 @@ void downgradeClick_ignoresAnyoneButTheLeader() { private static Faction faction(String leader) { Faction faction = mock(Faction.class); when(faction.getLeader()).thenReturn(leader); + when(faction.isLeader(leader)).thenReturn(true); when(faction.getMilitary()).thenReturn(mock(Military.class)); return faction; } From d19dab91de9e8b4b892e3fbae827e5d0b2af490f Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:36:45 +0200 Subject: [PATCH 02/13] Keep reported ledger title readable and document migrated config --- ESPIONAGE.md | 5 ++--- .../simplefactions/managers/inventory/EspionageView.java | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ESPIONAGE.md b/ESPIONAGE.md index 497ff9d0..1c011fb4 100644 --- a/ESPIONAGE.md +++ b/ESPIONAGE.md @@ -9,7 +9,7 @@ in-game menus; website exports and staff administration commands are unchanged. ## Staff viewing permission -Set the permission node in `plugins/SimpleFactions/config.yml`: +Set the permission node in `plugins/SimpleFactions/special-positions.yml`: ```yaml espionage: @@ -32,8 +32,7 @@ explicitly to `staff` and deny it for `staff_inactive`; explicit denials inherit 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 or wait for its periodic refresh after a -rank change. +exact at every rank. Reopen the menu after a rank change. ## Spymaster appointment diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java index 99ac9d53..8ab445a5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -117,7 +117,7 @@ public static void guild(Inventory inventory, Player viewer, Guild guild, Invent public static void foreignLedger(Player viewer, Guild guild, InventoryManager manager) { Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(guild.getId(), SFGUI.FOREIGN_LEDGER_VIEW), - 54, "\u00a77Reported Ledger for " + org.bukkit.ChatColor.stripColor(guild.getName())); + 54, "\u00a77Reported Ledger"); var report = EspionageService.report(viewer, guild.getFaction()); inventory.setItem(4, ledgerItem(report, guild)); int slot = 9; From 631b3c452b6df4e59c52649f76a8173015e04b74 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:46:18 +0200 Subject: [PATCH 03/13] Address espionage review findings and cover migration and completion edges --- .../simplefactions/espionage/CharacterNames.java | 16 ++++++++++++++-- .../espionage/SpecialPositionsConfigFile.java | 4 +++- .../managers/inventory/GuildCreator.java | 3 +-- .../simplefactions/utils/TabCompletion.java | 5 +---- .../espionage/CharacterNameResolutionTest.java | 13 +++++++++++++ .../SpecialPositionsConfigFileTest.java | 4 ++-- 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java index c028f66d..394d1ec1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java @@ -2,7 +2,6 @@ import java.nio.file.Files; import java.nio.file.Path; -import java.util.HashMap; import java.util.Map; import org.bukkit.Bukkit; import net.tfminecraft.rpcharacters.managers.PlayerManager; @@ -12,7 +11,7 @@ /** Offline names are read without loading or modifying RPCharacters player state. */ public final class CharacterNames { private record Cached(String name, long expires) {} - private static final Map cache = new HashMap<>(); + private static final Map cache = new java.util.concurrent.ConcurrentHashMap<>(); private CharacterNames() {} public static String display(org.bukkit.entity.Player viewer, String account) { @@ -41,6 +40,19 @@ static Resolution resolve(String query, java.util.Collection inviteCompletions(java.util.Collection names, String[] args) { + if (args.length < 2) return java.util.List.of(); + int skip = args.length - 2; + String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length)); + return names.stream().distinct() + .filter(name -> name.regionMatches(true, 0, prefix, 0, prefix.length())) + .map(name -> name.split(" ")) + .filter(words -> words.length > skip) + .map(words -> String.join(" ", java.util.Arrays.copyOfRange(words, skip, words.length))) + .toList(); + } + public static String of(String playerName) { if (playerName == null) return "Unknown"; if (Bukkit.getServer() == null || !Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) return playerName; diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java index 734e0d70..83e5152a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java @@ -26,7 +26,9 @@ public static void load(JavaPlugin plugin) { if (!existed && legacy.exists()) { // Preserve comments and unrelated settings. Never rewrite the entire main YAML. String before = Files.readString(legacy.toPath()); - String after = before.replaceFirst("(?m)^espionage:[^\\r\\n]*(?:\\r?\\n(?:[ \\t]+[^\\r\\n]*|#[^\\r\\n]*|))*", ""); + // Keep comments for the next root key; interior comments have another indented line after them. + String after = before.replaceFirst("(?m)^espionage:[^\\r\\n]*(?:(?:\\r?\\n(?:[ \\t]*|[ \\t]*#[^\\r\\n]*))*" + + "\\r?\\n[ \\t]+[^\\r\\n]*)*(?:\\r?\\n[ \\t]*(?=\\r?\\n|$))*(?:\\r?\\n)?", ""); if (!before.equals(after)) { Files.copy(legacy.toPath(), new File(plugin.getDataFolder(), "config.yml.before-special-positions").toPath(), StandardCopyOption.REPLACE_EXISTING); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java index 2e44485e..4442b784 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -220,9 +220,8 @@ public ItemStack createMenuItem(Player p, Guild guild, MenuItemType t) { for(Guild g : top) { if (r.visibleGuildValue(p, g, RankType.INCOME) == null) continue; x++; - var report = EspionageService.report(p, g.getFaction()); String income = EspionageService.canViewExact(p, g.getFaction()) ? String.valueOf(g.getLedger().getNetIncome()) - : report.display("Guild:" + g.getId() + ":Income"); + : guildIntelligence(EspionageService.report(p, g.getFaction()), g, "Income", ""); lore.add(StringFormatter.formatHex("§f - §e"+x+". "+g.getName()+" §7["+g.getSize()+"§7]#d4c9ae: #7fbd73"+income+"d/day")); if(x > 4) break; } diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java index 43815a1f..b22bd570 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java @@ -458,10 +458,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le completions.add(p.getName()); completions.add(net.tfminecraft.simplefactions.espionage.CharacterNames.of(p.getName())); } - String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length)); - return completions.stream().distinct().filter(name -> name.regionMatches(true, 0, prefix, 0, prefix.length())) - .map(name -> String.join(" ", java.util.Arrays.copyOfRange(name.split(" "), args.length - 2, name.split(" ").length))) - .toList(); + return net.tfminecraft.simplefactions.espionage.CharacterNames.inviteCompletions(completions, args); } } else if(cmd.getName().equalsIgnoreCase("guild") && args.length == 2 && args[0].equalsIgnoreCase("kick")){ if(sender instanceof Player){ diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java index 2d30799e..85981ebe 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java @@ -7,6 +7,19 @@ import org.junit.jupiter.api.Test; class CharacterNameResolutionTest { + @org.junit.jupiter.api.Test + void invitationCompletionReturnsOnlyCurrentTokenAndGuardsPastEndOfName() { + var names = java.util.List.of("Lady Raven", "Lady Rose", "Lady", "Account", "Lady Raven"); + assertEquals(java.util.List.of("Lady Raven", "Lady Rose", "Lady"), + CharacterNames.inviteCompletions(names, new String[]{"invite", "La"})); + assertEquals(java.util.List.of("Raven", "Rose"), + CharacterNames.inviteCompletions(names, new String[]{"invite", "Lady", "R"})); + assertEquals(java.util.List.of("Raven", "Rose"), + CharacterNames.inviteCompletions(names, new String[]{"invite", "lady", ""})); + assertTrue(CharacterNames.inviteCompletions(names, new String[]{"invite", "Lady", "Raven", ""}).isEmpty()); + assertTrue(CharacterNames.inviteCompletions(names, new String[]{"invite", "Account", "", ""}).isEmpty()); + assertEquals(java.util.List.of("Account"), CharacterNames.inviteCompletions(names, new String[]{"invite", "acc"})); + } private Player player(String name) { var player = mock(Player.class); when(player.getName()).thenReturn(name); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java index 8ab4811b..bda1eb0d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java @@ -12,7 +12,7 @@ class SpecialPositionsConfigFileTest { @Test void completeMigrationRemovesOnlyTheLegacySectionAndBacksUpTheOriginal() throws Exception { var legacy = directory.resolve("config.yml"); - String original = "# Keep this comment\nunrelated: value\nespionage:\n aptitude:\n attribute-weights:\n strength: -4.0\n\nafter: preserved\n"; + String original = "# Keep this comment\nunrelated: value\nespionage:\n aptitude:\n# An interior office comment\n attribute-weights:\n strength: -4.0\n\n# Keep the next key's comment\nafter: preserved\n"; Files.writeString(legacy, original); var plugin = org.mockito.Mockito.mock(net.tfminecraft.simplefactions.SimpleFactions.class); org.mockito.Mockito.when(plugin.getDataFolder()).thenReturn(directory.toFile()); @@ -21,7 +21,7 @@ class SpecialPositionsConfigFileTest { org.mockito.Mockito.when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); try { SpecialPositionsConfigFile.load(plugin); - assertEquals("# Keep this comment\nunrelated: value\nafter: preserved\n", Files.readString(legacy)); + assertEquals("# Keep this comment\nunrelated: value\n# Keep the next key's comment\nafter: preserved\n", Files.readString(legacy)); assertEquals(original, Files.readString(directory.resolve("config.yml.before-special-positions"))); assertEquals(-4, EspionageConfig.weights().get("strength")); SpecialPositionsConfigFile.load(plugin); From 6823646129b77beb7a006707bb7d4c79bfe51d57 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:48:59 +0200 Subject: [PATCH 04/13] Complete invitations using visible character names --- .../tfminecraft/simplefactions/espionage/CharacterNames.java | 2 +- .../simplefactions/espionage/CharacterNameResolutionTest.java | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java index 394d1ec1..3e677ad7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java @@ -45,7 +45,7 @@ public static java.util.List inviteCompletions(java.util.Collection name.regionMatches(true, 0, prefix, 0, prefix.length())) .map(name -> name.split(" ")) .filter(words -> words.length > skip) diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java index 85981ebe..b1c5dab7 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java @@ -19,6 +19,10 @@ void invitationCompletionReturnsOnlyCurrentTokenAndGuardsPastEndOfName() { assertTrue(CharacterNames.inviteCompletions(names, new String[]{"invite", "Lady", "Raven", ""}).isEmpty()); assertTrue(CharacterNames.inviteCompletions(names, new String[]{"invite", "Account", "", ""}).isEmpty()); assertEquals(java.util.List.of("Account"), CharacterNames.inviteCompletions(names, new String[]{"invite", "acc"})); + assertEquals(java.util.List.of("Lady Raven"), CharacterNames.inviteCompletions( + java.util.List.of("\u00a76Lady \u00a7eRaven"), new String[]{"invite", "La"})); + assertEquals(java.util.List.of("Raven"), CharacterNames.inviteCompletions( + java.util.List.of("\u00a76Lady \u00a7eRaven"), new String[]{"invite", "Lady", "R"})); } private Player player(String name) { var player = mock(Player.class); From 58c0bd4c80054077669920124e392648f01752ec Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:42:48 +0200 Subject: [PATCH 05/13] Keep rumours as report minimum and restore masked menu navigation --- ESPIONAGE.md | 34 +- .../espionage/EspionageAccess.java | 2 + .../espionage/EspionageConfig.java | 20 +- .../espionage/EspionageService.java | 33 +- .../espionage/IntelligenceLedger.java | 7 +- .../espionage/IntelligenceRanges.java | 8 +- .../espionage/IntelligenceReport.java | 21 +- .../espionage/ReportDetails.java | 39 +++ .../simplefactions/espionage/RosterLore.java | 68 ++++ .../managers/InventoryManager.java | 5 + .../managers/holder/SFInventoryHolder.java | 4 + .../managers/inventory/CompanyView.java | 1 + .../managers/inventory/EspionageView.java | 50 +-- .../managers/inventory/FactionCreator.java | 11 +- .../managers/inventory/FactionView.java | 13 +- .../managers/inventory/GovernmentView.java | 8 +- .../managers/inventory/GuildCreator.java | 20 +- .../managers/inventory/GuildView.java | 13 +- .../managers/inventory/InstallationView.java | 15 +- .../managers/inventory/InventoryUpdater.java | 1 + .../managers/inventory/LawView.java | 8 +- .../managers/inventory/LoanView.java | 7 + .../managers/inventory/MilitaryView.java | 7 +- .../managers/inventory/ReportedMenus.java | 306 ++++++++++++++++++ .../managers/inventory/SupplyHubView.java | 4 + .../managers/inventory/TaxView.java | 11 +- src/main/resources/special-positions.yml | 16 +- .../espionage/EspionageMathTest.java | 4 +- .../espionage/EspionagePermissionsTest.java | 24 +- .../espionage/IntelligenceBrowsingTest.java | 2 +- .../espionage/IntelligenceRangesTest.java | 2 +- .../espionage/ReportPresentationTest.java | 109 +++++++ 32 files changed, 781 insertions(+), 92 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java diff --git a/ESPIONAGE.md b/ESPIONAGE.md index 1c011fb4..4ec02759 100644 --- a/ESPIONAGE.md +++ b/ESPIONAGE.md @@ -1,7 +1,7 @@ # Espionage (development) Foreign faction and guild menus always show public identity and flavor details: -character names of leaders, government, rank, tier, titles, settlements, culture, +character names of faction leaders, government, rank, tier, titles, settlements, culture, religion, guild types, allies and subjects. Prestige is public and its ranking uses exact values. Other figures are hidden or shown as intelligence ranges. Members retain exact information about their own faction and guilds. This covers @@ -145,7 +145,7 @@ the next uncached rolls. A recreated target faction receives a fresh report. | Margin | Intelligence | Approximate range width | Known non-leader members | | --- | --- | --- | --- | -| <= 0 | Unknown | None | 0% | +| <= 0 | Rumours | Up to 300% | 20% | | 1-29 | Rumours | 300% of magnitude | 20% | | 30-64 | Broad estimates | 150% | 40% | | 65-99 | Reliable estimates | 40% | 60% | @@ -155,7 +155,9 @@ Samples are rounded down, capped at 23, and saved with the report so reopening cannot reveal extra names. They contain character names and guild affiliations. The faction leader is always displayed separately alongside the member-count range, regardless of quality. Guild rosters use the same sample, filtered by -guild, and always show their guild leader. Own rosters put the leader first. +guild. Guild leaders and special-office identities use their configurable tier gates. +All rosters distinguish the faction leader, guild leaders and office holders; guilds +are grouped by descending visible wealth (estimated midpoints for foreign views). Ranges are rounded, asymmetric snapshots containing the true value when generated. They never collapse to an exact number, including zero. Reports cover members, @@ -180,12 +182,12 @@ faction. Unknown entries appear alphabetically after ranked known entries and ha no numeric rank. Guild income leaderboards use the same intelligence policy. Foreign tooltips retain the same colors, spacing and field order as own entries; foreign faction and guild views retain their familiar slot layouts. Foreign -menus allow public diplomacy and guild flavor browsing while exact -ledgers, military, government, laws, installations and upgrade menus stay private. +menus allow diplomacy and guild browsing, plus read-only masked ledgers, military, +government, laws, installations and upgrade windows. Private menus recheck membership before interacting or refreshing. Foreign ledgers show daily income, expenses, net income, and a reported accounts -menu for individual cashflows. Trade breakdowns and dividends use the same +menu in the original ledger slots; the tooltip includes individual cashflows. Trade breakdowns and dividends use the same snapshot estimates. Dividend percentages stay within 0–100%; income and expense cashflows respect their valid signs. Missing new fields in an older report stay Unknown until the next day's report; opening a ledger never regenerates it. @@ -236,7 +238,10 @@ Rumours. Office identities require Reliable and office aptitude requires Detaile An `unknown` threshold disables that field. Invalid or unconfigured fields fail closed. `cashflows` accepts individual enum-category overrides such as `TRADE`. These gates apply when generating **and reading** reports, including old caches. -Roster names also obey their own tier gate; leaders remain public. +Roster names also obey roster/guild-members gates. Faction leaders remain public; +guild leaders use guild-leader, and office identities use office-holder. Reports +always have at least Rumours quality, including negative margins. Every guild in +a faction shares that same daily quality. Foreign Special Positions is a read-only report using the same daily comparison. Office names/vacancy and aptitude ranges are saved snapshots; holder-only sabotage @@ -254,3 +259,18 @@ default false), and intentionally overrides the once-per-day rule for testing. Reopen menus afterward to see refreshed accounts. It preserves permanent aptitude, office holders, sabotage preferences, appointment counts, treasury and unrest. `/faction reloadconfigs` reloads settings without regenerating reports. + +### Read-only foreign navigation + +Foreign menus use the original inventory sizes, category slots, template icons +and back routes. Guild branches return to their normal building slots; their +levels/effects are estimates gated by `buildings`. Guild upgrades and the upgrade +queue use `upgrades`. The military view opens normally; its regiment counts are +gated by professional-army/levies and its training queue by `training` (Reliable +by default). Numeric tax rates use `taxes`, selected laws use `laws`, and +installation identity/levels/construction use `installation-details`. All use +the faction's cached report. Hidden categories keep their menu entry with +Unknown details. Foreign views cannot submit proposals, train troops, upgrade +buildings, cancel queues or modify another faction. Periodic refreshes never +replace a masked snapshot with an exact submenu. Ledger detail windows retain +the original 27-slot layout; cashflow estimates remain in the Ledger tooltip. diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java index 0d5ae2f6..3cc89d0b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java @@ -33,6 +33,8 @@ public static Faction owner(SFInventoryHolder holder) { public static boolean denied(Player player, SFInventoryHolder holder) { if (!requiresOwn(holder.getType())) return false; Faction faction = owner(holder); + if (faction != null && holder.isReported() + && net.tfminecraft.simplefactions.managers.inventory.ReportedMenus.READABLE.contains(holder.getType())) return false; boolean office = holder.getType() == SFGUI.SPECIAL_POSITIONS || holder.getType() == SFGUI.SPYMASTER_VIEW || holder.getType() == SFGUI.SPYMASTER_SETTINGS || holder.getType() == SFGUI.SPYMASTER_SELECT; diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java index 0f550626..4eda7938 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java @@ -31,7 +31,12 @@ public final class EspionageConfig { Map.entry("cashflow", IntelligenceTier.RELIABLE), Map.entry("dividend-rate", IntelligenceTier.RELIABLE), Map.entry("dividend-pool", IntelligenceTier.RELIABLE), Map.entry("dividend-tax", IntelligenceTier.RELIABLE), Map.entry("dividend-per-member", IntelligenceTier.RELIABLE), Map.entry("office-holder", IntelligenceTier.RELIABLE), - Map.entry("office-aptitude", IntelligenceTier.DETAILED)); + Map.entry("office-aptitude", IntelligenceTier.DETAILED), + Map.entry("guild-leader", IntelligenceTier.RELIABLE), Map.entry("guild-members", IntelligenceTier.RUMOURS), + Map.entry("training", IntelligenceTier.RELIABLE), Map.entry("buildings", IntelligenceTier.BROAD), + Map.entry("upgrades", IntelligenceTier.RELIABLE), Map.entry("taxes", IntelligenceTier.RELIABLE), + Map.entry("laws", IntelligenceTier.RELIABLE), Map.entry("government", IntelligenceTier.RELIABLE), + Map.entry("installation-details", IntelligenceTier.RELIABLE)); static { load(new org.bukkit.configuration.file.YamlConfiguration()); } public record TierSettings(int minimumMargin, double uncertainty, double rosterFraction, double boundedFraction, double relativeWidth) {} @@ -64,12 +69,12 @@ public static void load(ConfigurationSection config) { luckDraws = (int) bounded(config, "espionage.checks.luck-draws", 3, 1, 20); rosterLimit = (int) bounded(config, "espionage.intelligence.maximum-roster-size", 23, 0, 1000); tiers.clear(); - int previous = 0; + int previous = -1; for (var tier : IntelligenceTier.values()) { if (tier == IntelligenceTier.UNKNOWN) continue; int index = tier.ordinal() - 1; String path = "espionage.intelligence.tiers." + tier.key() + "."; - int margin = Math.max(previous + 1, (int) bounded(config, path + "minimum-margin", new int[]{1,30,65,100}[index], 1, 10000)); + int margin = Math.max(previous + 1, (int) bounded(config, path + "minimum-margin", new int[]{0,30,65,100}[index], 0, 10000)); tiers.put(tier, new TierSettings(margin, bounded(config, path + "uncertainty", new double[]{1.5,.75,.2,.1}[index], .01, 10), bounded(config, path + "roster-fraction", new double[]{.2,.4,.6,.8}[index], 0, 1), @@ -97,16 +102,23 @@ private static double bounded(ConfigurationSection config, String key, double fa } public static IntelligenceTier tier(int margin) { - IntelligenceTier result = IntelligenceTier.UNKNOWN; + IntelligenceTier result = IntelligenceTier.RUMOURS; for (var tier : IntelligenceTier.values()) if (tier != IntelligenceTier.UNKNOWN && margin >= tiers.get(tier).minimumMargin()) result = tier; return result; } public static TierSettings settings(IntelligenceTier tier) { return tiers.get(tier); } public static String metricKey(String metric) { + if (metric.equals("Legitimacy") || metric.equals("Council size")) return "government"; + if (metric.startsWith("Tax:")) return "taxes"; + if (metric.startsWith("Training:")) return "training"; + if (metric.startsWith("Regiment:")) return metric.endsWith(":Levies") ? "levies" : "professional-army"; + if (metric.startsWith("Installation:")) return "installation-details"; if (metric.startsWith("Position:")) return "office-aptitude"; if (metric.contains(":Cashflow:")) return "cashflow"; String leaf = metric.startsWith("Guild:") ? metric.substring(metric.indexOf(':', 6) + 1) : metric; + if (leaf.startsWith("Branch:")) return "buildings"; + if (leaf.startsWith("Upgrade:")) return "upgrades"; if (leaf.equals("Daily net income") || leaf.equals("Income")) return "net-income"; return leaf.toLowerCase(java.util.Locale.ROOT).replace(' ', '-'); } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index cba955d1..156a803b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -271,9 +271,10 @@ private static IntelligenceReport generateReport(Faction observer, Faction targe 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(margin <= 0 ? Map.of() : metrics(target), margin, random); + IntelligenceReport generated = createReport(metrics(target), margin, random); captureMembers(generated, target, margin, random); captureOffices(generated, target, random); + ReportDetails.capture(generated, target); return generated; }); if (created[0] && persist) { @@ -305,6 +306,21 @@ static void captureMembers(IntelligenceReport report, Faction target, int margin report.guildMembers.put(guild.getId(), sample.stream().filter(guild::isMember) .filter(name -> !guild.isLeader(name)).map(CharacterNames::of).toList()); } + report.roster = new java.util.ArrayList<>(); + for (var guild : target.getGuildHandler().getGuilds()) { + for (String name : guild.getMembers()) { + if (target.isLeader(name)) continue; + var offices = java.util.Arrays.stream(SpecialPosition.values()).filter(office -> { + var holder = target.getEspionage().holder(office); + return holder != null && holder.playerName.equalsIgnoreCase(name); + }).toList(); + 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.of(name), guild.getId(), guild.getName(), sample.contains(name), + knownLeader, knownOffice ? offices : java.util.List.of())); + } + } } public static Double visibleValue(Player viewer, Faction target, String metric, java.util.function.DoubleSupplier exact) { @@ -370,16 +386,31 @@ private static Map metrics(Faction faction) { values.put("Levies", (double) (faction.getMilitary().getManpower(false) - faction.getMilitary().getManpowerNoLevy(false))); values.put("Mercenaries", (double) faction.getMilitary().getMercenaryManpower()); values.put("Installations", (double) faction.getInstallationHandler().getAll().size()); + for (var tax : net.tfminecraft.simplefactions.government.proposal.TaxTarget.values()) + if (!tax.name().endsWith("_ID")) values.put("Tax:" + tax.name(), faction.getTaxHandler().getTaxRate(tax, null, false)); + for (int index = 0; index < faction.getMilitary().getQueue().size(); index++) + values.put("Training:" + index, (double) faction.getMilitary().getQueue().get(index).getTimeLeft()); for (var guild : faction.getGuildHandler().getGuilds()) { values.put("Guild:" + guild.getId() + ":Wealth", guild.getWealth()); values.put("Guild:" + guild.getId() + ":Members", (double) guild.getMembers().size()); values.put("Guild:" + guild.getId() + ":Income", guild.getLedger().getNetIncome()); IntelligenceLedger.capture(values, guild); + for (int group = 0; group < 10; group++) { + var branch = guild.getBranch(group); + if (branch != null) values.put(IntelligenceLedger.key(guild, "Branch:" + branch.getId()), (double) branch.getLevel()); + } + for (var upgrade : guild.getUpgrades()) values.put(IntelligenceLedger.key(guild, "Upgrade:" + upgrade.getId()), (double) upgrade.getLevel()); if (guild.hasCapital()) values.put("Guild:" + guild.getId() + ":Trade power", guild.getTradeBreakdown().getTradePower()); } + for (var regiment : faction.getMilitary().getRegiments()) values.put("Regiment:" + regiment.getId() + + (regiment.isLevy() ? ":Levies" : ":Soldiers"), (double) (regiment.isLevy() + ? regiment.getEntries().stream().mapToInt(net.tfminecraft.simplefactions.army.LevyEntry::getAmount).sum() : regiment.getCurrentSlots())); + for (var installation : faction.getInstallationHandler().getAll()) values.put("Installation:" + installation.getId() + ":Level", (double) installation.getLevel()); if (faction.getGovernment() != null) { values.put("Stability", faction.getGovernment().getStability()); values.put("Administrative power", faction.getGovernment().getPower()); + values.put("Legitimacy", faction.getGovernment().stateReport().legitimacy); + values.put("Council size", (double) faction.getGovernment().getCouncil().getCurrentSize()); } return values; } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java index a2cb10d1..9755dd15 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java @@ -37,10 +37,13 @@ public static String value(IntelligenceReport report, Guild guild, String metric } public static List summary(IntelligenceReport report, Guild guild) { - return List.of("#4c5250\u00a7oAdded to the bank at the", "#4c5250\u00a7ostart of a new day", "", + var lore = new java.util.ArrayList<>(List.of("#4c5250\u00a7oAdded to the bank at the", "#4c5250\u00a7ostart of a new day", "", "#4fd945Income", "#d6cf69Total: " + value(report, guild, "Income total", "d/day"), "", "#cf493aExpenses", "#d6cf69Total: " + value(report, guild, "Expense total", "d/day"), "", "#f2e5c2Net Income", "#d6cf69Total: " + value(report, guild, "Income", "d/day"), "", - "\u00a77Click to inspect the reported accounts."); + "\u00a77Click to inspect the reported accounts.")); + lore.add(""); + for (Cashflow flow : Cashflow.values()) lore.add(flow.getDisplay() + ": " + value(report, guild, "Cashflow:" + flow.name(), "d/day")); + return lore; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceRanges.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceRanges.java index 67550ca4..6e55e4fe 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceRanges.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceRanges.java @@ -10,7 +10,11 @@ public final class IntelligenceRanges { private IntelligenceRanges() {} public static boolean nonnegative(String metric) { - return NONNEGATIVE.contains(metric) || metric.endsWith(":Members") || metric.endsWith(":Trade power") + return NONNEGATIVE.contains(metric) || metric.equals("Legitimacy") || metric.equals("Council size") + || metric.startsWith("Regiment:") || metric.startsWith("Installation:") + || metric.startsWith("Tax:") || metric.startsWith("Training:") + || metric.contains(":Branch:") || metric.contains(":Upgrade:") + || metric.endsWith(":Members") || metric.endsWith(":Trade power") || metric.endsWith(":Income total") || metric.endsWith(":Expense total") || metric.contains(":Dividend ") || metric.startsWith("Position:") || cashflow(metric) != null && cashflow(metric) != net.tfminecraft.simplefactions.guild.income.Cashflow.VEHICLE_FEES && !nonpositive(metric); @@ -44,7 +48,7 @@ public static EspionageMath.Estimate reasonable(String metric, EspionageMath.Est if (raw == null || raw.lower() >= raw.upper()) return null; long minimum = nonnegative(metric) ? 0 : Long.MIN_VALUE; Long maximum = null; - if (metric.equals("Stability") || metric.endsWith(":Dividend rate") || metric.startsWith("Position:")) maximum = 100L; + if (metric.equals("Stability") || metric.equals("Legitimacy") || metric.startsWith("Tax:") || metric.endsWith(":Dividend rate") || metric.startsWith("Position:")) maximum = 100L; else if (metric.startsWith("Guild:") && metric.endsWith(":Members") && Cache.maxMembers > 0) maximum = (long) Cache.maxMembers; boolean boundedDomain = maximum != null; diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java index aa73b415..6d8fcd68 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java @@ -13,6 +13,17 @@ public class IntelligenceReport { public Map estimates = new LinkedHashMap<>(); public java.util.List members = new java.util.ArrayList<>(); public Map> guildMembers = new LinkedHashMap<>(); + public java.util.List roster = new java.util.ArrayList<>(); + public Map> details = new LinkedHashMap<>(); + public Map maximums = new LinkedHashMap<>(); + + public record RosterMember(String character, String guildId, String guildName, boolean sampled, boolean guildLeader, + java.util.List offices) {} + + /** Detail lists use the same disclosure gates as ranged metrics, including after reload. */ + public java.util.List details(String field, String key) { + return !allows(field) || details == null ? java.util.List.of() : details.getOrDefault(key, java.util.List.of()); + } public String loreDate() { return net.tfminecraft.rpcharacters.calendar.FantasyCalendar.formatDate(java.time.LocalDate.ofEpochDay(day) @@ -25,8 +36,14 @@ public String display(String metric) { } public EspionageMath.Estimate estimate(String metric) { - return !EspionageConfig.allows(tier(), metric) ? null - : IntelligenceRanges.reasonable(metric, estimates == null ? null : estimates.get(metric), tier()); + if (!EspionageConfig.allows(tier(), metric)) return null; + var raw = estimates == null ? null : estimates.get(metric); + Long maximum = maximums == null ? null : maximums.get(metric); + if (raw != null && maximum != null) { + raw = new EspionageMath.Estimate(Math.max(0, raw.lower()), Math.min(maximum, raw.upper())); + if (raw.upper() - (double) raw.lower() > maximum * EspionageConfig.settings(tier()).boundedFraction()) return null; + } + return IntelligenceRanges.reasonable(metric, raw, tier()); } public String qualityLabel() { diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java b/src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java new file mode 100644 index 00000000..a552fae8 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java @@ -0,0 +1,39 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.List; +import net.tfminecraft.simplefactions.objects.Faction; + +/** Non-numeric intelligence is frozen with the same faction-wide daily report. */ +final class ReportDetails { + private ReportDetails() {} + + static void capture(IntelligenceReport report, Faction target) { + for (var installation : target.getInstallationHandler().getAll()) report.maximums.put("Installation:" + installation.getId() + ":Level", + (long) net.tfminecraft.simplefactions.loaders.InstallationConfigLoader.getMaximumLevel(installation.getKind())); + for (var guild : target.getGuildHandler().getGuilds()) for (var upgrade : guild.getUpgrades()) + if (upgrade.hasMaxLevel()) report.maximums.put(IntelligenceLedger.key(guild, "Upgrade:" + upgrade.getId()), (long) upgrade.getMaxLevel()); + if (report.allows("office-holder")) report.details.put("leader-offices", java.util.Arrays.stream(SpecialPosition.values()).filter(office -> { + var holder = target.getEspionage().holder(office); + return holder != null && target.isLeader(holder.playerName); + }).map(SpecialPosition::label).toList()); + if (report.allows("training")) { + report.details.put("training", target.getMilitary().getQueue().stream() + .map(entry -> entry.getRegiment().getName()).limit(3).toList()); + } + if (report.allows("laws")) for (var group : target.getLawHandler().getGroupList()) { + if (group.getCurrent() != null) report.details.put("law:" + group.getId(), List.of(group.getCurrent().getName())); + } + if (report.allows("installation-details")) { + report.details.put("installations", target.getInstallationHandler().getAll().stream() + .sorted(java.util.Comparator.comparing(installation -> installation.getKind().name() + installation.getId())) + .map(installation -> installation.getId() + "\n" + installation.getKind().name() + "\n" + installation.getName()).toList()); + var pending = target.getInstallationHandler().getPendingConstruction(); + report.details.put("construction", pending == null ? List.of() : List.of((pending.isUpgrade() ? "Upgrading " : "Building ") + pending.getName())); + } + if (report.allows("guild-leader")) for (var guild : target.getGuildHandler().getGuilds()) + report.details.put("guild-leader:" + guild.getId(), List.of(CharacterNames.of(guild.getLeader()))); + if (report.allows("upgrades")) for (var guild : target.getGuildHandler().getGuilds()) + report.details.put("upgrading:" + guild.getId(), guild.getUpgradeQueue().stream() + .map(entry -> entry.getUpgrade().getName()).limit(3).toList()); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java b/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java new file mode 100644 index 00000000..b6148896 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java @@ -0,0 +1,68 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import org.bukkit.entity.Player; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; + +/** One roster format for owned, staff and estimated views. Never sort foreign guilds by exact wealth. */ +public final class RosterLore { + private RosterLore() {} + + public static List faction(Player viewer, Faction faction) { return build(viewer, faction, null); } + public static List guild(Player viewer, Guild guild) { return build(viewer, guild.getFaction(), guild); } + + private static List build(Player viewer, Faction faction, Guild only) { + boolean exact = EspionageService.canViewExact(viewer, faction); + var report = exact ? null : EspionageService.report(viewer, faction); + List lore = new ArrayList<>(); + lore.add("#e8c55a\u00a7lFaction Leader: #f4e4aa" + CharacterNames.display(viewer, faction.getLeader())); + List leaderOffices = exact ? java.util.Arrays.stream(SpecialPosition.values()).filter(office -> { + var holder = faction.getEspionage().holder(office); + return holder != null && holder.playerName.equalsIgnoreCase(faction.getLeader()); + }).map(SpecialPosition::label).toList() : report == null ? List.of() : report.details("office-holder", "leader-offices"); + for (String office : leaderOffices) lore.add("#93c9a7 " + office + ": #c2daca" + CharacterNames.display(viewer, faction.getLeader())); + List guilds = new ArrayList<>(only == null ? faction.getGuildHandler().getGuilds() : List.of(only)); + if (exact && only == null) for (String name : faction.getCompleteMemberList()) { + Guild represented = net.tfminecraft.simplefactions.managers.FactionManager.getGuildByMember(name); + if (represented != null && guilds.stream().noneMatch(guild -> guild.getId().equals(represented.getId()))) guilds.add(represented); + } + guilds.sort(Comparator.comparing((Guild guild) -> { + var wealth = exact ? guild.getWealth() : EspionageService.visibleValue(viewer, faction, + IntelligenceLedger.key(guild, "Wealth"), guild::getWealth); + return wealth == null ? Double.NEGATIVE_INFINITY : wealth; + }).reversed().thenComparing(Guild::getName, String.CASE_INSENSITIVE_ORDER)); + for (Guild guild : guilds) { + List entries; + if (exact) entries = guild.getMembers().stream().filter(name -> !faction.isLeader(name)).map(name -> + new IntelligenceReport.RosterMember(CharacterNames.display(viewer, name), guild.getId(), guild.getName(), true, guild.isLeader(name), + java.util.Arrays.stream(SpecialPosition.values()).filter(office -> { + var holder = faction.getEspionage().holder(office); + return holder != null && holder.playerName.equalsIgnoreCase(name); + }).toList())).toList(); + else entries = report == null || report.roster == null ? List.of() : report.roster.stream() + .filter(entry -> entry.guildId().equals(guild.getId())) + .filter(entry -> entry.sampled() && report.allows("guild-members") && report.allows("roster") + || entry.guildLeader() && report.allows("guild-leader") + || !entry.offices().isEmpty() && report.allows("office-holder")).toList(); + lore.add(""); + lore.add("#cba351\u00a7l" + guild.getName() + (guild.isBase() ? " #7fbd73(Capital)" : " #b8ae61(Guild)")); + var sorted = new ArrayList<>(entries); + sorted.sort(Comparator.comparingInt((IntelligenceReport.RosterMember entry) -> + (entry.guildLeader() && (exact || report.allows("guild-leader")) ? 0 + : !entry.offices().isEmpty() && (exact || report.allows("office-holder")) ? 1 : 2)) + .thenComparing(IntelligenceReport.RosterMember::character, String.CASE_INSENSITIVE_ORDER)); + for (var entry : sorted) { + List roles = new ArrayList<>(); + if (entry.guildLeader() && (exact || report.allows("guild-leader"))) roles.add("Guild Leader"); + if (exact || report.allows("office-holder")) entry.offices().forEach(office -> roles.add(office.label())); + String prefix = roles.isEmpty() ? "#d4c9ae - " : "#93c9a7 " + String.join(" / ", roles) + ": #c2daca"; + lore.add(prefix + entry.character()); + } + if (!exact && entries.isEmpty() && !guild.isMember(faction.getLeader())) lore.add("\u00a77 Members: Unknown"); + } + return lore; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java index 9f3dbca8..a434194e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java @@ -1282,6 +1282,11 @@ public void clickButton(InventoryClickEvent e) { break; } } + if (h.isReported() && !net.tfminecraft.simplefactions.espionage.EspionageService.canViewExact(p, + net.tfminecraft.simplefactions.espionage.EspionageAccess.owner(h))) { + net.tfminecraft.simplefactions.managers.inventory.ReportedMenus.navigate(e, p, h, this); + return; + } if(h.getType() == SFGUI.FACTION_LIST || h.getType() == SFGUI.FACTION_VIEW || h.getType() == SFGUI.FACTION_GUILDS) { factionView.click(e, inv, p); } else if(h.getType() == SFGUI.GUILD_LIST || h.getType() == SFGUI.GUILD_VIEW || h.getType() == SFGUI.UPGRADE_VIEW) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/holder/SFInventoryHolder.java b/src/main/java/net/tfminecraft/simplefactions/managers/holder/SFInventoryHolder.java index 8b172e42..54802425 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/holder/SFInventoryHolder.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/holder/SFInventoryHolder.java @@ -10,6 +10,7 @@ public class SFInventoryHolder implements InventoryHolder { private final SFGUI type; private int page; private boolean flag; + private boolean reported; private final String secondaryId; public SFInventoryHolder(String id, SFGUI type) { @@ -61,6 +62,9 @@ public String getSecondaryId() { return secondaryId; } + public boolean isReported() { return reported; } + public void markReported() { reported = true; } + @Override public Inventory getInventory() { return null; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java index a9a2ddf2..e1113459 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyView.java @@ -52,6 +52,7 @@ public void companyView(Player player, Guild guild) { } public void companyView(Player player, Guild guild, Inventory i) { + if (!net.tfminecraft.simplefactions.espionage.EspionageService.canViewExact(player, guild.getFaction())) { ReportedMenus.company(i, inv); return; } i.clear(); MercenaryCompany company = guild.getCompany(); if (company != null && company.isFormed()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java index 8ab445a5..13fcc8eb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -31,7 +31,7 @@ import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; import net.tfminecraft.simplefactions.objects.Faction; -/** Foreign reports deliberately have no links to exact military or economic submenus. */ +/** Foreign reports retain normal navigation through read-only, masked menus. */ @SuppressWarnings("deprecation") public final class EspionageView { private EspionageView() {} @@ -85,7 +85,7 @@ public static void guild(Inventory inventory, Player viewer, Guild guild, Invent var creator = new GuildCreator(); inventory.clear(); inventory.setItem(4, reportHeader(viewer, report)); - inventory.setItem(10, creator.createPublicListItem(viewer, guild)); + inventory.setItem(10, creator.createMenuItem(viewer, guild, MenuItemType.BANNER)); inventory.setItem(11, creator.createMenuItem(viewer, guild, MenuItemType.LEADER)); inventory.setItem(12, styled(Material.GOLD_NUGGET, "#d1b43fWealth: " + IntelligenceLedger.value(report, guild, "Wealth", "d"))); inventory.setItem(13, styled(Material.EMERALD, "#338651Trade Breakdown", @@ -95,19 +95,22 @@ public static void guild(Inventory inventory, Player viewer, Guild guild, Invent "#d4c9aeHub Tax Paid: " + IntelligenceLedger.value(report, guild, "Cashflow:HUB_TAX_PAYMENTS", "d/day"), "#d4c9aeTotal Trade Power: " + IntelligenceLedger.value(report, guild, "Trade power", ""))); inventory.setItem(14, ledgerItem(report, guild)); - List roster = new ArrayList<>(); - roster.add("#d4c9aeLeader: " + CharacterNames.display(viewer, guild.getLeader())); - if (report != null && report.allows("roster") && report.guildMembers != null) - report.guildMembers.getOrDefault(guild.getId(), List.of()).forEach(name -> roster.add("#d4c9ae" + name)); + List roster = net.tfminecraft.simplefactions.espionage.RosterLore.guild(viewer, guild); inventory.setItem(15, styled(Material.PLAYER_HEAD, "#b8ae61Members: " - + IntelligenceLedger.value(report, guild, "Members", ""), roster.toArray(String[]::new))); - inventory.setItem(16, styled(Material.ENCHANTED_BOOK, "#a6659fUpgrades", "\u00a77Upgrades: Unknown")); + + IntelligenceLedger.value(report, guild, "Members", "") + "/" + net.tfminecraft.simplefactions.Cache.maxMembers, roster.toArray(String[]::new))); + if (guild.hasUpgrades()) inventory.setItem(16, styled(Material.GOLD_INGOT, "#d979c2Upgrades", "\u00a77Click to inspect upgrades.")); + for (int group = 0; group < 10; group++) { + var branch = guild.getBranch(group); + if (branch != null) inventory.setItem(group + 29, ReportedMenus.branch(viewer, guild, branch)); + } + inventory.setItem(GuildView.COMPANY_SLOT, styled(Material.IRON_SWORD, "Mercenary Company", "\u00a77Company details: Unknown", "\u00a77Click to inspect.")); if (!guild.isBase()) inventory.setItem(17, styled(Material.GOLD_INGOT, "#c49e5cDividends", "#d4c9aeRate: " + IntelligenceLedger.value(report, guild, "Dividend rate", "%"), "#d4c9aePool: " + IntelligenceLedger.value(report, guild, "Dividend pool", "d"), "#d4c9aeTax withheld: " + IntelligenceLedger.value(report, guild, "Dividend tax", "d"), "#d4c9aePer member: " + IntelligenceLedger.value(report, guild, "Dividend per member", "d"))); - inventory.setItem(25, styled(Material.GOLD_INGOT, "#d6cf69Loans", "\u00a77Loans: Unknown")); + inventory.setItem(25, ReportedMenus.mask(IconGetter.getIconOrDefault("guild_loans", Material.BLACK_DYE), + "#e8c65fLoans", List.of("\u00a77Loans: Unknown"))); inventory.setItem(26, styled(Material.CHEST, "#b5835aSupply Hubs", "\u00a77Supply hubs: Unknown")); inventory.setItem(GuildView.HOST_FACTION_SLOT, creator.createHostFactionItem(guild)); inventory.setItem(53, manager.createBackButton(SFGUI.GUILD_VIEW)); @@ -117,13 +120,23 @@ public static void guild(Inventory inventory, Player viewer, Guild guild, Invent public static void foreignLedger(Player viewer, Guild guild, InventoryManager manager) { Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(guild.getId(), SFGUI.FOREIGN_LEDGER_VIEW), - 54, "\u00a77Reported Ledger"); + 27, "\u00a77Ledger for " + guild.getName()); var report = EspionageService.report(viewer, guild.getFaction()); - inventory.setItem(4, ledgerItem(report, guild)); - int slot = 9; - for (Cashflow flow : Cashflow.values()) inventory.setItem(slot++, styled(Material.PAPER, flow.getDisplay(), - "#d6cf69Reported daily amount: " + IntelligenceLedger.value(report, guild, "Cashflow:" + flow.name(), "d/day"))); - inventory.setItem(53, manager.createBackButton(SFGUI.FOREIGN_LEDGER_VIEW)); + if (guild.isBase()) { + Material[] icons = {Material.PLAYER_HEAD, Material.BARREL, Material.IRON_INGOT, Material.GOLD_INGOT, + Material.EMERALD, Material.CHEST, Material.GOLD_NUGGET}; + String[] titles = {"#94b572Citizens", "#b89448Guild Taxes", "#7299b5Vassals", "#ab8568Tributes", + "#5cc46aTariffs", "#c9a25eDeposits", "#5cc46aHub Tax"}; + Cashflow[] flows = {Cashflow.CITIZENS, Cashflow.GUILDS, Cashflow.VASSALS, Cashflow.TRIBUTES, + Cashflow.TARIFFS, null, Cashflow.HUB_TAX}; + for (int index = 0; index < icons.length; index++) inventory.setItem(10 + index, styled(icons[index], titles[index], + "\u00a77Today's amount: " + (flows[index] == null ? "Unknown" : IntelligenceLedger.value(report, guild, "Cashflow:" + flows[index].name(), "d/day")), + "\u00a77Contributors and history: Unknown")); + } + inventory.setItem(17, styled(Material.GOLD_NUGGET, "#5cc46aHub Tax Payments", + "\u00a77Today's amount: " + IntelligenceLedger.value(report, guild, "Cashflow:HUB_TAX_PAYMENTS", "d/day"), + "\u00a77Contributors and history: Unknown")); + inventory.setItem(26, manager.createBackButton(SFGUI.FOREIGN_LEDGER_VIEW)); viewer.openInventory(inventory); } @@ -165,10 +178,7 @@ yield styled(Material.GOLD_NUGGET, "#d1b43fWealth: #ccbb76" + value(report, "Wea "#7fbd73Current Rank: " + target.getRank().getName(), "#7fbd73Prestige ranking: #" + new net.tfminecraft.simplefactions.utils.FactionRanker().getPrestigeRank(target)); case MEMBERS -> { - List lore = new ArrayList<>(); - lore.add("#d4c9aeLeader: " + CharacterNames.display(viewer, target.getLeader()) + " " - + net.tfminecraft.simplefactions.utils.Represents.represents(target, target.getLeader())); - if (report != null && report.allows("roster") && report.members != null) report.members.forEach(member -> lore.add("#d4c9ae" + member)); + List lore = net.tfminecraft.simplefactions.espionage.RosterLore.faction(viewer, target); yield styled(Material.PLAYER_HEAD, "#b8ae61Members: #7fbd73" + value(report, "Members", ""), lore.toArray(String[]::new)); } case MODIFIERS -> styled(Material.GOLDEN_APPLE, "#c49760Modifiers", @@ -342,7 +352,7 @@ public static void click(InventoryClickEvent event, Player viewer, SFInventoryHo return; } if (menu.getType() == SFGUI.FOREIGN_LEDGER_VIEW) { - if (event.getRawSlot() == 53) { + if (event.getRawSlot() == 26) { var guild = FactionManager.getGuildByString(menu.getId()); if (guild != null) { if (guild.isBase()) manager.factionView(viewer, guild.getFaction()); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java index 6db234af..b3f35680 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java @@ -408,16 +408,7 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { memberNames.removeIf(name -> name.equalsIgnoreCase(f.getLeader())); memberNames.add(0, f.getLeader()); m.setDisplayName(StringFormatter.formatHex("#b8ae61Members: #7fbd73"+memberNames.size())); - List lore = new ArrayList(); - int count = 0; - for(String s : memberNames) { - if(count == 24) break; - lore.add(StringFormatter.formatHex("#d4c9ae"+CharacterNames.display(p, s)+" "+Represents.represents(f, s))); - count++; - } - if(count < memberNames.size()) { - lore.add("§7...and "+(memberNames.size()-count)+" more"); - } + List lore = net.tfminecraft.simplefactions.espionage.RosterLore.faction(p, f).stream().map(StringFormatter::formatHex).toList(); m.setLore(lore); i.setItemMeta(m); } else if(t.equals(MenuItemType.GUILDS)) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java index face7b64..99286ac8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java @@ -265,7 +265,17 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Faction viewed = FactionManager.getByString(holder.getId()); if (viewed == null) return; if (!EspionageService.canViewExact(p, viewed) - && e.getSlot() != 23 && e.getSlot() != 31 && e.getSlot() != 53 && e.getSlot() != 15 && e.getSlot() != 20) return; + && e.getSlot() != 23 && e.getSlot() != 31 && e.getSlot() != 53 && e.getSlot() != 15 && e.getSlot() != 20) { + switch (e.getSlot()) { + case 11 -> inv.governmentView(p, viewed, null); + case 25 -> inv.taxView(p, viewed); + case 28 -> inv.lawView(p, viewed, null); + case 29 -> inv.militaryView(null, p, viewed, true); + case 32 -> inv.installationsView(null, p, viewed, true); + default -> { } + } + return; + } if (e.getSlot() == 20) { EspionageView.positions(p, viewed, inv); return; @@ -397,7 +407,6 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(!(inventory.getHolder() instanceof SFInventoryHolder)) return; SFInventoryHolder h = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(h.getId()); - if(!f.getLeader().equalsIgnoreCase(p.getName())) return; inv.taxView(p, f); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); } else if(e.getSlot() == 23) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java index 490c176e..004e27f1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java @@ -55,7 +55,13 @@ public GovernmentView(InventoryManager inv) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void governmentView(Player player, Faction f, Inventory i) { - if (!EspionageService.canViewExact(player, f)) { inv.factionView(player, f); return; } + if (!EspionageService.canViewExact(player, f)) { + boolean show = i == null; + if (show) i = ReportedMenus.open(player, f.getId(), SFGUI.GOVERNMENT_VIEW, 54, "Government View"); + ReportedMenus.government(i, player, f, inv); + if (show) player.openInventory(i); + return; + } boolean open = i == null; if(i == null) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.GOVERNMENT_VIEW), 54, "§7Government View"); i.clear(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java index 4442b784..90be517c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -87,7 +87,7 @@ public ItemStack createListItem(Player p, Guild guild) { lore.add(StringFormatter.formatHex("#b8ae61Part of: "+guild.getFaction().getName())); lore.add(StringFormatter.formatHex("#b8ae61Based in: #d4c9ae" + HomeSettlementNames.of(guild))); lore.add(" "); - lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7"+CharacterNames.display(p, guild.getLeader()))); + lore.add(StringFormatter.formatHex("#9c9775Leader: #c2bea7"+ReportedMenus.guildLeader(p, guild))); lore.add(StringFormatter.formatHex("#b8ae61Members: #7fbd73"+(own ? String.valueOf(guild.getMembers().size()) : guildIntelligence(report, guild, "Members", "")))); lore.add(" "); if (guild.hasCapital()) { @@ -165,8 +165,8 @@ public ItemStack createMenuItem(Player p, Guild guild, MenuItemType t) { i = new ItemStack(Material.PLAYER_HEAD, 1); SkullMeta m = (SkullMeta) i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - m.setDisplayName(StringFormatter.formatHex("#9c9775§lLeader: #c2bea7"+CharacterNames.display(p, guild.getLeader()))); - m.setOwningPlayer(Bukkit.getOfflinePlayer(guild.getLeader())); + m.setDisplayName(StringFormatter.formatHex("#9c9775§lLeader: #c2bea7"+ReportedMenus.guildLeader(p, guild))); + if (!ReportedMenus.guildLeader(p, guild).endsWith("Unknown")) m.setOwningPlayer(Bukkit.getOfflinePlayer(guild.getLeader())); i.setItemMeta(m); } else if(t.equals(MenuItemType.WEALTH)) { i = new ItemStack(Material.GOLD_NUGGET, 1); @@ -232,19 +232,7 @@ public ItemStack createMenuItem(Player p, Guild guild, MenuItemType t) { ItemMeta m = i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); m.setDisplayName(StringFormatter.formatHex("#b8ae61Members: #7fbd73"+guild.getMembers().size()+"/"+Cache.maxMembers)); - List lore = new ArrayList<>(); - int outputPenalty = net.tfminecraft.simplefactions.inactivity.InactivityService.guildPercent(guild); - if (outputPenalty > 0) { - lore.add(StringFormatter.formatHex("#c95644Inactive output -" + outputPenalty + "%. Upgrades stay, but they are weakened.")); - } - List members = new ArrayList<>(guild.getMembers()); - members.removeIf(name -> name.equalsIgnoreCase(guild.getLeader())); - members.addFirst(guild.getLeader()); - for(String s : members) { - String mark = net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(s) - ? " #c95644(inactive)" : ""; - lore.add(StringFormatter.formatHex("#d4c9ae"+CharacterNames.display(p, s)+mark)); - } + List lore = net.tfminecraft.simplefactions.espionage.RosterLore.guild(p, guild).stream().map(StringFormatter::formatHex).toList(); m.setLore(lore); i.setItemMeta(m); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java index b12b5f0e..3cf043a9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java @@ -189,7 +189,7 @@ public void guildView(Player player, Guild guild, Inventory i) { tradePreview = BranchIncomePreview.prepare(manager); } int group = 0; - while(guild.getBranch(group) != null || group > 10) { + while(group < 10 && guild.getBranch(group) != null) { Branch b = guild.getBranch(group); group++; i.setItem(group+28, creator.createBranchItem(player, guild, b)); @@ -227,7 +227,7 @@ public void upgradeView(Player player, Guild guild) { } public void upgradeView(Player player, Guild guild, Inventory i) { - if (!EspionageService.canViewExact(player, guild.getFaction())) { inv.factionView(player, guild.getFaction()); return; } + if (!EspionageService.canViewExact(player, guild.getFaction())) { ReportedMenus.upgrades(i, player, guild, inv); return; } i.clear(); List upgrades = guild.getUpgrades(); @@ -263,7 +263,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if (h.getType() == SFGUI.GUILD_VIEW && e.getSlot() == 26) { e.setCancelled(true); Guild guild = FactionManager.getGuildByString(h.getId()); - if (guild != null && guild.isMember(p)) { + if (guild != null) { inv.supplyHubView.guildView(p, guild); } return; @@ -326,7 +326,12 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { return; } if (!EspionageService.canViewExact(p, guild.getFaction()) - && e.getSlot() != HOST_FACTION_SLOT && e.getSlot() != 53 && e.getSlot() != 14) return; + && e.getSlot() != HOST_FACTION_SLOT && e.getSlot() != 53 && e.getSlot() != 14) { + if (e.getSlot() == 16) upgradeView(p, guild); + if (e.getSlot() == COMPANY_SLOT) inv.companyView.companyView(p, guild); + if (e.getSlot() == 25) inv.loanView.loanMainView(p, guild); + return; + } if (e.getSlot() == 14) { ledgerView(p, guild, null); return; } if (e.getSlot() == HOST_FACTION_SLOT) { Faction faction = guild.getFaction(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java index ee36dbb9..449e13ba 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java @@ -42,7 +42,12 @@ public InstallationView(InventoryManager inv) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void installationsView(Inventory inventory, Player player, Faction f, boolean open) { - if (!EspionageService.canViewExact(player, f)) { inv.factionView(player, f); return; } + if (!EspionageService.canViewExact(player, f)) { + if (open || inventory == null) inventory = ReportedMenus.open(player, f.getId(), SFGUI.INSTALLATIONS_VIEW, 54, "Installations View"); + ReportedMenus.installations(inventory, player, f, inv); + if (open) player.openInventory(inventory); + return; + } if (open) { inventory = SimpleFactions.plugin @@ -92,7 +97,13 @@ public void installationDetailView(Player player, Faction f, String installation // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void installationDetailView(Player player, Faction f, String installationId, Inventory inventory) { - if (!EspionageService.canViewExact(player, f)) { inv.factionView(player, f); return; } + if (!EspionageService.canViewExact(player, f)) { + boolean show = inventory == null; + if (show) inventory = ReportedMenus.open(player, f.getId(), SFGUI.INSTALLATION_DETAIL_VIEW, 54, "Installation Details"); + ReportedMenus.installationDetail(inventory, player, f, installationId, inv); + if (show) player.openInventory(inventory); + return; + } InstallationHandler handler = f.getInstallationHandler(); InstallationConstruction pending = handler.getPendingConstruction(); boolean isPending = isPendingConstruction(pending, installationId); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java index 4cf7ac0c..da6107f1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java @@ -119,6 +119,7 @@ private void refreshSFHolder(Player p, Inventory i, SFInventoryHolder h) { return; } SFGUI type = h.getType(); + if (h.isReported() && !net.tfminecraft.simplefactions.espionage.EspionageService.canViewExact(p, EspionageAccess.owner(h))) return; if (skipsPeriodicRefresh(type)) { return; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java index a1cdba31..2f93ed87 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java @@ -37,7 +37,13 @@ public LawView(InventoryManager inv) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void lawView(Player player, Faction f, Inventory i) { - if (!EspionageService.canViewExact(player, f)) { inv.factionView(player, f); return; } + if (!EspionageService.canViewExact(player, f)) { + boolean show = i == null; + if (show) i = ReportedMenus.open(player, f.getId(), SFGUI.LAW_VIEW, 54, "Laws"); + ReportedMenus.laws(i, player, f, inv); + if (show) player.openInventory(i); + return; + } boolean open = i == null; if(open) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.LAW_VIEW), 54, "§7Laws"); i.clear(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java index 32966556..acc423bf 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java @@ -39,6 +39,13 @@ public void loanMainView(Player player, Guild guild) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void loanMainView(Player player, Guild guild, Inventory i) { + if (!net.tfminecraft.simplefactions.espionage.EspionageService.canViewExact(player, guild.getFaction())) { + boolean show = i == null; + if (show) i = ReportedMenus.open(player, guild.getId(), SFGUI.LOAN_MAIN_VIEW, 9, "Loans - " + guild.getName()); + ReportedMenus.loans(i, inv); + if (show) player.openInventory(i); + return; + } boolean open = i == null; if(open) i = SimpleFactions.plugin.getServer().createInventory( new SFInventoryHolder(guild.getId(), SFGUI.LOAN_MAIN_VIEW), diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java index e07e18a5..672560ae 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java @@ -33,7 +33,12 @@ public MilitaryView(InventoryManager inv) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void militaryView(Inventory i, Player player, Faction f, boolean open) { - if (!EspionageService.canViewExact(player, f)) { inv.factionView(player, f); return; } + if (!EspionageService.canViewExact(player, f)) { + if (open || i == null) i = ReportedMenus.open(player, f.getId(), SFGUI.MILITARY_VIEW, 54, "Military View"); + ReportedMenus.military(i, player, f, inv); + if (open) player.openInventory(i); + return; + } if(open) { i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.MILITARY_VIEW), 54, "§7Military View"); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java new file mode 100644 index 00000000..60dbdb2d --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java @@ -0,0 +1,306 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.espionage.*; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; + +/** Read-only versions of the normal menus: original slots and template icons, cached intelligence only. */ +@SuppressWarnings("deprecation") +public final class ReportedMenus { + public static final Set READABLE = java.util.EnumSet.of(SFGUI.MILITARY_VIEW, SFGUI.GOVERNMENT_VIEW, + SFGUI.INSTALLATIONS_VIEW, SFGUI.INSTALLATION_DETAIL_VIEW, SFGUI.LAW_VIEW, SFGUI.LAW_SELECT, + SFGUI.TAX_VIEW, SFGUI.TAX_VIEW_SPECIFIC, SFGUI.UPGRADE_VIEW, SFGUI.SUPPLY_HUB_VIEW, SFGUI.COMPANY_VIEW, + SFGUI.COUNCIL_VIEW, SFGUI.PROPOSALS, SFGUI.MOVEMENT_LIST, SFGUI.STABILITY_VIEW, + SFGUI.LOAN_MAIN_VIEW, SFGUI.LOANS_GIVEN_VIEW, SFGUI.LOANS_TAKEN_VIEW, + SFGUI.COMPANY_SLOTS_VIEW, SFGUI.COMPANY_ROSTER_VIEW, SFGUI.COMPANY_UPGRADE_VIEW, SFGUI.CONTRACT_LIST_VIEW); + private ReportedMenus() {} + + private static void prepare(Inventory inventory) { + inventory.clear(); + if (inventory.getHolder() instanceof SFInventoryHolder holder) holder.markReported(); + } + + public static ItemStack mask(ItemStack template, String title, List lore) { + ItemStack item = template.clone(); + var meta = item.getItemMeta(); + meta.setDisplayName(StringFormatter.formatHex(title)); + meta.setLore(lore.stream().map(StringFormatter::formatHex).toList()); + // Template icons carry appearance only. Report items never carry mutation payloads. + for (var key : new java.util.HashSet<>(meta.getPersistentDataContainer().getKeys())) meta.getPersistentDataContainer().remove(key); + item.setItemMeta(meta); + return item; + } + + public static String guildLeader(Player viewer, Guild guild) { + if (EspionageService.canViewExact(viewer, guild.getFaction()) || guild.getFaction().isLeader(guild.getLeader())) + return CharacterNames.display(viewer, guild.getLeader()); + var report = EspionageService.report(viewer, guild.getFaction()); + var names = report == null ? List.of() : report.details("guild-leader", "guild-leader:" + guild.getId()); + return names.isEmpty() ? "\u00a77Unknown" : names.getFirst(); + } + + public static ItemStack branch(Player viewer, Guild guild, net.tfminecraft.simplefactions.guild.branch.Branch branch) { + var report = EspionageService.report(viewer, guild.getFaction()); + List lore = new ArrayList<>(); + lore.add("#575150[#d6cf69LVL " + IntelligenceLedger.value(report, guild, "Branch:" + branch.getId(), "") + "#575150]"); + lore.add(""); lore.addAll(branch.getDescription()); lore.add(""); + lore.add("#a6c793Effects:"); + var levels = report == null ? null : report.estimate(IntelligenceLedger.key(guild, "Branch:" + branch.getId())); + for (var key : branch.getModifierKeys()) { + var modifier = branch.getModifier(key); + if (modifier == null) continue; + String estimate = "\u00a77Unknown"; + if (levels != null) { + double first = modifier.getCurrent((int) Math.min(Integer.MAX_VALUE, levels.lower())); + double second = modifier.getCurrent((int) Math.min(Integer.MAX_VALUE, levels.upper())); + estimate = (key.isPositive() ? "#4fd945" : "#cf493a") + Math.floor(Math.min(first, second)) + " to " + Math.ceil(Math.max(first, second)); + } + lore.add("\u00a7f - " + key.getName() + ": " + estimate); + } + return mask(branch.getIconItem(), branch.getName(), lore); + } + + public static void military(Inventory inventory, Player viewer, Faction faction, InventoryManager manager) { + var report = EspionageService.report(viewer, faction); + prepare(inventory); + inventory.setItem(10, EspionageView.factionItem(viewer, faction, net.tfminecraft.simplefactions.enums.MenuItemType.MILITARY)); + int slot = 12; + for (var regiment : faction.getMilitary().getRegiments()) { + if (slot > 38) break; + List lore = new ArrayList<>(); + lore.add("\u00a77" + (regiment.isLevy() ? "Total Levies: " : "Current Slots: ") + + (report == null ? "Unknown" : report.display("Regiment:" + regiment.getId() + (regiment.isLevy() ? ":Levies" : ":Soldiers")))); + lore.add("\u00a77Current Upkeep: Unknown"); lore.add(""); lore.addAll(regiment.getDescription()); + inventory.setItem(slot++, mask(regiment.getIcon(), regiment.getName(), lore)); + } + queue(inventory, report, "training", "training", "Army training"); + inventory.setItem(49, EspionageView.item(Material.MINECART, "Faction Vehicle Pool", "\u00a77Vehicles: Unknown")); + inventory.setItem(53, manager.createBackButton(SFGUI.MILITARY_VIEW)); + } + + private static void queue(Inventory inventory, IntelligenceReport report, String field, String key, String label) { + boolean known = report != null && report.allows(field); + List names = known ? report.details(field, key) : List.of(); + for (int index = 0; index < 3; index++) { + if (known && index >= names.size()) continue; + inventory.setItem(39 + index, EspionageView.item(Material.CLOCK, + known ? (index == 0 ? (field.equals("training") ? "Training " : "Upgrading ") : "Queued: ") + names.get(index) : label, + known && field.equals("training") ? "\u00a77Time remaining: " + report.display("Training:" + index) + " seconds" + : known ? "\u00a77Time remaining: Unknown" : "\u00a77Queue status: Unknown")); + } + } + + public static void upgrades(Inventory inventory, Player viewer, Guild guild, InventoryManager manager) { + var report = EspionageService.report(viewer, guild.getFaction()); + prepare(inventory); + int slot = 9; + for (var upgrade : guild.getUpgrades()) { + if (slot > 17) break; + List lore = new ArrayList<>(); + lore.add("#575150Level: " + IntelligenceLedger.value(report, guild, "Upgrade:" + upgrade.getId(), "")); + lore.addAll(upgrade.getDescription()); lore.add("\u00a77Effects: Unknown"); + inventory.setItem(slot++, mask(upgrade.getIconItem(), upgrade.getName(), lore)); + } + queue(inventory, report, "upgrades", "upgrading:" + guild.getId(), "Upgrade queue"); + inventory.setItem(53, manager.createBackButton(SFGUI.UPGRADE_VIEW)); + } + + public static void government(Inventory inventory, Player viewer, Faction faction, InventoryManager manager) { + prepare(inventory); + var report = EspionageService.report(viewer, faction); + inventory.setItem(10, mask(new ItemStack(Material.WRITABLE_BOOK), "#93c9a7Government:", List.of( + "#9c9775" + faction.getRulerTitle() + ": #c2bea7" + CharacterNames.display(viewer, faction.getLeader()), + "#85c265Administrative Power: " + (report == null ? "Unknown" : report.display("Administrative power")), + "", "#b8ae61Ruling System: #d4c9ae" + faction.getGovernmentString()))); + inventory.setItem(11, mask(IconGetter.getIconOrDefault("stability", Material.GREEN_DYE), + IntelligenceReport.stabilityState(report) + ": " + IntelligenceReport.stabilityRange(report), List.of("\u00a77Modifiers: Unknown"))); + inventory.setItem(12, mask(IconGetter.getIconOrDefault("overlord", Material.BLACK_DYE), "#d4c9aeLegitimacy: " + + (report == null ? "Unknown" : report.display("Legitimacy")), List.of("\u00a77Effects: Unknown"))); + inventory.setItem(13, EspionageView.item(Material.PAPER, "Council:", "\u00a77Council size: " + + (report == null ? "Unknown" : report.display("Council size")), "\u00a77Council members: Unknown")); + if (faction.getGovernment().hasElections()) inventory.setItem(14, EspionageView.item(Material.PAPER, "Elections", "\u00a77Election details: Unknown")); + inventory.setItem(15, EspionageView.item(Material.WRITABLE_BOOK, "Proposals", "\u00a77Foreign proposals cannot be submitted.")); + inventory.setItem(24, EspionageView.item(Material.PAPER, "Current Proposals", "\u00a77Proposals: Unknown")); + inventory.setItem(33, EspionageView.item(Material.PAPER, "Movements", "\u00a77Movements: Unknown")); + inventory.setItem(53, manager.createBackButton(SFGUI.GOVERNMENT_VIEW)); + } + + public static void installations(Inventory inventory, Player viewer, Faction faction, InventoryManager manager) { + prepare(inventory); + var report = EspionageService.report(viewer, faction); + inventory.setItem(10, EspionageView.factionItem(viewer, faction, net.tfminecraft.simplefactions.enums.MenuItemType.INSTALLATIONS)); + var entries = report == null ? List.of() : report.details("installation-details", "installations"); + int slot = 12; + for (String entry : entries) { + if (slot > 38) break; + String[] fields = entry.split("\n", 3); + if (fields.length < 3) continue; + Material material = fields[1].equals("TRAIN_STATION") ? Material.MINECART : Material.GREEN_CONCRETE; + inventory.setItem(slot++, EspionageView.item(material, fields[2], "\u00a77Level: " + report.display("Installation:" + fields[0] + ":Level"), + "\u00a77Coordinates: Unknown", "\u00a77Vehicles: Unknown")); + } + if (report == null || !report.allows("installation-details")) inventory.setItem(12, EspionageView.item(Material.GRAY_CONCRETE, "Installations", "\u00a77Locations and levels: Unknown")); + var construction = report == null ? List.of() : report.details("installation-details", "construction"); + if (!construction.isEmpty() || report == null || !report.allows("installation-details")) inventory.setItem(39, + EspionageView.item(Material.YELLOW_CONCRETE, construction.isEmpty() ? "Construction" : construction.getFirst(), "\u00a77Time remaining: Unknown")); + inventory.setItem(53, manager.createBackButton(SFGUI.INSTALLATIONS_VIEW)); + } + + public static void laws(Inventory inventory, Player viewer, Faction faction, InventoryManager manager) { + prepare(inventory); var report = EspionageService.report(viewer, faction); + int[] slots = {10,11,12,13,14,15,16,19,20,21,22,23,24,25}; int index = 0; + for (var group : faction.getLawHandler().getGroupList()) { + if (index >= slots.length) break; + var current = report == null ? List.of() : report.details("laws", "law:" + group.getId()); + inventory.setItem(slots[index++], EspionageView.item(Material.WRITABLE_BOOK, group.getName(), + "\u00a77Current law: " + (current.isEmpty() ? "Unknown" : current.getFirst()))); + } + inventory.setItem(53, manager.createBackButton(SFGUI.LAW_VIEW)); + } + + public static Inventory open(Player viewer, String id, SFGUI type, int size, String title) { + return SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(id, type), size, "\u00a77" + title); + } + + public static void taxes(Inventory inventory, Player viewer, Faction faction, InventoryManager manager) { + prepare(inventory); + var report = EspionageService.report(viewer, faction); int slot = 0; + for (var target : net.tfminecraft.simplefactions.government.proposal.TaxTarget.values()) { + if (!faction.getTaxHandler().canCollectTax(target)) continue; + inventory.setItem(slot++, EspionageView.item(Material.GOLD_INGOT, target.getDisplayName(), + "\u00a77Tax rate: " + (report == null ? "Unknown" : report.display("Tax:" + target.name())))); + } + inventory.setItem(17, manager.createBackButton(SFGUI.TAX_VIEW)); + } + + public static void company(Inventory inventory, InventoryManager manager) { + prepare(inventory); + inventory.setItem(4, EspionageView.item(Material.IRON_SWORD, "Mercenary Company", "\u00a77Company details: Unknown")); + inventory.setItem(11, EspionageView.item(Material.IRON_SWORD, "Company Slots", "\u00a77Slots: Unknown")); + inventory.setItem(13, EspionageView.item(Material.PLAYER_HEAD, "Company Roster", "\u00a77Roster: Unknown")); + inventory.setItem(15, EspionageView.item(Material.GOLD_INGOT, "Company Upgrades", "\u00a77Upgrades: Unknown")); + inventory.setItem(22, EspionageView.item(Material.WRITABLE_BOOK, "Contracts", "\u00a77Contracts: Unknown")); + inventory.setItem(26, manager.createBackButton(SFGUI.COMPANY_VIEW)); + } + + public static void supplyHubs(Inventory inventory, Player viewer, Guild guild, InventoryManager manager) { + prepare(inventory); + var report = EspionageService.report(viewer, guild.getFaction()); + inventory.setItem(49, EspionageView.item(Material.PAPER, "Supply Hub Information", "\u00a77Hubs: Unknown", + "\u00a77Daily upkeep: " + IntelligenceLedger.value(report, guild, "Cashflow:SUPPLY_HUBS", "d/day"))); + inventory.setItem(53, manager.createBackButton(SFGUI.SUPPLY_HUB_VIEW)); + } + + public static void loans(Inventory inventory, InventoryManager manager) { + prepare(inventory); + inventory.setItem(2, EspionageView.item(Material.GOLD_INGOT, "Loans Given", "\u00a77Loans given: Unknown")); + inventory.setItem(4, EspionageView.item(Material.GOLD_INGOT, "Loans Taken", "\u00a77Loans taken: Unknown")); + inventory.setItem(8, manager.createBackButton(SFGUI.LOAN_MAIN_VIEW)); + } + + /** Only navigation survives masking. No foreign click reaches the original mutation handlers. */ + public static void navigate(org.bukkit.event.inventory.InventoryClickEvent event, Player viewer, SFInventoryHolder holder, InventoryManager manager) { + Faction faction = EspionageAccess.owner(holder); + if (faction == null || event.getCurrentItem() == null || event.getCurrentItem().getType() == Material.BARRIER) return; + int slot = event.getSlot(); + var guild = net.tfminecraft.simplefactions.managers.FactionManager.getGuildByString(holder.getId()); + switch (holder.getType()) { + case INSTALLATIONS_VIEW -> { + var report = EspionageService.report(viewer, faction); + var entries = report == null ? List.of() : report.details("installation-details", "installations"); + if (slot >= 12 && slot - 12 < entries.size()) { + String[] fields = entries.get(slot - 12).split("\n", 3); + if (fields.length == 3) manager.installationDetailView(viewer, faction, fields[0]); + } + } + case TAX_VIEW -> { + var types = java.util.Arrays.stream(net.tfminecraft.simplefactions.government.proposal.TaxTarget.values()) + .filter(faction.getTaxHandler()::canCollectTax).toList(); + if (slot >= 0 && slot < types.size() && types.get(slot).name().endsWith("_ID")) { + var target = types.get(slot); + var inventory = open(viewer, faction.getId(), SFGUI.TAX_VIEW_SPECIFIC, 54, target.getDisplayName()); prepare(inventory); + int position = 0; + if (target == net.tfminecraft.simplefactions.government.proposal.TaxTarget.GUILD_ID) + for (var entry : faction.getGuildHandler().getGuilds()) { + if (entry.isBase() || position >= 53) continue; + inventory.setItem(position++, mask(entry.getBanner(), entry.getName(), List.of("\u00a77Tax rate: Unknown"))); + } + else inventory.setItem(0, EspionageView.item(Material.GOLD_INGOT, target.getDisplayName(), "\u00a77Specific rates: Unknown")); + inventory.setItem(53, manager.createBackButton(SFGUI.TAX_VIEW_SPECIFIC)); viewer.openInventory(inventory); + } + } + case GOVERNMENT_VIEW -> { + if (slot == 13) { + var inventory = open(viewer, faction.getId(), SFGUI.COUNCIL_VIEW, 54, "Council"); prepare(inventory); + for (int index : new int[]{10,11,12,13,14,15,16,19,20,21,22,23,24,25}) + inventory.setItem(index, EspionageView.item(Material.PLAYER_HEAD, "Council Seat", "\u00a77Office holder: Unknown")); + inventory.setItem(53, manager.createBackButton(SFGUI.COUNCIL_VIEW)); viewer.openInventory(inventory); + } + if (slot == 24) placeholder(viewer, faction.getId(), SFGUI.PROPOSALS, 27, "Proposals", 13, "Proposals", 26, manager); + if (slot == 33) placeholder(viewer, faction.getId(), SFGUI.MOVEMENT_LIST, 54, "Movements", 10, "Movements", 53, manager); + } + case LAW_VIEW -> { + int[] slots = {10,11,12,13,14,15,16,19,20,21,22,23,24,25}; + for (int index = 0; index < slots.length && index < faction.getLawHandler().getGroupList().size(); index++) { + if (slot != slots[index]) continue; + var group = faction.getLawHandler().getGroupList().get(index); + var inventory = open(viewer, faction.getId(), SFGUI.LAW_SELECT, 27, "Law View"); prepare(inventory); + int position = 0; + var report = EspionageService.report(viewer, faction); + var current = report == null ? List.of() : report.details("laws", "law:" + group.getId()); + for (var law : group.getLaws().values()) { + if (position >= 26) break; + inventory.setItem(position++, mask(law.getIcon(), law.getName(), List.of("\u00a77Current policy: " + + (current.isEmpty() ? "Unknown" : current.getFirst().equals(law.getName()) ? "Yes" : "No")))); + } + inventory.setItem(26, manager.createBackButton(SFGUI.LAW_SELECT)); viewer.openInventory(inventory); + } + } + case LOAN_MAIN_VIEW -> { + if (slot == 2 || slot == 4) placeholder(viewer, holder.getId(), slot == 2 ? SFGUI.LOANS_GIVEN_VIEW : SFGUI.LOANS_TAKEN_VIEW, + 54, slot == 2 ? "Loans Given" : "Loans Taken", 0, "Loan records", 53, manager); + } + case COMPANY_VIEW -> { + if (slot == 11) placeholder(viewer, holder.getId(), SFGUI.COMPANY_SLOTS_VIEW, 54, "Company Slots", 9, "Company Slots", 53, manager); + if (slot == 13) placeholder(viewer, holder.getId(), SFGUI.COMPANY_ROSTER_VIEW, 54, "Company Roster", 9, "Company Roster", 53, manager); + if (slot == 15) placeholder(viewer, holder.getId(), SFGUI.COMPANY_UPGRADE_VIEW, 54, "Company Upgrades", 9, "Company Upgrades", 53, manager); + if (slot == 22) placeholder(viewer, holder.getId(), SFGUI.CONTRACT_LIST_VIEW, 54, "Contracts", 0, "Contracts", 53, manager); + } + default -> { } + } + } + + private static void placeholder(Player viewer, String id, SFGUI type, int size, String title, int slot, String field, int back, InventoryManager manager) { + var inventory = open(viewer, id, type, size, title); prepare(inventory); + inventory.setItem(slot, EspionageView.item(Material.PAPER, field, "\u00a77Details: Unknown")); + inventory.setItem(back, manager.createBackButton(type)); viewer.openInventory(inventory); + } + + public static void installationDetail(Inventory inventory, Player viewer, Faction faction, String id, InventoryManager manager) { + prepare(inventory); + var report = EspionageService.report(viewer, faction); + var entries = report == null ? List.of() : report.details("installation-details", "installations"); + String title = "Installation Details"; Material icon = Material.GREEN_CONCRETE; + for (String entry : entries) { + String[] fields = entry.split("\n", 3); + if (fields.length == 3 && fields[0].equals(id)) { + title = fields[2]; icon = fields[1].equals("TRAIN_STATION") ? Material.MINECART : Material.GREEN_CONCRETE; + } + } + inventory.setItem(49, EspionageView.item(icon, title, "\u00a77Level: " + (report == null ? "Unknown" : report.display("Installation:" + id + ":Level")), + "\u00a77Coordinates: Unknown", "\u00a77Vehicles: Unknown")); + inventory.setItem(53, manager.createBackButton(SFGUI.INSTALLATION_DETAIL_VIEW)); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java index 3fc4ff52..fd21126f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java @@ -52,6 +52,10 @@ public void guildView(Player player, Guild guild) { } private void populateGuildView(Player player, Guild guild, Inventory inventory) { + if (!net.tfminecraft.simplefactions.espionage.EspionageService.canViewExact(player, guild.getFaction())) { + ReportedMenus.supplyHubs(inventory, player, guild, inv); + return; + } inventory.clear(); ProvinceManager provinces = SimpleFactions.getInstance().getProvinceManager(); List hubs = SupplyHubService.oldestFirst(guild.getSupplyHubs()); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java index bef8afcf..4369cb30 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java @@ -31,6 +31,13 @@ public TaxView(InventoryManager inv) { @SuppressWarnings("deprecation") public void taxView(Player player, Faction f, Inventory i) { if(f == null) return; + if (!net.tfminecraft.simplefactions.espionage.EspionageService.canViewExact(player, f)) { + boolean show = i == null; + if (show) i = ReportedMenus.open(player, f.getId(), SFGUI.TAX_VIEW, 18, "Tax View"); + ReportedMenus.taxes(i, player, f, inv); + if (show) player.openInventory(i); + return; + } boolean open = i == null; if(open) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.TAX_VIEW), 18, "§7Tax View"); i.clear(); @@ -83,12 +90,12 @@ public void specificTaxView(Player player, Faction f, TaxTarget target, Inventor public void click(InventoryClickEvent e, Inventory inventory, Player p) { e.setCancelled(true); - Faction f = FactionManager.getByLeader(p.getName()); + SFInventoryHolder holder = (SFInventoryHolder) inventory.getHolder(); + Faction f = FactionManager.getByString(holder.getId()); if(f == null) { p.closeInventory(); return; } - SFInventoryHolder holder = (SFInventoryHolder) inventory.getHolder(); if(holder.getType() == SFGUI.TAX_VIEW) { ItemStack item = e.getCurrentItem(); if(item == null || item.getItemMeta() == null) return; diff --git a/src/main/resources/special-positions.yml b/src/main/resources/special-positions.yml index 7077ad0c..9865892f 100644 --- a/src/main/resources/special-positions.yml +++ b/src/main/resources/special-positions.yml @@ -32,10 +32,11 @@ espionage: luck-spread: 75 luck-draws: 3 intelligence: - # Margin = observing faction's offense minus target's defense. Nonpositive margins are Unknown. + # Margin = observing faction's offense minus target's defense. Rumours is the minimum report quality. + # Negative margins return Rumours; individual fields stay Unknown below their required tier. tiers: rumours: - minimum-margin: 1 + minimum-margin: 0 uncertainty: 1.5 roster-fraction: 0.2 maximum-bounded-fraction: 0.75 @@ -61,7 +62,7 @@ espionage: maximum-roster-size: 23 # Fields stay Unknown below their minimum tier, even if an old report contains an estimate. # Valid values: rumours, broad, reliable, detailed. Unknown disables disclosure entirely. - # These rules apply to both faction and guild metrics; flavours, leaders, guilds and prestige stay public. + # The faction and all its guilds share one daily quality. Flavours, faction leaders, guilds and prestige stay public. minimum-tiers: members: rumours roster: rumours @@ -84,5 +85,14 @@ espionage: dividend-per-member: reliable office-holder: reliable office-aptitude: detailed + guild-leader: reliable + guild-members: rumours + training: reliable + buildings: broad + upgrades: reliable + taxes: reliable + laws: reliable + government: reliable + installation-details: reliable # Optional per-category overrides, e.g. TRADE: broad. All other categories use cashflow above. cashflows: {} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMathTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMathTest.java index 4d36710b..4cbec335 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMathTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMathTest.java @@ -95,8 +95,8 @@ void sabotageLowersBothRollTypesByTheChosenAmount() { @Test void hiddenAndFiniteChecksFailClosed() { - assertNull(EspionageMath.estimate(100, 0, false, new Random(1))); - assertNull(EspionageMath.estimate(100, -10, false, new Random(1))); + assertNotNull(EspionageMath.estimate(100, 0, false, new Random(1))); + assertNotNull(EspionageMath.estimate(100, -10, false, new Random(1))); assertNull(EspionageMath.estimate(Double.NaN, 100, false, new Random(1))); } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java index 4a6b998e..3064bae1 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java @@ -23,22 +23,30 @@ class EspionagePermissionsTest { @Test - void outsidersAreRedirectedBeforeAnyExactSubmenuIsBuilt() { + void outsidersOpenMaskedSubmenusWithoutBuildingExactInformation() { Player outsider = mock(Player.class); Faction faction = mock(Faction.class); Guild guild = mock(Guild.class); when(guild.getFaction()).thenReturn(faction); InventoryManager manager = mock(InventoryManager.class); - new MilitaryView(manager).militaryView(null, outsider, faction, true); - new GovernmentView(manager).governmentView(outsider, faction, null); - new LawView(manager).lawView(outsider, faction, null); - new InstallationView(manager).installationsView(null, outsider, faction, true); - try (var views = mockStatic(net.tfminecraft.simplefactions.managers.inventory.EspionageView.class)) { + var inventory = mock(org.bukkit.inventory.Inventory.class); + try (var reported = mockStatic(net.tfminecraft.simplefactions.managers.inventory.ReportedMenus.class); + var views = mockStatic(net.tfminecraft.simplefactions.managers.inventory.EspionageView.class)) { + reported.when(() -> net.tfminecraft.simplefactions.managers.inventory.ReportedMenus.open(any(), any(), any(), anyInt(), anyString())).thenReturn(inventory); + new MilitaryView(manager).militaryView(null, outsider, faction, true); + new GovernmentView(manager).governmentView(outsider, faction, null); + new LawView(manager).lawView(outsider, faction, null); + new InstallationView(manager).installationsView(null, outsider, faction, true); new GuildView(manager).ledgerView(outsider, guild, null); + new GuildView(manager).upgradeView(outsider, guild, inventory); + reported.verify(() -> net.tfminecraft.simplefactions.managers.inventory.ReportedMenus.military(inventory, outsider, faction, manager)); + reported.verify(() -> net.tfminecraft.simplefactions.managers.inventory.ReportedMenus.government(inventory, outsider, faction, manager)); + reported.verify(() -> net.tfminecraft.simplefactions.managers.inventory.ReportedMenus.laws(inventory, outsider, faction, manager)); + reported.verify(() -> net.tfminecraft.simplefactions.managers.inventory.ReportedMenus.installations(inventory, outsider, faction, manager)); + reported.verify(() -> net.tfminecraft.simplefactions.managers.inventory.ReportedMenus.upgrades(inventory, outsider, guild, manager)); views.verify(() -> net.tfminecraft.simplefactions.managers.inventory.EspionageView.foreignLedger(outsider, guild, manager)); } - new GuildView(manager).upgradeView(outsider, guild, null); - verify(manager, times(5)).factionView(outsider, faction); + verify(manager, never()).factionView(outsider, faction); verify(faction, never()).getMilitary(); verify(faction, never()).getGovernment(); verify(guild, never()).getLedger(); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java index 00ea5346..384ba939 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java @@ -117,7 +117,7 @@ void staffRefreshRebuildsAllDirectionsAndPersistsEachFactionOnce() { @Test void sampledRostersImproveWithQualityAndPersistAsSnapshots() { List members = java.util.stream.IntStream.range(0, 10).mapToObj(i -> "Character " + i + " — Guild").toList(); - assertTrue(EspionageService.sample(members, 0, new Random(1)).isEmpty()); + assertEquals(2, EspionageService.sample(members, 0, new Random(1)).size()); assertEquals(2, EspionageService.sample(members, 1, new Random(1)).size()); assertEquals(4, EspionageService.sample(members, 30, new Random(1)).size()); assertEquals(6, EspionageService.sample(members, 65, new Random(1)).size()); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java index 91d462e6..12487a4b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java @@ -97,6 +97,6 @@ void missingAndLegacyReportsUseUnknownWithoutChangingTheirData() { assertEquals("Unknown", report.display("Wealth")); report.estimates = null; assertEquals("Unknown", report.display("Wealth")); - assertEquals("Unknown", EspionageMath.quality(0)); + assertEquals("Rumours", EspionageMath.quality(0)); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java new file mode 100644 index 00000000..27f601a0 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java @@ -0,0 +1,109 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.List; +import java.util.Map; +import java.util.Random; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; + +class ReportPresentationTest { + @AfterEach void defaults() { EspionageConfig.load(new YamlConfiguration()); } + + @Test void installationAndUpgradeRangesRespectTheirConfiguredMaximums() { + var report = EspionageService.createReport(Map.of(), 100, new Random(1)); + for (String metric : List.of("Installation:fort:Level", "Guild:g:Upgrade:workshop")) { + report.estimates.put(metric, new EspionageMath.Estimate(4, 200)); + report.maximums.put(metric, 5L); + assertEquals(new EspionageMath.Estimate(4, 5), report.estimate(metric)); + assertEquals(200, report.estimates.get(metric).upper()); + report.estimates.put(metric, new EspionageMath.Estimate(0, 200)); + assertEquals("Unknown", report.display(metric)); + } + } + + @Test void onlyMaskedHoldersAllowForeignAccessToPreviouslyPrivateMenus() { + var outsider = mock(Player.class); var faction = mock(Faction.class); + 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); + assertTrue(EspionageAccess.denied(outsider, holder)); + holder.markReported(); + assertFalse(EspionageAccess.denied(outsider, holder)); + var privateOffice = new net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder("f", net.tfminecraft.simplefactions.enums.SFGUI.SPYMASTER_SETTINGS); + privateOffice.markReported(); + assertTrue(EspionageAccess.denied(outsider, privateOffice)); + } + } + + @Test void evenTheWorstMarginProducesRumoursAndUsefulLowTierFields() { + for (int margin : new int[]{Integer.MIN_VALUE, -500, -1, 0, 1}) { + var report = EspionageService.createReport(Map.of("Members", 100.0, "Professional army", 100.0), margin, new Random(8)); + assertEquals(IntelligenceTier.RUMOURS, report.tier()); + assertNotNull(report.estimate("Members")); + assertEquals("Unknown", report.display("Professional army")); + } + var config = new YamlConfiguration(); + config.set("espionage.intelligence.tiers.rumours.minimum-margin", 0); + EspionageConfig.load(config); + assertEquals(0, EspionageConfig.settings(IntelligenceTier.RUMOURS).minimumMargin()); + } + + @Test void nonNumericTrainingAndRolesObeyTheSameTierAfterSerializationAndReload() { + var report = EspionageService.createReport(Map.of(), 65, new Random(1)); + report.details.put("training", List.of("Professional Infantry")); + report.details.put("guild-leader:g", List.of("Lady Raven")); + report.details.put("leader-offices", List.of("Spymaster")); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(report), IntelligenceReport.class); + assertEquals(List.of("Professional Infantry"), restored.details("training", "training")); + var config = new YamlConfiguration(); + for (String field : List.of("training", "guild-leader", "office-holder")) + config.set("espionage.intelligence.minimum-tiers." + field, "detailed"); + EspionageConfig.load(config); + assertTrue(restored.details("training", "training").isEmpty()); + assertTrue(restored.details("guild-leader", "guild-leader:g").isEmpty()); + assertTrue(restored.details("office-holder", "leader-offices").isEmpty()); + } + + @Test void rosterGroupsUseEstimatedWealthAndNeverLeakUnsampledRolesWhenTheirGateChanges() { + var viewer = mock(Player.class); + var faction = mock(Faction.class, RETURNS_DEEP_STUBS); + var poor = mock(Guild.class); var rich = mock(Guild.class); + when(faction.getLeader()).thenReturn("Leader"); + when(faction.getGuildHandler().getGuilds()).thenReturn(List.of(poor, rich)); + when(poor.getId()).thenReturn("poor"); when(poor.getName()).thenReturn("Poor Guild"); + when(rich.getId()).thenReturn("rich"); when(rich.getName()).thenReturn("Rich Guild"); + var report = EspionageService.createReport(Map.of(), 65, new Random(1)); + report.roster = List.of(new IntelligenceReport.RosterMember("Known Member", "rich", "Rich Guild", true, false, List.of()), + new IntelligenceReport.RosterMember("Secret Spy", "poor", "Poor Guild", false, false, List.of(SpecialPosition.SPYMASTER)), + new IntelligenceReport.RosterMember("Guild Baron", "rich", "Rich Guild", false, true, List.of())); + try (var service = mockStatic(EspionageService.class); var names = mockStatic(CharacterNames.class)) { + service.when(() -> EspionageService.report(viewer, faction)).thenReturn(report); + service.when(() -> EspionageService.visibleValue(eq(viewer), eq(faction), anyString(), any())) + .thenAnswer(call -> call.getArgument(2).toString().contains("rich") ? 100.0 : 10.0); + names.when(() -> CharacterNames.display(viewer, "Leader")).thenReturn("King Rowan"); + var lore = RosterLore.faction(viewer, faction); + String text = String.join("\n", lore); + assertTrue(text.startsWith("#e8c55a\u00a7lFaction Leader:")); + assertTrue(text.indexOf("Rich Guild") < text.indexOf("Poor Guild")); + assertTrue(text.indexOf("Guild Baron") < text.indexOf("Known Member")); + assertTrue(text.contains("Spymaster: #c2dacaSecret Spy")); + var config = new YamlConfiguration(); + config.set("espionage.intelligence.minimum-tiers.office-holder", "detailed"); + config.set("espionage.intelligence.minimum-tiers.guild-leader", "detailed"); + EspionageConfig.load(config); + text = String.join("\n", RosterLore.faction(viewer, faction)); + assertFalse(text.contains("Secret Spy")); + assertFalse(text.contains("Guild Baron")); + assertTrue(text.contains("Known Member")); + } + verify(poor, never()).getWealth(); verify(rich, never()).getWealth(); + verify(poor, never()).getMembers(); verify(rich, never()).getMembers(); + } +} From 344ff558ca0042d03e9eab7895e637e6595adbd4 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:58:57 +0200 Subject: [PATCH 06/13] Label missing reports as botched and clean coloured menu titles --- ESPIONAGE.md | 3 +++ .../simplefactions/espionage/IntelligenceTier.java | 2 +- .../managers/inventory/EspionageView.java | 4 ++-- .../simplefactions/managers/inventory/GuildView.java | 2 +- .../simplefactions/managers/inventory/LoanView.java | 2 +- .../simplefactions/managers/inventory/MenuTitles.java | 10 ++++++++++ .../managers/inventory/ReportedMenus.java | 2 +- .../espionage/IntelligenceRangesTest.java | 2 +- .../espionage/ReportPresentationTest.java | 7 +++++++ 9 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/managers/inventory/MenuTitles.java diff --git a/ESPIONAGE.md b/ESPIONAGE.md index 4ec02759..9fc71138 100644 --- a/ESPIONAGE.md +++ b/ESPIONAGE.md @@ -274,3 +274,6 @@ Unknown details. Foreign views cannot submit proposals, train troops, upgrade buildings, cancel queues or modify another faction. Periodic refreshes never replace a masked snapshot with an exact submenu. Ledger detail windows retain the original 27-slot layout; cashflow estimates remain in the Ledger tooltip. + +Generated daily reports always start at Rumours. A missing or invalid report +uses the zero-information category **Botched report**, with private fields Unknown. diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceTier.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceTier.java index ed6bce87..c5e14fac 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceTier.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceTier.java @@ -4,7 +4,7 @@ /** Stable identifiers for saved reports and configurable disclosure rules. */ public enum IntelligenceTier { - UNKNOWN("Unknown"), RUMOURS("Rumours"), BROAD("Broad estimates"), + UNKNOWN("Botched report"), RUMOURS("Rumours"), BROAD("Broad estimates"), RELIABLE("Reliable estimates"), DETAILED("Detailed estimates"); private final String label; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java index 13fcc8eb..065cb2cd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -72,7 +72,7 @@ private static ItemStack reportHeader(Player viewer, IntelligenceReport report) String last = spy == null || !EspionageService.eligible(observer, spy.playerName) ? "\u00a77Without a Spymaster, scattered whispers 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 ? "Unknown" : report.qualityLabel()), + "\u00a77Report quality: \u00a7e" + (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); } @@ -120,7 +120,7 @@ public static void guild(Inventory inventory, Player viewer, Guild guild, Invent public static void foreignLedger(Player viewer, Guild guild, InventoryManager manager) { Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(guild.getId(), SFGUI.FOREIGN_LEDGER_VIEW), - 27, "\u00a77Ledger for " + guild.getName()); + 27, MenuTitles.legacy("Ledger for " + guild.getName())); var report = EspionageService.report(viewer, guild.getFaction()); if (guild.isBase()) { Material[] icons = {Material.PLAYER_HEAD, Material.BARREL, Material.IRON_INGOT, Material.GOLD_INGOT, diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java index 3cf043a9..a7abfcb2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java @@ -122,7 +122,7 @@ public void populateGuildList(Inventory inv, Player p) { public void ledgerView(Player player, Guild guild, Inventory i) { if (!EspionageService.canViewExact(player, guild.getFaction())) { EspionageView.foreignLedger(player, guild, inv); return; } boolean open = i == null; - if(i == null) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(guild.getId(), SFGUI.LEDGER_VIEW), 27, "§7Ledger for "+guild.getName()); + if(i == null) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(guild.getId(), SFGUI.LEDGER_VIEW), 27, MenuTitles.legacy("Ledger for "+guild.getName())); i.clear(); if(guild.isBase()) { i.setItem(10, creator.createLedgerCitizensItem(guild)); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java index acc423bf..0162e455 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java @@ -50,7 +50,7 @@ public void loanMainView(Player player, Guild guild, Inventory i) { if(open) i = SimpleFactions.plugin.getServer().createInventory( new SFInventoryHolder(guild.getId(), SFGUI.LOAN_MAIN_VIEW), 9, - "§7Loans - " + guild.getName() + MenuTitles.legacy("Loans - " + guild.getName()) ); i.clear(); i.setItem(2, creator.createLoansGivenButton(guild)); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MenuTitles.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MenuTitles.java new file mode 100644 index 00000000..e2ec9a2b --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MenuTitles.java @@ -0,0 +1,10 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +/** Native inventory titles use plain names; item lore retains the guild's configured colours. */ +@SuppressWarnings("deprecation") +public final class MenuTitles { + private MenuTitles() {} + public static String legacy(String title) { + return "\u00a77" + org.bukkit.ChatColor.stripColor(title == null ? "" : title).replace("\u00a7", ""); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java index 60dbdb2d..4a389343 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java @@ -171,7 +171,7 @@ public static void laws(Inventory inventory, Player viewer, Faction faction, Inv } public static Inventory open(Player viewer, String id, SFGUI type, int size, String title) { - return SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(id, type), size, "\u00a77" + title); + return SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(id, type), size, MenuTitles.legacy(title)); } public static void taxes(Inventory inventory, Player viewer, Faction faction, InventoryManager manager) { diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java index 12487a4b..6b1b6e06 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceRangesTest.java @@ -92,7 +92,7 @@ void missingAndLegacyReportsUseUnknownWithoutChangingTheirData() { var report = new IntelligenceReport(); report.quality = "Detailed estimates"; report.quality = "Hidden"; - assertEquals("Unknown", report.qualityLabel()); + assertEquals("Botched report", report.qualityLabel()); assertEquals("Hidden", report.quality); assertEquals("Unknown", report.display("Wealth")); report.estimates = null; diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java index 27f601a0..49ea2272 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java @@ -16,6 +16,13 @@ class ReportPresentationTest { @AfterEach void defaults() { EspionageConfig.load(new YamlConfiguration()); } + @Test void missingReportsAreBotchedAndColouredNamesProduceCleanWindowTitles() { + assertEquals("Botched report", new IntelligenceReport().qualityLabel()); + assertFalse(new IntelligenceReport().allows("members")); + assertEquals("\u00a77Ledger for Holy Order", net.tfminecraft.simplefactions.managers.inventory.MenuTitles.legacy("Ledger for \u00a7x\u00a71\u00a72\u00a73\u00a74\u00a75\u00a76Holy Order")); + assertEquals("\u00a77Loans - Holy Order", net.tfminecraft.simplefactions.managers.inventory.MenuTitles.legacy("Loans - \u00a76Holy Order\u00a7")); + } + @Test void installationAndUpgradeRangesRespectTheirConfiguredMaximums() { var report = EspionageService.createReport(Map.of(), 100, new Random(1)); for (String metric : List.of("Installation:fort:Level", "Guild:g:Upgrade:workshop")) { From 45f1f0543cbafabd57e6009a4466e148e0865a22 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:20:48 +0200 Subject: [PATCH 07/13] Preserve office state when persistence fails --- ESPIONAGE.md | 7 + .../simplefactions/database/Database.java | 9 +- .../simplefactions/database/JsonUtil.java | 21 +++ .../espionage/EspionageService.java | 41 ++++- .../espionage/EspionageState.java | 28 +++ .../database/JsonUtilAtomicTest.java | 32 ++++ .../espionage/EspionagePermissionsTest.java | 3 +- .../espionage/OfficePersistenceTest.java | 160 ++++++++++++++++++ 8 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/database/JsonUtilAtomicTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java diff --git a/ESPIONAGE.md b/ESPIONAGE.md index 9fc71138..6f2d3a70 100644 --- a/ESPIONAGE.md +++ b/ESPIONAGE.md @@ -277,3 +277,10 @@ the original 27-slot layout; cashflow estimates remain in the Ledger tooltip. Generated daily reports always start at Rumours. A missing or invalid report uses the zero-information category **Botched report**, with private fields Unknown. + +Office appointments, removals and sabotage changes acknowledge success only after +the faction save succeeds. Failed saves restore the prior office state and refund +any appointment charge; faction JSON is staged before replacing the previous save. +A founder aptitude save failure leaves the office initialization pending and retries +when the office is checked while the founder has an active character. It never +finalizes a failed aptitude roll as a permanent zero or consumes the free appointment. diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index 0f354b95..f623f8d5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -313,6 +313,11 @@ public void loadFactions() { * ===================================================== */ public void saveFaction(Faction f) { + saveFactionChecked(f); + } + + /** Office mutations must not acknowledge success unless the faction save completed. */ + public boolean saveFactionChecked(Faction f) { try { File file = new File("plugins/SimpleFactions/Data", f.getId() + ".json"); @@ -516,10 +521,12 @@ public void saveFaction(Faction f) { data.warReparationsObligations.add(obligationData); } - JsonUtil.writeJson(file, data); + JsonUtil.writeJsonAtomic(file, data); + return true; } catch (Exception e) { e.printStackTrace(); + return false; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/database/JsonUtil.java b/src/main/java/net/tfminecraft/simplefactions/database/JsonUtil.java index b543a18a..00ff1ebc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/JsonUtil.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/JsonUtil.java @@ -22,4 +22,25 @@ public static void writeJson(File file, Object data) throws IOException { GSON.toJson(data, w); } } + + /** Stage a complete save beside the destination so failed writes keep the previous data. */ + public static void writeJsonAtomic(File file, Object data) throws IOException { + var destination = file.toPath().toAbsolutePath(); + var pending = java.nio.file.Files.createFile(destination.resolveSibling(file.getName() + "." + java.util.UUID.randomUUID() + ".tmp")); + try { + if (java.nio.file.Files.exists(destination) + && java.nio.file.Files.getFileAttributeView(destination, java.nio.file.attribute.PosixFileAttributeView.class) != null) { + java.nio.file.Files.setPosixFilePermissions(pending, java.nio.file.Files.getPosixFilePermissions(destination)); + } + java.nio.file.Files.writeString(pending, GSON.toJson(data)); + try { + java.nio.file.Files.move(pending, destination, java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + java.nio.file.Files.move(pending, destination, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } finally { + java.nio.file.Files.deleteIfExists(pending); + } + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 156a803b..08a50d34 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -59,18 +59,22 @@ public static void initializeFounder(Faction faction) { for (SpecialPosition office : SpecialPosition.values()) { if (faction.getEspionage().holder(office) != null) continue; if (office == SpecialPosition.SPYMASTER && !eligible(faction, faction.getLeader())) continue; + if (faction.getEspionage().isPendingFounder(office) && character == null) continue; var assignment = new SpecialPositionAssignment(); assignment.playerName = faction.getLeader(); assignment.playerId = founder == null ? org.bukkit.Bukkit.getOfflinePlayer(faction.getLeader()).getUniqueId() : founder.getUniqueId(); int aptitude = 0; try { - if (character != null && characterAptitudes != null) { + if (character != null) { aptitude = characterAptitude(founder, character); assignment.characterId = character.getId(); } } catch (java.io.IOException exception) { net.tfminecraft.simplefactions.SimpleFactions.plugin.getLogger().log(java.util.logging.Level.SEVERE, "Could not save founder aptitude", exception); + faction.getEspionage().pendingFounder(office); + if (founder != null) founder.sendMessage("\u00a7cYour founding office could not be initialized. It will be retried when the office is checked."); + continue; } faction.getEspionage().assignFounder(office, assignment, aptitude); if (founder != null) founder.sendMessage("\u00a78\u00a7oUntil a trusted hand is appointed, you hold the keys to the " @@ -99,6 +103,16 @@ static boolean completeAppointment(Faction faction, SpecialPositionAssignment as return true; } + static boolean completeAndSaveAppointment(Faction faction, SpecialPositionAssignment assignment, int aptitude) { + var previous = faction.getEspionage().snapshotOffices(); + double cost = appointmentCost(faction); + if (!completeAppointment(faction, assignment, aptitude)) return false; + if (new Database().saveFactionChecked(faction)) return true; + faction.getEspionage().restoreOffices(previous); + if (cost > 0) faction.getBank().deposit(cost); + return false; + } + public static int effectiveAptitude(Faction faction, SpecialPositionAssignment holder) { if (holder == null || !eligible(faction, holder.playerName)) return 0; return faction.isLeader(holder.playerName) ? (int) Math.floor(holder.aptitude * EspionageConfig.soloMultiplier()) : holder.aptitude; @@ -119,6 +133,10 @@ public static boolean canViewExact(Player viewer, Faction target) { } public static SpecialPositionAssignment spymaster(Faction faction) { + if (faction.getEspionage().hasPendingFounder()) { + initializeFounder(faction); + if (!faction.getEspionage().hasPendingFounder()) new Database().saveFaction(faction); + } SpecialPositionAssignment holder = faction.getEspionage().getSpymaster(); if (holder != null && !eligible(faction, holder.playerName)) { faction.getEspionage().removeSpymaster(); @@ -166,14 +184,16 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) { try { if (characterAptitudes == null) throw new java.io.IOException("Character aptitude registry is unavailable"); int aptitude = characterAptitude(candidate, character); - if (!completeAppointment(faction, assignment, aptitude)) return false; + if (!completeAndSaveAppointment(faction, assignment, aptitude)) { + actor.sendMessage("\u00a7cThe appointment could not be saved. The office and treasury are unchanged."); + return false; + } } catch (java.io.IOException exception) { actor.sendMessage("§cThe aptitude could not be saved. The appointment has not been made."); net.tfminecraft.simplefactions.SimpleFactions.plugin.getLogger().log(java.util.logging.Level.SEVERE, "Could not save character aptitude", exception); return false; } - new Database().saveFaction(faction); // Address only the appointee. Sabotage preferences are never broadcast. candidate.sendMessage("§8§oA sealed letter reaches your hands. The keys to " + faction.getName() + "§8§o's unseen network are now yours. You have been appointed Spymaster."); @@ -206,8 +226,13 @@ public static boolean remove(Player actor, Faction faction) { actor.sendMessage("§cOnly your faction leader can remove the Spymaster."); return false; } + var previous = faction.getEspionage().snapshotOffices(); faction.getEspionage().removeSpymaster(); - new Database().saveFaction(faction); + if (!new Database().saveFactionChecked(faction)) { + faction.getEspionage().restoreOffices(previous); + actor.sendMessage("\u00a7cThe office removal could not be saved. The Spymaster remains appointed."); + return false; + } actor.sendMessage("§aThe Spymaster office is now vacant."); return true; } @@ -222,9 +247,15 @@ public static boolean setSabotage(Player actor, Faction faction, boolean offense actor.sendMessage("§cChoose a reduction of 0, 25, 50, 75, or 100. Zero disables sabotage."); return false; } + int previous = offense ? holder.offenseReduction : holder.defenseReduction; if (offense) holder.offenseReduction = reduction; else holder.defenseReduction = reduction; - new Database().saveFaction(faction); + if (!new Database().saveFactionChecked(faction)) { + if (offense) holder.offenseReduction = previous; + else holder.defenseReduction = previous; + actor.sendMessage("\u00a7cYour private conduct could not be saved. Your previous choice remains in effect."); + return false; + } actor.sendMessage("§7Private " + (offense ? "offensive" : "defensive") + " sabotage: " + (reduction == 0 ? "§adisabled" : "§c-" + reduction + " to your roll") + "§7. This affects your next daily rolls; existing reports stay unchanged."); diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java index 9508bd04..5e517b9e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java @@ -14,6 +14,31 @@ public class EspionageState { private Map reports = new HashMap<>(); private Map appointments = new EnumMap<>(SpecialPosition.class); private java.util.List unrest = new java.util.ArrayList<>(); + private java.util.Set pendingFounders = java.util.EnumSet.noneOf(SpecialPosition.class); + + void pendingFounder(SpecialPosition office) { pendingFounders.add(office); } + boolean hasPendingFounder() { return !pendingFounders.isEmpty(); } + boolean isPendingFounder(SpecialPosition office) { return pendingFounders.contains(office); } + + record OfficeSnapshot(Map positions, + Map appointments, java.util.List unrest, + java.util.Set pendingFounders) {} + + OfficeSnapshot snapshotOffices() { + var savedPositions = new EnumMap(SpecialPosition.class); + savedPositions.putAll(positions); + var savedAppointments = new EnumMap(SpecialPosition.class); + savedAppointments.putAll(appointments); + return new OfficeSnapshot(savedPositions, savedAppointments, + new java.util.ArrayList<>(unrest), new java.util.HashSet<>(pendingFounders)); + } + + void restoreOffices(OfficeSnapshot snapshot) { + positions.clear(); positions.putAll(snapshot.positions()); + appointments.clear(); appointments.putAll(snapshot.appointments()); + unrest.clear(); unrest.addAll(snapshot.unrest()); + pendingFounders.clear(); pendingFounders.addAll(snapshot.pendingFounders()); + } public SpecialPositionAssignment holder(SpecialPosition office) { return positions.get(office); } @@ -29,6 +54,7 @@ public void assignFounder(SpecialPosition office, SpecialPositionAssignment assi assignment.automatic = true; assignment.aptitude = EspionageMath.clamp(aptitude, 0, 100); positions.put(office, assignment); + pendingFounders.remove(office); } public void addUnrest(SpecialPosition office, double points, double days, long now) { @@ -54,6 +80,7 @@ public SpecialPositionAssignment getSpymaster() { } public void removeSpymaster() { + pendingFounders.remove(SpecialPosition.SPYMASTER); appointments.put(SpecialPosition.SPYMASTER, appointmentCount(SpecialPosition.SPYMASTER)); positions.remove(SpecialPosition.SPYMASTER); } @@ -70,6 +97,7 @@ void appoint(SpecialPositionAssignment assignment, Supplier aptitudeRol } public void appoint(SpecialPositionAssignment assignment, int permanentAptitude) { + pendingFounders.remove(SpecialPosition.SPYMASTER); appointments.put(SpecialPosition.SPYMASTER, appointmentCount(SpecialPosition.SPYMASTER) + 1); assignment.automatic = false; assignment.aptitude = EspionageMath.clamp(permanentAptitude, 0, 100); diff --git a/src/test/java/net/tfminecraft/simplefactions/database/JsonUtilAtomicTest.java b/src/test/java/net/tfminecraft/simplefactions/database/JsonUtilAtomicTest.java new file mode 100644 index 00000000..2df2bda4 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/database/JsonUtilAtomicTest.java @@ -0,0 +1,32 @@ +package net.tfminecraft.simplefactions.database; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class JsonUtilAtomicTest { + @TempDir Path directory; + + @Test void failedSerializationPreservesThePreviousSaveAndRemovesTheStagedFile() throws Exception { + Path file = directory.resolve("faction.json"); + Files.writeString(file, "{\"leader\":\"Old\"}"); + assertThrows(IllegalArgumentException.class, () -> JsonUtil.writeJsonAtomic(file.toFile(), Map.of("invalid", Double.NaN))); + assertEquals("{\"leader\":\"Old\"}", Files.readString(file)); + try (var files = Files.list(directory)) { assertEquals(1, files.count()); } + } + + @Test void completeSaveReplacesThePreviousData() throws Exception { + Path file = directory.resolve("faction.json"); + Files.writeString(file, "{}"); + var permissions = java.nio.file.attribute.PosixFilePermissions.fromString("rw-r-----"); + boolean posix = Files.getFileAttributeView(file, java.nio.file.attribute.PosixFileAttributeView.class) != null; + if (posix) Files.setPosixFilePermissions(file, permissions); + JsonUtil.writeJsonAtomic(file.toFile(), Map.of("leader", "New")); + assertEquals("New", JsonUtil.readJson(file.toFile(), Map.class).get("leader")); + if (posix) assertEquals(permissions, Files.getPosixFilePermissions(file)); + try (var files = Files.list(directory)) { assertEquals(1, files.count()); } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java index 3064bae1..c7da95f8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java @@ -70,7 +70,8 @@ void sabotageRequiresTheOfficeHoldersUuidEvenForLeadersAndMembers() { when(leader.getUniqueId()).thenReturn(UUID.randomUUID()); when(holder.getName()).thenReturn("Spy"); when(holder.getUniqueId()).thenReturn(assignment.playerId); - try (var databases = mockConstruction(Database.class)) { + try (var databases = mockConstruction(Database.class, (database, context) -> + when(database.saveFactionChecked(faction)).thenReturn(true))) { assertFalse(EspionageService.setSabotage(leader, faction, true, 100)); assertEquals(0, assignment.offenseReduction); assertFalse(EspionageService.setSabotage(holder, faction, true, 24)); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java new file mode 100644 index 00000000..0f1b5c8d --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java @@ -0,0 +1,160 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; + +class OfficePersistenceTest { + @Test void failedRemovalRestoresTheOfficeAndDoesNotClaimItWasRevoked() { + var faction = mock(Faction.class); + var actor = mock(Player.class); + var state = new EspionageState(); + var holder = holder("Spy"); + state.appoint(holder, 80); + when(faction.getEspionage()).thenReturn(state); + when(actor.getName()).thenReturn("Leader"); + when(faction.isLeader("Leader")).thenReturn(true); + try (var databases = mockConstruction(Database.class)) { + assertFalse(EspionageService.remove(actor, faction)); + assertSame(holder, state.getSpymaster()); + assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + verify(actor).sendMessage(contains("could not be saved")); + verify(actor, never()).sendMessage(contains("now vacant")); + } + try (var databases = mockConstruction(Database.class, (database, context) -> when(database.saveFactionChecked(faction)).thenReturn(true))) { + assertTrue(EspionageService.remove(actor, faction)); + assertNull(state.getSpymaster()); + assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + } + } + + @Test void failedPaidAppointmentRefundsTreasuryAndRestoresOfficeHistoryAndUnrest() { + var faction = mock(Faction.class); + var bank = mock(Bank.class); + var state = new EspionageState(); + var previous = holder("OldSpy"); + state.appoint(previous, 90); + var balance = new AtomicReference<>(1000.0); + when(faction.getEspionage()).thenReturn(state); + when(faction.getBank()).thenReturn(bank); + when(bank.getWealth()).thenAnswer(ignored -> balance.get()); + doAnswer(call -> { balance.updateAndGet(value -> value - (Double) call.getArgument(0)); return null; }).when(bank).withdraw(anyDouble()); + doAnswer(call -> { balance.updateAndGet(value -> value + (Double) call.getArgument(0)); return null; }).when(bank).deposit(anyDouble()); + try (var databases = mockConstruction(Database.class)) { + assertFalse(EspionageService.completeAndSaveAppointment(faction, holder("NewSpy"), 70)); + assertSame(previous, state.getSpymaster()); + assertEquals(1000.0, balance.get()); + assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + assertTrue(state.unrestModifiers(System.currentTimeMillis()).isEmpty()); + } + try (var databases = mockConstruction(Database.class, (database, context) -> when(database.saveFactionChecked(faction)).thenReturn(true))) { + assertTrue(EspionageService.completeAndSaveAppointment(faction, holder("NewSpy"), 70)); + assertEquals(750.0, balance.get()); + assertEquals("NewSpy", state.getSpymaster().playerName); + assertEquals(2, state.appointmentCount(SpecialPosition.SPYMASTER)); + assertEquals(-10.0, state.unrestModifiers(System.currentTimeMillis()).getFirst().getModifier(), 0.001); + } + } + + @Test void failedSabotageSaveKeepsThePreviousPrivateChoice() { + var faction = mock(Faction.class); + var actor = mock(Player.class); + var state = new EspionageState(); + var holder = holder("Spy"); + holder.offenseReduction = 25; + state.appoint(holder, 80); + when(faction.getEspionage()).thenReturn(state); + when(actor.getName()).thenReturn("Spy"); + when(actor.getUniqueId()).thenReturn(holder.playerId); + when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + try (var databases = mockConstruction(Database.class)) { + assertFalse(EspionageService.setSabotage(actor, faction, true, 100)); + assertFalse(EspionageService.setSabotage(actor, faction, false, 75)); + assertEquals(25, holder.offenseReduction); + assertEquals(0, holder.defenseReduction); + } + } + + @Test void failedFounderRollRemainsPendingAndRetriesWithoutUsingTheFreeAppointment() throws Exception { + var faction = mock(Faction.class); + var founder = mock(Player.class); + var player = mock(net.tfminecraft.rpcharacters.objects.PlayerData.class); + var character = mock(net.tfminecraft.rpcharacters.objects.RPCharacter.class); + var state = new EspionageState(); + when(faction.getEspionage()).thenReturn(state); + when(faction.getLeader()).thenReturn("Founder"); + when(faction.isLeader("Founder")).thenReturn(true); + when(faction.getMembers()).thenReturn(List.of("Founder")); + when(founder.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getActiveCharacter()).thenReturn(character); + when(character.getId()).thenReturn("founder-character"); + var registry = mock(CharacterAptitudes.class); + when(registry.aptitude(eq("founder-character"), any())).thenThrow(new java.io.IOException("Test save failure")).thenReturn(84); + var field = EspionageService.class.getDeclaredField("characterAptitudes"); + field.setAccessible(true); + var previous = field.get(null); + var previousPlugin = net.tfminecraft.simplefactions.SimpleFactions.plugin; + var plugin = mock(net.tfminecraft.simplefactions.SimpleFactions.class); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class); + var players = mockStatic(net.tfminecraft.rpcharacters.managers.PlayerManager.class); + var databases = mockConstruction(Database.class)) { + field.set(null, registry); + net.tfminecraft.simplefactions.SimpleFactions.plugin = plugin; + bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(mock(org.bukkit.Server.class)); + bukkit.when(() -> org.bukkit.Bukkit.getPlayerExact("Founder")).thenReturn(founder); + players.when(() -> net.tfminecraft.rpcharacters.managers.PlayerManager.get(founder)).thenReturn(player); + EspionageService.initializeFounder(faction); + assertNull(state.getSpymaster()); + assertTrue(state.hasPendingFounder()); + assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); + assertTrue(JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(state), EspionageState.class).hasPendingFounder()); + bukkit.when(() -> org.bukkit.Bukkit.getPlayerExact("Founder")).thenReturn(null); + EspionageService.initializeFounder(faction); + assertNull(state.getSpymaster(), "Offline retry must not finalize a zero-aptitude office"); + assertTrue(state.hasPendingFounder()); + bukkit.when(() -> org.bukkit.Bukkit.getPlayerExact("Founder")).thenReturn(founder); + assertEquals(84, EspionageService.spymaster(faction).aptitude); + assertEquals("founder-character", state.getSpymaster().characterId); + assertFalse(state.hasPendingFounder()); + assertTrue(state.getSpymaster().automatic); + assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); + } finally { + field.set(null, previous); + net.tfminecraft.simplefactions.SimpleFactions.plugin = previousPlugin; + } + } + + @Test void removingAPendingFounderPreventsAutomaticReassignment() { + var state = new EspionageState(); + state.pendingFounder(SpecialPosition.SPYMASTER); + state.removeSpymaster(); + assertFalse(state.hasPendingFounder()); + assertNull(state.getSpymaster()); + } + + @Test void emptyLoadedOfficeMapsCanBeSnapshottedAndRestored() { + var state = JsonUtil.GSON.fromJson("{\"positions\":{},\"appointments\":{}}", EspionageState.class); + var previous = state.snapshotOffices(); + state.appoint(holder("Spy"), 70); + state.restoreOffices(previous); + assertNull(state.getSpymaster()); + assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); + } + + private static SpecialPositionAssignment holder(String name) { + var holder = new SpecialPositionAssignment(); + holder.playerName = name; + holder.playerId = UUID.randomUUID(); + holder.characterId = name; + return holder; + } +} From 90f4ba958fc378108bcd5d5e21c54af69bbe6d75 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:41:12 +0200 Subject: [PATCH 08/13] Simplify faction rosters and apply unrest for vacant offices --- ESPIONAGE.md | 18 ++- .../simplefactions/SimpleFactions.java | 5 + .../espionage/EspionageConfig.java | 6 + .../espionage/EspionageService.java | 39 ++++++- .../espionage/IntelligenceLedger.java | 26 +++-- .../OfficeCharacterDeathListener.java | 17 +++ .../simplefactions/espionage/RosterLore.java | 14 +-- .../simplefactions/government/Government.java | 2 +- .../managers/inventory/FactionCreator.java | 2 +- src/main/resources/special-positions.yml | 5 + .../espionage/OfficeVacancyTest.java | 107 ++++++++++++++++++ .../espionage/ReportPresentationTest.java | 2 +- .../RosterAndLedgerPresentationTest.java | 54 +++++++++ 13 files changed, 275 insertions(+), 22 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java diff --git a/ESPIONAGE.md b/ESPIONAGE.md index 6f2d3a70..c2ee6fb7 100644 --- a/ESPIONAGE.md +++ b/ESPIONAGE.md @@ -156,8 +156,10 @@ cannot reveal extra names. They contain character names and guild affiliations. The faction leader is always displayed separately alongside the member-count range, regardless of quality. Guild rosters use the same sample, filtered by guild. Guild leaders and special-office identities use their configurable tier gates. -All rosters distinguish the faction leader, guild leaders and office holders; guilds -are grouped by descending visible wealth (estimated midpoints for foreign views). +All rosters distinguish the leader, guild leaders and office holders. The leader +uses the faction's custom ruler title, defaulting to Leader. Subjects belong only +to their own roster. The realm guild always comes first, followed by other guilds +in descending visible wealth (estimated midpoints for foreign views). Ranges are rounded, asymmetric snapshots containing the true value when generated. They never collapse to an exact number, including zero. Reports cover members, @@ -187,7 +189,8 @@ government, laws, installations and upgrade windows. Private menus recheck membership before interacting or refreshing. Foreign ledgers show daily income, expenses, net income, and a reported accounts -menu in the original ledger slots; the tooltip includes individual cashflows. Trade breakdowns and dividends use the same +menu in the original ledger slots; available cashflows appear under Income or +Expenses, without a long list of Unknown categories. Trade breakdowns and dividends use the same snapshot estimates. Dividend percentages stay within 0–100%; income and expense cashflows respect their valid signs. Missing new fields in an older report stay Unknown until the next day's report; opening a ledger never regenerates it. @@ -284,3 +287,12 @@ any appointment charge; faction JSON is staged before replacing the previous sav A founder aptitude save failure leaves the office initialization pending and retries when the office is checked while the founder has an active character. It never finalizes a failed aptitude roll as a permanent zero or consumes the free appointment. + +An empty or ineligible special office applies a persistent stability penalty until +filled, including offices never deliberately assigned and holders who leave. The +per-office setting `positions.spymaster.vacancy-stability-penalty` defaults to 10 +points; zero disables it. This is independent of the seven-day replacement unrest. +RPCharacters permanent character death removes that character's Spymaster office, +preserving paid appointment history. Ordinary Minecraft respawns and cancelled +character deaths do not remove the office. Removal is confirmed after the death +event commits, and loaded dead-character assignments are rejected when checked. diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 4761e2e5..f79bc577 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -235,6 +235,7 @@ public class SimpleFactions extends JavaPlugin{ private final VehicleReclaimFeeListener vehicleReclaimFeeListener = new VehicleReclaimFeeListener(vehicleFeeStore, vehicleFeeConfirmations, this::saveVehicleFees); private boolean registrationFeeRegistered; + private boolean officeCharacterDeathRegistered; private boolean vehicleIntegrationRegistered = false; private boolean constructionFreezeRegistered; private final PlayerEconomyManager playerEconomyManager = new PlayerEconomyManager(); @@ -654,6 +655,10 @@ private void registerRpCharactersIntegration() { net.tfminecraft.simplefactions.prestige.MemberPlaytime.setProbe( new net.tfminecraft.simplefactions.prestige.RpCharactersPlaytimeProbe()); net.tfminecraft.simplefactions.integration.rpcharacters.chat.RpCharactersChatIntegration.register(); + if (!officeCharacterDeathRegistered) { + getServer().getPluginManager().registerEvents(new net.tfminecraft.simplefactions.espionage.OfficeCharacterDeathListener(), this); + officeCharacterDeathRegistered = true; + } } private void registerVehicleIntegrationHooks() { diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java index 4eda7938..126bc30f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java @@ -20,6 +20,7 @@ public final class EspionageConfig { private static final Map tiers = new java.util.EnumMap<>(IntelligenceTier.class); private static final Map minimums = new LinkedHashMap<>(); private static final Map cashflows = new LinkedHashMap<>(); + private static final Map vacancyPenalties = new java.util.EnumMap<>(SpecialPosition.class); private static final Map DEFAULT_MINIMUMS = Map.ofEntries( Map.entry("members", IntelligenceTier.RUMOURS), Map.entry("roster", IntelligenceTier.RUMOURS), Map.entry("wealth", IntelligenceTier.RUMOURS), Map.entry("prosperity", IntelligenceTier.BROAD), @@ -56,6 +57,10 @@ public static void load(ConfigurationSection config) { repeatCost = nonnegative(config, "espionage.appointments.repeat-cost", 250); stabilityPenalty = Math.min(100, nonnegative(config, "espionage.appointments.stability-penalty", 10)); penaltyDays = Math.min(3650, nonnegative(config, "espionage.appointments.penalty-days", 7)); + vacancyPenalties.clear(); + for (SpecialPosition office : SpecialPosition.values()) vacancyPenalties.put(office, + Math.min(100, nonnegative(config, "positions." + office.name().toLowerCase(java.util.Locale.ROOT) + + ".vacancy-stability-penalty", 10))); reloadPermission = config.getString("espionage.reload-permission", "simplefactions.espionage.reload"); if (reloadPermission == null || reloadPermission.isBlank()) reloadPermission = "simplefactions.espionage.reload"; reloadPermission = reloadPermission.strip(); @@ -142,6 +147,7 @@ public static boolean allows(IntelligenceTier tier, String metric) { public static double repeatCost() { return repeatCost; } public static double stabilityPenalty() { return stabilityPenalty; } public static double penaltyDays() { return penaltyDays; } + public static double vacancyPenalty(SpecialPosition office) { return vacancyPenalties.getOrDefault(office, 10.0); } public static Map weights() { return weights; } public static String bypassPermission() { return bypassPermission; } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 08a50d34..1f8616ff 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -138,7 +138,7 @@ public static SpecialPositionAssignment spymaster(Faction faction) { if (!faction.getEspionage().hasPendingFounder()) new Database().saveFaction(faction); } SpecialPositionAssignment holder = faction.getEspionage().getSpymaster(); - if (holder != null && !eligible(faction, holder.playerName)) { + if (holder != null && (!eligible(faction, holder.playerName) || deadCharacter(holder))) { faction.getEspionage().removeSpymaster(); new Database().saveFaction(faction); return null; @@ -146,6 +146,43 @@ public static SpecialPositionAssignment spymaster(Faction faction) { return holder; } + private static boolean deadCharacter(SpecialPositionAssignment holder) { + if (holder.characterId == null || holder.playerId == null) return false; + var data = PlayerManager.get(holder.playerId); + return data != null && data.getCharacters().stream().anyMatch(character -> + holder.characterId.equals(character.getId()) && character.getStatus() == net.tfminecraft.rpcharacters.enums.Status.DEAD); + } + + /** Character death, rather than an ordinary Minecraft respawn, ends the appointment. */ + public static void characterDied(Player owner, RPCharacter character) { + if (character == null || character.getStatus() != net.tfminecraft.rpcharacters.enums.Status.DEAD) return; + for (Faction faction : FactionManager.getCopy()) { + var state = faction.getEspionage(); + var holder = state.getSpymaster(); + boolean matching = holder != null && (character.getId().equals(holder.characterId) + || holder.characterId == null && owner != null && holder.isHolder(owner.getUniqueId())); + boolean pending = state.isPendingFounder(SpecialPosition.SPYMASTER) && owner != null && faction.isLeader(owner.getName()); + if (!matching && !pending) continue; + state.removeSpymaster(); + new Database().saveFaction(faction); + if (owner != null) owner.sendMessage("\u00a78\u00a7oWith the passing of " + character.getName() + + ", the keys to the Spymaster's office return to the faction. The office awaits a successor."); + } + } + + public static java.util.List stabilityModifiers(Faction faction, long now) { + var result = new java.util.ArrayList<>(faction.getEspionage().unrestModifiers(now)); + for (SpecialPosition office : SpecialPosition.values()) { + var holder = faction.getEspionage().holder(office); + boolean occupied = holder != null && (office != SpecialPosition.SPYMASTER + || eligible(faction, holder.playerName) && !deadCharacter(holder)); + double penalty = EspionageConfig.vacancyPenalty(office); + if (!occupied && penalty > 0) result.add(new net.tfminecraft.simplefactions.government.StabilityModifier( + "Vacant " + office.label(), -penalty, 0)); + } + return result; + } + public static boolean appoint(Player actor, Faction faction, Player candidate) { if (!isOwn(actor, faction) || !faction.isLeader(actor.getName())) { actor.sendMessage("§cOnly your faction leader can appoint a Spymaster."); diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java index 9755dd15..5b1f735d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java @@ -37,13 +37,25 @@ public static String value(IntelligenceReport report, Guild guild, String metric } public static List summary(IntelligenceReport report, Guild guild) { - var lore = new java.util.ArrayList<>(List.of("#4c5250\u00a7oAdded to the bank at the", "#4c5250\u00a7ostart of a new day", "", - "#4fd945Income", "#d6cf69Total: " + value(report, guild, "Income total", "d/day"), "", - "#cf493aExpenses", "#d6cf69Total: " + value(report, guild, "Expense total", "d/day"), "", - "#f2e5c2Net Income", "#d6cf69Total: " + value(report, guild, "Income", "d/day"), "", - "\u00a77Click to inspect the reported accounts.")); - lore.add(""); - for (Cashflow flow : Cashflow.values()) lore.add(flow.getDisplay() + ": " + value(report, guild, "Cashflow:" + flow.name(), "d/day")); + var lore = new java.util.ArrayList<>(List.of("#4c5250\u00a7oAdded to the bank at the", "#4c5250\u00a7ostart of a new day", "", "#4fd945Income")); + cashflowLines(lore, report, guild, false); + lore.add("#d6cf69Total: " + value(report, guild, "Income total", "d/day")); + lore.add(""); lore.add("#cf493aExpenses"); + cashflowLines(lore, report, guild, true); + lore.add("#d6cf69Total: " + value(report, guild, "Expense total", "d/day")); + lore.add(""); lore.add("#f2e5c2Net Income"); + lore.add("#d6cf69Total: " + value(report, guild, "Income", "d/day")); + lore.add(""); lore.add("\u00a77Click to inspect the reported accounts."); return lore; } + + private static void cashflowLines(List lore, IntelligenceReport report, Guild guild, boolean expenses) { + if (report == null) return; + for (Cashflow flow : Cashflow.values()) { + String metric = "Cashflow:" + flow.name(); + var range = report.estimate(key(guild, metric)); + if (range != null && range.midpoint() != 0 && (range.midpoint() < 0) == expenses) + lore.add(flow.getDisplay() + ": " + value(report, guild, metric, "d/day")); + } + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java new file mode 100644 index 00000000..ab7caba9 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java @@ -0,0 +1,17 @@ +package net.tfminecraft.simplefactions.espionage; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import net.tfminecraft.rpcharacters.permadeath.CharacterPermakillEvent; +import net.tfminecraft.simplefactions.SimpleFactions; + +public final class OfficeCharacterDeathListener implements Listener { + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onCharacterDeath(CharacterPermakillEvent event) { + if (event.isCancelled()) return; + // RPCharacters fires before committing death. Confirm the resulting status next tick. + SimpleFactions.plugin.getServer().getScheduler().runTask(SimpleFactions.plugin, + () -> EspionageService.characterDied(event.getPlayer(), event.getCharacter())); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java b/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java index b6148896..88afd289 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java @@ -18,22 +18,19 @@ private static List build(Player viewer, Faction faction, Guild only) { boolean exact = EspionageService.canViewExact(viewer, faction); var report = exact ? null : EspionageService.report(viewer, faction); List lore = new ArrayList<>(); - lore.add("#e8c55a\u00a7lFaction Leader: #f4e4aa" + CharacterNames.display(viewer, faction.getLeader())); + String title = faction.getRulerTitle(); + lore.add("#e8c55a\u00a7l" + (title == null || title.isBlank() ? "Leader" : title) + ": #f4e4aa" + CharacterNames.display(viewer, faction.getLeader())); List leaderOffices = exact ? java.util.Arrays.stream(SpecialPosition.values()).filter(office -> { var holder = faction.getEspionage().holder(office); return holder != null && holder.playerName.equalsIgnoreCase(faction.getLeader()); }).map(SpecialPosition::label).toList() : report == null ? List.of() : report.details("office-holder", "leader-offices"); for (String office : leaderOffices) lore.add("#93c9a7 " + office + ": #c2daca" + CharacterNames.display(viewer, faction.getLeader())); List guilds = new ArrayList<>(only == null ? faction.getGuildHandler().getGuilds() : List.of(only)); - if (exact && only == null) for (String name : faction.getCompleteMemberList()) { - Guild represented = net.tfminecraft.simplefactions.managers.FactionManager.getGuildByMember(name); - if (represented != null && guilds.stream().noneMatch(guild -> guild.getId().equals(represented.getId()))) guilds.add(represented); - } - guilds.sort(Comparator.comparing((Guild guild) -> { + guilds.sort(Comparator.comparing((Guild guild) -> !guild.isBase()).thenComparing(Comparator.comparing((Guild guild) -> { var wealth = exact ? guild.getWealth() : EspionageService.visibleValue(viewer, faction, IntelligenceLedger.key(guild, "Wealth"), guild::getWealth); return wealth == null ? Double.NEGATIVE_INFINITY : wealth; - }).reversed().thenComparing(Guild::getName, String.CASE_INSENSITIVE_ORDER)); + }).reversed()).thenComparing(Guild::getName, String.CASE_INSENSITIVE_ORDER)); for (Guild guild : guilds) { List entries; if (exact) entries = guild.getMembers().stream().filter(name -> !faction.isLeader(name)).map(name -> @@ -48,7 +45,8 @@ private static List build(Player viewer, Faction faction, Guild only) { || entry.guildLeader() && report.allows("guild-leader") || !entry.offices().isEmpty() && report.allows("office-holder")).toList(); lore.add(""); - lore.add("#cba351\u00a7l" + guild.getName() + (guild.isBase() ? " #7fbd73(Capital)" : " #b8ae61(Guild)")); + String guildType = guild.isBase() && guild.getType() != null ? guild.getType().getName() : guild.isBase() ? "Realm" : "Guild"; + lore.add("#cba351\u00a7l" + guild.getName() + (guild.isBase() ? " #7fbd73(" : " #b8ae61(") + guildType + ")"); var sorted = new ArrayList<>(entries); sorted.sort(Comparator.comparingInt((IntelligenceReport.RosterMember entry) -> (entry.guildLeader() && (exact || report.allows("guild-leader")) ? 0 diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Government.java b/src/main/java/net/tfminecraft/simplefactions/government/Government.java index 4d9ea792..73cec1d7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/Government.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/Government.java @@ -230,7 +230,7 @@ public void addStabilityModifier(StabilityModifier modifier) { public List getStabilityModifiers() { if (f == null || f.getEspionage() == null) return stabilityModifiers; - var active = f.getEspionage().unrestModifiers(System.currentTimeMillis()); + var active = net.tfminecraft.simplefactions.espionage.EspionageService.stabilityModifiers(f, System.currentTimeMillis()); if (active.isEmpty()) return stabilityModifiers; List result = new ArrayList<>(stabilityModifiers); result.addAll(active); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java index b3f35680..4f33c24c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java @@ -404,7 +404,7 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { i = new ItemStack(Material.PLAYER_HEAD, 1); ItemMeta m = i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - List memberNames = new ArrayList<>(f.getCompleteMemberList()); + List memberNames = new ArrayList<>(f.getMembers()); memberNames.removeIf(name -> name.equalsIgnoreCase(f.getLeader())); memberNames.add(0, f.getLeader()); m.setDisplayName(StringFormatter.formatHex("#b8ae61Members: #7fbd73"+memberNames.size())); diff --git a/src/main/resources/special-positions.yml b/src/main/resources/special-positions.yml index 9865892f..b5315218 100644 --- a/src/main/resources/special-positions.yml +++ b/src/main/resources/special-positions.yml @@ -1,6 +1,11 @@ # Special offices and their intelligence network. Reload settings with /faction reloadconfigs. # For testing: /faction reloadespionage reloads this file, rerolls daily checks and regenerates reports. # Character aptitude rolls, appointment history and unrest are never reset by that command. +positions: + spymaster: + # Stability points lost whenever the office is vacant or its holder is ineligible. + # Persists without decay until filled. Zero disables this penalty. + vacancy-stability-penalty: 10.0 espionage: # Assign these nodes through LuckPerms; bypass only grants viewing access. bypass-permission: simplefactions.espionage.bypass diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java new file mode 100644 index 00000000..d986d1ee --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java @@ -0,0 +1,107 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.entity.Player; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import net.tfminecraft.rpcharacters.enums.Status; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.permadeath.CharacterPermakillEvent; +import net.tfminecraft.rpcharacters.permadeath.PermakillCause; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; + +class OfficeVacancyTest { + @AfterEach void defaults() { EspionageConfig.load(new YamlConfiguration()); } + + @Test void vacancyPersistsUntilFilledAndReturnsOnRemoval() { + var faction = mock(Faction.class); + var state = new EspionageState(); + when(faction.getEspionage()).thenReturn(state); + for (long now : new long[]{0, 365L * 86_400_000}) { + var penalties = EspionageService.stabilityModifiers(faction, now); + assertEquals(1, penalties.size()); + assertEquals(-10, penalties.getFirst().getModifier()); + assertEquals("Vacant Spymaster", penalties.getFirst().getName()); + } + var holder = new SpecialPositionAssignment(); holder.playerName = "Spy"; + when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + state.appoint(holder, 80); + assertTrue(EspionageService.stabilityModifiers(faction, 0).isEmpty()); + state.removeSpymaster(); + assertEquals(-10, EspionageService.stabilityModifiers(faction, Long.MAX_VALUE).getFirst().getModifier()); + var config = new YamlConfiguration(); + config.set("positions.spymaster.vacancy-stability-penalty", 0); + EspionageConfig.load(config); + assertTrue(EspionageService.stabilityModifiers(faction, 0).isEmpty()); + config.set("positions.spymaster.vacancy-stability-penalty", 15); + EspionageConfig.load(config); + assertEquals(-15, EspionageService.stabilityModifiers(faction, 0).getFirst().getModifier()); + } + + @Test void ineligibleHolderCountsAsVacantAndReplacementUnrestRemainsIndependent() { + var faction = mock(Faction.class); + var state = new EspionageState(); + when(faction.getEspionage()).thenReturn(state); + var holder = new SpecialPositionAssignment(); holder.playerName = "FormerSpy"; + state.appoint(holder, 80); + state.addUnrest(SpecialPosition.SPYMASTER, 10, 7, 1000); + assertEquals(2, EspionageService.stabilityModifiers(faction, 1000).size()); + assertEquals(1, EspionageService.stabilityModifiers(faction, 1000 + 8L * 86_400_000).size()); + when(faction.isMemberIgnoreCase("FormerSpy")).thenReturn(true); + assertTrue(EspionageService.stabilityModifiers(faction, 1000 + 8L * 86_400_000).isEmpty()); + } + + @Test void confirmedDeathRevokesOnlyTheAppointedCharacterAndKeepsAppointmentHistory() { + var faction = mock(Faction.class); + var state = new EspionageState(); + when(faction.getEspionage()).thenReturn(state); + var holder = new SpecialPositionAssignment(); holder.playerName = "Spy"; holder.characterId = "appointed"; + state.appoint(holder, 80); + var character = mock(RPCharacter.class); + when(character.getId()).thenReturn("other"); + when(character.getStatus()).thenReturn(Status.DEAD); + try (var factions = mockStatic(FactionManager.class); var databases = mockConstruction(Database.class)) { + factions.when(FactionManager::getCopy).thenReturn(List.of(faction)); + EspionageService.characterDied(null, character); + assertSame(holder, state.getSpymaster()); + when(character.getId()).thenReturn("appointed"); + when(character.getStatus()).thenReturn(Status.ALIVE); + EspionageService.characterDied(null, character); + assertSame(holder, state.getSpymaster()); + when(character.getStatus()).thenReturn(Status.DEAD); + EspionageService.characterDied(null, character); + assertNull(state.getSpymaster()); + assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + verify(databases.constructed().getFirst()).saveFaction(faction); + } + } + + @Test void deathListenerIgnoresCancellationAndWaitsForConfirmedCharacterStatus() { + var character = mock(RPCharacter.class); var owner = mock(Player.class); + var event = new CharacterPermakillEvent(owner, character, PermakillCause.OTHER); + event.setCancelled(true); + var plugin = mock(SimpleFactions.class, RETURNS_DEEP_STUBS); + var previous = SimpleFactions.plugin; SimpleFactions.plugin = plugin; + var task = new AtomicReference(); + try (var service = mockStatic(EspionageService.class)) { + var listener = new OfficeCharacterDeathListener(); + listener.onCharacterDeath(event); + verify(plugin, never()).getServer(); + event.setCancelled(false); + when(plugin.getServer().getScheduler().runTask(eq(plugin), any(Runnable.class))) + .thenAnswer(call -> { task.set(call.getArgument(1)); return null; }); + listener.onCharacterDeath(event); + service.verifyNoInteractions(); + task.get().run(); + service.verify(() -> EspionageService.characterDied(owner, character)); + } finally { SimpleFactions.plugin = previous; } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java index 49ea2272..0d7f620b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java @@ -97,7 +97,7 @@ class ReportPresentationTest { names.when(() -> CharacterNames.display(viewer, "Leader")).thenReturn("King Rowan"); var lore = RosterLore.faction(viewer, faction); String text = String.join("\n", lore); - assertTrue(text.startsWith("#e8c55a\u00a7lFaction Leader:")); + assertTrue(text.startsWith("#e8c55a\u00a7lLeader:")); assertTrue(text.indexOf("Rich Guild") < text.indexOf("Poor Guild")); assertTrue(text.indexOf("Guild Baron") < text.indexOf("Known Member")); assertTrue(text.contains("Spymaster: #c2dacaSecret Spy")); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java new file mode 100644 index 00000000..fd50f0d2 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java @@ -0,0 +1,54 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.List; +import java.util.Random; +import java.util.Map; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; + +class RosterAndLedgerPresentationTest { + @Test void ownRosterExcludesSubjectsAndKeepsRealmBeforeWealthOrderedGuilds() { + var viewer = mock(Player.class); var faction = mock(Faction.class, RETURNS_DEEP_STUBS); + when(faction.getEspionage()).thenReturn(new EspionageState()); + var realm = mock(Guild.class); var rich = mock(Guild.class); var poor = mock(Guild.class); + when(faction.getLeader()).thenReturn("LeaderAccount"); when(faction.getRulerTitle()).thenReturn("Elder"); + when(faction.getGuildHandler().getGuilds()).thenReturn(List.of(poor, rich, realm)); + when(realm.isBase()).thenReturn(true); when(realm.getName()).thenReturn("Main Realm"); + when(realm.getFaction()).thenReturn(faction); + when(rich.getName()).thenReturn("Rich Guild"); when(rich.getWealth()).thenReturn(1000.0); + when(poor.getName()).thenReturn("Poor Guild"); when(poor.getWealth()).thenReturn(100.0); + try (var service = mockStatic(EspionageService.class); var names = mockStatic(CharacterNames.class)) { + service.when(() -> EspionageService.canViewExact(viewer, faction)).thenReturn(true); + names.when(() -> CharacterNames.display(viewer, "LeaderAccount")).thenReturn("Rowan"); + String text = String.join("\n", RosterLore.faction(viewer, faction)); + assertTrue(text.startsWith("#e8c55a\u00a7lElder: #f4e4aaRowan")); + assertTrue(text.indexOf("Main Realm") < text.indexOf("Rich Guild")); + assertTrue(text.indexOf("Rich Guild") < text.indexOf("Poor Guild")); + assertFalse(text.contains("Capital")); + verify(faction, never()).getCompleteMemberList(); + when(faction.getRulerTitle()).thenReturn(null); + assertTrue(RosterLore.guild(viewer, realm).getFirst().startsWith("#e8c55a\u00a7lLeader:")); + } + } + + @Test void lowTierLedgerDoesNotListEveryUnknownCashflowAndKnownFlowsUseTheirSections() { + var guild = mock(Guild.class); when(guild.getId()).thenReturn("g"); + var report = EspionageService.createReport(Map.of(), 0, new Random(1)); + var empty = IntelligenceLedger.summary(report, guild); + assertEquals(3, empty.stream().filter(line -> line.contains("Unknown")).count()); + assertTrue(empty.size() < 15); + report = EspionageService.createReport(Map.of(), 65, new Random(1)); + report.estimates.put("Guild:g:Cashflow:TRADE", new EspionageMath.Estimate(100, 120)); + report.estimates.put("Guild:g:Cashflow:MILITARY_UPKEEP", new EspionageMath.Estimate(-20, -10)); + String text = String.join("\n", IntelligenceLedger.summary(report, guild)); + assertTrue(text.indexOf("Income\n") < text.indexOf("Trade:")); + assertTrue(text.indexOf("Trade:") < text.indexOf("Expenses\n")); + assertTrue(text.indexOf("Expenses\n") < text.indexOf("Military Upkeep:")); + assertFalse(text.contains("Guilds:")); + verify(guild, never()).getLedger(); + } +} From a872ecf194f0b4995ed2e15ba77f6a5659fd1e3d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:49:25 +0200 Subject: [PATCH 09/13] Omit inconclusive cashflow rows from ledger summaries --- .../simplefactions/espionage/IntelligenceLedger.java | 2 +- .../espionage/RosterAndLedgerPresentationTest.java | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java index 5b1f735d..c28225b5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java @@ -54,7 +54,7 @@ private static void cashflowLines(List lore, IntelligenceReport report, for (Cashflow flow : Cashflow.values()) { String metric = "Cashflow:" + flow.name(); var range = report.estimate(key(guild, metric)); - if (range != null && range.midpoint() != 0 && (range.midpoint() < 0) == expenses) + if (range != null && (expenses ? range.upper() < 0 : range.lower() > 0)) lore.add(flow.getDisplay() + ": " + value(report, guild, metric, "d/day")); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java index fd50f0d2..2af3b912 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java @@ -44,11 +44,14 @@ class RosterAndLedgerPresentationTest { report = EspionageService.createReport(Map.of(), 65, new Random(1)); report.estimates.put("Guild:g:Cashflow:TRADE", new EspionageMath.Estimate(100, 120)); report.estimates.put("Guild:g:Cashflow:MILITARY_UPKEEP", new EspionageMath.Estimate(-20, -10)); + report.estimates.put("Guild:g:Cashflow:GUILDS", new EspionageMath.Estimate(0, 2)); + report.estimates.put("Guild:g:Cashflow:VEHICLE_UPKEEP", new EspionageMath.Estimate(-2, 0)); String text = String.join("\n", IntelligenceLedger.summary(report, guild)); assertTrue(text.indexOf("Income\n") < text.indexOf("Trade:")); assertTrue(text.indexOf("Trade:") < text.indexOf("Expenses\n")); assertTrue(text.indexOf("Expenses\n") < text.indexOf("Military Upkeep:")); assertFalse(text.contains("Guilds:")); + assertFalse(text.contains("Vehicle Upkeep:")); verify(guild, never()).getLedger(); } } From 6fe69ec9b11762aa7d576bb099e43b31d98eb839 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:58:03 +0200 Subject: [PATCH 10/13] Address espionage review privacy, optional plugins and persistence --- ESPIONAGE.md | 6 + .../simplefactions/SimpleFactions.java | 12 +- .../espionage/CharacterNames.java | 24 +++- .../espionage/EspionageService.java | 105 ++++++++---------- .../OfficeCharacterDeathListener.java | 6 +- .../espionage/OfficeCharacters.java | 66 +++++++++++ .../espionage/ReportDetails.java | 2 +- .../simplefactions/espionage/RosterLore.java | 4 +- .../managers/inventory/EspionageView.java | 2 +- .../managers/inventory/FactionCreator.java | 6 +- .../managers/inventory/GovernmentCreator.java | 2 +- .../managers/inventory/ReportedMenus.java | 4 +- .../SimpleFactionsShutdownTest.java | 31 ++++++ .../CharacterNameResolutionTest.java | 28 +++++ .../espionage/IntelligenceBrowsingTest.java | 23 +++- .../espionage/IntelligencePolicyTest.java | 2 +- .../espionage/OfficeAppointmentsTest.java | 6 +- .../OfficeOptionalIntegrationsTest.java | 89 +++++++++++++++ .../espionage/OfficePersistenceTest.java | 3 + .../espionage/OfficeVacancyTest.java | 16 +-- .../espionage/ReportPresentationTest.java | 2 +- .../RosterAndLedgerPresentationTest.java | 2 +- 22 files changed, 351 insertions(+), 90 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacters.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/SimpleFactionsShutdownTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/OfficeOptionalIntegrationsTest.java diff --git a/ESPIONAGE.md b/ESPIONAGE.md index c2ee6fb7..330a705a 100644 --- a/ESPIONAGE.md +++ b/ESPIONAGE.md @@ -287,6 +287,12 @@ any appointment charge; faction JSON is staged before replacing the previous sav A founder aptitude save failure leaves the office initialization pending and retries when the office is checked while the founder has an active character. It never finalizes a failed aptitude roll as a permanent zero or consumes the free appointment. +Founders without an active character (or without RPCharacters enabled) also remain +pending. With MMOCore absent, aptitude uses RPCharacters' saved attribute values. +Foreign reports use Unknown for missing character names; own views, staff bypass +and invitations retain their account-name fallback. Daily refreshes batch faction +saves once per changed faction. A startup failure before faction restoration +completes cannot overwrite faction saves or the saved timer during shutdown. An empty or ineligible special office applies a persistent stability penalty until filled, including offices never deliberately assigned and holders who leave. The diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index f79bc577..a417cd7f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -373,10 +373,7 @@ public void onDisable() { net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService.saveAll(); sessionManager.end(); net.tfminecraft.simplefactions.inactivity.InactivityService.save(); - db.saveTimer(FactionManager.getTimer(), FactionManager.getDay()); - for(Faction f : FactionManager.factions) { - db.saveFaction(f); - } + saveLoadedFactions(); for(War w : WarManager.get()){ db.saveWar(w); } @@ -387,6 +384,13 @@ public void onDisable() { vehicleMaintenancePersistence.save(); } } + void saveLoadedFactions() { + // An early enable failure must not overwrite partially restored faction state. + if (!FactionManager.isLoaded()) return; + db.saveTimer(FactionManager.getTimer(), FactionManager.getDay()); + for (Faction faction : FactionManager.factions) db.saveFaction(faction); + } + public void loadConfigs() { net.tfminecraft.simplefactions.espionage.SpecialPositionsConfigFile.load(this); configLoader.loadConfig(new File(getDataFolder(), "config.yml")); diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java index 3e677ad7..b163ac0f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java @@ -19,6 +19,19 @@ public static String display(org.bukkit.entity.Player viewer, String account) { return EspionageService.bypasses(viewer) ? character + " \u00a77(" + account + ")" : character; } + public static String display(org.bukkit.entity.Player viewer, String account, + net.tfminecraft.simplefactions.objects.Faction faction) { + if (!OfficeCharacters.enabled("RPCharacters") || EspionageService.canViewExact(viewer, faction)) + return display(viewer, account); + return forForeign(account); + } + + /** A missing active character must not expose account identity in foreign reports. */ + public static String forForeign(String account) { + String name = activeName(account); + return name == null ? "Unknown" : name; + } + /** Exact account names take precedence; ambiguous character names never pick a player arbitrarily. */ public static org.bukkit.entity.Player resolveOnline(org.bukkit.entity.Player sender, String input) { String query = input.strip(); @@ -54,8 +67,13 @@ public static java.util.List inviteCompletions(java.util.Collection System.currentTimeMillis()) return cached.name; - String result = playerName; + String result = null; var plugin = Bukkit.getPluginManager().getPlugin("RPCharacters"); Path folder = plugin.getDataFolder().toPath().resolve("data/characterdata") .resolve(Bukkit.getOfflinePlayer(playerName).getUniqueId().toString()); @@ -78,7 +96,7 @@ public static String of(String playerName) { } } } catch (java.io.IOException | com.google.gson.JsonParseException | IllegalStateException ignored) { - // No readable character: keep the account name as a useful fallback. + // Cache the absence too; account-oriented callers supply their own fallback. } } cache.put(playerName, new Cached(result, System.currentTimeMillis() + 30_000)); diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 1f8616ff..142a1e29 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -2,7 +2,6 @@ import java.time.LocalDate; import java.time.ZoneOffset; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.ThreadLocalRandom; @@ -10,9 +9,6 @@ import org.bukkit.entity.Player; -import net.tfminecraft.rpcharacters.managers.PlayerManager; -import net.tfminecraft.rpcharacters.objects.RPCharacter; -import net.tfminecraft.rpcharacters.objects.attributes.AttributeModifier; import net.tfminecraft.simplefactions.database.Database; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; @@ -54,21 +50,21 @@ public static boolean eligible(Faction faction, String playerName) { public static void initializeFounder(Faction faction) { if (faction.getEspionage() == null || org.bukkit.Bukkit.getServer() == null) return; var founder = org.bukkit.Bukkit.getPlayerExact(faction.getLeader()); - var data = founder == null ? null : PlayerManager.get(founder); - RPCharacter character = data == null ? null : data.getActiveCharacter(); + String characterId = OfficeCharacters.activeCharacterId(founder); for (SpecialPosition office : SpecialPosition.values()) { if (faction.getEspionage().holder(office) != null) continue; if (office == SpecialPosition.SPYMASTER && !eligible(faction, faction.getLeader())) continue; - if (faction.getEspionage().isPendingFounder(office) && character == null) continue; + if (characterId == null) { + faction.getEspionage().pendingFounder(office); + continue; + } var assignment = new SpecialPositionAssignment(); assignment.playerName = faction.getLeader(); assignment.playerId = founder == null ? org.bukkit.Bukkit.getOfflinePlayer(faction.getLeader()).getUniqueId() : founder.getUniqueId(); int aptitude = 0; try { - if (character != null) { - aptitude = characterAptitude(founder, character); - assignment.characterId = character.getId(); - } + aptitude = characterAptitude(founder, characterId); + assignment.characterId = characterId; } catch (java.io.IOException exception) { net.tfminecraft.simplefactions.SimpleFactions.plugin.getLogger().log(java.util.logging.Level.SEVERE, "Could not save founder aptitude", exception); @@ -133,39 +129,45 @@ public static boolean canViewExact(Player viewer, Faction target) { } public static SpecialPositionAssignment spymaster(Faction faction) { + return spymaster(faction, null); + } + + private static void saveOrMark(Faction faction, java.util.Set dirty) { + if (dirty == null) new Database().saveFaction(faction); + else dirty.add(faction); + } + + private static SpecialPositionAssignment spymaster(Faction faction, java.util.Set dirty) { if (faction.getEspionage().hasPendingFounder()) { initializeFounder(faction); - if (!faction.getEspionage().hasPendingFounder()) new Database().saveFaction(faction); + if (!faction.getEspionage().hasPendingFounder()) saveOrMark(faction, dirty); } SpecialPositionAssignment holder = faction.getEspionage().getSpymaster(); if (holder != null && (!eligible(faction, holder.playerName) || deadCharacter(holder))) { faction.getEspionage().removeSpymaster(); - new Database().saveFaction(faction); + saveOrMark(faction, dirty); return null; } return holder; } private static boolean deadCharacter(SpecialPositionAssignment holder) { - if (holder.characterId == null || holder.playerId == null) return false; - var data = PlayerManager.get(holder.playerId); - return data != null && data.getCharacters().stream().anyMatch(character -> - holder.characterId.equals(character.getId()) && character.getStatus() == net.tfminecraft.rpcharacters.enums.Status.DEAD); + return OfficeCharacters.isDead(holder.playerId, holder.characterId); } /** Character death, rather than an ordinary Minecraft respawn, ends the appointment. */ - public static void characterDied(Player owner, RPCharacter character) { - if (character == null || character.getStatus() != net.tfminecraft.rpcharacters.enums.Status.DEAD) return; + public static void characterDied(Player owner, String characterId, String characterName) { + if (characterId == null) return; for (Faction faction : FactionManager.getCopy()) { var state = faction.getEspionage(); var holder = state.getSpymaster(); - boolean matching = holder != null && (character.getId().equals(holder.characterId) + boolean matching = holder != null && (characterId.equals(holder.characterId) || holder.characterId == null && owner != null && holder.isHolder(owner.getUniqueId())); boolean pending = state.isPendingFounder(SpecialPosition.SPYMASTER) && owner != null && faction.isLeader(owner.getName()); if (!matching && !pending) continue; state.removeSpymaster(); new Database().saveFaction(faction); - if (owner != null) owner.sendMessage("\u00a78\u00a7oWith the passing of " + character.getName() + if (owner != null) owner.sendMessage("\u00a78\u00a7oWith the passing of " + characterName + ", the keys to the Spymaster's office return to the faction. The office awaits a successor."); } } @@ -196,15 +198,14 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) { actor.sendMessage("§cThe faction leader cannot be Spymaster unless the faction has only one member."); return false; } - var playerData = PlayerManager.get(candidate); - RPCharacter character = playerData == null ? null : playerData.getActiveCharacter(); - if (character == null) { + String characterId = OfficeCharacters.activeCharacterId(candidate); + if (characterId == null) { actor.sendMessage("§cThat member needs an active roleplay character."); return false; } SpecialPositionAssignment current = spymaster(faction); if (current != null && !current.automatic && current.isHolder(candidate.getUniqueId()) - && character.getId().equals(current.characterId)) { + && characterId.equals(current.characterId)) { actor.sendMessage("§7That member is already your Spymaster."); return false; } @@ -217,10 +218,10 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) { SpecialPositionAssignment assignment = new SpecialPositionAssignment(); assignment.playerId = candidate.getUniqueId(); assignment.playerName = candidate.getName(); - assignment.characterId = character.getId(); + assignment.characterId = characterId; try { if (characterAptitudes == null) throw new java.io.IOException("Character aptitude registry is unavailable"); - int aptitude = characterAptitude(candidate, character); + int aptitude = characterAptitude(candidate, characterId); if (!completeAndSaveAppointment(faction, assignment, aptitude)) { actor.sendMessage("\u00a7cThe appointment could not be saved. The office and treasury are unchanged."); return false; @@ -244,18 +245,10 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) { return true; } - private static int characterAptitude(Player player, RPCharacter character) throws java.io.IOException { + private static int characterAptitude(Player player, String characterId) throws java.io.IOException { if (characterAptitudes == null) throw new java.io.IOException("Character aptitude registry is unavailable"); - return characterAptitudes.aptitude(character.getId(), () -> { - Map attributes = new HashMap<>(); - var mmoAttributes = net.Indyuce.mmocore.api.player.PlayerData.get(player).getAttributes(); - for (String attribute : EspionageConfig.DEFAULT_WEIGHTS.keySet()) { - var instance = mmoAttributes.getInstance(attribute); - attributes.put(attribute, instance == null - ? character.getAttributeData().getAmount(new AttributeModifier(attribute, 0)) : instance.getBase()); - } - return EspionageMath.aptitude(attributes, ThreadLocalRandom.current()); - }); + return characterAptitudes.aptitude(characterId, () -> + EspionageMath.aptitude(OfficeCharacters.attributes(player), ThreadLocalRandom.current())); } public static boolean remove(Player actor, Faction faction) { @@ -315,24 +308,22 @@ public static void refreshReports(Player viewer) { Faction observer = FactionManager.getByMember(viewer.getName()); if (observer == null) return; boolean updated = false; + java.util.Set dirty = new java.util.LinkedHashSet<>(); 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); + generateReport(observer, target, dirty); updated = true; } } + for (Faction faction : dirty) new Database().saveFaction(faction); if (updated) viewer.sendMessage("§8§oYour faction's unseen network delivers today's sealed intelligence reports."); } - private static IntelligenceReport generateReport(Faction observer, Faction target) { - return generateReport(observer, target, true); - } - - private static IntelligenceReport generateReport(Faction observer, Faction target, boolean persist) { + private static IntelligenceReport generateReport(Faction observer, Faction target, java.util.Set dirty) { long day = LocalDate.now(ZoneOffset.UTC).toEpochDay(); - var attacker = spymaster(observer); - var defender = spymaster(target); + var attacker = spymaster(observer, dirty); + var defender = spymaster(target, dirty); boolean[] created = {false}; RandomGenerator random = ThreadLocalRandom.current(); IntelligenceReport report = observer.getEspionage().report(target.getId(), target.getFoundedAt(), day, () -> { @@ -345,10 +336,9 @@ private static IntelligenceReport generateReport(Faction observer, Faction targe ReportDetails.capture(generated, target); return generated; }); - if (created[0] && persist) { - Database database = new Database(); - database.saveFaction(target); - database.saveFaction(observer); + if (created[0]) { + dirty.add(target); + dirty.add(observer); } return report; } @@ -368,11 +358,11 @@ static java.util.List sample(java.util.List members, int margin, 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); - report.members = sample.stream().map(name -> CharacterNames.of(name) + " §7— " + 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()) { report.guildMembers.put(guild.getId(), sample.stream().filter(guild::isMember) - .filter(name -> !guild.isLeader(name)).map(CharacterNames::of).toList()); + .filter(name -> !guild.isLeader(name)).map(CharacterNames::forForeign).toList()); } report.roster = new java.util.ArrayList<>(); for (var guild : target.getGuildHandler().getGuilds()) { @@ -385,7 +375,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.of(name), guild.getId(), guild.getName(), sample.contains(name), + report.roster.add(new IntelligenceReport.RosterMember(CharacterNames.forForeign(name), guild.getId(), guild.getName(), sample.contains(name), knownLeader, knownOffice ? offices : java.util.List.of())); } } @@ -415,7 +405,7 @@ static void captureOffices(IntelligenceReport report, Faction target, RandomGene for (var office : SpecialPosition.values()) { var holder = target.getEspionage().holder(office); if (report.allows("office-holder")) report.officeHolders.put(office, - holder == null ? "Vacant" : CharacterNames.of(holder.playerName)); + holder == null ? "Vacant" : CharacterNames.forForeign(holder.playerName)); String key = IntelligenceReport.officeAptitudeKey(office); if (!report.allows(key)) continue; double aptitude = office == SpecialPosition.SPYMASTER ? effectiveAptitude(target, holder) @@ -431,14 +421,15 @@ public static int regenerateReports() { var factions = FactionManager.getCopy(); for (var faction : factions) faction.getEspionage().resetReportsAndRolls(); int count = 0; + java.util.Set dirty = new java.util.LinkedHashSet<>(factions); for (var observer : factions) { for (var target : factions) if (!observer.getId().equals(target.getId())) { - generateReport(observer, target, false); + generateReport(observer, target, dirty); count++; } - // Also persist resets for a lone faction, which has no foreign targets. - new Database().saveFaction(observer); } + // Persist every reset/report once, including a lone faction with no foreign targets. + for (Faction faction : factions) new Database().saveFaction(faction); return count; } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java index ab7caba9..75bc8b88 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java @@ -12,6 +12,10 @@ public void onCharacterDeath(CharacterPermakillEvent event) { if (event.isCancelled()) return; // RPCharacters fires before committing death. Confirm the resulting status next tick. SimpleFactions.plugin.getServer().getScheduler().runTask(SimpleFactions.plugin, - () -> EspionageService.characterDied(event.getPlayer(), event.getCharacter())); + () -> { + var character = event.getCharacter(); + if (character.getStatus() == net.tfminecraft.rpcharacters.enums.Status.DEAD) + EspionageService.characterDied(event.getPlayer(), character.getId(), character.getName()); + }); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacters.java b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacters.java new file mode 100644 index 00000000..ca76c625 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacters.java @@ -0,0 +1,66 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +/** Keep optional roleplay/attribute APIs out of the office service's signatures. */ +final class OfficeCharacters { + private OfficeCharacters() {} + + static boolean enabled(String plugin) { + return Bukkit.getServer() != null && Bukkit.getPluginManager() != null + && Bukkit.getPluginManager().isPluginEnabled(plugin); + } + + static String activeCharacterId(Player player) { + return player == null || !enabled("RPCharacters") ? null : Roleplay.activeId(player); + } + + static boolean isDead(UUID owner, String characterId) { + return owner != null && characterId != null && enabled("RPCharacters") + && Roleplay.isDead(owner, characterId); + } + + static Map attributes(Player player) { + return !enabled("RPCharacters") ? Map.of() : Roleplay.attributes(player); + } + + private static final class Roleplay { + static String activeId(Player player) { + var data = net.tfminecraft.rpcharacters.managers.PlayerManager.get(player); + var character = data == null ? null : data.getActiveCharacter(); + return character == null ? null : character.getId(); + } + + static boolean isDead(UUID owner, String id) { + var data = net.tfminecraft.rpcharacters.managers.PlayerManager.get(owner); + return data != null && data.getCharacters().stream().anyMatch(character -> + id.equals(character.getId()) && character.getStatus() == net.tfminecraft.rpcharacters.enums.Status.DEAD); + } + + static Map attributes(Player player) { + var data = net.tfminecraft.rpcharacters.managers.PlayerManager.get(player); + var character = data == null ? null : data.getActiveCharacter(); + if (character == null) throw new IllegalStateException("An active character is required for an aptitude roll"); + Map result = new LinkedHashMap<>(); + for (String attribute : EspionageConfig.DEFAULT_WEIGHTS.keySet()) result.put(attribute, + character.getAttributeData().getAmount(new net.tfminecraft.rpcharacters.objects.attributes.AttributeModifier(attribute, 0))); + // RPCharacters supplies the permanent values when MMOCore is absent. + if (enabled("MMOCore")) Attributes.overlay(player, result); + return result; + } + } + + private static final class Attributes { + static void overlay(Player player, Map result) { + var attributes = net.Indyuce.mmocore.api.player.PlayerData.get(player).getAttributes(); + for (String attribute : result.keySet()) { + var instance = attributes.getInstance(attribute); + if (instance != null) result.put(attribute, instance.getBase()); + } + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java b/src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java index a552fae8..130505d5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/ReportDetails.java @@ -31,7 +31,7 @@ static void capture(IntelligenceReport report, Faction target) { report.details.put("construction", pending == null ? List.of() : List.of((pending.isUpgrade() ? "Upgrading " : "Building ") + pending.getName())); } if (report.allows("guild-leader")) for (var guild : target.getGuildHandler().getGuilds()) - report.details.put("guild-leader:" + guild.getId(), List.of(CharacterNames.of(guild.getLeader()))); + report.details.put("guild-leader:" + guild.getId(), List.of(CharacterNames.forForeign(guild.getLeader()))); if (report.allows("upgrades")) for (var guild : target.getGuildHandler().getGuilds()) report.details.put("upgrading:" + guild.getId(), guild.getUpgradeQueue().stream() .map(entry -> entry.getUpgrade().getName()).limit(3).toList()); diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java b/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java index 88afd289..505c5b9a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java @@ -19,12 +19,12 @@ private static List build(Player viewer, Faction faction, Guild only) { var report = exact ? null : EspionageService.report(viewer, faction); List lore = new ArrayList<>(); String title = faction.getRulerTitle(); - lore.add("#e8c55a\u00a7l" + (title == null || title.isBlank() ? "Leader" : title) + ": #f4e4aa" + CharacterNames.display(viewer, faction.getLeader())); + lore.add("#e8c55a\u00a7l" + (title == null || title.isBlank() ? "Leader" : title) + ": #f4e4aa" + CharacterNames.display(viewer, faction.getLeader(), faction)); List leaderOffices = exact ? java.util.Arrays.stream(SpecialPosition.values()).filter(office -> { var holder = faction.getEspionage().holder(office); return holder != null && holder.playerName.equalsIgnoreCase(faction.getLeader()); }).map(SpecialPosition::label).toList() : report == null ? List.of() : report.details("office-holder", "leader-offices"); - for (String office : leaderOffices) lore.add("#93c9a7 " + office + ": #c2daca" + CharacterNames.display(viewer, faction.getLeader())); + for (String office : leaderOffices) lore.add("#93c9a7 " + office + ": #c2daca" + CharacterNames.display(viewer, faction.getLeader(), faction)); List guilds = new ArrayList<>(only == null ? faction.getGuildHandler().getGuilds() : List.of(only)); guilds.sort(Comparator.comparing((Guild guild) -> !guild.isBase()).thenComparing(Comparator.comparing((Guild guild) -> { var wealth = exact ? guild.getWealth() : EspionageService.visibleValue(viewer, faction, diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java index 065cb2cd..34678dfd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -159,7 +159,7 @@ public static ItemStack factionItem(Player viewer, Faction target, MenuItemType return switch (type) { case GOVERNMENT -> { ItemStack head = styled(Material.PLAYER_HEAD, "#93c9a7Government:", - "#9c9775§l" + target.getRulerTitle() + ": #c2bea7" + CharacterNames.display(viewer, target.getLeader()), + "#9c9775§l" + target.getRulerTitle() + ": #c2bea7" + CharacterNames.display(viewer, target.getLeader(), target), "#85c265Administrative Power§7: §e" + value(report, "Administrative power", ""), "#85c265Stability: " + IntelligenceReport.stabilityRange(report), " ", "#b8ae61Ruling System: #d4c9ae" + target.getGovernmentString()); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java index 4f33c24c..92af3b21 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java @@ -93,7 +93,7 @@ public List listLore(Player p, Faction f) { if(realmSize > 0 && realmSize-f.getProvinces().size() > 0) lore.add(StringFormatter.formatHex("#d4c9aeRealm Size: #7a706a"+realmSize+" #a39ba8("+(realmSize-f.getProvinces().size())+" from subjects)")); else if(realmSize > 0) lore.add(StringFormatter.formatHex("#d4c9aeRealm Size: #7a706a"+realmSize)); lore.add(" "); - lore.add(StringFormatter.formatHex("#9c9775"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader()))); + lore.add(StringFormatter.formatHex("#9c9775"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader(), f))); lore.add(StringFormatter.formatHex("#b8ae61Ruling System: #d4c9ae"+f.getGovernmentString())); lore.add(StringFormatter.formatHex("#b8ae61Culture: #d4c9ae"+f.getCulture())); lore.add(StringFormatter.formatHex("#b8ae61Religion: #d4c9ae"+f.getReligion())); @@ -291,7 +291,7 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { i = new ItemStack(Material.PLAYER_HEAD, 1); SkullMeta m = (SkullMeta) i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - m.setDisplayName(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader()))); + m.setDisplayName(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader(), f))); m.setOwningPlayer(Bukkit.getOfflinePlayer(f.getLeader())); List lore = new ArrayList(); lore.add(StringFormatter.formatHex("#b8ae61Ruling System: #d4c9ae"+f.getGovernmentString())); @@ -305,7 +305,7 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { m.setOwningPlayer(Bukkit.getOfflinePlayer(f.getLeader())); List lore = new ArrayList(); Government gov = f.getGovernment(); - lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader()))); + lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+CharacterNames.display(p, f.getLeader(), f))); double power = Formatter.formatDouble(gov.getPower()); double maxPower = Formatter.formatDouble(gov.getMaxPower()); String powerString = ((power < 0) ? "§c" : "") + power+"/"+((maxPower < 0) ? "§c" : "") + maxPower; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java index 3ff737c3..1925ef25 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java @@ -67,7 +67,7 @@ public ItemStack createGovernmentItem(Player p, Faction f) { m.setDisplayName(StringFormatter.formatHex("#93c9a7Government:")); List lore = new ArrayList(); Government gov = f.getGovernment(); - lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+net.tfminecraft.simplefactions.espionage.CharacterNames.display(p, f.getLeader()))); + lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+net.tfminecraft.simplefactions.espionage.CharacterNames.display(p, f.getLeader(), f))); double power = Formatter.formatDouble(gov.getPower()); double maxPower = Formatter.formatDouble(gov.getMaxPower()); String powerString = ((power < 0) ? "§c" : "") + power+"/"+((maxPower < 0) ? "§c" : "") + maxPower; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java index 4a389343..e1123e3c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java @@ -45,7 +45,7 @@ public static ItemStack mask(ItemStack template, String title, List lore public static String guildLeader(Player viewer, Guild guild) { if (EspionageService.canViewExact(viewer, guild.getFaction()) || guild.getFaction().isLeader(guild.getLeader())) - return CharacterNames.display(viewer, guild.getLeader()); + return CharacterNames.display(viewer, guild.getLeader(), guild.getFaction()); var report = EspionageService.report(viewer, guild.getFaction()); var names = report == null ? List.of() : report.details("guild-leader", "guild-leader:" + guild.getId()); return names.isEmpty() ? "\u00a77Unknown" : names.getFirst(); @@ -121,7 +121,7 @@ public static void government(Inventory inventory, Player viewer, Faction factio prepare(inventory); var report = EspionageService.report(viewer, faction); inventory.setItem(10, mask(new ItemStack(Material.WRITABLE_BOOK), "#93c9a7Government:", List.of( - "#9c9775" + faction.getRulerTitle() + ": #c2bea7" + CharacterNames.display(viewer, faction.getLeader()), + "#9c9775" + faction.getRulerTitle() + ": #c2bea7" + CharacterNames.display(viewer, faction.getLeader(), faction), "#85c265Administrative Power: " + (report == null ? "Unknown" : report.display("Administrative power")), "", "#b8ae61Ruling System: #d4c9ae" + faction.getGovernmentString()))); inventory.setItem(11, mask(IconGetter.getIconOrDefault("stability", Material.GREEN_DYE), diff --git a/src/test/java/net/tfminecraft/simplefactions/SimpleFactionsShutdownTest.java b/src/test/java/net/tfminecraft/simplefactions/SimpleFactionsShutdownTest.java new file mode 100644 index 00000000..38ff96bd --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/SimpleFactionsShutdownTest.java @@ -0,0 +1,31 @@ +package net.tfminecraft.simplefactions; + +import static org.mockito.Mockito.*; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; + +class SimpleFactionsShutdownTest { + @Test void failedStartupDoesNotSavePartiallyRestoredFactionsOrTimer() throws Exception { + var plugin = mock(SimpleFactions.class, CALLS_REAL_METHODS); + var database = mock(Database.class); + var field = SimpleFactions.class.getDeclaredField("db"); + field.setAccessible(true); field.set(plugin, database); + var previous = FactionManager.factions; + var faction = mock(Faction.class); + FactionManager.factions = new ArrayList<>(List.of(faction)); + try (var manager = mockStatic(FactionManager.class)) { + plugin.saveLoadedFactions(); + verifyNoInteractions(database); + manager.when(FactionManager::isLoaded).thenReturn(true); + manager.when(FactionManager::getTimer).thenReturn(100); + manager.when(FactionManager::getDay).thenReturn(4); + plugin.saveLoadedFactions(); + verify(database).saveTimer(100, 4); + verify(database).saveFaction(faction); + } finally { FactionManager.factions = previous; } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java index b1c5dab7..bfd089cc 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java @@ -7,6 +7,34 @@ import org.junit.jupiter.api.Test; class CharacterNameResolutionTest { + @org.junit.jupiter.api.io.TempDir java.nio.file.Path folder; + + @Test void foreignFallbackHidesAccountsOnlyWhenRoleplayIsEnabled() { + var account = "NoActiveCharacterAccount"; + var viewer = player("ForeignViewer"); + var faction = mock(net.tfminecraft.simplefactions.objects.Faction.class); + var plugins = mock(org.bukkit.plugin.PluginManager.class); + var roleplay = mock(org.bukkit.plugin.Plugin.class); + var offline = mock(org.bukkit.OfflinePlayer.class); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + when(plugins.getPlugin("RPCharacters")).thenReturn(roleplay); + when(roleplay.getDataFolder()).thenReturn(folder.toFile()); + when(offline.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(mock(org.bukkit.Server.class)); + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(() -> org.bukkit.Bukkit.getOfflinePlayer(account)).thenReturn(offline); + assertEquals("Unknown", CharacterNames.forForeign(account)); + assertEquals("Unknown", CharacterNames.display(viewer, account, faction)); + assertEquals(account, CharacterNames.of(account), "Invitations keep account fallback"); + when(faction.isMemberIgnoreCase("ForeignViewer")).thenReturn(true); + assertEquals(account, CharacterNames.display(viewer, account, faction)); + when(viewer.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true); + assertEquals(account + " \u00a77(" + account + ")", CharacterNames.display(viewer, account, faction)); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(false); + assertEquals(account, CharacterNames.forForeign(account), "Servers without RPCharacters keep account names"); + } + } @org.junit.jupiter.api.Test void invitationCompletionReturnsOnlyCurrentTokenAndGuardsPastEndOfName() { var names = java.util.List.of("Lady Raven", "Lady Rose", "Lady", "Account", "Lady Raven"); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java index 384ba939..f85c4192 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java @@ -47,15 +47,34 @@ void browsingOnlyReadsCacheAndMenuRefreshIsSharedOncePerDay() { var report = EspionageService.report(first, target); assertNotNull(report); assertSame(report, EspionageService.report(second, target)); - assertEquals(1, databases.constructed().size()); + assertEquals(2, databases.constructed().size()); EspionageService.refreshReports(second); assertSame(report, EspionageService.report(second, target)); - assertEquals(1, databases.constructed().size(), "A second member cannot reroll the faction's day"); + assertEquals(2, databases.constructed().size(), "A second member cannot reroll the faction's day"); verify(first, times(1)).sendMessage(anyString()); verify(second, never()).sendMessage(anyString()); } } + @Test + void dailyRefreshBatchesObserverAndTargetsAndSkipsUnchangedReports() { + Player viewer = mock(Player.class); + when(viewer.getName()).thenReturn("Viewer"); + Faction observer = faction("observer"), first = faction("first"), second = faction("second"); + try (var factions = mockStatic(FactionManager.class); var databases = mockConstruction(Database.class)) { + factions.when(() -> FactionManager.getByMember("Viewer")).thenReturn(observer); + factions.when(FactionManager::getCopy).thenAnswer(ignored -> new ArrayList<>(List.of(observer, first, second))); + EspionageService.refreshReports(viewer); + assertEquals(3, databases.constructed().size()); + verify(databases.constructed().get(0)).saveFaction(first); + verify(databases.constructed().get(1)).saveFaction(observer); + verify(databases.constructed().get(2)).saveFaction(second); + EspionageService.refreshReports(viewer); + assertEquals(3, databases.constructed().size()); + for (Database database : databases.constructed()) verifyNoMoreInteractions(database); + } + } + @Test void prestigeIsPublicAndWealthRankingUsesOnlyKnownMidpoints() { Player viewer = mock(Player.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java index bbea38fc..5b1ebd2d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligencePolicyTest.java @@ -65,7 +65,7 @@ class IntelligencePolicyTest { holder.playerName = "Account"; state.appoint(holder, 80); try (var names = mockStatic(CharacterNames.class)) { - names.when(() -> CharacterNames.of("Account")).thenReturn("Lady Raven"); + names.when(() -> CharacterNames.forForeign("Account")).thenReturn("Lady Raven"); for (int margin : new int[]{30, 65, 100}) { var report = EspionageService.createReport(Map.of(), margin, new Random(1)); EspionageService.captureOffices(report, target, new Random(1)); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java index d2a78477..bbfecf50 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java @@ -10,7 +10,7 @@ class OfficeAppointmentsTest { @Test - void newSoloFactionAssignsFounderWithoutChargingOrUsingTheFreeAppointment() { + void newSoloFactionWaitsForACharacterWithoutUsingTheFreeAppointment() { var faction = mock(Faction.class); var state = new EspionageState(); when(faction.getEspionage()).thenReturn(state); @@ -23,8 +23,8 @@ void newSoloFactionAssignsFounderWithoutChargingOrUsingTheFreeAppointment() { bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(mock(org.bukkit.Server.class)); bukkit.when(() -> org.bukkit.Bukkit.getOfflinePlayer("Founder")).thenReturn(offline); EspionageService.initializeFounder(faction); - assertEquals("Founder", state.getSpymaster().playerName); - assertTrue(state.getSpymaster().automatic); + assertNull(state.getSpymaster()); + assertTrue(state.hasPendingFounder()); assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); verify(faction, never()).getBank(); state.removeSpymaster(); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeOptionalIntegrationsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeOptionalIntegrationsTest.java new file mode 100644 index 00000000..ef3a2de9 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeOptionalIntegrationsTest.java @@ -0,0 +1,89 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.net.URLClassLoader; +import java.util.List; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.objects.Faction; + +class OfficeOptionalIntegrationsTest { + @Test void roleplayAttributesRemainAvailableWithoutMmocore() { + var plugins = mock(PluginManager.class); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + var player = mock(Player.class); + var data = mock(net.tfminecraft.rpcharacters.objects.PlayerData.class); + var character = mock(net.tfminecraft.rpcharacters.objects.RPCharacter.class); + var attributes = mock(net.tfminecraft.rpcharacters.objects.attributes.AttributeData.class); + when(data.getActiveCharacter()).thenReturn(character); + when(character.getId()).thenReturn("active-character"); + when(character.getAttributeData()).thenReturn(attributes); + when(attributes.getAmount(any(net.tfminecraft.rpcharacters.objects.attributes.AttributeModifier.class))).thenReturn(12); + try (var bukkit = mockStatic(Bukkit.class); + var players = mockStatic(net.tfminecraft.rpcharacters.managers.PlayerManager.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + players.when(() -> net.tfminecraft.rpcharacters.managers.PlayerManager.get(player)).thenReturn(data); + assertEquals("active-character", OfficeCharacters.activeCharacterId(player)); + var values = OfficeCharacters.attributes(player); + assertEquals(EspionageConfig.DEFAULT_WEIGHTS.keySet(), values.keySet()); + assertTrue(values.values().stream().allMatch(value -> value == 12)); + verify(plugins).isPluginEnabled("MMOCore"); + } + } + + @Test void absentRoleplayLeavesFounderPendingAndDoesNotRevokeLoadedHolder() { + var plugins = mock(PluginManager.class); + var founder = mock(Player.class); + var faction = mock(Faction.class); + var state = new EspionageState(); + when(faction.getEspionage()).thenReturn(state); + when(faction.getLeader()).thenReturn("Founder"); + when(faction.isLeader("Founder")).thenReturn(true); + when(faction.getMembers()).thenReturn(List.of("Founder")); + when(founder.getUniqueId()).thenReturn(UUID.randomUUID()); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(() -> Bukkit.getPlayerExact("Founder")).thenReturn(founder); + EspionageService.initializeFounder(faction); + assertTrue(state.hasPendingFounder()); + assertNull(state.getSpymaster()); + assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); + var holder = new SpecialPositionAssignment(); + holder.playerId = founder.getUniqueId(); holder.characterId = "founder"; holder.playerName = "Founder"; + state.assignFounder(SpecialPosition.SPYMASTER, holder, 80); + assertSame(holder, EspionageService.spymaster(faction)); + assertTrue(EspionageService.stabilityModifiers(faction, 0).isEmpty()); + } + } + + @Test void officeServiceLoadsAndReflectsWithoutOptionalPluginClasses() throws Exception { + var classes = EspionageService.class.getProtectionDomain().getCodeSource().getLocation(); + try (var loader = new URLClassLoader(new java.net.URL[]{classes}, getClass().getClassLoader()) { + @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("net.tfminecraft.rpcharacters.") || name.startsWith("net.Indyuce.mmocore.")) + throw new ClassNotFoundException("Optional plugin absent: " + name); + if (name.startsWith("net.tfminecraft.simplefactions.espionage.")) { + Class loaded = findLoadedClass(name); + if (loaded == null) loaded = findClass(name); + if (resolve) resolveClass(loaded); + return loaded; + } + return super.loadClass(name, resolve); + } + }) { + var service = Class.forName(EspionageService.class.getName(), true, loader); + assertDoesNotThrow(service::getDeclaredMethods); + var bridge = Class.forName(OfficeCharacters.class.getName(), true, loader); + var active = bridge.getDeclaredMethod("activeCharacterId", Player.class); + active.setAccessible(true); + assertNull(active.invoke(null, new Object[]{null})); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java index 0f1b5c8d..14b6cf30 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java @@ -110,6 +110,9 @@ class OfficePersistenceTest { field.set(null, registry); net.tfminecraft.simplefactions.SimpleFactions.plugin = plugin; bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(mock(org.bukkit.Server.class)); + var plugins = mock(org.bukkit.plugin.PluginManager.class); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(plugins); bukkit.when(() -> org.bukkit.Bukkit.getPlayerExact("Founder")).thenReturn(founder); players.when(() -> net.tfminecraft.rpcharacters.managers.PlayerManager.get(founder)).thenReturn(player); EspionageService.initializeFounder(faction); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java index d986d1ee..b93132ea 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java @@ -70,14 +70,10 @@ class OfficeVacancyTest { when(character.getStatus()).thenReturn(Status.DEAD); try (var factions = mockStatic(FactionManager.class); var databases = mockConstruction(Database.class)) { factions.when(FactionManager::getCopy).thenReturn(List.of(faction)); - EspionageService.characterDied(null, character); + EspionageService.characterDied(null, character.getId(), "Spy"); assertSame(holder, state.getSpymaster()); when(character.getId()).thenReturn("appointed"); - when(character.getStatus()).thenReturn(Status.ALIVE); - EspionageService.characterDied(null, character); - assertSame(holder, state.getSpymaster()); - when(character.getStatus()).thenReturn(Status.DEAD); - EspionageService.characterDied(null, character); + EspionageService.characterDied(null, character.getId(), "Spy"); assertNull(state.getSpymaster()); assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); verify(databases.constructed().getFirst()).saveFaction(faction); @@ -100,8 +96,14 @@ class OfficeVacancyTest { .thenAnswer(call -> { task.set(call.getArgument(1)); return null; }); listener.onCharacterDeath(event); service.verifyNoInteractions(); + when(character.getStatus()).thenReturn(Status.ALIVE); + task.get().run(); + service.verifyNoInteractions(); + when(character.getStatus()).thenReturn(Status.DEAD); + when(character.getId()).thenReturn("appointed"); + when(character.getName()).thenReturn("Spy"); task.get().run(); - service.verify(() -> EspionageService.characterDied(owner, character)); + service.verify(() -> EspionageService.characterDied(owner, "appointed", "Spy")); } finally { SimpleFactions.plugin = previous; } } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java index 0d7f620b..07b13913 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java @@ -90,7 +90,7 @@ class ReportPresentationTest { report.roster = List.of(new IntelligenceReport.RosterMember("Known Member", "rich", "Rich Guild", true, false, List.of()), new IntelligenceReport.RosterMember("Secret Spy", "poor", "Poor Guild", false, false, List.of(SpecialPosition.SPYMASTER)), new IntelligenceReport.RosterMember("Guild Baron", "rich", "Rich Guild", false, true, List.of())); - try (var service = mockStatic(EspionageService.class); var names = mockStatic(CharacterNames.class)) { + try (var service = mockStatic(EspionageService.class); var names = mockStatic(CharacterNames.class, CALLS_REAL_METHODS)) { service.when(() -> EspionageService.report(viewer, faction)).thenReturn(report); service.when(() -> EspionageService.visibleValue(eq(viewer), eq(faction), anyString(), any())) .thenAnswer(call -> call.getArgument(2).toString().contains("rich") ? 100.0 : 10.0); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java index 2af3b912..a16c634c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java @@ -21,7 +21,7 @@ class RosterAndLedgerPresentationTest { when(realm.getFaction()).thenReturn(faction); when(rich.getName()).thenReturn("Rich Guild"); when(rich.getWealth()).thenReturn(1000.0); when(poor.getName()).thenReturn("Poor Guild"); when(poor.getWealth()).thenReturn(100.0); - try (var service = mockStatic(EspionageService.class); var names = mockStatic(CharacterNames.class)) { + try (var service = mockStatic(EspionageService.class); var names = mockStatic(CharacterNames.class, CALLS_REAL_METHODS)) { service.when(() -> EspionageService.canViewExact(viewer, faction)).thenReturn(true); names.when(() -> CharacterNames.display(viewer, "LeaderAccount")).thenReturn("Rowan"); String text = String.join("\n", RosterLore.faction(viewer, faction)); From d852d663467747777c544cb048f6074a56bd083f Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:56:30 +0200 Subject: [PATCH 11/13] Bind pending founder offices to their character identity --- ESPIONAGE.md | 2 ++ .../espionage/EspionageService.java | 5 ++-- .../espionage/EspionageState.java | 14 +++++++-- .../espionage/OfficePersistenceTest.java | 5 +++- .../espionage/OfficeVacancyTest.java | 30 +++++++++++++++++++ 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/ESPIONAGE.md b/ESPIONAGE.md index 330a705a..ef0305b4 100644 --- a/ESPIONAGE.md +++ b/ESPIONAGE.md @@ -302,3 +302,5 @@ RPCharacters permanent character death removes that character's Spymaster office preserving paid appointment history. Ordinary Minecraft respawns and cancelled character deaths do not remove the office. Removal is confirmed after the death event commits, and loaded dead-character assignments are rejected when checked. +Pending founder appointments retain their character identity when an aptitude +save fails; an unrelated character death cannot cancel that pending appointment. diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 142a1e29..0dabb5d4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -68,7 +68,7 @@ public static void initializeFounder(Faction faction) { } catch (java.io.IOException exception) { net.tfminecraft.simplefactions.SimpleFactions.plugin.getLogger().log(java.util.logging.Level.SEVERE, "Could not save founder aptitude", exception); - faction.getEspionage().pendingFounder(office); + faction.getEspionage().pendingFounder(office, characterId); if (founder != null) founder.sendMessage("\u00a7cYour founding office could not be initialized. It will be retried when the office is checked."); continue; } @@ -163,7 +163,8 @@ public static void characterDied(Player owner, String characterId, String charac var holder = state.getSpymaster(); boolean matching = holder != null && (characterId.equals(holder.characterId) || holder.characterId == null && owner != null && holder.isHolder(owner.getUniqueId())); - boolean pending = state.isPendingFounder(SpecialPosition.SPYMASTER) && owner != null && faction.isLeader(owner.getName()); + boolean pending = state.isPendingFounder(SpecialPosition.SPYMASTER) && owner != null && faction.isLeader(owner.getName()) + && characterId.equals(state.pendingFounderCharacter(SpecialPosition.SPYMASTER)); if (!matching && !pending) continue; state.removeSpymaster(); new Database().saveFaction(faction); diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java index 5e517b9e..74477166 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java @@ -15,14 +15,20 @@ public class EspionageState { private Map appointments = new EnumMap<>(SpecialPosition.class); private java.util.List unrest = new java.util.ArrayList<>(); private java.util.Set pendingFounders = java.util.EnumSet.noneOf(SpecialPosition.class); + private Map pendingFounderCharacters = new EnumMap<>(SpecialPosition.class); void pendingFounder(SpecialPosition office) { pendingFounders.add(office); } + void pendingFounder(SpecialPosition office, String characterId) { + pendingFounder(office); + if (characterId != null) pendingFounderCharacters.put(office, characterId); + } + String pendingFounderCharacter(SpecialPosition office) { return pendingFounderCharacters.get(office); } boolean hasPendingFounder() { return !pendingFounders.isEmpty(); } boolean isPendingFounder(SpecialPosition office) { return pendingFounders.contains(office); } record OfficeSnapshot(Map positions, Map appointments, java.util.List unrest, - java.util.Set pendingFounders) {} + java.util.Set pendingFounders, Map pendingFounderCharacters) {} OfficeSnapshot snapshotOffices() { var savedPositions = new EnumMap(SpecialPosition.class); @@ -30,7 +36,7 @@ OfficeSnapshot snapshotOffices() { var savedAppointments = new EnumMap(SpecialPosition.class); savedAppointments.putAll(appointments); return new OfficeSnapshot(savedPositions, savedAppointments, - new java.util.ArrayList<>(unrest), new java.util.HashSet<>(pendingFounders)); + new java.util.ArrayList<>(unrest), new java.util.HashSet<>(pendingFounders), new HashMap<>(pendingFounderCharacters)); } void restoreOffices(OfficeSnapshot snapshot) { @@ -38,6 +44,7 @@ void restoreOffices(OfficeSnapshot snapshot) { appointments.clear(); appointments.putAll(snapshot.appointments()); unrest.clear(); unrest.addAll(snapshot.unrest()); pendingFounders.clear(); pendingFounders.addAll(snapshot.pendingFounders()); + pendingFounderCharacters.clear(); pendingFounderCharacters.putAll(snapshot.pendingFounderCharacters()); } public SpecialPositionAssignment holder(SpecialPosition office) { return positions.get(office); } @@ -55,6 +62,7 @@ public void assignFounder(SpecialPosition office, SpecialPositionAssignment assi assignment.aptitude = EspionageMath.clamp(aptitude, 0, 100); positions.put(office, assignment); pendingFounders.remove(office); + pendingFounderCharacters.remove(office); } public void addUnrest(SpecialPosition office, double points, double days, long now) { @@ -81,6 +89,7 @@ public SpecialPositionAssignment getSpymaster() { public void removeSpymaster() { pendingFounders.remove(SpecialPosition.SPYMASTER); + pendingFounderCharacters.remove(SpecialPosition.SPYMASTER); appointments.put(SpecialPosition.SPYMASTER, appointmentCount(SpecialPosition.SPYMASTER)); positions.remove(SpecialPosition.SPYMASTER); } @@ -98,6 +107,7 @@ void appoint(SpecialPositionAssignment assignment, Supplier aptitudeRol public void appoint(SpecialPositionAssignment assignment, int permanentAptitude) { pendingFounders.remove(SpecialPosition.SPYMASTER); + pendingFounderCharacters.remove(SpecialPosition.SPYMASTER); appointments.put(SpecialPosition.SPYMASTER, appointmentCount(SpecialPosition.SPYMASTER) + 1); assignment.automatic = false; assignment.aptitude = EspionageMath.clamp(permanentAptitude, 0, 100); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java index 14b6cf30..b9bee94f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java @@ -119,7 +119,9 @@ class OfficePersistenceTest { assertNull(state.getSpymaster()); assertTrue(state.hasPendingFounder()); assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); - assertTrue(JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(state), EspionageState.class).hasPendingFounder()); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(state), EspionageState.class); + assertTrue(restored.hasPendingFounder()); + assertEquals("founder-character", restored.pendingFounderCharacter(SpecialPosition.SPYMASTER)); bukkit.when(() -> org.bukkit.Bukkit.getPlayerExact("Founder")).thenReturn(null); EspionageService.initializeFounder(faction); assertNull(state.getSpymaster(), "Offline retry must not finalize a zero-aptitude office"); @@ -128,6 +130,7 @@ class OfficePersistenceTest { assertEquals(84, EspionageService.spymaster(faction).aptitude); assertEquals("founder-character", state.getSpymaster().characterId); assertFalse(state.hasPendingFounder()); + assertNull(state.pendingFounderCharacter(SpecialPosition.SPYMASTER)); assertTrue(state.getSpymaster().automatic); assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); } finally { diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java index b93132ea..f562efca 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java @@ -80,6 +80,36 @@ class OfficeVacancyTest { } } + @Test void pendingFounderDeathOnlyRevokesTheAssociatedCharacterAndKeepsFreeAppointment() { + var faction = mock(Faction.class); + var owner = mock(Player.class); + var state = new EspionageState(); + when(faction.getEspionage()).thenReturn(state); + when(owner.getName()).thenReturn("Founder"); + when(faction.isLeader("Founder")).thenReturn(true); + state.pendingFounder(SpecialPosition.SPYMASTER, "founder-character"); + var snapshot = state.snapshotOffices(); + state.removeSpymaster(); + state.restoreOffices(snapshot); + assertEquals("founder-character", state.pendingFounderCharacter(SpecialPosition.SPYMASTER)); + try (var factions = mockStatic(FactionManager.class); var databases = mockConstruction(Database.class)) { + factions.when(FactionManager::getCopy).thenReturn(List.of(faction)); + EspionageService.characterDied(owner, "unrelated-character", "Other"); + assertTrue(state.hasPendingFounder()); + assertTrue(databases.constructed().isEmpty()); + verify(owner, never()).sendMessage(anyString()); + EspionageService.characterDied(owner, "founder-character", "Founder"); + assertFalse(state.hasPendingFounder()); + assertNull(state.pendingFounderCharacter(SpecialPosition.SPYMASTER)); + assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); + verify(databases.constructed().getFirst()).saveFaction(faction); + state.pendingFounder(SpecialPosition.SPYMASTER); + EspionageService.characterDied(owner, "unrelated-character", "Other"); + assertTrue(state.hasPendingFounder(), "A founder with no character identity cannot match an unrelated death"); + assertEquals(1, databases.constructed().size()); + } + } + @Test void deathListenerIgnoresCancellationAndWaitsForConfirmedCharacterStatus() { var character = mock(RPCharacter.class); var owner = mock(Player.class); var event = new CharacterPermakillEvent(owner, character, PermakillCause.OTHER); From 7cd88f6427b37e3ccaa321dec199c84f731f722f Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:10:46 +0200 Subject: [PATCH 12/13] Persist changed pending founder identities on office lookups --- ESPIONAGE.md | 2 ++ .../simplefactions/espionage/EspionageService.java | 8 +++++++- .../simplefactions/espionage/OfficePersistenceTest.java | 9 ++++++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/ESPIONAGE.md b/ESPIONAGE.md index ef0305b4..ec470311 100644 --- a/ESPIONAGE.md +++ b/ESPIONAGE.md @@ -304,3 +304,5 @@ character deaths do not remove the office. Removal is confirmed after the death event commits, and loaded dead-character assignments are rejected when checked. Pending founder appointments retain their character identity when an aptitude save fails; an unrelated character death cannot cancel that pending appointment. +Changed pending identities are saved immediately during office lookups, or once +with the faction's report batch, so they survive an unexpected process exit. diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 0dabb5d4..0a7d981f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -48,6 +48,10 @@ public static boolean eligible(Faction faction, String playerName) { /** Only new factions pass through addFaction; loading saves never reassigns offices. */ public static void initializeFounder(Faction faction) { + initializeFounder(faction, null); + } + + private static void initializeFounder(Faction faction, java.util.Set dirty) { if (faction.getEspionage() == null || org.bukkit.Bukkit.getServer() == null) return; var founder = org.bukkit.Bukkit.getPlayerExact(faction.getLeader()); String characterId = OfficeCharacters.activeCharacterId(founder); @@ -68,7 +72,9 @@ public static void initializeFounder(Faction faction) { } catch (java.io.IOException exception) { net.tfminecraft.simplefactions.SimpleFactions.plugin.getLogger().log(java.util.logging.Level.SEVERE, "Could not save founder aptitude", exception); + String previousCharacterId = faction.getEspionage().pendingFounderCharacter(office); faction.getEspionage().pendingFounder(office, characterId); + if (!characterId.equals(previousCharacterId)) saveOrMark(faction, dirty); if (founder != null) founder.sendMessage("\u00a7cYour founding office could not be initialized. It will be retried when the office is checked."); continue; } @@ -139,7 +145,7 @@ private static void saveOrMark(Faction faction, java.util.Set dirty) { private static SpecialPositionAssignment spymaster(Faction faction, java.util.Set dirty) { if (faction.getEspionage().hasPendingFounder()) { - initializeFounder(faction); + initializeFounder(faction, dirty); if (!faction.getEspionage().hasPendingFounder()) saveOrMark(faction, dirty); } SpecialPositionAssignment holder = faction.getEspionage().getSpymaster(); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java index b9bee94f..809307dd 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java @@ -97,7 +97,8 @@ class OfficePersistenceTest { when(player.getActiveCharacter()).thenReturn(character); when(character.getId()).thenReturn("founder-character"); var registry = mock(CharacterAptitudes.class); - when(registry.aptitude(eq("founder-character"), any())).thenThrow(new java.io.IOException("Test save failure")).thenReturn(84); + when(registry.aptitude(eq("founder-character"), any())).thenThrow(new java.io.IOException("Test save failure")) + .thenThrow(new java.io.IOException("Test retry failure")).thenReturn(84); var field = EspionageService.class.getDeclaredField("characterAptitudes"); field.setAccessible(true); var previous = field.get(null); @@ -122,17 +123,23 @@ class OfficePersistenceTest { var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(state), EspionageState.class); assertTrue(restored.hasPendingFounder()); assertEquals("founder-character", restored.pendingFounderCharacter(SpecialPosition.SPYMASTER)); + assertEquals(1, databases.constructed().size()); + verify(databases.constructed().getFirst()).saveFaction(faction); bukkit.when(() -> org.bukkit.Bukkit.getPlayerExact("Founder")).thenReturn(null); EspionageService.initializeFounder(faction); assertNull(state.getSpymaster(), "Offline retry must not finalize a zero-aptitude office"); assertTrue(state.hasPendingFounder()); + assertEquals(1, databases.constructed().size(), "An unchanged pending identity does not need another save"); bukkit.when(() -> org.bukkit.Bukkit.getPlayerExact("Founder")).thenReturn(founder); + assertNull(EspionageService.spymaster(faction)); + assertEquals(1, databases.constructed().size(), "Retrying the same character does not rewrite the pending identity"); assertEquals(84, EspionageService.spymaster(faction).aptitude); assertEquals("founder-character", state.getSpymaster().characterId); assertFalse(state.hasPendingFounder()); assertNull(state.pendingFounderCharacter(SpecialPosition.SPYMASTER)); assertTrue(state.getSpymaster().automatic); assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); + assertEquals(2, databases.constructed().size(), "The completed appointment is saved after its pending identity"); } finally { field.set(null, previous); net.tfminecraft.simplefactions.SimpleFactions.plugin = previousPlugin; From 1b65f87d7e492206552ec0143ff2c0368c6b6db2 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:38:42 +0200 Subject: [PATCH 13/13] Roll back failed pending founder identity saves and document name fallback --- ESPIONAGE.md | 8 ++- .../espionage/EspionageService.java | 9 ++- .../espionage/OfficePersistenceTest.java | 58 ++++++++++++++++++- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/ESPIONAGE.md b/ESPIONAGE.md index ec470311..43859128 100644 --- a/ESPIONAGE.md +++ b/ESPIONAGE.md @@ -1,7 +1,7 @@ -# Espionage (development) +# Espionage and special positions -Foreign faction and guild menus always show public identity and flavor details: -character names of faction leaders, government, rank, tier, titles, settlements, culture, +Foreign faction and guild menus show public identity and flavor details, including +faction leaders' character names when available, government, rank, tier, titles, settlements, culture, religion, guild types, allies and subjects. Prestige is public and its ranking uses exact values. Other figures are hidden or shown as intelligence ranges. Members retain exact information about their own faction and guilds. This covers @@ -306,3 +306,5 @@ Pending founder appointments retain their character identity when an aptitude save fails; an unrelated character death cannot cancel that pending appointment. Changed pending identities are saved immediately during office lookups, or once with the faction's report batch, so they survive an unexpected process exit. +Failed immediate saves restore the previous identity and keep initialization +pending, so a later lookup can retry the binding without consuming an appointment. diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 0a7d981f..fad63862 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -73,8 +73,15 @@ private static void initializeFounder(Faction faction, java.util.Set di net.tfminecraft.simplefactions.SimpleFactions.plugin.getLogger().log(java.util.logging.Level.SEVERE, "Could not save founder aptitude", exception); String previousCharacterId = faction.getEspionage().pendingFounderCharacter(office); + // Keep the retry intent even for a newly created faction; roll back only the unsaved binding. + faction.getEspionage().pendingFounder(office); + var previous = faction.getEspionage().snapshotOffices(); faction.getEspionage().pendingFounder(office, characterId); - if (!characterId.equals(previousCharacterId)) saveOrMark(faction, dirty); + if (!characterId.equals(previousCharacterId)) { + if (dirty == null) { + if (!new Database().saveFactionChecked(faction)) faction.getEspionage().restoreOffices(previous); + } else saveOrMark(faction, dirty); + } if (founder != null) founder.sendMessage("\u00a7cYour founding office could not be initialized. It will be retried when the office is checked."); continue; } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java index 809307dd..7a49a363 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java @@ -107,7 +107,7 @@ class OfficePersistenceTest { when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); try (var bukkit = mockStatic(org.bukkit.Bukkit.class); var players = mockStatic(net.tfminecraft.rpcharacters.managers.PlayerManager.class); - var databases = mockConstruction(Database.class)) { + var databases = mockConstruction(Database.class, (database, context) -> when(database.saveFactionChecked(faction)).thenReturn(true))) { field.set(null, registry); net.tfminecraft.simplefactions.SimpleFactions.plugin = plugin; bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(mock(org.bukkit.Server.class)); @@ -124,7 +124,7 @@ class OfficePersistenceTest { assertTrue(restored.hasPendingFounder()); assertEquals("founder-character", restored.pendingFounderCharacter(SpecialPosition.SPYMASTER)); assertEquals(1, databases.constructed().size()); - verify(databases.constructed().getFirst()).saveFaction(faction); + verify(databases.constructed().getFirst()).saveFactionChecked(faction); bukkit.when(() -> org.bukkit.Bukkit.getPlayerExact("Founder")).thenReturn(null); EspionageService.initializeFounder(faction); assertNull(state.getSpymaster(), "Offline retry must not finalize a zero-aptitude office"); @@ -146,6 +146,60 @@ class OfficePersistenceTest { } } + @Test void failedPendingIdentitySaveRestoresBindingAndRetriesForNewAndExistingFounders() throws Exception { + var registry = mock(CharacterAptitudes.class); + when(registry.aptitude(eq("new-character"), any())).thenThrow(new java.io.IOException("Test aptitude save failure")); + var field = EspionageService.class.getDeclaredField("characterAptitudes"); + field.setAccessible(true); + var previous = field.get(null); + var previousPlugin = net.tfminecraft.simplefactions.SimpleFactions.plugin; + var plugin = mock(net.tfminecraft.simplefactions.SimpleFactions.class); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + field.set(null, registry); + net.tfminecraft.simplefactions.SimpleFactions.plugin = plugin; + try { + for (String oldIdentity : new String[]{null, "old-character"}) { + var faction = mock(Faction.class); + var founder = mock(Player.class); + var data = mock(net.tfminecraft.rpcharacters.objects.PlayerData.class); + var character = mock(net.tfminecraft.rpcharacters.objects.RPCharacter.class); + var state = new EspionageState(); + if (oldIdentity != null) state.pendingFounder(SpecialPosition.SPYMASTER, oldIdentity); + when(faction.getEspionage()).thenReturn(state); + when(faction.getLeader()).thenReturn("Founder"); + when(faction.isLeader("Founder")).thenReturn(true); + when(faction.getMembers()).thenReturn(List.of("Founder")); + when(founder.getUniqueId()).thenReturn(UUID.randomUUID()); + when(data.getActiveCharacter()).thenReturn(character); + when(character.getId()).thenReturn("new-character"); + var saves = new java.util.concurrent.atomic.AtomicInteger(); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class); + var players = mockStatic(net.tfminecraft.rpcharacters.managers.PlayerManager.class); + var databases = mockConstruction(Database.class, (database, context) -> + when(database.saveFactionChecked(faction)).thenReturn(saves.incrementAndGet() > 1))) { + bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(mock(org.bukkit.Server.class)); + var plugins = mock(org.bukkit.plugin.PluginManager.class); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(() -> org.bukkit.Bukkit.getPlayerExact("Founder")).thenReturn(founder); + players.when(() -> net.tfminecraft.rpcharacters.managers.PlayerManager.get(founder)).thenReturn(data); + EspionageService.initializeFounder(faction); + assertTrue(state.hasPendingFounder(), "Failed saving a new founder must retain the retry intent"); + assertEquals(oldIdentity, state.pendingFounderCharacter(SpecialPosition.SPYMASTER)); + assertNull(EspionageService.spymaster(faction)); + assertEquals("new-character", state.pendingFounderCharacter(SpecialPosition.SPYMASTER)); + assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); + assertEquals(2, databases.constructed().size()); + for (Database database : databases.constructed()) verify(database).saveFactionChecked(faction); + verify(faction, never()).getBank(); + } + } + } finally { + field.set(null, previous); + net.tfminecraft.simplefactions.SimpleFactions.plugin = previousPlugin; + } + } + @Test void removingAPendingFounderPreventsAutomaticReassignment() { var state = new EspionageState(); state.pendingFounder(SpecialPosition.SPYMASTER);