Skip to content

App Core

Valkerran edited this page Oct 2, 2026 · 4 revisions

App Core

PCEdit.App.Core is everything that is the app but not a UI framework: ViewModels, domain services, the item catalogs, localization, and the interfaces a UI head must implement.

It has no reference to Avalonia. That boundary is what makes the app layer testable headlessly — PCEdit.App.Core.Tests drives real ViewModels with fake platform services.

The workspace — the single point of mutation

Services/ISaveFileWorkspace (SaveFileWorkspace) is a DI singleton holding the loaded PlanetCrafterSaveFile, its path, IsDirty and SaveStatus.

PlanetCrafterSaveFile? Current { get; }
string? FilePath { get; }
bool IsLoaded { get; }
bool IsDirty { get; }
string? SaveStatus { get; }

void Load(string path);
void Save();

void MutateUnlocks(Func<SaveFileUnlocks, SaveFileUnlocks> mutate);
void ReplaceTerraformation(string planetId, Func<PlanetTerraformation, PlanetTerraformation> mutate);
void ReplacePlayer(long playerId, Func<PlayerData, PlayerData> mutate);
void ReplaceInventory(int inventoryId, Func<Inventory, Inventory> mutate);
int  GrantTerraTokens(long playerId, int amount);

ViewModels never build a modified save themselves. They call one of these, which applies the root-rebuild-vs-list-replace pattern from Save File Library and flips IsDirty. Centralising it is the reason an edit cannot accidentally drop a field it did not know about.

GrantTerraTokens is the one composite operation: it raises the save's balance, the all-time total, and the chosen player's earned count, because a balance above the all-time total is a state the game never produces. All three are 32-bit and saturate at the maximum rather than wrapping to a negative balance; the return value is what the balance could actually take, so a clamped grant can be reported honestly.

Save also copies the file aside first, through ISaveBackupService — once per file opened rather than once per save, since by the second save the file on disk is already PCEdit's own output and the pristine copy is the only one that cannot be reconstructed. A backup that fails is logged and the save proceeds: refusing the user's edit because a safety copy failed inverts the priority.

Domain services

