From 688aa3bcf47d5864df0334751a6d6f866a5e30a7 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:22:08 +0000 Subject: [PATCH 1/2] docs: remove local manuals and stale references --- docs/README.md | 38 - docs/campaign-raids.md | 141 --- docs/conditionalevents-title-enter.yml | 81 -- docs/dev-config.md | 263 ---- docs/fertility-verify.md | 35 - docs/fertility.md | 48 - docs/installations.md | 521 -------- docs/map-export.md | 211 --- docs/mercenaries.md | 98 -- docs/prestige.md | 91 -- docs/province-grid.md | 110 -- docs/roadmap.md | 40 - docs/settlements.md | 136 -- docs/vehicles.md | 133 -- docs/wars.md | 1127 ----------------- .../mercenary/contract/TerminationReason.java | 5 +- .../resources/Guilds/company-upgrades.yml | 2 - src/main/resources/config.yml | 2 +- src/main/resources/regiments.yml | 5 +- 19 files changed, 5 insertions(+), 3082 deletions(-) delete mode 100644 docs/README.md delete mode 100644 docs/campaign-raids.md delete mode 100644 docs/conditionalevents-title-enter.yml delete mode 100644 docs/dev-config.md delete mode 100644 docs/fertility-verify.md delete mode 100644 docs/fertility.md delete mode 100644 docs/installations.md delete mode 100644 docs/map-export.md delete mode 100644 docs/mercenaries.md delete mode 100644 docs/prestige.md delete mode 100644 docs/province-grid.md delete mode 100644 docs/roadmap.md delete mode 100644 docs/settlements.md delete mode 100644 docs/vehicles.md delete mode 100644 docs/wars.md diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f353267d..00000000 --- a/docs/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# SimpleFactions documentation - -**SimpleFactions** is the TFMC Paper plugin for nations, diplomacy, economy, wars, installations, settlements, and map export to [tfminecraft.net](https://www.tfminecraft.net/). - -This `docs/` folder is the product and technical reference for the **SimpleFactions** repository. The website map pipeline is documented in [ProvinceSystem/docs/integrations/simplefactions.md](../../ProvinceSystem/docs/integrations/simplefactions.md). - -## Reading order - -1. [roadmap.md](./roadmap.md) - current systems and follow-up work -2. [map-export.md](./map-export.md) - HTTP upload, regen, JSON contract -3. [province-grid.md](./province-grid.md) - local province lookup -4. Product areas: - - [fertility.md](./fertility.md) - province fertility 0-100 lookup (Cooking owns crop growth) - - [fertility-verify.md](./fertility-verify.md) - in-game QA matrix for fertility crop growth - - [prestige.md](./prestige.md) - what a nation's standing is made of, and the playtime term - - [wars.md](./wars.md) - automated campaign war system (canonical spec) - - [campaign-raids.md](./campaign-raids.md) - inter-battle installation raids - - [mercenaries.md](./mercenaries.md) - companies for hire, contracts, wages, reputation - - [installations.md](./installations.md) - forts, ports, airports - - [settlements.md](./settlements.md) - named cities on the map - - [vehicles.md](./vehicles.md) - berths, slots, VehicleFramework integration -5. [dev-config.md](./dev-config.md) - dev-only config and bypasses - -## Verify (tests) - -```bash -cd simplefactions && mvn test -Dtest="net.tfminecraft.simplefactions.War.**" # war changes -cd simplefactions && mvn test -Dtest="net.tfminecraft.simplefactions.vehicles.**" # vehicle berth changes -cd simplefactions && mvn test # broad changes -``` - -## Related repos - -| Component | Role | Docs | -|-----------|------|------| -| **ProvinceSystem** | Mapgen, web map, file serving | [ProvinceSystem/docs/](../../ProvinceSystem/docs/) | -| **TFMCWeb** | HTTP gateway to ProvinceSystem | [ProvinceSystem/docs/identity/tfmcweb.md](../../ProvinceSystem/docs/identity/tfmcweb.md) | -| **VehicleFramework** | Vehicle entities (berth integration) | VF plugin repo | diff --git a/docs/campaign-raids.md b/docs/campaign-raids.md deleted file mode 100644 index 9fd0e50d..00000000 --- a/docs/campaign-raids.md +++ /dev/null @@ -1,141 +0,0 @@ -# Campaign raids - -**Shipped.** Between scheduled **campaign** battles, faction leaders may launch **installation assaults** during a fixed daily window. Raids are not a separate war type; they run inside an active automated campaign war. - -**Canonical war context:** [wars.md](./wars.md#campaign-raids) · **Installations:** [installations.md](./installations.md) · **Vehicles:** [vehicles.md](./vehicles.md) - ---- - -## What it is - -| Term | Meaning | -|------|---------| -| **Campaign raid** | Timed assault from own port/airport to enemy port/airport/fort | -| **Pillage war** | Planned one-battle settlement war type (not this feature) | -| **Staff `BattleType.RAID`** | Manual template battle with capture points (dev tool) | - -One raid per **coalition side** per battle day. One active raid per war at a time. - ---- - -## Battle day timeline (Europe/Paris) - -Config keys under `war.battle_schedule`: - -| Phase | Default hours | Rule | -|-------|---------------|------| -| Defender choice deadline | 12 | Hold / counter-push / white peace | -| Vote close + pick lock | 16 | Hour vote tally; installation picks freeze | -| **Raid window** | 19-20 | Leaders may **launch** a raid | -| Warband signup blocked | 19-20 | `CampaignWarbandSignupService` | -| Warband signup open | 20-21 | Pre-battle muster | -| Battle window | 21-24 | Scheduled campaign fights | - -In-flight raids may run past 20:00 (60s muster + 10 min fight timer). - ---- - -## Launch flow - -1. Faction **leader** on a belligerent side opens campaign GUI → **Start raid**. -2. Page 1: pick **source** (own operational port or airport). -3. Page 2: pick **target** (enemy operational port, airport, or fort). -4. **60s muster** (`war.campaign_raid.muster_seconds`); broadcast + `/raid join ` (slug from raid name, e.g. `harbor_raid`). -5. **10 min fight** (`duration_seconds`); timer-only outcome. - -**Eligibility:** `CampaignRaidEligibilityService` (not installation picks). - -| Role | Rule | -|------|------| -| Source | Launching faction's operational `port` or `airport` | -| Target | Any enemy operational `port`, `airport`, or `fort` | -| Raid kind | `NAVAL` (port→port), `AIR` (airport→airport), `FORT` (port/airport→fort) | - -Quota: first confirming leader spends the side's daily raid on that `battleDay`. - ---- - -## Join rules - -| Rule | Detail | -|------|--------| -| Who may join | **Attacker coalition** only via `/raid join` | -| Exclusion | Cannot join if already in **any** warband | -| Defenders | Online at fight start + logins during raid auto-join defender warband if warband-free | -| Attacker TP | Source installation center at fight start | -| Defender TP | None at start; respawn at **target** center | - -Warband ids: `{raid_slug}_attacker` / `{raid_slug}_defender` (e.g. `harbor_raid_attacker`). - -Raid display names follow battle naming: `Harbor Raid`, `Second Harbor Raid`, etc. Join id is the slugified name (`harbor_raid`). - ---- - -## Fight rules (`campaign_raid_template`) - -| Rule | Value | -|------|-------| -| Capture points | None | -| Win condition | Timer or all raiders eliminated (defender win); no early end from defender logout | -| Attacker lives | One each; death or disconnect = out | -| Defender respawn | Infinite at target center | -| `keep_inventory` | `true` for participants | -| Boss bars | Blue: raid name + time remaining. Red: raiders remaining (attackers). Side life bars hidden. | -| Province fence | None | -| Intruders | Attacker-coalition players in target province who are not raid participants take periodic damage | - ---- - -## Damage and repair embargo - -On the **target installation** during and after a raid: - -| Effect | Detail | -|--------|--------| -| Damage gating | `InstallationVulnerabilityService` - blocks most block damage except configured exceptions | -| Installation repair embargo | `InstallationRepairEmbargoService` (toggle: `installation_repair_embargo_enabled`) | -| Berth embargo | `VehicleInstallationLockService` | -| Post-raid lock | `repair_lock_hours` (default **48**) from fight start | - -Berthed vehicles at the target cannot be newly berthed while locked. Vehicle repair is always allowed. See [installations.md](./installations.md#campaign-raid-damage-and-repair). - ---- - -## Config - -```yaml -war: - campaign_raid: - muster_seconds: 60 - duration_seconds: 600 - repair_lock_hours: 48 - installation_repair_embargo_enabled: true - intruder_damage_interval_ticks: 10 - intruder_damage_amount: 4 -``` - -Battle template: `battle-templates.yml` → `campaign_raid_template` (`campaign_raid: true`). - ---- - -## Code map - -| Area | Classes | -|------|---------| -| State / quota | `CampaignRaidService` | -| Eligibility | `CampaignRaidEligibilityService` | -| Launch GUI | `CampaignRaidLaunchView` | -| Muster / join | `CampaignRaidJoinService`, `CampaignRaidMusterScheduler` | -| Warbands | `CampaignRaidWarbandService`, `CampaignRaidWarbandListener` | -| Fight | `CampaignRaidLaunchService`, `CampaignRaidBattleService`, `CampaignRaidFightScheduler`, `CampaignRaidBossBarService` | -| End | `CampaignRaidBattleEndService` | -| Intruders | `CampaignRaidIntruderService`, `CampaignRaidIntruderListener`, `CampaignRaidIntruderTickService` | -| Command | `RaidCommandManager` | - ---- - -## Related docs - -- [wars.md](./wars.md) - full campaign system -- [roadmap.md](./roadmap.md) - shipped feature list -- [dev-config.md](./dev-config.md) - shortened schedules on test server diff --git a/docs/conditionalevents-title-enter.yml b/docs/conditionalevents-title-enter.yml deleted file mode 100644 index ef56f62a..00000000 --- a/docs/conditionalevents-title-enter.yml +++ /dev/null @@ -1,81 +0,0 @@ -# Copy into ConditionalEvents (type: custom). SimpleFactions softdepends ConditionalEvents. -# Title events fire only when that title id changes, not on every province hop inside the same title. - -sf_province_enter: - type: custom - custom_event_data: - event: net.tfminecraft.simplefactions.events.PlayerProvinceEnterEvent - player_variable: getPlayer() - variables_to_capture: - - '%province_id%;getProvinceId()' - - '%previous_province_id%;getPreviousProvinceId()' - conditions: - - '%province_id% equals 42' - actions: - default: - - 'message: &eYou entered province %province_id%' - -sf_title_enter: - type: custom - custom_event_data: - event: net.tfminecraft.simplefactions.events.PlayerTitleEnterEvent - player_variable: getPlayer() - variables_to_capture: - - '%tier%;getTierId()' - - '%title_id%;getTitleId()' - - '%title_name%;getTitleName()' - - '%previous_title_id%;getPreviousTitleId()' - conditions: - - '%tier% equals county' - - '%title_id% equals osheim' - actions: - default: - - 'message: &eYou entered %title_name%' - -sf_title_leave: - type: custom - custom_event_data: - event: net.tfminecraft.simplefactions.events.PlayerTitleLeaveEvent - player_variable: getPlayer() - variables_to_capture: - - '%tier%;getTierId()' - - '%title_id%;getTitleId()' - - '%next_title_id%;getNextTitleId()' - conditions: - - '%tier% equals kingdom' - actions: - default: - - 'message: &7You left kingdom %title_id%' - -# getTierId() is one of: county, duchy, kingdom, empire - -# Gameplay map regions (Input/regions.json), not WorldGuard and not title tiers. -# Fires only when the region id changes, not on every province hop inside it. - -sf_region_enter: - type: custom - custom_event_data: - event: net.tfminecraft.simplefactions.events.PlayerEnterRegionEvent - player_variable: getPlayer() - variables_to_capture: - - '%region_id%;getRegionId()' - - '%region_name%;getRegionName()' - - '%previous_region_id%;getPreviousRegionId()' - conditions: - - '%region_id% equals REGION_1' - actions: - default: - - 'message: &eYou entered %region_name%' - -sf_region_leave: - type: custom - custom_event_data: - event: net.tfminecraft.simplefactions.events.PlayerLeaveRegionEvent - player_variable: getPlayer() - variables_to_capture: - - '%region_id%;getRegionId()' - - '%next_region_id%;getNextRegionId()' - actions: - default: - - 'message: &7You left %region_id%' - diff --git a/docs/dev-config.md b/docs/dev-config.md deleted file mode 100644 index fb94a147..00000000 --- a/docs/dev-config.md +++ /dev/null @@ -1,263 +0,0 @@ -# Dev config and bypasses - -Running list of **dev-only config, commented-out checks, and test bypasses** on the SimpleFactions test server. Revert or replace before production. - -**War gameplay spec:** [wars.md](./wars.md) · **Map upload:** [map-export.md](./map-export.md) - ---- - -## `config.yml` (dev server template) - -Shipped default: `src/main/resources/config.yml`. Live server merges overrides into `plugins/SimpleFactions/config.yml`. - -Campaign keys (`war.*`) are in **`war.yml`** (`src/main/resources/war.yml` → `plugins/SimpleFactions/war.yml`). - -| Key | Dev value | Production target | Notes | -|-----|-----------|-------------------|-------| -| Header comment | Was `dev server template` | Now `map-reference: main (world: TFMC_Map)` | Documents intent only | -| `map-reference` | `main` on prod file; use `dev` on test | `main` (or live map id) | Drives TFMCWeb upload/regen paths | -| `enable-chronicle` | `true` | `true` | Chronicle snapshot upload. Set `false` to stop the chronicle without taking the map down; `enable-map` still gates it | -| `war.require_declare_code` (`war.yml`) | `false` to declare without a Discord ticket | `true` (**shipped value**) | On, an attacking leader must type a staff-minted Discord code, which pins the war goal. `simplefactions.admin` bypasses it | -| `war.declare_code_timeout_seconds` (`war.yml`) | `10` | `10` | How long to wait for ProvinceSystem before refusing the declare. The gate fails closed | -| `war.battle_cadence.provinces_between_battles` (`war.yml`) | `3` | `3` (or higher after playtest) | Field battle cadence | -| `war.battle_loot.mode` (`war.yml`) | `COMMAND` | `COMMAND` | `COMMAND` runs every line in `commands` from console once per rewarded player; `ITEM` gives a TLibs item instead. An unrecognised value falls back to `COMMAND`. See [wars.md](./wars.md#battle-loot) | -| `war.battle_loot.commands` (`war.yml`) | Point it at a throwaway command you can watch in console | The real crate-key command | Used when `mode` is `COMMAND`. Both `%player%` and `#player#` are replaced with the player name. An empty list means no loot | -| `war.battle_loot.item` (`war.yml`) | `v.diamond` | The real reward item | Used when `mode` is `ITEM`. TLibs path (`v.`, `ia.`, `m.`, `modeled.`). Blank means no loot | -| `war.battle_loot.item_amount` (`war.yml`) | `1` | `1` | Stack size for `ITEM` mode. Clamped to at least `1` | -| `max-prestige-playtime-exponent` | `5`, or lower to reach the ceiling sooner while testing | `5` | Caps the per-member playtime prestige curve. `5` means a member tops out at 32, at 500 online hours. Needs RPCharacters; the term is 0 without it. See [prestige.md](./prestige.md) | -| `installations.*.construction-time` | `10` to watch a build finish in a session | `432000` fort / `259200` port+airport (**shipped values**) | Seconds | -| `mercenary-formation-seconds` | `10` to test the founding flow | `86400` (24 h) | Real seconds, same tick model as construction. Shipped file has the production value | - -**Tick model:** faction `tick()` runs **once per real second** (`FactionManager` timer every 20 ticks). Construction and regiment expansion `timeLeft` decrement **once per second**, so `10` = **10 seconds**, not 10 days. - -Mercenary key reference (all keys with defaults): [mercenaries.md](./mercenaries.md). - ---- - -## `regiments.yml` - -All four now ship production values. Lower any of them to `10` to test the expansion queue, then restore. - -| Regiment | Key | Shipped value | Dev override | -|----------|-----|---------------|--------------| -| professional | `expansion-time` | `21600` (6 h) | `10` | -| militia | `expansion-time` | `43200` (12 h) | `10` | -| levy | `expansion-time` | `43200` (12 h) | `0` for instant expansion | -| mercenary | `expansion-time` | `86400` (24 h) | `10` | - -Queue ticks once per faction second (same as construction). - -`Guilds/company-upgrades.yml` uses `expansion-time: 86400` for all three company upgrades; lower it the same way to test the purchase queue. - ---- - -## `Guilds/upgrades.yml` - -All four upgrades now use `expansion-time: 21600` (6 h), matching the `Upgrade.java` loader fallback. Lower to `10` to test the purchase queue, then restore. - -| Upgrade id | Shipped `expansion-time` | -|------------|--------------------------| -| `max_admin_power` | 21600 | -| `max_diplomatic_capacity` | 21600 | -| `admin_power_gain` | 21600 | -| `auto_dealer_tables` | 21600 | - ---- - -## Code bypasses (must revert) - -| Location | What | Production action | -|----------|------|-------------------| -| `RestServer.java` L24 | `REGEN_HASH` hardcoded | Move to config / env | - ---- - -## Admin / debug commands (intentional staff tools) - -| Command | Purpose | -|---------|---------| -| `/war list` | Open war list GUI | -| `/war admin status ` | JSON war state (campaign, initiative, proposals) | -| `/war admin path ` | Regenerate campaign route | -| `/war admin end ` | Force-end a war (no goal apply, no reparations) | -| `/war admin win attacker\|defender` | End war with victory outcome (goal apply or reparations) | -| `/war admin devmode on\|off\|status` | Volatile war devmode (roster fill + unrestricted raid launch) | -| `/war admin raid resetquota [aggressor\|defender\|both]` | Clear daily raid quota for current battle day | -| `/faction fullregen ` | Trigger map regen via TFMCWeb | -| `/faction regen` | Queue nation upload + regen | - ---- - -## Timing quirks (verify before prod) - -| System | Actual tick | UI / docs say | Action | -|--------|-------------|---------------|--------| -| Administrative power gain | Every **3600 s** (`FactionManager.timer % 3600`) | Lore shows `+N/hour` | Matches lore | -| Stability / pillage decay | Same hourly `powerTick` | `%/hour` lore; pillage `POWER_TICKS_PER_DAY = 24` | Matches lore | -| Movement organization | Once per daily tick (`Faction.newDay`) | GUI `per day`; `base` 30 | ~3.3 days to 100 org at 100% power, 1 cause | -| Faction daily cycle | `timer >= 86400` (~24 h real time) | Daily guild income, loans, movement org | Real-time days on test server | -| Diplomacy opinion drift | `RelationManager.tick` every **3600 s** | - | Real-time hour | -| Map partial upload | `MapSystem` full update every **3600 s** | - | Real-time hour | - ---- - -## Wars - dev-friendly settings - -| Item | Dev setting | Prod | -|------|-------------|------| -| Declare without Discord ticket | `war.require_declare_code: false` | Declare codes + ticket gate | -| Declare GUI pre-checks | Live: opinion threshold, duplicate war, target online | Same, plus the code gate | -| Faction id lookup for minting | `/war admin factions [filter]` | Same (staff only) | - -### Declare codes (test server E2E) - -Prerequisites: `war.require_declare_code: true` in `war.yml`, the `factions` cog loaded and pointed at the same ProvinceSystem as TFMCWeb, and its `realm_id` matching what TFMCWeb reports on this server. Run the declare as a **non-admin** leader, or the gate bypasses itself. - -1. `/war admin factions` in game, copy the attacker and defender ids. -2. `/warcode mint ` in Discord, copy the code. -3. `/warcode list` shows the id, the pairing and the goal, and **no** code text. -4. As the attacking leader, diplomacy → **Declare War**. Expect a chat prompt, not the goal picker. -5. Type a wrong code. Expect `Invalid war code` and no state change. -6. Type the real code. Expect `Code accepted. War goal: ` and the goal's **own** sub-picker (or confirm for War, Tributary, Usurp, Open Market). The goal picker must never appear. -7. Confirm. Check `plugins/SimpleFactions/Logs/war.log` for a `DECLARE_CODE` line next to the `DECLARE` line. -8. `/warcode list` no longer lists that id. -9. Repeat step 4 with the same code. Expect `This code has already been used`. -10. Mint another code, then make the declare fail on purpose (naval path with no port, or an ineligible goal target). The code must survive: `/warcode list` still shows it and a second attempt works. -11. Stop ProvinceSystem, mint nothing, and try to declare. Expect a refusal, not a free declare. Then repeat as an admin (`simplefactions.admin`) and confirm the goal picker opens with no prompt. - -### Battle scheduling timeline (Europe/Paris) - -Defaults under `war.battle_schedule`: - -- **Vote open:** when next battle is pending (at declare or after battle end). -- **`defender_choice_deadline_hour` (12):** auto Push (winner) or Attack (loser after Hold) if unresolved. -- **`vote_close_hour` (16):** tally, schedule or postpone (`battleDay` +1, votes persist). -- **First battle day:** calendar day after declare. - -**Dev surface (remove before prod):** - -| Mechanism | Detail | -|-----------|--------| -| `/war admin schedule ` | Admin subcommands: `opencvote`, `closevote`, `skipday`, `castvote [attacker\|defender\|both]`, `forcequorum`, `setscheduled `, `battlecreate`, `battledelete`, `battlestart`, `winbattle attacker\|defender`, `choice push\|hold\|attack\|accept` (aliases: `battlechoice`, `defenderchoice`, …) | -| `war.battle_voting.dev_min_players: 1` | Test-server quorum override. **Removed from the shipped `war.yml`**; add the key back to lower quorum, delete it to fall back to `min_players: 4` | -| Shortened hour keys | Test server may use e.g. 10/11/12/13 if order constraint holds | - ---- - -## War dev mode - -| Mechanism | Detail | -|-----------|--------| -| `/war admin devmode on\|off\|status` | Volatile in-memory toggle; resets on restart. Admin only. | -| `war.devmode.phantom_count` | Default 10 phantom UUIDs on manual `/warband create` or campaign `battlecreate` when devmode on | -| Raid QA | With devmode on, raid launch ignores battle day, raid hour window, and daily quota (mutex still applies). | -| `battle.capture_min_players: 1` | Min players at capture zone | -| Campaign join rules | Side membership check; roster cap = preview collective lives; bypass faction `WarbandSlot` for campaign battles | - -**Solo staging workflow:** - -1. `/war admin devmode on` -2. Declare war; open/close vote with `dev_min_players: 1` -3. `/warband create` or war GUI - phantoms in lore when devmode on -4. `/battle join attacker` - wrong side rejected; roster cap enforced -5. Fight solo (`capture_min_players: 1`), verify lives/casualties/commitments -6. `/war admin devmode off`; restart clears devmode - ---- - -## Campaign time dev mode - -| Mechanism | Detail | -|-----------|--------| -| `/war admin time add 1h 31m` | Advance spoofed Paris schedule clock (compound tokens: `1h31m`, `1d`, `90s`) | -| `/war admin time reset` | Restore real time; resets hour gate and cancels raid Bukkit tasks | -| `/war admin time status` | Show offset, Paris date/hour, spoofed flag, active war count | -| `/war admin time skip-to-battle-day ` | Jump to 00:00 Paris on war's `battleDay` (fixes `first_battle_day_after_declare` tomorrow default) | -| Route / schedule GUI | Gray **Starts in X** under **Next battle** on active route slot; schedule panel when `SCHEDULED` | - -**Notes:** - -- Admin only (`Permissions.isAdmin`); offset is volatile (lost on restart). -- Each mutating command runs `BattleScheduleTickService.onClockOffsetChanged()` then `tick(CampaignClock.now())` for immediate effect. -- Does **not** affect faction daily timer, guild income, or vehicle upkeep. -- `war admin schedule` subcommands remain for low-level overrides; prefer `war admin time` for schedule E2E. - -**Typical workflow:** - -1. Declare war (two factions). -2. `war admin time skip-to-battle-day ` (or `add 1d` if battle day is tomorrow). -3. `add` into vote window; cast votes in GUI. -4. `add` past `vote_close_hour` - vote closes, phase `SCHEDULED`. -5. Confirm route shows **Starts in X**; `add` until fight starts. -6. Post-battle: `add` to `defender_choice_deadline_hour` for hold/push auto-resolve. -7. Raid window: `add` into raid hours; muster/fight via tick (no long real wait while spoofed). -8. `war admin time reset` when done. - ---- - -## Campaign UX (test server E2E) - -Prerequisites: `war.battle_voting.dev_min_players: 1`, optional `/war admin devmode on`. - -**War setup (preferred):** declare → `war admin time skip-to-battle-day ` → vote in GUI → `war admin time add` past vote close → confirm **Starts in X** on route → `add` to fight time. - -**War setup (manual override):** `war admin schedule opencvote` → `castvote` → `forcequorum` → `closevote` (or `setscheduled` / `battlecreate`) when you need to bypass clock logic. - -**Battle prep:** campaign warbands start empty until signup; staff `/battle edit` for spawns/jails/points; **siege:** Contest Area → click Min/Max at fort corners; `/warband join`. Optional: shorten `battle.signup_reminder_seconds_before` (e.g. `60`, `30`) to verify chat reminders before fight time. - -**Fight loop:** `war admin time add` to fight time (tick runs on each add) or `war admin schedule battlestart`; if siege contest is missing, countdown shows **Cannot start** and belligerents get a chat warning; casualties apply; war returns to **VOTING**. - -**Cleanup:** `war admin time reset`; `/war admin devmode off`; battles/warbands persist on disk (`plugins/SimpleFactions/Battles/`, `Warbands/`). - -### Strategic retreat (test server E2E) - -Prerequisites: same as **Campaign UX** (`dev_min_players: 1`, optional `/war admin devmode on`). - -1. Declare war; `war admin time skip-to-battle-day `. -2. Open campaign GUI as **pushed** war leader during `VOTING` (defender on invasion push). -3. Confirm **Retreat** (slot 46) visible; pusher war leader does not see it. -4. Confirm retreat: route slot shows **Retreated**; schedule index and cursor advance; initiative fuel unchanged. -5. Hour votes still present; phase stays `VOTING`. -6. Second retreat in same window (if next slot exists): next slot **Retreated**; still `VOTING`. -7. Siege slot retreat: fort controller flips without a fought battle. -8. `war admin time add` past `vote_close_hour`: retreat button and hour toggles lock (GUI refreshes within ~1s). -9. Counter-push (`toward_aggressor_capital`): **attacker** (pushed) may retreat; counter schedule index advances. -10. `retake_objective` phase: retreat hidden / rejected. - -### Battle retreat (test server E2E) - -Prerequisites: campaign battle running; set `battle.retreat_min_elapsed_seconds: 0` in config for fast testing (restore `1200` after). - -1. Declare war; schedule and start a campaign field or siege battle (`war admin time` / `war admin schedule battlestart`). -2. As **warband leader**: `/warband retreat` - confirm GUI with warning lore. -3. Cancel - battle continues. -4. Confirm - opponent wins; success message; campaign advances as a normal battle loss. -5. Take some deaths before retreat - verify partial casualties in `war admin status` / commitment rows. -6. Restore cooldown to `1200`; retry within 20 min - rejected with remaining-time message (no GUI). -7. Non-leader or non-warband player - rejected on command. -8. Campaign raid battle - rejected. - -### Mercenary companies (test server E2E) - -Prerequisites: two factions and two players, `war.battle_voting.dev_min_players: 1`, optional `/war admin devmode on`, and `/war admin time` for the campaign clock. Lower `mercenary-formation-seconds` (`config.yml`), `mercenary.expansion-time` (`regiments.yml`) and the company `expansion-time` keys (`Guilds/company-upgrades.yml`) to `10` so the 24 h timers finish inside a session; restore them afterwards. - -Commands: `/company `, `/mercenaries [list|hire ]`, `/ledger`. - -1. `/company found ` - 100 d charged once; wait out formation; confirm 1 slot. -2. `/company expand` with the slot empty - refused; enlist, retry, restart mid-expansion and confirm it resumes. -3. Confirm the mercenary regiment appears in **no** faction military screen and moves no faction totals. -4. Sign a contract at the company's home settlement as a council member; try again from another province - refused, naming the settlement. -5. Fight a battle: confirm the company in the war screen with promised slots, lives folding in filled and attending slots only, and a dual-role player subtracted once. -6. Run a daily tick mid-contract: six ledger lines with correct signs, wages in the soldier's `/ledger`. -7. Restore the timer keys. - -The money and reputation checks need a faction daily tick, which is real time (`timer >= 86400`) and is **not** moved by `war admin time`. - ---- - -## Related docs - -- [roadmap.md](./roadmap.md) - what is still planned for production -- [wars.md](./wars.md) - full war spec -- [mercenaries.md](./mercenaries.md) - mercenary config keys and defaults -- [map-export.md](./map-export.md) - `map-reference` and regen hash diff --git a/docs/fertility-verify.md b/docs/fertility-verify.md deleted file mode 100644 index 3fd18434..00000000 --- a/docs/fertility-verify.md +++ /dev/null @@ -1,35 +0,0 @@ -# Province fertility lookup - in-game verification - -**Reference:** [fertility.md](./fertility.md). Crop **growth** and harvest QA is in Cooking [docs/crops.md](../../cooking/docs/crops.md). - -Manual checks for the SimpleFactions fertility **score**, not growth ticks. - ---- - -## Prerequisites - -- Staging world with `Input/province_id_grid.bin.gz` and `provinces.txt` loaded (see [province-grid.md](./province-grid.md)). -- `config.yml`: `enable-map: true`, `enable-provinces: true`. -- Web map **Fertility** mode (or `provinces.txt`) to pick plots at known fertility **0**, **~40**, and **100**. - ---- - -## Lookup - -| # | Step | Expected | -|---|------|----------| -| 1 | Stand in a fertility 100 province with map enabled. Cooking harvest/growth uses 100. | Harvest is not barren-weighted; growth ticks are not fertility-0 blocked. | -| 2 | Stand in a fertility 0 province. | Harvest hamper; governed crops do not grow (Cooking gate). | -| 3 | Set `enable-provinces: false`, reload. | Cooking growth **allows** ticks. Harvest still sees fertility 0 from `CropFertility.at`. Restore flags after. | - -Bone meal is TFMCCore, not SimpleFactions. - ---- - -## Regression sweep - -```bash -cd simplefactions && mvn test -Dtest="net.tfminecraft.simplefactions.map.fertility.FertilityProvinceResolverTest" -``` - -Cooking: `mvn test` (includes `CropGrowthChanceTest`, `CropGrowthGateTest`). diff --git a/docs/fertility.md b/docs/fertility.md deleted file mode 100644 index 9ddcc440..00000000 --- a/docs/fertility.md +++ /dev/null @@ -1,48 +0,0 @@ -# Province fertility - -Each province has a fertility score **0-100** stored in `provinces.txt` and looked up on the province grid. SimpleFactions exposes that score. **Crop growth cancel and harvest stars live in Cooking.** - -**Province grid:** [province-grid.md](./province-grid.md) · **Map fertility layer:** [ProvinceSystem map overview](../../ProvinceSystem/docs/map/overview.md) · **Cooking crops:** [cooking/docs/crops.md](../../cooking/docs/crops.md) - ---- - -## What SimpleFactions owns - -`FertilityProvinceResolver`: - -- `isActive()` is true only when `Cache.provincesEnabled` and `Cache.mapEnabled` are both true. -- `fertilityAt(x, z)` / `fertilityAt(Location)` reads `ProvinceGrid.getAt` then `ProvinceManager.get(id).getFertility()`. -- Unmapped or invalid province id → **0**. -- Missing plugin / inactive map: Cooking harvest treats fertility as **0**; Cooking **does not cancel growth** when the map is off. - -Cooking calls this API through `CropFertility`. There is no `fertility-crops.yml` and no `BlockGrowEvent` listener in SimpleFactions. - ---- - -## Fertility data source - -Per-province fertility comes from `provinces.txt` field 3: - -``` -id = R,G,B;terrain;fertility -``` - -Mapgen writes the fertility layer; the web map viewer fertility mode reads the same source. See [ProvinceSystem title-editor](../../ProvinceSystem/docs/map/title-editor.md). - ---- - -## Class map - -| Class | Role | -|-------|------| -| `Map/fertility/FertilityProvinceResolver` | Grid + `ProvinceManager` fertility | - ---- - -## Tests - -```bash -cd simplefactions && mvn test -Dtest="net.tfminecraft.simplefactions.map.fertility.FertilityProvinceResolverTest" -``` - -Growth formula, vanilla cancel, and CustomCrops `province-fertility` tests are in the Cooking plugin. diff --git a/docs/installations.md b/docs/installations.md deleted file mode 100644 index fa238a66..00000000 --- a/docs/installations.md +++ /dev/null @@ -1,521 +0,0 @@ -# Installations - -> **Status:** Shipped. See [roadmap.md](./roadmap.md). Vehicle berths: [vehicles.md](./vehicles.md). Campaign raids: [campaign-raids.md](./campaign-raids.md). - -Military **installations** are named structures a faction can build on owned land: forts, ports, and airports. Each **operational** installation appears on the political map via `installations[]` in `map_markers.json`. Under-construction installations are **not** exported. Settlements and installations are independent — a province may have both a city and a fort. - ---- - -## Concepts - -| Term | Meaning | -|------|---------| -| **Installation** | Named fort, port, or airport on a single province (operational) | -| **Construction** | In-progress build; max **one** per faction; not on map until complete | -| **Kind** | `fort`, `port`, or `airport` | -| **Province** | Block coords at construct time; one installation of each kind per province per faction | - -**Invariants:** - -- At most **one of each kind** per province per faction (many forts across different provinces OK). -- **Direct faction ownership only** — `provinceHandler.hasProvince(P)`; vassal/subject land does not qualify. -- Independent of settlements — settlement + fort + port + airport on the same province is allowed. - ---- - -## Data model - -### `installation.Installation` - -| Field | Type | Notes | -|-------|------|--------| -| `id` | `String` | From `Formatter.formatId` | -| `name` | `String` | Display name (colour codes via hex formatter) | -| `kind` | `InstallationKind` | `FORT`, `PORT`, `AIRPORT` | -| `province` | `int` | Province id | -| `centerX` / `centerZ` | `int` | Player block coords at construct | -| `completedAt` | `long` | Epoch ms when construction finished (0 on legacy saves) | - -### `installation.InstallationConstruction` (in-progress) - -| Field | Type | Notes | -|-------|------|--------| -| Same as `Installation` | | id, name, kind, province, centerX, centerZ | -| `timeLeft` | `int` | Seconds until operational | -| `startedAt` | `long` | Epoch ms at enqueue | - -Persisted on faction JSON as `installation queue` (single object or null). - -### `installation.handler.InstallationHandler` (per `Faction`) - -| Responsibility | Notes | -|----------------|--------| -| `byId` | Lookup by operational installation id | -| `byProvinceKind` | One operational per kind per province | -| `pendingConstruction` | At most one in-progress build | -| `construct` | Validate + enqueue construction | -| `tick` | Decrement `timeLeft` each second; complete at 0 | -| `deconstruct` | Remove operational installation or cancel pending build | -| `payDailyUpkeep` | Daily pass in `Faction.newDay()`: withdraw upkeep or dissolve (cheapest first) | -| `onProvinceLost` | Wilderness / no new owner: cancel pending + dissolve operational on province | -| `InstallationTransferService.transfer` | New owner exists: cancel pending on that tile; move completed installs; VF leader sync when a live berthed vehicle exists | -| `validate()` | Cancel pending / dissolve if province no longer owned | -| `serialize()` / `load()` | Operational installations | -| `serializeConstruction()` / `loadConstruction()` | Pending build | - ---- - -## Command - -Player stands in the target province. Block coords taken from player location. - -### `/faction construct ` - -Leader only (`FactionManager.getByLeader`). - -| Check | Behaviour | -|-------|-----------| -| Kind valid | `fort`, `port`, or `airport` | -| Name | Non-blank after trim | -| Ownership | Faction directly owns province | -| Duplicate | No existing installation of same kind on province | -| Land | Province valid and not sea/water | -| Port only | Within `port-sea-proximity-blocks` of sea or river | -| Unique id | No duplicate `id` within faction | -| Queue | Faction not already building another installation | - -On success: enqueue construction (province+kind reserved). Leader sees time remaining. Installation registers and map updates when `timeLeft` reaches 0. - -### `/faction deconstruct [id]` - -Leader only. - -| Form | Behaviour | -|------|-----------| -| No args | Opens **Installations** GUI (`INSTALLATIONS_VIEW`) | -| `` | Opens confirm GUI for that installation or pending build | - -Deconstruct always requires GUI confirm (green/red). Tab-complete includes pending ids. - -### Faction GUI - -Implemented in `Managers/Inventory/InstallationView.java` and `InstallationCreator.java`. - -| Entry | Detail | -|-------|--------| -| Hub slot **32** | Installations tab (`MenuItemType.INSTALLATIONS`, march icon) — summary counts + total upkeep | -| `SFGUI.INSTALLATIONS_VIEW` | List view — operational installations (green concrete), active build in slot 39 | -| `SFGUI.INSTALLATION_DETAIL_VIEW` | Detail lore + deconstruct button (leader only) | -| Confirm | `InventoryManager.confirmView` with key `installation` + installation id | - ---- - -## Territory loss - -**Wilderness / no new owner:** cancel any pending construction on that province and dissolve **all** operational installations on that province (`InstallationHandler.onProvinceLost`). Leader notified if online. - -**New owner:** `InstallationTransferService.transfer` moves completed installs to the new faction and cancels pending construction on that tile. VF owner sync (`player_`) runs when a live berthed vehicle exists. Callers include prestige steal (`MapSystem.claim`: transfer then unclaim), de jure annex, `Guild.elevate`, `Faction.dissolve` into overlord, and war land apply after peace revert. - -## Wartime occupation - -Occupation lists on the war are **not** de jure owner changes. While a tile is occupied (or a siege takes the fort's own province), installs on that tile transfer to the occupying coalition's **war leader**. Snapshot `installationId -> originalFactionId` persists on the war. Recapture restores the snapshot original (vassal), not the recapturing war leader. Peace always reverts the snapshot first; then land apply may transfer again. - ---- - -## Daily upkeep - -Each **operational** installation costs `daily-upkeep` denars per faction day (from `InstallationConfigLoader`). Pending constructions are excluded until complete. - -**Payment** runs in `Faction.newDay()` **after** army upkeep, **before** `guild.newDay()`: - -1. Collect operational installations; sort by `dailyUpkeep` ascending, then `completedAt` ascending. -2. For each: if faction bank has enough → `withdraw(upkeep)`; else destroy installation (cheapest first when broke). - -**Ledger** (`Cashflow.INSTALLATIONS`) on the base guild shows the daily expense total in the guild book GUI. Payment is **not** applied again at `settleIncome()` — same model as army upkeep. - -Leader message on non-payment destroy: `… has been destroyed §7(unable to pay upkeep)`. - ---- - -## Vehicle berth - -Faction leaders can berth **player-owned** VehicleFramework vehicles at operational installations. Personal ownership is VF `player_`. Berthed vehicles get an `INSTALLATION` row in `PlayerVehicleRegistry` (installation id + original owner), count against installation slot capacity, and use VF owner `player_`. - -### Command - -`/faction transfervehicle ` — faction leader only. Tab-complete lists operational installation ids (same as deconstruct). - -On success the leader receives: `§aRight-click the vehicle to transfer it to .` A session is stored until the leader right-clicks a vehicle or the session expires (`transfer-request-timeout-seconds`). - -### Flow - -```mermaid -sequenceDiagram - participant Leader - participant SF as SimpleFactions - participant Owner - - Leader->>SF: /faction transfervehicle installationId - SF->>Leader: Right-click the vehicle - Leader->>SF: right-click vehicle - alt self_owned - SF->>SF: canRegister then register - else other_owner - SF->>Owner: consent prompt - Owner->>SF: /faction accept - SF->>SF: canRegister re-validate then register - end -``` - -### Validation (`InstallationVehicleService.canRegister`) - -Checks run in order; first failure stops the transfer: - -| Check | Rule | -|-------|------| -| Owner | Vehicle VF owner must be `player_` (not `none`) | -| Already berthed | Vehicle must not already have an `INSTALLATION` registry row | -| Category | Vehicle type must map to a `vehicles.yml` category supported by the installation kind | -| Capacity | Sum of vehicle `size` at installation for that category must not exceed `slots.` | -| Radius | Vehicle horizontal XZ distance from installation center must be `<= radius` | -| Province | Vehicle must be in the installation's province | - -**Category hosting** (which installation kind accepts which `vehicles.yml` category): - -| Category | Installation host | -|----------|-------------------| -| `static_emplacements`, `land_vehicles` | fort | -| `ships` | port | -| `aircraft` | airport | -| `train` | none (personal only; `ignore-limit` on all train types) | - -Berth capacity **sums vehicle `size`** per category at the installation. Personal slot limits (below) count **vehicles**, not `size`. - -### Personal claim limits (`VehicleOwnerClaimedEvent` / `VehiclePreInteractEvent`) - -Vehicles spawned outside VFBuilders (e.g. `/vf spawn`) become personal when VF owner is claimed from `none`. SimpleFactions listens and: - -- **Allows** the claim when slot limits allow (no personal registry row is written; VF owner is the source of truth) -- **Skips** slot enforcement when the vehicle is already berthed (`INSTALLATION` row) -- **Cancels** `VehiclePreInteractEvent` and `VehicleOwnerClaimedEvent` when the type is unknown or personal slot limits are exceeded (owner stays `none`; interact does not continue into seats/containers) - -Berthed vehicles keep `INSTALLATION` registry rows; VF owner sync to `player_` does not fire this event. - -### Other-owner consent - -When the leader right-clicks another player's vehicle: - -1. Owner must be **online** -2. Owner must be within `consent-proximity-blocks` of the **vehicle** (horizontal distance) -3. Pre-consent `canRegister` must pass -4. Owner receives consent prompt; leader receives confirmation that the request was sent -5. Owner accepts with `/faction accept` (owner need **not** be a faction leader) -6. Accept re-runs full `canRegister`; on success both players are notified - -Request timeout uses `transfer-request-timeout-seconds`. Expired requests send: `§cVehicle transfer request expired or was cancelled.` - -### VF owner sync - -On berth, VF `ownerData` is set to `player_`. On `VehicleSpawnEvent`, `InstallationVehicleOwnerSync` re-applies the current leader if the registry row is still `INSTALLATION` and the VF owner is stale (e.g. after a leadership change). - -VehicleFramework has no faction logic; SF registry (`INSTALLATION` + `installationId`) is the source of truth for **berths**. Personal ownership is VF `player_`. - -### Chat messages - -| Situation | Message | -|-----------|---------| -| Command armed | `§aRight-click the vehicle to transfer it to .` | -| Out of radius | `§cVehicle must be within blocks of (currently ).` | -| Wrong province | `§cVehicle must be in province (currently ).` | -| No capacity | `§c has no space for (/ used).` | -| Unsupported category | `§cThis installation does not support vehicles.` | -| Not owned | `§cThis vehicle must be owned by a player before it can be berthed.` | -| Already berthed | `§cThis vehicle is already berthed at an installation.` | -| Consent prompt (owner) | `§e wants to berth your at . It will become a faction vehicle. §7/faction accept` | -| Owner offline | `§cThe vehicle owner must be online to transfer this vehicle.` | -| Owner too far | `§cThe vehicle owner must be within blocks of the vehicle.` | -| Success | `§aVehicle berthed at .` | -| Consent timeout | `§cVehicle transfer request expired or was cancelled.` | -| Not leader | `§cYou need to be a faction leader to transfer vehicles.` | -| Unknown installation | `§cUnknown installation id.` | -| No pending session | `§cYou are not transferring a vehicle. Use /faction transfervehicle .` | - -### Out of scope - -- Installation GUI berth list / detail view -- Un-berth (return to personal ownership) -- Faction ledger charge for berthed vehicle upkeep (personal upkeep stops on berth only) - ---- - -## Personal vehicle limits - -When a player starts building a vehicle (`BeginVehicleConstructionEvent`), SimpleFactions enforces personal limits from [`vehicles.yml`](../src/main/resources/vehicles.yml) via `VehicleSlotGuard.checkCanBuild`. - -| Rule | Detail | -|------|--------| -| Total cap | `personal-slot-limit` (shipped: 3); counts **vehicles**, not `size` | -| Per-type cap | `default-per-person` (shipped: 1) with per-type `per-person` override (e.g. land: 3) | -| Trains | `ignore-limit: true` skips the total cap; per-type cap still applies | -| `size` | Used for **installation** berth capacity only; does not affect personal slot counting | - -**Check order:** known type in `vehicles.yml` → per-type cap → `ignore-limit` bypass → total cap (`personal-slot-limit: 0` = unlimited total). - -### Construction chat messages - -| Situation | Message | -|-----------|---------| -| Total cap | `§cYou have reached your personal vehicle limit ().` | -| Per-type cap | `§cYou already have the maximum number of vehicles ().` | -| Unknown type | `§cThis vehicle type is not registered for faction upkeep.` | - -`` is the vehicle type id from config (e.g. `cloudskimmer`, `horse_cart`). `` is the limit that was exceeded. - -### Campaign battle vehicle eligibility - -During **campaign battles** (`battle.warId != null`), berthable vehicles must be berthed at an installation **in play** for the player's faction: - -```text -eligible iff NOT isBerthableType(vehicleTypeId) - OR ( - registry row exists with mode == INSTALLATION - AND installationId IN inPlaySet(playerFaction) - ) -``` - -Missing registry rows are **not** eligible for berthable types. - -| In `inPlaySet` | Source | -|----------------|--------| -| Committed pick | Leader-selected **port** or **airport** for current `battleDay` | -| Defender ZOC port | Current naval slot `portInstallationId`, auto-committed for the defender war leader | -| Siege fort | Active schedule **`SIEGE`** slot `fortInstallationId` owned by that faction | - -- **Trains** and other **non-berthable** types: always eligible as personal vehicles. -- Enforced by `BattleVehicleEligibilityService` on vehicle interact/spawn during campaign battles. -- Full pick rules: [Wars.md installation picks](./wars.md#installation-picks). - -### Campaign installation picks - -Faction leaders commit **ports and airports** for each battle day from the campaign war GUI (**Installations** button, slot 33). Only installations in provinces your coalition still controls are pickable. **Forts** are not pickable; the active **siege** on the campaign schedule puts the owning faction's fort emplacements in play automatically. - -On a current naval slot, the defender war leader's **ZOC port** (`portInstallationId`) is auto-committed and cannot be unpicked. Other pickable ports and airports still toggle. - -Picks lock at **vote close** on battle day. After lock, berth and unberth are blocked on **in-play** installs (see [vehicles.md](./vehicles.md#locks-during-battles-and-raids)). Empty pick means nothing from that faction's installations is in play except a required ZOC port. See [Wars.md](./wars.md#installation-picks). - -### Campaign raid damage and repair - -During **campaign raids**, installation structures and nearby blocks are protected unless the installation is **vulnerable**. Repair on the raid **target** is embargoed after the fight starts. - -#### Damage gating - -Enforced by `InstallationVulnerabilityService` and `InstallationProtectionListener`. - -| Rule | Detail | -|------|--------| -| Default | Installation-tied blocks/entities within `installations.yml` **radius** (default 80) are **protected** from block break, explosions, and entity damage | -| **Vulnerable** when | Active campaign raid **source** or **target**; campaign battle installation in play (committed pick or siege fort); staff raid battle explicitly targeting the installation | -| Staff | Exempt from protection | - -#### Repair embargo - -Enforced by `InstallationRepairEmbargoService` when `installation_repair_embargo_enabled` is true; lock written at fight start by `CampaignRaidLaunchService`. - -| Rule | Detail | -|------|--------| -| Scope | **Target installation only** (not source) | -| Area | Target province + installation radius | -| Start | When fight phase begins (muster end), not when raid completes | -| Duration | `war.campaign_raid.repair_lock_hours` (default **48**) from start | -| Blocks | Place and break for non-staff | -| Repeat raids | **Allowed** on same installation even if embargo active | -| After fight | Embargo **continues** until expiry | -| Toggle | `war.campaign_raid.installation_repair_embargo_enabled` (default **true**); set `false` to allow block repair immediately | - -#### Vehicle berth (71.12) - -Enforced by `VehicleInstallationLockService`. - -| Rule | Detail | -|------|--------| -| Who | Vehicles with `OwnershipMode.INSTALLATION` (berthed) and new berths at the same installation | -| During battle/raid | Cannot berth or unberth if the installation is **vulnerable** (raid source/target, campaign battle in-play, staff raid target) | -| After vote close | Cannot berth or unberth on **in-play** installs: committed picks, defender ZOC port, siege fort. Other ports stay open. | -| After raid | **Target only** keeps the 48h lock for new berths | -| Repair | Always allowed (personal and berthed vehicles) | -| Staff | Exempt from berth lock | - -#### Config (`war.campaign_raid`) - -| Key | Default | Meaning | -|-----|---------|---------| -| `muster_seconds` | 60 | Join window after leader confirms source + target | -| `duration_seconds` | 600 | Fight timer after muster ends | -| `repair_lock_hours` | 48 | Post-raid lock duration (installation block repair when enabled; berth lock on target) | -| `installation_repair_embargo_enabled` | true | Block place/break on raid target during post-raid lock | -| `intruder_damage_interval_ticks` | 10 | Province intruder damage cadence | -| `intruder_damage_amount` | 4 | Damage per intruder tick | - ---- - -## Dissolve - -1. Remove from `byId` and `byProvinceKind`. -2. Enqueue map update. -3. Notify faction leader if online. - ---- - -## Map export - -See `Map/export/Markers.java` — `map_markers.json` per installation: - -| Field | Source | -|-------|--------| -| `id` | installation id | -| `name` | display name | -| `kind` | `fort`, `port`, or `airport` (lowercase) | -| `faction_id` | owning faction | -| `province_id` | province | -| `center_x` / `center_z` | construct coords | - -ProvinceSystem enriches `map_x` / `map_y` from `center_x` / `center_z` (1:1). Frontend renders fort/port/airport pins on political map modes. - -### Fort ZOC export (`forts[]`) - -Operational **forts only** (same set as `installations[]` where `kind == fort`). Map pins remain on `installations[]`; `forts[]` drives zone-of-control data and hatch overlays. - -| Field | Notes | -|-------|--------| -| `id`, `name`, `faction_id`, `province_id` | Same as installation | -| `center_x` / `center_z` | Pin position | -| `zoc_provinces` | Sorted unique province ids — computed by `ZocRealm` | - -**`zoc_provinces` rules:** fort province + one-ring **land** neighbors whose owner shares the **same top realm** (`RelationManager.getTopLiege` or faction id). Sea and unclaimed neighbors excluded. - -**Active wars:** during a campaign war, who **controls** a fort's ZOC for siege gating may differ from installation owner (`fortControllers` on the war JSON). Siege winner becomes controller; installation DB ownership unchanged. **Map export is war-aware (shipped ):** when a fort is referenced on an active war and has a `fortControllers` entry, `zoc_provinces` is computed from that coalition's war leader realm; otherwise the installation owner is used. See [Wars.md](./wars.md#campaign-battle-schedule-locked). - -#### PS + frontend - -| Stage | Behaviour | -|-------|-----------| -| **PS `zocgen`** | Unions `zoc_provinces` pixel mask; tiles diagonal hatch → `output/{map}/zoc/{id}.png` + `defines/{map}/zoc_overlays.json` | -| **Triggers** | `map_markers` upload and `fullregen` | -| **API** | `GET /{map}/data/markers` → `forts[].overlay`, `zoc_url`, `map_x`/`map_y` | -| **Static** | `GET /{map}/zoc/{id}.png` | -| **Frontend** | Hover fort pin on political map modes → `hoveredFortZoc` hatch layer (separate from nation `hoveredOverlay`) | - -Port and airport pins have no ZOC. Pending construction forts are excluded from `forts[]`. - ---- - -## Config - -**`plugins/SimpleFactions/installations.yml`** (loaded after [`vehicles.yml`](../src/main/resources/vehicles.yml) at enable): - -```yaml -consent-proximity-blocks: 20 -transfer-request-timeout-seconds: 60 - -fort: - radius: 80 - daily-upkeep: 50 - construction-time: 10 # 432000 (5 days) - slots: - static_emplacements: 8 - land_vehicles: 2 -port: - radius: 80 - daily-upkeep: 20 - construction-time: 10 # 259200 (3 days) - slots: - ships: 8 -airport: - radius: 80 - daily-upkeep: 35 - construction-time: 10 # 259200 (3 days) - slots: - aircraft: 10 -``` - -| Field | Kind | Dev value | Production (comment) | -|-------|------|-----------|----------------------| -| `consent-proximity-blocks` | root | 20 | Owner must be within this many blocks of vehicle for consent | -| `transfer-request-timeout-seconds` | root | 60 | Transfer session and consent request expiry | -| `radius` | fort / port / airport | 80 | Horizontal berth distance from installation center | -| `daily-upkeep` | fort / port / airport | 50 / 20 / 35 denars per day | - | -| `construction-time` | fort | 10 seconds | 432000 (5 days) | -| `construction-time` | port / airport | 10 seconds | 259200 (3 days) | -| `slots.` | per kind | see above | Capacity for vehicle category (sum of vehicle `size` when berthed) | - -`slots` keys must match a category id in `vehicles.yml` (e.g. `ships`, not `ship`). Slot values are integer capacity (sum of vehicle `size` for berthed vehicles at that installation). - -Loaded at enable by `InstallationConfigLoader` (fail loud if missing or unknown category). Access: `getDailyUpkeep(kind)`, `getConstructionTimeSeconds(kind)`, `getRadius(kind)`, `getConsentProximityBlocks()`, `getTransferRequestTimeoutSeconds()`, `getCategorySlotCapacity(kind, categoryId)`, `getCategorySlots(kind)`. - -**`config.yml`** still holds `port-sea-proximity-blocks`. **`war.yml`** holds `war.port_sea_zoc_radius`. Installation upkeep/construction/slots live in `installations.yml`. - -**Live servers:** copy `installations.yml` from the jar default; remove the old `installations:` block from `config.yml`. Add `land_vehicles: 2` under `fort.slots` when merging an existing file. Vehicle categories live in `vehicles.yml`. - -### `vehicles.yml` personal-limit keys - -Loaded at enable by `VehiclesConfigLoader` (before `installations.yml`). - -| Key | Location | Rule | -|-----|----------|------| -| `personal-slot-limit` | root | Total personal cap; `0` = unlimited; counts vehicles not `size` | -| `default-per-person` | root | Default per-type cap when type omits `per-person` | -| `default-upkeep` | root | Optional fallback when type omits `upkeep` | -| `upkeep` | per type | Required unless `default-upkeep` present | -| `size` | per type | Installation berth units only | -| `per-person` | per type | Overrides `default-per-person` | -| `ignore-limit` | per type | When `true`, type does not count toward total personal cap | - -Access: `getPersonalSlotLimit()`, `getDefaultPerPerson()`, `getPerPersonLimit(typeId)`, `ignoresPersonalSlotLimit(typeId)`, `isKnownType(typeId)`. - -`port-sea-proximity-blocks` is active (default 20). - ---- - -## Package layout - -```text -installation/ - Installation.java - InstallationKind.java - InstallationKindConfig.java - InstallationConstruction.java - InstallationBounds.java - handler/ - InstallationHandler.java - ConstructResult.java -vehicles/ - InstallationVehicleService.java - InstallationVehicleOwnerSync.java - VehicleIntegrationListener.java - VehicleRegistryClaimService.java - VehicleRegistryClaimListener.java - VehicleTransferListener.java - VehicleTransferConsentService.java - VehicleSpawnListener.java - VehicleIntegrationListener.java - VehicleTransferSessionManager.java - VehicleTransferMessages.java - VehicleConstructionMessages.java - VehicleSlotGuard.java - CanBuildResult.java - VehicleCategoryRules.java - PlayerVehicleRegistry.java - VehicleTypeConfig.java - … -Loaders/ - InstallationConfigLoader.java - VehiclesConfigLoader.java -Managers/Inventory/ - InstallationView.java - InstallationCreator.java -Database/ - InstallationData.java - InstallationConstructionData.java -``` - ---- diff --git a/docs/map-export.md b/docs/map-export.md deleted file mode 100644 index ee8c4de1..00000000 --- a/docs/map-export.md +++ /dev/null @@ -1,211 +0,0 @@ -# Map export - -SimpleFactions exports political map data to **ProvinceSystem** via the **TFMCWeb** gateway. The website reads uploaded JSON from the map data store; incremental regen redraws only changed provinces. - -**ProvinceSystem side:** [integrations/simplefactions.md](../../ProvinceSystem/docs/integrations/simplefactions.md) · **Wars overlay:** [map/wars-on-map.md](../../ProvinceSystem/docs/map/wars-on-map.md) · **Schema:** [map-export-schema.json](../../ProvinceSystem/docs/assets/map-export-schema.json) - ---- - -## Config - -| Key | Role | -|-----|------| -| `enable-map` | Master switch; when false, uploads and regen are skipped | -| `enable-provinces` | In-game land grid; default true. When false, Input grid is not loaded, land/war commands are blocked, and `enable-map` is forced off | -| `enable-chronicle` | Chronicle snapshot upload; default true. Independent of `enable-map` so a broken chronicle never blocks a map regen | -| `map-reference` | Map id in upload/regen URLs (e.g. `main`, `dev`) | -| `api.base-url` | TFMCWeb gateway (softdepend config, not in `config.yml`) | - -`Cache.mapRef` mirrors `map-reference` at load time. All upload paths use `/{mapRef}/data/upload/{mode}`. - ---- - -## Upload modes - -`RestServer.upload(mode, file)` POSTs JSON to TFMCWeb. `MapSystem` exports files under `plugins/SimpleFactions/MapAPI/` (and title JSON under `Input/`), then uploads: - -| Mode | File | Payload | -|------|------|---------| -| `nation` | `MapAPI/nation.json` | Faction colours / borders | -| `province_data` | `MapAPI/province_data.json` | Per-province trade/prosperity; wartime `occupied_by` (occupier faction id) | -| `guilds` | `MapAPI/guilds.json` | Guild markers | -| `map_markers` | `MapAPI/map_markers.json` | Settlements, installations, forts, wars | -| `chronicle` | `MapAPI/chronicle.json` | Wealth / prestige / territory snapshot for season graphs | -| `infestation_data` | Infestations plugin `MapAPI/infestation_data.json` | Per-province infestation severity/group (uploaded by Infestations via `RestServer.upload`) | -| `county` / `duchy` / `kingdom` / `empire` | `Input/*.json` | De jure title trees | -| `queue` | `MapAPI/queue.json` | Incremental province/border change list | - -Validation runs in `RestServer.validate(mode, payload)` before the POST. - -`map_markers`: - -- Root must be a JSON object with a `settlements` array. -- If present, `installations` and `forts` must be arrays. - -`chronicle`: - -- Root must be a JSON object with `captured_at` and a `factions` array. - ---- - -## Regen - -| Trigger | Mechanism | -|---------|-----------| -| Incremental | `MapSystem.updateMap()` uploads `queue` + full payloads, then `RestServer.commenceRegen("queued")` | -| Live only | `MapSystem.updateLiveData()` uploads the live payloads, then `commenceRegen("trade")` | -| Full | `/faction fullregen ` or `MapSystem.fullRegen()` → `commenceRegen("full")` | -| Nation-only queue | `/faction regen` enqueues all nations | - -Regen URL: `GET /{mapRef}/{REGEN_HASH}/api/regenerate/{queued|full|trade}`. `REGEN_HASH` is currently hardcoded in `RestServer` (move to config before production; see [dev-config.md](./dev-config.md)). - -`trade` redraws trade and prosperity overlays without touching nation borders or title geometry. Until ProvinceSystem implements it the call fails, gets logged and swallowed, so the uploads still land. - ---- - -## Tick cadence - -`MapSystem.tick()` splits the payloads by cost. Trade and chronicle change continuously with no map queue involvement, so they ship on every cycle; nation geometry and markers only when there is queued work. - -| Every | Condition | Path | Payloads | -|-------|-----------|------|----------| -| 3600 s | always | `queueAllNations()` → `updateMap()` | all | -| 300 s | queue non-empty | `updateMap()` | `queue`, live, `nation`, `map_markers`, titles | -| 300 s | queue empty | `updateLiveData()` | live only | - -Live payloads are `province_data`, `guilds` and `chronicle` (`prepareLiveFiles` / `uploadLiveFiles`). Map payloads are `map_markers` and `nation` (`prepareMapFiles`). - -The 3600 s branch is checked first and unconditionally. As an else-branch it could be starved indefinitely by a queue that happened to be non-empty on the crossing tick. - -Faction state is saved before each queued upload/regen. The live path skips the per-faction save loop because nothing on it reads `Data/*.json`. - ---- - -## `map_markers.json` shape - -Built by `Markers.export()`: - -| Top-level key | Source | -|---------------|--------| -| `map_id` | `Cache.mapRef` | -| `exported_at` | ISO-8601 instant | -| `settlement_large_population_threshold` | Config | -| `settlements[]` | Faction capitals + named settlements | -| `installations[]` | Operational forts, ports, airports | -| `forts[]` | Fort ZOC province lists (`zoc_provinces`, war-aware via `ZocRealm`) | -| `wars[]` | Active campaign wars only (`WarMapExporter`) | - -Under-construction installations are **not** exported. - ---- - -## `chronicle.json` shape - -Built by `ChronicleExport.export()` / `ChronicleSnapshot.build()`. A point-in-time record of every stock and flow the website needs to graph a season, uploaded every 300 s. - -Stocks are absolute; ProvinceSystem differences consecutive snapshots for deltas. Flows are shipped explicitly because they cannot be recovered from stock differences (a flat treasury may mean no activity, or trade income exactly cancelling upkeep). Flows are always the ledger **projections**, never the daily accumulators, which are cleared at settlement and would sawtooth across a 5 minute cadence. - -| Top-level key | Meaning | -|---------------|---------| -| `schema_version` | Snapshot schema version (currently 1) | -| `map_id` | `Cache.mapRef` | -| `captured_at` | ISO-8601 instant (real time) | -| `server_day` | Completed day rollovers (`FactionManager.day`, persisted in `Cache/data.json`) | -| `day_progress_seconds` | Seconds into the current in-game day (`FactionManager.timer`) | -| `complete` | Always true from a successful export. Absence of a faction only means deletion when set | -| `global` | Server-wide aggregates | -| `factions[]` | Per-faction rows | -| `guilds[]` | Per-guild rows | -| `events[]` | Reserved, always empty. The event stream is still owned outside SF | - -`server_day` counts server uptime, not calendar days, so it drifts against `captured_at` across downtime. Both ship: the in-game pair is the honest axis for economic continuity, `captured_at` is for display. - -### `global` - -`faction_wealth`, `pouch_wealth`, `player_bank_wealth`, `liquid_wealth`, `guild_liquid_wealth`, `node_wealth`, `expansion_wealth`, `guild_income`, plus `faction_count`, `guild_count`, `claimed_provinces`, `population`, `active_wars`, `max_wealth_prestige`. - -The three wealth figures are **not** pre-summed. `FactionManager.getGlobalWealth()` excludes all personal money, so total money supply is a website decision. - -### `factions[]` - -| Group | Fields | -|-------|--------| -| Identity | `id`, `founded_at`, `name`, `rgb`, `overlord`, `subjects[]` | -| Wealth | `wealth`, `wealth_breakdown{}`, `bank`, `vassal_wealth` | -| Flows | `net_income`, `inflation_delta`, `trade_power` | -| Prestige | `prestige`, `prestige_breakdown{}`, `rank`, `rank_level`, `rank_up_at`, `rank_down_at` | -| Standing | `prestige_position`, `wealth_position` | -| Territory | `provinces`, `realm_size`, `tier`, `tier_index`, `highest_title` | -| People | `members`, `members_with_vassals`, `settlements`, `population` | -| Assets | `installations`, `forts` | -| Conflict | `wars[]` | - -`founded_at` (epoch seconds) exists because faction ids come from `Formatter.formatId(name)` rather than a UUID, and `deleteFaction` frees the name. ProvinceSystem keys identity on `(id, founded_at)` so a recycled name does not splice two unrelated nations into one line. - -`prestige_breakdown` is load-bearing, not decoration. The `Wealth` component is a share of global wealth, so a faction's prestige falls when rivals get richer even with its own finances flat. Without the components those dips are unexplainable on a chart. - -`rank_up_at` / `rank_down_at` come from `FactionManager.getRankUpAmount`, which is competitive rather than fixed. The website draws threshold lines from these, never from `ranks.yml` minimums. - -### `guilds[]` - -`id`, `faction_id`, `name`, `type`, `wealth`, `bank`, `expansions`, `trade_power`, `credit_score`, `size`. The only view of the merchant economy separate from state finances. - ---- - -## `wars[]` route slice (shipped) - -`WarMapExporter.exportWars()` includes active wars that have a non-empty `campaign_provinces` axis. `WarType.RAID` (legacy/staff raid template wars) and wars with no axis are excluded. **Pillage** goals with a campaign axis **are** exported. - -Per-war object (snake_case): - -| Field | Meaning | -|-------|---------| -| `id`, `name`, `war_type`, `goal`, `status` | War identity | -| `attacker_leader_id`, `defender_leader_id` | Leader faction ids | -| `belligerents[]` | All participating faction ids | -| `campaign_provinces[]` | Route polyline (province ids) | -| `cursor_index` | Campaign cursor on the line | -| `objective_province_id` | War goal province (optional) | -| `push_target` | Push/hold/counter state | -| `campaign_schedule_index`, `campaign_counter_schedule_index` | Active slot indices | -| `campaign_battle_schedule[]` | Invasion leg slots | -| `campaign_counter_schedule[]` | Counter-push leg (optional) | -| `attacker_capital`, `defender_capital` | `{ province_id, center_x?, center_z? }` | -| `occupied_by_attacker[]` | Province ids in the attacker occupation bulge | -| `occupied_by_defender[]` | Province ids in the defender occupation bulge | - -Each schedule slot: - -| Field | Meaning | -|-------|---------| -| `schedule_index`, `leg` | `invasion` or `counter` | -| `province_id`, `kind`, `kind_label`, `battle_type` | Battle placement | -| `required`, `status` | `fought` / `next` / `upcoming` | -| `display_name` | UI label for map pin hover | -| `fort_installation_id`, `port_installation_id` | Siege/naval anchor (nullable) | - -### `province_data[].occupied_by` (shipped) - -`Compiler.exportProvincesToJson` sets `occupied_by` to the occupying **war leader** faction id via `OccupationMapExport.occupierByProvince`. ProvinceSystem remaps those tiles to the occupier nation colour and fills `occupied_held` for labels. This is not inferred from territory diffs. - -ProvinceSystem enriches coordinates and renders the campaign line, battle pins, occupier fill, and campaign-line front. Details: [wars.md](./wars.md#web-map-campaign-visualization). - ---- - -## Admin commands - -| Command | Action | -|---------|--------| -| `/faction regen` | Queue all nations + upload | -| `/faction fullregen ` | Full map regen for map id | - -After war schedule or installation changes, wait for the next `map_markers` upload or trigger regen manually so the website picks up new pins. - ---- - -## Related docs - -- [wars.md](./wars.md) - campaign system and map visualization rules -- [installations.md](./installations.md) - installation and fort ZOC export -- [province-grid.md](./province-grid.md) - local grid (not uploaded) -- [dev-config.md](./dev-config.md) - `map-reference: dev`, regen hash diff --git a/docs/mercenaries.md b/docs/mercenaries.md deleted file mode 100644 index f15763aa..00000000 --- a/docs/mercenaries.md +++ /dev/null @@ -1,98 +0,0 @@ -# Mercenary companies - -A **mercenary company** is a band of soldiers for hire, hosted and owned by a guild but with its own membership. A company is hired by contract onto one side of a war, fights only where its contract sends it, and is never a belligerent. - -**War-side rules** (participants, lives, attendance, loyalty): [wars.md](./wars.md). - ---- - -## How it works - -| Piece | Rule | -|-------|------| -| **One company per guild** | The host guild owns it. Company money is guild money; there is no second bank | -| **Leader** | Always the guild leader, and leadership follows when the guild leader changes | -| **Membership** | Invite and accept, not restricted by guild, faction or nationality. A player may be in one company at a time | -| **Slots** | A company owns slots, one soldier each. It cannot hire more players than slots, nor promise more slots in overlapping contracts than it owns | -| **Expansion** | Blocked while any slot sits unfilled, so capacity is built in peacetime rather than conjured when war breaks out. Survives a restart mid-expansion | -| **Upgrades** | Health, mana and mana regen, capped. The buffs apply **only** while the player fights as a hired mercenary, never in the world and never in a battle they joined as a normal faction fighter | -| **Contracts** | Every figure is an absolute denar value written into the contract at signing, never a percentage of anyone's income. Signing is **local**: you must be at the company's home settlement | -| **Wages** | An active share of what the slot earns, plus an optional flat peacetime wage per day. Both take a base and per-player overrides, and both are paid by the host guild | -| **Reputation** | An int from 0 to 100 starting at 50, shown on `/mercenaries` and stamped on the contract book at signing | - -**Termination:** the duration elapsing ends a contract normally. Dropping below the promised slots pays the breach refund and takes a large reputation hit. Host guild bankruptcy terminates with no refund, because a bankrupt guild is inert in both directions. A loyalty conflict appearing mid-contract terminates with no refund and no reputation change, since neither party caused it, and the days already served are still paid. - -## Money - -Six ledger lines move on a mid-contract daily tick. The company host guild reads `MERCENARY_CONTRACT` as income and `REFUND_PAYMENTS` plus `WAGE_PAYMENTS` as expenses; the hiring faction capital reads `MERCENARY_PAYMENTS` as an expense and `REFUNDS` as income. Contract income and refunds are separate lines and are never netted against each other. The soldier sees `WAGES` in `/ledger`. - -Contract income is gross-counted business income, so it feeds guild tax and the tribute and reparations bases. Refunds are not gross-counted: taxing compensation for a failure to deliver would make the refund figure mean different things per faction tax rate. - -Slot upkeep is billed to the host guild through `MILITARY_UPKEEP` and company upgrade upkeep through `UPGRADES_UPKEEP`, alongside whatever the guild already pays. A bankrupt host guild silently voids every contract it holds, which is why the company screen shows daily burn, expected contract income, net position, and a warning when burn exceeds what the guild earns. - ---- - -## Config - -Keys live in three files. Nothing here is a percentage of income by design. - -### `config.yml` (read into `Cache` by `ConfigLoader`) - -| Key | Default | Role | -|-----|---------|------| -| `mercenary-formation-cost` | `100.0` | One-off charge, taken from the host guild when the charter is requested | -| `mercenary-formation-seconds` | `86400` | Founding time. The company arrives with 1 slot | -| `mercenary-slot-upkeep` | `8.0` | Denars per slot per day, paid by the host guild, not by or to the soldier | -| `mercenary-min-price-per-battle` | `50.0` | Floor on price per slot per battle. A company may charge more | -| `mercenary-min-price-per-day` | `10.0` | Floor on price per slot per day. Charged on battle days too | -| `mercenary-max-contract-days` | `14` | Longest contract duration | -| `mercenary-default-breach-refund` | `500.0` | Pre-fill only; the real figure lives on the contract | -| `dividend-require-previous-tick-membership` | `true` | Blocks payday-joining for guild dividends | - -### `regiments.yml` - -| Key | Default | Role | -|-----|---------|------| -| `mercenary.expansion-time` | `86400` | Slot expansion time. Blocked while an unfilled slot exists | -| `mercenary.mercenary` | `true` | Keeps the type out of every faction military; only a company clones it | -| `mercenary.upkeep` | `8.0` | Mirror of `mercenary-slot-upkeep`; the ledger reads the `config.yml` key | - -### `Guilds/company-upgrades.yml` - -| Key | Default | Role | -|-----|---------|------| -| `.upkeep` | `10` | Denars per day **per purchased level** | -| `.max-level` | `10` | Hard cap. Guild upgrades omit this key and stay uncapped | -| `.expansion-time` | `86400` | Purchase time | - -Shipped upgrades: `company_health` (+0.5 max health per level), `company_mana` (+1 max mana per level), `company_mana_regen` (+0.1 mana regen per level). - -### Not config keys - -- **Dividend percent** starts at 0% and is a per-guild field the guild leader sets in the guild GUI, not a YAML key. -- **Reputation** starts at 50, is persisted on the company, and moves only through contract outcomes. -- **Wage terms** are per company: an active percentage and a flat peacetime figure, each with per-player overrides. - -### The one non-configurable rule - -The **absence refund per slot per battle must be at least the per-slot per-battle price**, validated when the contract is created. A smaller refund would make no-showing more profitable per head than fighting, which inverts the entire incentive, so there is deliberately no key to lower it. - ---- - -## Timing on the test server - -All three time keys are **real seconds** on the once-per-second faction tick, so `86400` is 24 hours of wall clock, not a campaign day. Dev overrides and the manual verification matrix are in [dev-config.md](./dev-config.md). - -## Worked example - -At the config minimums with a 20% active wage base, a soldier earns **2 denars per day** and **10 denars per battle**. A battle day pays both. - ---- - -## Related documentation - -| Doc | Topic | -|-----|--------| -| [wars.md](./wars.md) | Participants, shared lives, attendance, loyalty | -| [dev-config.md](./dev-config.md) | Dev-only timings and bypasses | -| [roadmap.md](./roadmap.md) | Shipped vs planned | diff --git a/docs/prestige.md b/docs/prestige.md deleted file mode 100644 index f05f58a1..00000000 --- a/docs/prestige.md +++ /dev/null @@ -1,91 +0,0 @@ -# Prestige - -**Prestige** is a faction's standing. It gates rank, province cap, de jure annexation headroom and diplomatic capacity, and it is shown as a breakdown so a leader can see where the number comes from. - -Two pieces exist per faction: the cached scalar `Faction.prestige`, and the `List` breakdown that produced it. Both are rebuilt together by `Faction.updatePrestige()`. - -**Assembly:** `Objects/PrestigeBreakdown.java` - **Input gathering:** `Faction.updatePrestige()` - **Playtime / trade curves:** `prestige/` - ---- - -## The terms - -Rebuilt from scratch on every recompute, in this order. The order matters because the bonus multiplies only the lines above it. - -| Line | Formula | -|------|---------| -| Persistent modifiers | Carried over untouched. Admin grants via `/faction addprestigemodifier`, war outcomes, and anything else flagged persistent | -| **Members** | `pow(memberCount + 4, 1.8) + 5`, **plus** the playtime sum below | -| **Wealth** | `(wealth / globalWealth) * max-prestige-from-wealth`, capped at the faction's own wealth | -| **Trade** | Linear `tradePower * prestige-per-trade-power` up to `prestige-from-trade-soft-cap` (default 2000). Past that the marginal rate decays toward `prestige-from-trade-falloff` (default 0.1) over each further cap-sized band, so it never hard-caps. Own guilds only; overlords still get a cut of subject prestige via **Subjects** | -| **Provinces** | `province tier prestige * provinceCount` | -| **Titles** | Prestige of the highest title tier held | -| **Subjects** | `sum(subject.getPrestige() * givePercent / 100)` over vassals | -| **`% Bonus`** | `sum(everything above) * pct / 100` | - -`prestige` is the sum of all lines. Rebuilding is idempotent: a recompute never sees its own previous bonus line. - -## The Members term - -Headcount alone rewards mass recruitment, so **Members is two halves added together**: the headcount curve, plus what each member's time on the server is worth. - -``` -Members = pow(memberCount + 4, 1.8) + 5 + sum over members of 2^(online hours / 100) -``` - -The per-member half doubles every 100 online hours and flattens at `max-prestige-playtime-exponent`: - -| Online hours | Per member | -|--------------|-----------| -| 0 | 1 | -| 100 | 2 | -| 200 | 4 | -| 300 | 8 | -| 400 | 16 | -| 500 and up | 32 | - -Both halves land in the single `Members` line rather than a separate line, so the GUI and the chronicle export keys are unchanged. - -Notes on the edges: - -- A member the playtime index has never seen is **skipped**, not counted as a fresh character. A roster of names that never held a character earns nothing. -- Playtime is **per character**, not per account, and only the **active** character's time counts. A permakilled character stops earning the moment it dies. -- Without RPCharacters the whole playtime half is 0 and prestige behaves exactly as it did before this term existed. - -## Where playtime comes from - -RPCharacters owns the counter. See [rpcharacters/docs/playtime-tracking.md](../../rpcharacters/docs/playtime-tracking.md). - -Do **not** reach for `RPCharacter.getAgeSeconds()`. That is wall-clock time since the character was created and keeps climbing while the player is offline, so it would cap out in about three weeks regardless of whether anyone logged in. - -SimpleFactions reads it through a probe seam, the same pattern as `MercenaryEligibility`: - -| Piece | Role | -|-------|------| -| `prestige/PlaytimePrestige` | The curve. Pure math, no Bukkit | -| `prestige/MemberPlaytime` | The seam and the roster sum. Default probe knows nobody | -| `prestige/RpCharactersPlaytimeProbe` | Reads the RPCharacters playtime index. Installed in `SimpleFactions.onEnable` only when that plugin is enabled | - -## Performance - -`updatePrestige()` is a **hot path**. `Bank` deposits and withdrawals call `Guild.updateWealth()`, which reaches `Faction.updateWealth()`, which ends in `FactionManager.updateAllPrestige()` - a recompute for **every** faction on **every** bank mutation. Each recompute may also walk up the overlord chain. - -So a `MemberPlaytime.Probe` must answer from memory. The RPCharacters index is an in-memory map that also covers offline players, which is why the probe does no disk or network work. Never add I/O behind that interface, and never loop `updateWealth()` over factions to refresh before reading. - -## Config - -`config.yml`, read into `Cache` by `ConfigLoader`: - -| Key | Default | Meaning | -|-----|---------|---------| -| `max-prestige-from-wealth` | `800` | Ceiling on the Wealth line, shared out by share of global wealth | -| `prestige-per-trade-power` | `0.1` | Prestige per point of the faction's own guilds' trade power, before the soft cap. `0` hides the Trade line | -| `prestige-from-trade-soft-cap` | `2000` | Linear Trade prestige up to this value. `0` disables the soft cap | -| `prestige-from-trade-falloff` | `0.1` | Marginal rate after one more cap-sized band. `1` is no falloff; `0` hard-caps at the soft cap | -| `max-prestige-playtime-exponent` | `5` | Ceiling on the per-member playtime exponent. `5` means a member tops out at `2^5` = 32, reached at 500 online hours | - -Other prestige inputs are not `Cache` keys: per-tier prestige in `tiers.yml`, `minimum-prestige` per rank in `ranks.yml`, and the `prestige` / `prestige_bonus` modifiers plus `scale: relative_prestige` in `diplomacy.yml`. - -## Tests - -`PrestigeBreakdownTest` covers assembly and idempotency. `PlaytimePrestigeTest` covers the curve and the cap. `TradePrestigeTest` covers the trade soft cap and falloff. `MemberPlaytimeTest` covers the roster sum, unknown members, and that the default probe leaves prestige untouched. diff --git a/docs/province-grid.md b/docs/province-grid.md deleted file mode 100644 index 1a5debf6..00000000 --- a/docs/province-grid.md +++ /dev/null @@ -1,110 +0,0 @@ -# Province grid - -> **Status:** Shipped (local O(1) province lookup). - -SimpleFactions loads a prebuilt **province ID grid** at enable time. Block coordinates map 1:1 to grid indices, returning a province id in O(1). Used by claim, setcapital, construct, and port proximity checks. - ---- - -## Purpose - -| Before | After | -|------------------|-------| -| `RestServer.getProvince()` HTTP to ProvinceSystem | Local `ProvinceGrid.getAt(x, z)` | -| Port sea proximity impractical at scale | `ProvinceSpatial` scans nearby grid cells | - ---- - -## Paths - -| Artifact | Location | -|----------|----------| -| PS source PNG | `ProvinceSystem/backend/src/input/{map}/provinces.png` | -| PS RGB → id map | `defines/{map}/provinces.txt` | -| **PS grid output** | `defines/{map}/province_id_grid.bin.gz` | -| **SF input** | `plugins/SimpleFactions/Input/province_id_grid.bin.gz` | -| SF MapAPI | **Output only** — grid is **not** loaded from MapAPI | - -**No `assets/` folder.** Copy grid manually from PS defines to SF Input after rebuilding. - ---- - -## Binary format - -Gzip-compressed: - -1. `width` — int32 little-endian -2. `height` — int32 little-endian -3. `width × height` uint16 values, row-major -4. `0` = no province - -Block X/Z ↔ grid index 1:1 (same as PS `find_province`). Example: 6400×6400 map ≈ 82 MB uncompressed array in memory. - ---- - -## SF classes - -| Class | Role | -|-------|------| -| `Map.ProvinceGrid` | Load gzip; `getAt(x, z)` → province id or 0 | -| `Map.ProvinceSpatial` | `isSeaAt`, `withinBlocksOfSea`, `withinConfiguredPortSeaProximity` | -| `REST.RestServer` | `getProvince(Player)` → grid lookup (no HTTP) | -| `SimpleFactions` | Load grid on enable; `getProvinceGrid()` | - -### Fail loud - -If `Input/province_id_grid.bin.gz` is missing on enable, the plugin **disables** (same severity as missing `provinces.txt`). - ---- - -## Build workflow - -Grid is **not** auto-generated on regen. Run manually when map geometry changes. - -**1. Build grid (ProvinceSystem):** - -```bash -cd ProvinceSystem/backend/src -python -m scripts.tools.build_province_id_grid --map main -``` - -Output: `defines/main/province_id_grid.bin.gz` - -**2. Copy to SimpleFactions:** - -```text -defines/main/province_id_grid.bin.gz - → plugins/SimpleFactions/Input/province_id_grid.bin.gz -``` - -Dev template copy lives at `simplefactions/src/main/resources/Input/province_id_grid.bin.gz`. - ---- - -## Port proximity - -Ports require the construct location to be within **N** blocks of a sea/water province cell. **N** = `port-sea-proximity-blocks` in `config.yml` (default `20`). - -`ProvinceSpatial.withinConfiguredPortSeaProximity(x, z)` scans the grid using this config value. - ---- - -## Config - -```yaml -port-sea-proximity-blocks: 20 -``` - -Loaded via `ConfigLoader` into `Cache.portSeaProximityBlocks`. - ---- - -## Package layout - -```text -Map/ - ProvinceGrid.java - ProvinceSpatial.java -``` - ---- diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index 7e69b6d5..00000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,40 +0,0 @@ -# Roadmap - -## Next - -Everything SimpleFactions owns is shipped. Diplomacy polish, the chronicle snapshot, war companies and declare codes are all done, and assassins have been dropped. - -- **Map chronicle events** - other member; SF hooks for ProvinceSystem (`war_declared`, `battle_scheduled`, `battle_result`, `province_occupied`, `war_ended`) - -## Shipped - -- Province fertility score 0-100 on the grid (`FertilityProvinceResolver`); crop growth and harvest quality are Cooking ([fertility.md](./fertility.md), [cooking/docs/crops.md](../../cooking/docs/crops.md)) -- Declare codes and ticket gate - staff mint a one-time code in Discord with the `factions` cog's `/warcode mint`, the attacking leader types it in chat, and it pins the war goal so the picker is skipped. Realm-scoped and hashed in ProvinceSystem (`war_declare_codes`), reached through TFMCWeb's gateway, which injects the realm id. Redeemed only once `declareWar` returns a war, so a navy-gate refusal does not burn a ticket. `simplefactions.admin` bypasses the gate, which is what lets it fail closed. Staff look up faction ids with `/war admin factions [filter]`. -- War companies: [mercenaries.md](./mercenaries.md) (reference). Army recruitment rule, guild dividends, mercenary companies and slots, contracts and the market, war participation with attendance and shared lives, wages, and company reputation. Company PvP stats are `GuildModifier` entries on the company, not normal guild upgrades, and only apply while a member fights as a hireling. -- Guild dividends - leader sets a percent of the guild's dividend base; eligible members split it equally on the daily tick, the faction withholds dividend tax, and the shares show in `/ledger`. Previous-tick membership is required by default (`dividend-require-previous-tick-membership`) so nobody joins on payday. -- Chronicle snapshot - `chronicle.json` uploaded every 300 s with per-faction wealth, prestige, rank and territory for season graphs ([map-export.md](./map-export.md)). Includes the prestige idempotency fix and `PrestigeRank` persistence. -- Guild ↔ faction GUI links; Friendly attitude used-cap; rival/hostile/unfriendly relative-prestige diplomatic capacity curve -- Council-forced white peace and surrender (political action on a chosen war; sticky offer or immediate surrender) -- NAP treaty overlay (diplomacy slot right of the nation icon; stacks with tributary; `blocks-war` declare block) -- Automated campaign wars (pathfinder, initiative, occupation bulge, battle scheduling) -- Inter-vassal wars (peer/cousin declare, CTA for all wars, liege transit, internal subjugate via `transferSubject`) -- War-goal declare and auto-apply (navy gate, relation/title/law/pillage goals, movement apply gate, civil wars, inter-vassal) -- Civil wars (temp rebels, land split, untangle then apply; no auto reparations on defender win) -- Pillage war type (one-battle settlement; distinct from campaign raids) -- Warbands, military commitment, collective lives, casualty ledger -- Battle runtime (field, siege, raid templates), battle dev mode for staging -- Campaign time dev mode (`/war admin time`, route **Starts in** countdown) -- Strategic retreat during voting (concede slots without initiative cost; **Retreated** route lore) -- Mid-fight battle retreat (`/warband retreat` on started campaign field/siege battles; ledger casualties only) -- Campaign GUI live refresh (1s) and vote-close hour lock -- Campaign battle schedule, fort/port ZOC, naval invasions, dual-leg counter-push -- Installation transfer with province owner; wartime occupation then revert at peace -- War campaign map export: route line, battle pins, `occupied_by_*` on `wars[]`, `occupied_by` on `province_data` -- Installations (fort, port, airport), settlements, province grid -- Vehicle berths at installations, personal slot limits, battle vehicle eligibility -- Campaign installation picks, vehicle in-play, siege fort on schedule slot -- Campaign raids (inter-battle installation assaults) - -Canonical war gameplay spec: [wars.md](./wars.md) - -Scratch list: [TODO.md](../TODO.md). diff --git a/docs/settlements.md b/docs/settlements.md deleted file mode 100644 index 9bd3181e..00000000 --- a/docs/settlements.md +++ /dev/null @@ -1,136 +0,0 @@ -# Settlements - -> **Status:** Shipped (map markers, one province per settlement). - -Settlements are **named cities** on the political map. A faction owns zero or more settlements. Each settlement occupies **exactly one province** — the province where it was founded — with a display name and map marker coordinates (`centerX` / `centerZ`). - -Guild and faction **capitals** are separate: they point at a province. A guild counts toward a settlement’s population when its capital province **is** that settlement’s province. - ---- - -## Concepts - -| Term | Meaning | -|------|---------| -| **Settlement** | Named city on a single province | -| **Centre province** | The only province in the settlement (`centerProvince`) | -| **Capital (faction/guild)** | Province id on `Faction` / `Guild` — seat of government or guild HQ | -| **Population** | Guilds whose `capital` equals the settlement’s `centerProvince` | - -**Invariant:** At most one settlement per province per faction. Faction land and settlement territory are independent — claiming a province does not add it to any settlement. - ---- - -## Data model - -### `settlement.Settlement` - -| Field | Type | Notes | -|-------|------|--------| -| `id` | `String` | From `Formatter.formatId` | -| `name` | `String` | Display name | -| `centerProvince` | `int` | Sole province; always the only entry in `provinces` | -| `centerX` / `centerZ` | `int` | Block coords at founding (map marker) | -| `provinces` | `Set` | Always `{ centerProvince }` after load/validate | - -### `settlement.handler.SettlementHandler` (per `Faction`) - -| Responsibility | Notes | -|----------------|--------| -| `byId` / `provinceIndex` | Lookup; index maps centre province → settlement | -| `found` | Create settlement on one province | -| `resolveCapital` | Set capital in existing city or found new with name | -| `onProvinceLost` | Lose settlement province → dissolve | -| `validate()` | Normalize to single province; dissolve if centre not owned | - ---- - -## Commands - -Player stands in the target province. Block coords taken from player location when **founding**. - -### `/faction setcapital [name]` - -| Situation | Behaviour | -|-----------|-----------| -| Faction has **0 provinces** | **Require** `name` → claim + found settlement + set capital | -| Province **has** a settlement | Set faction capital (no name) | -| Province **has no** settlement | **Require** `name` → found new settlement | - -### `/guild setcapital [name]` - -Same as faction, except base guild must use `/faction setcapital`. - -### `/faction claim` - -Adds faction territory only. **Does not** create or expand settlements. - ---- - -## Founding - -`/setcapital ` on a province without a settlement: - -1. Create `Settlement` with `centerProvince = P`, coords from player. -2. `provinces = { P }` only. -3. Set guild or faction capital to `P`. - -Adjacent provinces may have **separate** settlements — no distance rule. - ---- - -## Relocate - -Last guild leaving a settlement (relocate, capital clear, guild remove) → **dissolve**. Destination uses the same `/setcapital` rules: existing city if the province has one, otherwise require a name and found. - ---- - -## Territory loss - -When the faction **loses** the settlement’s province → **dissolve** the settlement (clear capitals in that province, remove from handler). - ---- - -## Dissolve - -When centre province is lost or last guild leaves the city: - -1. Clear guild/faction capitals on that province. -2. Remove settlement from handler. -3. Enqueue map update. - ---- - -## Population - -```text -population(S) = { guild g in faction | g.capital == S.centerProvince } -``` - ---- - -## Map export - -See `Map/export/Markers.java` — `map_markers.json` per settlement: - -| Field | Source | -|-------|--------| -| `province_id` | `centerProvince` | -| `center_x` / `center_z` | founding coords | -| `provinces` | `[centerProvince]` | -| `kind` | `faction_capital` if faction capital == centre | -| `population` / `marker_size` | guild count vs threshold | - ---- - -## Package layout - -```text -settlement/ - Settlement.java - handler/ - SettlementHandler.java - CapitalResult.java -``` - ---- diff --git a/docs/vehicles.md b/docs/vehicles.md deleted file mode 100644 index e24f33a7..00000000 --- a/docs/vehicles.md +++ /dev/null @@ -1,133 +0,0 @@ -# Vehicles - -Player-owned **VehicleFramework** entities integrate with SimpleFactions through `vehicles/` subpackages: `registry/` (berthed records), `berth/` (slots, transfer, category rules), `maintenance/` (upkeep, unpaid decay, pouch pay), and `battle/` (campaign eligibility). - -**Installations:** berth radius, operational state, and campaign picks are in [installations.md](./installations.md). - ---- - -## Config (`vehicles.yml`) - -Loaded by `VehiclesConfigLoader` at plugin enable. - -| Key | Default | Role | -|-----|---------|------| -| `personal-slot-limit` | `3` | Max vehicles per player (unless type overrides) | -| `default-upkeep` | `4` | Denars per upkeep tick when type omits `upkeep` | -| `default-per-person` | `1` | Default `per-person` cap per type | -| `categories.*` | See file | Nested vehicle types by category | - -Per-type keys (under each category): - -| Key | Role | -|-----|------| -| `upkeep` | Denar cost per upkeep cycle | -| `size` | Slot weight (cruiser/behemoth use 2+) | -| `per-person` | Override personal cap for this type | -| `ignore-limit` | When `true`, does not count toward `personal-slot-limit` (trains) | - -Categories in shipped config: `land_vehicles`, `train`, `ships`, `static_emplacements`, `aircraft`. - ---- - -## Package layout - -| Class | Role | -|-------|------| -| `registry/PlayerVehicleRegistry` / `VehicleRegistryPersistence` | Berthed (`INSTALLATION`) vehicle records only | -| `registry/VehicleOwnershipQueries` | Personal vehicles from VF owner minus berthed UUIDs | -| `berth/VehicleSlotGuard` | Personal limit + `ignore-limit` checks at claim/build | -| `berth/InstallationVehicleService` | Berth at port/airport/fort installations | -| `berth/VehicleTransferConsentService` / `VehicleTransferListener` | Two-party transfer flow | -| `VehicleSpawnListener` | Re-apply faction-leader VF owner on spawn for berthed vehicles | -| `VehicleIntegrationListener` | VF construction events (sets VF owner; no personal registry row) | -| `maintenance/VehicleUpkeepService` | Periodic denar upkeep for unberthed VF-owned vehicles | -| `battle/BattleVehicleEligibilityService` | Campaign battle in-play checks | -| `berth/VehicleInstallationLockService` | Berth embargo during battles and raids | -| `berth/VehicleCategoryRules` | Berthable vs train/static categories | - -VF-specific logic stays in `vehicles/`; installation bounds and handler state stay in `installation/`. - ---- - -## Personal slots - -Personal ownership is the VehicleFramework owner (`player_`). SimpleFactions does not store a second personal row. - -When a player claims or builds a vehicle: - -1. Resolve type config from `vehicles.yml`. -2. Count VF-owned vehicles for that player, **excluding** any UUID currently berthed at an installation. -3. If `ignore-limit: true`, skip the total cap (locomotives, rail cars). -4. Else apply `personal-slot-limit` and type `per-person`. - -Unowned (`none`) interact is cancelled when the claim would exceed those limits. - -`VehicleConstructionMessages` and `VehicleSlotGuard` surface player-facing errors. - ---- - -## Installation berths - -Operational **ports** and **airports** accept berthable vehicles within installation bounds (`InstallationBounds` + config radius). - -| Flow | Detail | -|------|--------| -| Berth | Player brings vehicle into installation radius; VF transfer hooks | -| Unberth | `InstallationVehicleUnberthService` | -| Owner sync | `InstallationVehicleOwnerSync` on faction/installation changes | - -Forts do not berth vehicles. Campaign **installation picks** choose which port/airport vehicles are in-play for a battle day; see [installations.md](./installations.md#campaign-installation-picks). - ---- - -## Campaign battle eligibility - -`BattleVehicleEligibilityService` checks: - -- Vehicle is at a **committed** installation for the current battle day, **or** -- Siege fort from the active schedule slot (`fortInstallationId`) for the owning faction. - -Trains and non-berthable categories follow `VehicleCategoryRules`. Listener blocks ineligible spawns during active campaign battles. - ---- - -## Locks during battles and raids - -| Lock | Service | -|------|---------| -| Berth / unberth embargo | `VehicleInstallationLockService` | -| Installation damage gating | `InstallationVulnerabilityService` (see [campaign-raids.md](./campaign-raids.md)) | - -After `BattleInstallationPickService.isLocked` (vote close), berth **and** unberth are blocked on **in-play** installs: committed picks, defender ZOC port, siege fort. Ports not in play stay open for the next battle day. - -Vehicle repair is always allowed. Raid **target** keeps the post-raid berth lock (`war.campaign_raid.repair_lock_hours`, default 48h). Raid/battle vulnerability embargo is unchanged. - -### Official navy at naval launch - -A war attacker contests a naval slot only if some attacker-side participating faction has an in-play **port** with a berthed vehicle whose type maps to category `ships` (`PlayerVehicleRegistry` `INSTALLATION` row). Personal unberthed ships do not count. See [wars.md](./wars.md#attacker-naval-launch). - ---- - -## Economy - -`DenarEconomyPlayerBank` implements `PlayerBank` for vehicle upkeep charges against faction/player denars. - ---- - -## Tests - -```bash -cd simplefactions && mvn test -Dtest="net.tfminecraft.simplefactions.vehicles.**" -``` - -Key tests: `VehicleSlotGuard`, `BattleVehicleEligibilityService`, `VehicleInstallationLockService`, `VehicleOwnershipQueries`. - ---- - -## Related docs - -- [installations.md](./installations.md) - ports, airports, construction -- [wars.md](./wars.md) - installation picks and vehicle in-play -- [campaign-raids.md](./campaign-raids.md) - installation repair embargo on raid targets -- [dev-config.md](./dev-config.md) - construction timing on test server diff --git a/docs/wars.md b/docs/wars.md deleted file mode 100644 index 38d23a41..00000000 --- a/docs/wars.md +++ /dev/null @@ -1,1127 +0,0 @@ -# Wars - automated campaign system - -> **Status:** See [roadmap.md](./roadmap.md) for shipped vs planned features. -> -> **Website:** [ProvinceSystem map wars overlay](../../ProvinceSystem/docs/map/wars-on-map.md) · [map-export-schema.json](../../ProvinceSystem/docs/assets/map-export-schema.json) -> -> **Campaign raids:** [campaign-raids.md](./campaign-raids.md) - -## Why this exists - -Previous-season wars were informal: players arranged fights in Discord, staff sometimes ruled outcomes, and the in-game war GUI was barely used. That caused drama and unfairness. - -**v1 automated wars** are fully system-driven after declare. The Discord ticket / one-time **declare code** is a **production gate**, not shipped yet (`war.require_declare_code: false` by default). Staff set battle **rule presets** in templates (lives, friendly fire, keep inventory, durations). Staff place **battle geometry** (spawns, jails, capture points) per scheduled fight via `/battle edit`. Players vote on battle times, sign up via warband join, and the campaign advances on the map without manual organisation. - ---- - -## Design principles - -| Principle | Rule | -|-----------|------| -| **Automated** | Campaign route, schedule, progression, goal enforcement, and occupation are system-driven. | -| **Staff-light** | Staff maintain **rule presets** and place battle geometry per scheduled fight; ticket/code gate only in production. No mid-battle rulings. | -| **Transparent** | Campaign line, occupation zones, next battle, votes, and initiative visible in-game and on the web map. | -| **Wars encouraged** | Reparations are rare and attacker-only. White peace and initiative exhaustion avoid punishing failed wars too harshly. | -| **One goal** | One war = one war goal. No per-participant goal picking. | - ---- - -## Declaration flow - -Every declare runs the same pre-checks: no existing war with that target, opinion at or below `war.declare_opinion_threshold`, and at least one target member online. The declare code gate sits after those. - -### Production - -`war.require_declare_code: true` in `war.yml`. - -1. Players open a **Discord ticket** (war type, target, goal, belligerents). -2. Staff review, then mint a **one-time declare code** with `/warcode mint attacker defender goal [hours]`. Faction ids come from `/war admin factions [filter]` in game. The code is shown once; a lost code is revoked and reminted. -3. In-game: diplomacy → **Declare War** → the plugin asks for the code **in chat** (type `cancel` to back out). There is no anvil or sign input, so chat is the only free-text path. -4. The code is checked without being spent, and **pins the war goal**: the goal picker is skipped entirely and the leader lands on that goal's own sub-picker (title, subject, relation type, settlement, government) or straight on confirm. -5. On confirm, `WarManager.declareWar` runs. The code is **redeemed only if a war came back**, so a refusal from `WarGoalValidator`, `CampaignDeclareValidator` or `CampaignNavyGate` leaves the ticket spendable. - -**Staff bypass:** `simplefactions.admin` skips the code entirely. That bypass is what makes the gate safe to **fail closed** - if ProvinceSystem is unreachable or slow (`war.declare_code_timeout_seconds`), the declare is refused rather than waved through. - -### Development / testing - -- `war.require_declare_code: false` (the default). Declare war **directly in-game** from the diplomacy GUI; the goal picker opens as normal. -- All goal validation and FSM rules still apply; only the code check is skipped. - -**Code properties:** one-time use, expiry, bound to attacker/defender/goal/realm, audit log (`DECLARE_CODE` in the war log, plus `DECLARE_CODE_UNSPENT` if the war exists but redemption failed). - -Codes live in ProvinceSystem (`war_declare_codes`), realm-scoped, hashed with no plaintext column. SimpleFactions reaches them through TFMCWeb's gateway, which injects the realm id because the plugin does not know its own. Routes: [ProvinceSystem/docs/identity/tfmcweb.md](../../ProvinceSystem/docs/identity/tfmcweb.md). - ---- - -## War goals (locked) - -Generic **conquest** is **not** a goal. The goal defines the political outcome. **One war = one goal**, chosen at declare. - -**Do not** add a second diplomacy/law/tax engine. Apply calls `RelationManager`, `FactionManager.usurp`, `Faction.applyLaw`, tax handlers, and one movement apply gate. - -**War defender** is the **top liege** of the clicked faction. The goal payload may still be a nested vassal, title, or settlement. - -### Shared declare blocks - -Cannot declare (goal exceptions are in the planning lock) if: same realm (vassal / overlord / nested), ally, **NAP** (treaty overlay), or tributary unless the goal is **subjugate** or **War**. Usurp may target **direct overlord** only. - -**NAP** is a diplomacy **treaty overlay** (slot right of the nation icon), not the Ally / Tributary / Subject political type. It can stack with tributary. While it is in effect, **all** declares are blocked, including Subjugate and War against a tributary. Clear it with **No Treaty** first. - -**Navy (implemented):** if the generated **invasion** schedule includes a naval slot and the attacker has no **operational port**, declare is rejected (`You need an operational port for a naval path. Source ships before the battle.`). Empty port is allowed. Ships are not counted at declare or Push/Hold. If the next battle after a win is naval and that coalition has no port, they cannot **Push** (must **Hold**). - -### Goal list - -| Goal | Layer 2 | On attacker win | -|------|---------|-----------------| -| **Tributary** | None | `setRelationForced` tributary (not vassal) | -| **Subjugate** | Subject type (not Integrated; `getWarPickableVassalTypes`) | Chosen vassal type | -| **De jure annex** | Title (show blocked reasons) | Defender-realm provinces in title transfer; **unowned title is not granted** | -| **Transfer subject** | Nested realm faction | `transferSubject` | -| **Usurp** | None | `FactionManager.usurp` (primary title + subjects) | -| **Overthrow** | Movement / leader | Decline demands starts a civil war; apply gate + coup (stub). Not on the nation declare picker | -| **Change law** | Law GUI (movement) | Decline demands starts a civil war; law + Civil War stability. Not on the nation declare picker | -| **Change tax** | Tax pick + chat (movement) | Decline demands starts a civil war; rate + Civil War stability. Not on the nation declare picker | -| **Open market** | None (law ids in war-goal config) | Configured free-trade law + stability | -| **Change government** | Gov ± leadership | Laws + stability | -| **Pillage** | Settlement | Trade-income hit + loot (not a campaign raid) | -| **War** | None | Pickable; no auto-apply (ticket codes later) | -| **Revolt** | None | Staff / no-apply label; civil war uses overthrow / change law / change tax | - -**De jure:** own the title, **or** title unowned and you own at least one province in it. No settlements in the title. Prestige must cover incoming land. Rank gate: title at or below attacker rank. Victory is still a single **objective province** (see [Objective province](#objective-province)), not 100% occupation. - -**Pillage vs campaign raid:** pillage is a **war type / goal**. Campaign raids stay inter-battle installation assaults ([campaign-raids.md](./campaign-raids.md)). - -### On war end - -| Outcome | Apply | -|---------|--------| -| Attacker victory | Goal (civil war: movement apply on the restored host) | -| Defender victory | **External:** reparations from attacker (no goal). **Civil war** (`movementId` / snapshot): restore, empty movement, **no** auto reparations | -| White peace / admin | Neither. Civil war: restore, empty movement, **no** auto reparations | - ---- - -## War types (campaign shape) - -| Type | Campaign | Battles | End | -|------|----------|---------|-----| -| **De jure / subjugate / transfer / usurp / diplomatic / law** | Border → objective province (and capital push if counter-invasion) | Campaign battles on schedule | Goal applied or reparations / white peace | -| **Pillage (war type)** | Shortest path border → **one settlement** | **One** battle | Pillage apply + war ends (no return battle) | - -**Pillage distance:** YAML `range_provinces` (default **3**): settlement within that many provinces of attacker land borders, **or** within that many of sea **and** `hasSeaConnection` between the realms. Disconnected oceans, landlocked attackers, and settlements deeper than that range cannot be pillaged. There is no airborne pillage. - -### Campaign raids - -**Not a war type.** Between scheduled **campaign** battles, faction leaders may launch **campaign raids** during the [raid window](#battle-day-timeline) on battle day. See [campaign-raids.md](./campaign-raids.md). - -**Distinguish from:** - -| Term | Meaning | -|------|---------| -| **Campaign raid** (this section) | 19-20 inter-battle installation assault; timer fight; no plugin scoring | -| **Pillage war** | One-battle **settlement** war goal/type (land range or connected-sea range) | -| **Staff `BattleType.RAID`** | Manual template battle with capture points (dev/lore tool; unchanged) | - -#### Timeline (battle day, Europe/Paris) - -| Phase | Default | Rule | -|-------|---------|------| -| Raid **call** window | 19:00-20:00 | May **initiate** a campaign raid only (`raid_window_start_hour` / `raid_window_end_hour`) | -| Campaign warband signup | **Blocked** 19:00-20:00; **open** 20:00-21:00 | `CampaignWarbandSignupService` | -| In-flight raids | May overrun past 20:00 | Muster (60s) + fight timer (10 min) | - -#### Launch and flow - -| Rule | Detail | -|------|--------| -| Who | Faction **leader** on a belligerent side | -| GUI | Campaign view **Start raid** → page 1 **source** (own port/airport) → page 2 **target** (enemy installation) | -| Quota | **One raid per coalition side** per `battleDay`; first leader to confirm spends the side quota | -| Mutex | **One active campaign raid** per war at a time | -| Muster | **60s** after confirm; broadcast + `/raid join ` | -| Join | **Attacker coalition** only; must **not** already be in any warband | -| Fight | **10 min** timer (`BattleEndReason.TIMER`); no winner scoring | -| Attacker TP | **Source** installation center at fight start | -| Defenders | Title + horn at fight start; **no** teleport; respawn at **target** center | - -#### Source and target eligibility - -Uses `CampaignRaidEligibilityService` (not installation picks). - -| Role | Rule | -|------|------| -| **Source** | Launching faction's **operational** `port` or `airport` (any; pick not required) | -| **Target** | Any enemy **operational** `port`, `airport`, or `fort` | -| **Raid kind** | `NAVAL` (port→port), `AIR` (airport→airport), `FORT` (port/airport→fort); cross-kind invalid | - -**Installation picks** control battle vehicle in-play and post-lock intel only. They do **not** limit campaign raid targets. - -#### Warbands - -| Rule | Detail | -|------|--------| -| Ids | `{raid_slug}_attacker` / `{raid_slug}_defender` (e.g. `harbor_raid_attacker`) | -| Exclusion | Cannot join raid if in **any** warband (including campaign shell) | -| Defenders | Online at fight start + login during raid → defender raid warband if warband-free | - -#### Fight rules (`campaign_raid_template`) - -| Rule | Value | -|------|-------| -| Capture points | **None** | -| Win condition | **Timer** or all raiders eliminated (defender win); no early end from defender logout | -| Attacker lives | **One each**; death or disconnect = out | -| Defender respawn | **Infinite** at target installation center | -| `keep_inventory` | `true` for raid participants | -| Province fence | **None** | -| Intruders | Attacker-coalition players in **target province** who are not raid participants (or eliminated) take periodic damage + `§cYou are not part of this raid. Leave the area!`; **normal death** (no battle keepInventory) | - -Installation **damage gating** and **repair embargo** on the raid target: see [installations.md](./installations.md#campaign-raid-damage-and-repair). Vehicle repair is always allowed. The 48h target lock still blocks new berths at the raid target. - -Config (`war.campaign_raid`): `muster_seconds` (60), `duration_seconds` (600), `repair_lock_hours` (48), `installation_repair_embargo_enabled` (true), `intruder_damage_interval_ticks` (10), `intruder_damage_amount` (4). - ---- - -## Participants - -War participants: - -- **Attacker / defender** sides with **main participants** -- **Subjects** auto-included on participant side (direct subjects of each main) -- **Allies** via call-to-arms (`/faction accept`, 60s timeout) -- **Mercenary companies** hired by contract on one side, listed with their promised slots. A third kind, neither main nor secondary, and never a belligerent: see [Mercenaries (locked)](#mercenaries-locked) below. -- **No switch sides in war GUI.** Subject independence / rebellion uses the **movement system**, not a war-view button. - -**Internal (inter-vassal) wars:** two factions that share a top liege and are **not** on each other's overlord path. Defender is the clicked faction, not the king. The liege is not a participant and is not callable. - -**Call to arms (all wars):** the caller must be a **main**. The target must be an unjoined ally on that main's ally snapshot (match by faction id). The target must not already be participating, must not be the overlord of a main, must not be nested under an enemy participant, and must not have a top liege who is already a main on either side. Same-realm allies are callable only when those rules hold. - -**Declined call to arms:** **-30% stability** (config), decays over time. - -**Multiple wars** per faction are allowed in design; implement **one war FSM first**, tag all military commits with **`war_id`** from day one. - -**War leaders** (main attacker / main defender faction leaders) can still surrender and offer white peace on the campaign map. Council, leader proposal, or a movement can also force that side: **White Peace** (sticky offer on a chosen war) or **Surrender** (immediate). A rebel civil-war win for those causes applies the same action. - -### Militia (locked) - -| Rule | Detail | -|------|--------| -| Militia fights only on **that faction's direct land** | Province owner = faction | -| **Overlord militia** does **not** deploy in **vassal** territory | Overlord sends army + levies | -| Battle in **vassal land** | Vassal's **full** military including militia joins; overlord sends non-militia + levies | - -### Mercenaries (locked) - -A **mercenary company** is a guild-owned band of soldiers for hire. It fights where its contract sends it and is the third kind of thing that can be on a war side, alongside belligerents and their allies. Reference: [mercenaries.md](./mercenaries.md). - -| Rule | Detail | -|------|--------| -| **Hired by contract** | A company joins a side only through a signed contract naming the war, the side, and the promised slots | -| **Listed with promised slots** | The war screen shows the company and the slot count it promised, not a regiment row | -| **Never a belligerent** | A company is not a participant: it has no war goal, no initiative, no call to arms, and cannot be declared on because it fought | -| **Never a party to a peace deal** | White peace, surrender, and goal apply ignore companies entirely; a contract ends on its own terms | -| **The host faction stays out** | The company's **host faction** (the one its guild belongs to) does **not** become a participant because its citizens fight for hire | - -**Loyalty:** a company can never fight **its own host faction**, whichever side the contract is on. A rostered mercenary who is a plain, **non-government citizen of another faction may fight their own faction**; a leader or council member of that faction may not. A contract that becomes illegal (an ally joins on the opposing side, an overlord bond forms) terminates with no refund and no reputation change, and the days already served are still paid. - ---- - -## Campaign route generation - -Uses a new **`ProvincePathfinder`** module (not embedded in `ProvinceManager` trade pipeline). Graph: province adjacency from `province_neighbors.json` + terrain from `provinces.txt`. Edge costs: terrain-weighted (plains preferred over mountains), same philosophy as trade `terrain-modifiers`. - -### WATER vs SEA (locked) - -| Terrain | Pathfinder rule | -|---------|-----------------| -| **WATER** (rivers, lakes) | Always crossable on **land passes** at normal terrain cost (~`0.75`). Routes may cross water so paths do not zigzag around rivers. | -| **SEA** (ocean) | **Impassable** on land pass 1. Only traversable in **pass 2** (naval/amphibious). | - -**Note:** `Province.isSea()` returns true for both WATER and SEA. Pathfinder code must use `terrain == Terrain.SEA` for ocean logic, not `isSea()`. - -### Neutral provinces (locked) - -**Neutral** splits into two cases for pathfinding: - -| Type | Definition | Route UI | -|------|------------|----------| -| **Wilderness** | Province has no owner (`owner == null`) | Gray tiles on campaign route | -| **Liege transit** | Internal war only: owner is the war's top liege, or shares that top liege, and is **not** a belligerent | Gray tiles; land-pass allowed like wilderness; not occupied | -| **Foreign nation** | Owner exists, is not a belligerent, and is not liege transit | Gray tiles on campaign route | - -Belligerent set = attacker side + defender side, including subjects and called allies. - -| Pass | Wilderness | Liege transit | Foreign nation | -|------|------------|---------------|----------------| -| **1** Land | Allowed at normal terrain cost | Allowed | Blocked | -| **2** Sea | N/A (land tiles irrelevant on sea hops) | N/A | Blocked on **land** tiles; sea tiles always allowed | - -`war.pathfinder.neutral_penalty` is no longer used by the pathfinder fallback chain. - -### Route priority (locked) - -Run passes **in order**; first pass that finds a route wins: - -1. **Land campaign** - land + WATER + wilderness + liege transit (internal); no SEA; foreign nations blocked -2. **Sea campaign** - SEA hops between coastal belligerent provinces; foreign-owned land blocked - -No sea-first routing; land (including through wilderness) is always preferred when possible. - -**Future (not v1):** generate 2–3 alternative campaigns (e.g. long land vs risky naval); attacker chooses after declare. - -### Step A — border start (conquest / de jure / subjugate) - -**Intent:** shortest invasion corridor from **attacker–defender border** toward **objective province**, not capital-to-capital through messy borders. - -1. For each province **B** on the **defender** side of the border (defender-owned adjacent to attacker-owned), pathfind **B → objective** using passes 1→2. -2. Pick **B** with minimum total cost → **`campaignStartProvinceId`** (first battle / invasion entry). -3. If no land border: use defender provinces adjacent to **sea** as **B** candidates; pathfind **B → objective** (sea landing on enemy soil). - -### Step B — full campaign axis (locked) - -Shipped **`B → objective`** only. A **full axis** is built at declare / `warpath` regen: - -```text -← ATTACKER [ attacker capital … … atk border … B … … objective ] DEFENDER → - ↑ - cursor_index (first battle) -``` - -**B** is the first **defender-owned** province on the invasion route (first battle on enemy soil). The attacker-owned border province remains on the axis but is not **B**. - -1. **Step A:** pick invasion entry **B** (defender-side; see above). -2. **Capital-closer rule:** if defender/subject **faction capital** is closer from **B** than the regional objective (path cost), **capital replaces** `objectiveProvinceId`; rebuild right segment. -3. **Left segment:** pathfind **attacker faction capital → B** (full path, always at declare). -4. **Right segment:** pathfind **B → objective** (inclusive). -5. **`campaign_provinces[]`** = merged left + right (**B** once). -6. **`cursor_index`** = index of **B** in the array (**middle**, not `0`). -7. **`campaignStartProvinceId`** = **B**. - -**Counter-push:** defender fights **leftward** on the **existing** line toward attacker capital - no polyline append at choice time. - -### Pillage war type route (shipped) - -`WarGoalType.PILLAGE`. Shortest path: attacker border (or connected-sea landing) → **one settlement**. One battle at the settlement, empty counter. Navy gate still applies if the natural path has a naval slot. Distinct from [campaign raids](./campaign-raids.md). - ---- - -## Objective province (locked) - -One province represents the de jure / vassal / regional target for capture and recake battles: - -| Condition | Pick | -|-----------|------| -| Title/region **capital** in set | Capital province | -| Else **largest settlement** | Province with largest settlement; **capital settlement beats non-capital**; population/size tiebreaker | -| No settlements | **Geometric center** of title/region provinces | - -All capture/recapture battles occur **at this province**. No multi-province occupation requirement to win. - -When **capital itself** is the war target, capital province is the objective. - -**Capital vs regional target (locked):** for subjugate / transfer / de jure wars, if the defender (or subject) **faction capital** is strictly **closer** from the campaign border start than the regional objective picked above, **faction capital replaces** the regional objective for both **`objectiveProvinceId`** and capture/recapture battles. De jure title capital in-set already wins via the table above; this rule covers cases where settlement/centroid pick would otherwise skip a nearer faction capital. - ---- - -## Campaign progression - -### Battle cadence - -- **One campaign battle per day** (config) inside **battle window** (default **21:00-24:00** Europe/Paris; see [Battle day timeline](#battle-day-timeline)). -- Exact hour chosen by [voting](#battle-scheduling--voting). -- **First invasion land battle** is at border **B**, unless an enemy fort ZOC covers B (and B is not the objective). Then the siege is first (fort home, possibly off-axis with `chronologyProvinceId`). -- **Two battle lists** are built at declare (see [Campaign battle schedule](#campaign-battle-schedule-locked-70)), each trimmed to per-goal **`max_battles_per_leg`**, then fought via the **active** leg index for the current `pushTarget`. -- Fort ZOC covering a tile → **siege first**. The field that tile would have had is omitted unless that tile is the **objective** (siege then required field). -- **Field cadence:** default **`war.battle_cadence.provinces_between_battles: 3`**. Each leg walks its segment once; place a non-required **field** slot when `offset % N == 0` from the leg start (`N` = config value). This is a **grid from leg start**, not step-since-last-battle counting. - - **Invasion:** `cadenceOrigin = borderIndex` (`cursor_index` at declare); offset = `abs(axisIndex - borderIndex)`. - - **Counter:** `cadenceOrigin = borderIndex - 1` (first tile **left** of border **B**); offset = `abs(axisIndex - (borderIndex - 1))`. - - Sieges, naval, and required terminal slots do not suppress cadence on the same province when rules both apply. - -**Target feel:** default **4** slots per leg after trim (up to **8** total across both directions). Example: invasion 4 slots / counter 2 slots → starting fuel **6 / 3** at `initiative_factor` **1.5**. Back-and-forth spends fuel until exhaustion or victory. - -### Campaign battle schedule (locked ; updated ) - -At declare, after the campaign axis is set: - -```text -built = CampaignScheduleBuilder.buildAll(war, axis, cursorIndex, objectiveIndex, capitalIndex, fortIndex, portIndex) -invasionTrimmed = CampaignScheduleTrimmer.trimInvasion(built.invasion(), max_battles_per_leg[goal]) -counterTrimmed = CampaignScheduleTrimmer.trimCounter(built.counter(), max_battles_per_leg[goal]) -war.campaignBattleSchedule = invasionTrimmed -war.campaignCounterSchedule = counterTrimmed -war.campaignScheduleIndex = 0 -war.campaignCounterScheduleIndex = 0 -initiativeAttacker = ceil(invasionTrimmed.size × initiative_factor) -initiativeDefender = ceil(counterTrimmed.size × initiative_factor) -``` - -Only **`CampaignBattlePlacer.placeBattle`** mutates a leg list. Each slot is **inserted** at the correct axis position (fight order = list order = geographic order along that leg's segment). Same-province tie-break: siege → optional field → required field. - -#### FB legs - -| Leg | Segment | List order | -|-----|---------|------------| -| **Invasion** | FB province → DT (defender target) | Chronological along axis (increasing index) | -| **Counter** | `axis[cursorIndex - 1]` → AC (aggressor capital) | Chronological along axis (decreasing index); never includes border **B** | - -**FB** = first invasion battle (required **FIELD** at border **B** = `campaign_provinces[cursor_index]`). If a **NAVAL** battle happens before landing, NAVAL is list index **0** and the FB field at **B** stays at index **1**. - -**Example (Brume vs Lantan):** axis `452, 782, 758, 757, 672, 709, 713, 705`. Invasion list: `713 SIEGE` → `705 required` when Greenfort ZOC covers `709` (no `709` field). Optional `795 NAVAL` prefix when harbour covers sea. Geographic GUI row: `452 - 782 - 672 - 713 siege - 705`. - -#### Two legs (axis walk) - -| Leg | Axis walk | First battle province | -|-----|-----------|----------------------| -| **Invasion** | border → objective | `campaign_provinces[cursor_index]` (border **B** / FB) | -| **Counter** | `borderIndex - 1` → aggressor capital | First slot **left of border** (border itself is invasion-only) | - -Natural slot rules (field cadence, fort siege, port naval) apply on **each leg independently** with coalition-appropriate direction. New wars do **not** emit `NAVAL_INVASION` slots (enum kept for old saves / display only). - -#### Two layers: template vs display - -| Layer | Purpose | Values | -|-------|---------|--------| -| **`BattleType`** | Win rules (capture vs contest) | `FIELD`, `SIEGE` (no `BattleType.NAVAL`) | -| **`CampaignBattleKind`** | GUI label / staff setup expectations | `Field Battle`, `Siege`, `Naval Battle`, `Naval Invasion` (legacy saves) | - -Objective battles use **field** template and **Field Battle** display (required slot, never trimmed). Naval kinds use **`FIELD`** template plus **`navalVariant`** on the battle (staff sea spawn layout). - -#### Natural slots (before trim) - -| Slot | When | `BattleType` | Display | -|------|------|--------------|---------| -| **Border / FB** | Phase 1 at border **B**: optional **FIELD**, unless an enemy fort ZOC covers B (and B is not the objective). Then the siege is the first land battle, whether the fort home is on-axis, off-axis, or is B itself. | `FIELD` or `SIEGE` | Field Battle or Siege | -| **Siege** | Axis passes through province in operational fort ZOC; fort controller is enemy. Optional field at the same fight-order tile is omitted except at the **objective**, where siege and required field both remain. | `SIEGE` | Siege | -| **Naval** | Invasion: enemy port sea ZOC blocks sea on axis segment AC→DT; prepended at index 0 | `FIELD` + `navalVariant` | Naval Battle | -| **Field** | Leg walk: `offset % provinces_between_battles == 0` from leg start (non-terminal provinces) | `FIELD` | Field Battle | -| **Objective** | Always at `objectiveProvinceId` | `FIELD` | Field Battle (`required`) | - -Siege fires **once per fort** on the line. The slot **`provinceId`** is the **fort home province** (e.g. Greenfort → **713**); **`fortInstallationId`** names the fort. When the fort home is **off the campaign axis**, **`chronologyProvinceId`** stores the axis tile where ZOC was entered; fight order and GUI geographic sort use that tile. That siege **replaces** the optional field on that chronology tile. On-axis fort homes sort by `provinceId` as before. The invasion leg never schedules battles after the objective province. Overlapping ZOC for **schedule** identity: **oldest** operational fort wins per province (`completedAt`, then id). Occupation still requires **all** covering forts to be taken. The GUI **First Battle** marker is the first non-naval invasion slot (landing field, or the replacing siege). - -#### Port sea ZOC (shipped ; updated ) - -Sea zones use **`Terrain.SEA`** only (not `Province.isSea()` / rivers). Invasion sea scan walks axis indices **0 → objective** (AC toward DT) for contiguous **SEA** runs. - -| Rule | Detail | -|------|--------| -| **Port coverage** | BFS from **SEA neighbours of the port land province**; expand only across ocean tiles; radius **`war.port_sea_zoc_radius`** (default **2**) | -| **Blocking port** | Operational port whose owner coalition is **enemy of aggressor** at declare and whose coverage intersects any sea province in the run | -| **Naval slot** | One **`NAVAL`** per blocking port at the **first sea province on the axis sea run** (`portInstallationId` on slot); invasion leg prepends at index 0 (before FB field); friendly port covering the run → no naval slot | -| **No enemy port** | Sea on axis alone does **not** insert a naval slot | -| **Landing** | Amphibious landing is the FB **FIELD** at border **B**; no new **`NAVAL_INVASION`** slot is emitted | -| **Overlap** | Oldest operational port wins per sea province (`completedAt`, then `id`) | - -#### Trim priority - -Each leg is trimmed **independently** via `CampaignScheduleTrimmer.maxBattlesPerLegForGoal`: - -| Leg | Policy | -|-----|--------| -| **Invasion** | Drop optional **FIELD** from **DT side** first; never drop required objective; protect index **0** (first battle); if index 0 is **NAVAL**, also protect index **1** (first land battle). Then drop legacy `NAVAL_INVASION`, **NAVAL**, **SIEGE** if still over cap | -| **Counter** | Drop optional **FIELD** from border-adjacent side first (lowest axis index on counter segment) | - -Config key **`max_battles_per_leg`** (default **4** per goal). Legacy **`max_battles`** is a deprecated alias with the same per-leg semantics. - -#### War-time fort control - -Installation DB ownership does **not** change. `fortControllers` on the war tracks who **controls the ZOC**. Siege winner becomes controller. Counter-push through enemy-held ZOC **inserts a siege slot** on the **active** leg schedule at the active index before the next battle resolves. - -#### Progression (active leg) - -| `pushTarget` | Active schedule | Active index | -|--------------|-----------------|--------------| -| `TOWARD_OBJECTIVE` | `campaignBattleSchedule` | `campaignScheduleIndex` | -| `TOWARD_AGGRESSOR_CAPITAL` | `campaignCounterSchedule` | `campaignCounterScheduleIndex` | -| `RETAKE_OBJECTIVE` | invasion schedule | `campaignScheduleIndex` | - -- `nextBattleProvince(war)` → active leg `currentSlot` province (after re-siege insert check). -- Each fought campaign battle increments the **active** schedule index and `campaignBattlesFought`. Switching `pushTarget` does **not** reset the other leg's index. -- `cursorIndex` still advances on winner **Push** per existing rules. - -#### Persistence (war JSON fields) - -| Field | Role | -|-------|------| -| `campaignBattleSchedule` | Invasion leg slots (border → objective) | -| `campaignScheduleIndex` | Next slot index on invasion leg | -| `campaignCounterSchedule` | Counter leg slots (border − 1 → aggressor capital) | -| `campaignCounterScheduleIndex` | Next slot index on counter leg | -| `initiativeAttacker` | Starting fuel from invasion leg slot count (persisted at declare) | -| `initiativeDefender` | Starting fuel from counter leg slot count (persisted at declare) | -| `fortControllers` | installation id → coalition key | -| `wartimeInstallationOwners` | installation id → original faction id (snapshot before wartime transfer) | - -Slot shape: `provinceId` (axis tile where the battle is fought), `kind`, `required`, optional `fortInstallationId` (siege), optional `portInstallationId` (naval). - -### Cursor movement (after each fought battle) - -Cursor moves only when the **battle winner chooses Push** after the battle. **Hold** keeps the cursor in place and auto-proposes white peace. - -| Winner choice | Cursor | -|---------------|--------| -| **Push** | Advances along the current `pushTarget` (toward objective, toward aggressor capital, or retake objective) | -| **Hold** | Unchanged; white peace proposed to the loser | - -### Initiative (locked; updated ) - -| Rule | Default | -|------|---------| -| Attacker starting fuel | `ceil(invasion_leg_slot_count × initiative_factor)` | -| Defender starting fuel | `ceil(counter_leg_slot_count × initiative_factor)` | -| Empty counter leg | Defender fuel **0** | -| `initiative_factor` | **1.5** (config) | -| Per-goal `max_battles_per_leg` | **4** each (`DE_JURE_ANNEX`, `SUBJUGATE`, `TRANSFER_SUBJECT`) | -| **`initiativeHolderCoalition`** | Which coalition may schedule the next campaign battle and is battle-offensive (starts **aggressor** at declare) | -| Each **fought** battle | **Battle offensive coalition** (holder at battle start) loses **1** fuel when the battle ends | -| **Winner** | Becomes initiative holder after post-battle choices resolve (unless Hold assigns attack to the loser) | -| **Postponed** battle (low votes) | **No** fuel spent; holder unchanged | -| Coalition at **0 fuel** while holding initiative | Cannot schedule until they win initiative back | - -**Legacy load:** wars declared before without `campaignCounterSchedule` in JSON default defender fuel to the invasion-based symmetric value; re-declare to rebuild both legs. - -**Removed:** symmetric fuel from a single schedule for both coalitions. Re-siege inserts do not recompute fuel. - -### Post-battle choice (every battle) - -After **every** campaign battle, the **winner's war leader** chooses on the campaign view (or admin `battlechoice`): - -| Winner choice | Result | -|---------------|--------| -| **Push** | Continue the offensive; cursor moves per `pushTarget`; voting reopens | -| **Hold** | Front held; winner auto-proposes white peace; **loser** chooses **Attack** or **Accept peace** | - -**Loser response after Hold:** - -| Loser choice | Result | -|--------------|--------| -| **Attack** | Loser gets initiative at the held front; voting reopens | -| **Accept peace** | White peace; war ends with no goal | - -**Defaults at deadline:** winner **Push**; loser **Attack** after Hold. - -**Mandatory Hold (moment C):** if the battle winner cannot field an offensive army at the **next** battle province after a Push (troops must be ready immediately), the winner is treated as having chosen **Hold** - the loser gets **Attack** / **Accept peace** without a Push/Hold prompt. - -While `postBattleChoicePhase` is not `NONE`, **no** new battle may be scheduled (vote close blocked until choice or deadline). - -### Declare gate (locked) - -War declare is blocked unless the **declaring attacker faction** has at least **1 offensive manpower** from live military (`Military.getManpower(true)`). Regiment types count when marked `offense: true` in `regiments.yml` (levy or professional). No first-battle province or `canAttack()` check at declare. - -### Battle offensive forfeit (locked) - -At **scheduled battle time**, if the **battle offensive coalition** (initiative holder) cannot `canAttack()` at that province, they **forfeit** the battle: the opponent wins with no casualties, then normal post-battle choice rules apply. The same forfeit applies in the military walkover chain when the initiative holder cannot attack. - -### White peace proposals (locked) - -Symmetric reach checks per **coalition** via `CampaignCapabilityService.canReachTarget`: - -| Coalition | Capitulation target (axis steps from cursor) | -|-----------|-----------------------------------------------| -| Aggressor | Objective province index | -| Defender | Aggressor capital index | - -| Situation | Result | -|-----------|--------| -| One coalition cannot reach its target | That coalition **auto-proposes white peace** | -| Other war leader **accepts** | **White peace** - war ends, no goal, no reparations | -| **Both** coalitions auto-propose (includes **neither can attack**) | **Automatic white peace** | -| Neither coalition can mount next offensive (VOTING/SCHEDULED) | **Automatic white peace** (offensive stalemate) | -| Hold peace proposal active | Winner's coalition stays flagged until next battle ends | - -Persist `whitePeaceProposedByAttacker` / `whitePeaceProposedByDefender` (coalition flags); recalc after each choice resolution and walkover chain. - -### Both sides initiative = 0 - -**Automatic white peace** via mutual auto-proposal (see above) — no goal, no reparations. - -### Regional retake loop - -1. Attacker wins at **objective** → objective held (attacker occupation). -2. Next battle: defenders **retake** at objective (defender offensive). -3. **Defenders win** → objective stays defender; cursor stays at objective; attackers must attack objective again. -4. **Attackers win retake attempt** → **attacker victory** (war ends; no cursor rollback). - -Capital as objective: capital battle won → **auto victory** (no retake loop). Symmetric rule: aggressor wins at **defender capital** → attacker victory; defender wins at **attacker capital** → defender victory. Failed retake at objective (attackers win while `retake_objective` is active) → attacker victory. - ---- - -## Campaign GUI (locked) - -Primary player surface for campaign line, post-battle Push/Hold choice, white peace accept, and battle hour voting. **GUI-first** for campaign choices. - -### Navigation - -War list → War view → **Campaign** button → **Campaign view**. - -### Route row - -**Source of truth:** `campaignBattleSchedule` + `campaignCounterSchedule` on war JSON. The route row shows **only** scheduled battle slots - never axis provinces without a slot. - -| Rule | Detail | -|------|--------| -| **Order** | Geographic: all slots from both legs merged and sorted by `campaignProvinces` index ascending (attacker-cap side left, defender objective right) | -| **Row layout** | Single row, inventory slots 10-18 (max 9 items); no pagination | -| **Cap** | `max_battles_per_leg` hard max **4** per goal at config load (max 8 battle items total) | -| **Both legs visible** | Full war plan at declare, regardless of active `pushTarget` | -| **First-battle marker** | Below the slot at border **B** (`campaignProvinces[cursorIndex]`; invasion schedule index 0 when tied) | -| **Axis fields** | `campaign_provinces[]` / `cursor_index` also drive map line and cursor push | - -Schedule-only: never render axis provinces without a persisted slot. No `Counter-push schedule` lore. - -### Concrete legend (viewer-relative) - -| Material | Meaning | -|----------|---------| -| **Blue concrete** | Province owned by **your** belligerent coalition (upcoming slots) | -| **Red concrete** | Province owned by **enemy** belligerent coalition (upcoming slots) | -| **Green concrete** | **Next battle** on the **active** leg's current schedule slot | -| **Gray concrete** | **Fought** slot (`index < activeIndex` on that leg, not conceded) | -| **Gray concrete** + **Retreated** lore | **Conceded** slot (`concededScheduleSlots` key on that leg/index) | - -Naval kinds use trident / iron sword icons instead of concrete when applicable. - -**Not de jure.** Use **belligerent territorial ownership**. Neutral provinces on the line: **red** for both sides (v1). - -Route row lists **all** slots from **both** legs (invasion then counter). Green concrete / "Next battle" lore follow the **active** leg for the current `pushTarget`. Fought slots stay visible with **Fought** lore and gray styling. Conceded slots show **Retreated** lore (checked before fought index). - -### Display names - -Player-facing title per schedule slot (GUI item name and export `display_name`): - -| Kind | Pattern | -|------|---------| -| Field | `{ordinal}Battle of {location}` | -| Siege | `{ordinal}Siege of {location}` | -| Naval / invasion | Same as field template; kind shown in lore | - -**Location** resolution: settlement name → fort name → county title → `Wilderness`. - -**Ordinal** at render/export time: - -```text -ordinal = locationBattleCounts[key] + 1 + count(previous slots in SAME leg with same location key) -``` - -Siege slots use `fort:{installationId}` as the location key. Two scheduled fields at the same settlement before any are fought → `Battle of Lanbury`, then `Second Battle of Lanbury`. Implemented in `BattleNamingService.resolveScheduledDisplayName`. - -### Leader interactions - -| Situation | Campaign view | -|-----------|----------------| -| Winner choice pending | **Push** / **Hold** buttons (slots 40-41) | -| Loser response after Hold | **Attack** / **Accept white peace** buttons (slots 42-43) | -| Pushed coalition war leader during voting | **Retreat** (slot **46**); confirm concedes active schedule slot | -| War leader (no choice pending) | **Surrender** (slot 47) | -| Enemy white peace proposed | **Accept peace** (slot 48) when eligible | -| White peace proposed | Other war leader **Accept white peace** button | -| Both auto-propose | Automatic white peace | -| Faction leader (belligerent) | **Installations** pick entry (slot **33**); post-lock enemy intel book (slot **34**) | -| Fort / objective / capital | Lore tags; scheduled battle kind (**Field Battle** / **Siege** / naval kinds) on route provinces for **both** legs; siege provinces show enchant glint | - -Admin **`/war admin status`** and **`/war admin schedule`** output include invasion and counter schedule indices and slot lists. - -Voting hour toggles and schedule info: Campaign view slots **28-32** (hour multi-select), info book slot **4**, autoresolve propose slots **49-51**. - ---- - -## Occupation map (locked) - -Each **won campaign battle** adds explicit province(s) to the occupier's zone (**not** a single snake: a natural **bulge / front**). Implemented in `OccupationService`. - -| Field | Meaning | -|-------|---------| -| `occupied_by_attacker[]` | Province ids tinted attacker-held | -| `occupied_by_defender[]` | Province ids tinted defender-held | -| `objective_province_id` | Capture/recapture pin | -| `campaign_provinces[]` | Campaign polyline | -| `cursor_index` | Index into campaign line | -| `last_battle_occupied[]` | Provinces added by last battle (for chronicle / UI) | -| `whitePeaceProposedByAttacker` | Attacker auto-proposed white peace (unreachable capitulation) | -| `whitePeaceProposedByDefender` | Defender auto-proposed white peace | - -**Website (shipped):** `occupied_by_*` on `wars[]` plus `province_data[].occupied_by`. ProvinceSystem remaps those tiles to the occupier nation colour (slightly greyer fill) and uses `occupied_held` for labels. The campaign line uses `occupied_by_attacker` to advance the dotted-line front. Chronicle events are owned elsewhere. See [map-export.md](./map-export.md) and [wars-on-map.md](../../ProvinceSystem/docs/map/wars-on-map.md). - -**Campaign GUI (in-game):** route block colors use **belligerent territorial ownership**, not de jure title claims and not `occupied_by_*` bulge lists. - -**Per-battle rule (locked):** winning battle **occupies** the battle province and qualifying adjacent enemy tiles (bulge front), then exports. - ---- - -## Battle scheduling & voting - -> **Shipped:** battle scheduling, voting, raid window, installation pick lock at vote close, and strategic retreat during voting. - -All clock times under `war.battle_schedule` use **Europe/Paris** hours in shipped `war.yml` (CET/CEST intent): - -| Key | Default | Role | -|-----|---------|------| -| `defender_choice_deadline_hour` | 12 | Hold / counter-push / white peace deadline on battle day; no choice → auto **Hold** | -| `vote_close_hour` | 16 | Hour vote tally on battle day; **installation picks lock** at same instant | -| `raid_window_start_hour` / `raid_window_end_hour` | 19 / 20 | Inter-battle raid window (campaign raid launch) | -| `window_start_hour` / `window_end_hour` | 21 / 24 | Fightable hours on battle day | - -**Validation:** `vote_close_hour` < `raid_window_start_hour` <= `raid_window_end_hour` < `window_start_hour` <= `window_end_hour` <= 24. - -- **Vote open:** when a next battle is pending (declare or after prior battle end); battle province not required. Installation picks editable in parallel. -- **Vote close:** `vote_close_hour` on battle day → pick hour, postpone, or autoresolve; installation picks frozen. -- **First battle day:** calendar day **after** declare (voting may start at declare). -- Valid battle slots: one per full hour in the battle window (e.g. 21, 22, 23, 24). -- **Eligible voters:** **online** members of participating factions (main + subjects + called allies on that side). -- Each player selects **all hours they can attend** (multi-select). - -**Pick hour:** maximize `min(attacker_votes(H), defender_votes(H))`; tie → **earliest** hour. - -### Quorum - -Config under `war.battle_voting`: - -| Key | Default | Role | -|-----|---------|------| -| `min_players` | 4 | Minimum distinct voters (any hour) | -| `require_smallest_side_full` | true | Smaller side must have 100% of **eligible members** represented | -| `pass_if_either` | true | Pass if **either** threshold met | -| `dev_min_players` | (optional) | Test-server override when key explicitly set; lower than `min_players` lowers quorum threshold. Remove before prod (dev-config.md). | - -### Low turnout - -| Situation | Resolution | -|-----------|------------| -| Quorum not met at `vote_close_hour` | **Postpone 1 battle day** (no initiative spent) | -| On postpone | `battleDay` +1; **votes persist**; stay in `VOTING` until next close | -| **Autoresolve** | Only if **both war leaders** agree (separate from white peace) | - -### Strategic retreat - -> **Shipped:** pushed coalition war leader may concede the active schedule slot during `VOTING` (before vote close). - -| Rule | Detail | -|------|--------| -| **Who** | War leader of the **pushed** coalition (`defender` on invasion push; `aggressor` on counter-push) | -| **When** | `battleSchedulePhase == VOTING`, before `vote_close_hour`, no post-battle choice pending | -| **Push targets** | `toward_objective` and `toward_aggressor_capital` only (not `retake_objective`) | -| **Cost** | No initiative/fuel spent; not a battle (`campaignBattlesFought` unchanged) | -| **Effect** | Pusher wins the active slot; auto-push (no Hold prompt); siege slot flips fort controller; occupation applied | -| **Votes** | Hour votes persist; each confirm concedes one slot; phase stays `VOTING` until normal vote close | -| **GUI** | **Retreat** button (slot 46) + confirm; route lore **Retreated** on conceded slots | -| **Persistence** | `concededScheduleSlots[]` keys: `invasion:0`, `counter:1`, etc. | - -Mid-fight surrender during a **started** campaign battle is separate: see **Battle retreat** below (warband leader, `/warband retreat`). - -### Persistence - -| Field | Role | -|-------|------| -| `battleSchedulePhase` | `IDLE`, `VOTING`, `SCHEDULED`, `AUTORESOLVE_PENDING` | -| `battleDay` | UTC calendar day of current slot | -| `scheduledBattleAt` / `scheduledBattleHour` | Chosen fight time | -| `scheduledBattleProvinceId` | From `resolveNextBattleNodes` at vote close | -| `battleVotes` | UUID → selected hours | -| `autoresolveProposedByAttacker/Defender` | Dual-leader autoresolve flags | -| `postponementsThisCycle` | Debug counter | -| `postBattleChoicePhase` | `NONE`, `WINNER_PUSH_HOLD`, `LOSER_ATTACK_PEACE` | -| `postBattleChoiceResolved` | Choice locked (deadline defaults applied) | -| `initiativeHolderCoalition` | `aggressor` or `defender` coalition key | -| `pushTarget` | `toward_objective`, `toward_aggressor_capital`, `retake_objective` | -| `defenderChoiceResolved` | Legacy alias of `postBattleChoiceResolved` (v2 saves) | -| `forceQuorumNextClose` | Dev-only: next admin/tick close bypasses quorum (dev-config.md) | -| `battleInstallationPicks` | Faction id → installation ids committed for current battle day | -| `battleInstallationPicksBattleDay` | UTC date the pick set applies to; must match `battleDay` when locked | -| `concededScheduleSlots` | Leg/index keys for slots conceded via retreat (`invasion:0`, `counter:1`) | -| `wartimeInstallationOwners` | installation id → original faction id before wartime transfer | - -### Battle day timeline - -On each **battle day**, phases run in this order (defaults from `war.battle_schedule` in `war.yml`): - -| Phase | Default (Europe/Paris) | Config key | -|-------|------------------------|------------| -| Vote + installation picks open | — | From declare / prior battle end | -| Defender choice deadline | 12:00 | `defender_choice_deadline_hour` | -| **Vote close + installation lock** | 16:00 | `vote_close_hour` | -| **Raid window** | 19:00-20:00 | `raid_window_start_hour`, `raid_window_end_hour` | -| **Campaign battle window** | 21:00-24:00 | `window_start_hour`, `window_end_hour` | - -Raids run **before** the main campaign battle on the same battle day. - -### Installation picks - -Faction leaders commit installations for the current battle day from the campaign GUI (**Installations** button, slot **33**). See [installations.md](./installations.md#campaign-installation-picks) for vehicle berth interaction. - -| Rule | Detail | -|------|--------| -| Who picks | **Faction leader** only; each coalition faction picks **independently** | -| Pickable kinds | **`port` and `airport` only** | -| Territory | Province must be under your coalition's **control** (not enemy-occupied; occupation bulge + de jure ownership) | -| Forts | **Not pickable**; active **siege** schedule slot puts the owning faction's fort emplacements in play without a pick | -| Defender ZOC port | On a current `NAVAL` / `NAVAL_INVASION` slot, `portInstallationId` is **auto-committed** for the defender war leader and cannot be unpicked (`REJECTED_ZOC_PORT`). Other pickable ports and airports still toggle. | -| Lock | Same instant as vote close (`vote_close_hour`). After lock: **no berth or unberth** on in-play installs (committed picks, defender ZOC port, siege fort). Ports not in play stay open. | -| Empty pick | Nothing in play for that faction (no berthable vehicle pool for campaign battles) | -| Pre-lock enemy view | **Hidden** | -| Post-lock enemy view | Enemy intel book (slot **34**) shows per-faction committed lists | -| Reset | Cleared when `battleDay` advances | - -**Vehicle in-play:** berthable vehicles at a **committed** port/airport, the defender **ZOC port**, **or** the active siege `fortInstallationId` for the owning faction. Trains and other non-berthable types follow rules. See [installations.md](./installations.md#campaign-battle-vehicle-eligibility). - -After vote close, `VehicleInstallationLockService` blocks berth and unberth on those in-play installs. Raid/vulnerability embargo is unchanged. - -### Runtime - -- **UTC scheduler:** `BattleScheduleTickService` polls every minute; at `defender_choice_deadline_hour` applies post-battle choice defaults; at `vote_close_hour` runs tally. Persists on change. -- **Campaign battle launch:** On `SCHEDULED`, `CampaignBattleLaunchService` creates campaign battle, enrolls warbands. Naval slots: if the war attacker has no berthed `ships` vehicle at an in-play port, the defender wins the slot without a live battle and attacker fuel is spent (`lastBattleOffensiveCoalition` forced to aggressor). On `BattleEndedEvent`, casualties apply, then `CampaignBattleOutcomeService` spends fuel, begins winner Push/Hold choice, and may chain military walkovers after choice resolves. -- **Admin dev commands:** `/war admin schedule choice push|hold|attack|accept` (aliases: `battlechoice`, `defenderchoice`, `pushchoice`, `holdchoice`). Permission `simplefactions.admin`. - ---- - -## Battles & Warbands - -### Province presence (central tracker) - -SimpleFactions runs **one** province location poll for all online players every **1 second**. It fires **`PlayerProvinceEnterEvent`** / **`PlayerProvinceLeaveEvent`** when a player's province changes. - -Battles and future systems (ZOC, raids) **subscribe to these events** instead of running separate location scans. Province-leave battle penalty removed. - -Lookup: [`RestServer.getProvince`](./ProvinceGrid.md) → local `ProvinceGrid`. - -### Merge Warbands into SimpleFactions - -Same pattern as professions → RPCharacters. SF owns campaign battles, join flow, lives, and campaign linkage. Warbands battle engine (capture points, deaths, respawn) becomes an SF submodule. - -### Battle modes (locked ; zones removed ) - -| Mode | Win | Region | Respawns | -|------|-----|--------|----------| -| **Field** | Side eliminated when **lives = 0** and all online fighters are in **jail** (capture points gate spawn teleports only) | **No province fence** (64.08) | Campaign: collective lives from committed regiments (61.04). Staff manual: per-side lives in side edit GUI | -| **Siege** | Hold **contest area** until timer hits **0** (ETW-style bidirectional timer); defender elimination also ends battle | **No province fence** (64.08) | Same as field | -| **Raid** | Capture **target** to 100%; defender eliminated when `LIVES` mode exhausted | **No fence** - map-wide movement | Attackers: **none** (elimination on death/disconnect). Defenders: **infinite** or **set lives** (template) | - -**Naval variant** (field + siege only): template flag for staff layout (**attacker spawn** on naval point). Campaign schedule inserts **`NAVAL`** slots (prepended on invasion leg when port blocks sea); launch sets **`navalVariant`** on field battles. Legacy **`NAVAL_INVASION`** slots from old saves still display and launch with naval variant. Does not enforce province bounds after 64.08. - -**Province-leave penalty:** **removed** (was: leave allowed set → 10s → death). Staff place spawns, jails, capture points, and contest areas anywhere on the map. - -### Automatic vs manual battles - -| Mode | Use | -|------|-----| -| **Campaign battle** | System-created from schedule; join via command; mode = field or siege from campaign context | -| **Campaign raid** | Inter-battle installation assault; timer fight; no capture points | -| **Pillage war battle** | One-shot border settlement raid war type | -| **Staff raid battle** | Manual `BattleType.RAID` with capture points; lore/dev events | -| **Manual battle** | Lore / staff non-campaign fights. **One manual battle at a time** (61c.09); persisted to `plugins/SimpleFactions/Battles/`; delete via battle edit GUI (slot 22) or admin flow | - -### Staff template battles (61c) - -**YAML templates** (`battle-templates.yml`) apply **battle rules only**: lives, friendly fire, keep inventory, siege/raid durations, defender respawn mode, naval variant flag. They do **not** seed spawns, jails, or capture point coordinates. - -For each **campaign battle**, staff use `/battle edit` (or battle GUI) to place spawns, jails, capture points (field), contest area + duration (siege), and naval spawn when applicable. Manual (non-campaign) battles from the war GUI reuse the same edit flow. **Fast edit:** open Sides, click a side, use Set spawn / Set jail / Add point at your feet (61c.10). **Siege contest:** open Contest Area (slot 23), stand at fort corners, click **Min** then **Max** to set corners at your feet; click duration to cycle hold time. - -When a scheduled fight is overdue but cannot start (e.g. siege contest unset), the campaign GUI shows **Cannot start: …** instead of **Starting now**, and online belligerents receive a chat warning. - -**Pre-battle signup reminders:** while phase is `SCHEDULED` and the battle has not started, chat reminders fire at configured offsets before `scheduledBattleAt` for coalition members not yet in any warband (`/warband list` to join). Set `battle.signup_reminder_seconds_before: []` to disable. - -### Capture points enabled (`capture_points_enabled`) - -Template YAML key (default `true` on `field_default`, `false` on siege/raid). When enabled: - -- Battle edit slot 23 opens the points list -- Side Edit shows **Add point** (auto-names A, B, C per side) -- Capture point tick runs during the fight - -When disabled, siege/raid use contest area or raid target UI on slot 23 instead. - -### Battle & warband persistence (61c.09) - -| Path | Content | -|------|---------| -| `plugins/SimpleFactions/Battles/battle_{id}.json` | Battle layout, rules, started state, `startedAt` (ISO-8601), side warband id references | -| `plugins/SimpleFactions/Warbands/warband_{id}.json` | Roster, leader, campaign shell fields (devmode dummy members not saved) | - -- **Autosave:** every 60s and on plugin disable. -- **Resume:** `started=true` battles restore lives, capture progress, contest timer, and `startedAt`; tick loop continues without re-teleport/title. -- **Manual limit:** only one battle with `warId == null`; `/battle create` blocked until deleted. -- **Orphan cleanup:** manual warbands not attached to any persisted battle are removed on save/disable. -- **Campaign end:** `CampaignBattleOutcomeService` deletes battle + campaign warband JSON when the war outcome resolves the fight. - -### In-battle rules - -- **No province-leave penalty** (removed 64.08). Movement is unrestricted during field/siege battles. -- Friendly fire / keep inventory per template config. -- **Raid attackers** do not respawn; fight until eliminated. -- **Raid defenders:** infinite respawns or finite lives per template. - -### Battle retreat (mid-fight) - -> **Shipped:** campaign **warband leader** may concede a started field or siege battle via `/warband retreat`. - -| Rule | Detail | -|------|--------| -| **Who** | **Warband leader** of the side's campaign shell warband (one per battle side) | -| **When** | Started **field** or **siege** campaign battle (`warId != null`); not campaign raid | -| **Cooldown** | `battle.retreat_min_elapsed_seconds` after battle start (default **1200** = 20 minutes) | -| **Command** | `/warband retreat` + confirm GUI | -| **Effect** | Opponent wins; **ledger casualties only** (partial deaths preserved); normal `CampaignBattleOutcomeService` path (fuel spend, post-battle choice, war end on final battle) | -| **vs strategic retreat** | Map voting only; no battle fought; no initiative cost; **Retreated** route lore | - -Retreating the **final battle** is allowed: it is a normal battle loss and may end the war (preserves army vs fighting to elimination). - -### Battle dev mode and capture (61b + 61c, test server) - -Solo staging on the test server: [dev-config.md](./dev-config.md). - -| Topic | Detail | -|-------|--------| -| Capture threshold | `battle.capture_min_players` (default **1**). The side with strictly more players in the zone ticks capture. | -| Devmode | `/war admin devmode on\|off\|status` (admin, volatile). **On:** fills every active campaign battle side warband with up to `phantom_count` dummy roster members (re-applies after restart); raid launch ignores schedule window and battle day. **Off:** strips dummy members from all warbands. Dummies are not persisted to disk. Manual `/warband create` also seeds dummies when devmode on. Dummies count toward roster display, lives subtraction, and join cap preview. Capture markers and capture presence still use online real players only. | -| Campaign join | When `battle.warId != null`: joining faction must be on the battle side; side roster capped by **pool lives** (`livesPerRegiment × committedRegiments`, pre-battle) or side lives (mid-battle join costs 1 life). `/battle join` redirects to `/warband list` signup. One auto warband per battle side. Player-facing errors: wrong side, roster full, no lives left, blocked rejoin after mid-battle leave. | - -Config under `battle:`: - -| Key | Default | Purpose | -|-----|---------|---------| -| `province_block_protection_enabled` | `false` | When `true`, players cannot break or place blocks in the battle province during started field/siege battles. Vehicle/artillery damage is unaffected. Staff bypass. | -| `capture_min_players` | `1` | Min players at a capture zone | -| `retreat_min_elapsed_seconds` | `1200` | Minimum elapsed time after battle start before `/warband retreat` is allowed | -| `signup_reminder_seconds_before` | `1800, 600, 300, 60` | Chat reminders before fight time for players not in a warband; `[]` disables | -| `war.devmode.phantom_count` | `10` | Dummy roster fill on manual warband create or campaign seed when devmode on | - -### Campaign time dev mode (test server) - -Staff can fast-forward the Paris battle schedule without waiting on real clock: `/war admin time` (`add`, `reset`, `status`, `skip-to-battle-day`). The active route slot shows a gray **Starts in X** countdown when a fight is scheduled. Offset is volatile (cleared on restart). Full command table and E2E workflow: [dev-config.md](./dev-config.md). - -### Manpower pool per battle (locked ) - -Military commitment, battle pool, collective lives, and casualty apply are shipped. See levy and vassal rules below. - -Offense/defense regiment pools depend on **where the battle is fought** and **campaign phase**, not who declared war. Use `CampaignProgressionService.getOffensiveSide(war)` to determine which belligerent role is offensive; that side's factions use offensive regiments, the other side uses defensive regiments. - -| Location (simplified) | Attacker-side factions | Defender-side factions | -|----------|------------------------|-------------------------| -| Inside **defender** territory (invasion push) | Offensive regiments | Defensive regiments | -| Inside **attacker** territory (counter-push) | Defensive regiments | Offensive regiments | - -**Militia** (`militia` regiment): deploys only on **that faction's direct land** (`TitleManager.getByProvince(battleProvinceId) == faction`). Overlord militia does **not** deploy in vassal territory; vassal gets full military including militia on vassal land; overlord sends professional army + levies only. - -### War commitment (`WarCommitment`, +) - -Minimal rules (locked 61.01 + 61.01b): - -1. **Fighter OR levy-only, never both** on a war side. Fighters = main leaders, their **direct subjects**, and **called allies** (`BattleSideMembers.collectParticipatingFactions`). Nested vassals are levy-only. -2. **Fighter own regiments:** live slot count at each battle (mid-war buildup counts). -3. **Levy:** frozen rows `holder → source → count`. Snapshotted at **declare** and when an **ally joins**. Nearest **fighter** on the overlord chain is the holder (not the top overlord when a subject also fights). -4. Casualties always debit the **source** faction for levy rows. - -**Levy mid-war:** - -| Event | Effect | -|-------|--------| -| Ally joins | New levy rows for joiner only | -| Subject buildup / levy % change | No change | -| **New vassal** (of main, subject fighter, or ally) | No new rows | -| **Vassal bond breaks** | Remove rows for broken subject **and its subject subtree**; if a fighting subject leaves, remove all rows it held | - -Today `WarManager.getCommitmentsForWar` returns snapshot rows via `WarCommitmentService` (61.02). Re-commit is forbidden per own-regiment row. Levy rows use `sourceFactionId`. Commitments persist on war JSON (`WarData.commitments`, 61.06) and reload on server start. - -**Admin debug:** `/war admin status ` prints one JSON line per war. Use `commitmentRows` to inspect per-faction rows (`factionId`, optional `sourceFactionId`, `regimentId`, `count`). Example own row: `{"factionId":"atk","regimentId":"militia","count":4}`. Example levy row: `{"factionId":"atk","sourceFactionId":"sub","regimentId":"levy","count":6}`. The `commitments` field is the row count (same as `commitmentRows.length`). After a campaign battle ends, re-run `war admin status` to confirm rows and counts decreased (61.06). - -Militia eligibility is filtered at **battle pool** time, not at commit. - -### Lives (collective, campaign field + siege) - -Applies when `battle.warId != null` and type is **FIELD** or **SIEGE** at `battle.start()` (61.04). Campaign lives are **computed per side** from war commitment and roster size; the battle editor shows a read-only preview before start. `/battle setlives` is rejected on campaign battles. Staff manual battles (`warId == null`) configure **per-side lives** in the side edit GUI (FIELD/SIEGE); **raids** keep template defender lives. - -**Formula (per side):** - -```text -sideLives = max(minSideLives, livesPerRegiment × committedRegiments − rosterFighters) -``` - -- `committedRegiments`: eligible pool total from battle pool resolver (61.03), **plus mercenary slots** (below) -- `rosterFighters`: unique warband roster members on that side (includes devmode dummies; offline real players included) -- Capture markers and capture presence still count **online real players only** -- Pre-battle join cap uses **pool lives** (committed regiments × lives per regiment); mid-battle join costs 1 life from the started side pool - -**Mercenary contribution (locked):** a hired company adds to `committedRegiments` only its **filled and attending** slots - enlisted players actually on that side's warband roster, capped at the slots the contract promised. An empty or unfilled slot adds nothing, and a company cannot inflate the pool past its promise. Lives stay a **single shared side pool**: mercenary lives are not a separate allowance, so a company burning lives spends the belligerent's pool too. - -A **dual-role** player (a mercenary who is also a citizen fighting on that side) is counted once in the pool and **subtracted once** from `rosterFighters`, because both figures walk unique roster ids. - -**Attendance (locked):** a slot attends when its player is present at **battle start** and still on the roster at **resolution**. End means on the roster, not alive and not online: a player who dies or logs out mid-battle still attends as long as they were not removed. Absence is what drives the refund, so a restart that loses the battle-start snapshot records **no** absence rather than guessing one. - -Config under `war.battle_military`: - -| Key | Default | -|-----|---------| -| `lives_per_regiment` | `5` | -| `min_side_lives` | `1` | - -Mercenary slot, price and contract keys are documented in [mercenaries.md](./mercenaries.md). - -### Casualties (locked ) - -**Ledger (61.05):** During campaign field/siege battles, track per-side casualties from deaths and disconnects after start. Province-leave penalty deaths **removed** with 64.08. No ledger for staff manual or raid battles. - -**Apply (61.06):** After battle via `CampaignBattleOutcomeService` (before `openVote`). Implemented in `BattleCasualtyService`: militia first (when eligible at battle province), then army + levies split **proportionally** across contributors. Debits `WarCommitment.count` and faction `Regiment.currentSlots` (permanent until rebuilt). Applies even when **no winner**. Side casualties are snapshotted on `BattleEndedEvent` before the ledger clears. - -**Out of scope for 61:** staff battles, goal apply (**62**), campaign battle schedule / fort sieges (**64**), raid war type (planned). - -### Levies (war-scoped) - -- Frozen integer pool per `(holder, source)` row; snapshotted at **declare** and on **ally join** only. -- **Holder** = nearest participating fighter walking up from source (avoids double count when main and subject both fight). -- **Source** = levy-only faction whose troops and casualties are tracked; can be any depth in the vassal chain. -- **No** mid-war add from subject troop buildup, levy % changes, or **becoming** a new vassal (of main, subject fighter, or ally). -- **Yes** mid-war **remove** when a subject stops being a vassal: drop that source and **all its subject descendants**; drop holder rows if a fighting subject leaves the side. -- **transferSubject** mid-war: remove old subtree only; no snapshot for new overlord. -- Fighter on the war side never also appears as levy from the same side (no double count). -- All commits tagged **`war_id`**. Losses decrement committed levy and source faction sent counts. - -### Battle loot - -One identical reward, paid once, to every fighter who was **online when the battle ended**. Both sides are paid: the battle happened in lore, so losing it still earns the reward. A retreat pays too, since a retreat produces a winner. - -Three things must all hold, checked in `BattleLootService.shouldPay`: - -1. The battle produced a **winner**. A timer expiry with no winner pays nothing. -2. The battle is **not a campaign raid**. -3. The battle's own **loot toggle** is on. - -The toggle is a per-battle flag, persisted with the battle and flippable at **slot 14** of `/battle edit` ("Battle Loot"). It defaults **on** for manual and campaign field and siege battles, and **off** for campaign raids. Battle files written before this feature existed load as on, except campaign raids, which load as off. Staff can turn a raid's loot on by hand and it sticks. - -What gets paid is server-wide, not per battle - see the `war.battle_loot` keys in [dev-config.md](./dev-config.md). `COMMAND` mode dispatches each configured line from console once per rewarded player, which is how a crate key is handed out; `ITEM` mode gives a TLibs item and drops it at the player's feet if the inventory is full. An empty command list or a blank item path means no loot, so there is no separate enable key. - -Everyone is paid the same regardless of contribution, kills, or time on the field. - -**Wins that pay nothing:** the reward hangs off `BattleEndedEvent`, which only fires for battles that were actually fought. Walkovers, offensive forfeits, the naval auto-loss, campaign slot concessions and admin `/war admin schedule winbattle` all record a winner without a live battle, so none of them pay. Offline roster members are also skipped, which means joining a warband and logging off earns nothing. - ---- - -## Naval & installations - -### Campaign naval segments (shipped ) - -Full rules: [Port sea ZOC](#port-sea-zoc-shipped) under campaign battle schedule. - -- Enemy **port** sea ZOC blocking an axis sea run → **`NAVAL`** schedule slot prepended on the invasion leg (index 0; `navalVariant` field battle). Landing fight is the FB **FIELD** at border **B** (no new **`NAVAL_INVASION`** slot). -- Sea on axis without an enemy blocking port → no naval slot. - -### Port protection - -`war.port_sea_zoc_radius` (default **2**): sea-hop BFS from the port's adjacent ocean tiles. Distinct from `port-sea-proximity-blocks` (construction validation only). See [installations.md](./installations.md#config). - -### Installation picks per battle (shipped ) - -Leaders commit **ports and airports** each battle day via the campaign GUI; picks lock at vote close. Schedule slots still carry `portInstallationId` / `fortInstallationId` for **blocking** ports and **siege** battles respectively; those are separate from the pick UI except the defender **ZOC port**, which is auto-committed from `portInstallationId`. - -- **Campaign raids**: leaders launch source→target assaults during the raid window; targets are **any operational** enemy port/airport/fort (`CampaignRaidEligibilityService`). See [Campaign raids](#campaign-raids). -- **Fort raids** in campaign raids use port/airport source → fort target; not chosen via installation picks. - -### Attacker naval launch - -For `NAVAL` / `NAVAL_INVASION` slots, the **war attacker** needs at least one **in-play port** with a berthed naval vehicle (registry `INSTALLATION` row, category `ships`). Personal unberthed ships do not count. Null registry counts as no navy. - -If that check fails at launch (including both sides empty of official navy): - -- Unstarted battle is purged; defender wins the slot through the existing battle-end path. -- Attacker initiative is spent (`lastBattleOffensiveCoalition` = aggressor). -- Field and siege slots are unchanged. - -From battle day through launch, while this would still fire, `CampaignNavalAutoLossReminderService` pings the **attacker war leader** every schedule tick. - -### Wartime installations and peace - -Occupation (and siege take of the fort's province) **transfers** installations on occupied tiles to the occupying coalition's **war leader**. Snapshot `wartimeInstallationOwners` stores original faction ids. This does **not** change de jure province owner. - -**Every** `WarEndReason` (`WarManager.endWar`): revert the snapshot **first**, then `WarOutcomeService.apply`. Land apply that calls `addProvince` can transfer installs again for tiles the winner keeps. White peace and admin end still revert. - -Fort ZOC on campaign line → **siege** when line passes through ZOC and fort is enemy-controlled. War-time fort controller may differ from installation owner; see [Campaign battle schedule](#campaign-battle-schedule-locked). Capital inside fort ZOC → siege then objective field battle. - -See [installations.md](./installations.md) for fort/port/airport pipeline. War-aware ZOC on map export (`ZocRealm` controller filter) is shipped; see [installations.md](./installations.md#fort-zoc-export-forts). - ---- - -## War end conditions - -| Outcome | Trigger | Goal | Reparations | -|---------|---------|------|-------------| -| **Attacker victory** | Aggressor wins battle at defender capital, failed objective retake, or defender leader **surrenders** (slot 47) | Goal apply | No | -| **Defender victory** | Defender wins battle at attacker capital, or attacker leader **surrenders** (slot 47) | None | **Attacker pays** | -| **White peace** | Leader accept of auto-proposal, voluntary mutual agreement, mutual exhaustion auto-proposal, or offensive stalemate | None | **No** | -| **Pillage success** | Pillage battle won at settlement | Pillage apply | No (unless attacker loses; N/A for one-shot pillage) | - -### `WarEndReason` values (shipped ) - -| Value | Meaning | -|-------|---------| -| `white_peace` | No winner; no goal | -| `attacker_victory` | Attacker coalition wins | -| `defender_victory` | Defender coalition wins | -| `admin_end` | Staff / command end | - -Opening the campaign view **recalculates** white peace proposal flags only; it does **not** auto-end the war. - -`WarManager.endWar` always reverts wartime installation transfers (`WartimeInstallationService.revert`) **before** `WarOutcomeService.apply`. Civil wars also run `CivilWarUntangleService.restore` after that revert and before apply. - -### War reparations (attacker-only) - -**Only when attacker loses badly, on an external war:** - -- Attacker **surrenders**, or -- Attacker **loses capital** (defender counter-push and wins there) - -**Not when:** defender loses (land/subject loss is enough), any **white peace** (including accepted auto-proposal), initiative exhaustion white peace, or a **civil war** defender / white peace / admin end (restore + empty movement; no auto imprison). - -**Mechanic:** flat **% of main guild ledger income** for **X days** paid to winner. Source: **main faction guild ledger only** - not subsidiary guilds. Applied via ledger pipeline (`Cashflow.WAR_REPARATIONS` / `WAR_REPARATIONS_PAYMENT`). Config: `war.reparations.income_percent` (default **25**) and `war.reparations.days` (default **10**). - -Staff can add the same obligation by hand (after a civil war, or any time): `/war admin reparations [percent] [days]`. Optional args default to the config values. Uses `WarReparationsService.apply`. Permission: existing war admin. Not tied to an active war. - ---- - -## Map export contract - -Exported in `map_markers.json` (or sidecar) per `map-export-schema.json`. - -Emit on declare, after each battle, and on war end. Chronicle events: `war_declared`, `battle_scheduled`, `battle_result`, `province_occupied`, `war_ended`. - -### Web map campaign visualization - -Active campaign wars export a **`wars[]` route slice** in `map_markers.json` (SF `WarMapExporter` + PS loader enrichment). The ProvinceSystem web map renders: - -| Feature | Source | Notes | -|---------|--------|-------| -| Smooth dotted campaign line | `campaign_provinces[]` / `campaign_line_points[]` | Catmull-Rom spline; border `#2a1810` + dash `#8b3a3a` | -| Battle pins | `campaign_battle_schedule[]` + `campaign_counter_schedule[]` | One `battle.png` per slot (`leg` + `schedule_index`); siege/port coords from installation when set | -| Pin hover | `display_name` or `kind_label` + `province_name` + `status` | Prefer `{display_name} - {status}` when SF export includes `display_name` | -| Next battle highlight | slot `status === "next"` on active leg | 1.1x scale + ring on pin | -| Occupier nation fill | `province_data[].occupied_by` | Political overlay remaps occupied tiles to occupier colour | -| Campaign-line front | `occupied_by_attacker[]` | Pushes the dotted front along the axis | - -Visible on nation, county, duchy, kingdom, empire, and trade map modes (same as settlement markers). - -Re-upload `map_markers` or wait for the next regen after deploy so active wars pick up the route slice and occupation lists. Chronicle event stream is owned elsewhere. - ---- - -## Related documentation - -| Doc | Topic | -|-----|--------| -| [installations.md](./installations.md) | Forts, ports, airports, ZOC | -| [settlements.md](./settlements.md) | Settlement provinces (de jure annex block) | -| [province-grid.md](./province-grid.md) | Province ids and neighbours | -| [campaign-raids.md](./campaign-raids.md) | Inter-battle installation assaults | -| [mercenaries.md](./mercenaries.md) | Companies, contracts, wages, reputation, config keys | -| [map-export.md](./map-export.md) | War route slice in `map_markers.json` | -| [roadmap.md](./roadmap.md) | Shipped vs planned features | -| [ProvinceSystem map wars overlay](../../ProvinceSystem/docs/map/wars-on-map.md) | Website overlay | - ---- - -## Open items - -- Inter-vassal wars **shipped** (Participants, campaign pathfinder, apply) -- NAP treaty overlay **shipped** (stacks with tributary; blocks all declares until cleared) -- Occupation overlay **shipped** (occupier fill) -- Council-forced peace **shipped** (white peace offer or surrender on a chosen war) -- Map chronicle events: other member (`war_declared`, `battle_scheduled`, `battle_result`, `province_occupied`, `war_ended`) -- Production declare codes / Discord ticket gate: last -- When to **recalculate** white peace auto-proposal flags after cursor / phase change - -`provinces_between_battles` (default **3**), `max_battles_per_leg`, and `initiative_factor` are locked in config (see `war.yml`). diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/TerminationReason.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/TerminationReason.java index 9db67ae4..d61dbabe 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/TerminationReason.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/TerminationReason.java @@ -1,9 +1,8 @@ package net.tfminecraft.simplefactions.mercenary.contract; /** - * The ways a contract can end, each carrying the outcome locked in - * docs/planning/war-companies/00-index.md section 5. Days already served are paid - * in every one of them, so that is not a per-reason flag. + * The ways a contract can end, each carrying its outcome. Days already served + * are paid in every one of them, so that is not a per-reason flag. */ public enum TerminationReason { /** Ran its course. Reputation rises only if attendance was clean throughout. */ diff --git a/src/main/resources/Guilds/company-upgrades.yml b/src/main/resources/Guilds/company-upgrades.yml index d8e4aa5c..d84dbbe9 100644 --- a/src/main/resources/Guilds/company-upgrades.yml +++ b/src/main/resources/Guilds/company-upgrades.yml @@ -6,8 +6,6 @@ # expansion-time purchase time in real seconds (24 h) # max-level hard cap; guild upgrades omit this and stay uncapped # modifiers GuildModifier, flat then per-level: " " -# -# Full reference: docs/mercenaries.md company_health: name: "#e06c75Hardened Bodies" icon: "writable_book.0" diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index ec08209a..47f81e54 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -32,7 +32,7 @@ province-cost: 50 # a config key; each guild leader sets it, starting at 0%. dividend-require-previous-tick-membership: true -# Mercenary companies. Full reference: docs/mercenaries.md +# Mercenary companies. # One-off charge taken from the host guild when the charter is requested. mercenary-formation-cost: 100.0 # Founding time. The faction timer ticks once per real second, so 86400 is 24 h diff --git a/src/main/resources/regiments.yml b/src/main/resources/regiments.yml index 6a8e5cd5..f964cbff 100644 --- a/src/main/resources/regiments.yml +++ b/src/main/resources/regiments.yml @@ -24,8 +24,7 @@ militia: - "#a89977soldiers, but can be called upon to" - "#a89977defend their home" # Prototype for mercenary company slots. The mercenary: true flag keeps this -# type out of every faction military; only a company clones it. Full reference: -# docs/mercenaries.md +# type out of every faction military; only a company clones it. mercenary: name: "#b7aae3Mercenary Company" mercenary: true @@ -60,4 +59,4 @@ levy: - "#a89977a vassal state to its overlord" - "#a89977these soldiers vary in quality, some are" - "#a89977professional troops, others are farmers or" - - "#a89977otherwise conscripted troops" \ No newline at end of file + - "#a89977otherwise conscripted troops" From 4c046820b977204802f1fcaa3726e6872bce6f25 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:26:12 +0000 Subject: [PATCH 2/2] chore: ignore local documentation directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 947f850a..1583b226 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ Desktop.ini /src/main/resources/Cache/ /src/main/resources/Data/ /src/main/resources/MapAPI/ + +# Technical documentation is maintained in TF-Minecraft/Docs +/docs/