Skip to content

Alpha v0.57.4 - Hotfix

Choose a tag to compare

@binary-knight binary-knight released this 18 Apr 18:10

v0.57.4 - Editor Hotfix + Companion Bag Persistence

Bugs reported within hours of the v0.57.3 editor shipping, several of them fatal to the editor itself. This hotfix addresses four editor bugs (JSON options mismatch, long-menu scrolling, stat-edit persistence, and WeapPow/ArmPow no-op edits) plus the v0.57.3 companion-bag persistence fix that shipped slightly too late in the cycle to get its own release.

Editor save changes didn't persist

The editor wrote JSON with default JsonSerializerOptions (WriteIndented = true, nothing else), producing PascalCase property names like "Gold": 5000. But the game's FileSaveBackend both reads AND writes with PropertyNamingPolicy = JsonNamingPolicy.CamelCase + IncludeFields = true — so when the game next loaded the edited save, System.Text.Json looked up "gold", didn't find it (no case-insensitive fallback on the read side), defaulted the field to 0, and the next autosave then overwrote the editor's file with the zeroed state. Net effect: every single edit looked saved but was silently discarded on first load.

Fix: the editor's serializer options now EXACTLY mirror FileSaveBackend's. Read and write share a single static GameSaveJsonOptions constant in PlayerSaveEditor. Edits now round-trip through the game's save pipeline unchanged.

