diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5227aba..862f80a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,27 +6,27 @@ on: version: description: Release/tag version required: true - default: 2.1.0.0-beta.3 + default: 2.1.0.0 target_ref: description: Branch, tag or commit to package required: true - default: release/v2.1.0.0-beta.3 + default: release/v2.1.0.0 release_title: description: GitHub release title required: true - default: Crop Control Override 2.1.0.0 Beta 3 + default: Crop Control Override 2.1.0.0 notes_file: description: Release notes file on target ref required: true - default: BETA3_RELEASE_NOTES.md + default: RELEASE_NOTES_v2.1.0.0.md prerelease: description: Publish as a GitHub pre-release required: true type: boolean - default: true + default: false push: branches: - - release/v2.1.0.0-beta.3 + - release/v2.1.0.0 paths: - .release/READY @@ -44,11 +44,11 @@ jobs: timeout-minutes: 5 env: - RELEASE_VERSION: ${{ inputs.version || '2.1.0.0-beta.3' }} + RELEASE_VERSION: ${{ inputs.version || '2.1.0.0' }} TARGET_REF: ${{ inputs.target_ref || github.ref_name }} - RELEASE_TITLE: ${{ inputs.release_title || 'Crop Control Override 2.1.0.0 Beta 3' }} - NOTES_FILE: ${{ inputs.notes_file || 'BETA3_RELEASE_NOTES.md' }} - PRERELEASE: ${{ github.event_name == 'push' || inputs.prerelease }} + RELEASE_TITLE: ${{ inputs.release_title || 'Crop Control Override 2.1.0.0' }} + NOTES_FILE: ${{ inputs.notes_file || 'RELEASE_NOTES_v2.1.0.0.md' }} + PRERELEASE: ${{ inputs.prerelease || false }} steps: - name: Check out requested release source @@ -74,7 +74,7 @@ jobs: git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add -A if ! git diff --cached --quiet; then - git commit -m 'Promote CCO 2.1.0.0-beta.3 release source [skip ci]' + git commit -m 'Promote CCO 2.1.0.0 release source [skip ci]' git push origin HEAD:"${TARGET_REF}" fi fi diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..e70f110 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,176 @@ +name: Validate Crop Control Override + +on: + pull_request: + branches: + - main + push: + branches: + - release/v2.1.0.0 + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Validate metadata and XML + shell: bash + run: | + set -euo pipefail + python - <<'PY' + import re + import xml.etree.ElementTree as ET + from pathlib import Path + + expected_version = "2.1.0.0" + + required_xml = [ + "modDesc.xml", + "gui/CropControlOverrideMenu.xml", + "gui/guiProfiles.xml", + "l10n/l10n_en.xml", + "l10n/l10n_de.xml", + "l10n/l10n_fr.xml", + "templates/customRegionalProfile.xml", + ] + for path in required_xml: + ET.parse(path) + + root = ET.parse("modDesc.xml").getroot() + mod_version = root.findtext("version") + if mod_version != expected_version: + raise SystemExit(f"Unexpected modDesc version: {mod_version!r}") + + if root.get("descVersion") != "113": + raise SystemExit(f"Unexpected descVersion: {root.get('descVersion')!r}") + + title = root.find("title") + if title is None or (title.findtext("en") or "").strip() != "Crop Control Override": + raise SystemExit("Missing canonical English mod title") + duplicate_titles = [] + for language in ("de", "fr"): + localized = title.findtext(language) + if localized is not None and localized.strip() == "Crop Control Override": + duplicate_titles.append(language) + if duplicate_titles: + raise SystemExit( + "Redundant identical title localizations: " + ", ".join(duplicate_titles) + ) + + runtime = Path("scripts/CropControlOverride.lua").read_text(encoding="utf-8") + match = re.search(r'VERSION\s*=\s*"([^"]+)"', runtime) + runtime_version = match.group(1) if match else None + if runtime_version != expected_version: + raise SystemExit(f"Unexpected runtime version: {runtime_version!r}") + + required_source = [ + "scripts/Debug.lua", + "scripts/CropControlOverrideChangeSettingsEvent.lua", + "scripts/RegionalProfileManager.lua", + "scripts/CropCalendarManager.lua", + "scripts/NativeCalendarSort.lua", + "scripts/gui/CropControlOverrideMenu.lua", + "scripts/CropControlOverride.lua", + "scripts/CCOIntegrationApi.lua", + ] + missing = [path for path in required_source if not Path(path).is_file()] + if missing: + raise SystemExit("Missing required source files: " + ", ".join(missing)) + + protected_call_pattern = re.compile(r"\b(?:pcall|xpcall)\s*\(") + protected_calls = [] + long_lines = [] + for lua_path in sorted(Path("scripts").rglob("*.lua")): + source = lua_path.read_text(encoding="utf-8") + for line_number, line in enumerate(source.splitlines(), 1): + if protected_call_pattern.search(line): + protected_calls.append(f"{lua_path}:{line_number}") + if len(line) > 200: + long_lines.append(f"{lua_path}:{line_number} ({len(line)} chars)") + + if protected_calls: + raise SystemExit( + "PublicLuaCheck-style protected calls remain:\n" + + "\n".join(protected_calls) + ) + if long_lines: + raise SystemExit( + "TestRunner-style Lua lines over 200 characters remain:\n" + + "\n".join(long_lines) + ) + + description = root.find("description") + if description is None or description.find("en") is None: + raise SystemExit("Missing ModHub description") + english = "".join(description.find("en").itertext()) + if "CHANGELOG 2.1.0.0" not in english: + raise SystemExit("2.1.0.0 changelog missing from English modDesc description") + PY + + - name: Run FSM compatibility check + shell: bash + run: | + set -euo pipefail + if test -f tools/check_fsm_compat.py; then + python tools/check_fsm_compat.py + fi + + - name: Build candidate ZIP + shell: bash + run: | + set -euo pipefail + rm -f FS25_CropControlOverride.zip + zip -r FS25_CropControlOverride.zip \ + modDesc.xml icon_CropControlOverride.dds gui l10n scripts templates modSettings docs \ + -x '*.DS_Store' '*__MACOSX*' 'docs/examples/*' + + python - <<'PY' + import zipfile + import xml.etree.ElementTree as ET + + required = { + "modDesc.xml", + "icon_CropControlOverride.dds", + "gui/CropControlOverrideMenu.xml", + "scripts/CropControlOverride.lua", + "scripts/CropCalendarManager.lua", + "scripts/NativeCalendarSort.lua", + "scripts/RegionalProfileManager.lua", + "scripts/CCOIntegrationApi.lua", + "templates/customRegionalProfile.xml", + } + + with zipfile.ZipFile("FS25_CropControlOverride.zip", "r") as archive: + bad = archive.testzip() + if bad is not None: + raise SystemExit("Corrupt ZIP member: " + bad) + + names = set(archive.namelist()) + missing = sorted(required - names) + if missing: + raise SystemExit("Missing packaged files: " + ", ".join(missing)) + + root = ET.fromstring(archive.read("modDesc.xml")) + if root.findtext("version") != "2.1.0.0": + raise SystemExit("Unexpected packaged modDesc version") + if root.get("descVersion") != "113": + raise SystemExit("Unexpected packaged descVersion") + PY + + sha256sum FS25_CropControlOverride.zip + + - name: Upload production candidate + uses: actions/upload-artifact@v4 + with: + name: FS25_CropControlOverride-2.1.0.0-candidate + path: FS25_CropControlOverride.zip + if-no-files-found: error + retention-days: 14 diff --git a/README.md b/README.md index f047108..4ea78fa 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,49 @@ # FS25 Crop Control Override -**Version:** 2.0.3.5 +**Version:** 2.1.0.0 **Game:** Farming Simulator 25 -**Status:** Stable release -**Author:** SimGamerJen, Hyper138 +**Status:** Production release candidate +**Author:** SimGamerJen -Crop Control Override is a per-save crop policy manager for Farming Simulator 25. It allows players to control which crops are permitted for the player, which crops NPC farmers may use, and whether NPC crop planting should be limited by field size. +Crop Control Override (CCO) is a per-save crop-policy and crop-calendar manager for Farming Simulator 25. -The mod is designed for players who want tighter control over crop realism, map-specific crop suitability, roleplay save rules, NPC field behaviour, and cleanup of NPC fields that no longer match the active crop policy. +If you have ever started a realistic regional save only to find NPC farmers planting crops that do not belong there, or wanted to change planting windows and crop availability without editing the map itself, CCO is designed for that problem. -## Current stable release +It lets the map adapt to the rules of your save rather than forcing your save to accept every crop and calendar decision supplied by the map. -The current recommended release is: +## What CCO can control -**v2.0.3.5** from the main branch. - -Older alpha and beta releases are retained for history only. - ---- - -## Contents - -- [What this mod does](#what-this-mod-does) -- [Release highlights](#release-highlights) -- [Installation](#installation) -- [Opening the GUI](#opening-the-gui) -- [Important concepts](#important-concepts) -- [GUI overview](#gui-overview) -- [Crop rule settings](#crop-rule-settings) -- [Multiplayer support](#multiplayer-support) -- [Validation and blocked NPC fields](#validation-and-blocked-npc-fields) -- [Reset modes](#reset-modes) -- [RESEED SEASONAL and candidate selection](#reseed-seasonal-and-candidate-selection) -- [Reseed weighting XML](#reseed-weighting-xml) -- [Configuration files](#configuration-files) -- [Recommended workflows](#recommended-workflows) -- [Console commands](#console-commands) -- [Troubleshooting](#troubleshooting) -- [Known limitations](#known-limitations) -- [Screenshot checklist](#screenshot-checklist) -- [Changelog](#changelog) - ---- - -## What this mod does - -Crop Control Override gives each savegame its own crop policy file. For every crop known to the mod, you can control: +For each crop, CCO can manage: | Setting | Purpose | |---|---| | Player Permitted | Whether the crop is available to the player. | | NPC Permitted | Whether NPC farmers may use the crop. | -| Max Field Size | Optional maximum field size for NPC use of that crop. | -| Reset NPC Fields | Whether blocked NPC fields of that crop may be reset by the cleanup tools. | -| Reseed Weight | Relative `0–5` chance of this crop being selected during NPC-field reseeding. `0` excludes it from reseeding without disabling it. | - -This allows you to create map-specific or region-specific crop rules without editing the map directly. - -Examples: - -- Disable rice, cotton, sugarcane, or other crops unsuitable for a region. -- Allow the player to use a crop but stop NPC farmers from planting it. -- Allow NPC farmers to use a crop only on smaller fields. -- Validate existing NPC fields after changing policy. -- Reset or reseed NPC fields that no longer comply with the active rules. - ---- - -## Release highlights - -Version `2.0.3.5` expands the NPC-field reseed system with per-crop weighting, retains deterministic reseed behaviour, completes the disabled-crop sowing safeguards introduced in 2.0.3.4, and improves German localisation coverage. - -Key features: - -- FS25-style custom GUI. -- Per-save XML configuration. -- Native selector controls for editable rule values. -- Crop table terminology aligned to the details pane: - - `Player`: `ON / OFF` - - `NPC`: `Map Default / ON / OFF` - - `Loaded`: `Yes / No` -- `NOT LOADED` crop visibility toggle. -- Field-level blocked reset scope. -- `RESET MODE` support: - - `CULTIVATED` - - `RESEED SEASONAL` -- Seasonal reseed candidate detection using FS25 growth data. -- GRASS lifecycle reseed support. -- Per-crop reseed weights from `0` to `5`. -- A global `leaveCultivated` weight. -- Deterministic weighted reseed selection. -- Dry-run before confirm workflow. -- Diagnostic console commands. -- Multiplayer-aware settings flow for local host and dedicated servers. -- Server-authoritative per-save XML handling for dedicated multiplayer. -- Read-only rule viewing for normal dedicated-server clients. -- Admin/master-user editing support for elevated multiplayer users. -- Immediate removal of disabled crops from compatible sowing-machine selectors. -- Server-authoritative sowing safeguards for players, helpers, and multiplayer clients. -- External l10n file support for community translations. - ---- - -## Installation - -1. Download the mod ZIP. -2. Place the ZIP in your Farming Simulator 25 mods folder. -3. Enable the mod in your savegame. -4. Load the save. -5. Open the Crop Control Override GUI with `ALT+C`. - -Typical Windows mod folder: - -```text -Documents/My Games/FarmingSimulator2025/mods -``` - -Do not unzip the mod for normal gameplay use. - ---- - -## Opening the GUI +| Max Field Size | Optional maximum field size for NPC use. | +| Existing Field Cleanup | Whether existing non-compliant NPC fields participate in validation/cleanup. | +| Reseed Weight | Relative 0-5 chance of selection during CCO NPC-field reseeding. | + +Rules are stored per save, so different saves can use completely different crop policies. + +## Main features + +- Per-save crop rules covering crops loaded by the active map and mod setup. +- Independent player and NPC crop permissions. +- NPC maximum field-size restrictions. +- Individual 0-5 NPC reseed weighting. +- Full Crop Calendar editor. +- Regional Profiles for regional crop suitability and planting/harvest periods. +- Player-created custom Regional Profile XML support. +- Native Crop Calendar integration. +- Nine native calendar sort modes. +- Automatic hiding of CCO-disabled crops from the native Crop Calendar. +- Existing-field validation and preview-first cleanup. +- Guarded full-map NPC field regeneration. +- Existing-field reconciliation after calendar/profile changes. +- Multiplayer and dedicated-server support. +- English, German and French localisation. + +## Opening CCO Default keybind: @@ -128,836 +51,180 @@ Default keybind: ALT+C ``` -The keybind can be changed in the Farming Simulator controls menu. +The binding can be changed in the Farming Simulator controls menu. -You can also open the GUI from the console: +The GUI can also be opened from the console: ```text ccoGui ``` -CCO v2.0.x all rules - ---- - -## Important concepts - -### Loaded crops - -A crop can exist in the CCO rule list but not be loaded by the current map or mod stack. - -The table shows this using the `Loaded` column: - -| Loaded | Meaning | -|---|---| -| Yes | The crop is currently loaded by the game/map/mod stack. | -| No | The crop is in the CCO config but is not currently loaded. | - -The `NOT LOADED` visibility toggle lets you show or hide crops that are present in the XML but not currently active in the save. - -### Player policy vs NPC policy - -Player and NPC permissions are separate. - -Example: - -| Player | NPC | Meaning | -|---|---|---| -| ON | Map Default | Player can use the crop. NPC behaviour follows map/default behaviour. | -| ON | OFF | Player can use the crop. NPCs should not plant it. | -| OFF | OFF | Crop is disabled from player and NPC use. | - -### Map Default - -`Map Default` means CCO does not force a specific NPC permission for that crop. The crop follows the map/game/default behaviour unless another CCO rule, field-size rule, or reset workflow applies. - ---- - -## GUI overview +## Crop Calendar -### ALL RULES +CCO includes a visual crop-calendar editor with planting and harvesting periods for loaded crops. -Shows all configured crop rules. +Supported workflows include: -Use this tab to select a crop and edit its policy in the details pane. +- Shift a crop lifecycle forward or backward. +- Define custom planting and harvesting windows. +- Preview changes before applying them. +- Cancel unapplied changes. +- Restore the original map-defined calendar. +- Open the CCO Calendar editor directly from the game's native Crop Calendar. -CCO v2.0.x selected crop +The native Crop Calendar also gains a sorting selector with: -### DISABLED +- Native Order +- Alphabetical A-Z +- Plantable A-Z +- Planting Start +- Harvest Start +- Plant Now +- Harvest Now +- Next Planting +- Next Harvest -Shows crops where Player Permitted is `OFF`. +The selected sort mode is stored as a player UI preference. -### SIZE LIMITED +## Regional Profiles -Shows crops with an NPC Max Field Size greater than zero. +Regional Profiles allow a save to use regionally appropriate crop suitability and planting/harvest periods without modifying the underlying map. -### NPC DISABLED +CCO includes built-in regional data and also supports player-authored XML profiles under the CCO modSettings directory. -Shows crops where NPC Permitted is `OFF`. +Regional suitability can be used to allow, hide or block crops that do not fit the selected region. -### VALIDATION +An optional read-only MoistureSystem integration can compare the selected CCO region with the active environmental profile without changing weather settings. -Shows NPC fields that violate the active crop policy. +## Validation and existing fields -This is the most important tab when changing NPC rules or field-size limits. +Changing crop policy can make existing NPC fields non-compliant. -CCO v2.0.x validation blocked fields +CCO validation reports those fields before cleanup so the player can decide what to do. -### SUMMARY +Cleanup supports: -Shows the active configuration path, rule counts, and validation summary. +- **CULTIVATED** - clear the affected NPC field to cultivated state. +- **RESEED SEASONAL** - select a valid replacement crop using the current seasonal calendar, crop policy and reseed weighting. -### HELP - -Shows in-game guidance. - ---- - -## Crop rule settings - -When you select a crop, the right-hand details pane exposes editable settings. - -### Player Permitted - -Values: - -```text -OFF / ON -``` +The GUI uses a dry-run/confirmation workflow before field state is changed. -Controls whether the crop is permitted for player use. - -### NPC Permitted - -Values: - -```text -Map Default / ON / OFF -``` - -Controls whether NPC farmers may use the crop. - -### Max Field (ha) - -Sets a maximum NPC field size for the crop. - -- `0` means no CCO size limit. -- Any value above `0` limits NPC use to fields at or below that size. -- The table displays both hectares and acres for readability. - -Example: - -```text -5.0ha / 12.4ac -``` - -### Reset NPC Fields - -Values: - -```text -OFF / ON -``` - -Controls whether CCO cleanup tools may reset blocked NPC fields of this crop. - -If this is `OFF`, blocked fields of that crop are not reset by the reset workflow. - -### Reseed Weight - -Values: - -```text -0 / 1 / 2 / 3 / 4 / 5 -``` - -Controls how strongly the selected crop participates in weighted NPC-field reseeding. - -- `0` excludes the crop from CCO reseeding. -- `1` gives the crop a very low relative chance. -- `5` gives the crop the highest normal relative chance. -- A crop with weight `0` can still remain enabled for players and NPC policy; only automatic CCO reseeding is excluded. -- The setting is stored per crop in the active savegame XML and is synchronised in multiplayer. - ---- - -## Multiplayer support - -CCO supports single-player, local hosted multiplayer, and dedicated/cloud multiplayer. The mod uses different authority rules depending on how the save is being run. - -| Mode | XML authority | GUI access | Expected behaviour | -|---|---|---|---| -| Single-player | Local player PC | Editable | Uses local `modSettings/FS25_CropControlOverride/config.xml` and the active local per-save XML. | -| Local hosted multiplayer | Host PC | Host editable; joined clients server-synced | The host remains the authority. Joined clients receive the host/server rule state and should not own the rules independently. | -| Dedicated/cloud server | Server | Admin/master users editable; normal players read-only | The dedicated server owns the active per-save XML. Remote clients display a server-synchronised in-memory copy. | - -### Dedicated server rule ownership - -On a dedicated or cloud-hosted server, CCO treats the server as the only source of truth. The server loads or creates its own active per-save XML and sends the current ruleset to joining clients. - -Remote clients should not load, create, or overwrite local CCO save files while connected to a dedicated server. In particular, a cloud-server session should not create local files such as: - -```text -modSettings/FS25_CropControlOverride/saves/savegame0.xml -modSettings/FS25_CropControlOverride/saves/savegame1.xml -``` +Player-owned fields are protected from NPC cleanup and reconciliation workflows. -The client GUI uses the server-sent rules in memory only. This prevents dedicated-server rules from overwriting local single-player or local-hosted multiplayer files. +## Full-map NPC regeneration -### Multiplayer editing permissions +CCO can build a guarded full-map NPC regeneration plan using: -Normal dedicated-server players can open CCO and view the active server rules, but the rule controls are read-only. +- Active player/NPC crop policy. +- Crop field-size limits. +- Reseed weights. +- Regional/calendar restrictions. +- Seasonal crop growth data. +- Contract-safety checks. -Players who elevate to admin/master-user status through the Farming Simulator multiplayer admin/Farm Management controls can update crop rules. Even for an elevated remote admin, changes are still sent to the server and saved server-side. The remote client does not write local CCO XML. +The plan is previewed before confirmation and uses deterministic crop selection so the confirmed operation matches the reviewed plan. -Admin-edit flow: - -```text -Remote admin opens CCO -↓ -Client displays the server-synchronised ruleset -↓ -Admin changes a rule and clicks APPLY -↓ -Request is sent to the server -↓ -Server validates admin/master-user permission -↓ -Server updates the active server-side savegame XML -↓ -Server broadcasts the refreshed ruleset back to clients -``` - -### Multiplayer safety guarantees - -The multiplayer support is designed to preserve existing local behaviour: - -- Single-player saves continue to use local per-save XML files. -- Local hosted multiplayer continues to use the host machine as the file authority. -- Dedicated-server clients use server-synchronised rules and do not create local per-save CCO XML files. -- Normal multiplayer players can view rules but cannot change them. -- Admin/master-user edits are validated by the server before the server XML is changed. - ---- - -## Validation and blocked NPC fields - -A blocked NPC field is an NPC-owned field that no longer complies with the active CCO rules. - -Common causes: - -- The crop has been disabled. -- NPC use has been disabled for the crop. -- The crop has a Max Field limit and the field is too large. -- The crop remains in the field after changing rules. - -Validation checks existing NPC fields and reports fields that need attention. - -Example validation reason: - -```text -field 6.81 ha > max 5.00 ha -``` - -The validation screen supports scoped cleanup, so you do not have to reset every blocked field at once. - -Reset scopes include: - -```text -ALL -CROP: -FIELD: -``` - -CCO v2.0.x reset scope - ---- - -## Reset modes - -The Validation screen includes `RESET MODE`. - -Available modes: - -```text -CULTIVATED -RESEED SEASONAL -``` - -### CULTIVATED - -Blocked NPC fields are cleared and reset to cultivated state. - -This is the safest cleanup behaviour and matches the earlier reset workflow. - -Dry-run example: - -```text -resetMode=CULTIVATED action=CULTIVATED reseedCandidate=NONE -``` - -### RESEED SEASONAL - -Blocked NPC fields are reset and then reseeded using a replacement crop selected by the seasonal candidate engine. - -Dry-run example: - -```text -resetMode=RESEED SEASONAL action=RESEED_SEASONAL reseedCandidate=GRASS -``` - -If no suitable seasonal candidate is available, CCO falls back to cultivated reset. - -Fallback example: - -```text -action=CULTIVATED_FALLBACK -``` - -If the weighted reseed variety chooses to leave a field cultivated, dry-run reports: - -```text -action=CULTIVATED_VARIETY -``` - -CCO v2.0.x reset mode - ---- - -## RESEED SEASONAL and candidate selection - -`RESEED SEASONAL` uses the current game period and crop growth data to find suitable replacement crops. - -The seasonal check uses: - -```text -growthDataSeasonal.periods[currentPeriod].plantingAllowed -``` - -A crop must generally pass: - -- Crop is loaded. -- Crop is seedable. -- Crop is allowed by CCO player/NPC policy. -- Crop passes Max Field limits. -- Crop is seasonally plantable in the current period. -- Crop is not in the special exclusion list. - -### Candidate categories - -Candidate diagnostics classify crops as: - -| Category | Meaning | -|---|---| -| mission | Standard seasonal field/mission crop. | -| lifecycle | Lifecycle crop allowed for reseed, currently GRASS. | -| blocked | Not eligible as an automatic reseed candidate. | - -Example candidate output: - -```text -CANOLA OK category=mission reason=allowed seasonal=OK -GRASS OK category=lifecycle reason=allowed seasonal=OK -``` - -### Special exclusions +## Configuration files -The following crop types are deliberately excluded from automatic reseed candidates for now: +Global/default template: ```text -GRAPE -OLIVE -POPLAR -MEADOW -OILSEEDRADISH -RICE -RICELONGGRAIN -``` - -These may require special handling and should not be injected into ordinary NPC field cleanup automatically. - ---- - -## Reseed weighting XML - -CCO uses a per-crop weighting system for NPC-field reseeding. - -The only global reseed candidate setting is: - -```xml - - - -``` - -Each crop rule stores its own reseed weight: - -```xml - - - -``` - -### Per-crop weights - -| Value | Behaviour | -|---|---| -| `0` | Never selected by CCO reseeding. The crop itself is not disabled. | -| `1` | Very low relative chance. | -| `2` | Low relative chance. | -| `3` | Medium relative chance. | -| `4` | High relative chance. | -| `5` | Highest normal relative chance. | - -Weights are relative. A crop at `5` is five times as likely to be selected as a crop at `1`, provided both crops pass all seasonal, policy, field-size, and compatibility checks. - -### Leave cultivated weight - -`leaveCultivated` remains a global weighted pseudo-candidate. When selected, the reset field is left cultivated instead of being reseeded. - -Example: - -```xml - +modSettings/FS25_CropControlOverride/config.xml ``` -With three valid crops weighted `5`, `3`, and `1`, the weighted pool is effectively: +Per-save configurations: ```text -CROP_A x5 -CROP_B x3 -CROP_C x1 -LEAVE_CULTIVATED x1 +modSettings/FS25_CropControlOverride/saves/savegameXX.xml ``` -Set `leaveCultivated="0"` to prevent weighted reseed variety from intentionally leaving a field cultivated when valid crop candidates exist. - -### Migration from earlier configs - -Existing per-save XML files are migrated automatically when loaded: - -- missing per-crop `reseedWeight` values default to `5`; -- obsolete `seasonalMission` and `seasonalLifecycle` settings are removed; -- the existing `leaveCultivated` value is retained; -- existing crop permissions and field-size rules are preserved. - -The migrated active configuration can be reloaded from disk without restarting FS25 by running: +Native Calendar sort preference: ```text -ccoReload +modSettings/FS25_CropControlOverride/calendarSort.xml ``` -Do not save through the GUI between manually editing the XML and running `ccoReload`, because the currently loaded rules could overwrite the external edits. - ---- - -## Configuration files - -CCO uses a template/default config and per-save configs. - -The active per-save config is stored under: +CCO UI preferences: ```text -modSettings/FS25_CropControlOverride/saves/savegameXX.xml +modSettings/FS25_CropControlOverride/uiSettings.xml ``` -The template/default config is stored under: +Custom Regional Profiles: ```text -modSettings/FS25_CropControlOverride/config.xml +modSettings/FS25_CropControlOverride/regionalProfiles/ ``` -### Per-save behaviour - -When a savegame is loaded, CCO uses the per-save XML if it exists. If it does not exist, CCO creates one from the template config. - -This prevents rules for one savegame from unexpectedly changing another savegame. - -### Multiplayer file behaviour - -In single-player and local hosted multiplayer, the local machine or host machine owns the CCO XML files. - -In dedicated/cloud multiplayer, the dedicated server owns the XML files. Remote clients receive the active ruleset from the server and keep it in memory for display. They should not create or update local CCO per-save XML files during that session. - -Use `ccoWhichConfig` from the server or host environment when you need to confirm the active file path. On a normal remote client, the GUI reflects the server-synchronised rules rather than a local savegame XML. - -### Save Defaults to config.xml - -The GUI option `SAVE DEFAULTS TO CONFIG.XML` writes the current active rule set to the template config. - -Existing per-save XML files are not overwritten. +CCO automatically creates a starter custom Regional Profile template when required. -### Load Defaults +## Multiplayer -The GUI option `LOAD DEFAULTS` loads the template config into the active save config. +CCO supports single-player, local hosted multiplayer and dedicated/cloud multiplayer. -Use this carefully because it updates the active save’s CCO XML. +On a dedicated server: ---- +- The server is authoritative for active crop rules. +- Remote clients use the server-synchronised ruleset. +- Normal players are read-only. +- Admin/master users can submit rule changes. +- Validation and cleanup remain server-authoritative. +- Remote clients do not own separate local per-save CCO rule files for the server session. -## Recommended workflows - -### Change a crop rule safely - -1. Open the GUI with `ALT+C`. -2. Go to `ALL RULES`. -3. Select the crop. -4. Change the desired settings in the details pane. -5. Click `APPLY`. -6. If validation blocks the change, review the warning. -7. Use `FORCE APPLY` only if you intend to allow blocked fields temporarily. -8. Go to `VALIDATION` to review affected NPC fields. - -### Change rules on a dedicated server - -1. Join the server. -2. Open CCO with `ALT+C`. -3. If you are a normal player, the rules should display as read-only. -4. Elevate to admin/master-user status through the Farming Simulator multiplayer admin/Farm Management controls. -5. Reopen or refresh CCO if needed. -6. Change the rule and click `APPLY`. -7. Confirm the server-side CCO savegame XML updates. -8. Confirm no local client-side CCO `savegame?.xml` file has been created. - -### Reset blocked fields to cultivated - -1. Go to `VALIDATION`. -2. Choose `RESET SCOPE`. -3. Set `RESET MODE` to `CULTIVATED`. -4. Run `RESET BLOCKED DRY-RUN`. -5. Review the output. -6. Click `CONFIRM RESET`. - -### Reseed blocked fields seasonally - -1. Go to `VALIDATION`. -2. Choose `RESET SCOPE`. -3. Set `RESET MODE` to `RESEED SEASONAL`. -4. Run `RESET BLOCKED DRY-RUN`. -5. Confirm the candidates look sensible. -6. Click `CONFIRM RESET`. - -Expected dry-run example: - -```text -field=24 action=RESEED_SEASONAL reseedCandidate=CANOLA -field=65 action=RESEED_SEASONAL reseedCandidate=GRASS -field=77 action=CULTIVATED_VARIETY reseedCandidate=NONE -``` - -Expected confirm example: - -```text -queued field 24 to reseeded crop CANOLA -queued field 65 to reseeded crop GRASS -queued field 77 to cultivated state -``` - ---- - -## Console commands - -### GUI and status +## Useful console commands ```text ccoGui ccoStatus ccoWhichConfig ccoReload -ccoHelp -``` - -### Rule listing - -```text -ccoListRules [CROP] -ccoListConfigured [CROP] -ccoListDisabled -ccoListBlockedRules -ccoListLimited -ccoListUndiscovered -``` - -### Validation and scanning - -```text ccoValidateSave -ccoScanFields [CROP] -ccoScanBlocked [CROP] -ccoScanSummary -``` - -### Reset commands - -```text +ccoScanBlocked ccoResetBlocked dryrun ccoResetBlocked -ccoResetNpcFields [CROP|all] [dryrun] -``` - -The GUI reset workflow is preferred for scoped reset and reseed mode control. - -### Candidate diagnostics - -```text -ccoListNpcCandidates -ccoSeasonProbe [CROP] -ccoGrowthProbe [CROP] -``` - -Example: - -```text -ccoListNpcCandidates 87 -``` - -Output may include: - -```text -CANOLA OK category=mission reason=allowed seasonal=OK -GRASS OK category=lifecycle reason=allowed seasonal=OK -``` - -### Crop flags and diagnostics - -```text -ccoListFlags [CROP] -ccoFindFruit -ccoExplain -``` - ---- - -## Troubleshooting - -### ALT+C does not open the GUI - -Check the game log for Lua errors. - -If the mod fails to compile or load, the GUI cannot register. - -Look for lines like: - -```text -Lua compiler error -CCO GUI: custom screen failed -CropControlOverrideMenu class is not available -``` - -### The GUI opens but shows fallback console output - -This usually means the XML GUI or GUI class failed to load. - -Check: - -- The mod ZIP structure. -- `gui/CropControlOverrideMenu.xml`. -- `scripts/gui/CropControlOverrideMenu.lua`. -- The game log for stack traces. - -### Dedicated server client sees default/local rules - -On a dedicated server, remote clients should display the server-synchronised ruleset, not their own local `config.xml` or local per-save XML. - -Check the game log for CCO sync messages and confirm the client is running the same mod version as the server. A normal remote client should not create a local CCO `savegame0.xml` when joining a dedicated server. - -### Dedicated server GUI is read-only - -This is expected for normal players. Rule changes are restricted to admin/master users. - -To edit rules on a dedicated server, elevate privileges through the Farming Simulator multiplayer admin/Farm Management controls, then reopen or refresh CCO. Changes are still saved by the server, not by the remote client. - -### Changes apply to config.xml but not the save XML - -The active target should normally be: - -```text -modSettings/FS25_CropControlOverride/saves/savegameXX.xml -``` - -Use: - -```text -ccoWhichConfig -``` - -to confirm which file is active. - -### A crop is not offered as a reseed candidate - -Run: - -```text -ccoListNpcCandidates -``` - -Reasons may include: - -- Crop is not loaded. -- Crop is not seedable. -- Crop is blocked by CCO policy. -- Crop exceeds Max Field limit. -- Crop is not seasonally plantable. -- Crop is specially excluded. -- Crop has `reseedWeight="0"`. -- Crop is valid but loses deterministic weighted selection. - -### A valid crop is not selected often - -This is expected when several crops are eligible. - -Check the crop's per-save XML rule: - -```xml - +ccoListNpcCandidates +ccoSeasonProbe +ccoGrowthProbe +ccoCalendarReport +ccoCalendarShiftPreview +ccoCalendarShiftApply +ccoCalendarCustomPreview +ccoCalendarCustomApply +ccoCalendarRestore +ccoHelp ``` -Increase or decrease the crop's `reseedWeight` from `0` to `5` to adjust its relative chance. A value of `0` excludes it from CCO reseeding entirely. +The GUI is the recommended interface for normal use; console commands are primarily useful for diagnostics and development. -### Dry-run and confirm do not match +## 2.1.0.0 highlights -This should not happen under normal conditions. +- Added the full Crop Calendar editor with visual planting and harvesting periods, preview/cancel workflow and restore-to-map-default support. +- Added Regional Profiles with built-in regional crop calendars and support for player-created XML profiles. +- Added regional crop suitability and optional allow, hide or block policies. +- Added read-only MoistureSystem environmental-profile integration. +- Added guarded full-map NPC field regeneration using the active crop policy and seasonal calendar. +- Added existing-field reconciliation while protecting player-owned fields. +- Added direct **EDIT CALENDAR** access from the native game Crop Calendar. +- Added native Crop Calendar sorting with nine modes. +- Added automatic hiding of CCO-disabled crops from the native Crop Calendar. +- Added persistent disabled-crop visibility preference. +- Added a Weight column showing each crop's 0-5 NPC reseed weighting. +- Excluded the technical MEADOW fruit type from CCO crop management and calendar presentation. +- Added extensive dedicated-server and multiplayer improvements. +- Added crop-selection safeguards, validation improvements, UI refinements, diagnostics and localisation updates. -Possible causes: +## Contributor acknowledgement -- The field state changed between dry-run and confirm. -- Another mod changed the field. -- The save was reloaded with different XML weights. -- The crop candidate set changed. +Hyper138's NPC field-size limitation concept and development inspired the field-size restriction functionality used by CCO. -Run dry-run again immediately before confirm. +## Safety ---- +Use a backup before large-scale NPC-field regeneration or cleanup. -## Known limitations - -- `RESEED SEASONAL` only targets blocked NPC fields in the selected reset scope. -- Player-owned fields are not reset by NPC cleanup. -- Some crop types are excluded from automatic reseed because they may require special handling. -- Console reset commands do not expose the full GUI reset-mode workflow. -- Seasonal logic depends on FS25 crop growth data being available for the loaded crop. -- Modded crops may behave differently depending on how their fruit type data is defined. -- Dedicated-server clients depend on the server-synchronised ruleset; if the server/client mod versions differ, GUI state may not match. -- Remote multiplayer editing requires admin/master-user elevation. Normal players are intentionally read-only. - ---- - -## Screenshot checklist - -Use these markers to add screenshots later. - -### Suggested screenshots - -1. `docs/screenshots/cco_all_rules.png` - - Main crop table on ALL RULES. - -2. `docs/screenshots/cco_all_rules_selected.png` - - Crop selected, details pane visible. - -3. `docs/screenshots/cco_not_loaded_toggle.png` - - NOT LOADED crops shown/hidden. - -4. `docs/screenshots/cco_validation_blocked.png` - - VALIDATION tab with blocked fields. - -5. `docs/screenshots/cco_reset_scope.png` - - RESET SCOPE button showing ALL / CROP / FIELD. - -6. `docs/screenshots/cco_reset_mode.png` - - RESET MODE showing CULTIVATED / RESEED SEASONAL. - -7. `docs/screenshots/cco_dry_run_reseed.png` - - Dry-run output with CANOLA / GRASS / CULTIVATED_VARIETY. - -8. `docs/screenshots/cco_summary.png` - - SUMMARY tab. - -9. `docs/screenshots/cco_help.png` - - HELP tab. - -### Markdown screenshot marker format - -Use this pattern: - -```markdown - - -![Screenshot placeholder - description](docs/screenshots/example.png) -``` +CCO deliberately uses preview, validation and confirmation stages before destructive field operations. Changing a crop rule does not automatically wipe existing fields. ---- - -## Changelog - -### 2.0.3.5 - -- Replaced category-based reseed weighting with an individual `0–5` weight for every configured crop. -- Added the Reseed Weight control to the crop-rule details pane. -- Defined `0` as an explicit reseed exclusion without disabling the crop itself. -- Retained `leaveCultivated` as the global weighted option for leaving some reset fields cultivated. -- Removed the obsolete `seasonalMission` and `seasonalLifecycle` weighting settings. -- Added per-crop reseed-weight persistence to active savegame XML files. -- Added multiplayer synchronisation for per-crop reseed weights. -- Added automatic migration for existing configs, defaulting missing crop weights to `5`. -- Prevented fallback reseed selection from bypassing crops configured with weight `0`. -- Added and translated new reseed-weight UI text. -- Completed a broader German localisation pass across the GUI and gameplay messages. - -### 2.0.3.4 - -- Removed disabled player crops immediately from compatible sowing-machine seed selectors. -- Restored re-enabled crops without requiring a savegame reload. -- Added server-authoritative sowing and direct-sowing safeguards. -- Prevented stale selections, helpers, multiplayer clients, and compatible third-party worker systems from planting disabled crops. -- Stopped active AI fieldwork when a prohibited crop is selected and displayed a centre-screen warning. -- Improved multiplayer rule delivery with server push, client retry handling, and deferred client policy application. -- Added and completed French localisation support. - -### 2.0.1.7 - -- Added dedicated/cloud multiplayer support with server-authoritative rule synchronisation. -- Added remote client protection so dedicated-server clients do not create or update local CCO per-save XML files. -- Added read-only GUI behaviour for normal dedicated-server players. -- Added admin/master-user permission support for elevated multiplayer users. -- Added server-side validation before accepting multiplayer rule changes. -- Updated NPC crop planning logic to use the FS25 planned-fruit hook rather than the older mission-generation approach. -- Reduced contract-list side effects by avoiding global NPC mission flag mutation for NPC-only blocking. -- Added defensive handling around NPC crop replacement logic. -- Added external l10n file support for translations. -- Fixed input-binding localisation fallback. -- Fixed dedicated-client server sync timing and GUI wait-state behaviour. -- Fixed permission hook load order. - -### 2.0.0 - -- Promoted the beta branch to the main stable release. -- Added native selector controls. -- Added NOT LOADED crop visibility toggle. -- Added field-level reset scope. -- Added RESET MODE. -- Added RESEED SEASONAL. -- Added seasonal reseed candidate detection. -- Added GRASS lifecycle reseed candidate support. -- Added XML-configurable reseed candidate weights. -- Added CULTIVATED_VARIETY weighted pseudo-candidate. -- Added deterministic weighted reseed selection. -- Updated validation, dry-run, confirm, and diagnostic output. - ---- - -## Disclaimer - -Use a backup save when making significant crop policy changes, resetting blocked NPC fields, or using reseed workflows. CCO changes active save configuration and can alter NPC field states when reset tools are confirmed. - ---- - -## Licence and Permissions +## Licence and permissions Copyright © 2026 SimGamerJen. All rights reserved. -You may download and use this mod for personal use. You may not modify, redistribute, re-upload, or publish this mod, in whole or in part, or any derivative version without prior written permission from SimGamerJen. +You may download and use this mod for personal use. You may not modify, redistribute, re-upload or publish the mod, in whole or in part, or a derivative version without prior written permission from SimGamerJen. diff --git a/RELEASE_NOTES_v2.1.0.0.md b/RELEASE_NOTES_v2.1.0.0.md new file mode 100644 index 0000000..3793276 --- /dev/null +++ b/RELEASE_NOTES_v2.1.0.0.md @@ -0,0 +1,47 @@ +# Crop Control Override 2.1.0.0 + +Crop Control Override 2.1.0.0 is the production release of the 2.1 development line for Farming Simulator 25. + +This release expands CCO from crop-policy management into a broader regional crop-management system: per-save crop rules, regional profiles, crop-calendar editing, native calendar sorting, NPC-field validation, safe cleanup and guarded full-map NPC field regeneration. + +## Highlights + +- Per-save crop availability and NPC planting policy. +- Maximum NPC field-size rules per crop. +- Individual 0-5 NPC reseed weighting. +- Full Crop Calendar editor with preview, custom planting/harvest windows and map-default restore. +- Regional Profiles with regional crop suitability and calendar data. +- Player-authored custom Regional Profile XML support. +- Native Crop Calendar integration with direct **EDIT CALENDAR** access. +- Native Crop Calendar sorting with nine ordering modes. +- Automatic hiding of CCO-disabled crops from the native Crop Calendar. +- Existing-field validation and preview-first cleanup. +- Guarded full-map NPC field regeneration using the active policy and seasonal calendar. +- Existing-field reconciliation while protecting player-owned fields. +- Multiplayer and dedicated-server support with server-authoritative rules. +- English, German and French localisation. + +## Changes since the previous ModHub production release + +- Added the full Crop Calendar editor with visual planting and harvesting periods, preview/cancel workflow and restore-to-map-default support. +- Added Regional Profiles with built-in regional crop calendars and support for player-created XML profiles. +- Added regional crop suitability and optional allow, hide or block policies for unsuitable crops. +- Added read-only MoistureSystem environmental-profile integration. +- Added guarded full-map NPC field regeneration using the active crop policy and seasonal calendar. +- Added existing-field reconciliation for safely applying calendar and Regional Profile changes to NPC fields while protecting player-owned fields. +- Added direct **EDIT CALENDAR** access from the native game Crop Calendar. +- Added native Crop Calendar sorting with Native Order, A-Z, Plantable A-Z, Planting Start, Harvest Start, Plant Now, Harvest Now, Next Planting and Next Harvest modes. +- Added persistent native calendar-sort preference. +- Added automatic hiding of CCO-disabled crops from the native Crop Calendar. +- Added persistent **DISABLED: SHOWN/HIDDEN** filtering to CCO rule and calendar views. +- Added a Weight column showing each crop's 0-5 NPC reseed weighting. +- Added automatic creation of a starter custom Regional Profile template. +- Excluded the technical MEADOW fruit type from CCO crop management and calendar presentation. +- Added extensive dedicated-server and multiplayer improvements. +- Added crop-selection safeguards, validation improvements, UI refinements, diagnostics and localisation updates. + +## Safety + +CCO uses preview, validation and confirmation stages before destructive field operations. Crop-rule changes do not automatically wipe fields, and calendar changes can be previewed before being committed. + +As with any mod that can alter NPC field state, keeping a backup before large-scale regeneration or cleanup is recommended. diff --git a/modDesc.xml b/modDesc.xml index 0ac1beb..c943018 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -1,166 +1,214 @@ - + SimGamerJen 2.1.0.0 <en>Crop Control Override</en> - <de>Crop Control Override Öffnen</de> - <fr>Ouvrir Crop Control Override</fr> diff --git a/scripts/CCOIntegrationApi.lua b/scripts/CCOIntegrationApi.lua index 64d5c45..9fc59b9 100644 --- a/scripts/CCOIntegrationApi.lua +++ b/scripts/CCOIntegrationApi.lua @@ -29,13 +29,13 @@ end local function getMissionCount(cco) if cco ~= nil and type(cco.getMissionCountForRegeneration) == "function" then - local ok, value = pcall(cco.getMissionCountForRegeneration, cco) - if ok then return tonumber(value) end + local value = cco:getMissionCountForRegeneration() + return tonumber(value) end if g_missionManager ~= nil and type(g_missionManager.getMissions) == "function" then - local ok, missions = pcall(g_missionManager.getMissions, g_missionManager) - if ok and type(missions) == "table" then return #missions end + local missions = g_missionManager:getMissions() + if type(missions) == "table" then return #missions end end return nil end @@ -262,14 +262,7 @@ if FSBaseMission ~= nil then results = { previousLoadMapFinished(self, ...) } end - local ok, publishError = pcall(publishApi, self) - if not ok then - if CCO_Debug ~= nil and CCO_Debug.warn ~= nil then - CCO_Debug:warn("failed publishing integration API: " .. tostring(publishError)) - else - print("CCO [WARN] failed publishing integration API: " .. tostring(publishError)) - end - end + publishApi(self) if results ~= nil then return unpack(results) end end diff --git a/scripts/CropCalendarManager.lua b/scripts/CropCalendarManager.lua index 2ea8da8..6cd9f77 100644 --- a/scripts/CropCalendarManager.lua +++ b/scripts/CropCalendarManager.lua @@ -191,8 +191,8 @@ local function getSeasonalSources(fruitType) if fruitType == nil then return sources end if fruitType.getSeasonalGrowthData ~= nil then - local ok, data = pcall(fruitType.getSeasonalGrowthData, fruitType) - if ok then addSeasonalSource(sources, seen, data, "getSeasonalGrowthData") end + local data = fruitType:getSeasonalGrowthData() + addSeasonalSource(sources, seen, data, "getSeasonalGrowthData") end addSeasonalSource(sources, seen, fruitType.growthDataSeasonal, "growthDataSeasonal") @@ -1294,9 +1294,8 @@ end local function missionIsActive(mission) if mission == nil then return false end - if mission.getWasStarted ~= nil then - local ok, value = pcall(mission.getWasStarted, mission) - if ok and value == true then return true end + if mission.getWasStarted ~= nil and mission:getWasStarted() == true then + return true end return mission.farmId ~= nil or mission.activeMissionId ~= nil end @@ -1583,7 +1582,11 @@ function Calendar:printReport(cropName) for _, row in ipairs(self:getRows()) do if wanted == nil or wanted == "" or row.name == wanted then found = true - print(("CCO CALENDAR: %-16s planting=%-20s harvest=%-20s periods=%d transitions=%d initial=%s initialSource=%s source=%s category=%s edit=%s lifecycle=%s status=%s mode=%s shift=%+d baselineChanged=%s"):format( + local formatText = + "CCO CALENDAR: %-16s planting=%-20s harvest=%-20s periods=%d transitions=%d " .. + "initial=%s initialSource=%s source=%s category=%s edit=%s lifecycle=%s " .. + "status=%s mode=%s shift=%+d baselineChanged=%s" + print((formatText):format( row.name, row.plantingText or "-", row.harvestText or "-", diff --git a/scripts/CropControlOverride.lua b/scripts/CropControlOverride.lua index 64064e0..d29360c 100644 --- a/scripts/CropControlOverride.lua +++ b/scripts/CropControlOverride.lua @@ -13,7 +13,7 @@ CropControlOverride = { MOD_ID = g_currentModName or "FS25_CropControlOverride", - VERSION = "2.1.0.0-beta.3", + VERSION = "2.1.0.0", _origFlags = {}, _rules = {}, @@ -337,12 +337,10 @@ local function getCurrentPeriodIndex() if env ~= nil then period = env.currentPeriod or env.period or env.currentSeasonPeriod if period == nil and env.getCurrentPeriod ~= nil then - local ok, result = pcall(function() return env:getCurrentPeriod() end) - if ok then period = result end + period = env:getCurrentPeriod() end if period == nil and env.getPeriod ~= nil then - local ok, result = pcall(function() return env:getPeriod() end) - if ok then period = result end + period = env:getPeriod() end end @@ -491,7 +489,10 @@ local function getSeasonalSowingStatus(ft) for _, key in ipairs(SEEDING_PERIOD_FIELDS) do local result = tableHasPeriod(ft.data[key], period) if result ~= nil then - return result == true, result == true and ("seasonal period " .. tostring(period) .. " allowed by data." .. key) or ("outside seasonal period " .. tostring(period) .. " via data." .. key), period + local seasonalMessage = result == true + and ("seasonal period " .. tostring(period) .. " allowed by data." .. key) + or ("outside seasonal period " .. tostring(period) .. " via data." .. key) + return result == true, seasonalMessage, period end end @@ -684,15 +685,8 @@ function CCO:applyRules(rules, seedSyncMode) -- time, so a mid-session rule change (GUI APPLY, server sync on a remote -- client) never reached them. Rebuild those cached lists now. local preserveNetworkIndex = seedSyncMode == "serverSnapshot" and isClientOnlyMultiplayer() - local ok, e = pcall(function() self:refreshAllSowingMachines(preserveNetworkIndex) end) - if not ok then debug("refreshAllSowingMachines skipped: " .. tostring(e)) end - - local calendarOk, calendarErr = pcall(function() - self:refreshNativeCalendarIfOpen() - end) - if not calendarOk then - debug("refreshNativeCalendarIfOpen skipped: " .. tostring(calendarErr)) - end + self:refreshAllSowingMachines(preserveNetworkIndex) + self:refreshNativeCalendarIfOpen() end local function buildDefaultRules() @@ -1397,9 +1391,9 @@ function CCO:filterSowingMachineSeeds(vehicle, preserveNetworkIndex) if isClientOnlyMultiplayer() then -- Never let a remote client's CCO cache refresh send a native -- SetSeedIndexEvent back to the authoritative server. - pcall(function() vehicle:setSeedIndex(newIndex, true) end) + vehicle:setSeedIndex(newIndex, true) else - pcall(function() vehicle:setSeedIndex(newIndex) end) + vehicle:setSeedIndex(newIndex) end elseif spec.currentSeed ~= nil then spec.currentSeed = math.min(math.max(1, newIndex), #spec.seeds) @@ -1426,8 +1420,8 @@ function CCO:refreshAllSowingMachines(preserveNetworkIndex) local refreshed = 0 for _, vehicle in pairs(iterMissionVehicles()) do if getSowingMachineSpec(vehicle) ~= nil then - local ok, changed = pcall(function() return self:filterSowingMachineSeeds(vehicle, preserveNetworkIndex) end) - if ok and changed == true then refreshed = refreshed + 1 end + local changed = self:filterSowingMachineSeeds(vehicle, preserveNetworkIndex) + if changed == true then refreshed = refreshed + 1 end end end if refreshed > 0 then @@ -1686,8 +1680,7 @@ function CCO:setFieldReseeded(field, fruit, growthState) local groundType = nil if fruit.getGrowthStateGroundType ~= nil then - local okGround, resolvedGround = pcall(function() return fruit:getGrowthStateGroundType(state) end) - if okGround then groundType = resolvedGround end + groundType = fruit:getGrowthStateGroundType(state) end if groundType == nil and FieldGroundType ~= nil then groundType = FieldGroundType.SOWN or FieldGroundType.SEEDBED or FieldGroundType.CULTIVATED @@ -1892,15 +1885,7 @@ function CCO:printStartupValidation() if self._startupValidationPrinted then return end self._startupValidationPrinted = true - local ok, summaryOrError = pcall(function() - return self:buildFieldSummary(nil) - end) - if not ok then - warn("startup validation skipped: " .. tostring(summaryOrError)) - return - end - - local summary = summaryOrError + local summary = self:buildFieldSummary(nil) if summary == nil then return end if summary.offending ~= nil and summary.offending > 0 then warn(("startup validation found %d blocked NPC field(s). Run ccoScanBlocked, then ccoResetBlocked dryrun if cleanup is intended."):format(summary.offending)) @@ -1939,7 +1924,15 @@ function CCO:resetNpcFields(filterCrop, dryRun, resetMode) wouldQueue = wouldQueue + 1 local action, candidateCrop, candidateReason = self:getDryRunResetActionForField(field, cropName, resetMode) print(("CCO: dry-run would reset field=%s crop=%s size=%.2fha reason=%s resetMode=%s action=%s reseedCandidate=%s candidateReason=%s"):format( - tostring(getFieldId(field, idx)), cropName, getFieldSizeHa(field), tostring(reason), select(2, self:normaliseResetMode(resetMode)), tostring(action), tostring(candidateCrop), tostring(candidateReason))) + tostring(getFieldId(field, idx)), + cropName, + getFieldSizeHa(field), + tostring(reason), + select(2, self:normaliseResetMode(resetMode)), + tostring(action), + tostring(candidateCrop), + tostring(candidateReason) + )) else if removedMissions == nil then local purgeReason @@ -1988,63 +1981,45 @@ end function CCO:generatePlannedFruitForField(superFunc, fieldManager, field) - local ok, proposedFruit = pcall(function() - return superFunc(fieldManager, field) - end) + local proposedFruit = superFunc(fieldManager, field) - if not ok then - warn("generatePlannedFruitForField original call failed: " .. tostring(proposedFruit)) - -- Do not convert an engine planning failure into a nil fruit value that - -- can be stored on a mission and crash repeatedly during update. - error(proposedFruit, 0) + if proposedFruit == nil or proposedFruit == FruitType.UNKNOWN then + return proposedFruit end - local ok2, selectedFruit = pcall(function() - if proposedFruit == nil or proposedFruit == FruitType.UNKNOWN then - return proposedFruit - end - - if field ~= nil and not isNpcField(field) then - return proposedFruit - end - - local ft = g_fruitTypeManager ~= nil and g_fruitTypeManager:getFruitTypeByIndex(proposedFruit) or nil - if ft == nil then - return proposedFruit - end + if field ~= nil and not isNpcField(field) then + return proposedFruit + end - local cropName = upper(ft.name) - local fieldHa = getFieldSizeHa(field) - local allowed, reason = self:isNpcCropAllowedForField(fieldHa, cropName) - if allowed then - return proposedFruit - end - - -- Mission planning must always return a registered fruit index. The - -- weighted "leave cultivated" outcome is valid for cleanup/regeneration, - -- but returning nil here creates a SowMission whose update later - -- dereferences a missing fruit descriptor. - local replacement, replacementReason = self:findReplacementNpcCropForField(field, cropName, false) - if replacement ~= nil and replacement.index ~= nil then - debug(("planned fruit replacement for field %s: %s -> %s (%.2f ha): %s"):format( - tostring(getFieldId(field)), cropName, upper(replacement.name), fieldHa, tostring(reason))) - return replacement.index - end - - warn(("planned fruit replacement unavailable for field %s: %s (%.2f ha): %s; retaining engine fruit index %s so SowMission remains valid (%s)"):format( - tostring(getFieldId(field)), cropName, fieldHa, tostring(reason), tostring(proposedFruit), tostring(replacementReason))) - -- SowMission.isAvailableForField applies the policy rejection later. - -- Keeping the engine's valid proposal is safer than injecting nil into - -- the base-game mission lifecycle. + local ft = g_fruitTypeManager ~= nil and g_fruitTypeManager:getFruitTypeByIndex(proposedFruit) or nil + if ft == nil then return proposedFruit - end) + end - if not ok2 then - warn("generatePlannedFruitForField CCO selection failed: " .. tostring(selectedFruit)) + local cropName = upper(ft.name) + local fieldHa = getFieldSizeHa(field) + local allowed, reason = self:isNpcCropAllowedForField(fieldHa, cropName) + if allowed then return proposedFruit end - return selectedFruit + -- Mission planning must always return a registered fruit index. The + -- weighted "leave cultivated" outcome is valid for cleanup/regeneration, + -- but returning nil here creates a SowMission whose update later + -- dereferences a missing fruit descriptor. + local replacement, replacementReason = self:findReplacementNpcCropForField(field, cropName, false) + if replacement ~= nil and replacement.index ~= nil then + debug(("planned fruit replacement for field %s: %s -> %s (%.2f ha): %s"):format( + tostring(getFieldId(field)), cropName, upper(replacement.name), fieldHa, tostring(reason))) + return replacement.index + end + + warn(("planned fruit replacement unavailable for field %s: %s (%.2f ha): %s; retaining engine fruit index %s so SowMission remains valid (%s)"):format( + tostring(getFieldId(field)), cropName, fieldHa, tostring(reason), tostring(proposedFruit), tostring(replacementReason))) + -- SowMission.isAvailableForField applies the policy rejection later. + -- Keeping the engine's valid proposal is safer than injecting nil into + -- the base-game mission lifecycle. + return proposedFruit end -- Enhanced runtime sowing enforcement (v2.0.3.x) ---------------------------- @@ -2114,19 +2089,19 @@ function CCO:showSowingNotPermitted(fruitTypeIndex) -- job has already been stopped, and may succeed without displaying text. local hud = g_currentMission.hud if hud ~= nil and hud.showBlinkingWarning ~= nil then - local ok, result = pcall(hud.showBlinkingWarning, hud, message, 5000) - info(("SOW-BLOCK warning display path=hud.showBlinkingWarning ok=%s result=%s message=%s"):format( - tostring(ok), tostring(result), tostring(message))) - if ok then return end + local result = hud:showBlinkingWarning(message, 5000) + info(("SOW-BLOCK warning display path=hud.showBlinkingWarning result=%s message=%s"):format( + tostring(result), tostring(message))) + return end -- Compatibility fallback for game builds/mod stacks exposing the method -- directly on the mission. if g_currentMission.showBlinkingWarning ~= nil then - local ok, result = pcall(g_currentMission.showBlinkingWarning, g_currentMission, message, 5000) - info(("SOW-BLOCK warning display path=mission.showBlinkingWarning ok=%s result=%s message=%s"):format( - tostring(ok), tostring(result), tostring(message))) - if ok then return end + local result = g_currentMission:showBlinkingWarning(message, 5000) + info(("SOW-BLOCK warning display path=mission.showBlinkingWarning result=%s message=%s"):format( + tostring(result), tostring(message))) + return end -- INGAME_NOTIFICATION_OK is already used elsewhere by CCO and is known to @@ -2134,10 +2109,10 @@ function CCO:showSowingNotPermitted(fruitTypeIndex) -- critical constants, as a nil type creates a blank overlay in FS25. local notificationType = FSBaseMission ~= nil and FSBaseMission.INGAME_NOTIFICATION_OK or nil if type(notificationType) == "number" and g_currentMission.addIngameNotification ~= nil then - local ok, result = pcall(g_currentMission.addIngameNotification, g_currentMission, notificationType, message) - info(("SOW-BLOCK warning display path=addIngameNotification ok=%s result=%s type=%s message=%s"):format( - tostring(ok), tostring(result), tostring(notificationType), tostring(message))) - if ok then return end + local result = g_currentMission:addIngameNotification(notificationType, message) + info(("SOW-BLOCK warning display path=addIngameNotification result=%s type=%s message=%s"):format( + tostring(result), tostring(notificationType), tostring(message))) + return end info("SOW-BLOCK warning display failed message=" .. tostring(message)) @@ -2147,8 +2122,8 @@ end local function ccoObjectName(object) if object == nil then return "nil" end if object.getName ~= nil then - local ok, name = pcall(object.getName, object) - if ok and name ~= nil then return tostring(name) end + local name = object:getName() + if name ~= nil then return tostring(name) end end return tostring(object) end @@ -2156,8 +2131,8 @@ end function CCO:stopBlockedSowingWorker(vehicle, fruitTypeIndex) local rootVehicle = vehicle if vehicle ~= nil and vehicle.getRootVehicle ~= nil then - local ok, result = pcall(vehicle.getRootVehicle, vehicle) - if ok and result ~= nil then rootVehicle = result end + local result = vehicle:getRootVehicle() + if result ~= nil then rootVehicle = result end end self:showSowingNotPermitted(fruitTypeIndex) @@ -2167,22 +2142,35 @@ function CCO:stopBlockedSowingWorker(vehicle, fruitTypeIndex) return end - local getIsAIActiveOk, getIsAIActiveResult = false, nil + local getIsAIActiveAvailable = rootVehicle.getIsAIActive ~= nil + local getIsAIActiveResult = nil local isAIActive = false - if rootVehicle.getIsAIActive ~= nil then - getIsAIActiveOk, getIsAIActiveResult = pcall(rootVehicle.getIsAIActive, rootVehicle) - isAIActive = getIsAIActiveOk and getIsAIActiveResult == true + if getIsAIActiveAvailable then + getIsAIActiveResult = rootVehicle:getIsAIActive() + isAIActive = getIsAIActiveResult == true elseif rootVehicle.spec_aiFieldWorker ~= nil then isAIActive = rootVehicle.spec_aiFieldWorker.isActive == true end local aiSpec = rootVehicle.spec_aiFieldWorker local job = rootVehicle.getCurrentAIJob ~= nil and rootVehicle:getCurrentAIJob() or nil - info(("SOW-BLOCK worker state crop=%s implement=%s root=%s aiActive=%s getIsAIActiveOk=%s specActive=%s currentJob=%s methods[stopCurrentAIJob=%s stopAIVehicle=%s setAIFieldWorkerActive=%s setIsAIActive=%s]"):format( - getSowingCropName(fruitTypeIndex), ccoObjectName(vehicle), ccoObjectName(rootVehicle), tostring(isAIActive), - tostring(getIsAIActiveOk), tostring(aiSpec ~= nil and aiSpec.isActive or nil), tostring(job), - tostring(rootVehicle.stopCurrentAIJob ~= nil), tostring(rootVehicle.stopAIVehicle ~= nil), - tostring(rootVehicle.setAIFieldWorkerActive ~= nil), tostring(rootVehicle.setIsAIActive ~= nil))) + local workerStateFormat = + "SOW-BLOCK worker state crop=%s implement=%s root=%s aiActive=%s " .. + "getIsAIActiveAvailable=%s specActive=%s currentJob=%s " .. + "methods[stopCurrentAIJob=%s stopAIVehicle=%s setAIFieldWorkerActive=%s setIsAIActive=%s]" + info((workerStateFormat):format( + getSowingCropName(fruitTypeIndex), + ccoObjectName(vehicle), + ccoObjectName(rootVehicle), + tostring(isAIActive), + tostring(getIsAIActiveAvailable), + tostring(aiSpec ~= nil and aiSpec.isActive or nil), + tostring(job), + tostring(rootVehicle.stopCurrentAIJob ~= nil), + tostring(rootVehicle.stopAIVehicle ~= nil), + tostring(rootVehicle.setAIFieldWorkerActive ~= nil), + tostring(rootVehicle.setIsAIActive ~= nil) + )) local stopSucceeded = false local attempts = {} @@ -2191,10 +2179,10 @@ function CCO:stopBlockedSowingWorker(vehicle, fruitTypeIndex) attempts[#attempts + 1] = name .. "=missing" return false end - local ok, result = pcall(fn, object, ...) - attempts[#attempts + 1] = name .. "[ok=" .. tostring(ok) .. ",result=" .. tostring(result) .. "]" - if ok then stopSucceeded = true end - return ok + local result = fn(object, ...) + attempts[#attempts + 1] = name .. "[called=true,result=" .. tostring(result) .. "]" + stopSucceeded = true + return true end if isAIActive or job ~= nil then @@ -2208,7 +2196,8 @@ function CCO:stopBlockedSowingWorker(vehicle, fruitTypeIndex) local turnOffOk = false if vehicle ~= nil and vehicle.setIsTurnedOn ~= nil then - turnOffOk = pcall(vehicle.setIsTurnedOn, vehicle, false, true) + vehicle:setIsTurnedOn(false, true) + turnOffOk = true end info(("SOW-BLOCK stop result crop=%s root=%s success=%s turnOffOk=%s attempts=%s"):format( @@ -2357,27 +2346,6 @@ end -local function ccoCreateAIStopMessage() - local constructors = { - AIMessageErrorUnknown, - AIMessageErrorBlockedByObject, - AIMessageErrorCouldNotStart, - } - for _, class in ipairs(constructors) do - if class ~= nil and class.new ~= nil then - local ok, message = pcall(class.new, class) - if ok and message ~= nil then - return message - end - ok, message = pcall(class.new) - if ok and message ~= nil then - return message - end - end - end - return nil -end - local function ccoCollectSowingImplements(rootVehicle) local result = {} local seen = {} @@ -2388,8 +2356,8 @@ local function ccoCollectSowingImplements(rootVehicle) result[#result + 1] = object end if object.getAttachedImplements ~= nil then - local ok, attached = pcall(object.getAttachedImplements, object) - if ok and attached ~= nil then + local attached = object:getAttachedImplements() + if attached ~= nil then for _, entry in pairs(attached) do add(entry.object or entry) end @@ -2422,8 +2390,8 @@ function CCO:scanAndStopBlockedSowingWorkers(dt) end if parameter ~= nil then if parameter.getVehicle ~= nil then - local ok, vehicle = pcall(parameter.getVehicle, parameter) - if ok and vehicle ~= nil then return vehicle end + local vehicle = parameter:getVehicle() + if vehicle ~= nil then return vehicle end end if parameter.vehicle ~= nil then return parameter.vehicle end if parameter.value ~= nil and type(parameter.value) == "table" then return parameter.value end @@ -2451,8 +2419,7 @@ function CCO:scanAndStopBlockedSowingWorkers(dt) addJobsFrom(aiSystem.jobs) addJobsFrom(aiSystem.currentJobs) if aiSystem.getActiveJobs ~= nil then - local ok, activeJobs = pcall(aiSystem.getActiveJobs, aiSystem) - if ok then addJobsFrom(activeJobs) end + addJobsFrom(aiSystem:getActiveJobs()) end end @@ -2503,9 +2470,9 @@ function CCO:scanAndStopBlockedSowingWorkers(dt) attempts[#attempts + 1] = name .. "=missing" return end - local ok, result = pcall(fn, object, ...) - attempts[#attempts + 1] = name .. "[ok=" .. tostring(ok) .. ",result=" .. tostring(result) .. "]" - if ok then stopped = true end + local result = fn(object, ...) + attempts[#attempts + 1] = name .. "[called=true,result=" .. tostring(result) .. "]" + stopped = true end -- Do not pass a fabricated AIMessage object. Courseplay and @@ -2562,12 +2529,12 @@ function CCO:installSeedingGuards() warn("SowingMachine class not available; seed selector filter not installed") elseif SowingMachine.onPostLoad ~= nil then SowingMachine.onPostLoad = Utils.appendedFunction(SowingMachine.onPostLoad, function(vehicle, ...) - pcall(function() CCO:filterSowingMachineSeeds(vehicle) end) + CCO:filterSowingMachineSeeds(vehicle) end) debug("hooked SowingMachine.onPostLoad (seed list filter)") elseif SowingMachine.onLoad ~= nil then SowingMachine.onLoad = Utils.appendedFunction(SowingMachine.onLoad, function(vehicle, ...) - pcall(function() CCO:filterSowingMachineSeeds(vehicle) end) + CCO:filterSowingMachineSeeds(vehicle) end) debug("hooked SowingMachine.onLoad (seed list filter)") end @@ -2651,8 +2618,7 @@ function CCO:discardInvalidSowMission(mission, failure) if g_missionManager ~= nil and g_missionManager.markMissionForDeletion ~= nil then g_missionManager:markMissionForDeletion(mission) elseif mission.delete ~= nil then - local ok, deleteError = pcall(mission.delete, mission) - if not ok then warn("failed deleting invalid SowMission: " .. tostring(deleteError)) end + mission:delete() end if g_missionManager ~= nil and g_missionManager.generationTimer ~= nil then @@ -2684,23 +2650,9 @@ function CCO:applyLateHooks() debug("hooked SowMission.isAvailableForField") end - if SowMission ~= nil and SowMission.update ~= nil and not self._sowUpdateGuardApplied then - self._sowUpdateGuardApplied = true - SowMission.update = Utils.overwrittenFunction(SowMission.update, function(mission, superFunc, dt) - local ok, result = pcall(superFunc, mission, dt) - if ok then return result end - - local failure = tostring(result) - if failure:find("attempt to index nil", 1, true) ~= nil - and failure:find("getIsPlantableInPeriod", 1, true) ~= nil then - CCO:discardInvalidSowMission(mission, failure) - return - end - - error(result, 0) - end) - debug("hooked SowMission.update (invalid fruit recovery guard)") - end + -- Do not wrap SowMission.update in a protected call in the public build. + -- Invalid fruit missions are prevented by the planning and availability + -- guards above; any unexpected engine error must remain visible in the log. end -- Native Calendar-page integration ------------------------------------------ @@ -2708,9 +2660,9 @@ end -- by CCO. This adds a direct route from that page into the guarded CCO editor. local function ccoNativeCalendarButtonText() if g_i18n ~= nil and g_i18n.getText ~= nil then - local ok, value = pcall(function() return g_i18n:getText("cco_calendar_open_editor") end) + local value = g_i18n:getText("cco_calendar_open_editor") local text = tostring(value or "") - if ok and text ~= "" and text ~= "cco_calendar_open_editor" and text:find("^Missing '") == nil then + if text ~= "" and text ~= "cco_calendar_open_editor" and text:find("^Missing '") == nil then return value end end @@ -2793,13 +2745,7 @@ function CCO:refreshNativeCalendarIfOpen() return false end - local ok, result = pcall(self.updateNativeCalendarContents, self, frame) - if not ok then - debug("native Calendar refresh failed: " .. tostring(result)) - return false - end - - return result == true + return self:updateNativeCalendarContents(frame) == true end function CCO:addNativeCalendarMenuButton(frame) @@ -2876,12 +2822,12 @@ function CCO:addNativeCalendarMenuButton(frame) end if frame.setMenuButtonInfo ~= nil then - pcall(frame.setMenuButtonInfo, frame, buttons) + frame:setMenuButtonInfo(buttons) else frame.menuButtonInfo = buttons end frame.hasCustomMenuButtons = true - if frame.setMenuButtonInfoDirty ~= nil then pcall(frame.setMenuButtonInfoDirty, frame) end + if frame.setMenuButtonInfoDirty ~= nil then frame:setMenuButtonInfoDirty() end end function CCO:installNativeCalendarButtonHook() @@ -2958,23 +2904,20 @@ local originalFruitTypeLoadMapData = FruitTypeManager.loadMapData function FruitTypeManager:loadMapData(xmlFile, missionInfo, baseDir, customEnv, isMission) local result = originalFruitTypeLoadMapData(self, xmlFile, missionInfo, baseDir, customEnv, isMission) - local ok, e = pcall(function() - if CCO_RegionalProfiles ~= nil and CCO_RegionalProfiles.loadCustomProfiles ~= nil then - CCO_RegionalProfiles:loadCustomProfiles() - end - local rules, path = CCO:loadRulesForMission(missionInfo) - if CCO._mpClientOnly == true and CCO._serverSettingsSynced ~= true then - -- Remote client before the server snapshot: the in-memory rules - -- are all-enabled placeholders. Applying them would actively - -- (re)enable every crop; leave the map defaults untouched and let - -- applyServerSettingsPayload do the first real apply. - debug("remote client: deferring crop policy apply until server rules arrive") - else - CCO:applyRules(rules) - debug(("applied crop policy from %s"):format(tostring(path))) - end - end) - if not ok then warn("failed applying crop policy in loadMapData: " .. tostring(e)) end + if CCO_RegionalProfiles ~= nil and CCO_RegionalProfiles.loadCustomProfiles ~= nil then + CCO_RegionalProfiles:loadCustomProfiles() + end + local rules, path = CCO:loadRulesForMission(missionInfo) + if CCO._mpClientOnly == true and CCO._serverSettingsSynced ~= true then + -- Remote client before the server snapshot: the in-memory rules + -- are all-enabled placeholders. Applying them would actively + -- (re)enable every crop; leave the map defaults untouched and let + -- applyServerSettingsPayload do the first real apply. + debug("remote client: deferring crop policy apply until server rules arrive") + else + CCO:applyRules(rules) + debug(("applied crop policy from %s"):format(tostring(path))) + end CCO:applyRuntimeHooks() @@ -2989,36 +2932,33 @@ function FSBaseMission:loadMapFinished(...) results = { originalFSBaseMissionLoadMapFinished(self, ...) } end - local ok, e = pcall(function() - CCO._rules = mergeMissingDiscoveredFruits(CCO._rules or {}) - if not isClientOnlyMultiplayer() and CCO._configPath ~= nil then - writeConfig(CCO._configPath, CCO._rules, CCO._settings) - end - if CCO._mpClientOnly == true and CCO._serverSettingsSynced ~= true then - debug("remote client: skipping loadMapFinished reapply until server rules arrive") - else - CCO:applyRules(CCO._rules) - end - CCO:applyLateHooks() - CCO:installNativeCalendarButtonHook() - if CCO_CropCalendar ~= nil and CCO_CropCalendar.captureMapDefaults ~= nil then - local _, calendarMessage = CCO_CropCalendar:captureMapDefaults(false) - debug("calendar discovery: " .. tostring(calendarMessage)) - if CCO_CropCalendar.setConfiguredShifts ~= nil then - CCO_CropCalendar:setConfiguredShifts(CCO._settings ~= nil and CCO._settings.calendarShifts or nil) - if CCO_CropCalendar.setConfiguredCustomOverrides ~= nil then - CCO_CropCalendar:setConfiguredCustomOverrides(CCO._settings ~= nil and CCO._settings.calendarCustomOverrides or nil) - end - end - if CCO_CropCalendar.applyConfiguredShifts ~= nil then - local _, applyMessage = CCO_CropCalendar:applyConfiguredShifts() - debug("calendar overrides: " .. tostring(applyMessage)) + CCO._rules = mergeMissingDiscoveredFruits(CCO._rules or {}) + if not isClientOnlyMultiplayer() and CCO._configPath ~= nil then + writeConfig(CCO._configPath, CCO._rules, CCO._settings) + end + if CCO._mpClientOnly == true and CCO._serverSettingsSynced ~= true then + debug("remote client: skipping loadMapFinished reapply until server rules arrive") + else + CCO:applyRules(CCO._rules) + end + CCO:applyLateHooks() + CCO:installNativeCalendarButtonHook() + if CCO_CropCalendar ~= nil and CCO_CropCalendar.captureMapDefaults ~= nil then + local _, calendarMessage = CCO_CropCalendar:captureMapDefaults(false) + debug("calendar discovery: " .. tostring(calendarMessage)) + if CCO_CropCalendar.setConfiguredShifts ~= nil then + CCO_CropCalendar:setConfiguredShifts(CCO._settings ~= nil and CCO._settings.calendarShifts or nil) + if CCO_CropCalendar.setConfiguredCustomOverrides ~= nil then + CCO_CropCalendar:setConfiguredCustomOverrides(CCO._settings ~= nil and CCO._settings.calendarCustomOverrides or nil) end end - debug("reapplied crop policy after loadMapFinished") - CCO:printStartupValidation() - end) - if not ok then warn("failed during loadMapFinished reapply: " .. tostring(e)) end + if CCO_CropCalendar.applyConfiguredShifts ~= nil then + local _, applyMessage = CCO_CropCalendar:applyConfiguredShifts() + debug("calendar overrides: " .. tostring(applyMessage)) + end + end + debug("reapplied crop policy after loadMapFinished") + CCO:printStartupValidation() if results ~= nil then return unpack(results) end end @@ -3070,10 +3010,7 @@ end) if FSBaseMission ~= nil and FSBaseMission.onConnectionFinishedLoading ~= nil then FSBaseMission.onConnectionFinishedLoading = Utils.appendedFunction(FSBaseMission.onConnectionFinishedLoading, function(mission, connection, ...) if g_server ~= nil and connection ~= nil then - local ok, e = pcall(function() - CCO:sendSettingsSnapshotToClient(connection, "clientFinishedLoading") - end) - if not ok then warn("failed to push CCO rules to joining client: " .. tostring(e)) end + CCO:sendSettingsSnapshotToClient(connection, "clientFinishedLoading") end end) end @@ -3105,12 +3042,7 @@ function CCO:updatePendingValidationSync(dt) if self._pendingValidationSyncTimer > 0 then return end self._pendingValidationSyncTimer = nil - local ok, e = pcall(function() - self:sendSettingsSnapshotToClient(nil, "validationRefresh") - end) - if not ok then - warn("failed to refresh dedicated-server validation snapshot after field cleanup: " .. tostring(e)) - end + self:sendSettingsSnapshotToClient(nil, "validationRefresh") end if FSBaseMission ~= nil and FSBaseMission.update ~= nil then @@ -3146,15 +3078,10 @@ local function showCcoCustomGui(title, text, topic, page) text = text or "" if CropControlOverrideMenu ~= nil and CropControlOverrideMenu.show ~= nil then - local ok, shown = pcall(function() - return CropControlOverrideMenu.show(title, text, CCO.MOD_DIRECTORY or g_currentModDirectory, topic, page) - end) - if ok and shown then + local shown = CropControlOverrideMenu.show(title, text, CCO.MOD_DIRECTORY or g_currentModDirectory, topic, page) + if shown then return true end - if not ok then - print("CCO GUI: custom screen failed: " .. tostring(shown)) - end else print("CCO GUI: CropControlOverrideMenu class is not available") end @@ -3168,7 +3095,10 @@ local function showCcoCustomGui(title, text, topic, page) end function CCO:consoleGuiTest() - return showCcoCustomGui("Crop Control Override - Dialog Test", "If you can read this, the custom CCO GUI screen is rendering correctly.\n\nThis screen replaces the experimental InfoDialog approach used in alpha.11-alpha.13.") + local body = + "If you can read this, the custom CCO GUI screen is rendering correctly.\n\n" .. + "This screen replaces the experimental InfoDialog approach used in alpha.11-alpha.13." + return showCcoCustomGui("Crop Control Override - Dialog Test", body) end function CCO:buildGuiStatusText() @@ -3263,8 +3193,8 @@ function CCO:getGuiCalendarRows() local displayName = row.name local iconFilename = nil if g_fillTypeManager ~= nil and g_fillTypeManager.getFillTypeByName ~= nil then - local ok, fillType = pcall(function() return g_fillTypeManager:getFillTypeByName(row.name) end) - if ok and fillType ~= nil then + local fillType = g_fillTypeManager:getFillTypeByName(row.name) + if fillType ~= nil then if fillType.title ~= nil and fillType.title ~= "" then displayName = fillType.title end iconFilename = fillType.hudOverlayFilename end @@ -3299,8 +3229,7 @@ local CCO_FIELD_STATE_KEYS = { "growthState", "growthStateIndex", "state", "frui local function getCalendarFieldGrowthState(field) local fieldState = field ~= nil and field.fieldState or nil if fieldState == nil and field ~= nil and field.getFieldState ~= nil then - local ok, value = pcall(field.getFieldState, field) - if ok then fieldState = value end + fieldState = field:getFieldState() end if type(fieldState) ~= "table" then return nil end for _, key in ipairs(CCO_FIELD_STATE_KEYS) do @@ -3938,9 +3867,7 @@ function CCO:_refreshOpenGuiPermissionState(msg) end if controller ~= nil and controller.showTopic ~= nil then - pcall(function() - controller:showTopic(controller.currentTopic or "rules", controller.currentPage or 1) - end) + controller:showTopic(controller.currentTopic or "rules", controller.currentPage or 1) end if msg ~= nil then @@ -3958,10 +3885,8 @@ function CCO:_setServerConnectionMasterUser(connection, isMasterUser) if isMasterUser == true then local user = true if g_currentMission ~= nil and g_currentMission.userManager ~= nil and g_currentMission.userManager.getUserByConnection ~= nil then - local okUser, resolvedUser = pcall(function() - return g_currentMission.userManager:getUserByConnection(connection) - end) - if okUser and resolvedUser ~= nil then + local resolvedUser = g_currentMission.userManager:getUserByConnection(connection) + if resolvedUser ~= nil then user = resolvedUser end end @@ -4036,15 +3961,15 @@ end function CCO:onMasterUserAdded(user) if user ~= nil and user.getConnection ~= nil then - local ok, connection = pcall(function() return user:getConnection() end) - if ok and connection ~= nil then + local connection = user:getConnection() + if connection ~= nil then self:_setServerConnectionMasterUser(connection, true) end end if g_currentMission ~= nil and user ~= nil and user.getId ~= nil then - local ok, userId = pcall(function() return user:getId() end) - if ok and userId == g_currentMission.playerUserId then + local userId = user:getId() + if userId == g_currentMission.playerUserId then self:_onLocalAdminStateChanged("masterUserAdded") end end @@ -4058,10 +3983,10 @@ function CCO:onUserAdded(user) -- Local server/host authority is always trusted. Track that connection too, -- matching the EasyDevControls master-user model. if g_currentMission ~= nil and user.getId ~= nil then - local okId, userId = pcall(function() return user:getId() end) - if okId and userId == g_currentMission.playerUserId then - local okCon, connection = pcall(function() return user:getConnection() end) - if okCon and connection ~= nil then + local userId = user:getId() + if userId == g_currentMission.playerUserId then + local connection = user:getConnection() + if connection ~= nil then self:_setServerConnectionMasterUser(connection, true) end end @@ -4070,8 +3995,8 @@ end function CCO:onUserRemoved(user) if user ~= nil and user.getConnection ~= nil then - local ok, connection = pcall(function() return user:getConnection() end) - if ok and connection ~= nil then + local connection = user:getConnection() + if connection ~= nil then self:_setServerConnectionMasterUser(connection, false) end end @@ -4119,8 +4044,8 @@ function CCO:getIsAdminConnection(connection) local connectionChecks = { "getIsMasterUser", "getIsAdmin", "getIsServerAdmin" } for _, fn in ipairs(connectionChecks) do if connection[fn] ~= nil then - local okCheck, value = pcall(function() return connection[fn](connection) end) - if okCheck and value == true then + local value = connection[fn](connection) + if value == true then self:_setServerConnectionMasterUser(connection, true) return true end @@ -4135,15 +4060,13 @@ function CCO:getIsAdminConnection(connection) -- Defensive fallback: if FS25 exposes the remote user and marks it directly -- as master/admin, honour that without requiring CCO to own an admin login. if g_currentMission ~= nil and g_currentMission.userManager ~= nil and g_currentMission.userManager.getUserByConnection ~= nil then - local ok, user = pcall(function() - return g_currentMission.userManager:getUserByConnection(connection) - end) - if ok and user ~= nil then + local user = g_currentMission.userManager:getUserByConnection(connection) + if user ~= nil then local checks = { "getIsMasterUser", "getIsAdmin", "getIsServerAdmin" } for _, fn in ipairs(checks) do if user[fn] ~= nil then - local okCheck, value = pcall(function() return user[fn](user) end) - if okCheck and value == true then + local value = user[fn](user) + if value == true then self:_setServerConnectionMasterUser(connection, true) return true end @@ -4154,13 +4077,13 @@ function CCO:getIsAdminConnection(connection) return true end if user.getId ~= nil then - local okId, userId = pcall(function() return user:getId() end) - if okId and userId ~= nil then + local userId = user:getId() + if userId ~= nil then local missionChecks = { "getIsUserMasterUser", "getIsUserAdmin", "getIsMasterUser" } for _, fn in ipairs(missionChecks) do if g_currentMission[fn] ~= nil then - local okMission, value = pcall(function() return g_currentMission[fn](g_currentMission, userId) end) - if okMission and value == true then + local value = g_currentMission[fn](g_currentMission, userId) + if value == true then self:_setServerConnectionMasterUser(connection, true) return true end @@ -4169,8 +4092,8 @@ function CCO:getIsAdminConnection(connection) local managerChecks = { "getIsUserMasterUser", "getIsUserAdmin", "getIsMasterUser" } for _, fn in ipairs(managerChecks) do if g_currentMission.userManager[fn] ~= nil then - local okManager, value = pcall(function() return g_currentMission.userManager[fn](g_currentMission.userManager, userId) end) - if okManager and value == true then + local value = g_currentMission.userManager[fn](g_currentMission.userManager, userId) + if value == true then self:_setServerConnectionMasterUser(connection, true) return true end @@ -4256,14 +4179,9 @@ function CCO:buildServerValidationPayload() } for _, row in ipairs(self:getBlockedFieldRows()) do - local candidateCrop, candidateReason = "NONE", "candidate unavailable" - local okCandidate, resolvedCrop, resolvedReason = pcall(function() - return self:getReseedCandidateTextForField(row.field, row.cropName) - end) - if okCandidate then - candidateCrop = resolvedCrop or "NONE" - candidateReason = resolvedReason or "" - end + local resolvedCrop, resolvedReason = self:getReseedCandidateTextForField(row.field, row.cropName) + local candidateCrop = resolvedCrop or "NONE" + local candidateReason = resolvedReason or "" table.insert(lines, table.concat({ "@field", encodeMultiplayerFieldValue(row.fieldId), @@ -4370,7 +4288,7 @@ end function CCO:sendSettingsSnapshotToClient(connection, reason) if self._rules == nil or not next(self._rules) then if not isClientOnlyMultiplayer() then - pcall(function() self:loadRulesForMission(g_currentMission and g_currentMission.missionInfo) end) + self:loadRulesForMission(g_currentMission and g_currentMission.missionInfo) end end @@ -4471,7 +4389,14 @@ function CCO:_sendMultiplayerEvent(operation, a, b, c, d, e, f, g) if g_server ~= nil then local ok, msg, extra = self:handleMultiplayerEvent(operation, {a, b, c, d, e, f, g}, nil) - if operation ~= "requestSettings" and operation ~= "syncSettings" and operation ~= "calendarShiftPreview" and operation ~= "calendarCustomPreview" and operation ~= "regionalProfilePreview" and ok == true then + local isPreviewOperation = + operation == "calendarShiftPreview" + or operation == "calendarCustomPreview" + or operation == "regionalProfilePreview" + if operation ~= "requestSettings" + and operation ~= "syncSettings" + and not isPreviewOperation + and ok == true then self:sendSettingsSnapshotToClient(nil, operation) end return ok, msg, extra @@ -4491,19 +4416,13 @@ function CCO:refreshOpenGuiAfterMultiplayerSync(operation, msg) if controller ~= nil and controller.showTopic ~= nil then local topic = controller.currentTopic or "rules" local page = controller.currentPage or 1 - pcall(function() - controller:showTopic(topic, page) - end) + controller:showTopic(topic, page) if controller.selectedDirtyText ~= nil and controller.selectedDirtyText.setText ~= nil then - pcall(function() - controller.selectedDirtyText:setText("Settings synced from server.") - end) + controller.selectedDirtyText:setText("Settings synced from server.") end if controller.selectedInfoText ~= nil and controller.selectedInfoText.setText ~= nil then - pcall(function() - controller.selectedInfoText:setText(tostring(self._guiNotice or "Server settings applied.")) - end) + controller.selectedInfoText:setText(tostring(self._guiNotice or "Server settings applied.")) end end end @@ -4569,7 +4488,7 @@ function CCO:handleMultiplayerEvent(operation, args, connection) args = args or {} self._handlingMpEvent = true - local ok, r1, r2, r3, r4 = pcall(function() + local function dispatchOperation() if operation == "syncSettings" then return self:applyServerSettingsPayload(args[1], args[2], args[3], args[5], args[6]) elseif operation == "operationResult" then @@ -4644,17 +4563,11 @@ function CCO:handleMultiplayerEvent(operation, args, connection) return self:_consoleSetCropLocal(args[1], args[2], args[3], args[4]) end return false, "Unknown CCO multiplayer operation: " .. tostring(operation) - end) + end + local r1, r2, r3, r4 = dispatchOperation() self._handlingMpEvent = false - if not ok then - local msg = "CCO multiplayer event failed: " .. tostring(r1) - warn(msg) - self._guiNotice = msg - return false, msg - end - if r1 == true and operation ~= "syncSettings" and operation ~= "operationResult" and g_client ~= nil and self.refreshOpenGuiAfterMultiplayerSync ~= nil then self:refreshOpenGuiAfterMultiplayerSync(operation, r2) end @@ -4899,11 +4812,17 @@ function CCO:registerGlobalActionEvents(player, inputBinding) return end - local ok, _, eventId = pcall(function() - return inputBinding:registerActionEvent(InputAction.CCO_OPEN_GUI, self, self.onInputOpenGui, false, true, false, true) - end) + local _, eventId = inputBinding:registerActionEvent( + InputAction.CCO_OPEN_GUI, + self, + self.onInputOpenGui, + false, + true, + false, + true + ) - if ok and eventId ~= nil then + if eventId ~= nil then self._openGuiActionEventId = eventId if inputBinding.setActionEventText ~= nil and g_i18n ~= nil then inputBinding:setActionEventText(eventId, g_i18n:getText("input_CCO_OPEN_GUI")) @@ -5019,7 +4938,14 @@ function CCO:consoleListConfigured(name) local r = self._rules[nameU] local ft = getFruitByName(nameU) print(("CCO: %s enabled=%s npcAllowed=%s npcMaxHa=%.2f resetNpcFields=%s reseedWeight=%d discovered=%s"):format( - nameU, tostring(r.enabled), tostring(r.npcAllowed == nil and "mapDefault" or r.npcAllowed), tonumber(r.npcMaxHa or 0), tostring(r.resetNpcFields), clampWeight(r.reseedWeight, DEFAULT_FRUIT_RESEED_WEIGHT), tostring(ft ~= nil))) + nameU, + tostring(r.enabled), + tostring(r.npcAllowed == nil and "mapDefault" or r.npcAllowed), + tonumber(r.npcMaxHa or 0), + tostring(r.resetNpcFields), + clampWeight(r.reseedWeight, DEFAULT_FRUIT_RESEED_WEIGHT), + tostring(ft ~= nil) + )) printed = printed + 1 end end @@ -5041,7 +4967,13 @@ function CCO:consoleListUndiscovered() for _, nameU in ipairs(names) do local r = self._rules[nameU] print(("CCO: %s enabled=%s npcAllowed=%s npcMaxHa=%.2f resetNpcFields=%s reseedWeight=%d"):format( - nameU, tostring(r.enabled), tostring(r.npcAllowed == nil and "mapDefault" or r.npcAllowed), tonumber(r.npcMaxHa or 0), tostring(r.resetNpcFields), clampWeight(r.reseedWeight, DEFAULT_FRUIT_RESEED_WEIGHT))) + nameU, + tostring(r.enabled), + tostring(r.npcAllowed == nil and "mapDefault" or r.npcAllowed), + tonumber(r.npcMaxHa or 0), + tostring(r.resetNpcFields), + clampWeight(r.reseedWeight, DEFAULT_FRUIT_RESEED_WEIGHT) + )) end print(("CCO: undiscovered crop list complete. count=%d"):format(#names)) end @@ -5742,7 +5674,15 @@ function CCO:resetBlockedFieldById(fieldId, dryRun, resetMode) if dryRun == true then local action, candidateCrop, candidateReason = self:getDryRunResetActionForField(field, cropName, resetMode) print(("CCO: dry-run would reset field=%s crop=%s size=%.2fha reason=%s resetMode=%s action=%s reseedCandidate=%s candidateReason=%s"):format( - tostring(getFieldId(field, idx)), cropName, getFieldSizeHa(field), tostring(reason), select(2, self:normaliseResetMode(resetMode)), tostring(action), tostring(candidateCrop), tostring(candidateReason))) + tostring(getFieldId(field, idx)), + cropName, + getFieldSizeHa(field), + tostring(reason), + select(2, self:normaliseResetMode(resetMode)), + tostring(action), + tostring(candidateCrop), + tostring(candidateReason) + )) return 1, 0 else local removedMissions, purgeReason = self:purgeAvailableContractsForRegeneration() @@ -6233,8 +6173,18 @@ function CCO:resolveRegenerationGrowthState(ft, periodsOverride) and harvestMin >= 1 and harvestMin <= harvestMax and ft.useForFieldMissions ~= false then - return harvestMin, ("authoritative harvest-window fallback; source=%s harvestRange=%s-%s naturalOrigins=0 replayedOrigins=%d rejectedOrigins=%d rejectionReasons=%s fallbackUsed=true"):format( - tostring(harvestSource), tostring(harvestMin), tostring(harvestMax), replayedOrigins, rejectedOrigins, summarizeRejections()), true + local fallbackReason = + "authoritative harvest-window fallback; source=%s harvestRange=%s-%s " .. + "naturalOrigins=0 replayedOrigins=%d rejectedOrigins=%d " .. + "rejectionReasons=%s fallbackUsed=true" + return harvestMin, (fallbackReason):format( + tostring(harvestSource), + tostring(harvestMin), + tostring(harvestMax), + replayedOrigins, + rejectedOrigins, + summarizeRejections() + ), true end return nil, ("no authoritative mapped outcome for current period; harvestable=%s harvestRange=%s-%s"):format( @@ -6248,9 +6198,21 @@ function CCO:resolveRegenerationGrowthState(ft, periodsOverride) return a.plantingPeriod < b.plantingPeriod end) local selected = outcomes[1] - return selected.state, ("authoritative seasonal growthMapping replay; plantedPeriod=%d steps=%d currentHarvestable=%s harvestReady=%s harvestRange=%s-%s naturalOrigins=%d replayedOrigins=%d rejectedOrigins=%d fallbackUsed=false"):format( - selected.plantingPeriod, selected.steps, tostring(currentHarvestable), tostring(selected.harvestReady), - tostring(harvestMin), tostring(harvestMax), #outcomes, replayedOrigins, rejectedOrigins), true + local replayReason = + "authoritative seasonal growthMapping replay; plantedPeriod=%d steps=%d " .. + "currentHarvestable=%s harvestReady=%s harvestRange=%s-%s naturalOrigins=%d " .. + "replayedOrigins=%d rejectedOrigins=%d fallbackUsed=false" + return selected.state, (replayReason):format( + selected.plantingPeriod, + selected.steps, + tostring(currentHarvestable), + tostring(selected.harvestReady), + tostring(harvestMin), + tostring(harvestMax), + #outcomes, + replayedOrigins, + rejectedOrigins + ), true end local function deterministicRegenerationValue(fieldId, period, year, totalWeight) @@ -6396,8 +6358,7 @@ function CCO:getActiveContractCount() for _, mission in ipairs(g_missionManager:getMissions() or {}) do local wasStarted = false if mission.getWasStarted ~= nil then - local ok, value = pcall(mission.getWasStarted, mission) - wasStarted = ok and value == true + wasStarted = mission:getWasStarted() == true end if wasStarted or mission.farmId ~= nil or mission.activeMissionId ~= nil then count = count + 1 @@ -6414,8 +6375,7 @@ function CCO:getContractBoardSummary() summary.total = summary.total + 1 local wasStarted = false if mission ~= nil and mission.getWasStarted ~= nil then - local ok, value = pcall(mission.getWasStarted, mission) - wasStarted = ok and value == true + wasStarted = mission:getWasStarted() == true end local active = mission ~= nil and (wasStarted or mission.farmId ~= nil or mission.activeMissionId ~= nil) if active then summary.active = summary.active + 1 @@ -6443,12 +6403,8 @@ function CCO:purgeAvailableContractsForRegeneration() local removed = 0 for _, mission in ipairs(missions) do - local ok, err = pcall(function() mission:delete() end) - if ok then - removed = removed + 1 - else - warn("failed deleting stale available contract: " .. tostring(err)) - end + mission:delete() + removed = removed + 1 end return removed, "ok" end @@ -6580,21 +6536,17 @@ end function CCO:refreshRegeneratedFieldStates(state) local refreshed, failed = 0, 0 for _, field in ipairs(state.fields or {}) do - local ok, err = pcall(function() - if field ~= nil and field.getFieldState ~= nil and field.getIndicatorPosition ~= nil then - local fieldState = field:getFieldState() - local posX, posZ = field:getIndicatorPosition() - if fieldState ~= nil and fieldState.update ~= nil and posX ~= nil and posZ ~= nil then - fieldState:update(posX, posZ) - refreshed = refreshed + 1 - return - end + if field ~= nil and field.getFieldState ~= nil and field.getIndicatorPosition ~= nil then + local fieldState = field:getFieldState() + local posX, posZ = field:getIndicatorPosition() + if fieldState ~= nil and fieldState.update ~= nil and posX ~= nil and posZ ~= nil then + fieldState:update(posX, posZ) + refreshed = refreshed + 1 + else + failed = failed + 1 end + else failed = failed + 1 - end) - if not ok then - failed = failed + 1 - warn("failed refreshing regenerated field state: " .. tostring(err)) end end info(("refreshed regenerated field-state caches refreshed=%d failed=%d"):format(refreshed, failed)) @@ -6636,11 +6588,9 @@ local function getRegenerationFieldIdFromValue(value) for _, getter in ipairs(getters) do local fn = value[getter] if type(fn) == "function" then - local ok, result = pcall(fn, value) - if ok then - local resolved = normalizeRegenerationFieldId(result) - if resolved ~= nil then return resolved end - end + local result = fn(value) + local resolved = normalizeRegenerationFieldId(result) + if resolved ~= nil then return resolved end end end @@ -6763,11 +6713,9 @@ local function getRegenerationMissionFieldId(mission) for _, getter in ipairs(getters) do local fn = mission[getter] if type(fn) == "function" then - local ok, result = pcall(fn, mission) - if ok then - local resolved = getRegenerationFieldIdFromValue(result) - if resolved ~= nil then return resolved, "mission:" .. getter .. "()" end - end + local result = fn(mission) + local resolved = getRegenerationFieldIdFromValue(result) + if resolved ~= nil then return resolved, "mission:" .. getter .. "()" end end end @@ -6898,8 +6846,21 @@ function CCO:auditNpcMapRegenerationMissions(state) debug(("mission audit crop=%s ready=%d contracts=%d naturalReady=%d naturalContracts=%d fallbackReady=%d fallbackContracts=%d"):format( crop, stats.ready, stats.contracts, stats.naturalReady, stats.naturalContracts, stats.fallbackReady, stats.fallbackContracts)) end - info(("mission audit summary harvestReadyFields=%d readyWithMission=%d readyWithoutMission=%d naturalReady=%d naturalContracts=%d fallbackReady=%d fallbackContracts=%d totalMissions=%d unmatchedMissionFields=%d"):format( - readyFields, readyWithMission, readyWithoutMission, naturalReady, naturalContracts, fallbackReady, fallbackContracts, #missions, unmatchedMissions)) + local auditSummaryFormat = + "mission audit summary harvestReadyFields=%d readyWithMission=%d readyWithoutMission=%d " .. + "naturalReady=%d naturalContracts=%d fallbackReady=%d fallbackContracts=%d " .. + "totalMissions=%d unmatchedMissionFields=%d" + info((auditSummaryFormat):format( + readyFields, + readyWithMission, + readyWithoutMission, + naturalReady, + naturalContracts, + fallbackReady, + fallbackContracts, + #missions, + unmatchedMissions + )) end function CCO:finishNpcMapRegenerationMissionRefill(state, reason) @@ -7010,7 +6971,10 @@ function CCO:confirmNpcMapRegeneration() return 0, 0 end if tonumber(plan.unverified or 0) > 0 then - print(("CCO: regeneration confirmation blocked: %d planned field action(s) use unverified growth states. Run a new dry-run and ccoGrowthProbe for the affected crops."):format(tonumber(plan.unverified or 0))) + local message = + "CCO: regeneration confirmation blocked: %d planned field action(s) use unverified " .. + "growth states. Run a new dry-run and ccoGrowthProbe for the affected crops." + print((message):format(tonumber(plan.unverified or 0))) return 0, 0 end if plan.period ~= getCurrentPeriodIndex() or plan.year ~= getCalendarYearToken() then @@ -7158,7 +7122,13 @@ function CCO:consoleRegenerateNpcFields(modeArg) end print("CCO: usage ccoRegenerateNpcFields [dryrun|confirm|clear]") end -addConsoleCommand("ccoRegenerateNpcFields", "Experimental: regenerate all NPC fields using weighted enabled crops at calendar-derived growth states. Usage: ccoRegenerateNpcFields [dryrun|confirm|clear]", "consoleRegenerateNpcFields", CCO) +addConsoleCommand( + "ccoRegenerateNpcFields", + "Experimental: regenerate all NPC fields using weighted enabled crops at calendar-derived growth states. " .. + "Usage: ccoRegenerateNpcFields [dryrun|confirm|clear]", + "consoleRegenerateNpcFields", + CCO +) function CCO:consoleCalendarCustomPreview(cropNameArg, plantingArg, harvestArg) if cropNameArg == nil or cropNameArg == "" or plantingArg == nil or harvestArg == nil then @@ -7522,10 +7492,7 @@ function CCO:_applyRegionalProfileLocal(profileId) end self._regionalProfilePreview=nil - local refreshOk,refreshErr=pcall(function() self:refreshAllSowingMachines(false) end) - if not refreshOk then - debug("Regional profile seed-list refresh skipped: "..tostring(refreshErr)) - end + self:refreshAllSowingMachines(false) local profile=CCO_RegionalProfiles:getProfile(normalized) local msg=("Regional profile applied: %s. Calendar updated; NPC regeneration forced for %d/%d planned field(s), skipped=%d. Contracts will rebuild after the settle delay."):format( tostring(profile.title or normalized),queued,planned,skipped) diff --git a/scripts/NativeCalendarSort.lua b/scripts/NativeCalendarSort.lua index 57b1ced..714285e 100644 --- a/scripts/NativeCalendarSort.lua +++ b/scripts/NativeCalendarSort.lua @@ -43,9 +43,9 @@ end local function localize(key, fallback) if g_i18n ~= nil and g_i18n.getText ~= nil then - local ok, value = pcall(function() return g_i18n:getText(key) end) + local value = g_i18n:getText(key) local text = tostring(value or "") - if ok and text ~= "" and text ~= key and text:find("^Missing '") == nil then + if text ~= "" and text ~= key and text:find("^Missing '") == nil then return text end end @@ -63,8 +63,8 @@ end local function normalizedText(value) local text = tostring(value or "") if utf8ToLower ~= nil then - local ok, lowered = pcall(utf8ToLower, text) - if ok and lowered ~= nil then return tostring(lowered) end + local lowered = utf8ToLower(text) + if lowered ~= nil then return tostring(lowered) end end return string.lower(text) end @@ -99,12 +99,8 @@ function Sort:getDisplayName(fruitType) if g_fruitTypeManager ~= nil and g_fruitTypeManager.getFillTypeByFruitTypeIndex ~= nil and fruitType.index ~= nil then - local ok, fillType = pcall( - g_fruitTypeManager.getFillTypeByFruitTypeIndex, - g_fruitTypeManager, - fruitType.index - ) - if ok and fillType ~= nil and fillType.title ~= nil and tostring(fillType.title) ~= "" then + local fillType = g_fruitTypeManager:getFillTypeByFruitTypeIndex(fruitType.index) + if fillType ~= nil and fillType.title ~= nil and tostring(fillType.title) ~= "" then return tostring(fillType.title) end end @@ -120,8 +116,8 @@ function Sort:getSeasonalGrowthData(fruitType) if fruitType == nil then return nil end if fruitType.getSeasonalGrowthData ~= nil then - local ok, data = pcall(fruitType.getSeasonalGrowthData, fruitType) - if ok and type(data) == "table" and type(data.periods) == "table" then return data end + local data = fruitType:getSeasonalGrowthData() + if type(data) == "table" and type(data.periods) == "table" then return data end end if type(fruitType.growthDataSeasonal) == "table" @@ -160,13 +156,11 @@ function Sort:getCurrentSeasonalPeriod() period = env.currentPeriod or env.period or env.currentSeasonPeriod if period == nil and env.getCurrentPeriod ~= nil then - local ok, result = pcall(function() return env:getCurrentPeriod() end) - if ok then period = result end + period = env:getCurrentPeriod() end if period == nil and env.getPeriod ~= nil then - local ok, result = pcall(function() return env:getPeriod() end) - if ok then period = result end + period = env:getPeriod() end if period == nil then @@ -291,7 +285,7 @@ function Sort:getSettingsPath() if last ~= "/" and last ~= "\\" then base = base .. "/" end local directory = base .. SETTINGS_FOLDER - if createFolder ~= nil then pcall(createFolder, directory) end + if createFolder ~= nil then createFolder(directory) end return directory .. SETTINGS_FILE end @@ -366,7 +360,7 @@ function Sort:showDialog(frame, onChanged) local changedCallback = type(args) == "table" and args[2] or nil local selectedFrame = type(args) == "table" and args[1] or frame if type(changedCallback) == "function" then - pcall(changedCallback, selectedFrame, mode.id) + changedCallback(selectedFrame, mode.id) end end end @@ -376,29 +370,25 @@ function Sort:showDialog(frame, onChanged) return false end - local ok, err = pcall(function() - OptionDialog.createFromExistingGui({ - options = options, - optionText = string.format(promptTemplate, self:getModeLabel(self.currentMode)), - optionTitle = title, - callbackFunc = callback, - }, "FS25_CropControlOverrideCalendarSortDialog") + OptionDialog.createFromExistingGui({ + options = options, + optionText = string.format(promptTemplate, self:getModeLabel(self.currentMode)), + optionTitle = title, + callbackFunc = callback, + }, "FS25_CropControlOverrideCalendarSortDialog") - local dialog = OptionDialog.INSTANCE - if dialog == nil then error("OptionDialog.INSTANCE unavailable") end - - if dialog.optionElement ~= nil and dialog.optionElement.setState ~= nil then - dialog.optionElement:setState(currentIndex or 1) - end + local dialog = OptionDialog.INSTANCE + if dialog == nil then + debug("OptionDialog.INSTANCE unavailable") + return false + end - if dialog.setCallback ~= nil then - dialog:setCallback(callback, self, callbackArgs) - end - end) + if dialog.optionElement ~= nil and dialog.optionElement.setState ~= nil then + dialog.optionElement:setState(currentIndex or 1) + end - if not ok then - debug("OptionDialog failed: " .. tostring(err)) - return false + if dialog.setCallback ~= nil then + dialog:setCallback(callback, self, callbackArgs) end return true diff --git a/scripts/RegionalProfileManager.lua b/scripts/RegionalProfileManager.lua index 823bf4d..fd255ac 100644 --- a/scripts/RegionalProfileManager.lua +++ b/scripts/RegionalProfileManager.lua @@ -27,8 +27,8 @@ end local function resolveUserProfileAppPath() if getUserProfileAppPath ~= nil then - local ok, value = pcall(getUserProfileAppPath) - if ok and value ~= nil and tostring(value) ~= "" then + local value = getUserProfileAppPath() + if value ~= nil and tostring(value) ~= "" then return ensureTrailingSlash(value) end end diff --git a/scripts/gui/CropControlOverrideMenu.lua b/scripts/gui/CropControlOverrideMenu.lua index c594a00..d114257 100644 --- a/scripts/gui/CropControlOverrideMenu.lua +++ b/scripts/gui/CropControlOverrideMenu.lua @@ -5,9 +5,9 @@ local CCO_GUI_MOD_DIRECTORY = g_currentModDirectory or "" local function ccoGuiText(key, fallback) if g_i18n ~= nil and g_i18n.getText ~= nil then - local ok, value = pcall(function() return g_i18n:getText(key) end) + local value = g_i18n:getText(key) local valueText = tostring(value or "") - if ok and valueText ~= "" and valueText ~= key and valueText:find("^Missing '") == nil then + if valueText ~= "" and valueText ~= key and valueText:find("^Missing '") == nil then return value end end @@ -80,7 +80,7 @@ local function getUiSettingsPath() if last ~= "/" and last ~= "\\" then base = base .. "/" end local directory = base .. UI_SETTINGS_FOLDER - if createFolder ~= nil then pcall(createFolder, directory) end + if createFolder ~= nil then createFolder(directory) end return directory .. UI_SETTINGS_FILE end @@ -182,7 +182,7 @@ function CropControlOverrideMenu.register(modDirectory) local profiles = baseDir .. "gui/guiProfiles.xml" if fileExists == nil or fileExists(profiles) then - pcall(function() g_gui:loadProfiles(profiles) end) + g_gui:loadProfiles(profiles) end local filename = baseDir .. "gui/CropControlOverrideMenu.xml" @@ -193,14 +193,7 @@ function CropControlOverrideMenu.register(modDirectory) return nil end - local ok, result = pcall(function() - return g_gui:loadGui(filename, "CropControlOverrideMenu", controller) - end) - - if not ok then - print("CCO GUI: failed to load custom screen: " .. tostring(result)) - return nil - end + g_gui:loadGui(filename, "CropControlOverrideMenu", controller) if g_gui.guis == nil or g_gui.guis.CropControlOverrideMenu == nil then print("CCO GUI: loadGui returned but screen was not registered") @@ -227,14 +220,7 @@ function CropControlOverrideMenu.show(title, body, modDirectory, topic, page) controller.currentPage = tonumber(page or controller.currentPage or 1) or 1 controller:setContent(title, body, topic) - local ok, result = pcall(function() - return g_gui:showGui("CropControlOverrideMenu") - end) - - if not ok then - print("CCO GUI: failed to show custom screen: " .. tostring(result)) - return false - end + g_gui:showGui("CropControlOverrideMenu") return true end @@ -416,10 +402,8 @@ local function filterCalendarRows(rows, showDisabled) local rulesResolved = false if CropControlOverride ~= nil and CropControlOverride.getGuiRuleRows ~= nil then - local ok, ruleRows = pcall(function() - return CropControlOverride:getGuiRuleRows("rules") - end) - if ok and type(ruleRows) == "table" then + local ruleRows = CropControlOverride:getGuiRuleRows("rules") + if type(ruleRows) == "table" then rulesResolved = true for _, ruleRow in ipairs(ruleRows) do if ruleRow.enabledBool == false then @@ -449,10 +433,8 @@ local function getCalendarRowsForDisplay(showDisabled) return {} end - local ok, rows = pcall(function() - return CropControlOverride:getGuiCalendarRows() - end) - if not ok or type(rows) ~= "table" then + local rows = CropControlOverride:getGuiCalendarRows() + if type(rows) ~= "table" then return {} end @@ -473,7 +455,7 @@ function CropControlOverrideMenu:setupTabs() -- stack overflow on some FS25 builds when this screen opened from a mod. -- The explicit tab buttons above remain the supported navigation path. if self.subCategoryPaging ~= nil then - pcall(function() self.subCategoryPaging:setVisible(false) end) + self.subCategoryPaging:setVisible(false) end self.suppressTabCallback = false @@ -503,7 +485,7 @@ function CropControlOverrideMenu:updateTabs() local idx = self:getActiveTabIndex() if self.subCategoryPaging ~= nil then - pcall(function() self.subCategoryPaging:setVisible(false) end) + self.subCategoryPaging:setVisible(false) end if self.subCategoryTabs ~= nil then @@ -631,12 +613,7 @@ function CropControlOverrideMenu:setEnabledSelectorState(enabled, disabled) local state = enabled == true and 2 or 1 local currentState = nil if option.getState ~= nil then - local ok, result = pcall(function() - return option:getState() - end) - if ok then - currentState = tonumber(result) - end + currentState = tonumber(option:getState()) end if option.setState ~= nil and currentState ~= state then @@ -661,12 +638,7 @@ function CropControlOverrideMenu:setResetSelectorState(enabled, disabled) local state = enabled == true and 2 or 1 local currentState = nil if option.getState ~= nil then - local ok, result = pcall(function() - return option:getState() - end) - if ok then - currentState = tonumber(result) - end + currentState = tonumber(option:getState()) end if option.setState ~= nil and currentState ~= state then @@ -697,12 +669,7 @@ function CropControlOverrideMenu:setNpcSelectorState(npcValue, disabled) local currentState = nil if option.getState ~= nil then - local ok, result = pcall(function() - return option:getState() - end) - if ok then - currentState = tonumber(result) - end + currentState = tonumber(option:getState()) end if option.setState ~= nil and currentState ~= state then @@ -777,18 +744,22 @@ function CropControlOverrideMenu:registerBackAction() end if g_inputBinding ~= nil and InputAction ~= nil and InputAction.MENU_BACK ~= nil then - local ok, result1, result2 = pcall(function() - return g_inputBinding:registerActionEvent(InputAction.MENU_BACK, self, self.onMenuBackAction, false, true, false, true) - end) + local result1, result2 = g_inputBinding:registerActionEvent( + InputAction.MENU_BACK, + self, + self.onMenuBackAction, + false, + true, + false, + true + ) -- GIANTS builds/mod examples differ here: some return (success, eventId), others effectively return eventId. local eventId = nil - if ok then - if type(result2) == "number" then - eventId = result2 - elseif type(result1) == "number" then - eventId = result1 - end + if type(result2) == "number" then + eventId = result2 + elseif type(result1) == "number" then + eventId = result1 end if eventId ~= nil then @@ -805,9 +776,7 @@ end function CropControlOverrideMenu:removeBackAction() if self.menuBackEventId ~= nil and g_inputBinding ~= nil and g_inputBinding.removeActionEvent ~= nil then - pcall(function() - g_inputBinding:removeActionEvent(self.menuBackEventId) - end) + g_inputBinding:removeActionEvent(self.menuBackEventId) end self.menuBackEventId = nil end @@ -1046,20 +1015,13 @@ function CropControlOverrideMenu:refreshValidationRows() local rows = {} if CropControlOverride ~= nil and CropControlOverride.getBlockedFieldRows ~= nil then - local ok, sourceRows = pcall(function() - return CropControlOverride:getBlockedFieldRows() - end) - if ok and type(sourceRows) == "table" then + local sourceRows = CropControlOverride:getBlockedFieldRows() + if type(sourceRows) == "table" then for _, source in ipairs(sourceRows) do local candidateCrop = source.candidateCrop if (candidateCrop == nil or tostring(candidateCrop) == "") and source.field ~= nil and CropControlOverride.getReseedCandidateTextForField ~= nil then - local candidateOk, candidate = pcall(function() - return CropControlOverride:getReseedCandidateTextForField(source.field, source.cropName) - end) - if candidateOk then - candidateCrop = candidate - end + candidateCrop = CropControlOverride:getReseedCandidateTextForField(source.field, source.cropName) end table.insert(rows, { @@ -1081,15 +1043,9 @@ function CropControlOverrideMenu:updateValidationSummary() local summary = nil if CropControlOverride ~= nil and CropControlOverride.getGuiValidationSummary ~= nil then - local ok, result = pcall(function() - return CropControlOverride:getGuiValidationSummary() - end) - if ok then summary = result end + summary = CropControlOverride:getGuiValidationSummary() elseif CropControlOverride ~= nil and CropControlOverride.buildFieldSummary ~= nil then - local ok, result = pcall(function() - return CropControlOverride:buildFieldSummary(nil) - end) - if ok then summary = result end + summary = CropControlOverride:buildFieldSummary(nil) end summary = summary or {} @@ -1272,7 +1228,7 @@ function CropControlOverrideMenu:updateContent() if self.ruleList ~= nil then self.ruleList:reloadData() if hasRows and self.ruleList.setSelectedIndex ~= nil then - pcall(function() self.ruleList:setSelectedIndex(self.selectedRowIndex or 1, true) end) + self.ruleList:setSelectedIndex(self.selectedRowIndex or 1, true) end end if self.emptyStateText ~= nil then @@ -1300,7 +1256,7 @@ function CropControlOverrideMenu:updateContent() self.calendarList:reloadData() self.calendarList:setVisible(hasCalendarRows) if hasCalendarRows and self.calendarList.setSelectedIndex ~= nil then - pcall(function() self.calendarList:setSelectedIndex(self.selectedCalendarRowIndex or 1, true) end) + self.calendarList:setSelectedIndex(self.selectedCalendarRowIndex or 1, true) end end if self.calendarListSliderBox ~= nil then self.calendarListSliderBox:setVisible(#self.calendarRows > 14) end @@ -1788,7 +1744,11 @@ function CropControlOverrideMenu:onClickApplyDryRun() self.selectedDirtyText:setText(ccoGuiText("cco_waiting_server_apply", "Waiting for server confirmation...")) end if self.selectedInfoText ~= nil and self.selectedInfoText.setText ~= nil then - self.selectedInfoText:setText(ccoGuiText("cco_rule_sent_server_validation", "The rule was sent to the dedicated server for validation. The staged change will be kept until the server replies.")) + self.selectedInfoText:setText(ccoGuiText( + "cco_rule_sent_server_validation", + "The rule was sent to the dedicated server for validation. " .. + "The staged change will be kept until the server replies." + )) end elseif ok then self.stagedDirty = false @@ -1821,7 +1781,7 @@ function CropControlOverrideMenu:onClickApplyDryRun() self:createStagedRuleFromRow(row) self:updateSelectedDetails() if self.ruleList ~= nil and self.ruleList.setSelectedIndex ~= nil then - pcall(function() self.ruleList:setSelectedIndex(i, true) end) + self.ruleList:setSelectedIndex(i, true) end break end @@ -1902,7 +1862,7 @@ function CropControlOverrideMenu:reloadCalendarListPreservingSelection() self.suppressCalendarSelectionCallback = true self.calendarList:reloadData() if self.selectedCalendarRowIndex ~= nil and self.calendarList.setSelectedIndex ~= nil then - pcall(function() self.calendarList:setSelectedIndex(self.selectedCalendarRowIndex, true) end) + self.calendarList:setSelectedIndex(self.selectedCalendarRowIndex, true) end self.suppressCalendarSelectionCallback = false end @@ -2341,14 +2301,8 @@ function CropControlOverrideMenu:onClickReload() return end if CropControlOverride ~= nil and CropControlOverride.loadTemplateDefaultsIntoCurrentSave ~= nil then - local ok, resultOk, msg = pcall(function() - return CropControlOverride:loadTemplateDefaultsIntoCurrentSave() - end) - - if not ok then - CropControlOverride._guiNotice = "Load Defaults failed: " .. tostring(resultOk) - print("CCO GUI: load defaults action failed: " .. tostring(resultOk)) - elseif resultOk ~= true then + local resultOk, msg = CropControlOverride:loadTemplateDefaultsIntoCurrentSave() + if resultOk ~= true then CropControlOverride._guiNotice = tostring(msg or "Load Defaults failed.") end end @@ -2371,15 +2325,7 @@ function CropControlOverrideMenu:onClickSaveDefaults() return end if CropControlOverride ~= nil and CropControlOverride.saveCurrentRulesToTemplateConfig ~= nil then - local ok, resultOk, msg = pcall(function() - return CropControlOverride:saveCurrentRulesToTemplateConfig() - end) - - if not ok then - msg = tostring(resultOk) - resultOk = false - print("CCO GUI: save defaults action failed: " .. tostring(msg)) - end + local resultOk, msg = CropControlOverride:saveCurrentRulesToTemplateConfig() if self.selectedDirtyText ~= nil and self.selectedDirtyText.setText ~= nil then self.selectedDirtyText:setText(resultOk and "Saved defaults." or "Save defaults failed.") @@ -2400,17 +2346,13 @@ function CropControlOverrideMenu:refreshResetScopes() } if CropControlOverride ~= nil and CropControlOverride.getBlockedResetScopeList ~= nil then - local ok, result = pcall(function() - return CropControlOverride:getBlockedResetScopeList() - end) - if ok and type(result) == "table" and #result > 0 then + local result = CropControlOverride:getBlockedResetScopeList() + if type(result) == "table" and #result > 0 then scopes = result end elseif CropControlOverride ~= nil and CropControlOverride.getBlockedCropList ~= nil then - local ok, crops = pcall(function() - return CropControlOverride:getBlockedCropList() - end) - if ok and type(crops) == "table" then + local crops = CropControlOverride:getBlockedCropList() + if type(crops) == "table" then for _, crop in ipairs(crops) do table.insert(scopes, { mode = "crop", crop = tostring(crop), label = "CROP: " .. tostring(crop) }) end @@ -2538,14 +2480,8 @@ function CropControlOverrideMenu:onClickResetBlockedDryRun() local scopeCrop, scopeText = self:getCurrentResetScope() self.pendingResetScopeText = tostring(scopeText or "ALL") - local callOk, success, result, wouldQueue, skipped, serverPending = pcall(function() - return CropControlOverride:resetBlockedFieldsDryRunFromGui(scopeCrop, self.resetMode) - end) - - if not callOk then - self:onServerResetDryRunResult(false, "Dry-run failed: " .. tostring(success), 0, 0) - return - end + local success, result, wouldQueue, skipped, serverPending = + CropControlOverride:resetBlockedFieldsDryRunFromGui(scopeCrop, self.resetMode) if success == true and serverPending == true then self.serverResetDryRunPending = true @@ -2575,7 +2511,11 @@ function CropControlOverrideMenu:onServerResetDryRunResult(success, result, woul local confirmHint = "" if self.resetConfirmArmed then - confirmHint = "\n\nCONFIRM RESET is now available for scope=" .. tostring(self.pendingResetScopeText or "ALL") .. " resetMode=" .. self:getResetModeLabel() .. ". It will apply the selected reset mode." + confirmHint = + "\n\nCONFIRM RESET is now available for scope=" .. + tostring(self.pendingResetScopeText or "ALL") .. + " resetMode=" .. self:getResetModeLabel() .. + ". It will apply the selected reset mode." elseif not ccoGuiCanEditRules() then confirmHint = "\n\nRemote multiplayer clients are read-only. Reset actions can only be run by the server/host." end @@ -2609,14 +2549,8 @@ function CropControlOverrideMenu:onClickConfirmBlockedReset() local scopeCrop = self:getCurrentResetScope() - local callOk, success, result, queued, skipped, serverPending = pcall(function() - return CropControlOverride:resetBlockedFieldsFromGui(scopeCrop, self.resetMode) - end) - - if not callOk then - self:onServerResetResult(false, "Reset failed: " .. tostring(success), 0, 0) - return - end + local success, result, queued, skipped, serverPending = + CropControlOverride:resetBlockedFieldsFromGui(scopeCrop, self.resetMode) if success == true and serverPending == true then self.serverResetPending = true @@ -2666,11 +2600,9 @@ function CropControlOverrideMenu:onClickRegenerateNpcPreview() return end - local ok, result, planned, confirmAllowed = pcall(function() - return CropControlOverride:regenerateNpcFieldsDryRunFromGui() - end) - local msg = ok and tostring(result or "Preview complete.") or ("Preview failed: " .. tostring(result)) - self.regenerationConfirmArmed = ok and confirmAllowed == true and (tonumber(planned or 0) or 0) > 0 + local result, planned, confirmAllowed = CropControlOverride:regenerateNpcFieldsDryRunFromGui() + local msg = tostring(result or "Preview complete.") + self.regenerationConfirmArmed = confirmAllowed == true and (tonumber(planned or 0) or 0) > 0 local body = "" if CropControlOverride.buildGuiBlockedText ~= nil then @@ -2698,10 +2630,8 @@ function CropControlOverrideMenu:onClickConfirmNpcRegeneration() return end - local ok, result = pcall(function() - return CropControlOverride:regenerateNpcFieldsFromGui() - end) - local msg = ok and tostring(result or "Regeneration queued.") or ("Regeneration failed: " .. tostring(result)) + local result = CropControlOverride:regenerateNpcFieldsFromGui() + local msg = tostring(result or "Regeneration queued.") self.regenerationConfirmArmed = false local body = ""