diff --git a/ESPIONAGE.md b/ESPIONAGE.md new file mode 100644 index 00000000..43859128 --- /dev/null +++ b/ESPIONAGE.md @@ -0,0 +1,310 @@ +# Espionage and special positions + +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 +in-game menus; website exports and staff administration commands are unchanged. + +## Staff viewing permission + +Set the permission node in `plugins/SimpleFactions/special-positions.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 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 | Rumours | Up to 300% | 20% | +| 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. Guild leaders and special-office identities use their configurable tier gates. +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, +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 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 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. +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 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 +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. + +### 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. + +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. +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 +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. +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/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 6e2e4fd0..25a33d39 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -238,6 +238,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(); @@ -279,6 +280,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()); @@ -372,10 +381,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); } @@ -386,7 +392,15 @@ 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")); configLoader.loadWar(new File(getDataFolder(), "war.yml")); VehiclesConfigLoader.load(new File(getDataFolder(), "vehicles.yml")); @@ -678,6 +692,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/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index ff7d71aa..f623f8d5 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); @@ -312,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"); @@ -389,6 +395,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()) { @@ -514,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/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/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/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..b163ac0f --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java @@ -0,0 +1,105 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.nio.file.Files; +import java.nio.file.Path; +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 java.util.concurrent.ConcurrentHashMap<>(); + 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; + } + + 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(); + 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) {} + + /** Bukkit replaces only the current argument, so return the untyped suffix of a full name. */ + public static java.util.List 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().filter(java.util.Objects::nonNull).map(org.bukkit.ChatColor::stripColor).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) { + String name = activeName(playerName); + return name == null ? playerName : name; + } + + private static String activeName(String playerName) { + if (playerName == null) return "Unknown"; + if (!OfficeCharacters.enabled("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 = null; + 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) { + // Cache the absence too; account-oriented callers supply their own 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..3cc89d0b --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageAccess.java @@ -0,0 +1,48 @@ +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); + 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; + 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..126bc30f --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java @@ -0,0 +1,154 @@ +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 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), + 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), + 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) {} + + 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)); + 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(); + 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 = -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[]{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), + 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.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(' ', '-'); + } + 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 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/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..fad63862 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -0,0 +1,490 @@ +package net.tfminecraft.simplefactions.espionage; + +import java.time.LocalDate; +import java.time.ZoneOffset; +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.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) { + 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); + for (SpecialPosition office : SpecialPosition.values()) { + if (faction.getEspionage().holder(office) != null) continue; + if (office == SpecialPosition.SPYMASTER && !eligible(faction, faction.getLeader())) 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 { + 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); + 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)) { + 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; + } + 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; + } + + 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; + } + + 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) { + 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, dirty); + if (!faction.getEspionage().hasPendingFounder()) saveOrMark(faction, dirty); + } + SpecialPositionAssignment holder = faction.getEspionage().getSpymaster(); + if (holder != null && (!eligible(faction, holder.playerName) || deadCharacter(holder))) { + faction.getEspionage().removeSpymaster(); + saveOrMark(faction, dirty); + return null; + } + return holder; + } + + private static boolean deadCharacter(SpecialPositionAssignment holder) { + return OfficeCharacters.isDead(holder.playerId, holder.characterId); + } + + /** Character death, rather than an ordinary Minecraft respawn, ends the appointment. */ + 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 && (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()) + && characterId.equals(state.pendingFounderCharacter(SpecialPosition.SPYMASTER)); + if (!matching && !pending) continue; + state.removeSpymaster(); + new Database().saveFaction(faction); + 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."); + } + } + + 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."); + 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; + } + 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()) + && characterId.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 = characterId; + try { + if (characterAptitudes == null) throw new java.io.IOException("Character aptitude registry is unavailable"); + 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; + } + } 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; + } + // 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, String characterId) throws java.io.IOException { + if (characterAptitudes == null) throw new java.io.IOException("Character aptitude registry is unavailable"); + return characterAptitudes.aptitude(characterId, () -> + EspionageMath.aptitude(OfficeCharacters.attributes(player), 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; + } + var previous = faction.getEspionage().snapshotOffices(); + faction.getEspionage().removeSpymaster(); + 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; + } + + 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; + } + int previous = offense ? holder.offenseReduction : holder.defenseReduction; + if (offense) holder.offenseReduction = reduction; + else holder.defenseReduction = reduction; + 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."); + 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; + 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, 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, java.util.Set dirty) { + long day = LocalDate.now(ZoneOffset.UTC).toEpochDay(); + 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, () -> { + created[0] = true; + int margin = observer.getEspionage().rolls(day, effectiveAptitude(observer, attacker), random).offense() + - target.getEspionage().rolls(day, effectiveAptitude(target, defender), random).defense(); + IntelligenceReport generated = createReport(metrics(target), margin, random); + captureMembers(generated, target, margin, random); + captureOffices(generated, target, random); + ReportDetails.capture(generated, target); + return generated; + }); + if (created[0]) { + dirty.add(target); + dirty.add(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.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::forForeign).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.forForeign(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) { + 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.forForeign(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; + 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, dirty); + count++; + } + } + // Persist every reset/report once, including a lone faction with no foreign targets. + for (Faction faction : factions) new Database().saveFaction(faction); + 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 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/EspionageState.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java new file mode 100644 index 00000000..74477166 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java @@ -0,0 +1,158 @@ +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<>(); + 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, Map pendingFounderCharacters) {} + + 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), new HashMap<>(pendingFounderCharacters)); + } + + 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()); + pendingFounderCharacters.clear(); pendingFounderCharacters.putAll(snapshot.pendingFounderCharacters()); + } + + 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); + pendingFounders.remove(office); + pendingFounderCharacters.remove(office); + } + + 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() { + pendingFounders.remove(SpecialPosition.SPYMASTER); + pendingFounderCharacters.remove(SpecialPosition.SPYMASTER); + 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) { + 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); + 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..c28225b5 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceLedger.java @@ -0,0 +1,61 @@ +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) { + 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 && (expenses ? range.upper() < 0 : range.lower() > 0)) + lore.add(flow.getDisplay() + ": " + value(report, guild, metric, "d/day")); + } + } +} 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..6e55e4fe --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceRanges.java @@ -0,0 +1,68 @@ +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.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); + } + + 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.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; + 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..6d8fcd68 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java @@ -0,0 +1,73 @@ +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 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) + .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) { + 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() { + 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..c5e14fac --- /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("Botched report"), 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/OfficeCharacterDeathListener.java b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java new file mode 100644 index 00000000..75bc8b88 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/OfficeCharacterDeathListener.java @@ -0,0 +1,21 @@ +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, + () -> { + 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 new file mode 100644 index 00000000..130505d5 --- /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.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 new file mode 100644 index 00000000..505c5b9a --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java @@ -0,0 +1,66 @@ +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<>(); + String title = faction.getRulerTitle(); + 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(), 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, + 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(""); + 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 + : !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/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..83e5152a --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java @@ -0,0 +1,74 @@ +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()); + // 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); + 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..73cec1d7 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 = net.tfminecraft.simplefactions.espionage.EspionageService.stabilityModifiers(f, 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 e9513a87..8a85c3f9 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; @@ -63,6 +64,14 @@ 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); if(cmd.getName().equalsIgnoreCase(cmd1) && args.length >= 1 && args[0].equalsIgnoreCase("addguildbank")) { return addGuildBank(sender, args); } @@ -72,6 +81,10 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String } 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; @@ -169,7 +182,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"); @@ -183,16 +196,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"); @@ -923,7 +933,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"); @@ -933,9 +943,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 03661f5d..23c248dd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -702,6 +702,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..a434194e 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); @@ -1269,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 de27602d..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,41 +10,27 @@ 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) { - 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; @@ -76,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 new file mode 100644 index 00000000..34678dfd --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -0,0 +1,408 @@ +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 retain normal navigation through read-only, masked menus. */ +@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 ? net.tfminecraft.simplefactions.espionage.IntelligenceTier.UNKNOWN.label() : 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.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", + "#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 = net.tfminecraft.simplefactions.espionage.RosterLore.guild(viewer, guild); + inventory.setItem(15, styled(Material.PLAYER_HEAD, "#b8ae61Members: " + + 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, 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)); + 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), + 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, + 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); + } + + 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(), target), + "#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 = 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", + "#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() == 26) { + 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..92af3b21 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(), 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())); - 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(), f))); 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(), f))); double power = Formatter.formatDouble(gov.getPower()); double maxPower = Formatter.formatDouble(gov.getMaxPower()); String powerString = ((power < 0) ? "§c" : "") + power+"/"+((maxPower < 0) ? "§c" : "") + maxPower; @@ -377,18 +404,11 @@ 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.getMembers()); + 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))); - 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)) { @@ -555,6 +575,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..99286ac8 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,25 @@ 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) { + 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; + } if(e.getSlot() == 28) { ItemStack item = e.getCurrentItem(); ItemMeta m = item.getItemMeta(); @@ -391,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/GovernmentCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java index c31c9876..1925ef25 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(), 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/GovernmentView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java index 25d3ba5b..004e27f1 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,17 @@ 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)) { + 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(); - 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..90be517c 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"+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()) { - 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,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"+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); @@ -197,11 +216,13 @@ 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")); + String income = EspionageService.canViewExact(p, g.getFaction()) ? String.valueOf(g.getLedger().getNetIncome()) + : 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; } m.setLore(lore); @@ -211,16 +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.")); - } - for(String s : guild.getMembers()) { - String mark = net.tfminecraft.simplefactions.inactivity.InactivityService.isMemberInactive(s) - ? " #c95644(inactive)" : ""; - lore.add(StringFormatter.formatHex("#d4c9ae"+s+mark)); - } + List lore = net.tfminecraft.simplefactions.espionage.RosterLore.guild(p, guild).stream().map(StringFormatter::formatHex).toList(); m.setLore(lore); i.setItemMeta(m); } @@ -412,6 +424,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..a7abfcb2 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,8 +120,9 @@ 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()); + 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)); @@ -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(); @@ -185,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)); @@ -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())) { ReportedMenus.upgrades(i, player, guild, inv); return; } i.clear(); List upgrades = guild.getUpgrades(); @@ -258,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; @@ -320,6 +325,14 @@ 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) { + 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(); 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..449e13ba 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,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)) { + 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 @@ -90,6 +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)) { + 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 25781bd8..da6107f1 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,7 +114,12 @@ 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 (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 cf9dfc5e..2f93ed87 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,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)) { + 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(); @@ -50,6 +58,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/LoanView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java index 32966556..0162e455 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java @@ -39,11 +39,18 @@ 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), 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/MilitaryView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryView.java index 167ce58e..672560ae 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,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)) { + 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..e1123e3c --- /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(), 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(); + } + + 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(), 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), + 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, MenuTitles.legacy(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/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 97c3bd69..135b38c7 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") && Permissions.isAdmin(sender) && args.length == 2 && args[0].equalsIgnoreCase("addguildbank")) { List completions = new ArrayList<>(); @@ -324,6 +334,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"); @@ -448,13 +460,14 @@ 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; + 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/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..b5315218 --- /dev/null +++ b/src/main/resources/special-positions.yml @@ -0,0 +1,103 @@ +# 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 + 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. Rumours is the minimum report quality. + # Negative margins return Rumours; individual fields stay Unknown below their required tier. + tiers: + rumours: + minimum-margin: 0 + 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. + # The faction and all its guilds share one daily quality. Flavours, faction 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 + 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/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/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/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..bfd089cc --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java @@ -0,0 +1,90 @@ +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 { + @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"); + 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"})); + 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); + 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..4cbec335 --- /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() { + 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))); + } + + @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..c7da95f8 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java @@ -0,0 +1,193 @@ +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 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); + 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)); + } + verify(manager, never()).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, (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)); + 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..f85c4192 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java @@ -0,0 +1,151 @@ +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(2, databases.constructed().size()); + EspionageService.refreshReports(second); + assertSame(report, EspionageService.report(second, target)); + 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); + 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(); + 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()); + 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..5b1ebd2d --- /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.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)); + 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..6b1b6e06 --- /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("Botched report", report.qualityLabel()); + assertEquals("Hidden", report.quality); + assertEquals("Unknown", report.display("Wealth")); + report.estimates = null; + assertEquals("Unknown", report.display("Wealth")); + assertEquals("Rumours", 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..bbfecf50 --- /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 newSoloFactionWaitsForACharacterWithoutUsingTheFreeAppointment() { + 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); + assertNull(state.getSpymaster()); + assertTrue(state.hasPendingFounder()); + 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/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 new file mode 100644 index 00000000..7a49a363 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java @@ -0,0 +1,227 @@ +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")) + .thenThrow(new java.io.IOException("Test retry 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, (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)); + 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); + assertNull(state.getSpymaster()); + assertTrue(state.hasPendingFounder()); + assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); + 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()).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"); + 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; + } + } + + @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); + 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; + } +} 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..f562efca --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java @@ -0,0 +1,139 @@ +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.getId(), "Spy"); + assertSame(holder, state.getSpymaster()); + when(character.getId()).thenReturn("appointed"); + EspionageService.characterDied(null, character.getId(), "Spy"); + assertNull(state.getSpymaster()); + assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + verify(databases.constructed().getFirst()).saveFaction(faction); + } + } + + @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); + 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(); + 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, "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 new file mode 100644 index 00000000..07b13913 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java @@ -0,0 +1,116 @@ +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 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")) { + 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, 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); + 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\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")); + 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(); + } +} 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..a16c634c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/RosterAndLedgerPresentationTest.java @@ -0,0 +1,57 @@ +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, 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)); + 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)); + 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(); + } +} 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..bda1eb0d --- /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# 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()); + 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\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); + 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; }