diff --git a/apps/save-editor/CHANGELOG.md b/apps/save-editor/CHANGELOG.md index db547070b..ab2b778fe 100644 --- a/apps/save-editor/CHANGELOG.md +++ b/apps/save-editor/CHANGELOG.md @@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- A Trade tab shows what a merchant offers for sale and how much ore he has to + buy with. Stock counts and his ore can be changed, lines can be added and + removed, and the restock baseline is editable next to the live stock. - An NPC can be moved, with the same location picker the hero has. - An NPC's daily routine can be switched off, so he stays where he was put instead of walking back within seconds. It can be switched back on again, as diff --git a/apps/save-editor/lib/features/editor/domain/character_index.dart b/apps/save-editor/lib/features/editor/domain/character_index.dart index 6207df775..baa198b03 100644 --- a/apps/save-editor/lib/features/editor/domain/character_index.dart +++ b/apps/save-editor/lib/features/editor/domain/character_index.dart @@ -9,6 +9,7 @@ class CharacterRow { required this.hasInventory, required this.hasKnowledge, required this.hasEvents, + this.isTrader = false, }); factory CharacterRow.fromJson(Map json) { @@ -19,6 +20,7 @@ class CharacterRow { hasInventory: json['hasInventory'] == true, hasKnowledge: json['hasKnowledge'] == true, hasEvents: json['hasEvents'] == true, + isTrader: json['isTrader'] == true, ); } @@ -29,6 +31,9 @@ class CharacterRow { final bool hasKnowledge; final bool hasEvents; + /// The character runs a shop — he owns a row in the global trader array. + final bool isTrader; + bool get isOrphan => globalId == null; } diff --git a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart index f9be4a1e3..0c55ee8f0 100644 --- a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart +++ b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart @@ -17,6 +17,7 @@ import 'package:goresave/features/editor/domain/pending_edits.dart'; import 'package:goresave/features/editor/domain/progression_models.dart'; import 'package:goresave/features/editor/domain/skills_models.dart'; import 'package:goresave/features/editor/domain/story_state_models.dart'; +import 'package:goresave/features/editor/domain/trader_models.dart'; import 'package:goresave/l10n/app_localizations.dart'; import 'package:goresave/l10n/app_localizations_en.dart'; import 'package:goresave/utils/default_paths.dart'; @@ -1279,6 +1280,11 @@ class EditorNotifier extends StateNotifier { 'private.glossary.setSegment', 'private.npc.revive', 'private.npc.setRelationship', + // Both splice a trader's stock map, which shifts every later byte offset + // and renumbers the map's entry indices. private.traders.setStock is + // deliberately absent: it overwrites a bare i32 in place, so it batches. + 'private.traders.addItem', + 'private.traders.removeItem', storyStateApplyPath, }; // A skill edit can learn/unlearn — splicing the hero's ActiveEffects array — @@ -1417,6 +1423,16 @@ class EditorNotifier extends StateNotifier { state = state.copyWith(error: _l10n.editorInventorySlotEditConflict); return false; } + // A trade change and a raw array operation on the trader array cannot be + // rescued by putting them in different writes: the trade change's row index + // came from a list read before either ran, so whichever goes second + // resolves it against a layout the first moved. The core refuses the pair + // inside one write; splitting them here would slip past that and report + // both as committed, so refuse before building the worklist. + if (traderArrayConflict(allEdits.map((k) => k.edit).toList()) != null) { + state = state.copyWith(error: _l10n.editorTraderArrayConflict); + return false; + } final fixedBatch = allEdits .where( (k) => @@ -2799,6 +2815,77 @@ class EditorNotifier extends StateNotifier { } } + /// Load every merchant's shop record (`private.traders.list`). + /// + /// Returns a result carrying an inline [TradersResult.error] instead of + /// throwing, and reports which trader commands this core build offers so the + /// panel degrades to read-only against an older core rather than sending a + /// command that does not exist. + Future loadTraders() async { + final path = state.selectedPath; + if (path == null) { + return TradersResult(error: _l10n.editorNoSaveSelected); + } + try { + final response = await _execute( + 'private.traders.list', + payload: {'path': path}, + ); + if (response['ok'] != true) { + return TradersResult( + error: _l10n.editorTradersLoadFailed(_errorDetails(response)), + ); + } + return TradersResult.fromJson( + (response['data'] as Map).cast(), + ); + } catch (error) { + return TradersResult(error: _l10n.editorTradersLoadFailed('$error')); + } + } + + /// Load one merchant's full record by its `m_Traders` index. + /// + /// The index, not the name, is the address: two shipped rows are named `None` + /// and the core refuses to guess between them. + Future loadTraderDetail(int index) async { + final path = state.selectedPath; + if (path == null) { + return TraderDetailResult(error: _l10n.editorNoSaveSelected); + } + try { + final response = await _execute( + 'private.traders.detail', + payload: {'path': path, 'index': index}, + ); + if (response['ok'] != true) { + return TraderDetailResult( + error: _l10n.editorTradersLoadFailed(_errorDetails(response)), + ); + } + return TraderDetailResult( + detail: TraderDetail.fromJson( + (response['data'] as Map).cast(), + ), + ); + } catch (error) { + return TraderDetailResult(error: _l10n.editorTradersLoadFailed('$error')); + } + } + + /// Queue one trader stock change. Re-editing the same line replaces its + /// pending edit rather than stacking a second one. + void setTraderStockEdit(TraderStockEdit edit) { + setPendingEdit( + edit.pendingKey, + PendingSaveEdit(edits: [edit.toEdit()]), + ); + } + + /// Drop a queued trader change (the user reverted the field). + void clearTraderStockEdit(TraderStockEdit edit) => + clearPendingEdit(edit.pendingKey); + /// Run one progression section query. Returns the raw data map, or null /// with [onError] called, so each typed loader below can build its own page /// object with an inline error. @@ -4053,11 +4140,56 @@ bool structuredEditRewrites( // container in the save, so it is not scoped to one actor. case 'private.inventory.repairSlots': return _pathWritesASlotId(typedPath); + // A trader edit is addressed by its row's position in m_Traders, and a raw + // array operation ON that array renumbers the rows. Splitting the two into + // separate writes does not rescue them: the index came from a list read + // BEFORE either ran, so whichever goes second resolves it against a layout + // the first moved. The pair is refused whichever way round it comes. + case 'private.traders.setStock': + case 'private.traders.addItem': + case 'private.traders.removeItem': + return _pathTargetsTheTraderArray(typedPath); default: return false; } } +/// The first pair of pending edits where a trade change meets a raw array +/// operation on the trader array, or null when there is none. +/// +/// Separate from the packer's boundary test: this pair is not made safe by a +/// split, so it has to abort the save rather than start a new sub-write. +@visibleForTesting +(Map, Map)? traderArrayConflict( + List> edits, +) { + const traderOps = { + 'private.traders.setStock', + 'private.traders.addItem', + 'private.traders.removeItem', + }; + const arrayOps = {'private.typed.arrayRemove', 'private.typed.arrayDuplicate'}; + for (final edit in edits) { + if (!traderOps.contains(edit['path'])) continue; + for (final other in edits) { + // Only an array operation ON the array renumbers its rows. An edit that + // merely runs THROUGH it — a value under one row, or a container inside + // one — moves nothing, and refusing those would block safe pairs. + if (!arrayOps.contains(other['path'])) continue; + final path = _rawTypedEditPath(other); + if (path != null && _pathTargetsTheTraderArray(path)) { + return (edit, other); + } + } + } + return null; +} + +/// Whether a raw typed path addresses the trader ARRAY itself rather than +/// something inside one of its rows. +bool _pathTargetsTheTraderArray(List path) => + path.isNotEmpty && path.last == 'm_Traders'; + /// Whether [left] and [right] address the same target in the sense above, in /// EITHER direction — the pair test the packer uses. The core's rule is /// order-independent, so a batch may hold neither ordering of such a pair. @@ -4113,6 +4245,11 @@ bool _mayInvalidateOrdinals(Map edit) { 'private.npc.setRelationship', 'private.glossary.setSegment', 'private.skills.set', + // Splice an entry into or out of a trader's stock map, which changes how + // many entries it holds. private.traders.setStock is absent: it overwrites a + // bare i32 in place. + 'private.traders.addItem', + 'private.traders.removeItem', storyStateApplyPath, }.contains(path); } @@ -4146,6 +4283,13 @@ bool _carriesCallerOrdinal(Map edit) { if (path == 'private.inventory.removeItem') { return (value is Map ? value['slotId'] : null) != null; } + if (path == 'private.traders.setStock' || + path == 'private.traders.addItem' || + path == 'private.traders.removeItem') { + // Every trader edit addresses its row by an index into the trader array that + // the user's view supplied. + return true; + } return false; } diff --git a/apps/save-editor/lib/features/editor/domain/trader_models.dart b/apps/save-editor/lib/features/editor/domain/trader_models.dart new file mode 100644 index 000000000..f48fa23fb --- /dev/null +++ b/apps/save-editor/lib/features/editor/domain/trader_models.dart @@ -0,0 +1,282 @@ +// Models for the Handel (trade) sub-tab: the `private.traders.list` / +// `private.traders.detail` read results and the three edit intents. +// +// A merchant's shop is not part of his inventory. It lives in one global array +// (`m_Traders`) keyed by the NPC's unique name, and it holds two maps: what he +// currently offers, and the baseline he restocks back toward. His ore sits in +// the same map as an ordinary line — ore is the colony's currency, and the +// amount he holds IS his purchasing power. +// +// Rows are addressed by ARRAY INDEX, never by name: two shipped rows are named +// `None` and belong to no NPC at all. + +/// The item class path of ore, which doubles as a merchant's purse. +const String kTraderOrePath = '/Script/Angelscript.ItMi_Orenugget'; + +/// Which of a trader's two stock maps an edit targets. +enum TraderStockMap { + /// `m_Items` — what he offers right now. + current, + + /// `m_DefaultItems` — the baseline he restocks toward. + base; + + /// The wire value the core expects for `value.map`. + String get wire => this == TraderStockMap.current ? 'current' : 'default'; +} + +/// One line of a merchant's stock: an item class and how many he holds. +class TraderItem { + const TraderItem({ + required this.path, + required this.id, + required this.count, + required this.unknownItem, + }); + + factory TraderItem.fromJson(Map json) { + return TraderItem( + path: json['path'] as String? ?? '', + id: json['id'] as String? ?? '', + count: (json['count'] as num?)?.toInt() ?? 0, + unknownItem: json['unknownItem'] as bool? ?? false, + ); + } + + /// Full class path, i.e. the map key an edit addresses. + final String path; + + /// Bare class name, e.g. `ItFo_Loaf`. + final String id; + final int count; + + /// The class is not in the bundled catalog — shown, but not offered as an + /// edit target, because we cannot vouch for what the game does with it. + final bool unknownItem; + + bool get isOre => path == kTraderOrePath; +} + +/// A merchant as listed: enough to find one and see his purchasing power. +class TraderSummary { + const TraderSummary({ + required this.index, + required this.uniqueName, + required this.itemCount, + required this.defaultItemCount, + required this.ore, + required this.totalSeconds, + required this.traded, + required this.generatedEventCount, + required this.placeholder, + this.stockMapsPresent = true, + }); + + factory TraderSummary.fromJson(Map json) { + return TraderSummary( + index: (json['index'] as num?)?.toInt() ?? 0, + uniqueName: json['uniqueName'] as String? ?? '', + itemCount: (json['itemCount'] as num?)?.toInt() ?? 0, + defaultItemCount: (json['defaultItemCount'] as num?)?.toInt() ?? 0, + ore: (json['ore'] as num?)?.toInt(), + totalSeconds: (json['totalSeconds'] as num?)?.toDouble() ?? -1000, + traded: json['traded'] as bool? ?? false, + generatedEventCount: (json['generatedEventCount'] as num?)?.toInt() ?? 0, + placeholder: json['placeholder'] as bool? ?? false, + // Absent on an older core, where the maps were always assumed present. + stockMapsPresent: json['stockMapsPresent'] as bool? ?? true, + ); + } + + /// Position in `m_Traders` — the only safe address for an edit. + final int index; + final String uniqueName; + final int itemCount; + final int defaultItemCount; + + /// His ore. `null` means the record carries no ore line at all, which is a + /// real state (Riordian, Scorpio, Xardas) and NOT the same as zero. + final int? ore; + final double totalSeconds; + + /// Whether the player has ever traded here. Derived from [totalSeconds]'s + /// never-traded sentinel by the core. + final bool traded; + final int generatedEventCount; + + /// One of the unnamed sentinel rows, which belongs to no NPC. + final bool placeholder; + + /// Both stock maps are present on the record. An omitted one reads as empty, + /// which would look editable and then fail at save time — the structural + /// appliers resolve the property and cannot create it. + final bool stockMapsPresent; +} + +/// Everything stored for one merchant. +class TraderDetail { + const TraderDetail({ + required this.summary, + required this.items, + required this.defaultItems, + required this.generatedEvents, + required this.hasItemsByDifficulty, + }); + + factory TraderDetail.fromJson(Map json) { + List stock(String key) => + (json[key] as List?) + ?.whereType() + .map((e) => TraderItem.fromJson(e.cast())) + .toList(growable: false) ?? + const []; + return TraderDetail( + summary: TraderSummary.fromJson(json), + items: stock('items'), + defaultItems: stock('defaultItems'), + generatedEvents: + (json['generatedEvents'] as List?) + ?.whereType() + .toList(growable: false) ?? + const [], + hasItemsByDifficulty: json['hasItemsByDifficulty'] as bool? ?? false, + ); + } + + final TraderSummary summary; + + /// Live stock. Note it also contains the ore line. + final List items; + + /// Restock baseline. Diverges from [items] in played saves in both values and + /// key set, so it is a separate editing surface rather than a mirror. + final List defaultItems; + final List generatedEvents; + + /// The per-difficulty staging map holds entries. Empty in every save observed + /// so far; if this is ever true the UI must not pretend it edited everything. + final bool hasItemsByDifficulty; + + List stock(TraderStockMap map) => + map == TraderStockMap.current ? items : defaultItems; +} + +/// Result of `private.traders.list`, carrying an inline [error] rather than +/// throwing so the panel can render a message in place. +class TradersResult { + const TradersResult({ + this.traders = const [], + this.writable = const {}, + this.error, + }); + + factory TradersResult.fromJson(Map json) { + return TradersResult( + traders: + (json['traders'] as List?) + ?.whereType() + .map((e) => TraderSummary.fromJson(e.cast())) + .toList(growable: false) ?? + const [], + writable: + (json['writable'] as List?)?.whereType().toSet() ?? const {}, + ); + } + + final List traders; + + /// Which trader commands this core build offers. The app feature-detects on + /// these instead of assuming, so an older core degrades to read-only. + final Set writable; + final String? error; + + bool get canSetStock => writable.contains('private.traders.setStock'); + bool get canAddItem => writable.contains('private.traders.addItem'); + bool get canRemoveItem => writable.contains('private.traders.removeItem'); + + /// Every non-placeholder record carrying [uniqueName]. + /// + /// Case-insensitively, the way the core joins these names: a character's + /// unique name is the stored knowledge key where one exists, whose casing can + /// differ from the trader row's. An exact compare would leave a character the + /// list badges as a merchant reading "does not trade". + /// + /// Placeholder rows belong to no NPC and are deliberately not matched. + List allForUniqueName(String uniqueName) { + final wanted = uniqueName.toLowerCase(); + return [ + for (final t in traders) + if (!t.placeholder && t.uniqueName.toLowerCase() == wanted) t, + ]; + } + + /// The record for an NPC, or null when he is not a merchant OR when the name + /// is ambiguous. + /// + /// Ambiguity is not resolved by taking the first hit: the index this returns + /// is what every edit is addressed by, so guessing would edit an arbitrary + /// shop. The core refuses the same case; [isAmbiguous] tells the two apart so + /// the panel can say which one it is. + TraderSummary? forUniqueName(String uniqueName) { + final matches = allForUniqueName(uniqueName); + return matches.length == 1 ? matches.first : null; + } + + /// More than one record carries this name, so no edit may be addressed by it. + bool isAmbiguous(String uniqueName) => + allForUniqueName(uniqueName).length > 1; +} + +/// Result of `private.traders.detail`. +class TraderDetailResult { + const TraderDetailResult({this.detail, this.error}); + + final TraderDetail? detail; + final String? error; +} + +/// A queued change to one stock line. +/// +/// [count] is the new count for [TraderEditKind.setStock] and +/// [TraderEditKind.addItem], and unused for a removal. +class TraderStockEdit { + const TraderStockEdit({ + required this.kind, + required this.index, + required this.map, + required this.path, + this.count = 0, + }); + + final TraderEditKind kind; + final int index; + final TraderStockMap map; + final String path; + final int count; + + String get commandPath => switch (kind) { + TraderEditKind.setStock => 'private.traders.setStock', + TraderEditKind.addItem => 'private.traders.addItem', + TraderEditKind.removeItem => 'private.traders.removeItem', + }; + + /// A stable per-line key so re-editing the same line replaces its pending + /// edit instead of queueing a second one. + String get pendingKey => 'traders:$index:${map.wire}:$path'; + + /// Insert and remove splice the map body; the core refuses to batch them with + /// anything else, and the notifier splits them into their own writes. + bool get isStructural => kind != TraderEditKind.setStock; + + Map toEdit() { + final value = { + 'index': index, + 'path': path, + 'map': map.wire, + }; + if (kind != TraderEditKind.removeItem) value['count'] = count; + return {'path': commandPath, 'value': value}; + } +} + +enum TraderEditKind { setStock, addItem, removeItem } diff --git a/apps/save-editor/lib/features/editor/ui/character_master_list.dart b/apps/save-editor/lib/features/editor/ui/character_master_list.dart index 587852eff..f59c89a3f 100644 --- a/apps/save-editor/lib/features/editor/ui/character_master_list.dart +++ b/apps/save-editor/lib/features/editor/ui/character_master_list.dart @@ -502,16 +502,10 @@ class _CharacterMasterListState extends State { style: const TextStyle(fontSize: 11), ) : null, - trailing: row.hasKnowledge - ? Tooltip( - message: l10n.dialogKnowledge, - child: Icon( - Icons.menu_book_outlined, - size: 18, - color: scheme.onSurfaceVariant, - ), - ) - : null, + // The same badges a spawned actor gets. An orphan can own a shop — a + // trader row is keyed by name, not by actor — so hand-rolling just the + // knowledge icon here would hide the merchants the core does flag. + trailing: _aspectBadges(row, scheme, l10n), selected: isSelected, selectedTileColor: scheme.primaryContainer, selectedColor: scheme.primary, @@ -526,7 +520,8 @@ class _CharacterMasterListState extends State { } /// Compact trailing badges for an actor row: a book when it has captured - /// knowledge, a history glyph when it has recorded events. No inventory badge. + /// knowledge, a history glyph when it has recorded events, a storefront when + /// he runs a shop. No inventory badge — nearly every actor has one. Widget? _aspectBadges( CharacterRow row, ColorScheme scheme, @@ -547,6 +542,15 @@ class _CharacterMasterListState extends State { message: l10n.sectionEvents, child: Icon(Icons.history, size: 18, color: scheme.onSurfaceVariant), ), + if (row.isTrader) + Tooltip( + message: l10n.tabTrade, + child: Icon( + Icons.storefront_outlined, + size: 18, + color: scheme.onSurfaceVariant, + ), + ), ]; if (badges.isEmpty) return null; return Wrap(spacing: 4, children: badges); diff --git a/apps/save-editor/lib/features/editor/ui/characters_tab.dart b/apps/save-editor/lib/features/editor/ui/characters_tab.dart index f20e8fe9c..a8f6b9c94 100644 --- a/apps/save-editor/lib/features/editor/ui/characters_tab.dart +++ b/apps/save-editor/lib/features/editor/ui/characters_tab.dart @@ -7,6 +7,7 @@ import 'package:goresave/features/editor/ui/attribute_detail.dart'; import 'package:goresave/features/editor/ui/character_master_list.dart'; import 'package:goresave/features/editor/ui/inventory_detail.dart'; import 'package:goresave/features/editor/ui/position_detail.dart'; +import 'package:goresave/features/editor/ui/trader_detail.dart'; import 'package:goresave/features/editor/ui/progression_panel.dart' show KnowledgeDetail, EventsDetail; import 'package:goresave/l10n/app_localizations.dart'; @@ -32,6 +33,29 @@ import '../domain/editor_notifier.dart'; /// `Padding(EdgeInsets.fromLTRB(20, 8, 20, 20))` → one `Card` → /// `Padding(EdgeInsets.all(16))` → content. Card titles are intentionally absent /// because the sub-tab labels already name the views. +/// Width one labelled sub-tab needs: `kTabLabelPadding` on both sides plus room +/// for the longest of the six labels across the shipped languages. +const double _labelledDetailTabWidth = 132; + +/// Whether a detail tab bar [width] pixels wide can carry labels rather than +/// bare icons. Public so the breakpoint is testable without a window. +bool detailTabsCanCarryLabels(double width) => + width >= 6 * _labelledDetailTabWidth; + +/// One sub-tab: icon and label where they fit, otherwise the icon alone with +/// the label as its tooltip. +/// +/// The icon-only form names itself twice over. A [Tooltip] alone puts the name +/// in the node's `tooltip`, which platforms surface as help text rather than as +/// the control's name, so the tab would read out as "Tab 3 of 6" — the +/// [Icon.semanticLabel] is what puts the name where a labelled tab has it. +Tab _detailTab(IconData icon, String label, bool labelled) => Tab( + icon: labelled + ? Icon(icon) + : Tooltip(message: label, child: Icon(icon, semanticLabel: label)), + text: labelled ? label : null, +); + class CharactersTab extends ConsumerWidget { const CharactersTab({ super.key, @@ -102,6 +126,21 @@ class CharactersTab extends ConsumerWidget { showActorHeader: false, ); + // Handel: a merchant's shop, which is NOT his inventory — it lives in a + // global array keyed by uniqueName alone. An orphan therefore gets it too: + // the record does not depend on a spawned actor, and the panel already + // shows a clean non-merchant state when no row matches the name. + final Widget tradeBody = TraderPanel( + inspection: inspection, + notifier: notifier, + actor: selected, + editable: progressionEditable, + // Carries the inspection, not just the actor: a save re-inspects the + // file, and without that the kept-alive panel would keep showing the + // pre-save stock. + reloadKey: (inspection, selected.uniqueName), + ); + // Position: the player's transform editor (its only home — it used to sit // in the Attribute tab's HeroStatsCard sidebar) and, for an NPC, the saved // pose from `private.npc.position` (editable again while the placement @@ -200,7 +239,7 @@ class CharactersTab extends ConsumerWidget { const VerticalDivider(width: 1), Expanded( child: DefaultTabController( - length: 5, + length: 6, child: Column( children: [ ActorDetailHeader( @@ -213,35 +252,43 @@ class CharactersTab extends ConsumerWidget { key: ValueKey('actor-header-tab-gap'), height: 12, ), - TabBar( - tabs: [ - Tab( - icon: const Icon(Icons.person_outline), - text: l10n.tabAttribute, - ), - Tab( - icon: const Icon(Icons.inventory_2_outlined), - text: l10n.tabInventory, - ), - Tab( - icon: const Icon(Icons.school_outlined), - text: l10n.dialogKnowledge, - ), - Tab( - icon: const Icon(Icons.history_outlined), - text: l10n.sectionEvents, - ), - Tab( - icon: const Icon(Icons.place_outlined), - text: l10n.heroTransform, - ), - ], + LayoutBuilder( + builder: (context, constraints) { + // A non-scrollable bar splits its width evenly, so six + // tabs each get a sixth of the detail pane — well under a + // label's width until the window is very wide, and the + // labels then clip rather than shrink. Below that the tabs + // carry their icon alone and name themselves on hover, so + // all six stay visible instead of hiding behind a scroll. + final labelled = detailTabsCanCarryLabels( + constraints.maxWidth, + ); + return TabBar( + tabs: [ + _detailTab(Icons.person_outline, l10n.tabAttribute, labelled), + _detailTab( + Icons.inventory_2_outlined, + l10n.tabInventory, + labelled, + ), + _detailTab(Icons.storefront_outlined, l10n.tabTrade, labelled), + _detailTab( + Icons.school_outlined, + l10n.dialogKnowledge, + labelled, + ), + _detailTab(Icons.history_outlined, l10n.sectionEvents, labelled), + _detailTab(Icons.place_outlined, l10n.heroTransform, labelled), + ], + ); + }, ), Expanded( child: TabBarView( children: [ _KeepAliveTab(child: attributeBody), _KeepAliveTab(child: inventoryBody), + _KeepAliveTab(child: tradeBody), _KeepAliveTab(child: knowledgeBody), _KeepAliveTab(child: eventsBody), _KeepAliveTab(child: positionBody), diff --git a/apps/save-editor/lib/features/editor/ui/inventory_detail.dart b/apps/save-editor/lib/features/editor/ui/inventory_detail.dart index dabf1e0d3..58d6e6aec 100644 --- a/apps/save-editor/lib/features/editor/ui/inventory_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/inventory_detail.dart @@ -6,6 +6,7 @@ import 'package:goresave/features/editor/domain/editor_models.dart'; import 'package:goresave/features/editor/domain/item_categories.dart'; import 'package:goresave/features/editor/domain/pending_edits.dart'; import 'package:goresave/features/editor/ui/actor_detail_header.dart'; +import 'package:goresave/features/editor/ui/pending_structural_row.dart'; import 'package:goresave/features/editor/ui/sidebar_tile.dart'; import 'package:goresave/features/editor/ui/slot_repair_banner.dart'; import 'package:goresave/l10n/app_localizations.dart'; @@ -909,8 +910,8 @@ class _PrivateInventorySummaryCardState ), for (final add in _pendingAdds) ...[ const SizedBox(height: 12), - _PendingStructuralRow( - tone: _PendingTone.add, + PendingStructuralRow( + tone: PendingTone.add, icon: Icons.add_circle_outline, title: pendingNameOf(add.path), subtitle: l10n.pendingAddSubtitle(add.count), @@ -924,8 +925,8 @@ class _PrivateInventorySummaryCardState ], if (hasPendingRemove) ...[ const SizedBox(height: 12), - _PendingStructuralRow( - tone: _PendingTone.remove, + PendingStructuralRow( + tone: PendingTone.remove, icon: Icons.delete_outline, title: pendingNameOf( _pendingRemovePath!, @@ -943,8 +944,8 @@ class _PrivateInventorySummaryCardState ], if (hasPendingReset) ...[ const SizedBox(height: 12), - _PendingStructuralRow( - tone: _PendingTone.remove, + PendingStructuralRow( + tone: PendingTone.remove, icon: Icons.settings_backup_restore, title: l10n.pendingResetTitle, subtitle: l10n.pendingResetSubtitle( @@ -1405,8 +1406,6 @@ class _PrivateInventorySummaryCardState /// Tone of a pending structural-edit card: an add (primary) or a remove /// (error). -enum _PendingTone { add, remove } - /// A human-readable id fragment derived from an item asset path. String _itemDisplayFromPath(String path) => path.contains('.') ? path.split('.').last : path.split('/').last; @@ -1442,75 +1441,6 @@ String _upgradeTier(AppLocalizations l10n, String value) { /// (add or remove) awaiting save. Mirrors how a not-yet-saved item is /// represented for both directions: the affected item is not shown inline, only /// here, with a cancel button. -class _PendingStructuralRow extends StatelessWidget { - const _PendingStructuralRow({ - required this.icon, - required this.title, - required this.subtitle, - required this.onCancel, - required this.cancelTooltip, - required this.tone, - this.technicalId, - }); - - final IconData icon; - final String title; - final String subtitle; - final VoidCallback onCancel; - final String cancelTooltip; - final _PendingTone tone; - final String? technicalId; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final isAdd = tone == _PendingTone.add; - final bg = isAdd ? scheme.primaryContainer : scheme.errorContainer; - final fg = isAdd ? scheme.onPrimaryContainer : scheme.onErrorContainer; - final accent = isAdd ? scheme.primary : scheme.error; - return DecoratedBox( - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: accent.withValues(alpha: 0.4)), - ), - child: ListTile( - dense: true, - leading: Icon(icon, color: accent), - title: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fg), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(subtitle, style: TextStyle(color: fg.withValues(alpha: 0.8))), - if (technicalId?.trim().isNotEmpty == true) - Text( - technicalId!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: fg.withValues(alpha: 0.72), - fontFamily: 'Consolas', - fontSize: 11, - ), - ), - ], - ), - trailing: IconButton( - icon: const Icon(Icons.close), - tooltip: cancelTooltip, - onPressed: onCancel, - ), - ), - ); - } -} - class _InventoryItemCountEditor extends StatefulWidget { const _InventoryItemCountEditor({ super.key, diff --git a/apps/save-editor/lib/features/editor/ui/pending_structural_row.dart b/apps/save-editor/lib/features/editor/ui/pending_structural_row.dart new file mode 100644 index 000000000..cb710163c --- /dev/null +++ b/apps/save-editor/lib/features/editor/ui/pending_structural_row.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +/// Whether a queued structural change adds something or takes it away. Only the +/// colour scheme differs; the two read as opposites at a glance. +enum PendingTone { add, remove } + +/// A queued structural change, shown OUTSIDE the list it will affect. +/// +/// An addition has no row to mark up — the thing does not exist on disk yet — so +/// showing it as if it were already there would claim a state the save does not +/// have. A tinted banner beside the list says the same thing honestly: this is +/// coming, and here is how to take it back. +/// +/// Shared by the inventory and the trade panel so a queued change looks the same +/// wherever it is made. +class PendingStructuralRow extends StatelessWidget { + const PendingStructuralRow({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.onCancel, + required this.cancelTooltip, + required this.tone, + this.technicalId, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onCancel; + final String cancelTooltip; + final PendingTone tone; + + /// Class path or id, shown monospaced under the subtitle when the user has + /// technical ids switched on. + final String? technicalId; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final isAdd = tone == PendingTone.add; + final bg = isAdd ? scheme.primaryContainer : scheme.errorContainer; + final fg = isAdd ? scheme.onPrimaryContainer : scheme.onErrorContainer; + final accent = isAdd ? scheme.primary : scheme.error; + return DecoratedBox( + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: accent.withValues(alpha: 0.4)), + ), + child: ListTile( + dense: true, + leading: Icon(icon, color: accent), + title: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fg), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(subtitle, style: TextStyle(color: fg.withValues(alpha: 0.8))), + if (technicalId?.trim().isNotEmpty == true) + Text( + technicalId!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: fg.withValues(alpha: 0.72), + fontFamily: 'Consolas', + fontSize: 11, + ), + ), + ], + ), + trailing: IconButton( + icon: const Icon(Icons.close), + tooltip: cancelTooltip, + onPressed: onCancel, + ), + ), + ); + } +} diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart new file mode 100644 index 000000000..ca0f5de5e --- /dev/null +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -0,0 +1,1247 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:goresave/features/app/domain/ui_settings.dart'; +import 'package:goresave/features/editor/domain/actor.dart'; +import 'package:goresave/features/editor/domain/editor_models.dart'; +import 'package:goresave/features/editor/domain/item_categories.dart'; +import 'package:goresave/features/editor/domain/trader_models.dart'; +import 'package:goresave/features/editor/ui/add_inventory_item_dialog.dart'; +import 'package:goresave/features/editor/ui/pending_structural_row.dart'; +import 'package:goresave/features/editor/ui/sidebar_tile.dart'; +import 'package:goresave/l10n/app_localizations.dart'; +import 'package:goresave/loc/loc_catalog_provider.dart'; +import 'package:goresave/providers/data_providers.dart'; + +import '../domain/editor_notifier.dart'; + +/// The Handel (trade) sub-tab: what a merchant offers and how much ore he has +/// to buy with. +/// +/// This is NOT his inventory. A merchant's shop lives in a global array keyed by +/// his unique name, and it carries two maps — the live stock and the baseline he +/// restocks toward. His ore sits inside the same map as an ordinary line, +/// because ore is the currency and what he holds is what he can pay with. +class TraderPanel extends ConsumerStatefulWidget { + const TraderPanel({ + super.key, + required this.inspection, + required this.notifier, + required this.actor, + required this.editable, + required this.reloadKey, + }); + + final SaveInspection inspection; + final EditorNotifier notifier; + final Actor actor; + + /// Same save-wide gate the other editing panes take + /// (`privateEditable && privateTypedVerified && codecCompressReady`). + final bool editable; + + /// Changes when the panel must re-read from disk: a different merchant, or the + /// same one after a save re-inspected the file. Without the inspection in here + /// a save would leave the panel showing the pre-save stock — the tab is kept + /// alive across switches, so nothing else would ever trigger the reload. + final Object reloadKey; + + @override + ConsumerState createState() => _TraderPanelState(); +} + +/// How much of a short pane the panel's fixed head may keep before it scrolls, +/// so the stock browser below it always gets a usable slice. +const double _minBrowserHeight = 260; + +double _headCap(double available) { + if (!available.isFinite) return double.infinity; + final cap = available - _minBrowserHeight; + return cap > 0 ? cap : 0; +} + +class _TraderPanelState extends ConsumerState { + TradersResult? _list; + TraderDetail? _detail; + String? _error; + + /// Several trader records carry this character's name, so none of them may be + /// edited: the index an edit is addressed by would be a guess. + bool _ambiguous = false; + bool _loading = true; + + /// Guards against a slow reload landing after a newer one: only the newest + /// epoch may write to the state. + int _epoch = 0; + + /// Which of the two stock maps is on screen. They hold the same kind of data + /// and are edited the same way, so showing both at once only invited the + /// question which one the ore field belonged to. + TraderStockMap _map = TraderStockMap.current; + + /// Which category the sidebar has selected. Null until the first build picks + /// one, and reset whenever the selection no longer has any lines — switching + /// maps can empty it. + ItemCategory? _category; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant TraderPanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.reloadKey != oldWidget.reloadKey) _load(); + } + + Future _load() async { + final epoch = ++_epoch; + setState(() { + _loading = true; + _error = null; + _detail = null; + _ambiguous = false; + }); + final list = await widget.notifier.loadTraders(); + if (!mounted || epoch != _epoch) return; + if (list.error != null) { + setState(() { + _loading = false; + _error = list.error; + _list = null; + }); + return; + } + final row = list.forUniqueName(widget.actor.uniqueName); + if (row == null) { + // Either not a merchant, or a name several records carry — which is not + // the same thing and must not read as one. + setState(() { + _loading = false; + _list = list; + _detail = null; + _ambiguous = list.isAmbiguous(widget.actor.uniqueName); + }); + return; + } + final detail = await widget.notifier.loadTraderDetail(row.index); + if (!mounted || epoch != _epoch) return; + setState(() { + _loading = false; + _list = list; + _error = detail.error; + _detail = detail.detail; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + // Rebuild when pending edits change so a reverted field drops its badge. + ref.watch(editorProvider.select((s) => s.pendingEdits.length)); + + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return _Message( + icon: Icons.error_outline, + title: l10n.tabTrade, + body: _error!, + onRetry: _load, + ); + } + final detail = _detail; + if (detail == null) { + return _Message( + icon: _ambiguous + ? Icons.warning_amber_outlined + : Icons.storefront_outlined, + title: l10n.tabTrade, + body: _ambiguous ? l10n.traderAmbiguousName : l10n.traderNotAMerchant, + ); + } + + final list = _list; + // Per-difficulty stock is not modelled, and the edits reach only m_Items and + // m_DefaultItems. A save that carries it would take an edit, report success, + // and leave that other stock standing — so nothing here is editable then. + // Two shapes the editor cannot honour: per-difficulty stock it does not + // model, and a record missing a stock list it cannot create. + final incomplete = !detail.summary.stockMapsPresent; + final unsupported = detail.hasItemsByDifficulty || incomplete; + final canSet = + widget.editable && !unsupported && (list?.canSetStock ?? false); + final canAdd = + widget.editable && !unsupported && (list?.canAddItem ?? false); + final canRemove = + widget.editable && !unsupported && (list?.canRemoveItem ?? false); + + // The live stock gets the ore its own card, because that number is the + // merchant's purchasing power and not just another line. The restock + // baseline has no such meaning, so there its ore stays an ordinary row. + final showOreCard = _map == TraderStockMap.current; + final removals = _pendingRemovals(_map); + final rows = [ + for (final item in detail.stock(_map)) + if (!(showOreCard && item.isOre) && !removals.contains(item.path)) item, + ]; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: LayoutBuilder( + builder: (context, pane) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // The notes, the map switch and the ore card scroll among themselves + // once the pane gets short, so they can never squeeze the browser out + // of the column — which they did, by 8px, at 620px tall. + ConstrainedBox( + constraints: BoxConstraints(maxHeight: _headCap(pane.maxHeight)), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // First, because it qualifies every number below it — the ore as much + // as the stock counts. + if (unsupported) ...[ + _NoteCard( + text: incomplete + ? l10n.traderRecordIncomplete + : l10n.traderDifficultyStockUnsupported, + tone: _NoteTone.warning, + ), + const SizedBox(height: 12), + ], + _NoteCard(text: l10n.traderPriceWarning), + // The core drops setStock from `writable` when no shop + // holds a line while still offering addItem, so "read only" + // has to mean none of the three is available — not merely + // that one of them is missing. + if (widget.editable && + !unsupported && + !canSet && + !canAdd && + !canRemove) ...[ + const SizedBox(height: 12), + Text( + l10n.traderReadOnlyCore, + style: theme.textTheme.bodySmall, + ), + ], + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: SegmentedButton( + segments: [ + ButtonSegment( + value: TraderStockMap.current, + icon: const Icon(Icons.storefront_outlined), + label: Text(l10n.traderStockCurrent), + ), + ButtonSegment( + value: TraderStockMap.base, + icon: const Icon(Icons.inventory_outlined), + label: Text(l10n.traderStockBase), + ), + ], + selected: {_map}, + onSelectionChanged: (selection) => + setState(() => _map = selection.first), + ), + ), + if (_map == TraderStockMap.base) ...[ + const SizedBox(height: 8), + Text( + l10n.traderStockBaseHint, + style: theme.textTheme.bodySmall, + ), + ], + if (showOreCard) ...[ + const SizedBox(height: 16), + _OreCard( + detail: detail, + editable: canSet, + canRemove: canRemove, + removalPending: removals.contains(kTraderOrePath), + onChanged: (value) => + _queueSet(_map, kTraderOrePath, value), + onRevert: () => _revert(_map, kTraderOrePath), + onRemove: () => _queueRemove(_map, kTraderOrePath), + pending: _pendingCountFor(_map, kTraderOrePath), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + Expanded( + child: _StockSection( + map: _map, + items: rows, + lineCount: detail.stock(_map).length, + pendingAdds: _pendingAdds(_map), + pendingRemovals: [ + for (final item in detail.stock(_map)) + if (removals.contains(item.path)) item, + ], + canSet: canSet, + canAdd: canAdd, + canRemove: canRemove, + selectedCategory: _category, + onSelectCategory: (category) => + setState(() => _category = category), + pendingOf: _pendingCountFor, + onChanged: _queueSet, + onRevert: _revert, + onRemove: _queueRemove, + onRevertAdd: _revertAdd, + onAdd: () => _addItem(_map, detail), + ), + ), + ], + ), + ), + ); + } + + int get _index => _detail!.summary.index; + + TraderStockEdit _edit( + TraderEditKind kind, + TraderStockMap map, + String path, { + int count = 0, + }) => TraderStockEdit( + kind: kind, + index: _index, + map: map, + path: path, + count: count, + ); + + /// The queued count for a line, or null when nothing is queued. Reads the + /// notifier's pending map rather than local state so the badge survives a + /// rebuild and matches what a save would actually send. + int? _pendingCountFor(TraderStockMap map, String path) { + final key = _edit(TraderEditKind.setStock, map, path).pendingKey; + final pending = ref.read(editorProvider).pendingEdits[key]; + final value = pending?.edits.firstOrNull?['value']; + if (value is Map && value['count'] is num) { + return (value['count'] as num).toInt(); + } + return null; + } + + bool _isRemovalPending(TraderStockMap map, String path) => + _pendingRemovals(map).contains(path); + + /// Item paths queued for removal. They are taken OUT of the list and shown as + /// a banner above it instead: a struck-through row still reads as something + /// the save contains, and after the write it will not. + Set _pendingRemovals(TraderStockMap map) { + final prefix = 'traders:$_index:${map.wire}:'; + final out = {}; + ref.read(editorProvider).pendingEdits.forEach((key, pending) { + if (!key.startsWith(prefix)) return; + final edit = pending.edits.firstOrNull; + if (edit?['path'] != 'private.traders.removeItem') return; + final value = edit?['value']; + if (value is Map && value['path'] is String) { + out.add(value['path'] as String); + } + }); + return out; + } + + /// Lines queued for insertion but not saved yet. + /// + /// A new line has no counterpart in the loaded stock, so it would otherwise be + /// invisible until the next save — the inventory shows its queued additions + /// the same way. Read out of the notifier rather than a local list so a tab + /// switch (which keeps this panel alive but rebuilds it) cannot lose them. + List _pendingAdds(TraderStockMap map) { + final prefix = 'traders:$_index:${map.wire}:'; + final out = []; + ref.read(editorProvider).pendingEdits.forEach((key, pending) { + if (!key.startsWith(prefix)) return; + final edit = pending.edits.firstOrNull; + if (edit?['path'] != 'private.traders.addItem') return; + final value = edit?['value']; + if (value is! Map) return; + final path = value['path'] as String? ?? ''; + if (path.isEmpty) return; + out.add( + TraderItem( + path: path, + id: path.split('.').last, + count: (value['count'] as num?)?.toInt() ?? 0, + unknownItem: false, + ), + ); + }); + out.sort((a, b) => a.id.compareTo(b.id)); + return out; + } + + void _revertAdd(TraderStockMap map, String path) { + widget.notifier.clearTraderStockEdit( + _edit(TraderEditKind.addItem, map, path), + ); + setState(() {}); + } + + void _queueSet(TraderStockMap map, String path, int count) { + widget.notifier.setTraderStockEdit( + _edit(TraderEditKind.setStock, map, path, count: count), + ); + setState(() {}); + } + + void _revert(TraderStockMap map, String path) { + widget.notifier.clearTraderStockEdit( + _edit(TraderEditKind.setStock, map, path), + ); + setState(() {}); + } + + void _queueRemove(TraderStockMap map, String path) { + final edit = _edit(TraderEditKind.removeItem, map, path); + if (_isRemovalPending(map, path)) { + widget.notifier.clearTraderStockEdit(edit); + } else { + // A removal supersedes a queued count change on the same line: sending + // both would set a value and then delete the line it lives in. + widget.notifier.clearTraderStockEdit( + _edit(TraderEditKind.setStock, map, path), + ); + widget.notifier.setTraderStockEdit(edit); + } + setState(() {}); + } + + Future _addItem(TraderStockMap map, TraderDetail detail) async { + final savePath = widget.notifier.selectedPath; + final held = {for (final i in detail.stock(map)) i.path}; + final result = await showDialog( + context: context, + // The core refuses a duplicate key, so never offer a line he already has. + builder: (_) => AddInventoryItemDialog(excludePaths: held), + ); + if (result == null) return; + if (!mounted || widget.notifier.selectedPath != savePath) return; + widget.notifier.setTraderStockEdit( + _edit(TraderEditKind.addItem, map, result.path, count: result.count), + ); + setState(() {}); + } +} + +/// Below this width the ore card's field and delete button no longer fit beside +/// the text, so they move under it. +const double _oreStackBelow = 320; + +/// Below this width a stock row's value no longer fits beside its name, so it +/// moves under it. A ListTile gives its trailing whatever width it asks for, so +/// the row has to stop using one. +const double _rowStackBelow = 300; + +class _OreCard extends ConsumerWidget { + const _OreCard({ + required this.detail, + required this.editable, + required this.canRemove, + required this.removalPending, + required this.onChanged, + required this.onRevert, + required this.onRemove, + required this.pending, + }); + + final TraderDetail detail; + final bool editable; + + /// Whether the ore line may be dropped entirely. A merchant without one is a + /// state the game itself produces, so the card has to offer it. + final bool canRemove; + final bool removalPending; + final void Function(int) onChanged; + final VoidCallback onRevert; + final VoidCallback onRemove; + final int? pending; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final ore = detail.summary.ore; + final label = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.traderOre, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text(l10n.traderOreHint, style: theme.textTheme.bodySmall), + ], + ); + final field = ore == null + // No ore line at all is a real state and NOT the same as zero, so say + // so instead of showing a 0 the save does not contain. + ? Text(l10n.traderNoOre, style: theme.textTheme.bodyMedium) + : _CountField( + value: ore, + pending: pending, + // While a removal is queued the number is on its way out; editing + // it would queue a count for a line about to go. + enabled: editable && !removalPending, + onChanged: onChanged, + onRevert: onRevert, + ); + final delete = ore != null && canRemove + ? IconButton( + tooltip: l10n.traderRemoveItem, + icon: const Icon(Icons.delete_outline, size: 20), + onPressed: removalPending ? null : onRemove, + ) + : null; + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: LayoutBuilder( + builder: (context, box) { + // Field plus delete button need a fixed ~190px. At the smallest + // supported window the character list leaves the detail pane + // narrower than that, so there they move under the text. + final stacked = box.maxWidth < _oreStackBelow; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.savings_outlined, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: stacked + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + label, + const SizedBox(height: 12), + // The field takes what is left rather than a fixed + // width: stacked, there may be very little. + Row( + children: [ + Expanded(child: field), + ?delete, + ], + ), + ], + ) + : label, + ), + if (!stacked) ...[ + const SizedBox(width: 12), + SizedBox(width: 140, child: field), + ?delete, + ], + ], + ); + }, + ), + ), + ); + } +} + +/// Whether a note merely explains something or reports a limit that stops the +/// panel from doing what it otherwise would. +enum _NoteTone { info, warning } + +class _NoteCard extends StatelessWidget { + const _NoteCard({required this.text, this.tone = _NoteTone.info}); + + final String text; + final _NoteTone tone; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isWarning = tone == _NoteTone.warning; + return Card( + margin: EdgeInsets.zero, + color: isWarning ? theme.colorScheme.errorContainer : null, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isWarning ? Icons.warning_amber_outlined : Icons.info_outline, + size: 18, + color: isWarning + ? theme.colorScheme.onErrorContainer + : theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: isWarning ? theme.colorScheme.onErrorContainer : null, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _StockSection extends ConsumerWidget { + const _StockSection({ + required this.map, + required this.items, + required this.lineCount, + required this.pendingAdds, + required this.pendingRemovals, + required this.canSet, + required this.canAdd, + required this.canRemove, + required this.selectedCategory, + required this.onSelectCategory, + required this.pendingOf, + required this.onChanged, + required this.onRevert, + required this.onRemove, + required this.onRevertAdd, + required this.onAdd, + }); + + /// Below this width the sidebar would leave the list unusably narrow, so the + /// categories collapse into one flat list instead. Same threshold the + /// inventory browser uses. + static const double _compactBelow = 600; + + /// The share of the pane the queued-change banners may claim before they + /// scroll among themselves, so the stock browser stays visible below them. + static const double _pendingMaxFraction = 0.4; + + /// Never more than this, however tall the pane is — past a few banners the + /// rest may as well scroll. + static const double _pendingMaxHeight = 240; + + /// Room the header and a usable slice of the list keep for themselves. On a + /// pane too short to grant even that, the banners give way rather than push + /// the column past its bounds. + static const double _pendingReserve = 140; + + /// The height the banner strip may occupy inside a pane of [available]. + /// + /// Measured against the pane, not against a constant: a fixed cap ignores the + /// header above and the list below, so a short window or a large UI scale + /// overflowed the column and collapsed the browser to nothing. + static double _bannerCap(double available) { + if (!available.isFinite) return _pendingMaxHeight; + final byFraction = available * _pendingMaxFraction; + final byReserve = available - _pendingReserve; + final cap = byFraction < byReserve ? byFraction : byReserve; + if (cap > _pendingMaxHeight) return _pendingMaxHeight; + return cap > 0 ? cap : 0; + } + + final TraderStockMap map; + + /// The rows to draw: saved lines minus the ones queued for removal, and minus + /// the ore when it has its own card. + final List items; + + /// How many lines the map holds on disk. The header states this rather than + /// [items].length, which no longer counts the rows filtered out of the view. + final int lineCount; + final List pendingAdds; + final List pendingRemovals; + final bool canSet; + final bool canAdd; + final bool canRemove; + final ItemCategory? selectedCategory; + final void Function(ItemCategory) onSelectCategory; + final int? Function(TraderStockMap, String) pendingOf; + final void Function(TraderStockMap, String, int) onChanged; + final void Function(TraderStockMap, String) onRevert; + final void Function(TraderStockMap, String) onRemove; + final void Function(TraderStockMap, String) onRevertAdd; + final VoidCallback onAdd; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + // Sort by the name the user actually reads, the way the inventory does. + // `.value` (not `.asData?.value`) so a background catalog refresh keeps the + // previous order instead of briefly re-sorting by raw class id. + final lang = ref.watch(currentGameLangProvider); + final locCatalog = ref.watch(locCatalogProvider).value ?? const {}; + String nameOf(TraderItem item) => + localizedGameName(locCatalog, lang, item.id) ?? item.id; + final groups = _grouped(items, displayNameOf: nameOf); + // The compact pane has no sidebar, so it lists every line at once. The core + // hands them over in class-id order, which in most languages is not the + // order of the names on screen — sort them the way the groups are sorted. + final flat = [...items]..sort(_byDisplayName(nameOf)); + // Hold the chosen category while it still has lines; otherwise fall back to + // the first one so the list is never blank next to a populated sidebar. + final selected = groups.any((g) => g.category == selectedCategory) + ? selectedCategory + : (groups.isEmpty ? null : groups.first.category); + final shown = + groups.where((g) => g.category == selected).firstOrNull?.items ?? + const []; + // "Nothing in stock" means the MAP is empty, not the filtered view: the ore + // is pulled out of the live stock into its own card, so a merchant holding + // only ore has a line and a purse on screen and must not be told otherwise. + final mapIsEmpty = + lineCount == 0 && pendingAdds.isEmpty && pendingRemovals.isEmpty; + + return LayoutBuilder( + builder: (context, pane) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.traderStockLineCount(lineCount), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ), + if (canAdd) + OutlinedButton.icon( + icon: const Icon(Icons.add), + label: Text(l10n.traderAddItem), + onPressed: onAdd, + ), + ], + ), + // Queued changes sit ABOVE the list: they are what the next save will + // do, while the list below is what the save holds right now. Bounded and + // scrollable, because replacing most of a merchant's stock queues enough + // of them to push the browser off screen — and then the very rows that + // cancel them become unreachable. + if (pendingAdds.isNotEmpty || pendingRemovals.isNotEmpty) + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: _bannerCap(pane.maxHeight), + ), + child: SingleChildScrollView( + child: Column( + children: [ + for (final item in pendingAdds) ...[ + const SizedBox(height: 8), + _PendingLineRow( + item: item, + tone: PendingTone.add, + onCancel: () => onRevertAdd(map, item.path), + ), + ], + for (final item in pendingRemovals) ...[ + const SizedBox(height: 8), + _PendingLineRow( + item: item, + tone: PendingTone.remove, + onCancel: () => onRemove(map, item.path), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 8), + if (mapIsEmpty) + Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.traderEmptyStock, + style: theme.textTheme.bodyMedium, + ), + ) + else if (items.isEmpty) + // Nothing left to browse — every line this view would show sits in + // the ore card or in the banners above. + const SizedBox.shrink() + else + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < _compactBelow; + final rows = compact ? flat : shown; + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!compact) ...[ + SizedBox( + width: 200, + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + children: [ + for (final group in groups) + SidebarTile( + icon: iconForItemCategory(group.category), + label: l10n.categoryWithCount( + localizedItemCategoryLabel( + l10n, + group.category, + ), + group.items.length, + ), + selected: group.category == selected, + onTap: () => + onSelectCategory(group.category), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 16), + ], + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: rows.length, + itemBuilder: (context, index) => _StockRow( + // The map belongs in the key: the same item exists in + // both, so keying on the path alone reused one row's + // field across a map switch — and with the focused + // guard skipping the sync, the old count stayed on + // screen while keystrokes went to the other map. + key: ValueKey((map, rows[index].path)), + item: rows[index], + map: map, + canSet: canSet, + canRemove: canRemove, + pending: pendingOf(map, rows[index].path), + onChanged: (v) => + onChanged(map, rows[index].path, v), + onRevert: () => onRevert(map, rows[index].path), + onRemove: () => onRemove(map, rows[index].path), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ); + } +} + +/// One category's lines, in [ItemCategory] declaration order. +class _StockGroup { + const _StockGroup({required this.category, required this.items}); + + final ItemCategory category; + final List items; +} + +/// Case-insensitively by the localized name the user reads, with the class id +/// as a stable tiebreak. Shared so the grouped pane and the compact one, which +/// has no sidebar to group by, agree on an order. +int Function(TraderItem, TraderItem) _byDisplayName( + String Function(TraderItem item) displayNameOf, +) => (a, b) { + final byName = displayNameOf( + a, + ).toLowerCase().compareTo(displayNameOf(b).toLowerCase()); + return byName != 0 ? byName : a.id.compareTo(b.id); +}; + +/// Group a stock map the way the inventory groups its own items — same +/// classifier, so a sword lands under Melee weapons in both places, and the same +/// sort: case-insensitively by the localized name the user reads, with the class +/// id as a stable tiebreak. +List<_StockGroup> _grouped( + List items, { + required String Function(TraderItem item) displayNameOf, +}) { + final byCategory = >{}; + for (final item in items) { + byCategory.putIfAbsent(itemCategoryFromId(item.id), () => []).add(item); + } + final compare = _byDisplayName(displayNameOf); + + return [ + for (final category in ItemCategory.values) + if (byCategory.containsKey(category)) + _StockGroup( + category: category, + items: byCategory[category]!..sort(compare), + ), + ]; +} + +/// A queued change, shown above the list rather than inside it. An insertion +/// has no row yet, and a removal's row is about to stop existing — drawing +/// either among the saved lines would claim a state the save does not have. +class _PendingLineRow extends ConsumerWidget { + const _PendingLineRow({ + required this.item, + required this.tone, + required this.onCancel, + }); + + final TraderItem item; + final PendingTone tone; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final lang = ref.watch(currentGameLangProvider); + final locCatalog = ref.watch(locCatalogProvider).value ?? const {}; + final showObjectIds = ref.watch(showObjectIdsProvider); + final isAdd = tone == PendingTone.add; + return PendingStructuralRow( + tone: tone, + icon: isAdd ? Icons.add_circle_outline : Icons.delete_outline, + title: localizedGameName(locCatalog, lang, item.id) ?? item.id, + subtitle: isAdd + ? l10n.pendingAddSubtitle(item.count) + : l10n.pendingRemovalSubtitle, + technicalId: showObjectIds ? item.path : null, + cancelTooltip: isAdd ? l10n.cancelPendingAdd : l10n.cancelPendingRemoval, + onCancel: onCancel, + ); + } +} + +class _StockRow extends ConsumerWidget { + const _StockRow({ + super.key, + required this.item, + required this.map, + required this.canSet, + required this.canRemove, + required this.pending, + required this.onChanged, + required this.onRevert, + required this.onRemove, + }); + + final TraderItem item; + final TraderStockMap map; + final bool canSet; + final bool canRemove; + final int? pending; + final void Function(int) onChanged; + final VoidCallback onRevert; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final lang = ref.watch(currentGameLangProvider); + // `.value` (not `.asData?.value`) so a background refresh keeps the previous + // catalog instead of briefly dropping every row back to its raw class id. + final locCatalog = ref.watch(locCatalogProvider).value ?? const {}; + final label = localizedGameName(locCatalog, lang, item.id) ?? item.id; + final showObjectIds = ref.watch(showObjectIdsProvider); + // The id repeats the title whenever no localized name exists, so drop it + // then rather than printing the same string twice. + final id = showObjectIds && label != item.id ? item.id : null; + final subtitle = [ + ?id, + if (item.unknownItem) l10n.traderUnknownItem, + ].join(' · '); + + final field = _CountField( + value: item.count, + pending: pending, + // An unknown class is shown but never edited: we cannot vouch for what + // the game does with a line it does not recognise. + enabled: canSet && !item.unknownItem, + onChanged: onChanged, + onRevert: onRevert, + ); + final delete = canRemove + ? IconButton( + tooltip: l10n.traderRemoveItem, + icon: const Icon(Icons.delete_outline, size: 20), + onPressed: onRemove, + ) + : null; + final icon = item.isOre + ? Icon(Icons.savings_outlined, color: theme.colorScheme.primary) + : const Icon(Icons.inventory_2_outlined); + final text = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + if (subtitle.isNotEmpty) + Text(subtitle, style: theme.textTheme.bodySmall), + ], + ); + + return LayoutBuilder( + builder: (context, box) { + // A ListTile keeps its trailing at full width, and the field plus the + // delete button want ~180px — more than the whole row gets at the + // smallest supported window. There the value moves under the name. + if (box.maxWidth >= _rowStackBelow) { + return ListTile( + dense: true, + leading: icon, + title: Text(label), + subtitle: subtitle.isEmpty + ? null + : Text(subtitle, style: theme.textTheme.bodySmall), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: 130, child: field), + ?delete, + ], + ), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + icon, + const SizedBox(width: 12), + Expanded(child: text), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: field), + ?delete, + ], + ), + ], + ), + ); + }, + ); + } +} + +/// A count field that shows the saved value until the user changes it, then +/// shows the queued value with a revert affordance. +class _CountField extends StatefulWidget { + const _CountField({ + required this.value, + required this.pending, + required this.enabled, + required this.onChanged, + required this.onRevert, + }); + + final int value; + final int? pending; + final bool enabled; + final void Function(int) onChanged; + final VoidCallback onRevert; + + @override + State<_CountField> createState() => _CountFieldState(); +} + +class _CountFieldState extends State<_CountField> { + /// Shown under the field while the typed value cannot be queued. + String? _error; + + /// Whether the user is in this field. While they are, its text belongs to + /// them: every sync below is a reaction to a change they just made. + final FocusNode _focus = FocusNode(); + + @override + void initState() { + super.initState(); + _focus.addListener(_onFocusChanged); + } + + /// Put the field back in step the moment the user leaves it. + /// + /// While focused the text is theirs and no sync runs, so an emptied or + /// refused entry would otherwise stay on screen afterwards — showing nothing, + /// or a number the save never took, with no pending edit and no revert + /// control to explain it. + /// The field's own undo, which has to put the text back itself. + /// + /// The sync that normally would is deliberately off while the field has + /// focus, so without this the discarded count stayed on screen with nothing + /// queued behind it — and Save disabled — until the user clicked away. + void _undo() { + final saved = '${widget.value}'; + if (_controller.text != saved) _controller.text = saved; + if (_error != null) setState(() => _error = null); + widget.onRevert(); + } + + void _onFocusChanged() { + if (_focus.hasFocus) return; + final shown = '${widget.pending ?? widget.value}'; + if (_controller.text != shown) _controller.text = shown; + if (_error != null) setState(() => _error = null); + } + + late final TextEditingController _controller = TextEditingController( + text: '${widget.pending ?? widget.value}', + ); + + @override + void didUpdateWidget(covariant _CountField oldWidget) { + super.didUpdateWidget(oldWidget); + // Never while the user is typing: backspacing through a queued count clears + // the pending value, and restoring the saved one here put it straight back + // under their cursor — leaving no way to empty the field and start over. + if (_focus.hasFocus) return; + final shown = widget.pending ?? widget.value; + final inputsChanged = + oldWidget.pending != widget.pending || oldWidget.value != widget.value; + // Only overwrite when the field is not the thing that produced the change, + // otherwise typing fights the controller. + if (inputsChanged && '$shown' != _controller.text) { + _controller.text = '$shown'; + } + } + + @override + void dispose() { + _focus.removeListener(_onFocusChanged); + _controller.dispose(); + _focus.dispose(); + super.dispose(); + } + + /// The core stores a count as an `i32`, so anything larger is refused at save + /// time. The add-item dialog already caps at the same value. + static const int _maxCount = 2147483647; // i32::MAX + + /// Queue on every keystroke, the way the inventory's count editor does. + /// + /// Waiting for Enter or a tap outside left a typed amount unregistered: Save + /// stayed disabled while it sat in the field, and a rebuild could overwrite + /// the text before it was ever queued — so the change simply never happened. + /// An invalid entry says so in place and withdraws the queued edit rather + /// than snapping the field back under the user's cursor. + void _onChanged(String raw) { + final l10n = AppLocalizations.of(context); + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + setState(() => _error = null); + widget.onRevert(); + return; + } + final parsed = int.tryParse(trimmed); + if (parsed == null || parsed < 1) { + // Min 1: a sold-out line is deleted, not held at zero. The delete button + // is how a line goes away. + setState(() => _error = l10n.min1); + widget.onRevert(); + return; + } + if (parsed > _maxCount) { + setState(() => _error = l10n.countMustBeAtMost(_maxCount)); + widget.onRevert(); + return; + } + setState(() => _error = null); + if (parsed == widget.value) { + widget.onRevert(); + } else { + widget.onChanged(parsed); + } + } + + @override + Widget build(BuildContext context) { + final dirty = widget.pending != null && widget.pending != widget.value; + return TextField( + controller: _controller, + focusNode: _focus, + enabled: widget.enabled, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.end, + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + errorText: _error, + suffixIcon: dirty + ? IconButton( + icon: const Icon(Icons.undo, size: 16), + onPressed: _undo, + ) + : null, + ), + onChanged: _onChanged, + ); + } +} + +class _Message extends StatelessWidget { + const _Message({ + required this.icon, + required this.title, + required this.body, + this.onRetry, + }); + + final IconData icon; + final String title; + final String body; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 40, color: theme.colorScheme.outline), + const SizedBox(height: 12), + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + body, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + if (onRetry != null) ...[ + const SizedBox(height: 12), + OutlinedButton( + onPressed: onRetry, + child: Text(AppLocalizations.of(context).traderRetry), + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/save-editor/lib/l10n/app_de.arb b/apps/save-editor/lib/l10n/app_de.arb index 614d62b6e..35bf566eb 100644 --- a/apps/save-editor/lib/l10n/app_de.arb +++ b/apps/save-editor/lib/l10n/app_de.arb @@ -131,6 +131,26 @@ "skillNameMagicCircle": "Magischer Kreis", "skillNameOrcish": "Orkisch", "tabInventory": "Inventar", + "tabTrade": "Handel", + "traderNotAMerchant": "Diese Person handelt nicht.", + "traderRetry": "Erneut versuchen", + "traderAmbiguousName": "Mehrere Händlereinträge tragen diesen Namen, deshalb lässt sich nicht sagen, welcher Laden zu dieser Person gehört. Bearbeiten ist gesperrt, statt womöglich den falschen zu ändern.", + "traderOre": "Erz (Kaufkraft)", + "traderNoOre": "kein Erz", + "traderStockCurrent": "Bestand", + "traderStockBase": "Nachschub-Basis", + "traderStockBaseHint": "Worauf der Händler wieder auffüllt. Wächst mit dem Story-Fortschritt, ist also kein Vanilla-Stand.", + "traderOreHint": "Der Wert im Spiel weicht ab: beim Laden rechnet das Spiel dazu, was seit seinem letzten Handel angefallen ist — er verkauft Überschussware und füllt davon auf. Diese Zahl ist der Ausgangswert, nicht der Betrag im Handelsmenü.", + "traderPriceWarning": "Preise reagieren darauf, wie viel ein Händler auf Lager hat und wie viel Erz er besitzt — diese Zahlen zu ändern kann also auch seine Preise verschieben.", + "traderAddItem": "Item hinzufügen", + "traderRemoveItem": "Zeile entfernen", + "traderReadOnlyCore": "Dieser Core kann Händlerdaten nur lesen.", + "traderDifficultyStockUnsupported": "Dieser Händler führt Bestand je Schwierigkeitsgrad, den der Editor nicht abbildet. Bearbeiten ist deshalb gesperrt — eine Änderung sähe erfolgreich aus, ließe diesen zusätzlichen Bestand aber unangetastet.", + "traderRecordIncomplete": "Die Bestandslisten dieses Händlers fehlen oder haben eine Form, die der Editor nicht unterstützt und nicht schreiben kann. Bearbeiten ist deshalb gesperrt, damit eine Änderung nicht erst beim Speichern scheitert.", + "traderEmptyStock": "Nichts auf Lager.", + "traderUnknownItem": "nicht im Item-Katalog", + "editorTradersLoadFailed": "Die Händlerdaten konnten nicht geladen werden: {details}", + "traderStockLineCount": "{count} Zeilen", "tabWorld": "Welt", "tabCharacters": "Charaktere", "characterNoActorBody": "Dieser Charakter hat keinen Akteur in der Welt und daher keine Attribute, kein Inventar und keine Ereignisse.", @@ -690,6 +710,7 @@ "slotRepairAction": "Reparieren", "slotRepairDiscard": "Verwerfen", "editorInventorySlotEditConflict": "Eine direkte Änderung an einem Inventar-Slot ist zusammen mit einer Änderung vorgemerkt, die ganze Slots beansprucht (Reparatur, Hinzufügen oder Entfernen). Die zweite würde die erste überschreiben — eine von beiden zurücknehmen, dann erneut speichern.", + "editorTraderArrayConflict": "Eine Handelsänderung ist zusammen mit einer direkten Änderung am Händler-Array vorgemerkt. Diese nummeriert die Zeilen um, über die eine Handelsänderung adressiert wird — eine von beiden träfe den falschen Händler. Eine zurücknehmen, dann erneut speichern.", "backupFactFile": "Datei", "renameBackupTooltip": "Backup benennen", "renameBackupTitle": "Backup benennen", diff --git a/apps/save-editor/lib/l10n/app_en.arb b/apps/save-editor/lib/l10n/app_en.arb index f994aabcd..7d44f043d 100644 --- a/apps/save-editor/lib/l10n/app_en.arb +++ b/apps/save-editor/lib/l10n/app_en.arb @@ -135,6 +135,28 @@ "skillNameMagicCircle": "Magic Circle", "skillNameOrcish": "Orcish", "tabInventory": "Inventory", + "tabTrade": "Trade", + "traderNotAMerchant": "This character does not trade.", + "traderRetry": "Try again", + "traderAmbiguousName": "More than one trader record carries this name, so the editor cannot tell which shop belongs to this character. Editing is disabled rather than risk changing the wrong one.", + "traderOre": "Ore (purchasing power)", + "traderNoOre": "no ore", + "traderStockCurrent": "Stock", + "traderStockBase": "Restock baseline", + "traderStockBaseHint": "What the merchant restocks back toward. It grows with story progress, so it is not a vanilla snapshot.", + "traderOreHint": "The in-game figure differs: on load the game adds what accrued since his last trade — he sells surplus goods and restocks from it. This number is the starting point, not what the trade screen shows.", + "traderPriceWarning": "Prices react to how much a merchant stocks and how much ore he holds, so changing these numbers can also move what he charges.", + "traderAddItem": "Add item", + "traderRemoveItem": "Remove line", + "traderReadOnlyCore": "This core build can only read trader data.", + "traderDifficultyStockUnsupported": "This merchant carries per-difficulty stock, which the editor does not model. Editing is disabled here, because a change would look successful while leaving that extra stock untouched.", + "traderRecordIncomplete": "This merchant's stock lists are missing, or in a shape the editor does not support and cannot write. Editing is disabled here so a change cannot fail at save time.", + "traderEmptyStock": "Nothing in stock.", + "traderUnknownItem": "not in the item catalog", + "editorTradersLoadFailed": "Trader load failed: {details}", + "@editorTradersLoadFailed": {"placeholders": {"details": {"type": "String"}}}, + "traderStockLineCount": "{count} lines", + "@traderStockLineCount": {"placeholders": {"count": {"type": "int"}}}, "tabWorld": "World", "tabCharacters": "Characters", "characterNoActorBody": "This character has no in-world actor, so it has no attributes, inventory, or events.", @@ -1215,6 +1237,7 @@ "slotRepairAction": "Repair", "slotRepairDiscard": "Discard", "editorInventorySlotEditConflict": "A direct edit of an inventory slot is queued together with a change that claims whole slots (repair, add or remove). The second would overwrite the first — revert one of them, then save again.", + "editorTraderArrayConflict": "A trade change is queued together with a direct edit of the trader array. That edit renumbers the rows a trade change is addressed by, so one of the two would land on the wrong merchant — revert one of them, then save again.", "backupFactFile": "File", "renameBackupTooltip": "Name this backup", "renameBackupTitle": "Name backup", diff --git a/apps/save-editor/lib/l10n/app_es.arb b/apps/save-editor/lib/l10n/app_es.arb index 39e88f995..360db4f5a 100644 --- a/apps/save-editor/lib/l10n/app_es.arb +++ b/apps/save-editor/lib/l10n/app_es.arb @@ -129,6 +129,26 @@ "skillNameMagicCircle": "Círculo mágico", "skillNameOrcish": "Idioma orco", "tabInventory": "Inventario", + "tabTrade": "Comercio", + "traderNotAMerchant": "Este personaje no comercia.", + "traderRetry": "Reintentar", + "traderAmbiguousName": "Más de un registro de mercader lleva este nombre, así que el editor no puede saber qué tienda pertenece a este personaje. La edición está desactivada en vez de arriesgarse a cambiar la equivocada.", + "traderOre": "Mineral (poder de compra)", + "traderNoOre": "sin mineral", + "traderStockCurrent": "Existencias", + "traderStockBase": "Base de reposición", + "traderStockBaseHint": "Aquello a lo que el mercader repone. Crece con el progreso de la historia, así que no es un estado original.", + "traderOreHint": "La cifra en el juego difiere: al cargar, el juego suma lo acumulado desde su último intercambio — vende excedentes y repone con ello. Este número es el punto de partida, no lo que muestra la pantalla de comercio.", + "traderPriceWarning": "Los precios reaccionan a cuánto tiene en existencias un mercader y cuánto mineral posee, así que cambiar estas cifras también puede mover lo que cobra.", + "traderAddItem": "Añadir objeto", + "traderRemoveItem": "Quitar línea", + "traderReadOnlyCore": "Esta versión del núcleo solo puede leer los datos del mercader.", + "traderDifficultyStockUnsupported": "Este mercader tiene existencias por dificultad, que el editor no modela. La edición está desactivada aquí, porque un cambio parecería correcto mientras deja intactas esas existencias adicionales.", + "traderRecordIncomplete": "Las listas de existencias de este mercader faltan, o tienen una forma que el editor no admite ni puede escribir. La edición está desactivada aquí para que un cambio no falle al guardar.", + "traderEmptyStock": "Sin existencias.", + "traderUnknownItem": "no está en el catálogo de objetos", + "editorTradersLoadFailed": "Error al cargar los mercaderes: {details}", + "traderStockLineCount": "{count} líneas", "tabWorld": "Mundo", "tabCharacters": "Personajes", "characterNoActorBody": "Este personaje no tiene un actor en el mundo, por lo que no tiene atributos, inventario ni eventos.", @@ -690,6 +710,7 @@ "slotRepairAction": "Reparar", "slotRepairDiscard": "Descartar", "editorInventorySlotEditConflict": "Hay en cola una edición directa de una ranura de inventario junto con una operación que ocupa ranuras enteras (reparar, añadir o eliminar). La segunda sobrescribiría la primera: revierte una de las dos y vuelve a guardar.", + "editorTraderArrayConflict": "Un cambio de comercio está en cola junto con una edición directa del array de mercaderes. Esa edición renumera las filas por las que se direcciona un cambio de comercio, así que uno de los dos caería en el mercader equivocado — revierte uno y vuelve a guardar.", "backupFactFile": "Archivo", "renameBackupTooltip": "Poner nombre a esta copia", "renameBackupTitle": "Nombrar copia de seguridad", diff --git a/apps/save-editor/lib/l10n/app_fr.arb b/apps/save-editor/lib/l10n/app_fr.arb index 203a820bc..d5154de72 100644 --- a/apps/save-editor/lib/l10n/app_fr.arb +++ b/apps/save-editor/lib/l10n/app_fr.arb @@ -129,6 +129,26 @@ "skillNameMagicCircle": "Cercle de la magie", "skillNameOrcish": "Langue orc", "tabInventory": "Inventaire", + "tabTrade": "Commerce", + "traderNotAMerchant": "Ce personnage ne fait pas de commerce.", + "traderRetry": "Réessayer", + "traderAmbiguousName": "Plusieurs fiches de marchand portent ce nom : impossible de dire quelle boutique appartient à ce personnage. L'édition est désactivée plutôt que de risquer de modifier la mauvaise.", + "traderOre": "Minerai (pouvoir d'achat)", + "traderNoOre": "aucun minerai", + "traderStockCurrent": "Stock", + "traderStockBase": "Base de réapprovisionnement", + "traderStockBaseHint": "Ce vers quoi le marchand se réapprovisionne. Cela augmente avec l'histoire, ce n'est donc pas un état d'origine.", + "traderOreHint": "La valeur en jeu diffère : au chargement, le jeu ajoute ce qui s'est accumulé depuis son dernier échange — il vend ses surplus et se réapprovisionne. Ce nombre est le point de départ, pas ce qu'affiche l'écran de commerce.", + "traderPriceWarning": "Les prix réagissent au stock du marchand et au minerai qu'il détient : modifier ces nombres peut donc aussi changer ses tarifs.", + "traderAddItem": "Ajouter un objet", + "traderRemoveItem": "Retirer la ligne", + "traderReadOnlyCore": "Cette version du cœur ne peut que lire les données des marchands.", + "traderDifficultyStockUnsupported": "Ce marchand possède un stock par difficulté, que l'éditeur ne modélise pas. L'édition est désactivée ici, car une modification semblerait réussie tout en laissant ce stock supplémentaire intact.", + "traderRecordIncomplete": "Les listes de stock de ce marchand sont absentes, ou d'une forme que l'éditeur ne prend pas en charge et ne peut pas écrire. L'édition est désactivée ici pour qu'une modification n'échoue pas à l'enregistrement.", + "traderEmptyStock": "Rien en stock.", + "traderUnknownItem": "absent du catalogue d'objets", + "editorTradersLoadFailed": "Échec du chargement des marchands : {details}", + "traderStockLineCount": "{count} lignes", "tabWorld": "Monde", "tabCharacters": "Personnages", "characterNoActorBody": "Ce personnage n'a pas d'acteur dans le monde ; il n'a donc ni attributs, ni inventaire, ni événements.", @@ -701,6 +721,7 @@ "slotRepairAction": "Réparer", "slotRepairDiscard": "Annuler", "editorInventorySlotEditConflict": "Une modification directe d’un emplacement d’inventaire est en attente en même temps qu’une opération qui s’approprie des emplacements entiers (réparation, ajout ou suppression). La seconde écraserait la première — annulez l’une des deux, puis enregistrez de nouveau.", + "editorTraderArrayConflict": "Une modification de commerce est en attente avec une édition directe du tableau des marchands. Celle-ci renumérote les lignes par lesquelles une modification de commerce est adressée : l'une des deux toucherait le mauvais marchand — annulez-en une, puis enregistrez.", "backupFactFile": "Fichier", "renameBackupTooltip": "Nommer cette sauvegarde", "renameBackupTitle": "Nommer la sauvegarde", diff --git a/apps/save-editor/lib/l10n/app_it.arb b/apps/save-editor/lib/l10n/app_it.arb index a92b6738e..826cdf026 100644 --- a/apps/save-editor/lib/l10n/app_it.arb +++ b/apps/save-editor/lib/l10n/app_it.arb @@ -129,6 +129,26 @@ "skillNameMagicCircle": "Cerchio Magico", "skillNameOrcish": "Orchese", "tabInventory": "Inventario", + "tabTrade": "Commercio", + "traderNotAMerchant": "Questo personaggio non commercia.", + "traderRetry": "Riprova", + "traderAmbiguousName": "Più record di mercante portano questo nome, quindi non si può dire quale negozio appartenga a questo personaggio. La modifica è disattivata invece di rischiare di cambiare quello sbagliato.", + "traderOre": "Minerale (potere d'acquisto)", + "traderNoOre": "nessun minerale", + "traderStockCurrent": "Scorte", + "traderStockBase": "Base di rifornimento", + "traderStockBaseHint": "Ciò verso cui il mercante si rifornisce. Cresce con la storia, quindi non è uno stato originale.", + "traderOreHint": "Il valore nel gioco è diverso: al caricamento il gioco aggiunge quanto maturato dall'ultimo scambio — vende le eccedenze e si rifornisce. Questo numero è il punto di partenza, non quello mostrato nella schermata di commercio.", + "traderPriceWarning": "I prezzi reagiscono a quanto un mercante ha in magazzino e a quanto minerale possiede, quindi cambiare questi numeri può spostare anche quanto chiede.", + "traderAddItem": "Aggiungi oggetto", + "traderRemoveItem": "Rimuovi riga", + "traderReadOnlyCore": "Questa build del core può solo leggere i dati dei mercanti.", + "traderDifficultyStockUnsupported": "Questo mercante ha scorte per difficoltà, che l'editor non modella. La modifica è disattivata qui, perché sembrerebbe riuscita lasciando però intatte quelle scorte aggiuntive.", + "traderRecordIncomplete": "Gli elenchi di scorte di questo mercante mancano, o hanno una forma che l'editor non supporta e non può scrivere. La modifica è disattivata qui perché un cambiamento non fallisca al salvataggio.", + "traderEmptyStock": "Niente in magazzino.", + "traderUnknownItem": "non presente nel catalogo oggetti", + "editorTradersLoadFailed": "Caricamento dei mercanti non riuscito: {details}", + "traderStockLineCount": "{count} righe", "tabWorld": "Mondo", "tabCharacters": "Personaggi", "characterNoActorBody": "Questo personaggio non ha un attore nel mondo, quindi non ha attributi, inventario o eventi.", @@ -690,6 +710,7 @@ "slotRepairAction": "Ripara", "slotRepairDiscard": "Annulla", "editorInventorySlotEditConflict": "Una modifica diretta a uno slot dell’inventario è in coda insieme a un’operazione che occupa slot interi (riparazione, aggiunta o rimozione). La seconda sovrascriverebbe la prima: annullane una, poi salva di nuovo.", + "editorTraderArrayConflict": "Una modifica di commercio è in coda insieme a una modifica diretta dell'array dei mercanti. Quella rinumera le righe con cui una modifica di commercio è indirizzata, quindi una delle due finirebbe sul mercante sbagliato — annullane una e salva di nuovo.", "backupFactFile": "File", "renameBackupTooltip": "Assegna un nome a questo backup", "renameBackupTitle": "Assegna un nome al backup", diff --git a/apps/save-editor/lib/l10n/app_ja.arb b/apps/save-editor/lib/l10n/app_ja.arb index c3f89b7d9..4b5ccc00c 100644 --- a/apps/save-editor/lib/l10n/app_ja.arb +++ b/apps/save-editor/lib/l10n/app_ja.arb @@ -129,6 +129,26 @@ "skillNameMagicCircle": "マジック・サークル", "skillNameOrcish": "オーク語", "tabInventory": "インベントリ", + "tabTrade": "取引", + "traderNotAMerchant": "このキャラクターは取引をしません。", + "traderRetry": "再試行", + "traderAmbiguousName": "同じ名前の商人レコードが複数あるため、どの店がこのキャラクターのものか判別できません。誤って別の店を変更しないよう、編集は無効です。", + "traderOre": "鉱石(購買力)", + "traderNoOre": "鉱石なし", + "traderStockCurrent": "在庫", + "traderStockBase": "補充の基準", + "traderStockBaseHint": "商人が補充する基準。ストーリーの進行とともに増えるため、初期状態ではありません。", + "traderOreHint": "ゲーム内の数値は異なります。読み込み時に、前回の取引以降に生じた分が加算されます(余剰品を売り、その分で補充します)。この数値は開始値であり、取引画面に表示される額ではありません。", + "traderPriceWarning": "価格は商人の在庫量と保有鉱石に反応します。これらの数値を変えると、提示価格も動くことがあります。", + "traderAddItem": "アイテムを追加", + "traderRemoveItem": "行を削除", + "traderReadOnlyCore": "このコアは商人データの読み取りのみ可能です。", + "traderDifficultyStockUnsupported": "この商人は難易度ごとの在庫を持っており、エディタはそれを扱えません。変更は成功したように見えても、その追加在庫はそのまま残るため、ここでの編集は無効です。", + "traderRecordIncomplete": "この商人の在庫リストが存在しないか、エディタが対応しておらず書き込めない形式です。保存時に失敗しないよう、ここでの編集は無効です。", + "traderEmptyStock": "在庫がありません。", + "traderUnknownItem": "アイテムカタログにありません", + "editorTradersLoadFailed": "商人データの読み込みに失敗しました: {details}", + "traderStockLineCount": "{count} 行", "tabWorld": "ワールド", "tabCharacters": "キャラクター", "characterNoActorBody": "このキャラクターはワールド内のアクターを持たないため、属性、インベントリ、イベントはありません。", @@ -697,6 +717,7 @@ "slotRepairAction": "修復", "slotRepairDiscard": "取り消す", "editorInventorySlotEditConflict": "インベントリスロットへの直接編集と、スロットごと扱う操作(修復・追加・削除)が同時に予約されています。後者が前者を上書きします。どちらかを取り消してから保存し直してください。", + "editorTraderArrayConflict": "取引の変更が、商人配列への直接編集と一緒に予約されています。その編集は取引の変更が参照する行番号を振り直すため、どちらかが別の商人に当たります。片方を取り消してから保存してください。", "backupFactFile": "ファイル", "renameBackupTooltip": "このバックアップに名前を付ける", "renameBackupTitle": "バックアップ名", diff --git a/apps/save-editor/lib/l10n/app_localizations.dart b/apps/save-editor/lib/l10n/app_localizations.dart index 1ff30a236..0a6c64b27 100644 --- a/apps/save-editor/lib/l10n/app_localizations.dart +++ b/apps/save-editor/lib/l10n/app_localizations.dart @@ -836,6 +836,126 @@ abstract class AppLocalizations { /// **'Inventory'** String get tabInventory; + /// No description provided for @tabTrade. + /// + /// In en, this message translates to: + /// **'Trade'** + String get tabTrade; + + /// No description provided for @traderNotAMerchant. + /// + /// In en, this message translates to: + /// **'This character does not trade.'** + String get traderNotAMerchant; + + /// No description provided for @traderRetry. + /// + /// In en, this message translates to: + /// **'Try again'** + String get traderRetry; + + /// No description provided for @traderAmbiguousName. + /// + /// In en, this message translates to: + /// **'More than one trader record carries this name, so the editor cannot tell which shop belongs to this character. Editing is disabled rather than risk changing the wrong one.'** + String get traderAmbiguousName; + + /// No description provided for @traderOre. + /// + /// In en, this message translates to: + /// **'Ore (purchasing power)'** + String get traderOre; + + /// No description provided for @traderNoOre. + /// + /// In en, this message translates to: + /// **'no ore'** + String get traderNoOre; + + /// No description provided for @traderStockCurrent. + /// + /// In en, this message translates to: + /// **'Stock'** + String get traderStockCurrent; + + /// No description provided for @traderStockBase. + /// + /// In en, this message translates to: + /// **'Restock baseline'** + String get traderStockBase; + + /// No description provided for @traderStockBaseHint. + /// + /// In en, this message translates to: + /// **'What the merchant restocks back toward. It grows with story progress, so it is not a vanilla snapshot.'** + String get traderStockBaseHint; + + /// No description provided for @traderOreHint. + /// + /// In en, this message translates to: + /// **'The in-game figure differs: on load the game adds what accrued since his last trade — he sells surplus goods and restocks from it. This number is the starting point, not what the trade screen shows.'** + String get traderOreHint; + + /// No description provided for @traderPriceWarning. + /// + /// In en, this message translates to: + /// **'Prices react to how much a merchant stocks and how much ore he holds, so changing these numbers can also move what he charges.'** + String get traderPriceWarning; + + /// No description provided for @traderAddItem. + /// + /// In en, this message translates to: + /// **'Add item'** + String get traderAddItem; + + /// No description provided for @traderRemoveItem. + /// + /// In en, this message translates to: + /// **'Remove line'** + String get traderRemoveItem; + + /// No description provided for @traderReadOnlyCore. + /// + /// In en, this message translates to: + /// **'This core build can only read trader data.'** + String get traderReadOnlyCore; + + /// No description provided for @traderDifficultyStockUnsupported. + /// + /// In en, this message translates to: + /// **'This merchant carries per-difficulty stock, which the editor does not model. Editing is disabled here, because a change would look successful while leaving that extra stock untouched.'** + String get traderDifficultyStockUnsupported; + + /// No description provided for @traderRecordIncomplete. + /// + /// In en, this message translates to: + /// **'This merchant\'s stock lists are missing, or in a shape the editor does not support and cannot write. Editing is disabled here so a change cannot fail at save time.'** + String get traderRecordIncomplete; + + /// No description provided for @traderEmptyStock. + /// + /// In en, this message translates to: + /// **'Nothing in stock.'** + String get traderEmptyStock; + + /// No description provided for @traderUnknownItem. + /// + /// In en, this message translates to: + /// **'not in the item catalog'** + String get traderUnknownItem; + + /// No description provided for @editorTradersLoadFailed. + /// + /// In en, this message translates to: + /// **'Trader load failed: {details}'** + String editorTradersLoadFailed(String details); + + /// No description provided for @traderStockLineCount. + /// + /// In en, this message translates to: + /// **'{count} lines'** + String traderStockLineCount(int count); + /// No description provided for @tabWorld. /// /// In en, this message translates to: @@ -4176,6 +4296,12 @@ abstract class AppLocalizations { /// **'A direct edit of an inventory slot is queued together with a change that claims whole slots (repair, add or remove). The second would overwrite the first — revert one of them, then save again.'** String get editorInventorySlotEditConflict; + /// No description provided for @editorTraderArrayConflict. + /// + /// In en, this message translates to: + /// **'A trade change is queued together with a direct edit of the trader array. That edit renumbers the rows a trade change is addressed by, so one of the two would land on the wrong merchant — revert one of them, then save again.'** + String get editorTraderArrayConflict; + /// No description provided for @backupFactFile. /// /// In en, this message translates to: diff --git a/apps/save-editor/lib/l10n/app_localizations_de.dart b/apps/save-editor/lib/l10n/app_localizations_de.dart index ba2515714..9fde002c5 100644 --- a/apps/save-editor/lib/l10n/app_localizations_de.dart +++ b/apps/save-editor/lib/l10n/app_localizations_de.dart @@ -418,6 +418,76 @@ class AppLocalizationsDe extends AppLocalizations { @override String get tabInventory => 'Inventar'; + @override + String get tabTrade => 'Handel'; + + @override + String get traderNotAMerchant => 'Diese Person handelt nicht.'; + + @override + String get traderRetry => 'Erneut versuchen'; + + @override + String get traderAmbiguousName => + 'Mehrere Händlereinträge tragen diesen Namen, deshalb lässt sich nicht sagen, welcher Laden zu dieser Person gehört. Bearbeiten ist gesperrt, statt womöglich den falschen zu ändern.'; + + @override + String get traderOre => 'Erz (Kaufkraft)'; + + @override + String get traderNoOre => 'kein Erz'; + + @override + String get traderStockCurrent => 'Bestand'; + + @override + String get traderStockBase => 'Nachschub-Basis'; + + @override + String get traderStockBaseHint => + 'Worauf der Händler wieder auffüllt. Wächst mit dem Story-Fortschritt, ist also kein Vanilla-Stand.'; + + @override + String get traderOreHint => + 'Der Wert im Spiel weicht ab: beim Laden rechnet das Spiel dazu, was seit seinem letzten Handel angefallen ist — er verkauft Überschussware und füllt davon auf. Diese Zahl ist der Ausgangswert, nicht der Betrag im Handelsmenü.'; + + @override + String get traderPriceWarning => + 'Preise reagieren darauf, wie viel ein Händler auf Lager hat und wie viel Erz er besitzt — diese Zahlen zu ändern kann also auch seine Preise verschieben.'; + + @override + String get traderAddItem => 'Item hinzufügen'; + + @override + String get traderRemoveItem => 'Zeile entfernen'; + + @override + String get traderReadOnlyCore => 'Dieser Core kann Händlerdaten nur lesen.'; + + @override + String get traderDifficultyStockUnsupported => + 'Dieser Händler führt Bestand je Schwierigkeitsgrad, den der Editor nicht abbildet. Bearbeiten ist deshalb gesperrt — eine Änderung sähe erfolgreich aus, ließe diesen zusätzlichen Bestand aber unangetastet.'; + + @override + String get traderRecordIncomplete => + 'Die Bestandslisten dieses Händlers fehlen oder haben eine Form, die der Editor nicht unterstützt und nicht schreiben kann. Bearbeiten ist deshalb gesperrt, damit eine Änderung nicht erst beim Speichern scheitert.'; + + @override + String get traderEmptyStock => 'Nichts auf Lager.'; + + @override + String get traderUnknownItem => 'nicht im Item-Katalog'; + + @override + String editorTradersLoadFailed(String details) { + return 'Die Händlerdaten konnten nicht geladen werden: $details'; + } + + @override + String traderStockLineCount(int count) { + return '$count Zeilen'; + } + @override String get tabWorld => 'Welt'; @@ -2728,6 +2798,10 @@ class AppLocalizationsDe extends AppLocalizations { String get editorInventorySlotEditConflict => 'Eine direkte Änderung an einem Inventar-Slot ist zusammen mit einer Änderung vorgemerkt, die ganze Slots beansprucht (Reparatur, Hinzufügen oder Entfernen). Die zweite würde die erste überschreiben — eine von beiden zurücknehmen, dann erneut speichern.'; + @override + String get editorTraderArrayConflict => + 'Eine Handelsänderung ist zusammen mit einer direkten Änderung am Händler-Array vorgemerkt. Diese nummeriert die Zeilen um, über die eine Handelsänderung adressiert wird — eine von beiden träfe den falschen Händler. Eine zurücknehmen, dann erneut speichern.'; + @override String get backupFactFile => 'Datei'; diff --git a/apps/save-editor/lib/l10n/app_localizations_en.dart b/apps/save-editor/lib/l10n/app_localizations_en.dart index be2667bb5..3cd26150b 100644 --- a/apps/save-editor/lib/l10n/app_localizations_en.dart +++ b/apps/save-editor/lib/l10n/app_localizations_en.dart @@ -417,6 +417,76 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tabInventory => 'Inventory'; + @override + String get tabTrade => 'Trade'; + + @override + String get traderNotAMerchant => 'This character does not trade.'; + + @override + String get traderRetry => 'Try again'; + + @override + String get traderAmbiguousName => + 'More than one trader record carries this name, so the editor cannot tell which shop belongs to this character. Editing is disabled rather than risk changing the wrong one.'; + + @override + String get traderOre => 'Ore (purchasing power)'; + + @override + String get traderNoOre => 'no ore'; + + @override + String get traderStockCurrent => 'Stock'; + + @override + String get traderStockBase => 'Restock baseline'; + + @override + String get traderStockBaseHint => + 'What the merchant restocks back toward. It grows with story progress, so it is not a vanilla snapshot.'; + + @override + String get traderOreHint => + 'The in-game figure differs: on load the game adds what accrued since his last trade — he sells surplus goods and restocks from it. This number is the starting point, not what the trade screen shows.'; + + @override + String get traderPriceWarning => + 'Prices react to how much a merchant stocks and how much ore he holds, so changing these numbers can also move what he charges.'; + + @override + String get traderAddItem => 'Add item'; + + @override + String get traderRemoveItem => 'Remove line'; + + @override + String get traderReadOnlyCore => 'This core build can only read trader data.'; + + @override + String get traderDifficultyStockUnsupported => + 'This merchant carries per-difficulty stock, which the editor does not model. Editing is disabled here, because a change would look successful while leaving that extra stock untouched.'; + + @override + String get traderRecordIncomplete => + 'This merchant\'s stock lists are missing, or in a shape the editor does not support and cannot write. Editing is disabled here so a change cannot fail at save time.'; + + @override + String get traderEmptyStock => 'Nothing in stock.'; + + @override + String get traderUnknownItem => 'not in the item catalog'; + + @override + String editorTradersLoadFailed(String details) { + return 'Trader load failed: $details'; + } + + @override + String traderStockLineCount(int count) { + return '$count lines'; + } + @override String get tabWorld => 'World'; @@ -2712,6 +2782,10 @@ class AppLocalizationsEn extends AppLocalizations { String get editorInventorySlotEditConflict => 'A direct edit of an inventory slot is queued together with a change that claims whole slots (repair, add or remove). The second would overwrite the first — revert one of them, then save again.'; + @override + String get editorTraderArrayConflict => + 'A trade change is queued together with a direct edit of the trader array. That edit renumbers the rows a trade change is addressed by, so one of the two would land on the wrong merchant — revert one of them, then save again.'; + @override String get backupFactFile => 'File'; diff --git a/apps/save-editor/lib/l10n/app_localizations_es.dart b/apps/save-editor/lib/l10n/app_localizations_es.dart index ba1c0a7f7..f30d1257e 100644 --- a/apps/save-editor/lib/l10n/app_localizations_es.dart +++ b/apps/save-editor/lib/l10n/app_localizations_es.dart @@ -419,6 +419,77 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tabInventory => 'Inventario'; + @override + String get tabTrade => 'Comercio'; + + @override + String get traderNotAMerchant => 'Este personaje no comercia.'; + + @override + String get traderRetry => 'Reintentar'; + + @override + String get traderAmbiguousName => + 'Más de un registro de mercader lleva este nombre, así que el editor no puede saber qué tienda pertenece a este personaje. La edición está desactivada en vez de arriesgarse a cambiar la equivocada.'; + + @override + String get traderOre => 'Mineral (poder de compra)'; + + @override + String get traderNoOre => 'sin mineral'; + + @override + String get traderStockCurrent => 'Existencias'; + + @override + String get traderStockBase => 'Base de reposición'; + + @override + String get traderStockBaseHint => + 'Aquello a lo que el mercader repone. Crece con el progreso de la historia, así que no es un estado original.'; + + @override + String get traderOreHint => + 'La cifra en el juego difiere: al cargar, el juego suma lo acumulado desde su último intercambio — vende excedentes y repone con ello. Este número es el punto de partida, no lo que muestra la pantalla de comercio.'; + + @override + String get traderPriceWarning => + 'Los precios reaccionan a cuánto tiene en existencias un mercader y cuánto mineral posee, así que cambiar estas cifras también puede mover lo que cobra.'; + + @override + String get traderAddItem => 'Añadir objeto'; + + @override + String get traderRemoveItem => 'Quitar línea'; + + @override + String get traderReadOnlyCore => + 'Esta versión del núcleo solo puede leer los datos del mercader.'; + + @override + String get traderDifficultyStockUnsupported => + 'Este mercader tiene existencias por dificultad, que el editor no modela. La edición está desactivada aquí, porque un cambio parecería correcto mientras deja intactas esas existencias adicionales.'; + + @override + String get traderRecordIncomplete => + 'Las listas de existencias de este mercader faltan, o tienen una forma que el editor no admite ni puede escribir. La edición está desactivada aquí para que un cambio no falle al guardar.'; + + @override + String get traderEmptyStock => 'Sin existencias.'; + + @override + String get traderUnknownItem => 'no está en el catálogo de objetos'; + + @override + String editorTradersLoadFailed(String details) { + return 'Error al cargar los mercaderes: $details'; + } + + @override + String traderStockLineCount(int count) { + return '$count líneas'; + } + @override String get tabWorld => 'Mundo'; @@ -2726,6 +2797,10 @@ class AppLocalizationsEs extends AppLocalizations { String get editorInventorySlotEditConflict => 'Hay en cola una edición directa de una ranura de inventario junto con una operación que ocupa ranuras enteras (reparar, añadir o eliminar). La segunda sobrescribiría la primera: revierte una de las dos y vuelve a guardar.'; + @override + String get editorTraderArrayConflict => + 'Un cambio de comercio está en cola junto con una edición directa del array de mercaderes. Esa edición renumera las filas por las que se direcciona un cambio de comercio, así que uno de los dos caería en el mercader equivocado — revierte uno y vuelve a guardar.'; + @override String get backupFactFile => 'Archivo'; diff --git a/apps/save-editor/lib/l10n/app_localizations_fr.dart b/apps/save-editor/lib/l10n/app_localizations_fr.dart index 09d84bdcc..dc05f866d 100644 --- a/apps/save-editor/lib/l10n/app_localizations_fr.dart +++ b/apps/save-editor/lib/l10n/app_localizations_fr.dart @@ -421,6 +421,77 @@ class AppLocalizationsFr extends AppLocalizations { @override String get tabInventory => 'Inventaire'; + @override + String get tabTrade => 'Commerce'; + + @override + String get traderNotAMerchant => 'Ce personnage ne fait pas de commerce.'; + + @override + String get traderRetry => 'Réessayer'; + + @override + String get traderAmbiguousName => + 'Plusieurs fiches de marchand portent ce nom : impossible de dire quelle boutique appartient à ce personnage. L\'édition est désactivée plutôt que de risquer de modifier la mauvaise.'; + + @override + String get traderOre => 'Minerai (pouvoir d\'achat)'; + + @override + String get traderNoOre => 'aucun minerai'; + + @override + String get traderStockCurrent => 'Stock'; + + @override + String get traderStockBase => 'Base de réapprovisionnement'; + + @override + String get traderStockBaseHint => + 'Ce vers quoi le marchand se réapprovisionne. Cela augmente avec l\'histoire, ce n\'est donc pas un état d\'origine.'; + + @override + String get traderOreHint => + 'La valeur en jeu diffère : au chargement, le jeu ajoute ce qui s\'est accumulé depuis son dernier échange — il vend ses surplus et se réapprovisionne. Ce nombre est le point de départ, pas ce qu\'affiche l\'écran de commerce.'; + + @override + String get traderPriceWarning => + 'Les prix réagissent au stock du marchand et au minerai qu\'il détient : modifier ces nombres peut donc aussi changer ses tarifs.'; + + @override + String get traderAddItem => 'Ajouter un objet'; + + @override + String get traderRemoveItem => 'Retirer la ligne'; + + @override + String get traderReadOnlyCore => + 'Cette version du cœur ne peut que lire les données des marchands.'; + + @override + String get traderDifficultyStockUnsupported => + 'Ce marchand possède un stock par difficulté, que l\'éditeur ne modélise pas. L\'édition est désactivée ici, car une modification semblerait réussie tout en laissant ce stock supplémentaire intact.'; + + @override + String get traderRecordIncomplete => + 'Les listes de stock de ce marchand sont absentes, ou d\'une forme que l\'éditeur ne prend pas en charge et ne peut pas écrire. L\'édition est désactivée ici pour qu\'une modification n\'échoue pas à l\'enregistrement.'; + + @override + String get traderEmptyStock => 'Rien en stock.'; + + @override + String get traderUnknownItem => 'absent du catalogue d\'objets'; + + @override + String editorTradersLoadFailed(String details) { + return 'Échec du chargement des marchands : $details'; + } + + @override + String traderStockLineCount(int count) { + return '$count lignes'; + } + @override String get tabWorld => 'Monde'; @@ -2742,6 +2813,10 @@ class AppLocalizationsFr extends AppLocalizations { String get editorInventorySlotEditConflict => 'Une modification directe d’un emplacement d’inventaire est en attente en même temps qu’une opération qui s’approprie des emplacements entiers (réparation, ajout ou suppression). La seconde écraserait la première — annulez l’une des deux, puis enregistrez de nouveau.'; + @override + String get editorTraderArrayConflict => + 'Une modification de commerce est en attente avec une édition directe du tableau des marchands. Celle-ci renumérote les lignes par lesquelles une modification de commerce est adressée : l\'une des deux toucherait le mauvais marchand — annulez-en une, puis enregistrez.'; + @override String get backupFactFile => 'Fichier'; diff --git a/apps/save-editor/lib/l10n/app_localizations_it.dart b/apps/save-editor/lib/l10n/app_localizations_it.dart index 13627f1da..606c34913 100644 --- a/apps/save-editor/lib/l10n/app_localizations_it.dart +++ b/apps/save-editor/lib/l10n/app_localizations_it.dart @@ -419,6 +419,77 @@ class AppLocalizationsIt extends AppLocalizations { @override String get tabInventory => 'Inventario'; + @override + String get tabTrade => 'Commercio'; + + @override + String get traderNotAMerchant => 'Questo personaggio non commercia.'; + + @override + String get traderRetry => 'Riprova'; + + @override + String get traderAmbiguousName => + 'Più record di mercante portano questo nome, quindi non si può dire quale negozio appartenga a questo personaggio. La modifica è disattivata invece di rischiare di cambiare quello sbagliato.'; + + @override + String get traderOre => 'Minerale (potere d\'acquisto)'; + + @override + String get traderNoOre => 'nessun minerale'; + + @override + String get traderStockCurrent => 'Scorte'; + + @override + String get traderStockBase => 'Base di rifornimento'; + + @override + String get traderStockBaseHint => + 'Ciò verso cui il mercante si rifornisce. Cresce con la storia, quindi non è uno stato originale.'; + + @override + String get traderOreHint => + 'Il valore nel gioco è diverso: al caricamento il gioco aggiunge quanto maturato dall\'ultimo scambio — vende le eccedenze e si rifornisce. Questo numero è il punto di partenza, non quello mostrato nella schermata di commercio.'; + + @override + String get traderPriceWarning => + 'I prezzi reagiscono a quanto un mercante ha in magazzino e a quanto minerale possiede, quindi cambiare questi numeri può spostare anche quanto chiede.'; + + @override + String get traderAddItem => 'Aggiungi oggetto'; + + @override + String get traderRemoveItem => 'Rimuovi riga'; + + @override + String get traderReadOnlyCore => + 'Questa build del core può solo leggere i dati dei mercanti.'; + + @override + String get traderDifficultyStockUnsupported => + 'Questo mercante ha scorte per difficoltà, che l\'editor non modella. La modifica è disattivata qui, perché sembrerebbe riuscita lasciando però intatte quelle scorte aggiuntive.'; + + @override + String get traderRecordIncomplete => + 'Gli elenchi di scorte di questo mercante mancano, o hanno una forma che l\'editor non supporta e non può scrivere. La modifica è disattivata qui perché un cambiamento non fallisca al salvataggio.'; + + @override + String get traderEmptyStock => 'Niente in magazzino.'; + + @override + String get traderUnknownItem => 'non presente nel catalogo oggetti'; + + @override + String editorTradersLoadFailed(String details) { + return 'Caricamento dei mercanti non riuscito: $details'; + } + + @override + String traderStockLineCount(int count) { + return '$count righe'; + } + @override String get tabWorld => 'Mondo'; @@ -2732,6 +2803,10 @@ class AppLocalizationsIt extends AppLocalizations { String get editorInventorySlotEditConflict => 'Una modifica diretta a uno slot dell’inventario è in coda insieme a un’operazione che occupa slot interi (riparazione, aggiunta o rimozione). La seconda sovrascriverebbe la prima: annullane una, poi salva di nuovo.'; + @override + String get editorTraderArrayConflict => + 'Una modifica di commercio è in coda insieme a una modifica diretta dell\'array dei mercanti. Quella rinumera le righe con cui una modifica di commercio è indirizzata, quindi una delle due finirebbe sul mercante sbagliato — annullane una e salva di nuovo.'; + @override String get backupFactFile => 'File'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ja.dart b/apps/save-editor/lib/l10n/app_localizations_ja.dart index ef05fe7fb..31b5122bc 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ja.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ja.dart @@ -411,6 +411,75 @@ class AppLocalizationsJa extends AppLocalizations { @override String get tabInventory => 'インベントリ'; + @override + String get tabTrade => '取引'; + + @override + String get traderNotAMerchant => 'このキャラクターは取引をしません。'; + + @override + String get traderRetry => '再試行'; + + @override + String get traderAmbiguousName => + '同じ名前の商人レコードが複数あるため、どの店がこのキャラクターのものか判別できません。誤って別の店を変更しないよう、編集は無効です。'; + + @override + String get traderOre => '鉱石(購買力)'; + + @override + String get traderNoOre => '鉱石なし'; + + @override + String get traderStockCurrent => '在庫'; + + @override + String get traderStockBase => '補充の基準'; + + @override + String get traderStockBaseHint => '商人が補充する基準。ストーリーの進行とともに増えるため、初期状態ではありません。'; + + @override + String get traderOreHint => + 'ゲーム内の数値は異なります。読み込み時に、前回の取引以降に生じた分が加算されます(余剰品を売り、その分で補充します)。この数値は開始値であり、取引画面に表示される額ではありません。'; + + @override + String get traderPriceWarning => + '価格は商人の在庫量と保有鉱石に反応します。これらの数値を変えると、提示価格も動くことがあります。'; + + @override + String get traderAddItem => 'アイテムを追加'; + + @override + String get traderRemoveItem => '行を削除'; + + @override + String get traderReadOnlyCore => 'このコアは商人データの読み取りのみ可能です。'; + + @override + String get traderDifficultyStockUnsupported => + 'この商人は難易度ごとの在庫を持っており、エディタはそれを扱えません。変更は成功したように見えても、その追加在庫はそのまま残るため、ここでの編集は無効です。'; + + @override + String get traderRecordIncomplete => + 'この商人の在庫リストが存在しないか、エディタが対応しておらず書き込めない形式です。保存時に失敗しないよう、ここでの編集は無効です。'; + + @override + String get traderEmptyStock => '在庫がありません。'; + + @override + String get traderUnknownItem => 'アイテムカタログにありません'; + + @override + String editorTradersLoadFailed(String details) { + return '商人データの読み込みに失敗しました: $details'; + } + + @override + String traderStockLineCount(int count) { + return '$count 行'; + } + @override String get tabWorld => 'ワールド'; @@ -2662,6 +2731,10 @@ class AppLocalizationsJa extends AppLocalizations { String get editorInventorySlotEditConflict => 'インベントリスロットへの直接編集と、スロットごと扱う操作(修復・追加・削除)が同時に予約されています。後者が前者を上書きします。どちらかを取り消してから保存し直してください。'; + @override + String get editorTraderArrayConflict => + '取引の変更が、商人配列への直接編集と一緒に予約されています。その編集は取引の変更が参照する行番号を振り直すため、どちらかが別の商人に当たります。片方を取り消してから保存してください。'; + @override String get backupFactFile => 'ファイル'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pl.dart b/apps/save-editor/lib/l10n/app_localizations_pl.dart index 155b846a8..c7b301126 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pl.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pl.dart @@ -420,6 +420,77 @@ class AppLocalizationsPl extends AppLocalizations { @override String get tabInventory => 'Ekwipunek'; + @override + String get tabTrade => 'Handel'; + + @override + String get traderNotAMerchant => 'Ta postać nie handluje.'; + + @override + String get traderRetry => 'Spróbuj ponownie'; + + @override + String get traderAmbiguousName => + 'Kilka rekordów kupca nosi tę nazwę, więc nie da się ustalić, który sklep należy do tej postaci. Edycja jest wyłączona, zamiast ryzykować zmianę niewłaściwego.'; + + @override + String get traderOre => 'Ruda (siła nabywcza)'; + + @override + String get traderNoOre => 'brak rudy'; + + @override + String get traderStockCurrent => 'Zapas'; + + @override + String get traderStockBase => 'Baza uzupełniania'; + + @override + String get traderStockBaseHint => + 'To, do czego kupiec uzupełnia zapasy. Rośnie wraz z fabułą, więc nie jest stanem pierwotnym.'; + + @override + String get traderOreHint => + 'Wartość w grze się różni: przy wczytaniu gra dolicza to, co narosło od jego ostatniego handlu — sprzedaje nadwyżki i z tego uzupełnia zapasy. Ta liczba to punkt wyjścia, a nie kwota z ekranu handlu.'; + + @override + String get traderPriceWarning => + 'Ceny reagują na to, ile kupiec ma na stanie i ile ma rudy, więc zmiana tych liczb może też zmienić jego stawki.'; + + @override + String get traderAddItem => 'Dodaj przedmiot'; + + @override + String get traderRemoveItem => 'Usuń pozycję'; + + @override + String get traderReadOnlyCore => + 'Ta wersja rdzenia może tylko odczytywać dane kupców.'; + + @override + String get traderDifficultyStockUnsupported => + 'Ten kupiec ma zapasy zależne od poziomu trudności, których edytor nie odwzorowuje. Edycja jest tu wyłączona, bo zmiana wyglądałaby na udaną, zostawiając te dodatkowe zapasy nietknięte.'; + + @override + String get traderRecordIncomplete => + 'Listy zapasów tego kupca nie istnieją albo mają postać, której edytor nie obsługuje i nie potrafi zapisać. Edycja jest wyłączona, aby zmiana nie zawiodła przy zapisie.'; + + @override + String get traderEmptyStock => 'Brak zapasów.'; + + @override + String get traderUnknownItem => 'brak w katalogu przedmiotów'; + + @override + String editorTradersLoadFailed(String details) { + return 'Nie udało się wczytać kupców: $details'; + } + + @override + String traderStockLineCount(int count) { + return '$count pozycji'; + } + @override String get tabWorld => 'Świat'; @@ -2744,6 +2815,10 @@ class AppLocalizationsPl extends AppLocalizations { String get editorInventorySlotEditConflict => 'W kolejce jest bezpośrednia zmiana slotu ekwipunku razem z operacją zajmującą całe sloty (naprawa, dodanie lub usunięcie). Druga nadpisałaby pierwszą — cofnij jedną z nich i zapisz ponownie.'; + @override + String get editorTraderArrayConflict => + 'Zmiana handlu jest w kolejce razem z bezpośrednią edycją tablicy kupców. Ta edycja przenumerowuje wiersze, po których adresowana jest zmiana handlu, więc jedna z nich trafiłaby w niewłaściwego kupca — cofnij jedną i zapisz ponownie.'; + @override String get backupFactFile => 'Plik'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pt.dart b/apps/save-editor/lib/l10n/app_localizations_pt.dart index e156d5a2d..4a726d880 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pt.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pt.dart @@ -419,6 +419,77 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tabInventory => 'Inventário'; + @override + String get tabTrade => 'Comércio'; + + @override + String get traderNotAMerchant => 'Esta personagem não comercia.'; + + @override + String get traderRetry => 'Tentar novamente'; + + @override + String get traderAmbiguousName => + 'Mais do que um registo de mercador tem este nome, por isso não é possível saber que loja pertence a esta personagem. A edição está desativada em vez de arriscar mudar a errada.'; + + @override + String get traderOre => 'Minério (poder de compra)'; + + @override + String get traderNoOre => 'sem minério'; + + @override + String get traderStockCurrent => 'Estoque'; + + @override + String get traderStockBase => 'Base de reposição'; + + @override + String get traderStockBaseHint => + 'Aquilo que o mercador repõe. Cresce com o progresso da história, por isso não é um estado original.'; + + @override + String get traderOreHint => + 'O valor no jogo difere: ao carregar, o jogo soma o que se acumulou desde a última troca — ele vende excedentes e repõe com isso. Este número é o ponto de partida, não o que o ecrã de comércio mostra.'; + + @override + String get traderPriceWarning => + 'Os preços reagem ao que o mercador tem em estoque e ao minério que possui, por isso mudar estes números também pode alterar o que ele cobra.'; + + @override + String get traderAddItem => 'Adicionar item'; + + @override + String get traderRemoveItem => 'Remover linha'; + + @override + String get traderReadOnlyCore => + 'Esta versão do núcleo só consegue ler os dados dos mercadores.'; + + @override + String get traderDifficultyStockUnsupported => + 'Este mercador tem existências por dificuldade, que o editor não modela. A edição está desativada aqui, porque uma alteração pareceria bem-sucedida deixando essas existências intactas.'; + + @override + String get traderRecordIncomplete => + 'As listas de existências deste mercador não existem, ou têm uma forma que o editor não suporta nem consegue gravar. A edição está desativada aqui para que uma alteração não falhe ao gravar.'; + + @override + String get traderEmptyStock => 'Nada em estoque.'; + + @override + String get traderUnknownItem => 'não está no catálogo de itens'; + + @override + String editorTradersLoadFailed(String details) { + return 'Falha ao carregar mercadores: $details'; + } + + @override + String traderStockLineCount(int count) { + return '$count linhas'; + } + @override String get tabWorld => 'Mundo'; @@ -2727,6 +2798,10 @@ class AppLocalizationsPt extends AppLocalizations { String get editorInventorySlotEditConflict => 'Uma edição direta de um espaço de inventário está na fila junto com uma operação que ocupa espaços inteiros (reparo, adição ou remoção). A segunda sobrescreveria a primeira — reverta uma delas e salve novamente.'; + @override + String get editorTraderArrayConflict => + 'Uma alteração de comércio está em fila junto com uma edição direta da matriz de mercadores. Essa edição renumera as linhas pelas quais uma alteração de comércio é endereçada, por isso uma das duas cairia no mercador errado — reverte uma e grava de novo.'; + @override String get backupFactFile => 'Arquivo'; @@ -3202,6 +3277,77 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get tabInventory => 'Inventário'; + @override + String get tabTrade => 'Comércio'; + + @override + String get traderNotAMerchant => 'Este personagem não comercia.'; + + @override + String get traderRetry => 'Tentar novamente'; + + @override + String get traderAmbiguousName => + 'Mais de um registro de mercador tem este nome, por isso não é possível saber qual loja pertence a este personagem. A edição está desativada em vez de arriscar mudar a errada.'; + + @override + String get traderOre => 'Minério (poder de compra)'; + + @override + String get traderNoOre => 'sem minério'; + + @override + String get traderStockCurrent => 'Estoque'; + + @override + String get traderStockBase => 'Base de reposição'; + + @override + String get traderStockBaseHint => + 'Aquilo que o mercador repõe. Cresce com o progresso da história, por isso não é um estado original.'; + + @override + String get traderOreHint => + 'O valor no jogo difere: ao carregar, o jogo soma o que se acumulou desde a última troca — ele vende excedentes e repõe com isso. Este número é o ponto de partida, não o que a tela de comércio mostra.'; + + @override + String get traderPriceWarning => + 'Os preços reagem ao que o mercador tem em estoque e ao minério que possui, por isso mudar esses números também pode alterar o que ele cobra.'; + + @override + String get traderAddItem => 'Adicionar item'; + + @override + String get traderRemoveItem => 'Remover linha'; + + @override + String get traderReadOnlyCore => + 'Esta versão do núcleo só consegue ler os dados dos mercadores.'; + + @override + String get traderDifficultyStockUnsupported => + 'Este mercador tem estoque por dificuldade, que o editor não modela. A edição está desativada aqui, porque uma alteração pareceria bem-sucedida deixando esse estoque intacto.'; + + @override + String get traderRecordIncomplete => + 'As listas de estoque deste mercador não existem, ou têm uma forma que o editor não suporta nem consegue gravar. A edição está desativada aqui para que uma alteração não falhe ao salvar.'; + + @override + String get traderEmptyStock => 'Nada em estoque.'; + + @override + String get traderUnknownItem => 'não está no catálogo de itens'; + + @override + String editorTradersLoadFailed(String details) { + return 'Falha ao carregar mercadores: $details'; + } + + @override + String traderStockLineCount(int count) { + return '$count linhas'; + } + @override String get tabWorld => 'Mundo'; @@ -5509,6 +5655,10 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { String get editorInventorySlotEditConflict => 'Uma edição direta de um espaço de inventário está na fila junto com uma operação que ocupa espaços inteiros (reparo, adição ou remoção). A segunda sobrescreveria a primeira — reverta uma delas e salve novamente.'; + @override + String get editorTraderArrayConflict => + 'Uma alteração de comércio está na fila junto com uma edição direta do array de mercadores. Essa edição renumera as linhas pelas quais uma alteração de comércio é endereçada, então uma das duas cairia no mercador errado — reverta uma e salve de novo.'; + @override String get backupFactFile => 'Arquivo'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ru.dart b/apps/save-editor/lib/l10n/app_localizations_ru.dart index e82006e75..e5661fe10 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ru.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ru.dart @@ -421,6 +421,77 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tabInventory => 'Инвентарь'; + @override + String get tabTrade => 'Торговля'; + + @override + String get traderNotAMerchant => 'Этот персонаж не торгует.'; + + @override + String get traderRetry => 'Повторить'; + + @override + String get traderAmbiguousName => + 'Это имя носит несколько записей торговцев, поэтому нельзя определить, чья это лавка. Правка отключена, чтобы не изменить чужую.'; + + @override + String get traderOre => 'Руда (покупательная способность)'; + + @override + String get traderNoOre => 'нет руды'; + + @override + String get traderStockCurrent => 'Запас'; + + @override + String get traderStockBase => 'База пополнения'; + + @override + String get traderStockBaseHint => + 'То, к чему торговец пополняет запасы. Растёт по ходу сюжета, поэтому это не исходное состояние.'; + + @override + String get traderOreHint => + 'В игре число другое: при загрузке игра добавляет накопившееся с его последней торговли — он продаёт излишки и пополняет запасы. Это число — отправная точка, а не сумма в окне торговли.'; + + @override + String get traderPriceWarning => + 'Цены зависят от того, сколько у торговца товара и руды, поэтому изменение этих чисел может сдвинуть и его расценки.'; + + @override + String get traderAddItem => 'Добавить предмет'; + + @override + String get traderRemoveItem => 'Удалить строку'; + + @override + String get traderReadOnlyCore => + 'Эта сборка ядра может только читать данные торговцев.'; + + @override + String get traderDifficultyStockUnsupported => + 'У этого торговца есть запас по уровню сложности, который редактор не моделирует. Правка здесь отключена: изменение выглядело бы успешным, но этот дополнительный запас остался бы нетронутым.'; + + @override + String get traderRecordIncomplete => + 'Списков товара этого торговца нет, либо они в форме, которую редактор не поддерживает и не может записать. Правка отключена, чтобы изменение не сорвалось при сохранении.'; + + @override + String get traderEmptyStock => 'Товара нет.'; + + @override + String get traderUnknownItem => 'нет в каталоге предметов'; + + @override + String editorTradersLoadFailed(String details) { + return 'Не удалось загрузить торговцев: $details'; + } + + @override + String traderStockLineCount(int count) { + return '$count строк'; + } + @override String get tabWorld => 'Мир'; @@ -2737,6 +2808,10 @@ class AppLocalizationsRu extends AppLocalizations { String get editorInventorySlotEditConflict => 'В очереди одновременно прямое изменение слота инвентаря и операция, занимающая слоты целиком (восстановление, добавление или удаление). Вторая перезапишет первую — отмените одно из них и сохраните снова.'; + @override + String get editorTraderArrayConflict => + 'Изменение торговли стоит в очереди вместе с прямой правкой массива торговцев. Она перенумеровывает строки, по которым адресуется изменение торговли, поэтому одно из двух попадёт не в того торговца — отмените одно и сохраните снова.'; + @override String get backupFactFile => 'Файл'; diff --git a/apps/save-editor/lib/l10n/app_localizations_zh.dart b/apps/save-editor/lib/l10n/app_localizations_zh.dart index 920d73a90..4b915a997 100644 --- a/apps/save-editor/lib/l10n/app_localizations_zh.dart +++ b/apps/save-editor/lib/l10n/app_localizations_zh.dart @@ -406,6 +406,73 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tabInventory => '物品栏'; + @override + String get tabTrade => '交易'; + + @override + String get traderNotAMerchant => '该角色不进行交易。'; + + @override + String get traderRetry => '重试'; + + @override + String get traderAmbiguousName => '有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。'; + + @override + String get traderOre => '矿石(购买力)'; + + @override + String get traderNoOre => '无矿石'; + + @override + String get traderStockCurrent => '库存'; + + @override + String get traderStockBase => '补货基准'; + + @override + String get traderStockBaseHint => '商人补货的基准。会随剧情推进而增长,因此不是初始状态。'; + + @override + String get traderOreHint => + '游戏内的数值会不同:载入时游戏会加上自他上次交易以来累积的部分——他会卖掉多余货物并以此补货。这个数字是起点,而非交易界面显示的金额。'; + + @override + String get traderPriceWarning => '价格会随商人的库存量和持有矿石而变化,因此修改这些数字也可能改变他的开价。'; + + @override + String get traderAddItem => '添加物品'; + + @override + String get traderRemoveItem => '移除条目'; + + @override + String get traderReadOnlyCore => '此核心版本只能读取商人数据。'; + + @override + String get traderDifficultyStockUnsupported => + '该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。'; + + @override + String get traderRecordIncomplete => + '该商人的库存清单缺失,或其结构编辑器不支持、无法写入。此处已禁用编辑,以免修改在保存时失败。'; + + @override + String get traderEmptyStock => '没有库存。'; + + @override + String get traderUnknownItem => '不在物品目录中'; + + @override + String editorTradersLoadFailed(String details) { + return '商人数据加载失败:$details'; + } + + @override + String traderStockLineCount(int count) { + return '$count 条'; + } + @override String get tabWorld => '世界'; @@ -2631,6 +2698,10 @@ class AppLocalizationsZh extends AppLocalizations { String get editorInventorySlotEditConflict => '对物品栏槽位的直接编辑与占用整个槽位的操作(修复、添加或删除)同时在待保存列表中。后者会覆盖前者 — 请撤销其中一项后再保存。'; + @override + String get editorTraderArrayConflict => + '一项交易修改与对商人数组的直接编辑一同排队。该编辑会重新编号交易修改所依据的行,因此两者之一会落到错误的商人身上——撤销其中一项后再保存。'; + @override String get backupFactFile => '文件'; @@ -3091,6 +3162,73 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get tabInventory => '物品栏'; + @override + String get tabTrade => '交易'; + + @override + String get traderNotAMerchant => '该角色不进行交易。'; + + @override + String get traderRetry => '重试'; + + @override + String get traderAmbiguousName => '有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。'; + + @override + String get traderOre => '矿石(购买力)'; + + @override + String get traderNoOre => '无矿石'; + + @override + String get traderStockCurrent => '库存'; + + @override + String get traderStockBase => '补货基准'; + + @override + String get traderStockBaseHint => '商人补货的基准。会随剧情推进而增长,因此不是初始状态。'; + + @override + String get traderOreHint => + '游戏内的数值会不同:载入时游戏会加上自他上次交易以来累积的部分——他会卖掉多余货物并以此补货。这个数字是起点,而非交易界面显示的金额。'; + + @override + String get traderPriceWarning => '价格会随商人的库存量和持有矿石而变化,因此修改这些数字也可能改变他的开价。'; + + @override + String get traderAddItem => '添加物品'; + + @override + String get traderRemoveItem => '移除条目'; + + @override + String get traderReadOnlyCore => '此核心版本只能读取商人数据。'; + + @override + String get traderDifficultyStockUnsupported => + '该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。'; + + @override + String get traderRecordIncomplete => + '该商人的库存清单缺失,或其结构编辑器不支持、无法写入。此处已禁用编辑,以免修改在保存时失败。'; + + @override + String get traderEmptyStock => '没有库存。'; + + @override + String get traderUnknownItem => '不在物品目录中'; + + @override + String editorTradersLoadFailed(String details) { + return '商人数据加载失败:$details'; + } + + @override + String traderStockLineCount(int count) { + return '$count 条'; + } + @override String get tabWorld => '世界'; @@ -5316,6 +5454,10 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get editorInventorySlotEditConflict => '对物品栏槽位的直接编辑与占用整个槽位的操作(修复、添加或删除)同时在待保存列表中。后者会覆盖前者 — 请撤销其中一项后再保存。'; + @override + String get editorTraderArrayConflict => + '一项交易修改与对商人数组的直接编辑一同排队。该编辑会重新编号交易修改所依据的行,因此两者之一会落到错误的商人身上——撤销其中一项后再保存。'; + @override String get backupFactFile => '文件'; diff --git a/apps/save-editor/lib/l10n/app_pl.arb b/apps/save-editor/lib/l10n/app_pl.arb index 3a1cd9977..5ec055126 100644 --- a/apps/save-editor/lib/l10n/app_pl.arb +++ b/apps/save-editor/lib/l10n/app_pl.arb @@ -129,6 +129,26 @@ "skillNameMagicCircle": "Krąg magiczny", "skillNameOrcish": "Język orkowy", "tabInventory": "Ekwipunek", + "tabTrade": "Handel", + "traderNotAMerchant": "Ta postać nie handluje.", + "traderRetry": "Spróbuj ponownie", + "traderAmbiguousName": "Kilka rekordów kupca nosi tę nazwę, więc nie da się ustalić, który sklep należy do tej postaci. Edycja jest wyłączona, zamiast ryzykować zmianę niewłaściwego.", + "traderOre": "Ruda (siła nabywcza)", + "traderNoOre": "brak rudy", + "traderStockCurrent": "Zapas", + "traderStockBase": "Baza uzupełniania", + "traderStockBaseHint": "To, do czego kupiec uzupełnia zapasy. Rośnie wraz z fabułą, więc nie jest stanem pierwotnym.", + "traderOreHint": "Wartość w grze się różni: przy wczytaniu gra dolicza to, co narosło od jego ostatniego handlu — sprzedaje nadwyżki i z tego uzupełnia zapasy. Ta liczba to punkt wyjścia, a nie kwota z ekranu handlu.", + "traderPriceWarning": "Ceny reagują na to, ile kupiec ma na stanie i ile ma rudy, więc zmiana tych liczb może też zmienić jego stawki.", + "traderAddItem": "Dodaj przedmiot", + "traderRemoveItem": "Usuń pozycję", + "traderReadOnlyCore": "Ta wersja rdzenia może tylko odczytywać dane kupców.", + "traderDifficultyStockUnsupported": "Ten kupiec ma zapasy zależne od poziomu trudności, których edytor nie odwzorowuje. Edycja jest tu wyłączona, bo zmiana wyglądałaby na udaną, zostawiając te dodatkowe zapasy nietknięte.", + "traderRecordIncomplete": "Listy zapasów tego kupca nie istnieją albo mają postać, której edytor nie obsługuje i nie potrafi zapisać. Edycja jest wyłączona, aby zmiana nie zawiodła przy zapisie.", + "traderEmptyStock": "Brak zapasów.", + "traderUnknownItem": "brak w katalogu przedmiotów", + "editorTradersLoadFailed": "Nie udało się wczytać kupców: {details}", + "traderStockLineCount": "{count} pozycji", "tabWorld": "Świat", "tabCharacters": "Postacie", "characterNoActorBody": "Ta postać nie ma aktora w świecie, więc nie ma atrybutów, ekwipunku ani zdarzeń.", @@ -690,6 +710,7 @@ "slotRepairAction": "Napraw", "slotRepairDiscard": "Odrzuć", "editorInventorySlotEditConflict": "W kolejce jest bezpośrednia zmiana slotu ekwipunku razem z operacją zajmującą całe sloty (naprawa, dodanie lub usunięcie). Druga nadpisałaby pierwszą — cofnij jedną z nich i zapisz ponownie.", + "editorTraderArrayConflict": "Zmiana handlu jest w kolejce razem z bezpośrednią edycją tablicy kupców. Ta edycja przenumerowuje wiersze, po których adresowana jest zmiana handlu, więc jedna z nich trafiłaby w niewłaściwego kupca — cofnij jedną i zapisz ponownie.", "backupFactFile": "Plik", "renameBackupTooltip": "Nazwij tę kopię", "renameBackupTitle": "Nazwa kopii zapasowej", diff --git a/apps/save-editor/lib/l10n/app_pt.arb b/apps/save-editor/lib/l10n/app_pt.arb index 99ceaaf9f..04fd4a835 100644 --- a/apps/save-editor/lib/l10n/app_pt.arb +++ b/apps/save-editor/lib/l10n/app_pt.arb @@ -129,6 +129,26 @@ "skillNameMagicCircle": "Círculo de Magia", "skillNameOrcish": "Língua dos Orcs", "tabInventory": "Inventário", + "tabTrade": "Comércio", + "traderNotAMerchant": "Esta personagem não comercia.", + "traderRetry": "Tentar novamente", + "traderAmbiguousName": "Mais do que um registo de mercador tem este nome, por isso não é possível saber que loja pertence a esta personagem. A edição está desativada em vez de arriscar mudar a errada.", + "traderOre": "Minério (poder de compra)", + "traderNoOre": "sem minério", + "traderStockCurrent": "Estoque", + "traderStockBase": "Base de reposição", + "traderStockBaseHint": "Aquilo que o mercador repõe. Cresce com o progresso da história, por isso não é um estado original.", + "traderOreHint": "O valor no jogo difere: ao carregar, o jogo soma o que se acumulou desde a última troca — ele vende excedentes e repõe com isso. Este número é o ponto de partida, não o que o ecrã de comércio mostra.", + "traderPriceWarning": "Os preços reagem ao que o mercador tem em estoque e ao minério que possui, por isso mudar estes números também pode alterar o que ele cobra.", + "traderAddItem": "Adicionar item", + "traderRemoveItem": "Remover linha", + "traderReadOnlyCore": "Esta versão do núcleo só consegue ler os dados dos mercadores.", + "traderDifficultyStockUnsupported": "Este mercador tem existências por dificuldade, que o editor não modela. A edição está desativada aqui, porque uma alteração pareceria bem-sucedida deixando essas existências intactas.", + "traderRecordIncomplete": "As listas de existências deste mercador não existem, ou têm uma forma que o editor não suporta nem consegue gravar. A edição está desativada aqui para que uma alteração não falhe ao gravar.", + "traderEmptyStock": "Nada em estoque.", + "traderUnknownItem": "não está no catálogo de itens", + "editorTradersLoadFailed": "Falha ao carregar mercadores: {details}", + "traderStockLineCount": "{count} linhas", "tabWorld": "Mundo", "tabCharacters": "Personagens", "characterNoActorBody": "Este personagem não tem um ator no mundo, portanto não tem atributos, inventário ou eventos.", @@ -690,6 +710,7 @@ "slotRepairAction": "Reparar", "slotRepairDiscard": "Descartar", "editorInventorySlotEditConflict": "Uma edição direta de um espaço de inventário está na fila junto com uma operação que ocupa espaços inteiros (reparo, adição ou remoção). A segunda sobrescreveria a primeira — reverta uma delas e salve novamente.", + "editorTraderArrayConflict": "Uma alteração de comércio está em fila junto com uma edição direta da matriz de mercadores. Essa edição renumera as linhas pelas quais uma alteração de comércio é endereçada, por isso uma das duas cairia no mercador errado — reverte uma e grava de novo.", "backupFactFile": "Arquivo", "renameBackupTooltip": "Nomear este backup", "renameBackupTitle": "Nomear backup", diff --git a/apps/save-editor/lib/l10n/app_pt_BR.arb b/apps/save-editor/lib/l10n/app_pt_BR.arb index 44b19523e..89a2b3098 100644 --- a/apps/save-editor/lib/l10n/app_pt_BR.arb +++ b/apps/save-editor/lib/l10n/app_pt_BR.arb @@ -129,6 +129,26 @@ "skillNameMagicCircle": "Círculo de Magia", "skillNameOrcish": "Língua dos Orcs", "tabInventory": "Inventário", + "tabTrade": "Comércio", + "traderNotAMerchant": "Este personagem não comercia.", + "traderRetry": "Tentar novamente", + "traderAmbiguousName": "Mais de um registro de mercador tem este nome, por isso não é possível saber qual loja pertence a este personagem. A edição está desativada em vez de arriscar mudar a errada.", + "traderOre": "Minério (poder de compra)", + "traderNoOre": "sem minério", + "traderStockCurrent": "Estoque", + "traderStockBase": "Base de reposição", + "traderStockBaseHint": "Aquilo que o mercador repõe. Cresce com o progresso da história, por isso não é um estado original.", + "traderOreHint": "O valor no jogo difere: ao carregar, o jogo soma o que se acumulou desde a última troca — ele vende excedentes e repõe com isso. Este número é o ponto de partida, não o que a tela de comércio mostra.", + "traderPriceWarning": "Os preços reagem ao que o mercador tem em estoque e ao minério que possui, por isso mudar esses números também pode alterar o que ele cobra.", + "traderAddItem": "Adicionar item", + "traderRemoveItem": "Remover linha", + "traderReadOnlyCore": "Esta versão do núcleo só consegue ler os dados dos mercadores.", + "traderDifficultyStockUnsupported": "Este mercador tem estoque por dificuldade, que o editor não modela. A edição está desativada aqui, porque uma alteração pareceria bem-sucedida deixando esse estoque intacto.", + "traderRecordIncomplete": "As listas de estoque deste mercador não existem, ou têm uma forma que o editor não suporta nem consegue gravar. A edição está desativada aqui para que uma alteração não falhe ao salvar.", + "traderEmptyStock": "Nada em estoque.", + "traderUnknownItem": "não está no catálogo de itens", + "editorTradersLoadFailed": "Falha ao carregar mercadores: {details}", + "traderStockLineCount": "{count} linhas", "tabWorld": "Mundo", "tabCharacters": "Personagens", "characterNoActorBody": "Este personagem não tem um ator no mundo, portanto não tem atributos, inventário ou eventos.", @@ -690,6 +710,7 @@ "slotRepairAction": "Reparar", "slotRepairDiscard": "Descartar", "editorInventorySlotEditConflict": "Uma edição direta de um espaço de inventário está na fila junto com uma operação que ocupa espaços inteiros (reparo, adição ou remoção). A segunda sobrescreveria a primeira — reverta uma delas e salve novamente.", + "editorTraderArrayConflict": "Uma alteração de comércio está na fila junto com uma edição direta do array de mercadores. Essa edição renumera as linhas pelas quais uma alteração de comércio é endereçada, então uma das duas cairia no mercador errado — reverta uma e salve de novo.", "backupFactFile": "Arquivo", "renameBackupTooltip": "Nomear este backup", "renameBackupTitle": "Nomear backup", diff --git a/apps/save-editor/lib/l10n/app_ru.arb b/apps/save-editor/lib/l10n/app_ru.arb index 293a35d7b..76a9df8fb 100644 --- a/apps/save-editor/lib/l10n/app_ru.arb +++ b/apps/save-editor/lib/l10n/app_ru.arb @@ -129,6 +129,26 @@ "skillNameMagicCircle": "Круг магии", "skillNameOrcish": "Орочий язык", "tabInventory": "Инвентарь", + "tabTrade": "Торговля", + "traderNotAMerchant": "Этот персонаж не торгует.", + "traderRetry": "Повторить", + "traderAmbiguousName": "Это имя носит несколько записей торговцев, поэтому нельзя определить, чья это лавка. Правка отключена, чтобы не изменить чужую.", + "traderOre": "Руда (покупательная способность)", + "traderNoOre": "нет руды", + "traderStockCurrent": "Запас", + "traderStockBase": "База пополнения", + "traderStockBaseHint": "То, к чему торговец пополняет запасы. Растёт по ходу сюжета, поэтому это не исходное состояние.", + "traderOreHint": "В игре число другое: при загрузке игра добавляет накопившееся с его последней торговли — он продаёт излишки и пополняет запасы. Это число — отправная точка, а не сумма в окне торговли.", + "traderPriceWarning": "Цены зависят от того, сколько у торговца товара и руды, поэтому изменение этих чисел может сдвинуть и его расценки.", + "traderAddItem": "Добавить предмет", + "traderRemoveItem": "Удалить строку", + "traderReadOnlyCore": "Эта сборка ядра может только читать данные торговцев.", + "traderDifficultyStockUnsupported": "У этого торговца есть запас по уровню сложности, который редактор не моделирует. Правка здесь отключена: изменение выглядело бы успешным, но этот дополнительный запас остался бы нетронутым.", + "traderRecordIncomplete": "Списков товара этого торговца нет, либо они в форме, которую редактор не поддерживает и не может записать. Правка отключена, чтобы изменение не сорвалось при сохранении.", + "traderEmptyStock": "Товара нет.", + "traderUnknownItem": "нет в каталоге предметов", + "editorTradersLoadFailed": "Не удалось загрузить торговцев: {details}", + "traderStockLineCount": "{count} строк", "tabWorld": "Мир", "tabCharacters": "Персонажи", "characterNoActorBody": "У этого персонажа нет актёра в мире, поэтому нет атрибутов, инвентаря или событий.", @@ -690,6 +710,7 @@ "slotRepairAction": "Восстановить", "slotRepairDiscard": "Отменить", "editorInventorySlotEditConflict": "В очереди одновременно прямое изменение слота инвентаря и операция, занимающая слоты целиком (восстановление, добавление или удаление). Вторая перезапишет первую — отмените одно из них и сохраните снова.", + "editorTraderArrayConflict": "Изменение торговли стоит в очереди вместе с прямой правкой массива торговцев. Она перенумеровывает строки, по которым адресуется изменение торговли, поэтому одно из двух попадёт не в того торговца — отмените одно и сохраните снова.", "backupFactFile": "Файл", "renameBackupTooltip": "Назвать эту копию", "renameBackupTitle": "Название копии", diff --git a/apps/save-editor/lib/l10n/app_zh.arb b/apps/save-editor/lib/l10n/app_zh.arb index 90aec8554..db5f896a0 100644 --- a/apps/save-editor/lib/l10n/app_zh.arb +++ b/apps/save-editor/lib/l10n/app_zh.arb @@ -129,6 +129,26 @@ "skillNameMagicCircle": "魔法环", "skillNameOrcish": "兽人语", "tabInventory": "物品栏", + "tabTrade": "交易", + "traderNotAMerchant": "该角色不进行交易。", + "traderRetry": "重试", + "traderAmbiguousName": "有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。", + "traderOre": "矿石(购买力)", + "traderNoOre": "无矿石", + "traderStockCurrent": "库存", + "traderStockBase": "补货基准", + "traderStockBaseHint": "商人补货的基准。会随剧情推进而增长,因此不是初始状态。", + "traderOreHint": "游戏内的数值会不同:载入时游戏会加上自他上次交易以来累积的部分——他会卖掉多余货物并以此补货。这个数字是起点,而非交易界面显示的金额。", + "traderPriceWarning": "价格会随商人的库存量和持有矿石而变化,因此修改这些数字也可能改变他的开价。", + "traderAddItem": "添加物品", + "traderRemoveItem": "移除条目", + "traderReadOnlyCore": "此核心版本只能读取商人数据。", + "traderDifficultyStockUnsupported": "该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。", + "traderRecordIncomplete": "该商人的库存清单缺失,或其结构编辑器不支持、无法写入。此处已禁用编辑,以免修改在保存时失败。", + "traderEmptyStock": "没有库存。", + "traderUnknownItem": "不在物品目录中", + "editorTradersLoadFailed": "商人数据加载失败:{details}", + "traderStockLineCount": "{count} 条", "tabWorld": "世界", "tabCharacters": "角色", "characterNoActorBody": "该角色在世界中没有对应的实体,因此没有属性、物品栏或事件。", @@ -697,6 +717,7 @@ "slotRepairAction": "修复", "slotRepairDiscard": "放弃", "editorInventorySlotEditConflict": "对物品栏槽位的直接编辑与占用整个槽位的操作(修复、添加或删除)同时在待保存列表中。后者会覆盖前者 — 请撤销其中一项后再保存。", + "editorTraderArrayConflict": "一项交易修改与对商人数组的直接编辑一同排队。该编辑会重新编号交易修改所依据的行,因此两者之一会落到错误的商人身上——撤销其中一项后再保存。", "backupFactFile": "文件", "renameBackupTooltip": "为此备份命名", "renameBackupTitle": "备份名称", diff --git a/apps/save-editor/lib/l10n/app_zh_Hans.arb b/apps/save-editor/lib/l10n/app_zh_Hans.arb index c8a77a6c5..be8f04387 100644 --- a/apps/save-editor/lib/l10n/app_zh_Hans.arb +++ b/apps/save-editor/lib/l10n/app_zh_Hans.arb @@ -129,6 +129,26 @@ "skillNameMagicCircle": "魔法环", "skillNameOrcish": "兽人语", "tabInventory": "物品栏", + "tabTrade": "交易", + "traderNotAMerchant": "该角色不进行交易。", + "traderRetry": "重试", + "traderAmbiguousName": "有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。", + "traderOre": "矿石(购买力)", + "traderNoOre": "无矿石", + "traderStockCurrent": "库存", + "traderStockBase": "补货基准", + "traderStockBaseHint": "商人补货的基准。会随剧情推进而增长,因此不是初始状态。", + "traderOreHint": "游戏内的数值会不同:载入时游戏会加上自他上次交易以来累积的部分——他会卖掉多余货物并以此补货。这个数字是起点,而非交易界面显示的金额。", + "traderPriceWarning": "价格会随商人的库存量和持有矿石而变化,因此修改这些数字也可能改变他的开价。", + "traderAddItem": "添加物品", + "traderRemoveItem": "移除条目", + "traderReadOnlyCore": "此核心版本只能读取商人数据。", + "traderDifficultyStockUnsupported": "该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。", + "traderRecordIncomplete": "该商人的库存清单缺失,或其结构编辑器不支持、无法写入。此处已禁用编辑,以免修改在保存时失败。", + "traderEmptyStock": "没有库存。", + "traderUnknownItem": "不在物品目录中", + "editorTradersLoadFailed": "商人数据加载失败:{details}", + "traderStockLineCount": "{count} 条", "tabWorld": "世界", "tabCharacters": "角色", "characterNoActorBody": "该角色在世界中没有对应的实体,因此没有属性、物品栏或事件。", @@ -697,6 +717,7 @@ "slotRepairAction": "修复", "slotRepairDiscard": "放弃", "editorInventorySlotEditConflict": "对物品栏槽位的直接编辑与占用整个槽位的操作(修复、添加或删除)同时在待保存列表中。后者会覆盖前者 — 请撤销其中一项后再保存。", + "editorTraderArrayConflict": "一项交易修改与对商人数组的直接编辑一同排队。该编辑会重新编号交易修改所依据的行,因此两者之一会落到错误的商人身上——撤销其中一项后再保存。", "backupFactFile": "文件", "renameBackupTooltip": "为此备份命名", "renameBackupTitle": "备份名称", diff --git a/apps/save-editor/test/inventory_pending_localized_name_test.dart b/apps/save-editor/test/inventory_pending_localized_name_test.dart index 1b2d8890a..312d33fca 100644 --- a/apps/save-editor/test/inventory_pending_localized_name_test.dart +++ b/apps/save-editor/test/inventory_pending_localized_name_test.dart @@ -9,6 +9,7 @@ import 'package:goresave/loc/loc_catalog_provider.dart'; import 'package:goresave/providers/data_providers.dart'; import 'support/ui_settings_test_store.dart'; +import 'support/detail_tabs.dart'; /// The queued add/remove cards used to name the item from its class id alone /// ("2H Sword Heavy 02"), while the picker that produced it and the inventory @@ -52,7 +53,7 @@ void main() { Future openNpcInventory(WidgetTester tester) async { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); await tester.tap(find.text('Lizard-A')); await tester.pumpAndSettle(); diff --git a/apps/save-editor/test/inventory_slot_repair_test.dart b/apps/save-editor/test/inventory_slot_repair_test.dart index 96c213b96..7a8d6a1bb 100644 --- a/apps/save-editor/test/inventory_slot_repair_test.dart +++ b/apps/save-editor/test/inventory_slot_repair_test.dart @@ -9,6 +9,7 @@ import 'package:goresave/features/editor/ui/slot_repair_banner.dart'; import 'package:goresave/providers/data_providers.dart'; import 'support/ui_settings_test_store.dart'; +import 'support/detail_tabs.dart'; /// A savegame an older build damaged — slots whose id no longer matches their /// position — must be called out, and the repair must reach the core as @@ -37,7 +38,7 @@ void main() { Future openPlayerInventory(WidgetTester tester) async { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); } diff --git a/apps/save-editor/test/knowledge_first_add_race_test.dart b/apps/save-editor/test/knowledge_first_add_race_test.dart index fa83f92d3..bf6d42712 100644 --- a/apps/save-editor/test/knowledge_first_add_race_test.dart +++ b/apps/save-editor/test/knowledge_first_add_race_test.dart @@ -5,6 +5,7 @@ import 'package:goresave/features/app/ui/goresave_app.dart'; import 'package:goresave/features/editor/domain/core_service.dart'; import 'package:goresave/features/editor/domain/editor_settings_store.dart'; import 'package:goresave/providers/data_providers.dart'; +import 'support/detail_tabs.dart'; /// Regression test for first knowledge adds. A missing character knowledge-map /// entry must not trigger a preparatory write; one value-addressed pending edit @@ -39,7 +40,7 @@ void main() { // Player is selected by default; open the Wissen sub-tab. The Hero has // no knowledge entry yet (benign "has no knowledge entry" core error), // so the add affordance is enabled in the no-knowledge-yet state. - await tester.tap(find.widgetWithText(Tab, 'Dialog Knowledge')); + await tester.tap(detailTab('Dialog Knowledge')); await tester.pumpAndSettle(); await tester.enterText( @@ -87,7 +88,7 @@ void main() { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Dialog Knowledge')); + await tester.tap(detailTab('Dialog Knowledge')); await tester.pumpAndSettle(); final badges = { diff --git a/apps/save-editor/test/npc_attribute_pending_per_npc_test.dart b/apps/save-editor/test/npc_attribute_pending_per_npc_test.dart index 5529dcbec..49c091b18 100644 --- a/apps/save-editor/test/npc_attribute_pending_per_npc_test.dart +++ b/apps/save-editor/test/npc_attribute_pending_per_npc_test.dart @@ -8,6 +8,7 @@ import 'package:goresave/features/editor/domain/editor_settings_store.dart'; import 'package:goresave/providers/data_providers.dart'; import 'support/ui_settings_test_store.dart'; +import 'support/detail_tabs.dart'; /// Regression test for the phantom cross-NPC edit bug: NPC attribute pending /// edits must be keyed PER-NPC (`npc.attributes:$id`) so that editing NPC-A, @@ -45,7 +46,7 @@ void main() { // sub-tab (which hosts the NPC editor). await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Attributes')); + await tester.tap(detailTab('Attributes')); await tester.pumpAndSettle(); // Select NPC-A from the shared master list and edit its Health base. With @@ -126,7 +127,7 @@ void main() { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Attributes')); + await tester.tap(detailTab('Attributes')); await tester.pumpAndSettle(); // Edit NPC-A (1 pending), then visit NPC-B but make NO edit. diff --git a/apps/save-editor/test/npc_attribute_rehydrate_on_revisit_test.dart b/apps/save-editor/test/npc_attribute_rehydrate_on_revisit_test.dart index 484628a0c..707b93c6f 100644 --- a/apps/save-editor/test/npc_attribute_rehydrate_on_revisit_test.dart +++ b/apps/save-editor/test/npc_attribute_rehydrate_on_revisit_test.dart @@ -8,6 +8,7 @@ import 'package:goresave/features/editor/domain/editor_settings_store.dart'; import 'package:goresave/providers/data_providers.dart'; import 'support/ui_settings_test_store.dart'; +import 'support/detail_tabs.dart'; /// Regression test for Bug #8: switching away from an NPC whose attribute draft /// is queued and returning must REHYDRATE the panel's local field state from the @@ -45,7 +46,7 @@ void main() { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Attributes')); + await tester.tap(detailTab('Attributes')); await tester.pumpAndSettle(); // NPC-A exposes Health + Strength (both in Main stats). Edit Health base. diff --git a/apps/save-editor/test/npc_inventory_containers_test.dart b/apps/save-editor/test/npc_inventory_containers_test.dart index a64eafe2e..ee4d314c7 100644 --- a/apps/save-editor/test/npc_inventory_containers_test.dart +++ b/apps/save-editor/test/npc_inventory_containers_test.dart @@ -8,6 +8,7 @@ import 'package:goresave/features/editor/domain/editor_settings_store.dart'; import 'package:goresave/providers/data_providers.dart'; import 'support/ui_settings_test_store.dart'; +import 'support/detail_tabs.dart'; /// An NPC inventory surfaces multiple containers (MainContainer + the equipped /// MeleeSlot weapon + the ore Pouch). The card must (a) show every container's @@ -38,7 +39,7 @@ void main() { Future openNpcInventory(WidgetTester tester) async { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); await tester.tap(find.text('Lizard-A')); await tester.pumpAndSettle(); diff --git a/apps/save-editor/test/npc_inventory_duplicate_stack_count_test.dart b/apps/save-editor/test/npc_inventory_duplicate_stack_count_test.dart index 8b7fab98f..4df7f9db0 100644 --- a/apps/save-editor/test/npc_inventory_duplicate_stack_count_test.dart +++ b/apps/save-editor/test/npc_inventory_duplicate_stack_count_test.dart @@ -8,6 +8,7 @@ import 'package:goresave/features/editor/domain/editor_settings_store.dart'; import 'package:goresave/providers/data_providers.dart'; import 'support/ui_settings_test_store.dart'; +import 'support/detail_tabs.dart'; /// Regression for Codex (P2): an NPC MainContainer with two stacks that share /// the SAME item id/path but differ by slotId/count. The count editors must be @@ -49,7 +50,7 @@ void main() { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); await tester.tap(find.text('Lizard-A')); await tester.pumpAndSettle(); @@ -86,7 +87,7 @@ void main() { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); await tester.tap(find.text('Lizard-A')); await tester.pumpAndSettle(); @@ -120,7 +121,7 @@ void main() { } await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); await tester.tap(find.text('Lizard-A')); await tester.pumpAndSettle(); diff --git a/apps/save-editor/test/npc_inventory_pending_per_npc_test.dart b/apps/save-editor/test/npc_inventory_pending_per_npc_test.dart index 6feb1009a..03f9fccd8 100644 --- a/apps/save-editor/test/npc_inventory_pending_per_npc_test.dart +++ b/apps/save-editor/test/npc_inventory_pending_per_npc_test.dart @@ -8,6 +8,7 @@ import 'package:goresave/features/editor/domain/editor_settings_store.dart'; import 'package:goresave/providers/data_providers.dart'; import 'support/ui_settings_test_store.dart'; +import 'support/detail_tabs.dart'; /// Regression test mirroring `npc_attribute_pending_per_npc_test.dart` for the /// Inventory tab: NPC inventory pending edits must be keyed PER-NPC @@ -44,7 +45,7 @@ void main() { // Open the Charaktere tab (shared master list) then its Inventar sub-tab. await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); // Select NPC-A and bump its single item's count. @@ -106,7 +107,7 @@ void main() { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); await tester.tap(find.text('Lizard-B')); await tester.pumpAndSettle(); @@ -114,7 +115,7 @@ void main() { // The Attribute sub-tab reads the SAME shared selectedActor — switching to // it (no re-select) loads Lizard-B's NPC attributes (the fake returns // Lizard-B's attribute row), proving selection is shared across sub-tabs. - await tester.tap(find.widgetWithText(Tab, 'Attributes')); + await tester.tap(detailTab('Attributes')); await tester.pumpAndSettle(); final attrReq = core.requests.lastWhere( (r) => r.command == 'private.npc.attributes', diff --git a/apps/save-editor/test/npc_inventory_rehydrate_on_revisit_test.dart b/apps/save-editor/test/npc_inventory_rehydrate_on_revisit_test.dart index 651751819..ef390d2b6 100644 --- a/apps/save-editor/test/npc_inventory_rehydrate_on_revisit_test.dart +++ b/apps/save-editor/test/npc_inventory_rehydrate_on_revisit_test.dart @@ -8,6 +8,7 @@ import 'package:goresave/features/editor/domain/editor_settings_store.dart'; import 'package:goresave/providers/data_providers.dart'; import 'support/ui_settings_test_store.dart'; +import 'support/detail_tabs.dart'; /// Regression test for Bug #7: switching away from an edited NPC inventory and /// returning must REHYDRATE the card from the queued per-NPC draft. Editing a @@ -43,7 +44,7 @@ void main() { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); // NPC-A has two items. Edit the FIRST item's count. diff --git a/apps/save-editor/test/npc_inventory_reset_test.dart b/apps/save-editor/test/npc_inventory_reset_test.dart index bd2eba590..6a1561e62 100644 --- a/apps/save-editor/test/npc_inventory_reset_test.dart +++ b/apps/save-editor/test/npc_inventory_reset_test.dart @@ -6,6 +6,7 @@ import 'package:goresave/features/editor/domain/core_service.dart'; import 'package:goresave/features/editor/domain/editor_notifier.dart'; import 'package:goresave/features/editor/domain/editor_settings_store.dart'; import 'package:goresave/providers/data_providers.dart'; +import 'support/detail_tabs.dart'; /// Task 15: proves the "Reset inventory" button (Task 14) queues a single /// `private.inventory.reset` pending edit under the player's `'inventory'` @@ -38,7 +39,7 @@ void main() { // with no further selection needed. await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); } diff --git a/apps/save-editor/test/player_events_hero_wiring_test.dart b/apps/save-editor/test/player_events_hero_wiring_test.dart index e47ed4aba..368610659 100644 --- a/apps/save-editor/test/player_events_hero_wiring_test.dart +++ b/apps/save-editor/test/player_events_hero_wiring_test.dart @@ -10,6 +10,7 @@ import 'package:goresave/features/editor/ui/actor_detail_header.dart'; import 'package:goresave/features/editor/ui/character_master_list.dart'; import 'package:goresave/features/editor/ui/characters_tab.dart'; import 'package:goresave/providers/data_providers.dart'; +import 'support/detail_tabs.dart'; /// The player's memory events live under the save's own "Hero" ACTOR GlobalId. /// The Charaktere master list hides that actor row (the pinned Player row @@ -69,7 +70,7 @@ void main() { ); for (final tab in ['Inventory', 'Dialog Knowledge', 'Events']) { - await tester.tap(find.widgetWithText(Tab, tab)); + await tester.tap(detailTab(tab)); await tester.pumpAndSettle(); expect(header, findsOneWidget); expect( @@ -105,7 +106,7 @@ void main() { ); // The Player is selected by default — open the Events sub-tab. - await tester.tap(find.widgetWithText(Tab, 'Events')); + await tester.tap(detailTab('Events')); await tester.pumpAndSettle(); // The events detail queried the events for the HERO actor's GlobalId @@ -143,7 +144,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400)); - await tester.tap(find.widgetWithText(Tab, 'Events')); + await tester.tap(detailTab('Events')); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400)); @@ -200,7 +201,7 @@ void main() { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); - await tester.tap(find.widgetWithText(Tab, 'Events')); + await tester.tap(detailTab('Events')); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); @@ -248,7 +249,7 @@ void main() { await tester.tap(find.text('Ghostvoice')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Events')); + await tester.tap(detailTab('Events')); await tester.pumpAndSettle(); // The Ereignisse pane shows the same clean no-actor empty state diff --git a/apps/save-editor/test/support/detail_tabs.dart b/apps/save-editor/test/support/detail_tabs.dart new file mode 100644 index 000000000..e57e69e76 --- /dev/null +++ b/apps/save-editor/test/support/detail_tabs.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The character detail sub-tab named [label]. +/// +/// The bar drops the labels when the pane is too narrow for them and names the +/// tabs by tooltip instead, which every test surface is — `setSurfaceSize` does +/// not widen the render view here, so tests always run in the narrow regime. +/// Matching either form keeps a test about inventory or position from turning +/// into a test about the tab bar's breakpoint. +Finder detailTab(String label) => find.byWidgetPredicate((widget) { + if (widget is! Tab) return false; + if (widget.text == label) return true; + final icon = widget.icon; + return icon is Tooltip && icon.message == label; +}, description: 'character detail tab "$label"'); diff --git a/apps/save-editor/test/support/npc_position_fake_core.dart b/apps/save-editor/test/support/npc_position_fake_core.dart index 0be1cf0b4..53dd895da 100644 --- a/apps/save-editor/test/support/npc_position_fake_core.dart +++ b/apps/save-editor/test/support/npc_position_fake_core.dart @@ -9,6 +9,7 @@ import 'package:goresave/features/editor/domain/location_catalog.dart'; import 'package:goresave/providers/data_providers.dart'; import 'ui_settings_test_store.dart'; +import 'detail_tabs.dart'; /// Shared scaffolding for the NPC position widget tests: a fake core that /// answers everything the editor shell needs (one save slot, decoded + typed-OK @@ -379,7 +380,7 @@ Future pickLocationSpot( Future openPositionTab(WidgetTester tester) async { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Position')); + await tester.tap(detailTab('Position')); await tester.pumpAndSettle(); } diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart new file mode 100644 index 000000000..c9d14cf89 --- /dev/null +++ b/apps/save-editor/test/trader_panel_test.dart @@ -0,0 +1,1492 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:goresave/features/app/ui/goresave_app.dart'; +import 'package:goresave/features/app/domain/ui_settings.dart'; +import 'package:goresave/features/editor/domain/core_service.dart'; +import 'package:goresave/features/editor/domain/editor_settings_store.dart'; +import 'package:goresave/features/editor/domain/pending_edits.dart'; +import 'package:goresave/features/editor/domain/trader_models.dart'; +import 'package:goresave/features/editor/ui/character_master_list.dart'; +import 'package:goresave/features/editor/ui/pending_structural_row.dart'; +import 'package:goresave/features/editor/ui/sidebar_tile.dart'; +import 'package:goresave/loc/loc_catalog_provider.dart'; +import 'package:goresave/features/editor/domain/editor_notifier.dart'; +import 'package:goresave/providers/data_providers.dart'; + +import 'support/ui_settings_test_store.dart'; +import 'support/detail_tabs.dart'; +import 'package:goresave/features/editor/ui/characters_tab.dart'; + +/// The Handel (trade) sub-tab. A merchant's shop is NOT his inventory: it lives +/// in a global array addressed by index, and his ore inside that shop is what he +/// can pay with. These tests pin the three things that are easy to get wrong — +/// index (not name) addressing, "no ore line" being distinct from zero, and a +/// structural add/remove being kept out of the batched edits. +/// A raw All-Data array removal aimed at the trader array itself. +Map arrayRemoveOnTraders() => { + 'path': 'private.typed.arrayRemove', + 'value': { + 'path': ['m_GenericData', '{GameStateDataBase}', 'm_Traders'], + 'index': 0, + }, +}; + +void main() { + group('trader edit encoding', () { + test('setStock sends the map and count, addressed by index', () { + const edit = TraderStockEdit( + kind: TraderEditKind.setStock, + index: 11, + map: TraderStockMap.current, + path: kTraderOrePath, + count: 4242, + ); + expect(edit.toEdit(), { + 'path': 'private.traders.setStock', + 'value': { + 'index': 11, + 'path': kTraderOrePath, + 'map': 'current', + 'count': 4242, + }, + }); + // Length-neutral, so it may share a write with its peers. + expect(edit.isStructural, isFalse); + }); + + test('removeItem omits the count it has no use for', () { + const edit = TraderStockEdit( + kind: TraderEditKind.removeItem, + index: 3, + map: TraderStockMap.base, + path: '/Script/Angelscript.ItFo_Loaf', + ); + expect(edit.toEdit()['value'], { + 'index': 3, + 'path': '/Script/Angelscript.ItFo_Loaf', + 'map': 'default', + }); + // Splices the map body, so the notifier must give it its own write. + expect(edit.isStructural, isTrue); + }); + + test('addItem is structural and carries its starting count', () { + const edit = TraderStockEdit( + kind: TraderEditKind.addItem, + index: 0, + map: TraderStockMap.current, + path: '/Script/Angelscript.ItFo_Cheese', + count: 9, + ); + expect(edit.commandPath, 'private.traders.addItem'); + expect((edit.toEdit()['value'] as Map)['count'], 9); + expect(edit.isStructural, isTrue); + }); + + test('the pending key separates trader, map and line', () { + const a = TraderStockEdit( + kind: TraderEditKind.setStock, + index: 1, + map: TraderStockMap.current, + path: kTraderOrePath, + ); + const b = TraderStockEdit( + kind: TraderEditKind.setStock, + index: 1, + map: TraderStockMap.base, + path: kTraderOrePath, + ); + const c = TraderStockEdit( + kind: TraderEditKind.setStock, + index: 2, + map: TraderStockMap.current, + path: kTraderOrePath, + ); + expect(a.pendingKey, isNot(b.pendingKey)); + expect(a.pendingKey, isNot(c.pendingKey)); + // Same line edited twice replaces rather than stacks. + const again = TraderStockEdit( + kind: TraderEditKind.setStock, + index: 1, + map: TraderStockMap.current, + path: kTraderOrePath, + count: 7, + ); + expect(again.pendingKey, a.pendingKey); + }); + }); + + group('trader list model', () { + test('a placeholder row never answers a name lookup', () { + // Two shipped rows are named `None` and belong to no NPC. Matching one + // would attach a stranger's shop to whichever character is selected. + final result = TradersResult.fromJson({ + 'traders': [ + {'index': 0, 'uniqueName': 'None', 'placeholder': true}, + {'index': 1, 'uniqueName': 'OC_STT_Dexter_329', 'ore': 55}, + {'index': 2, 'uniqueName': 'None', 'placeholder': true}, + ], + 'writable': ['private.traders.setStock'], + }); + expect(result.forUniqueName('None'), isNull); + expect(result.forUniqueName('OC_STT_Dexter_329')?.index, 1); + expect(result.forUniqueName('NC_ORG_Wolf_855'), isNull); + }); + + test('a name matches case-insensitively, the way the core joins it', () { + // A character's unique name is the stored knowledge key where one exists, + // and that key's casing can differ from the trader row's. The core badges + // the merchant through a lowercase match, so an exact compare here would + // badge him and then deny it. + final result = TradersResult.fromJson({ + 'traders': [ + {'index': 4, 'uniqueName': 'OC_STT_Dexter_329', 'ore': 55}, + ], + }); + expect(result.forUniqueName('oc_stt_dexter_329')?.index, 4); + expect(result.forUniqueName('OC_stt_Dexter_329')?.index, 4); + expect(result.isAmbiguous('OC_STT_Dexter_329'), isFalse); + }); + + test('two rows sharing a name are refused, not guessed between', () { + // The index this returns is what every edit is addressed by, so picking + // the first hit would edit an arbitrary shop. The core refuses the same + // case. + final result = TradersResult.fromJson({ + 'traders': [ + {'index': 4, 'uniqueName': 'OC_STT_Dexter_329', 'ore': 55}, + {'index': 9, 'uniqueName': 'oc_stt_dexter_329', 'ore': 12}, + ], + }); + expect(result.forUniqueName('OC_STT_Dexter_329'), isNull); + expect(result.isAmbiguous('OC_STT_Dexter_329'), isTrue); + expect(result.allForUniqueName('OC_STT_Dexter_329'), hasLength(2)); + // A name nobody carries is absent, not ambiguous — the panel says + // different things about the two. + expect(result.isAmbiguous('NC_ORG_Wolf_855'), isFalse); + }); + + test('a missing ore line reads as null, not zero', () { + // Riordian stocks goods but carries no ore key. Showing 0 would claim he + // is broke; null says the record has no such line at all. + final result = TradersResult.fromJson({ + 'traders': [ + {'index': 0, 'uniqueName': 'NC_KDW_Riordian_605', 'itemCount': 4}, + {'index': 1, 'uniqueName': 'OC_STT_Dexter_329', 'ore': 55}, + ], + }); + expect(result.traders[0].ore, isNull); + expect(result.traders[1].ore, 55); + }); + + test('command availability is feature-detected, not assumed', () { + // An older core offers no trader writes; the panel must stay read-only + // rather than send a command that does not exist. + final old = TradersResult.fromJson({'traders': []}); + expect(old.canSetStock, isFalse); + expect(old.canAddItem, isFalse); + expect(old.canRemoveItem, isFalse); + }); + }); + + group('trader detail model', () { + test('stock and restock baseline are separate lists', () { + final detail = TraderDetail.fromJson({ + 'index': 5, + 'uniqueName': 'OC_STT_Fisk_311', + 'ore': 50, + 'traded': true, + 'items': [ + {'path': kTraderOrePath, 'id': 'ItMi_Orenugget', 'count': 50}, + ], + 'defaultItems': [ + {'path': kTraderOrePath, 'id': 'ItMi_Orenugget', 'count': 96}, + { + 'path': '/Script/Angelscript.ItFo_Loaf', + 'id': 'ItFo_Loaf', + 'count': 3, + }, + ], + 'generatedEvents': ['OnWorldStart'], + 'hasItemsByDifficulty': false, + }); + expect(detail.stock(TraderStockMap.current), hasLength(1)); + // The baseline diverges in BOTH values and key set — it is not a mirror. + expect(detail.stock(TraderStockMap.base), hasLength(2)); + expect(detail.stock(TraderStockMap.base).first.count, 96); + expect(detail.items.first.isOre, isTrue); + expect(detail.summary.index, 5); + }); + + test('an uncatalogued class is flagged rather than silently editable', () { + final detail = TraderDetail.fromJson({ + 'index': 0, + 'items': [ + { + 'path': '/Script/Angelscript.ItXx_Mystery', + 'id': 'ItXx_Mystery', + 'count': 1, + 'unknownItem': true, + }, + ], + }); + expect(detail.items.single.unknownItem, isTrue); + }); + }); + + group('trader edit conflicts', () { + + test('a trader edit and an m_Traders splice conflict either way', () { + const traderEdit = TraderStockEdit( + kind: TraderEditKind.setStock, + index: 7, + map: TraderStockMap.current, + path: kTraderOrePath, + count: 5, + ); + final trader = traderEdit.toEdit(); + final splice = arrayRemoveOnTraders(); + expect(editsRewriteSameTarget(splice, trader), isTrue); + expect(editsRewriteSameTarget(trader, splice), isTrue); + }); + + test('the conflict is detected as a pair, in either order', () { + final trader = const TraderStockEdit( + kind: TraderEditKind.setStock, + index: 7, + map: TraderStockMap.current, + path: kTraderOrePath, + count: 5, + ).toEdit(); + final splice = arrayRemoveOnTraders(); + expect(traderArrayConflict([trader, splice]), isNotNull); + expect(traderArrayConflict([splice, trader]), isNotNull); + expect(traderArrayConflict([trader]), isNull); + expect(traderArrayConflict([splice]), isNull); + }); + + test('an edit inside a trader row is not a renumbering splice', () { + // Only an array operation ON m_Traders moves its rows. A value under one + // row, or a container edit inside one, runs through the array without + // touching its length — refusing those would block safe pairs. + final trader = const TraderStockEdit( + kind: TraderEditKind.setStock, + index: 7, + map: TraderStockMap.current, + path: kTraderOrePath, + count: 5, + ).toEdit(); + final insideRow = { + 'path': 'private.typed.setValue', + 'value': { + 'path': [ + 'm_GenericData', + '{GameStateDataBase}', + 'm_Traders', + '[7]', + 'm_TotalSeconds', + ], + 'value': '1.0', + }, + }; + final containerInsideRow = { + 'path': 'private.typed.arrayRemove', + 'value': { + 'path': [ + 'm_GenericData', + '{GameStateDataBase}', + 'm_Traders', + '[7]', + 'm_GeneratedEvents', + ], + 'index': 0, + }, + }; + expect(traderArrayConflict([trader, insideRow]), isNull); + expect(traderArrayConflict([trader, containerInsideRow]), isNull); + expect(editsRewriteSameTarget(insideRow, trader), isFalse); + expect(editsRewriteSameTarget(containerInsideRow, trader), isFalse); + }); + + test('an unrelated array splice does not conflict', () { + const traderEdit = TraderStockEdit( + kind: TraderEditKind.addItem, + index: 7, + map: TraderStockMap.current, + path: '/Script/Angelscript.ItFo_Cheese', + count: 1, + ); + final elsewhere = { + 'path': 'private.typed.arrayRemove', + 'value': { + 'path': ['m_GenericData', '{Story}', 'SomethingElse'], + 'index': 0, + }, + }; + expect(editsRewriteSameTarget(elsewhere, traderEdit.toEdit()), isFalse); + }); + }); + + group('Handel tab', () { + // The ore card, addressed through its own title: the first Card on the page + // is the price note, and the first TextField is the character search. + final oreCard = find.ancestor( + of: find.text('Ore (purchasing power)'), + matching: find.byType(Card), + ); + Future pumpApp( + WidgetTester tester, + GoresaveCoreService core, { + bool showObjectIds = false, + Map>? locCatalog, + Size surface = const Size(1400, 1000), + }) async { + await tester.binding.setSurfaceSize(surface); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + coreServiceProvider.overrideWithValue(core), + editorSettingsStoreProvider.overrideWithValue( + const NoopEditorSettingsStore(), + ), + uiSettingsStoreProvider.overrideWithValue( + TestUiSettingsStore(showObjectIds: showObjectIds), + ), + if (locCatalog != null) + locCatalogProvider.overrideWith((ref) async => locCatalog), + ], + child: const GoresaveApp(), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('the player is not a merchant and gets a clean empty state', ( + tester, + ) async { + final core = _TraderCoreService(); + await pumpApp(tester, core); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + expect(find.text('This character does not trade.'), findsOneWidget); + // A non-merchant must not cost a detail round trip. + expect( + core.requests.where((r) => r.command == 'private.traders.detail'), + isEmpty, + ); + }); + + testWidgets('a queued addition is visible before the save', (tester) async { + // Regression: a new line has no counterpart in the loaded stock, so + // without rendering the queued edit it stayed invisible until the next + // save — unlike the inventory, which shows its queued additions. + final core = _TraderCoreService(playerIsTrader: true); + await pumpApp(tester, core); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.add_circle_outline), findsNothing); + expect(find.text('ItFo_Cheese'), findsNothing); + + // Queue the add the way the panel does, then let it rebuild. + final notifier = ProviderScope.containerOf( + tester.element(find.byType(Scaffold).first), + ).read(editorProvider.notifier); + notifier.setTraderStockEdit( + const TraderStockEdit( + kind: TraderEditKind.addItem, + index: 7, + map: TraderStockMap.current, + path: '/Script/Angelscript.ItFo_Cheese', + count: 9, + ), + ); + await tester.pumpAndSettle(); + + // Shown as a banner BESIDE the list, the way the inventory shows its own + // queued additions — not as a row among the saved lines, which would + // claim a state the save does not have. + expect(find.byType(PendingStructuralRow), findsOneWidget); + expect(find.text('ItFo_Cheese'), findsOneWidget); + expect(find.text('×9 — pending add (not yet saved)'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Save (1)'), findsOneWidget); + + // Cancelling it takes the banner away again. + await tester.tap(find.descendant( + of: find.byType(PendingStructuralRow), + matching: find.byIcon(Icons.close), + )); + await tester.pumpAndSettle(); + expect(find.byType(PendingStructuralRow), findsNothing); + expect(find.text('ItFo_Cheese'), findsNothing); + }); + + testWidgets('a save re-reads the stock instead of showing stale rows', ( + tester, + ) async { + // Regression: the tab is kept alive, and the panel only reloaded when the + // merchant or the save path changed — neither of which a save does. The + // reload key carries the inspection so a save re-reads. + final core = _TraderCoreService(playerIsTrader: true); + await pumpApp(tester, core); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + final before = core.requests + .where((r) => r.command == 'private.traders.detail') + .length; + expect(before, greaterThan(0)); + + // Queue something, save, and let the trailing refresh run. + final notifier = ProviderScope.containerOf( + tester.element(find.byType(Scaffold).first), + ).read(editorProvider.notifier); + notifier.setTraderStockEdit( + const TraderStockEdit( + kind: TraderEditKind.setStock, + index: 7, + map: TraderStockMap.current, + path: kTraderOrePath, + count: 4242, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save (1)')); + await tester.pumpAndSettle(); + + expect( + core.requests.where((r) => r.command == 'write_save'), + isNotEmpty, + reason: 'the save must actually go out', + ); + expect( + core.requests.where((r) => r.command == 'private.traders.detail').length, + greaterThan(before), + reason: 'the panel must re-read after the save', + ); + }); + + testWidgets('two additions are split into separate writes', (tester) async { + // Regression: an insert changes how many entries a map holds, and every + // trader edit is addressed by an index — so the core refuses two of them + // in one write. The app has to split them itself; when its classification + // did not mirror the core's, saving an addition to the restock baseline + // beside one to the live stock failed outright. + final core = _TraderCoreService(playerIsTrader: true); + await pumpApp(tester, core); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + final notifier = ProviderScope.containerOf( + tester.element(find.byType(Scaffold).first), + ).read(editorProvider.notifier); + for (final map in TraderStockMap.values) { + notifier.setTraderStockEdit( + TraderStockEdit( + kind: TraderEditKind.addItem, + index: 7, + map: map, + path: '/Script/Angelscript.ItFo_Cheese', + count: 2, + ), + ); + } + await tester.pumpAndSettle(); + // Only the selected map is on screen, so only its banner shows — but both + // edits are queued and both must reach the core. + expect(find.byType(PendingStructuralRow), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Save (2)')); + await tester.pumpAndSettle(); + + final writes = core.requests + .where((r) => r.command == 'write_save') + .toList(); + expect(writes, hasLength(2), reason: 'one write per insert'); + for (final w in writes) { + expect((w.payload['edits'] as List), hasLength(1)); + } + }); + + testWidgets('a queued addition prints its class path only when ids are on', ( + tester, + ) async { + Future queueAdd(WidgetTester tester) async { + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + ProviderScope.containerOf(tester.element(find.byType(Scaffold).first)) + .read(editorProvider.notifier) + .setTraderStockEdit( + const TraderStockEdit( + kind: TraderEditKind.addItem, + index: 7, + map: TraderStockMap.current, + path: '/Script/Angelscript.ItFo_Cheese', + count: 1, + ), + ); + await tester.pumpAndSettle(); + } + + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await queueAdd(tester); + expect(find.text('/Script/Angelscript.ItFo_Cheese'), findsNothing); + + await pumpApp( + tester, + _TraderCoreService(playerIsTrader: true), + showObjectIds: true, + ); + await queueAdd(tester); + expect(find.text('/Script/Angelscript.ItFo_Cheese'), findsOneWidget); + }); + + testWidgets('stock is grouped by category and the sidebar filters it', ( + tester, + ) async { + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + // One sidebar entry per populated category, counted. Ore is not among + // them: in the live stock it has its own card, so it leaves the list. + expect(find.text('Melee weapons (1)'), findsOneWidget); + expect(find.text('Ammunition (1)'), findsOneWidget); + expect(find.text('Food & potions (2)'), findsOneWidget); + expect(find.textContaining('Miscellaneous'), findsNothing); + + // The list shows the selected category only — melee comes first. + expect(find.text('ItMw_1H_Sword_01'), findsOneWidget); + expect(find.text('ItFo_Loaf'), findsNothing); + + await tester.tap(find.text('Food & potions (2)')); + await tester.pumpAndSettle(); + expect(find.text('ItFo_Loaf'), findsOneWidget); + expect(find.text('ItFo_Apple'), findsOneWidget); + expect(find.text('ItMw_1H_Sword_01'), findsNothing); + }); + + testWidgets('a category sorts by the localized name, not the class id', ( + tester, + ) async { + // By class id ItFo_Apple leads ItFo_Loaf; by name "Brot" leads "Apfel" + // — this is the order the user reads, so it is the order that counts. + await pumpApp( + tester, + _TraderCoreService(playerIsTrader: true), + // The real loader lowercases its keys; the override supplies them so. + locCatalog: const { + 'itfo_apple': {'english': 'Zucchini'}, + 'itfo_loaf': {'english': 'Bread'}, + }, + ); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Food & potions (2)')); + await tester.pumpAndSettle(); + + final bread = tester.getTopLeft(find.text('Bread')).dy; + final zucchini = tester.getTopLeft(find.text('Zucchini')).dy; + expect(bread, lessThan(zucchini)); + }); + + testWidgets('the compact list sorts by localized name as well', ( + tester, + ) async { + // Under 600px the pane drops the sidebar and lists every line at once. + // That list came straight from the core, which orders by class id, so the + // one view without categories to lean on was also the one out of order. + await pumpApp( + tester, + _TraderCoreService(playerIsTrader: true), + surface: const Size(1200, 900), + locCatalog: const { + 'itfo_apple': {'english': 'Zucchini'}, + 'itfo_loaf': {'english': 'Bread'}, + 'itmw_1h_sword_01': {'english': 'Axe'}, + }, + ); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + expect(find.byType(SidebarTile), findsNothing, reason: 'compact pane'); + final axe = tester.getTopLeft(find.text('Axe')).dy; + final bread = tester.getTopLeft(find.text('Bread')).dy; + final zucchini = tester.getTopLeft(find.text('Zucchini')).dy; + expect(axe, lessThan(bread)); + expect(bread, lessThan(zucchini)); + }); + + testWidgets('a count field never keeps the previous line value', ( + tester, + ) async { + // Regression: the field only refreshed on a pending change, and rows had + // no key — so switching category reused one line's field State for the + // next, showing a stale count that a submit would then write to the wrong + // item. + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + List fieldTexts() => tester + .widgetList(find.byType(TextField)) + .map((f) => f.controller?.text ?? '') + .toList(); + + // Melee holds one sword, count 1. The ore card's own field shows 55. + await tester.tap(find.text('Melee weapons (1)')); + await tester.pumpAndSettle(); + expect(fieldTexts(), containsAll(['55', '1'])); + + // Ammunition holds one arrow stack of 18 at the same list position. + await tester.tap(find.text('Ammunition (1)')); + await tester.pumpAndSettle(); + expect(fieldTexts(), containsAll(['55', '18'])); + expect( + fieldTexts().where((t) => t == '1'), + isEmpty, + reason: 'the sword count must not survive the category switch', + ); + }); + + testWidgets('the ore line can be dropped, not just counted down', ( + tester, + ) async { + // A merchant with no ore line is a state the game itself produces, and + // setStock refuses 0 — so without a delete on the ore card there would be + // no way to ask for it at all. + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(Scaffold).first), + ); + await tester.tap( + find.descendant(of: oreCard, matching: find.byIcon(Icons.delete_outline)), + ); + await tester.pumpAndSettle(); + + final queued = container.read(editorProvider).pendingEdits.values + .expand((p) => p.edits) + .where((e) => e['path'] == 'private.traders.removeItem') + .toList(); + expect(queued, hasLength(1)); + expect((queued.single['value'] as Map)['path'], kTraderOrePath); + // And it is announced the way every other queued removal is. + expect(find.byType(PendingStructuralRow), findsOneWidget); + }); + + testWidgets('a count beyond the i32 the core stores is refused', ( + tester, + ) async { + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + final field = find.descendant(of: oreCard, matching: find.byType(TextField)); + await tester.enterText(field, '2147483648'); + await tester.pump(); + + // Refused in place — an error under the field and nothing queued — rather + // than the save failing later on the core's bound. The text stays as + // typed: snapping it back under the cursor would fight the typing. + expect(find.textContaining('2147483647'), findsOneWidget); + expect( + ProviderScope.containerOf(tester.element(find.byType(Scaffold).first)) + .read(editorProvider) + .pendingEdits, + isEmpty, + ); + }); + + testWidgets('unmodelled per-difficulty stock turns the panel read-only', ( + tester, + ) async { + // The edits reach only m_Items and m_DefaultItems. A save carrying stock + // the editor does not model would take an edit, report success, and leave + // that other stock standing — so it says so and offers nothing. + await pumpApp( + tester, + _TraderCoreService(playerIsTrader: true, hasItemsByDifficulty: true), + ); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + expect(find.textContaining('per-difficulty stock'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Add item'), findsNothing); + expect(find.byIcon(Icons.delete_outline), findsNothing); + final oreField = tester.widget( + find.descendant(of: oreCard, matching: find.byType(TextField)), + ); + expect(oreField.enabled, isFalse); + }); + + testWidgets('many queued changes scroll instead of overflowing', ( + tester, + ) async { + // Replacing most of a merchant's stock queues enough banners to push the + // browser off screen; unbounded, they overflowed and took the cancel + // buttons with them. + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + final notifier = ProviderScope.containerOf( + tester.element(find.byType(Scaffold).first), + ).read(editorProvider.notifier); + for (var i = 0; i < 25; i++) { + notifier.setTraderStockEdit( + TraderStockEdit( + kind: TraderEditKind.addItem, + index: 7, + map: TraderStockMap.current, + path: '/Script/Angelscript.ItFo_Filler_$i', + count: 1, + ), + ); + } + await tester.pumpAndSettle(); + + expect(find.byType(PendingStructuralRow), findsNWidgets(25)); + expect(tester.takeException(), isNull, reason: 'no RenderFlex overflow'); + // The stock browser is still there below them. + expect(find.byType(SidebarTile), findsWidgets); + }); + + testWidgets('the smallest supported window bounds the banners', ( + tester, + ) async { + // 960x600 is the minimum the app itself allows (main.dart), and it is the + // size that matters: a cap measured against a constant ignores the header + // above and the list below, so the column overflowed and the browser + // collapsed to nothing. Testing only a roomy surface hid that. + await pumpApp( + tester, + _TraderCoreService(playerIsTrader: true), + surface: const Size(960, 600), + ); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + final notifier = ProviderScope.containerOf( + tester.element(find.byType(Scaffold).first), + ).read(editorProvider.notifier); + for (var i = 0; i < 25; i++) { + notifier.setTraderStockEdit( + TraderStockEdit( + kind: TraderEditKind.addItem, + index: 7, + map: TraderStockMap.current, + path: '/Script/Angelscript.ItFo_Short_$i', + count: 1, + ), + ); + } + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: 'no RenderFlex overflow'); + }); + + testWidgets('a knowledge-only merchant is badged like any other', ( + tester, + ) async { + // A trader row is keyed by name, so a row with no spawned actor can own + // one. The orphan tile drew its own trailing and showed only the + // knowledge icon, hiding exactly the merchants the core had started to + // flag. + await pumpApp(tester, _TraderCoreService(orphanMerchant: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + + // Scoped to the list: the Trade sub-tab carries the same icon, so an + // unscoped finder passes with or without the badge. + expect( + find.descendant( + of: find.byType(CharacterMasterList), + matching: find.byIcon(Icons.storefront_outlined), + ), + findsOneWidget, + ); + }); + + testWidgets('typing a count queues it without leaving the field', ( + tester, + ) async { + // The field used to queue only on Enter or a tap outside, so a typed + // amount left Save disabled and could be overwritten by a rebuild before + // it was ever registered. The inventory queues per keystroke; so does this. + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + final field = find.descendant(of: oreCard, matching: find.byType(TextField)); + await tester.enterText(field, '4242'); + await tester.pump(); + + final pending = ProviderScope.containerOf( + tester.element(find.byType(Scaffold).first), + ).read(editorProvider).pendingEdits.values.expand((p) => p.edits).toList(); + expect(pending, hasLength(1)); + expect((pending.single['value'] as Map)['count'], 4242); + + // Back to the saved value withdraws it again, no Enter needed. + await tester.enterText(field, '55'); + await tester.pump(); + expect( + ProviderScope.containerOf(tester.element(find.byType(Scaffold).first)) + .read(editorProvider) + .pendingEdits, + isEmpty, + ); + }); + + testWidgets('clearing an edited count leaves the field empty', ( + tester, + ) async { + // Backspacing through a queued count clears the pending value, and the + // sync used to restore the saved one straight back under the cursor — so + // the field could never be emptied to type a fresh number. + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + final field = find.descendant(of: oreCard, matching: find.byType(TextField)); + await tester.tap(field); + await tester.pump(); + await tester.enterText(field, '12'); + await tester.pump(); + await tester.enterText(field, ''); + await tester.pump(); + + expect(tester.widget(field).controller?.text, isEmpty); + // And an empty field queues nothing rather than a zero. + expect( + ProviderScope.containerOf(tester.element(find.byType(Scaffold).first)) + .read(editorProvider) + .pendingEdits, + isEmpty, + ); + }); + + testWidgets('leaving a cleared count field restores the saved value', ( + tester, + ) async { + // The focus guard keeps the sync off while typing, so without a matching + // reset on blur an emptied or refused entry stayed on screen afterwards — + // with no pending edit and no revert control to explain it. + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + final field = find.descendant(of: oreCard, matching: find.byType(TextField)); + await tester.tap(field); + await tester.pump(); + await tester.enterText(field, ''); + await tester.pump(); + expect(tester.widget(field).controller?.text, isEmpty); + + // Focus moves away: the field goes back to what the save holds. + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); + expect(tester.widget(field).controller?.text, '55'); + expect( + ProviderScope.containerOf(tester.element(find.byType(Scaffold).first)) + .read(editorProvider) + .pendingEdits, + isEmpty, + ); + }); + + testWidgets('a row is keyed by map AND path, not path alone', ( + tester, + ) async { + // The same item lives in both maps. Keyed on the path alone, one row's + // field state carries across a map switch; the count shown then depends + // on whether anything happens to resync it, which is not a property to + // rely on. Keying by both makes the two rows distinct outright. + // + // Note the reuse is not reachable by tapping the switch — that moves + // focus, and the blur listener resyncs — so this asserts the key rather + // than a user-visible symptom. + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Food & potions (2)')); + await tester.pumpAndSettle(); + + const loaf = '/Script/Angelscript.ItFo_Loaf'; + expect( + find.byKey(const ValueKey((TraderStockMap.current, loaf))), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey((TraderStockMap.base, loaf))), + findsNothing, + ); + + await tester.tap(find.text('Restock baseline')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Food & potions (1)')); + await tester.pumpAndSettle(); + + // A distinct key, so the row cannot inherit the other map's field state. + expect( + find.byKey(const ValueKey((TraderStockMap.base, loaf))), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey((TraderStockMap.current, loaf))), + findsNothing, + ); + // And it shows the baseline's own count. + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey((TraderStockMap.base, loaf))), + matching: find.byType(TextField), + ), + ) + .controller + ?.text, + '9', + ); + }); + + testWidgets('the save aborts rather than splitting a trader/array pair', ( + tester, + ) async { + // Splitting them into two writes would slip past the core's refusal — + // each write is fine on its own — and report both as committed while the + // array operation renumbers the row the trade change was addressed by. + final core = _TraderCoreService(playerIsTrader: true); + await pumpApp(tester, core); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + final notifier = ProviderScope.containerOf( + tester.element(find.byType(Scaffold).first), + ).read(editorProvider.notifier); + notifier.setTraderStockEdit( + const TraderStockEdit( + kind: TraderEditKind.setStock, + index: 7, + map: TraderStockMap.current, + path: kTraderOrePath, + count: 5, + ), + ); + notifier.setPendingEdit( + 'all-data:m_Traders', + PendingSaveEdit(edits: [arrayRemoveOnTraders()]), + ); + await tester.pumpAndSettle(); + + final saved = await notifier.saveAllPending(); + await tester.pumpAndSettle(); + + expect(saved, isFalse); + expect( + core.requests.where((r) => r.command == 'write_save'), + isEmpty, + reason: 'nothing may reach the save while the pair is queued', + ); + }); + + testWidgets('the in-field undo restores the text while still focused', ( + tester, + ) async { + // The sync that would otherwise restore it is off while the field has + // focus, so the undo has to put the text back itself — or the discarded + // count sits there with nothing queued behind it and Save disabled. + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + final field = find.descendant(of: oreCard, matching: find.byType(TextField)); + await tester.tap(field); + await tester.pump(); + await tester.enterText(field, '4242'); + await tester.pump(); + expect(find.descendant(of: oreCard, matching: find.byIcon(Icons.undo)), findsOneWidget); + + await tester.tap(find.descendant(of: oreCard, matching: find.byIcon(Icons.undo))); + await tester.pump(); + + expect(tester.widget(field).controller?.text, '55'); + expect( + ProviderScope.containerOf(tester.element(find.byType(Scaffold).first)) + .read(editorProvider) + .pendingEdits, + isEmpty, + ); + }); + + testWidgets('a record missing a stock list is read-only', (tester) async { + // An omitted map reads as an empty one, so Add looked available and the + // save would then fail: the structural appliers resolve the property and + // cannot create it. + await pumpApp( + tester, + _TraderCoreService(playerIsTrader: true, stockMapsPresent: false), + ); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('does not support and cannot write'), + findsOneWidget, + ); + expect(find.widgetWithText(OutlinedButton, 'Add item'), findsNothing); + expect(find.byIcon(Icons.delete_outline), findsNothing); + }); + + testWidgets('a merchant holding only ore is not called empty', ( + tester, + ) async { + // The ore is lifted out of the live stock into its own card, so the + // filtered list is empty while the map is not. Reading "nothing in stock" + // off the filtered list contradicted both the line count and the purse + // shown right above it. + await pumpApp(tester, _TraderCoreService(playerIsTrader: true, oreOnly: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + expect(find.text('Nothing in stock.'), findsNothing); + expect(find.text('1 lines'), findsOneWidget); + expect(find.text('Ore (purchasing power)'), findsOneWidget); + }); + + testWidgets('the read-only note waits until nothing at all is writable', ( + tester, + ) async { + // A core with no stocked shop drops setStock but still offers addItem, so + // announcing "read only" beside a working Add button was simply wrong. + await pumpApp( + tester, + _TraderCoreService( + playerIsTrader: true, + writable: const ['private.traders.addItem'], + ), + ); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(OutlinedButton, 'Add item'), findsOneWidget); + expect( + find.textContaining('can only read trader data'), + findsNothing, + reason: 'Add works, so this core is not read-only', + ); + + // With nothing advertised at all, the note is the honest thing to show. + await pumpApp( + tester, + _TraderCoreService(playerIsTrader: true, writable: const []), + ); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + expect(find.textContaining('can only read trader data'), findsOneWidget); + }); + + testWidgets('a detail pane too narrow for labels shows bare icons', ( + tester, + ) async { + // Trade made six icon-and-label tabs out of five. A non-scrollable bar + // splits its width evenly, and the detail pane is a fraction of the + // window, so each tab gets far less than a label needs and the labels + // clip. Below the breakpoint they carry the icon alone. + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + + // The top-level bar also has six tabs, so pick the one carrying Trade. + final bar = find.ancestor( + of: detailTab('Trade'), + matching: find.byType(TabBar), + ); + final detail = tester.widget(bar); + expect(detailTabsCanCarryLabels(tester.getSize(bar).width), isFalse); + expect( + detail.tabs.whereType().map((t) => t.text), + everyElement(isNull), + reason: 'no label to clip', + ); + // And each one still says what it is. + expect(detailTab('Trade'), findsOneWidget); + expect(detailTab('Dialog Knowledge'), findsOneWidget); + }); + + testWidgets('an icon-only tab still names itself to a screen reader', ( + tester, + ) async { + // A Tooltip alone lands the name in the node's `tooltip`, which platforms + // surface as help text; the tab itself then reads out as "Tab 3 of 6". + final handle = tester.ensureSemantics(); + await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + + final node = tester.getSemantics(detailTab('Trade')); + expect(node.label, contains('Trade')); + handle.dispose(); + }); + + test('the tab bar takes labels once every tab has room for one', () { + // 132px is kTabLabelPadding either side plus the longest shipped label. + expect(detailTabsCanCarryLabels(6 * 132), isTrue); + expect(detailTabsCanCarryLabels(6 * 132 - 1), isFalse); + }); + + testWidgets('a merchant shows his ore and both stock sections', ( + tester, + ) async { + final core = _TraderCoreService(playerIsTrader: true); + await pumpApp(tester, core); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(detailTab('Trade')); + await tester.pumpAndSettle(); + + expect(find.text('Ore (purchasing power)'), findsOneWidget); + expect(find.text('Stock'), findsOneWidget); + expect(find.text('Restock baseline'), findsOneWidget); + // The detail is fetched by INDEX, never by the name. + final detail = core.requests.firstWhere( + (r) => r.command == 'private.traders.detail', + ); + expect(detail.payload['index'], 7); + expect(detail.payload.containsKey('uniqueName'), isFalse); + }); + }); +} + +class _RecordedRequest { + _RecordedRequest(this.command, this.payload); + final String command; + final Map payload; +} + +/// Minimal core fixture: one save, one player, and a trader array whose single +/// real row optionally carries the player's own unique name so the Handel tab +/// can be exercised without inventing a second character. +class _TraderCoreService implements GoresaveCoreService { + _TraderCoreService({ + this.playerIsTrader = false, + this.hasItemsByDifficulty = false, + this.orphanMerchant = false, + this.stockMapsPresent = true, + this.oreOnly = false, + this.writable, + }); + + /// Override the advertised command list. The core drops setStock when no shop + /// holds a line, while still offering addItem. + final List? writable; + + /// A merchant holding nothing but his ore: the live stock has one line, and + /// it is the one the ore card takes out of the list. + final bool oreOnly; + + /// A record missing one of its stock lists. No shipped save has one, which is + /// why the fixture has to fake it. + final bool stockMapsPresent; + + /// A knowledge-only row that owns a trader record. It has no spawned actor, + /// which is exactly why it used to be hidden from the trade panel. + final bool orphanMerchant; + + final bool playerIsTrader; + + /// Per-difficulty stock, which the editor does not model. Empty in every real + /// save seen so far, so the fixture has to fake it. + final bool hasItemsByDifficulty; + final requests = <_RecordedRequest>[]; + + /// Whatever unique name the app resolved for the pinned player row. The + /// fixture answers `private.traders.list` with this name so the panel's join + /// succeeds regardless of how the player row is keyed. + static const String _playerUniqueName = 'Hero'; + + @override + String get description => 'trader-fake-core'; + + @override + bool get isAvailable => true; + + @override + Future> execute( + String command, { + Map payload = const {}, + }) async { + requests.add(_RecordedRequest(command, Map.from(payload))); + switch (command) { + case 'scan_save_dir': + return { + 'ok': true, + 'data': { + 'saveRoot': r'C:\tmp\saves', + 'saves': [ + { + 'path': r'C:\tmp\saves\G1R-001.sav', + 'slot': 'G1R-001', + 'format': 'GSAV', + 'fileSize': 914367, + 'sha1': 'abc', + 'status': 'ok', + 'playerSaveName': 'Save', + 'chapterId': 1, + 'autoSave': true, + 'slotName': 'G1R-001', + }, + ], + 'profiles': [], + 'activeProfileId': null, + }, + }; + case 'inspect_save': + return { + 'ok': true, + 'data': { + 'format': 'GSAV', + 'path': payload['path'], + 'slot': 'G1R-001', + 'size': 914367, + 'sha1': 'abc', + 'public': {'slotName': 'G1R-001', 'playerSaveName': 'Save'}, + 'private': { + 'status': 'decoded', + 'preview': false, + 'decompressedSize': 9, + 'typedParse': {'status': 'ok', 'propertyCount': 1, 'maxDepth': 1}, + 'player': { + 'saveVersionNumber': 17, + 'playerName': 'Hero', + 'uniqueName': _playerUniqueName, + 'attributes': [], + 'writable': [], + }, + 'inventory': { + 'itemStackCount': 0, + 'items': [], + 'mainContainerPaths': [], + 'writable': [], + }, + }, + }, + }; + case 'private.traders.list': + return { + 'ok': true, + 'data': { + 'traders': [ + { + 'index': 0, + 'uniqueName': 'None', + 'itemCount': 4, + 'defaultItemCount': 4, + 'ore': 75, + 'totalSeconds': -1000, + 'traded': false, + 'generatedEventCount': 1, + 'placeholder': true, + }, + { + 'index': 7, + 'uniqueName': playerIsTrader + ? _playerUniqueName + : 'OC_STT_Dexter_329', + 'itemCount': 2, + 'defaultItemCount': 2, + 'ore': 55, + 'totalSeconds': 937101.34, + 'traded': true, + 'generatedEventCount': 11, + 'placeholder': false, + }, + ], + 'writable': + writable ?? + const [ + 'private.traders.addItem', + 'private.traders.setStock', + 'private.traders.removeItem', + ], + }, + }; + case 'private.traders.detail': + return { + 'ok': true, + 'data': { + 'index': payload['index'], + 'uniqueName': playerIsTrader + ? _playerUniqueName + : 'OC_STT_Dexter_329', + 'itemCount': 2, + 'defaultItemCount': 2, + 'ore': 55, + 'totalSeconds': 937101.34, + 'traded': true, + 'generatedEventCount': 11, + 'placeholder': false, + 'stockMapsPresent': stockMapsPresent, + 'items': [ + { + 'path': kTraderOrePath, + 'id': 'ItMi_Orenugget', + 'count': 55, + 'unknownItem': false, + }, + if (!oreOnly) ...[ + { + 'path': '/Script/Angelscript.ItFo_Loaf', + 'id': 'ItFo_Loaf', + 'count': 3, + 'unknownItem': false, + }, + { + 'path': '/Script/Angelscript.ItFo_Apple', + 'id': 'ItFo_Apple', + 'count': 7, + 'unknownItem': false, + }, + { + 'path': '/Script/Angelscript.ItMw_1H_Sword_01', + 'id': 'ItMw_1H_Sword_01', + 'count': 1, + 'unknownItem': false, + }, + { + 'path': '/Script/Angelscript.ItAm_Arrow', + 'id': 'ItAm_Arrow', + 'count': 18, + 'unknownItem': false, + }, + ], + ], + 'defaultItems': [ + { + 'path': kTraderOrePath, + 'id': 'ItMi_Orenugget', + 'count': 64, + 'unknownItem': false, + }, + { + // Deliberately unlike the live stock's 3: a row reused across + // the map switch would keep showing the wrong one. + 'path': '/Script/Angelscript.ItFo_Loaf', + 'id': 'ItFo_Loaf', + 'count': 9, + 'unknownItem': false, + }, + ], + 'generatedEvents': ['OnWorldStart'], + 'hasItemsByDifficulty': hasItemsByDifficulty, + }, + }; + case 'list_backups': + return { + 'ok': true, + 'data': { + 'path': payload['path'], + 'backups': [], + 'companionBackups': [], + }, + }; + case 'check_codec': + return { + 'ok': true, + 'data': { + 'available': true, + 'canDecompress': true, + 'canCompress': true, + 'status': 'ready', + 'adapter': 'pure_rust_kraken', + 'message': 'Codec host is ready.', + }, + }; + case 'private.characters.list': + return { + 'ok': true, + 'data': { + 'total': orphanMerchant ? 1 : 0, + 'characters': orphanMerchant + ? [ + { + 'globalId': null, + 'uniqueName': 'OC_STT_Fisk_311', + 'isDead': false, + 'personalRelationship': null, + 'hasInventory': false, + 'hasKnowledge': true, + 'hasEvents': false, + 'isTrader': true, + }, + ] + : [], + }, + }; + case 'write_save': + return { + 'ok': true, + 'data': {'backupPath': r'C:\tmp\saves\G1R-001.sav.bak.1'}, + }; + default: + return { + 'ok': false, + 'error': {'message': 'Unhandled fake command $command'}, + }; + } + } +} diff --git a/apps/save-editor/test/widget_test.dart b/apps/save-editor/test/widget_test.dart index 4e632d60b..25aa5ede4 100644 --- a/apps/save-editor/test/widget_test.dart +++ b/apps/save-editor/test/widget_test.dart @@ -10,6 +10,7 @@ import 'package:goresave/features/editor/domain/editor_settings_store.dart'; import 'package:goresave/providers/data_providers.dart'; import 'support/ui_settings_test_store.dart'; +import 'support/detail_tabs.dart'; void main() { testWidgets('renders editor shell with fake save data', (tester) async { @@ -114,7 +115,7 @@ void main() { // by default in the shared master list, so the player attribute view shows. await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Attributes')); + await tester.tap(detailTab('Attributes')); await tester.pumpAndSettle(); // Player summary card and name editor fields are deleted. @@ -163,7 +164,7 @@ void main() { // sub-tab (its only home; two copies would both drive the one 'transform' // pending key). It renders there regardless of privateTypedVerified, so // this legacy fixture still reaches it. - await tester.tap(find.widgetWithText(Tab, 'Position')); + await tester.tap(detailTab('Position')); await tester.pumpAndSettle(); expect(find.widgetWithText(TextField, 'Location X'), findsOneWidget); expect(find.widgetWithText(TextField, 'Location Y'), findsOneWidget); @@ -214,7 +215,7 @@ void main() { // Still inside the Charaktere tab from the Attributes navigation above, so // switching to the Inventar sub-tab needs no Characters prefix. The shared // Player selection carries over, so the player inventory shows. - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); // Stacks are grouped by category in a sidebar; Food is selected first @@ -755,7 +756,7 @@ void main() { // Inventory is now a sub-tab inside the Charaktere (Characters) tab. await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Inventory')); + await tester.tap(detailTab('Inventory')); await tester.pumpAndSettle(); // Food is the first category, so the non-removable Cheese stack is visible. diff --git a/crates/gore-save/src/factions.rs b/crates/gore-save/src/factions.rs index e7678480a..aae7c5dff 100644 --- a/crates/gore-save/src/factions.rs +++ b/crates/gore-save/src/factions.rs @@ -194,7 +194,7 @@ fn find_crime_blob(root: &RootObject) -> Option<(Vec, &[Property])> { /// anywhere in the tree. Returns the path of `m_GenericData` (with `{key}`/`[i]` /// segments) and the instanced struct's property list. Used for both the crime /// blob and the `GameTime` subsystem. -fn find_generic_instanced<'a>( +pub(crate) fn find_generic_instanced<'a>( root: &'a RootObject, target_key: &str, ) -> Option<(Vec, &'a [Property])> { diff --git a/crates/gore-save/src/lib.rs b/crates/gore-save/src/lib.rs index 0c7cf6af4..3447cb55c 100644 --- a/crates/gore-save/src/lib.rs +++ b/crates/gore-save/src/lib.rs @@ -7,6 +7,7 @@ pub mod properties; pub mod skills; pub mod startsaves; pub mod story; +pub mod traders; use base64::{Engine as _, engine::general_purpose}; use serde::{Deserialize, Serialize}; @@ -491,6 +492,18 @@ fn execute_json_inner(input: &str) -> Result { let codec_backend = Some(&kraken_backend as &dyn codec_backend::CodecBackend); list_guild_crimes_command(&path, codec_backend) } + "private.traders.list" => { + let path = required_path(&payload)?; + let kraken_backend = codec_backend::KrakenBackend::default(); + let codec_backend = Some(&kraken_backend as &dyn codec_backend::CodecBackend); + traders_list_command(&path, codec_backend) + } + "private.traders.detail" => { + let path = required_path(&payload)?; + let kraken_backend = codec_backend::KrakenBackend::default(); + let codec_backend = Some(&kraken_backend as &dyn codec_backend::CodecBackend); + trader_detail_command(&path, &payload, codec_backend) + } "validate_roundtrip" => { let path = required_path(&payload)?; Ok(validate_roundtrip(&path)?) @@ -6570,6 +6583,71 @@ fn npc_inventory_command( Ok(summary) } +/// `private.traders.list`: every merchant's shop record in array order. +/// +/// Payload: `{ path }`. Returns `{ traders: [...], writable: [...] }`. +/// +/// The generic typed edit cannot reach these values — it refuses paths that end +/// on a map entry — so the trader commands are the only way in and advertise +/// themselves here for the app to feature-detect. +fn traders_list_command( + path: &Path, + backend: Option<&dyn codec_backend::CodecBackend>, +) -> Result { + let backend = backend.ok_or_else(|| { + CoreError::Codec("reading traders requires a working codec backend".to_string()) + })?; + let root = decode_private_root_cached(path, backend)?; + let traders = traders::list_traders(&root)?; + // addItem needs nothing but a trader row; setStock and removeItem need a + // line that already exists, so they are advertised only when there is one. + let mut writable = vec!["private.traders.addItem"]; + if traders + .iter() + .any(|t| t.item_count > 0 || t.default_item_count > 0) + { + writable.push("private.traders.setStock"); + writable.push("private.traders.removeItem"); + } + Ok(json!({ + "traders": traders, + "writable": writable, + })) +} + +/// `private.traders.detail`: one merchant's full record. +/// +/// Payload: `{ path, index }` — or `{ path, uniqueName }`, which resolves to an +/// index and fails on an ambiguous name rather than guessing. Two shipped rows +/// share the name `None`, so the index is the authoritative address. +fn trader_detail_command( + path: &Path, + payload: &Value, + backend: Option<&dyn codec_backend::CodecBackend>, +) -> Result { + let backend = backend.ok_or_else(|| { + CoreError::Codec("reading traders requires a working codec backend".to_string()) + })?; + let root = decode_private_root_cached(path, backend)?; + let index = match payload.get("index").and_then(Value::as_u64) { + Some(i) => i as usize, + None => { + let name = payload + .get("uniqueName") + .and_then(Value::as_str) + .ok_or_else(|| { + CoreError::InvalidRequest( + "missing payload.index or payload.uniqueName".to_string(), + ) + })?; + traders::index_of_unique_name(&traders::list_traders(&root)?, name)? + } + }; + let detail = traders::trader_detail(&root, index)?; + serde_json::to_value(detail) + .map_err(|e| CoreError::Parse(format!("serializing trader detail failed: {e}"))) +} + /// Property lookup inside a struct-valued map entry (tagged property list or /// InstancedStruct wrapper). pub(crate) fn struct_member<'a>( @@ -8902,6 +8980,21 @@ fn apply_private_edits( parse_private_inventory_reset_edit(edit).map(PrivateEdit::InventoryReset) } "private.inventory.repairSlots" => Ok(PrivateEdit::InventoryRepairSlots), + // Length-neutral: the count is a bare i32 at the tail of its map + // entry, so several of these batch safely into one write. Do NOT + // list it as a splicing edit. + "private.traders.setStock" => { + parse_private_traders_set_stock_edit(edit).map(PrivateEdit::TraderSetStock) + } + // Structural: both splice the map body and shift every later offset, + // so they are listed as splicing edits below and must stand alone. + "private.traders.addItem" => { + parse_private_traders_stock_line_edit(edit, true).map(PrivateEdit::TraderAddItem) + } + "private.traders.removeItem" => { + parse_private_traders_stock_line_edit(edit, false) + .map(PrivateEdit::TraderRemoveItem) + } "private.story.apply" => { parse_private_story_apply_edit(edit).map(PrivateEdit::StoryApply) } @@ -9106,6 +9199,16 @@ fn path_enters_map_entry(path: &[properties::PathSeg], map: &str, key: &str) -> } } +/// Whether a raw typed path addresses the trader ARRAY itself, which is the only +/// shape that renumbers its rows. +/// +/// A path that merely runs THROUGH the array — `m_Traders[7].m_TotalSeconds`, or +/// a container edit on something inside one row — leaves every row where it is, +/// and refusing those would block pairs that are perfectly safe. +fn path_targets_the_trader_array(path: &[properties::PathSeg]) -> bool { + matches!(path.last(), Some(properties::PathSeg::Name(name)) if name == "m_Traders") +} + fn path_has_name(path: &[properties::PathSeg], name: &str) -> bool { path.iter() .any(|segment| matches!(segment, properties::PathSeg::Name(found) if found == name)) @@ -9217,6 +9320,26 @@ fn structured_edit_target(edit: &PrivateEdit) -> Option<(&'static str, String)> glossary.segment_asset.as_str(), ]), )), + // Both are declarative about ONE line: a count it shall hold, or that it + // shall not exist. Sharing the key makes the same-target rule reject the + // pair — a set followed by a removal applied the count and then deleted + // the line it lived in, reporting both as applied. Two different lines + // still carry two keys, so unrelated edits keep batching. addItem is + // deliberately absent: it ADDS to the map, which is what a batch is for. + PrivateEdit::TraderSetStock(stock) => Some(( + "stock line of that trader", + key([ + &format!("{}\u{1e}{}", stock.index, stock.map.property_name()), + stock.path.as_str(), + ]), + )), + PrivateEdit::TraderRemoveItem(line) => Some(( + "stock line of that trader", + key([ + &format!("{}\u{1e}{}", line.index, line.map.property_name()), + line.path.as_str(), + ]), + )), _ => None, } } @@ -9274,6 +9397,16 @@ fn structured_edit_rewrites(edit: &PrivateEdit, path: &[properties::PathSeg]) -> // Narrower still: it only rewrites ids, but it does so across every // container in the save, so it is not scoped to one actor. PrivateEdit::InventoryRepairSlots => path_writes_a_slot_id(path), + // A trader edit is addressed by its row's position in m_Traders, and a + // raw array operation ON that array renumbers the rows. Splitting the + // two into separate writes does not rescue them: the index came from a + // list the caller read BEFORE either ran, so whichever goes second + // resolves it against a layout the first moved — and if the raw edit + // removed that very row, the trader change lands on a neighbour while + // the write reports success. Refuse the pair whichever way round. + PrivateEdit::TraderSetStock(_) + | PrivateEdit::TraderAddItem(_) + | PrivateEdit::TraderRemoveItem(_) => path_targets_the_trader_array(path), _ => false, } } @@ -9313,6 +9446,9 @@ fn may_invalidate_caller_ordinals(edit: &PrivateEdit) -> bool { // Splices the StoryPropertyValues map. Already exclusive; listed so the // classification is complete rather than relying on the other guard. PrivateEdit::StoryApply(_) => true, + // Splice an entry into or out of a trader's stock map, which changes how + // many entries it holds and renumbers every later one. + PrivateEdit::TraderAddItem(_) | PrivateEdit::TraderRemoveItem(_) => true, // A setValue resolves to a scalar, a string or a native struct, so it can // add or drop no container element — but writing a slot's m_Id renumbers @@ -9326,6 +9462,9 @@ fn may_invalidate_caller_ordinals(edit: &PrivateEdit) -> bool { PrivateEdit::PlayerAttribute(_) | PrivateEdit::PlayerTransform(_) | PrivateEdit::InventoryItemCount(_) => false, + // Overwrites a bare i32 at the tail of an existing map entry: no entry is + // added or dropped, nothing moves, no slot id is touched. + PrivateEdit::TraderSetStock(_) => false, } } @@ -9359,6 +9498,12 @@ fn carries_caller_ordinal(edit: &PrivateEdit) -> bool { // moves the window it searches. PrivateEdit::InventoryItemCount(edit) => edit.slot_id.is_some() || edit.actor_id.is_none(), PrivateEdit::InventoryRemoveItem(edit) => edit.slot_id.is_some(), + // Every trader edit addresses its row by an index into m_Traders that the + // caller read off a `private.traders.list` taken before this write. The + // line itself is addressed by item path, which no peer can renumber. + PrivateEdit::TraderSetStock(_) + | PrivateEdit::TraderAddItem(_) + | PrivateEdit::TraderRemoveItem(_) => true, _ => false, } } @@ -9480,6 +9625,118 @@ enum PrivateEdit { KnowledgeSetEntry(PrivateKnowledgeSetEntryEdit), FactionsForgive(PrivateFactionsForgiveEdit), SkillSet(skills::SkillSetEdit), + TraderSetStock(traders::SetStockEdit), + TraderAddItem(traders::StockLineEdit), + TraderRemoveItem(traders::StockLineEdit), +} + +/// `private.traders.addItem` / `private.traders.removeItem` — insert or drop one +/// stock line. +/// +/// Value: `{ index, path, map?, count? }`. `count` is required for an insert and +/// ignored for a removal. Like `setStock`, the trader is addressed by ARRAY INDEX +/// because `m_TradersUniqueName` is not unique. +fn parse_private_traders_stock_line_edit( + edit: &Edit, + needs_count: bool, +) -> Result { + let op = if needs_count { "addItem" } else { "removeItem" }; + let value = edit.value.as_object().ok_or_else(|| { + CoreError::InvalidRequest(format!("private.traders.{op} value must be an object")) + })?; + let index = value.get("index").and_then(Value::as_u64).ok_or_else(|| { + CoreError::InvalidRequest(format!( + "private.traders.{op} requires integer value.index" + )) + })? as usize; + let path = value + .get("path") + .and_then(Value::as_str) + .filter(|p| !p.trim().is_empty()) + .ok_or_else(|| { + CoreError::InvalidRequest(format!( + "private.traders.{op} requires a non-empty value.path" + )) + })? + .to_string(); + let count = if needs_count { + let raw = value.get("count").and_then(Value::as_i64).ok_or_else(|| { + CoreError::InvalidRequest( + "private.traders.addItem requires integer value.count".to_string(), + ) + })?; + // Sold-out lines are deleted rather than left at zero, so inserting a + // zero-count line would write a state the game never produces. + if !(1..=i32::MAX as i64).contains(&raw) { + return Err(CoreError::InvalidRequest( + "private.traders.addItem value.count must be a positive i32".to_string(), + )); + } + raw as i32 + } else { + 0 + }; + let map = match value.get("map").and_then(Value::as_str) { + Some(raw) => traders::StockMap::parse(raw)?, + None => traders::StockMap::Current, + }; + Ok(traders::StockLineEdit { + index, + map, + path, + count, + }) +} + +/// `private.traders.setStock` — set one existing stock line of one trader. +/// +/// Value: `{ index, path, count, map? }`. `map` is `"current"` (default) or +/// `"default"`. The trader is addressed by ARRAY INDEX because +/// `m_TradersUniqueName` is not unique (two shipped rows are named `None`). +fn parse_private_traders_set_stock_edit(edit: &Edit) -> Result { + let value = edit.value.as_object().ok_or_else(|| { + CoreError::InvalidRequest("private.traders.setStock value must be an object".to_string()) + })?; + let index = value.get("index").and_then(Value::as_u64).ok_or_else(|| { + CoreError::InvalidRequest( + "private.traders.setStock requires integer value.index".to_string(), + ) + })? as usize; + let path = value + .get("path") + .and_then(Value::as_str) + .filter(|p| !p.trim().is_empty()) + .ok_or_else(|| { + CoreError::InvalidRequest( + "private.traders.setStock requires a non-empty value.path".to_string(), + ) + })? + .to_string(); + let count = value.get("count").and_then(Value::as_i64).ok_or_else(|| { + CoreError::InvalidRequest( + "private.traders.setStock requires integer value.count".to_string(), + ) + })?; + // A sold-out line is DELETED from the map, never left at zero — 604 stock + // entries across a played save hold no zero and no negative. Writing one + // would put the record in a state the game never produces, so a caller who + // means "he no longer offers this" has to say so with removeItem. + if !(1..=i32::MAX as i64).contains(&count) { + return Err(CoreError::InvalidRequest( + "private.traders.setStock value.count must be a positive i32; drop the line with private.traders.removeItem instead of setting it to 0" + .to_string(), + )); + } + let map = match value.get("map").and_then(Value::as_str) { + Some(raw) => traders::StockMap::parse(raw)?, + None => traders::StockMap::Current, + }; + Ok(traders::SetStockEdit { + index, + map, + path, + count: count as i32, + }) } fn parse_private_story_apply_edit(edit: &Edit) -> Result, CoreError> { @@ -11006,6 +11263,9 @@ fn apply_private_edit_to_payload_uncached( PrivateEdit::SkillSet(edit) => { skills::apply_skill_set(payload, edit, &mut PayloadRoot::default()) } + PrivateEdit::TraderSetStock(edit) => traders::apply_set_stock(payload, edit), + PrivateEdit::TraderAddItem(edit) => traders::apply_add_item(payload, edit), + PrivateEdit::TraderRemoveItem(edit) => traders::apply_remove_item(payload, edit), PrivateEdit::NpcRevive(edit) => npc::apply_revive(payload, &edit.id), PrivateEdit::NpcRelationship(edit) => { npc::apply_relationship(payload, &edit.id, edit.relationship) diff --git a/crates/gore-save/src/npc.rs b/crates/gore-save/src/npc.rs index 67d04b0b9..92066b423 100644 --- a/crates/gore-save/src/npc.rs +++ b/crates/gore-save/src/npc.rs @@ -192,6 +192,9 @@ pub struct CharacterSummary { pub has_inventory: bool, pub has_knowledge: bool, pub has_events: bool, + /// The character owns a row in the global trader array, i.e. he runs a shop. + /// Joined here so the master list can badge merchants without a second query. + pub is_trader: bool, } /// The character's UniqueName key: the GlobalId prefix before the first `-`, in @@ -639,6 +642,17 @@ fn personal_relationships_by_id(root: &RootObject) -> HashMap Result, CoreError> { + // Merchants, by unique name. A save with no trader array at all is not a + // reason to fail the whole character list, so an error here means "nobody + // trades" rather than "no characters". + let traders: HashSet = crate::traders::list_traders(root) + .map(|rows| { + rows.into_iter() + .filter(|t| !t.placeholder) + .map(|t| t.unique_name.to_ascii_lowercase()) + .collect() + }) + .unwrap_or_default(); // Knowledge keys in ORIGINAL case, indexed by lowercased form for the join. let knowledge_orig = map_keys(root, "CharacterKnowledgeByUniqueName"); let knowledge_by_lower: HashMap = knowledge_orig @@ -671,6 +685,7 @@ pub fn list_characters(root: &RootObject) -> Result, CoreE has_inventory: inventory.contains(&id_lower), has_knowledge, has_events: events.contains(&id_lower), + is_trader: traders.contains(&lk), }); } // Knowledge-only orphans (original case), no matching actor charKey. @@ -680,6 +695,7 @@ pub fn list_characters(root: &RootObject) -> Result, CoreE .collect(); orphans.sort(); for key in orphans { + let is_trader = traders.contains(&key.to_ascii_lowercase()); out.push(CharacterSummary { global_id: None, unique_name: key, @@ -687,6 +703,9 @@ pub fn list_characters(root: &RootObject) -> Result, CoreE personal_relationship: None, has_inventory: false, has_knowledge: true, + // A trader row is keyed by name alone, so a knowledge-only row can + // own one even with no actor behind it. + is_trader, has_events: false, }); } diff --git a/crates/gore-save/src/traders.rs b/crates/gore-save/src/traders.rs new file mode 100644 index 000000000..cd6346633 --- /dev/null +++ b/crates/gore-save/src/traders.rs @@ -0,0 +1,1304 @@ +//! Trader shop data: what each merchant offers for sale and how much ore he has +//! to buy with. +//! +//! Every trader in the game lives in ONE global array, not on the NPC actor: +//! `m_GenericData["GameStateDataBase"].m_Traders` — an `ArrayProperty` of +//! `FTraderData` (`G1R.hpp:3867`) with exactly six members: +//! +//! - `m_TradersUniqueName` (`NameProperty`): the NPC's GlobalId without the +//! `-WorldPointActor…` suffix. **Not unique** — shipped saves carry two +//! `None` sentinel rows, so rows are addressed by ARRAY INDEX. +//! - `m_Items` (`MapProperty`): the live stock. +//! This is both "what he sells" and "how much ore he can pay with" — the +//! ore is an ordinary entry keyed [`ORE_PATH`]. +//! - `m_DefaultItems` (same shape): the restock baseline. NOT a frozen vanilla +//! snapshot; it grows as story events grant new batches. +//! - `m_GeneratedEvents` (`ArrayProperty`): which batches this +//! trader has already been granted (the idempotency ledger). +//! - `m_ItemsByDifficulty` (`MapProperty`): empty in every save observed. +//! - `m_TotalSeconds` (`DoubleProperty`): world-clock stamp of the last trade +//! session, [`NEVER_TRADED`] when the player has never traded here. It is a +//! timestamp, NOT a restock trigger. +//! +//! Sold-out items are REMOVED from `m_Items`, never left at zero, so "restock +//! this line" is structurally an insert rather than a set. + +use serde::Serialize; + +use crate::CoreError; +use crate::properties::{Property, PropertyValue, RootObject}; + +/// The `m_GenericData` key holding the game-state blob that owns `m_Traders`. +const GAME_STATE_KEY: &str = "GameStateDataBase"; + +/// The array of per-trader shop records inside that blob. +const TRADERS_PROPERTY: &str = "m_Traders"; + +/// Ore is the colony's currency, and a trader's stock entry for it is his +/// purchasing power ("Liquidität" in the game's own trading tutorial). +pub const ORE_PATH: &str = "/Script/Angelscript.ItMi_Orenugget"; + +/// `m_TotalSeconds` sentinel for "the player has never traded with this NPC". +pub const NEVER_TRADED: f64 = -1000.0; + +/// The placeholder value `m_TradersUniqueName` carries on the rows that belong +/// to no NPC. Two shipped rows share it, which is why nothing may be addressed +/// by name alone. +const PLACEHOLDER_NAME: &str = "None"; + +/// One line of a trader's stock: an item class and how many he holds. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TraderItem { + /// Full class path, e.g. `/Script/Angelscript.ItFo_Loaf`. This is the map key. + pub path: String, + /// Bare class name, e.g. `ItFo_Loaf`. + pub id: String, + pub count: i32, + /// `true` when the path is not in the bundled item catalog — shown, but not + /// offered as an edit target. + pub unknown_item: bool, +} + +/// A trader as shown in a list: enough to pick one, not the whole stock. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TraderSummary { + /// Position in `m_Traders`. The ONLY safe address for an edit. + pub index: usize, + pub unique_name: String, + /// How many distinct item classes he currently stocks (ore included). + pub item_count: usize, + pub default_item_count: usize, + /// His ore, i.e. what he can pay with. `None` when he carries no ore entry + /// at all — a real state (Riordian, Scorpio, Xardas), not an error. + pub ore: Option, + pub total_seconds: f64, + /// `false` while `total_seconds` is still [`NEVER_TRADED`]. + pub traded: bool, + pub generated_event_count: usize, + /// `true` for the unnamed sentinel rows, which belong to no NPC. + pub placeholder: bool, + /// Both stock maps are present AND shaped the way every applier assumes: + /// an object-path key and a bare `i32` value. + /// + /// An omitted map reads as an empty one, and an EMPTY map of some other + /// shape has no entries to give that shape away — either would look + /// editable and then fail at save time, since the appliers resolve the + /// property, encode an object key and patch four bytes. Every shipped save + /// carries both maps in that shape on all 31 rows, so this guards a shape + /// we have not seen rather than a known state. + pub stock_maps_present: bool, +} + +/// Everything stored for one trader. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TraderDetail { + #[serde(flatten)] + pub summary: TraderSummary, + /// Live stock, sorted by class name. + pub items: Vec, + /// Restock baseline, sorted by class name. Diverges from `items` in played + /// saves in both values AND key set. + pub default_items: Vec, + pub generated_events: Vec, + /// `true` when `m_ItemsByDifficulty` holds entries. Empty in every save + /// observed so far; if this ever flips, the staging map needs modelling. + pub has_items_by_difficulty: bool, +} + +/// Locate `m_Traders` and return its elements. +/// +/// Fails rather than returning an empty list when the array is missing: an empty +/// result would be indistinguishable from "this save has no traders", which is +/// not a state the game produces. +fn traders_array(root: &RootObject) -> Result<&[PropertyValue], CoreError> { + let (_, props) = + crate::factions::find_generic_instanced(root, GAME_STATE_KEY).ok_or_else(|| { + CoreError::Parse(format!("m_GenericData[\"{GAME_STATE_KEY}\"] not found")) + })?; + let property = props + .iter() + .find(|p| p.name == TRADERS_PROPERTY) + .ok_or_else(|| { + CoreError::Parse(format!("{GAME_STATE_KEY} has no {TRADERS_PROPERTY} array")) + })?; + match &property.value { + PropertyValue::Array { elements } => Ok(elements.as_slice()), + _ => Err(CoreError::Parse(format!( + "{TRADERS_PROPERTY} is not an ArrayProperty" + ))), + } +} + +/// The tagged property list of one `FTraderData` element. +fn element_props(element: &PropertyValue) -> Option<&[Property]> { + match element { + PropertyValue::Struct(crate::properties::StructValue::Properties(p)) => Some(p.as_slice()), + PropertyValue::Struct(crate::properties::StructValue::Instanced(Some(i))) => { + Some(i.properties.as_slice()) + } + _ => None, + } +} + +fn member<'a>(props: &'a [Property], name: &str) -> Option<&'a PropertyValue> { + props.iter().find(|p| p.name == name).map(|p| &p.value) +} + +fn property<'a>(props: &'a [Property], name: &str) -> Option<&'a Property> { + props.iter().find(|p| p.name == name) +} + +/// Whether a stock map is there AND carries the key/value types every applier +/// assumes. Checked on the DESCRIPTOR, because an empty map has no entry to +/// check and `read_stock` can only see the entries. +/// +/// Both failures land on the same flag, so whatever reports it has to describe +/// both: a record can be missing a map, or carry one the appliers cannot write. +fn stock_map_is_writable(props: &[Property], name: &str) -> bool { + let Some(property) = property(props, name) else { + return false; + }; + if property.type_name != "MapProperty" { + return false; + } + match property.descriptor.map.as_deref() { + Some((key, value)) => key.type_name == "ObjectProperty" && value.type_name == "IntProperty", + None => false, + } +} + +/// Read one stock map, verifying its descriptor as it goes. +/// +/// The descriptor check is the write guard in disguise: an edit command patches +/// the last four bytes of an entry on the assumption that keys serialize as an +/// object path and values as a bare `i32`. If a save ever violates that, we must +/// refuse loudly here rather than hand back plausible numbers that a later write +/// would splice into the wrong offset. +fn read_stock(value: Option<&PropertyValue>, what: &str) -> Result, CoreError> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let PropertyValue::Map { entries, .. } = value else { + return Err(CoreError::Parse(format!("{what} is not a MapProperty"))); + }; + let mut items = Vec::with_capacity(entries.len()); + for (key, val) in entries { + let PropertyValue::Object(path) = key else { + return Err(CoreError::Parse(format!( + "{what} key is not an ObjectProperty" + ))); + }; + let PropertyValue::Int(count) = val else { + return Err(CoreError::Parse(format!( + "{what}[{path}] is not an IntProperty" + ))); + }; + items.push(TraderItem { + id: class_name(path).to_string(), + unknown_item: !crate::is_item_definition_class(path), + path: path.clone(), + count: *count, + }); + } + items.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(items) +} + +/// `/Script/Angelscript.ItFo_Loaf` → `ItFo_Loaf`. +fn class_name(path: &str) -> &str { + path.rsplit('.').next().unwrap_or(path) +} + +/// A trader's ore, looked up BY KEY. +/// +/// Never read positionally: the ore is not reliably the first entry (Cronos and +/// Riordian lead with `ItMs_Remedy`, Fisk with `ItKe_Lockpick`). +fn ore_of(items: &[TraderItem]) -> Option { + items.iter().find(|i| i.path == ORE_PATH).map(|i| i.count) +} + +fn summarize( + index: usize, + props: &[Property], +) -> Result<(TraderSummary, Vec, Vec), CoreError> { + let unique_name = match member(props, "m_TradersUniqueName") { + Some(PropertyValue::Name(n)) => n.clone(), + Some(_) => { + return Err(CoreError::Parse(format!( + "trader[{index}].m_TradersUniqueName is not a NameProperty" + ))); + } + None => PLACEHOLDER_NAME.to_string(), + }; + let items = read_stock( + member(props, "m_Items"), + &format!("trader[{index}].m_Items"), + )?; + let default_items = read_stock( + member(props, "m_DefaultItems"), + &format!("trader[{index}].m_DefaultItems"), + )?; + let total_seconds = match member(props, "m_TotalSeconds") { + Some(PropertyValue::Double(d)) => *d, + _ => NEVER_TRADED, + }; + let generated_event_count = match member(props, "m_GeneratedEvents") { + Some(PropertyValue::Array { elements }) => elements.len(), + _ => 0, + }; + let summary = TraderSummary { + index, + stock_maps_present: stock_map_is_writable(props, "m_Items") + && stock_map_is_writable(props, "m_DefaultItems"), + placeholder: unique_name == PLACEHOLDER_NAME, + unique_name, + item_count: items.len(), + default_item_count: default_items.len(), + ore: ore_of(&items), + total_seconds, + traded: total_seconds > NEVER_TRADED, + generated_event_count, + }; + Ok((summary, items, default_items)) +} + +/// Every trader in the save, in array order. +pub fn list_traders(root: &RootObject) -> Result, CoreError> { + let elements = traders_array(root)?; + let mut out = Vec::with_capacity(elements.len()); + for (index, element) in elements.iter().enumerate() { + let props = element_props(element) + .ok_or_else(|| CoreError::Parse(format!("trader[{index}] is not a struct element")))?; + out.push(summarize(index, props)?.0); + } + Ok(out) +} + +/// One trader's full record, addressed by array index. +pub fn trader_detail(root: &RootObject, index: usize) -> Result { + let elements = traders_array(root)?; + let element = elements.get(index).ok_or_else(|| { + CoreError::InvalidRequest(format!( + "trader index {index} out of range (have {})", + elements.len() + )) + })?; + let props = element_props(element) + .ok_or_else(|| CoreError::Parse(format!("trader[{index}] is not a struct element")))?; + let (summary, items, default_items) = summarize(index, props)?; + let generated_events = match member(props, "m_GeneratedEvents") { + Some(PropertyValue::Array { elements }) => elements + .iter() + .map(|e| match e { + PropertyValue::Str(s) => s.clone(), + other => format!("{other:?}"), + }) + .collect(), + _ => Vec::new(), + }; + let has_items_by_difficulty = matches!( + member(props, "m_ItemsByDifficulty"), + Some(PropertyValue::Map { entries, .. }) if !entries.is_empty() + ); + Ok(TraderDetail { + summary, + items, + default_items, + generated_events, + has_items_by_difficulty, + }) +} + +/// Resolve a trader by `m_TradersUniqueName`. +/// +/// Case-insensitively, because a caller's name comes from +/// `private.characters.list`, which returns the stored knowledge key where one +/// exists — and that key's casing can differ from the trader row's while the +/// same list marks the character a trader through a lowercase join. An exact +/// compare would mark him and then fail to find him. +/// +/// Rejects an ambiguous name instead of picking the first hit: the two sentinel +/// rows share the name `None` and are otherwise indistinguishable, so silently +/// choosing one would edit an arbitrary record. +pub fn index_of_unique_name( + summaries: &[TraderSummary], + unique_name: &str, +) -> Result { + let wanted = unique_name.to_ascii_lowercase(); + let mut matches = summaries + .iter() + .filter(|s| s.unique_name.to_ascii_lowercase() == wanted); + let first = matches + .next() + .ok_or_else(|| CoreError::InvalidRequest(format!("no trader named {unique_name}")))?; + if matches.next().is_some() { + return Err(CoreError::InvalidRequest(format!( + "trader name {unique_name} is ambiguous; address by index" + ))); + } + Ok(first.index) +} + +/// Which of a trader's two stock maps an edit targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StockMap { + /// `m_Items` — what he has right now. + Current, + /// `m_DefaultItems` — what he restocks back toward. + Default, +} + +impl StockMap { + pub fn property_name(self) -> &'static str { + match self { + StockMap::Current => "m_Items", + StockMap::Default => "m_DefaultItems", + } + } + + pub fn parse(raw: &str) -> Result { + match raw { + "current" | "m_Items" => Ok(StockMap::Current), + "default" | "m_DefaultItems" => Ok(StockMap::Default), + other => Err(CoreError::InvalidRequest(format!( + "unknown stock map {other:?}; expected \"current\" or \"default\"" + ))), + } + } +} + +/// Set one existing stock line to a new count. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetStockEdit { + pub index: usize, + pub map: StockMap, + /// Full item class path, i.e. the map key. + pub path: String, + pub count: i32, +} + +/// Apply a stock-count change in place. +/// +/// This is a fixed-size write: the value is a bare `i32` at the tail of its map +/// entry, so nothing moves and no enclosing size field changes. That is why +/// several of these batch safely into one save, unlike the insert/remove ops. +/// +/// Only EXISTING lines can be set. A sold-out item is deleted from the map +/// rather than left at zero, so "put it back" is an insert and belongs to a +/// different command — silently creating the entry here would hide that +/// difference behind a write that cannot actually do it. +pub fn apply_set_stock(payload: &mut [u8], edit: &SetStockEdit) -> Result<(), CoreError> { + // Zero is not a count the game writes: it deletes the line instead. Refuse + // it here as well as at the request boundary, since this is public. + if edit.count < 1 { + return Err(CoreError::InvalidRequest(format!( + "stock count must be positive; remove {} instead of setting it to {}", + edit.path, edit.count + ))); + } + let root = crate::properties::parse_private_root(payload)?; + let (generic_path, _) = crate::factions::find_generic_instanced(&root, GAME_STATE_KEY) + .ok_or_else(|| { + CoreError::Parse(format!("m_GenericData[\"{GAME_STATE_KEY}\"] not found")) + })?; + + let mut segments = generic_path; + segments.push(format!("{{{GAME_STATE_KEY}}}")); + segments.push(TRADERS_PROPERTY.to_string()); + segments.push(format!("[{}]", edit.index)); + segments.push(edit.map.property_name().to_string()); + let path = crate::properties::parse_path(&segments)?; + let chain = crate::properties::resolve_chain(&root.properties, &path)?; + let property = chain.target; + + let layout = crate::properties::map_layout(payload, property)?; + // Entry order in the parsed value and in `map_layout` is the same walk over + // the same bytes, so the parsed key at position i addresses entry_ranges[i]. + let PropertyValue::Map { entries, .. } = &property.value else { + return Err(CoreError::Parse(format!( + "{} is not a MapProperty", + edit.map.property_name() + ))); + }; + if entries.len() != layout.entry_ranges.len() { + return Err(CoreError::Parse( + "map entry count disagrees between parsed value and byte layout".to_string(), + )); + } + let position = entries + .iter() + .position(|(k, _)| matches!(k, PropertyValue::Object(p) if *p == edit.path)) + .ok_or_else(|| { + CoreError::UnsupportedEdit(format!( + "trader[{}].{} has no entry for {} — adding a sold-out line needs an insert", + edit.index, + edit.map.property_name(), + edit.path + )) + })?; + // Guard the value shape before trusting the tail-4-bytes assumption. + if !matches!(entries[position].1, PropertyValue::Int(_)) { + return Err(CoreError::Parse(format!( + "trader[{}].{}[{}] is not an IntProperty", + edit.index, + edit.map.property_name(), + edit.path + ))); + } + let range = &layout.entry_ranges[position]; + let value_at = range + .end + .checked_sub(4) + .filter(|start| *start >= range.start) + .ok_or_else(|| CoreError::Parse("map entry shorter than its value".to_string()))?; + payload[value_at..range.end].copy_from_slice(&edit.count.to_le_bytes()); + Ok(()) +} + +/// Add a stock line that does not exist yet, or drop one entirely. +/// +/// Both are structural: they splice bytes into or out of the map body and shift +/// every offset after it, so each must be the only edit in its write. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StockLineEdit { + pub index: usize, + pub map: StockMap, + /// Full item class path, i.e. the map key. + pub path: String, + /// Starting count for an insert. Ignored when removing. + pub count: i32, +} + +/// Resolve one trader's stock map to a patchable target. +/// +/// Returns the cloned property plus its enclosing size-field chain, so the +/// borrow on the parsed tree is dropped before the caller mutates the payload. +fn resolve_stock_map( + payload: &[u8], + index: usize, + map: StockMap, +) -> Result<(Property, Vec, Vec), CoreError> { + let root = crate::properties::parse_private_root(payload)?; + let (generic_path, _) = crate::factions::find_generic_instanced(&root, GAME_STATE_KEY) + .ok_or_else(|| { + CoreError::Parse(format!("m_GenericData[\"{GAME_STATE_KEY}\"] not found")) + })?; + // Fail on a bad index here rather than letting the path resolver report it: + // "trader index 40 out of range (have 31)" is actionable, "no such array + // element" is not. + let elements = traders_array(&root)?; + if index >= elements.len() { + return Err(CoreError::InvalidRequest(format!( + "trader index {index} out of range (have {})", + elements.len() + ))); + } + let mut segments = generic_path; + segments.push(format!("{{{GAME_STATE_KEY}}}")); + segments.push(TRADERS_PROPERTY.to_string()); + segments.push(format!("[{index}]")); + segments.push(map.property_name().to_string()); + let path = crate::properties::parse_path(&segments)?; + let chain = crate::properties::resolve_chain(&root.properties, &path)?; + let keys = match &chain.target.value { + PropertyValue::Map { entries, .. } => entries + .iter() + .map(|(k, _)| match k { + PropertyValue::Object(p) => Ok(p.clone()), + _ => Err(CoreError::Parse(format!( + "trader[{index}].{} key is not an ObjectProperty", + map.property_name() + ))), + }) + .collect::, _>>()?, + _ => { + return Err(CoreError::Parse(format!( + "trader[{index}].{} is not a MapProperty", + map.property_name() + ))); + } + }; + Ok(( + chain.target.clone(), + chain.enclosing_size_fields.clone(), + keys, + )) +} + +/// Read back one trader's stock keys, used to validate a structural patch before +/// it is allowed to replace the caller's payload. +fn stock_keys_after(payload: &[u8], index: usize, map: StockMap) -> Result, CoreError> { + let root = crate::properties::parse_private_root(payload)?; + let detail = trader_detail(&root, index)?; + let items = match map { + StockMap::Current => detail.items, + StockMap::Default => detail.default_items, + }; + Ok(items.into_iter().map(|i| i.path).collect()) +} + +/// Insert a new stock line. +/// +/// The item path is checked against the bundled catalog: an unknown class would +/// serialize fine and then resolve to nothing in game, leaving a line the player +/// can neither see nor buy. +/// +/// Only ONE map is touched per call. Adding to both `m_Items` and +/// `m_DefaultItems` is two structural edits, which the write guard refuses to +/// batch — the caller submits them as two writes. +pub fn apply_add_item(payload: &mut Vec, edit: &StockLineEdit) -> Result<(), CoreError> { + if !crate::is_item_definition_class(&edit.path) { + return Err(CoreError::InvalidRequest(format!( + "{} is not a known item class", + edit.path + ))); + } + // Same boundary apply_set_stock draws, and for the same reason: a + // zero-valued entry is a record the game never writes. This is public, so + // the request parser is not the only way in. + if edit.count < 1 { + return Err(CoreError::InvalidRequest(format!( + "stock count must be positive; {} cannot be inserted with {}", + edit.path, edit.count + ))); + } + let (target, enclosing, keys) = resolve_stock_map(payload, edit.index, edit.map)?; + if keys.iter().any(|k| k == &edit.path) { + return Err(CoreError::InvalidRequest(format!( + "trader[{}].{} already stocks {} — use setStock to change the count", + edit.index, + edit.map.property_name(), + edit.path + ))); + } + let mut entry = crate::properties::encode_fstring_value(&edit.path); + entry.extend_from_slice(&edit.count.to_le_bytes()); + + // Patch a scratch copy first: a length-changing splice that produced an + // inconsistent payload must never reach the caller. + let mut patched = payload.clone(); + crate::properties::patch_container( + &mut patched, + &target, + &enclosing, + &crate::properties::ContainerEdit::MapInsert { entry_bytes: entry }, + )?; + let keys_after = stock_keys_after(&patched, edit.index, edit.map).map_err(|err| { + CoreError::Parse(format!("adding a trader stock line left the save unreadable: {err}")) + })?; + if !keys_after.iter().any(|k| k == &edit.path) { + return Err(CoreError::Parse( + "post-insert validation failed: the new stock line does not read back".to_string(), + )); + } + *payload = patched; + Ok(()) +} + +/// Drop a stock line entirely. +/// +/// This is what the game itself does when a trader sells out, so it is also the +/// honest way to say "he no longer offers this" — setting the count to zero +/// would leave a line the game never writes. +pub fn apply_remove_item(payload: &mut Vec, edit: &StockLineEdit) -> Result<(), CoreError> { + let (target, enclosing, keys) = resolve_stock_map(payload, edit.index, edit.map)?; + // `map_layout`'s entry order is the same walk over the same bytes as the + // parsed value's, so the parsed position is the on-disk entry index. + let position = keys.iter().position(|k| k == &edit.path).ok_or_else(|| { + CoreError::InvalidRequest(format!( + "trader[{}].{} has no entry for {}", + edit.index, + edit.map.property_name(), + edit.path + )) + })?; + + let mut patched = payload.clone(); + crate::properties::patch_container( + &mut patched, + &target, + &enclosing, + &crate::properties::ContainerEdit::MapRemove { + entry_index: position, + }, + )?; + let keys_after = stock_keys_after(&patched, edit.index, edit.map).map_err(|err| { + CoreError::Parse(format!( + "removing a trader stock line left the save unreadable: {err}" + )) + })?; + if keys_after.iter().any(|k| k == &edit.path) { + return Err(CoreError::Parse( + "post-remove validation failed: the stock line is still present".to_string(), + )); + } + *payload = patched; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::properties::{InstancedStruct, StructValue}; + + /// A tagged property with no byte backing. These tests exercise the shape + /// logic only; offsets are irrelevant because nothing here writes. + fn prop(name: &str, value: PropertyValue) -> Property { + Property { + name: name.to_string().into(), + type_name: String::new().into(), + descriptor: Default::default(), + array_index: 0, + tag_flags: 0, + value_offset: 0, + value_size: 0, + value, + } + } + + /// A stock map property with the real key/value descriptor, since the + /// writability check reads the descriptor rather than the entries. + fn stock_prop(name: &str, pairs: &[(&str, i32)]) -> Property { + let inner = |type_name: &str| crate::properties::InnerDescriptor { + type_name: type_name.to_string().into(), + struct_type: None, + enum_type: None, + }; + let mut p = prop(name, stock(pairs)); + p.type_name = "MapProperty".to_string().into(); + p.descriptor.map = Some(Box::new(( + inner("ObjectProperty"), + inner("IntProperty"), + ))); + p + } + + fn stock(pairs: &[(&str, i32)]) -> PropertyValue { + PropertyValue::Map { + num_to_remove: 0, + entries: pairs + .iter() + .map(|(p, c)| { + ( + PropertyValue::Object((*p).to_string()), + PropertyValue::Int(*c), + ) + }) + .collect(), + } + } + + fn trader(name: &str, items: &[(&str, i32)], seconds: f64) -> PropertyValue { + PropertyValue::Struct(StructValue::Properties(vec![ + prop("m_TradersUniqueName", PropertyValue::Name(name.to_string())), + stock_prop("m_Items", items), + stock_prop("m_DefaultItems", items), + prop( + "m_GeneratedEvents", + PropertyValue::Array { + elements: vec![PropertyValue::Str("OnWorldStart".to_string())], + }, + ), + prop( + "m_ItemsByDifficulty", + PropertyValue::Map { + num_to_remove: 0, + entries: Vec::new(), + }, + ), + prop("m_TotalSeconds", PropertyValue::Double(seconds)), + ])) + } + + fn root_with(traders: Vec) -> RootObject { + let blob = InstancedStruct { + actual_type: "GameStateDataBaseSaveData".to_string().into(), + data_size_offset: 0, + properties: vec![prop( + "m_Traders", + PropertyValue::Array { elements: traders }, + )], + }; + RootObject { + class: "UGothicPersistentDataGame".to_string(), + flag: 0, + properties: vec![prop( + "m_GenericData", + PropertyValue::Map { + num_to_remove: 0, + entries: vec![( + PropertyValue::Str(GAME_STATE_KEY.to_string()), + PropertyValue::Struct(StructValue::Instanced(Some(blob))), + )], + }, + )], + footer: 0, + consumed: 0, + } + } + + #[test] + fn ore_is_found_by_key_not_by_position() { + // Fisk's ore is not the first entry; a positional read would return the + // lockpick count instead. + let root = root_with(vec![trader( + "OC_STT_Fisk_311", + &[("/Script/Angelscript.ItKe_Lockpick", 3), (ORE_PATH, 50)], + 1344287.223, + )]); + let list = list_traders(&root).expect("list"); + assert_eq!(list[0].ore, Some(50)); + } + + #[test] + fn an_omitted_stock_map_is_reported_as_absent() { + // An omitted map parses as an empty one, which would look editable and + // then fail at save time — the structural appliers resolve the property + // and cannot create it. Shipped saves always carry both. + let bare = PropertyValue::Struct(StructValue::Properties(vec![prop( + "m_TradersUniqueName", + PropertyValue::Name("OC_STT_Dexter_329".to_string()), + )])); + let list = list_traders(&root_with(vec![bare])).expect("list"); + assert!(!list[0].stock_maps_present); + + // A map of the wrong shape is just as unusable, and an EMPTY one gives + // that away only through its descriptor. + let mut wrong = prop("m_Items", stock(&[])); + wrong.type_name = "MapProperty".to_string().into(); + wrong.descriptor.map = None; + let odd = PropertyValue::Struct(StructValue::Properties(vec![ + prop("m_TradersUniqueName", PropertyValue::Name("X".to_string())), + wrong, + stock_prop("m_DefaultItems", &[]), + ])); + assert!(!list_traders(&root_with(vec![odd])).expect("list")[0].stock_maps_present); + + let whole = root_with(vec![trader("OC_STT_Fisk_311", &[(ORE_PATH, 3)], 1.0)]); + assert!(list_traders(&whole).expect("list")[0].stock_maps_present); + } + + #[test] + fn missing_ore_entry_is_none_not_zero() { + // Riordian stocks goods but carries no ore key at all. Reporting 0 would + // claim he is broke; reporting None says the record has no such line. + let root = root_with(vec![trader( + "NC_KDW_Riordian_605", + &[("/Script/Angelscript.ItMs_Remedy", 4)], + NEVER_TRADED, + )]); + let list = list_traders(&root).expect("list"); + assert_eq!(list[0].ore, None); + assert!(!list[0].traded); + } + + #[test] + fn name_lookup_folds_case() { + // A character's unique name is the stored knowledge key where one + // exists, whose casing can differ from the trader row's. The character + // list marks him a trader through a lowercase join, so an exact compare + // here would mark him and then refuse to resolve him. + let root = root_with(vec![trader( + "OC_STT_Dexter_329", + &[(ORE_PATH, 55)], + 937101.34, + )]); + let list = list_traders(&root).expect("list"); + assert_eq!(index_of_unique_name(&list, "oc_stt_dexter_329").unwrap(), 0); + assert_eq!(index_of_unique_name(&list, "OC_stt_Dexter_329").unwrap(), 0); + } + + #[test] + fn case_only_duplicates_are_still_ambiguous() { + // Folding case must not turn two distinct rows into a silent pick. + let root = root_with(vec![ + trader("OC_STT_Dexter_329", &[(ORE_PATH, 1)], NEVER_TRADED), + trader("oc_stt_dexter_329", &[(ORE_PATH, 2)], NEVER_TRADED), + ]); + let list = list_traders(&root).expect("list"); + let err = index_of_unique_name(&list, "OC_STT_Dexter_329").unwrap_err(); + assert!(matches!(err, CoreError::InvalidRequest(m) if m.contains("ambiguous"))); + } + + #[test] + fn duplicate_none_rows_are_rejected_by_name_lookup() { + let root = root_with(vec![ + trader("None", &[(ORE_PATH, 75)], NEVER_TRADED), + trader("OC_STT_Dexter_329", &[(ORE_PATH, 55)], 937101.34), + trader("None", &[(ORE_PATH, 75)], NEVER_TRADED), + ]); + let list = list_traders(&root).expect("list"); + assert!(list[0].placeholder && list[2].placeholder); + assert_eq!(index_of_unique_name(&list, "OC_STT_Dexter_329").unwrap(), 1); + let err = index_of_unique_name(&list, "None").unwrap_err(); + assert!(matches!(err, CoreError::InvalidRequest(m) if m.contains("ambiguous"))); + } + + #[test] + fn non_int_stock_value_is_refused() { + // A float-valued stock map would make the 4-byte in-place patch write + // into the wrong bytes, so the read must fail rather than round it. + let bad = PropertyValue::Struct(StructValue::Properties(vec![ + prop("m_TradersUniqueName", PropertyValue::Name("X".to_string())), + prop( + "m_Items", + PropertyValue::Map { + num_to_remove: 0, + entries: vec![( + PropertyValue::Object(ORE_PATH.to_string()), + PropertyValue::Float(1.0), + )], + }, + ), + ])); + let err = list_traders(&root_with(vec![bad])).unwrap_err(); + assert!(matches!(err, CoreError::Parse(m) if m.contains("IntProperty"))); + } + + /// Decode a real shipped save so the write tests run against genuine bytes. + /// The synthetic trees above carry no byte backing, and `apply_set_stock` + /// writes into the payload — a fake tree could not catch an offset mistake. + fn real_payload() -> Vec { + let backend = crate::codec_backend::KrakenBackend; + crate::decode_private_payload_from_bytes( + crate::startsaves::start_save_bytes(crate::startsaves::ResourcesLevel::Gothic), + &backend, + ) + .expect("decode embedded start save") + } + + fn ore_at(payload: &[u8], index: usize) -> Option { + let root = crate::properties::parse_private_root(payload).expect("parse"); + trader_detail(&root, index).expect("detail").summary.ore + } + + /// Index of the first shipped trader that actually stocks ore. + fn first_ore_trader(payload: &[u8]) -> usize { + let root = crate::properties::parse_private_root(payload).expect("parse"); + list_traders(&root) + .expect("list") + .into_iter() + .find(|t| t.ore.is_some() && !t.placeholder) + .expect("some trader stocks ore") + .index + } + + #[test] + fn set_stock_writes_in_place_without_moving_bytes() { + let mut payload = real_payload(); + let before_len = payload.len(); + let index = first_ore_trader(&payload); + + let edit = SetStockEdit { + index, + map: StockMap::Current, + path: ORE_PATH.to_string(), + count: 4242, + }; + apply_set_stock(&mut payload, &edit).expect("apply"); + + assert_eq!(payload.len(), before_len, "the edit must be length-neutral"); + assert_eq!(ore_at(&payload, index), Some(4242)); + } + + #[test] + fn two_stock_edits_batch_without_invalidating_each_other() { + // Length-neutral writes leave every recorded offset valid, which is the + // whole reason this command may share a save with its peers. + let mut payload = real_payload(); + let a = first_ore_trader(&payload); + let root = crate::properties::parse_private_root(&payload).expect("parse"); + let b = list_traders(&root) + .expect("list") + .into_iter() + .find(|t| t.ore.is_some() && !t.placeholder && t.index != a) + .expect("a second ore trader") + .index; + + for (index, count) in [(a, 1), (b, 999)] { + apply_set_stock( + &mut payload, + &SetStockEdit { + index, + map: StockMap::Current, + path: ORE_PATH.to_string(), + count, + }, + ) + .expect("apply"); + } + assert_eq!(ore_at(&payload, a), Some(1)); + assert_eq!(ore_at(&payload, b), Some(999)); + } + + #[test] + fn set_stock_refuses_a_zero_count() { + // The map holds no zero-valued entry in any shipped or played save, so + // writing one would invent a state the game never produces. Dropping the + // line is what "he no longer offers this" actually looks like. + let mut payload = real_payload(); + let index = first_ore_trader(&payload); + let before = payload.clone(); + let err = apply_set_stock( + &mut payload, + &SetStockEdit { + index, + map: StockMap::Current, + path: ORE_PATH.to_string(), + count: 0, + }, + ) + .unwrap_err(); + assert!(matches!(err, CoreError::InvalidRequest(m) if m.contains("positive"))); + assert_eq!(payload, before); + } + + #[test] + fn set_stock_refuses_a_line_that_does_not_exist() { + // Sold-out items are deleted from the map, so "set it to 5" cannot mean + // "create it" — that needs an insert, and pretending otherwise would + // report success for a write that never happened. + let mut payload = real_payload(); + let index = first_ore_trader(&payload); + let err = apply_set_stock( + &mut payload, + &SetStockEdit { + index, + map: StockMap::Current, + path: "/Script/Angelscript.ItMw_2H_Sword_04".to_string(), + count: 5, + }, + ) + .unwrap_err(); + assert!(matches!(err, CoreError::UnsupportedEdit(m) if m.contains("insert"))); + } + + #[test] + fn set_stock_targets_the_requested_map_only() { + let mut payload = real_payload(); + let index = first_ore_trader(&payload); + let before = { + let root = crate::properties::parse_private_root(&payload).expect("parse"); + trader_detail(&root, index).expect("detail") + }; + let default_ore_before = before + .default_items + .iter() + .find(|i| i.path == ORE_PATH) + .map(|i| i.count); + + apply_set_stock( + &mut payload, + &SetStockEdit { + index, + map: StockMap::Default, + path: ORE_PATH.to_string(), + count: 7, + }, + ) + .expect("apply"); + + let root = crate::properties::parse_private_root(&payload).expect("parse"); + let after = trader_detail(&root, index).expect("detail"); + assert_eq!( + after + .default_items + .iter() + .find(|i| i.path == ORE_PATH) + .map(|i| i.count), + Some(7) + ); + assert_eq!(after.summary.ore, before.summary.ore, "m_Items untouched"); + assert_ne!(default_ore_before, Some(7), "the test would be vacuous"); + } + + #[test] + fn shipped_save_has_the_documented_trader_shape() { + let payload = real_payload(); + let root = crate::properties::parse_private_root(&payload).expect("parse"); + let list = list_traders(&root).expect("list"); + assert_eq!(list.len(), 31, "every shipped save carries 31 trader rows"); + // Every shipped row carries both maps in the shape the appliers assume. + assert!(list.iter().all(|t| t.stock_maps_present)); + assert_eq!( + list.iter().filter(|t| t.placeholder).count(), + 2, + "two rows are unnamed sentinels, which is why names cannot address a row" + ); + // A game-start save has been traded with nowhere. + assert!(list.iter().all(|t| !t.traded)); + // The staging map is empty everywhere; if this ever fires, it needs modelling. + for index in 0..list.len() { + assert!(!trader_detail(&root, index).unwrap().has_items_by_difficulty); + } + } + + /// An item class in the bundled catalog that no shipped trader stocks, so an + /// insert test cannot collide with existing data. + fn unstocked_catalog_item(payload: &[u8], index: usize) -> String { + let root = crate::properties::parse_private_root(payload).expect("parse"); + let held: std::collections::HashSet = trader_detail(&root, index) + .expect("detail") + .items + .into_iter() + .map(|i| i.path) + .collect(); + crate::item_catalog_paths() + .iter() + .find(|p| !held.contains(*p)) + .expect("catalog has an item this trader does not stock") + .clone() + } + + #[test] + fn add_item_inserts_a_new_line_and_grows_the_payload() { + let mut payload = real_payload(); + let index = first_ore_trader(&payload); + let path = unstocked_catalog_item(&payload, index); + let before_len = payload.len(); + let before_count = { + let root = crate::properties::parse_private_root(&payload).expect("parse"); + trader_detail(&root, index).expect("detail").items.len() + }; + + apply_add_item( + &mut payload, + &StockLineEdit { + index, + map: StockMap::Current, + path: path.clone(), + count: 7, + }, + ) + .expect("add"); + + assert!(payload.len() > before_len, "an insert must grow the payload"); + let root = crate::properties::parse_private_root(&payload).expect("reparse"); + let detail = trader_detail(&root, index).expect("detail"); + assert_eq!(detail.items.len(), before_count + 1); + let added = detail + .items + .iter() + .find(|i| i.path == path) + .expect("the new line reads back"); + assert_eq!(added.count, 7); + assert!(!added.unknown_item); + } + + #[test] + fn add_item_rejects_a_class_outside_the_catalog() { + // An unknown class serializes fine and then resolves to nothing in game, + // so it would create stock the player can never see. + let mut payload = real_payload(); + let index = first_ore_trader(&payload); + let before = payload.clone(); + let err = apply_add_item( + &mut payload, + &StockLineEdit { + index, + map: StockMap::Current, + path: "/Script/Angelscript.ItXx_NotAThing".to_string(), + count: 1, + }, + ) + .unwrap_err(); + assert!(matches!(err, CoreError::InvalidRequest(m) if m.contains("not a known item class"))); + assert_eq!(payload, before, "a rejected add must not touch the payload"); + } + + #[test] + fn add_item_rejects_a_zero_count() { + // A line the merchant holds none of is simply absent from the map, so + // inserting one at zero would invent a state the game never produces. + let mut payload = real_payload(); + let index = first_ore_trader(&payload); + let path = unstocked_catalog_item(&payload, index); + let before = payload.clone(); + let err = apply_add_item( + &mut payload, + &StockLineEdit { + index, + map: StockMap::Current, + path, + count: 0, + }, + ) + .unwrap_err(); + assert!(matches!(err, CoreError::InvalidRequest(m) if m.contains("positive"))); + assert_eq!(payload, before); + } + + #[test] + fn add_item_rejects_a_line_that_already_exists() { + let mut payload = real_payload(); + let index = first_ore_trader(&payload); + let before = payload.clone(); + let err = apply_add_item( + &mut payload, + &StockLineEdit { + index, + map: StockMap::Current, + path: ORE_PATH.to_string(), + count: 1, + }, + ) + .unwrap_err(); + assert!(matches!(err, CoreError::InvalidRequest(m) if m.contains("already stocks"))); + assert_eq!(payload, before); + } + + #[test] + fn remove_item_drops_the_line_and_shrinks_the_payload() { + let mut payload = real_payload(); + let index = first_ore_trader(&payload); + let before_len = payload.len(); + let before = { + let root = crate::properties::parse_private_root(&payload).expect("parse"); + trader_detail(&root, index).expect("detail") + }; + // Pick a non-ore line so the removal is not confused with the ore path. + let victim = before + .items + .iter() + .find(|i| i.path != ORE_PATH) + .expect("trader stocks something besides ore") + .path + .clone(); + + apply_remove_item( + &mut payload, + &StockLineEdit { + index, + map: StockMap::Current, + path: victim.clone(), + count: 0, + }, + ) + .expect("remove"); + + assert!(payload.len() < before_len, "a removal must shrink the payload"); + let root = crate::properties::parse_private_root(&payload).expect("reparse"); + let after = trader_detail(&root, index).expect("detail"); + assert_eq!(after.items.len(), before.items.len() - 1); + assert!(after.items.iter().all(|i| i.path != victim)); + // The neighbours must survive intact — a wrong entry index would eat one. + assert_eq!(after.summary.ore, before.summary.ore); + assert_eq!(after.default_items.len(), before.default_items.len()); + } + + #[test] + fn remove_item_refuses_a_line_that_does_not_exist() { + let mut payload = real_payload(); + let index = first_ore_trader(&payload); + let path = unstocked_catalog_item(&payload, index); + let before = payload.clone(); + let err = apply_remove_item( + &mut payload, + &StockLineEdit { + index, + map: StockMap::Current, + path, + count: 0, + }, + ) + .unwrap_err(); + assert!(matches!(err, CoreError::InvalidRequest(m) if m.contains("no entry for"))); + assert_eq!(payload, before); + } + + #[test] + fn add_then_remove_restores_the_original_bytes() { + // Round-tripping proves the size-field chain is fixed up symmetrically: + // a leftover byte anywhere would show up as a length or content diff. + let original = real_payload(); + let mut payload = original.clone(); + let index = first_ore_trader(&payload); + let path = unstocked_catalog_item(&payload, index); + + apply_add_item( + &mut payload, + &StockLineEdit { + index, + map: StockMap::Current, + path: path.clone(), + count: 3, + }, + ) + .expect("add"); + apply_remove_item( + &mut payload, + &StockLineEdit { + index, + map: StockMap::Current, + path, + count: 0, + }, + ) + .expect("remove"); + + assert_eq!(payload, original, "add+remove must be byte-identical to a no-op"); + } + + #[test] + fn structural_edits_reject_an_out_of_range_trader() { + let mut payload = real_payload(); + let err = apply_add_item( + &mut payload, + &StockLineEdit { + index: 9999, + map: StockMap::Current, + path: ORE_PATH.to_string(), + count: 1, + }, + ) + .unwrap_err(); + assert!(matches!(err, CoreError::InvalidRequest(m) if m.contains("out of range"))); + } + + #[test] + fn add_item_into_an_empty_trader_map() { + // Scorpio and Xardas ship with no stock at all, so the insert path must + // work against a zero-entry map, not just append after an existing one. + let mut payload = real_payload(); + let index = { + let root = crate::properties::parse_private_root(&payload).expect("parse"); + list_traders(&root) + .expect("list") + .into_iter() + .find(|t| t.item_count == 0 && !t.placeholder) + .expect("a shipped trader with an empty stock map") + .index + }; + apply_add_item( + &mut payload, + &StockLineEdit { + index, + map: StockMap::Current, + path: ORE_PATH.to_string(), + count: 250, + }, + ) + .expect("add into empty map"); + + let root = crate::properties::parse_private_root(&payload).expect("reparse"); + let detail = trader_detail(&root, index).expect("detail"); + assert_eq!(detail.items.len(), 1); + assert_eq!(detail.summary.ore, Some(250)); + } + + #[test] + fn detail_reports_events_and_empty_difficulty_map() { + let root = root_with(vec![trader( + "OC_STT_Dexter_329", + &[(ORE_PATH, 55)], + 937101.34, + )]); + let detail = trader_detail(&root, 0).expect("detail"); + assert_eq!(detail.generated_events, vec!["OnWorldStart".to_string()]); + assert!(!detail.has_items_by_difficulty); + assert!(trader_detail(&root, 7).is_err()); + } +} diff --git a/crates/gore-save/tests/traders.rs b/crates/gore-save/tests/traders.rs new file mode 100644 index 000000000..e6d85e586 --- /dev/null +++ b/crates/gore-save/tests/traders.rs @@ -0,0 +1,509 @@ +//! Trader shop read + edit roundtrip over the JSON command surface. +//! +//! Runs against the embedded game-start save, so it needs no `GORE_SAVE` and no +//! game install: the shipped bytes already contain all 31 trader rows. +//! +//! cargo test -p gore-save --test traders -- --nocapture + +use serde_json::{Value, json}; + +fn exec(req: Value) -> Value { + let resp: Value = serde_json::from_str(&gore_save::execute_json(&req.to_string())).unwrap(); + assert_eq!(resp["ok"], json!(true), "request failed: {resp}"); + resp["data"].clone() +} + +fn exec_err(req: Value) -> String { + let resp: Value = serde_json::from_str(&gore_save::execute_json(&req.to_string())).unwrap(); + assert_eq!(resp["ok"], json!(false), "request unexpectedly succeeded: {resp}"); + resp["error"].to_string() +} + +/// Lay the embedded start save down as a real file so the command surface, which +/// works on paths, has something to open. +fn start_save(name: &str) -> String { + let mut p = std::env::temp_dir(); + p.push(format!("gore_traders_{name}.sav")); + std::fs::write( + &p, + gore_save::startsaves::start_save_bytes(gore_save::startsaves::ResourcesLevel::Gothic), + ) + .expect("write temp save"); + p.to_string_lossy().to_string() +} + +fn out_path(name: &str) -> String { + let mut p = std::env::temp_dir(); + p.push(format!("gore_traders_{name}_out.sav")); + p.to_string_lossy().to_string() +} + +fn list(path: &str) -> Value { + exec(json!({ "command": "private.traders.list", "payload": { "path": path } })) +} + +fn detail(path: &str, index: u64) -> Value { + exec(json!({ + "command": "private.traders.detail", + "payload": { "path": path, "index": index } + })) +} + +fn write(path: &str, out: &str, edits: Value) -> Value { + exec(json!({ + "command": "write_save", + "payload": { "path": path, "outputPath": out, "backup": false, "edits": edits } + })) +} + +/// The first real (non-placeholder) trader that stocks something. +fn stocked_trader(data: &Value) -> (u64, String) { + let t = data["traders"] + .as_array() + .unwrap() + .iter() + .find(|t| t["placeholder"] == json!(false) && t["itemCount"].as_u64().unwrap() > 0) + .expect("a shipped trader stocks something"); + ( + t["index"].as_u64().unwrap(), + t["uniqueName"].as_str().unwrap().to_string(), + ) +} + +fn item_count(detail: &Value, path: &str) -> Option { + detail["items"] + .as_array() + .unwrap() + .iter() + .find(|i| i["path"] == json!(path)) + .map(|i| i["count"].as_i64().unwrap()) +} + +const ORE: &str = "/Script/Angelscript.ItMi_Orenugget"; + +#[test] +fn list_reports_every_trader_and_the_ore_they_can_pay_with() { + let path = start_save("list"); + let data = list(&path); + let traders = data["traders"].as_array().unwrap(); + assert_eq!(traders.len(), 31, "every shipped save carries 31 trader rows"); + + // Two rows are unnamed sentinels; that is why nothing may be addressed by + // name alone. + assert_eq!( + traders + .iter() + .filter(|t| t["placeholder"] == json!(true)) + .count(), + 2 + ); + // Nobody has traded in a game-start save. + assert!(traders.iter().all(|t| t["traded"] == json!(false))); + // Ore is optional: a trader without the key reports null, not zero. + assert!(traders.iter().any(|t| t["ore"].is_i64())); + assert!(traders.iter().any(|t| t["ore"].is_null())); + + let writable: Vec<&str> = data["writable"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(writable.contains(&"private.traders.setStock")); + assert!(writable.contains(&"private.traders.addItem")); + assert!(writable.contains(&"private.traders.removeItem")); +} + +#[test] +fn detail_resolves_by_index_and_by_unambiguous_name() { + let path = start_save("detail"); + let data = list(&path); + let (index, name) = stocked_trader(&data); + + let by_index = detail(&path, index); + let by_name = exec(json!({ + "command": "private.traders.detail", + "payload": { "path": &path, "uniqueName": &name } + })); + assert_eq!(by_index, by_name); + assert_eq!(by_index["uniqueName"], json!(name)); + assert!(!by_index["items"].as_array().unwrap().is_empty()); + // The staging map is empty in every shipped save; if this fires it needs modelling. + assert_eq!(by_index["hasItemsByDifficulty"], json!(false)); + + // The sentinel name is shared by two rows and must be refused, not guessed. + let err = exec_err(json!({ + "command": "private.traders.detail", + "payload": { "path": &path, "uniqueName": "None" } + })); + assert!(err.contains("ambiguous"), "{err}"); +} + +#[test] +fn set_stock_roundtrips_and_batches() { + let path = start_save("setstock"); + let out = out_path("setstock"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + + // Two length-neutral edits in ONE write: this is exactly what the guard + // allows and the reason setStock is not a splicing edit. + let victim = detail(&path, index)["items"] + .as_array() + .unwrap() + .iter() + .find(|i| i["path"] != json!(ORE)) + .expect("something besides ore") + .clone(); + let victim_path = victim["path"].as_str().unwrap().to_string(); + + write( + &path, + &out, + json!([ + { "path": "private.traders.setStock", "value": { "index": index, "path": ORE, "count": 4242 } }, + { "path": "private.traders.setStock", "value": { "index": index, "path": &victim_path, "count": 11 } }, + ]), + ); + + let after = detail(&out, index); + assert_eq!(item_count(&after, ORE), Some(4242)); + assert_eq!(item_count(&after, &victim_path), Some(11)); +} + +#[test] +fn set_stock_targets_the_restock_baseline_when_asked() { + let path = start_save("setstock_default"); + let out = out_path("setstock_default"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + let before = detail(&path, index); + let ore_before = item_count(&before, ORE); + + write( + &path, + &out, + json!([ + { "path": "private.traders.setStock", + "value": { "index": index, "path": ORE, "count": 5, "map": "default" } }, + ]), + ); + + let after = detail(&out, index); + let default_ore = after["defaultItems"] + .as_array() + .unwrap() + .iter() + .find(|i| i["path"] == json!(ORE)) + .map(|i| i["count"].as_i64().unwrap()); + assert_eq!(default_ore, Some(5)); + assert_eq!(item_count(&after, ORE), ore_before, "m_Items untouched"); +} + +#[test] +fn add_and_remove_a_stock_line_through_the_command_surface() { + let path = start_save("addremove"); + let added = out_path("addremove_added"); + let removed = out_path("addremove_removed"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + + // A catalogued item this trader does not already stock. + let held: std::collections::HashSet = detail(&path, index)["items"] + .as_array() + .unwrap() + .iter() + .map(|i| i["path"].as_str().unwrap().to_string()) + .collect(); + let fresh = ["ItFo_Cheese", "ItFo_Loaf", "ItKe_Lockpick", "ItAm_Bolt"] + .iter() + .map(|n| format!("/Script/Angelscript.{n}")) + .find(|p| !held.contains(p)) + .expect("one of the probe items is unstocked"); + + write( + &path, + &added, + json!([ + { "path": "private.traders.addItem", + "value": { "index": index, "path": &fresh, "count": 9 } }, + ]), + ); + let after_add = detail(&added, index); + assert_eq!(item_count(&after_add, &fresh), Some(9)); + assert_eq!( + after_add["items"].as_array().unwrap().len(), + held.len() + 1 + ); + + write( + &added, + &removed, + json!([ + { "path": "private.traders.removeItem", + "value": { "index": index, "path": &fresh } }, + ]), + ); + let after_remove = detail(&removed, index); + assert_eq!(item_count(&after_remove, &fresh), None); + assert_eq!(after_remove["items"].as_array().unwrap().len(), held.len()); +} + +#[test] +fn an_index_addressed_trader_edit_may_not_follow_a_structural_one() { + // Every trader edit addresses its row by an index the caller read off a + // list taken before the write. An insert earlier in the same write changes + // how many entries a container holds, so the core refuses the pair rather + // than let the index resolve against the changed layout. + let path = start_save("guard"); + let out = out_path("guard"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + + let err = exec_err(json!({ + "command": "write_save", + "payload": { + "path": path, + "outputPath": out, + "backup": false, + "edits": [ + { "path": "private.traders.addItem", + "value": { "index": index, "path": "/Script/Angelscript.ItFo_Cheese", "count": 1 } }, + { "path": "private.traders.setStock", + "value": { "index": index, "path": ORE, "count": 1 } }, + ] + } + })); + assert!(err.contains("addresses an element by index"), "{err}"); + assert!(err.contains("private.traders.addItem"), "{err}"); + assert!(!std::path::Path::new(&out).exists(), "a refused write must not produce a save"); +} + +#[test] +fn the_same_pair_is_accepted_the_other_way_round() { + // The refusal above is about ORDER, not about the two operations being + // incompatible: with the index-addressed edit first, both resolve against + // the layout they were read from and the write goes through. + let path = start_save("order"); + let out = out_path("order"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + let fresh = "/Script/Angelscript.ItFo_Cheese"; + assert!( + item_count(&detail(&path, index), fresh).is_none(), + "the probe item must not already be stocked" + ); + + write( + &path, + &out, + json!([ + { "path": "private.traders.setStock", "value": { "index": index, "path": ORE, "count": 321 } }, + { "path": "private.traders.addItem", "value": { "index": index, "path": fresh, "count": 4 } }, + ]), + ); + + let after = detail(&out, index); + assert_eq!(item_count(&after, ORE), Some(321)); + assert_eq!(item_count(&after, fresh), Some(4)); +} + +#[test] +fn a_trader_edit_and_an_m_traders_array_splice_are_refused_together() { + // A trader edit is addressed by its row's position, and an array operation + // on m_Traders renumbers the rows — so the pair is unsafe in either order, + // and splitting it into two writes would not help: the index came from a + // list read before either ran. + let path = start_save("arrayconflict"); + let out = out_path("arrayconflict"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + let traders_path = ["m_GenericData", "{GameStateDataBase}", "m_Traders"]; + + for edits in [ + json!([ + { "path": "private.typed.arrayRemove", + "value": { "path": traders_path, "index": 0 } }, + { "path": "private.traders.setStock", + "value": { "index": index, "path": ORE, "count": 5 } }, + ]), + // The reverse order is no safer, so it is refused too. + json!([ + { "path": "private.traders.setStock", + "value": { "index": index, "path": ORE, "count": 5 } }, + { "path": "private.typed.arrayRemove", + "value": { "path": traders_path, "index": 0 } }, + ]), + ] { + let err = exec_err(json!({ + "command": "write_save", + "payload": { + "path": path, "outputPath": out, "backup": false, "edits": edits + } + })); + assert!(err.contains("rewrites"), "{err}"); + assert!(!std::path::Path::new(&out).exists()); + } +} + +#[test] +fn an_edit_inside_a_trader_row_is_not_a_renumbering_splice() { + // Only an array operation ON m_Traders moves its rows. A value under one row + // runs through the array without changing its length, so the pair is safe + // and must go through. + let path = start_save("insiderow"); + let out = out_path("insiderow"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + + write( + &path, + &out, + json!([ + { "path": "private.traders.setStock", + "value": { "index": index, "path": ORE, "count": 77 } }, + { "path": "private.typed.setValue", + "value": { + "path": ["m_GenericData", "{GameStateDataBase}", "m_Traders", + format!("[{index}]"), "m_TotalSeconds"], + "value": 12345.5 + } }, + ]), + ); + + let after = detail(&out, index); + assert_eq!(item_count(&after, ORE), Some(77)); + assert_eq!(after["totalSeconds"].as_f64(), Some(12345.5)); +} + +#[test] +fn a_set_and_a_removal_of_one_line_are_refused_together() { + // The set would apply and the removal would then delete the line it lives + // in, with the write reporting both as applied. + let path = start_save("setremove"); + let out = out_path("setremove"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + + let err = exec_err(json!({ + "command": "write_save", + "payload": { + "path": path, "outputPath": out, "backup": false, + "edits": [ + { "path": "private.traders.setStock", + "value": { "index": index, "path": ORE, "count": 9 } }, + { "path": "private.traders.removeItem", + "value": { "index": index, "path": ORE } }, + ] + } + })); + assert!(err.contains("stock line of that trader"), "{err}"); + assert!(!std::path::Path::new(&out).exists()); +} + +#[test] +fn a_set_and_a_removal_of_different_lines_still_batch() { + // Only the SAME line collides; unrelated lines are what a batch is for. + let path = start_save("setremove_other"); + let out = out_path("setremove_other"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + let other = detail(&path, index)["items"] + .as_array() + .unwrap() + .iter() + .find(|i| i["path"] != json!(ORE)) + .expect("something besides ore")["path"] + .as_str() + .unwrap() + .to_string(); + + write( + &path, + &out, + json!([ + { "path": "private.traders.setStock", + "value": { "index": index, "path": ORE, "count": 9 } }, + { "path": "private.traders.removeItem", + "value": { "index": index, "path": &other } }, + ]), + ); + + let after = detail(&out, index); + assert_eq!(item_count(&after, ORE), Some(9)); + assert_eq!(item_count(&after, &other), None); +} + +#[test] +fn add_item_refuses_a_class_the_game_does_not_know() { + let path = start_save("badclass"); + let out = out_path("badclass"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + + let err = exec_err(json!({ + "command": "write_save", + "payload": { + "path": path, + "outputPath": out, + "backup": false, + "edits": [ + { "path": "private.traders.addItem", + "value": { "index": index, "path": "/Script/Angelscript.ItXx_Nope", "count": 1 } }, + ] + } + })); + assert!(err.contains("not a known item class"), "{err}"); + assert!(!std::path::Path::new(&out).exists(), "a refused write must not produce a save"); +} + +#[test] +fn set_stock_rejects_a_zero_count() { + // A sold-out line is deleted from the map, never held at zero, so setting a + // count to 0 would write a record the game never produces. The refusal names + // removeItem so the caller knows what to send instead. + let path = start_save("setzero"); + let out = out_path("setzero"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + + let err = exec_err(json!({ + "command": "write_save", + "payload": { + "path": path, + "outputPath": out, + "backup": false, + "edits": [ + { "path": "private.traders.setStock", + "value": { "index": index, "path": ORE, "count": 0 } }, + ] + } + })); + assert!(err.contains("positive i32"), "{err}"); + assert!(err.contains("removeItem"), "{err}"); + assert!(!std::path::Path::new(&out).exists()); +} + +#[test] +fn add_item_rejects_a_zero_count() { + // Sold-out lines are deleted, never left at zero, so a zero-count insert + // would write a state the game never produces. + let path = start_save("zerocount"); + let out = out_path("zerocount"); + let data = list(&path); + let (index, _) = stocked_trader(&data); + + let err = exec_err(json!({ + "command": "write_save", + "payload": { + "path": path, + "outputPath": out, + "backup": false, + "edits": [ + { "path": "private.traders.addItem", + "value": { "index": index, "path": "/Script/Angelscript.ItFo_Cheese", "count": 0 } }, + ] + } + })); + assert!(err.contains("positive i32"), "{err}"); +}