Service What it owns
IInventoryEditor / InventoryEditor Inventory grouping, item moves, logistics config
ContainerOrigins Who placed a container — Map (ids 100,000,000–199,999,999, the developers' objects), Wreck (wreck-only types, or listed in a procedural instance's woIdsGenerated), Built (a runtime id of a type never found in wrecks), else Unknown
ItemStackKey What makes two items "identical" for a stack row: everything but their id and where they lay (pos / rot / planet)
IOverflowRepair / OverflowRepair The Repair: plan and apply trimming over-full inventories to N × size (never past 7,200), after moving ticked types into free storage
FreeStorage Free slots for the Repair: Built Container1/2/3 only, not logistics, same planet, a crate already holding the type first
SaveCopyName The suggested file name for a repaired copy — the next free <Mode>-<N>.json slot, else a -repaired suffix
IPlanetIndex / PlanetIndex Which worlds exist, and which world a WorldObject.Planet hash is
IItemCatalog / ItemCatalog GId → display name, category icon, logistics flags, game-version metadata
ILogisticsGroupCatalog / LogisticsGroupCatalog The logistics pick-lists — a projection of the item catalog's canDemand / canSupply flags, minus deprecated items (no data file of its own since v1.4.0)
IdSearch The "search by id" rule shared by the Inventories page and the Move dialog: a digits-only term (optional #) matches ids by prefix and does not fall back to text
WorldObjectIdsCodec The inventory woIds comma-string — skips entries it cannot read, and carries them through a rewrite untouched
GroupListCodec The demandGrps / supplyGrps comma-strings
PositionCodec The "x,y,z" string shared by player and object positions — use TryParse for anything read out of a save

InventoryEditor

  • BuildInventoryGroups() is O(n). It pre-indexes world objects and container → inventory links once; a real multi-planet save has ~1,000 inventories and ~12,000 world objects, and the naive nested scan is visibly slow. Each InventoryGroup is tagged with an InventoryKind (for the page's type filter), a PlanetId resolved via IPlanetIndex (for its world filter) and a ContainerOrigin. A machine's second (output) inventory is linked through siIds, not liId; both are indexed, liId winning.
  • TryMoveItems (with TryMoveItem as its one-item case) moves items all or none: it changes nothing unless they are all in one source and the destination has room for every one against its stored Inventory.Size, so PCEdit never makes an inventory over-full. Moving out of an over-full one is always allowed.
  • GetDestinationOptions builds the Move dialog's list. Each InventoryOptionView carries its Kind and owning container's object id, and has a Matches() using IdSearch, so the dialog and the page search the same way.
  • GetLogisticsContainer / UpdateLogistics read and write demand groups, supply groups and priority. Only logistics containers carry a priority key, which is how one is recognised.

InventoryGroup and search

InventoryGroup (one Inventories card) is a record since v1.5.0, so a filtered copy is a with expression that cannot drop a property. NarrowTo(query) returns the card to show for a search:

  • the card itself when the inventory matches (label, world, inventory id, container object id);
  • a copy listing only the matching items when only its contents match — TotalItemCount keeps the true count, so CapacityLabel still reads the real fill and IsNarrowed drives the "Showing N of M items" caption;
  • null when nothing matches.

Since v1.7.0 a card's rows are Stacks (items grouped by ItemStackKey) rather than items. Whether a card groups follows the Identical items setting (StackingMode, stored through IInventoryDisplayStore); a card over MaxItemsListedOneByOne (500) groups regardless. Fill (InventoryFill) is over-full (more items than slots) or near the load limit (over 7,200), and drives the capacity badge, the Needs attention filter and the Overview banner.

InventoriesViewModel builds the full card list once per Load() and projects it through NarrowTo on every filter change; the prebuilt list is never modified.

PlanetIndex

KnownPlanetIds() is the ordered union of every PlanetId in the save (metadata + terraformations + players). ResolvePlanetId(int?) maps a WorldObject.Planet hash back to one of them through PlanetHash.Of. It backs the Inventories World filter, the Teleport landmark filter, and the Teleport planet dropdown. See Worlds & Planets.

Page ViewModels

ViewModel Page
OpenFileViewModel Open File
OverviewViewModel (+ PlanetTerraformViewModel, PlayerOverviewRow) Overview
InventoriesViewModel Inventories
SelectInventoryViewModel "Choose a destination" modal
LogisticsEditorViewModel Logistics editor modal
TerraTokensViewModel Terra Tokens
TeleportViewModel Teleport
AboutViewModel About

They use CommunityToolkit.Mvvm (ObservableObject, [ObservableProperty], [RelayCommand]).

The Load() contract

Page ViewModels are singletons and implement ViewModels/ILoadable. They re-read workspace state in Load() rather than caching it, so switching pages always reflects the latest in-memory edits.

The shell calls Load() only when a workspace revision counter has changed since that page last loaded — so navigating back and forth does not rebuild hundreds of inventory cards for nothing.

Every ILoadable page also takes INavigationService and exposes an OpenFileCommand for its "no file loaded" empty state.

Presentation helpers

Presentation/VitalStatus classifies a gauge value (healthy / warning / critical) and Presentation/StatusPalette maps a status to a theme resource key. Each UI head wraps them in a thin IValueConverter, so the thresholds and the colour mapping live in one place and are unit tested. Status text held in a ViewModel is stored as a localization key plus arguments and re-formatted when the language changes.

Platform-abstraction interfaces

The head implements these; Core only ever sees the interface.

Interface Purpose
IFilePickerService Choose a save file
INavigationService Switch pages, open / close a modal
IDialogService Message and confirmation dialogs
IScreenReaderAnnouncer Announce status changes to assistive technology
IAppVersionInfo The running version, for About
ILanguageStore, IDisclaimerGate Persist the chosen language and the disclaimer acknowledgement
ISaveBackupService Keep a copy of a save from before PCEdit first wrote to it

INavigationService is deliberately narrow — go to Overview, go to Open File, open the select-inventory modal, open the logistics editor, close the modal. No URL routing, no view types.

See Desktop UI for the Avalonia implementations, and Localization for ILocalizer.

Clone this wiki locally