Adjacent fix caught while auditing the file-path logic: the editor used char.IsLetterOrDigit to sanitize player names into file names, but FileSaveBackend.GetSaveFileName uses Path.GetInvalidFileNameChars. Names containing hyphens or apostrophes (John-Smith, Fastfinge'1) sanitized to different filenames between the two — the editor's File.Exists check failed and reported "save file not found" even though the save was right there. The editor now trusts the on-disk filename already returned by FileSaveBackend.GetAllSaves() instead of reconstructing it.

Long menus scrolled items off the top

RunArrowMenu rendered every label on every redraw using a relative "cursor up N lines" ANSI sequence. On a 30-row terminal with 60 entries, the first render pushed the top 30 rows into scrollback, then the next redraw (triggered on any keystroke) tried to move up 63+ lines — which the terminal clamped to the visible top, so subsequent redraws piled on top of each other and the early entries stayed stuck in scrollback forever. Console.ReadKey also swallowed any mouse-scroll input, so there was no way to scroll the terminal back manually.

Fix: menus are now viewport-scrolled. The menu picks a viewportSize based on Console.WindowHeight (safe default 24 rows, minimum 5 rows) and renders only that many labels at a time. Arrow keys still move the selection — when the selection leaves the visible window, the window follows. PgUp/PgDn paginate by the viewport size. Home/End jump to first/last. Number shortcuts still work and scroll the viewport to their target. Overflow indicators (... N more above / ... N more below) and a showing X-Y/Z counter in the title bar make the window position visible.

Stats reverted on load

Caught while testing the JSON options fix: PlayerSaveEditor.EditStats was writing to PlayerData.Strength, PlayerData.MaxHP, etc. The save file correctly contained those edits. But on load, the game calls Character.RecalculateStats() which resets every derived stat from its Base* counterpart and then layers equipment bonuses on top:

Strength = BaseStrength;
Dexterity = BaseDexterity;
MaxHP = BaseMaxHP;
// ... then add equipment bonuses ...

So editing Strength without touching BaseStrength was a no-op: the edit survived the file round-trip but got wiped the instant the game applied RecalculateStats. Only Level and Gold (fields with no derived-vs-base split) appeared to persist.

Fix: EditStats now prompts for and writes the Base* fields for every core stat (STR, DEX, CON, INT, WIS, CHA, DEF, STA, AGI, MaxHP, MaxMana). The derived field is also mirrored to the edited value so the editor's summary views remain accurate pre-save. Current HP and Mana are still editable but the label now says they're clamped to final MaxHP/MaxMana at load time.

WeapPow and ArmPow are removed from editing — they're derived entirely from equipped items at runtime (RecalculateStats zeroes them then sums equipment contributions), so any direct edit was always a no-op. The edit menu now points users at the Inventory & Equipment menu instead.

Companion bag persistence

The v0.57.2 Party Inventory Viewer let players SEE items companions were carrying, but the underlying Companion class had no inventory field at all — the Character wrapper returned from GetCompanionsAsCharacters() started every call with a fresh empty list, and the save format never stored any companion-side inventory. Items transferred to Mira via the combat loot </> cycler + [T] prompt, or stashed via Home / Team Corner / dungeon party-bag viewer, worked on screen until the dungeon reload or logout discarded the wrapper.

Fix is structural. Companion now holds its own List<Item> Inventory. GetCompanionsAsCharacters() assigns that list by reference to the wrapper, so every take/give on the wrapper mutates the companion's actual bag. CompanionSaveData / CompanionSaveInfo gain a matching List<InventoryItemData> Inventory field, with the same Item ↔ InventoryItemData conversion the player inventory already uses (including LootEffects). Combat's [T] transfer to a companion now flushes AutoSave + (online) SaveAllSharedState immediately, mirroring the Party Inventory Viewer's take-back path, so a mid-dungeon disconnect can't lose the item between transfer and combat-end save. Existing saves load cleanly — missing Inventory field defaults to empty.

  • Achievement editing removed. Granting achievements via the editor was a Steam-leaderboard cheat vector — and even more concerning, the achievement-definitions editor (AchievementEditor) let users edit the built-in first_steps achievement to pay out 100000 gold, which the game would then unlock normally via TryUnlock at runtime, firing the matching Steam achievement. Both paths are now hard-disabled on every platform with a clear message. Modders who want custom achievements can still hand-edit GameData/achievements.json — the editor just doesn't facilitate it.

  • NPC editor warns about MUD-mode override. In online / MUD mode, NPC state lives in the server's world_state SQLite and is restored via OnlineStateManager.LoadSharedNPCs at login, which OVERRIDES npcs.json. NPC edits are only visible in single-player / Steam builds. Warning shown at editor entry.

  • Equipment editor validates IDs on save. Built-in equipment IDs are reserved (< 200000) and silently dropped by the game if they appear in equipment.json. Save now refuses to write if any item is in the reserved range or has a duplicate ID, with a list of offenders — prevents silent-ignore surprises for users who hand-edited the file between editor sessions.

  • Quest edits warned as cosmetic. QuestSystem.RestoreFromSaveData accepts whatever Status the save says without re-validating objectives, so marking a quest Completed via the editor doesn't re-fire rewards or objective hooks. Editor now explicitly says so; users wanting rewards should edit Gold directly.

  • Old God state edits warned about prereqs. The game loads Old God states as-is with no prerequisite enforcement — setting Manwe Defeated while Maelketh is Dormant is a technically-valid save but can soft-lock dungeon events and endings because progression assumes floor order (Maelketh 25 → Veloura 40 → Thorgrim 55 → Noctura 70 → Aurelion 85 → Terravok 95 → Manwe 100). Warning shown at editor entry.

  • Relationship edits warned as dialogue-only. The per-NPC Relationships score dictionary is independent from the marriage / lover / ex tracking in RomanceTracker, which lives in a separate save field and is restored from a different code path at GameEngine.cs:4350. Setting a spouse's relationship score to -500 doesn't divorce them — it just makes their dialogue tone hostile. Editor now says so; for actual romance / marriage changes, use in-game church / temple actions.

  • Verified as fine (no changes needed): companion stat edits persist correctly (CompanionSystem.Deserialize's hasScaledSecondaryStats heuristic preserves user edits), quest edits won't crash (just produce stale state), RomanceData IS restored on load (the audit agent's grep missed the restore site — false alarm).

  • Inventory add / equip / unequip / remove: raw-ID prompts replaced with pickers. AddInventoryItem and EquipItemInSlot used to ask for an integer equipment ID ("Equipment ID to copy into inventory (built-ins start at 1000, custom at 200000)") and offered no way to discover valid IDs. EquipItemInSlot would even let the user write a non-existent ID into the save; on load, Character.RecalculateStats silently skips unknown IDs, so the slot looked "equipped" but contributed nothing. All four operations now use arrow-key pickers with a slot filter (where relevant) and a name substring filter. IDs and name discovery are no longer user responsibility.

  • Spell-grant for non-caster classes warns. GrantAllSpells wrote the full spell matrix to the save regardless of class. For Warrior / Barbarian / Ranger / Assassin / Jester / Paladin / Bard / Alchemist (all non-mana classes; Paladin / Bard / Alchemist moved to stamina in v0.49.5), the edit was a silent no-op — RecalculateStats zeroes MaxMana so nothing is castable. Editor now warns and requires explicit confirmation.

  • Language: free-text replaced with picker over installed languages. A typo'd language code used to silently fall back to English at runtime. EditSettings now shows an arrow-key picker over Loc.AvailableLanguages so users only see languages actually shipped with the build.

  • Single-player-only notes added to EditWorldState (king / treasury / town pot), EditCharacterInfo (King / Immortal flags), and EditTeamAndGuild (Team / IsTeamLeader). In online / MUD mode these fields live in the server's SQLite (world_state.royal_court, teams table, guild tables) and are authoritative — local save edits never sync to the server. Editor is always local, but users who dual-play locally AND online could otherwise wonder why their local edits don't show up on the server.

  • Companion state-machine desync. CompanionSystem.GetActiveCompanions() iterates the top-level activeCompanions list, NOT the per-companion IsActive bool. The editor's Recruit action only flipped the bool — so companions looked recruited in the list but never joined the active party on load. Also, reviving a companion marked IsDead = false but left them in FallenCompanions, and CompanionSystem.Deserialize rebuilds the fallenCompanions dict from that list — so revived companions could stay "permanently dead" in gameplay UI. Fix: recruit/dismiss/revive all sync ActiveCompanionIds AND FallenCompanions together. Recruit refuses to mark a 5th companion active when the 4-slot cap is already full.

  • Dynamic equipment names showed <unknown>. ResolveItemName queried EquipmentDatabase by ID, but the editor never registered dungeon-loot equipment (IDs in the 10000+ range stored in SaveGameData.DynamicEquipment) into that database at load time. So any equipped loot slot rendered as <unknown> even though the save had the full stats. Fix: editor now mirrors GameEngine.LoadSaveByFileName's dynamic-equipment registration step right after deserializing the save.

  • Skill-proficiency picker wrote dead keys. EditSkillsAndTraining offered a vocabulary of "sword", "axe", "mace", "dagger", "spear", "bow", "staff", ... but the game stores proficiencies under IDs like basic_attack, backstab, cleric_spell_3. Editing a "sword" proficiency wrote a dict entry nothing in the game ever reads. Fix: picker now enumerates real skill IDs — basic_attack + every registered ClassAbilitySystem ability + class-appropriate spell slots for casters. Value range corrected from 0-10 to 0-8 (the actual ProficiencyLevel enum range: Untrained → Legendary).

  • Inventory/equip pickers. Follow-up from pass 2 — AddInventoryItem / RemoveInventoryItem / EquipItemInSlot / UnequipSlot all converted from raw-integer-ID prompts to arrow-key pickers with slot and name filters. Raw-ID entry was a UX dead end — nobody knows internal IDs — and EquipItemInSlot would silently let you write a non-existent ID that produced a ghost "equipped" slot contributing nothing on load.

  • Gold fields min-bounded. Gold, BankGold, BankLoan, BankInterest, BankWage, RoyalLoanAmount all accepted negative values (they're stored as long). The game's affordability / loan / interest checks don't handle negatives gracefully. All clamped to min: 0.

  • Spell grant warns on non-caster class. GrantAllSpells wrote the spell matrix regardless of class, but RecalculateStats zeroes MaxMana for non-caster classes (Paladin / Bard / Alchemist moved to stamina in v0.49.5). The edit was a silent no-op for ~8 classes. Editor now warns and requires explicit confirmation.

  • Language picker. EditSettings.Language was free-text; typos silently fell back to English at runtime. Now a picker over Loc.AvailableLanguages.

  • NG+ cycle edit warns + offers XP multiplier co-edit. Setting CurrentCycle alone bumps monster scaling (via GameConfig.GetNGPlus*Multiplier) but leaves CycleExpMultiplier at 1.0x — harder monsters, no XP bonus. Editor now explains the asymmetry and offers to set CycleExpMultiplier to the cycle-appropriate value (1.0x, 1.25x, 1.5x, ...) in a follow-up confirmation.

  • Kids counter desync note. p.Kids is just a counter; the actual children (names, ages, classes) live in StorySystemsData.Children. Editing the counter without touching the list produces a "ghost 5 kids" state — say you have 5 children but Home Location has zero to interact with. Editor now says so.

  • Monster ability free-text → picker. MonsterEditor.EditFamilyTiers "Add ability to tier" prompted "Ability ID (e.g. poison_bite, power_strike)" — free text. Typos silently fell through Enum.TryParse<MonsterAbilities.AbilityType> in CombatEngine.TryMonsterAbility and produced no runtime effect (the game skipped the ability). Now a picker over the real MonsterAbilities.AbilityType enum (minus None), so invalid values are impossible.

  • Equipment custom-item field bounds. EquipmentEditor.EditItemFields let users write negative WeaponPower (weapon that subtracts damage), ArmorClass (armor that hurts defense), ShieldBonus, Value. Added min: 0 on those; BlockChance now clamped 0-100 (percentage). Stat-bonus fields (STR+ / DEX+ / etc.) intentionally left unbounded — cursed items legitimately carry negative stat bonuses, and modders may want e.g. "iron cuirass: -2 STR, +8 Defence".

  • Chivalry / Darkness capped at editor time. Game's AlignmentSystem.ChangeAlignment clamps both to 0-1000 on every modification, but a raw file edit let values above 1000 into the save. They loaded fine but clamped back to 1000 the next time the value changed in-game — so the editor value didn't match what the user saw in play. Editor now enforces the same 0-1000 cap at entry.

  • SaveFileManager + PlayerSaveEditor: SanitizeFileName mirrors backend now. Both files had a char.IsLetterOrDigit sanitizer that didn't match FileSaveBackend.GetSaveFileName's Path.GetInvalidFileNameChars-based sanitizer. Names with hyphens / apostrophes / periods produced mismatched paths in Clone / Delete / Load flows — File.Exists checks failed even though the save existed. Both sanitizers now use the backend's algorithm exactly, AND the primary lookup path uses SaveInfo.FileName (the on-disk filename from GetAllSaves) so the sanitizer is only a last-resort fallback.

  • Verified fine: NPCEditor.EditFields (all picker-backed, no stat field issues), editor-mode CLI gating (--editor short-circuits before any server code runs), concurrency warning already in place at editor entry.

NPC / Companion inventory — fifth pass

Deep audit of the NPC and Companion inventory pipeline turned up two more structural bugs, same class as the report that kicked off v0.57.3:

  • NPC teammate inventories never serialized. Companion.Inventory was fixed in v0.57.3, but regular-NPC teammates (spouses, recruited citizens, party members added via Team Corner) go through a completely different save path — NPCData in the player save and in the MUD world_state SQLite. That class had Gold, Items (legacy Pascal equipped-item-IDs), MarketInventory (shop stock NPCs sell), and EquippedItems — but NO Inventory field. So items transferred to an NPC teammate via combat [T] / Home / Team Corner / dungeon Party Inventory viewer populated NPC.Inventory at runtime, but the very next save silently dropped them. On reload the NPC's bag was empty. Same shape as the companion bug, same fix: added List<InventoryItemData> Inventory to NPCData, wired it through SaveSystem + OnlineStateManager (write) and GameEngine.RestoreNPCs + WorldSimService.RestoreNPCsFromData (read). Also extended CombatEngine.SyncNPCTeammateToActiveNPCs to copy Inventory when the world sim reloads ActiveNPCs between dungeon-side and canonical NPC objects (MUD mode only concern, but safe in single-player too).
  • Companion permadeath lost everything in their bag. CompanionSystem.KillCompanion and TriggerCompanionDeathByParadox carefully returned EquippedItems to the player but completely ignored companion.Inventory. If a companion died with potions, herbs, or loot in their bag, those items evaporated — the companion stayed in FallenCompanions with their inventory still intact but no code path ever reached it. Fix: new shared ReturnCompanionGearOnDeath helper consolidates both paths. Returns equipped gear AND bag contents to the player, respecting the player's 50-item cap (overflow is explicitly reported as "dropped on the battlefield" rather than silently disappearing). Clears both collections on the companion after transfer.

Verified fine in this pass (no changes)

  • Companion capacity — Character.IsInventoryFull uses GameConfig.MaxInventoryItems = 50, and the companion wrapper IS a Character, so companions inherit the same 50-item cap. Combat [T] transfer checks player.IsInventoryFull AFTER player has been reassigned to the selected companion wrapper, so the cap check is correct.
  • NG+ — v0.57.3's ResetAllCompanions already clears Inventory; confirmed no regressions.
  • Shared-reference integrity — only three sites reassign *.Inventory: player-load (fresh player), wrapper creation (intentional shared-ref setup), companion Deserialize (replaces list from save data, wrappers are short-lived and never stashed across save/load boundaries).
  • Combat loot auto-pickup displacement path — when a companion auto-equips a dropped item and their displaced gear moves out, existing code correctly pushes it to the player's inventory (or drops it past the cap). No change needed.

Files Changed

  • Scripts/Core/GameConfig.cs — Version 0.57.4.
  • Scripts/Editor/PlayerSaveEditor.cs — New GameSaveJsonOptions static (PropertyNamingPolicy.CamelCase + IncludeFields = true + WriteIndented = true) used by both read and write paths. Editor now trusts SaveInfo.FileName from FileSaveBackend.GetAllSaves() instead of re-sanitizing the player name. EditStats rewritten to write BaseStrength / BaseDexterity / BaseConstitution / BaseIntelligence / BaseWisdom / BaseCharisma / BaseDefence / BaseAgility / BaseStamina / BaseMaxHP / BaseMaxMana (the fields that survive Character.RecalculateStats on load); derived-stat fields are mirrored to the Base value so pre-save previews stay consistent. WeapPow / ArmPow removed from editing with a warn note pointing at the Inventory & Equipment menu (they're zeroed and re-derived from equipment on load).
  • Scripts/Editor/EditorIO.csRunArrowMenu viewport-scrolls now. Uses Console.WindowHeight (fallback 24) to compute viewportSize = max(5, min(labels.Count, height - 7)), recenters the view on the selected row each redraw, emits ... N more above/below overflow markers, and adds a showing X-Y/Z counter to the hint line. PgUp / PgDn paginate by viewport size; Home / End jump to extremes.
  • Scripts/Systems/CompanionSystem.cs — New List<Item> Inventory on Companion. GetCompanionsAsCharacters() assigns the companion's list to the wrapper by reference so wrapper edits persist to the companion. ResetAllCompanions() now clears Inventory. New private ItemToData + DataToItem helpers mirror the player's Item ↔ InventoryItemData round-trip (including LootEffects). Serialize() and Deserialize() write/read Inventory. New Inventory field on CompanionSaveData.
  • Scripts/Systems/SaveDataStructures.cs — New List<InventoryItemData> Inventory field on CompanionSaveInfo (the on-disk shape — missing on legacy saves defaults to empty).
  • Scripts/Systems/SaveSystem.csCompanionSaveData ↔ CompanionSaveInfo conversions in both directions (Save and Load) now pass the Inventory field through.
  • Scripts/Systems/CombatEngine.cs — Combat loot [T] transfer to a companion now calls SyncNPCTeammateToActiveNPCs + AutoSave + (online) SaveAllSharedState immediately, so a mid-dungeon disconnect doesn't lose the item between transfer and combat-end save. Mirrors the Party Inventory Viewer's take-back persistence pattern.
  • Scripts/Editor/EditorMain.cs — Achievement definitions editor disabled (Steam-cheat vector via TryUnlock-fired rewards). Placeholder message points modders at direct JSON edits.
  • Scripts/Editor/PlayerSaveEditor.cs — Player-save achievement-granting menu disabled (same rationale). Warnings added at the top of EditQuests (state edits not re-validated, treat as cosmetic), EditStoryAndGods (no prerequisite enforcement — floor order listed so users understand soft-lock risk), and EditRelationshipsAndFamily (Relationship dict is independent of marriage / lover / ex tracking in RomanceTracker). EditAchievements method body removed (~70 lines).
  • Scripts/Editor/NPCEditor.cs — Entry-time warning that NPC edits only apply to single-player / Steam builds; online / MUD mode restores from the server's world_state SQLite at login, overriding npcs.json.
  • Scripts/Editor/EquipmentEditor.csSaveCustomItems now validates every item has ID ≥ GameDataLoader.ModdedEquipmentIdStart (200000) and that all IDs are unique. Violations are listed and the save is refused. Stops silent-ignore on hand-edited JSON.
  • Scripts/Editor/PlayerSaveEditor.cs — UX pass: AddInventoryItem, RemoveInventoryItem, EquipItemInSlot, UnequipSlot replaced raw-integer-ID prompts with arrow-key pickers (over EquipmentDatabase.GetAll() for add/equip, over the actual inventory / equipped slots for remove/unequip). Equip picker filters by chosen slot + optional name substring so hundreds-of-items catalogs stay navigable. GrantAllSpells warns when called against a non-caster class and requires explicit confirmation (non-mana classes can never cast, so the edit was a silent no-op). EditSettings language field swapped from free-text to a picker over Loc.AvailableLanguages. EditWorldState, EditCharacterInfo, EditTeamAndGuild now explicitly note that their fields are authoritative in single-player saves only — in online / MUD mode the shared world, kingship, team and guild state live in the server's SQLite and are overridden at login.