From d7aa8871e3c0ebbeb69df6a38bbf0343fd093661 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Tue, 11 Aug 2026 23:25:29 +0200 Subject: [PATCH 01/29] feat(save-editor): edit what a merchant sells and how much ore he has A merchant's shop is not his inventory. It lives in one global array (m_Traders) keyed by his unique name, and holds two maps: the live stock and the baseline he restocks toward. His ore sits in that same map as an ordinary line, because ore is the currency and what he holds is what he can pay with. Adds private.traders.list/.detail plus three edits: setStock (a bare i32 at the tail of its map entry, so it batches), and addItem/removeItem (structural splices, so they stand alone in their write and are listed in both the core guard and the app's splicingPaths). Two things the data forces: - Rows are addressed by array index, never by name. Two shipped rows are named `None`, are byte-identically long, and hold the same ore, so a name lookup has to refuse rather than guess. - A sold-out item is deleted from the map, not left at zero. setStock therefore refuses a line that does not exist and points at addItem instead of reporting success for a write it cannot do. Ore is optional: three merchants carry no ore line at all, which reads as null rather than zero so the UI does not claim they are broke. Co-Authored-By: Claude Opus 5 --- apps/save-editor/CHANGELOG.md | 3 + .../editor/domain/editor_notifier.dart | 77 ++ .../features/editor/domain/trader_models.dart | 251 ++++ .../features/editor/ui/characters_tab.dart | 25 +- .../lib/features/editor/ui/trader_detail.dart | 654 ++++++++++ apps/save-editor/lib/l10n/app_de.arb | 18 + apps/save-editor/lib/l10n/app_en.arb | 20 + apps/save-editor/lib/l10n/app_es.arb | 18 + apps/save-editor/lib/l10n/app_fr.arb | 18 + apps/save-editor/lib/l10n/app_it.arb | 18 + apps/save-editor/lib/l10n/app_ja.arb | 18 + .../lib/l10n/app_localizations.dart | 108 ++ .../lib/l10n/app_localizations_de.dart | 61 + .../lib/l10n/app_localizations_en.dart | 61 + .../lib/l10n/app_localizations_es.dart | 62 + .../lib/l10n/app_localizations_fr.dart | 62 + .../lib/l10n/app_localizations_it.dart | 62 + .../lib/l10n/app_localizations_ja.dart | 59 + .../lib/l10n/app_localizations_pl.dart | 62 + .../lib/l10n/app_localizations_pt.dart | 124 ++ .../lib/l10n/app_localizations_ru.dart | 62 + .../lib/l10n/app_localizations_zh.dart | 116 ++ apps/save-editor/lib/l10n/app_pl.arb | 18 + apps/save-editor/lib/l10n/app_pt.arb | 18 + apps/save-editor/lib/l10n/app_pt_BR.arb | 18 + apps/save-editor/lib/l10n/app_ru.arb | 18 + apps/save-editor/lib/l10n/app_zh.arb | 18 + apps/save-editor/lib/l10n/app_zh_Hans.arb | 18 + apps/save-editor/test/trader_panel_test.dart | 453 +++++++ crates/gore-save/src/factions.rs | 2 +- crates/gore-save/src/lib.rs | 228 ++++ crates/gore-save/src/traders.rs | 1129 +++++++++++++++++ crates/gore-save/tests/traders.rs | 356 ++++++ 33 files changed, 4233 insertions(+), 2 deletions(-) create mode 100644 apps/save-editor/lib/features/editor/domain/trader_models.dart create mode 100644 apps/save-editor/lib/features/editor/ui/trader_detail.dart create mode 100644 apps/save-editor/test/trader_panel_test.dart create mode 100644 crates/gore-save/src/traders.rs create mode 100644 crates/gore-save/tests/traders.rs 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/editor_notifier.dart b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart index f9be4a1e3..2fc7ec24e 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 — @@ -2799,6 +2805,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. 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..0a59ffb6b --- /dev/null +++ b/apps/save-editor/lib/features/editor/domain/trader_models.dart @@ -0,0 +1,251 @@ +// 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, + }); + + 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, + ); + } + + /// 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; +} + +/// 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'); + + /// The record for an NPC, or null when he is not a merchant. Placeholder rows + /// belong to no NPC and are deliberately not matched. + TraderSummary? forUniqueName(String uniqueName) { + for (final t in traders) { + if (!t.placeholder && t.uniqueName == uniqueName) return t; + } + return null; + } +} + +/// 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/characters_tab.dart b/apps/save-editor/lib/features/editor/ui/characters_tab.dart index f20e8fe9c..427ce7316 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'; @@ -102,6 +103,23 @@ 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. Orphans have no such record, and the + // panel itself shows the same empty state for any non-merchant, so it only + // needs the orphan guard the other actor-backed panes take. + final Widget tradeBody = isOrphan + ? _MessagePane( + icon: Icons.storefront_outlined, + title: l10n.tabTrade, + body: l10n.characterNoActorBody, + ) + : TraderPanel( + inspection: inspection, + notifier: notifier, + actor: selected, + editable: progressionEditable, + ); + // 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 +218,7 @@ class CharactersTab extends ConsumerWidget { const VerticalDivider(width: 1), Expanded( child: DefaultTabController( - length: 5, + length: 6, child: Column( children: [ ActorDetailHeader( @@ -231,6 +249,10 @@ class CharactersTab extends ConsumerWidget { icon: const Icon(Icons.history_outlined), text: l10n.sectionEvents, ), + Tab( + icon: const Icon(Icons.storefront_outlined), + text: l10n.tabTrade, + ), Tab( icon: const Icon(Icons.place_outlined), text: l10n.heroTransform, @@ -244,6 +266,7 @@ class CharactersTab extends ConsumerWidget { _KeepAliveTab(child: inventoryBody), _KeepAliveTab(child: knowledgeBody), _KeepAliveTab(child: eventsBody), + _KeepAliveTab(child: tradeBody), _KeepAliveTab(child: positionBody), ], ), 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..222abf63b --- /dev/null +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -0,0 +1,654 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:goresave/features/editor/domain/actor.dart'; +import 'package:goresave/features/editor/domain/editor_models.dart'; +import 'package:goresave/features/editor/domain/trader_models.dart'; +import 'package:goresave/features/editor/ui/add_inventory_item_dialog.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, + }); + + final SaveInspection inspection; + final EditorNotifier notifier; + final Actor actor; + + /// Same save-wide gate the other editing panes take + /// (`privateEditable && privateTypedVerified && codecCompressReady`). + final bool editable; + + @override + ConsumerState createState() => _TraderPanelState(); +} + +class _TraderPanelState extends ConsumerState { + TradersResult? _list; + TraderDetail? _detail; + String? _error; + bool _loading = true; + + /// Which save and actor the currently held data belongs to, so a reload that + /// lands after the user moved on is discarded instead of shown. + String? _loadedFor; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant TraderPanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.actor.uniqueName != widget.actor.uniqueName || + oldWidget.notifier.selectedPath != widget.notifier.selectedPath) { + _load(); + } + } + + String get _token => '${widget.notifier.selectedPath}|${widget.actor.uniqueName}'; + + Future _load() async { + final token = _token; + setState(() { + _loading = true; + _error = null; + _detail = null; + }); + final list = await widget.notifier.loadTraders(); + if (!mounted || _token != token) return; + if (list.error != null) { + setState(() { + _loading = false; + _error = list.error; + _list = null; + }); + return; + } + final row = list.forUniqueName(widget.actor.uniqueName); + if (row == null) { + // Not a merchant. A clean empty state, not an error. + setState(() { + _loading = false; + _list = list; + _detail = null; + _loadedFor = token; + }); + return; + } + final detail = await widget.notifier.loadTraderDetail(row.index); + if (!mounted || _token != token) return; + setState(() { + _loading = false; + _list = list; + _error = detail.error; + _detail = detail.detail; + _loadedFor = token; + }); + } + + @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 || _loadedFor != _token) { + return _Message( + icon: Icons.storefront_outlined, + title: l10n.tabTrade, + body: l10n.traderNotAMerchant, + ); + } + + final list = _list; + final canSet = widget.editable && (list?.canSetStock ?? false); + final canAdd = widget.editable && (list?.canAddItem ?? false); + final canRemove = widget.editable && (list?.canRemoveItem ?? false); + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + _OreCard( + detail: detail, + editable: canSet, + onChanged: (value) => _queueSet(TraderStockMap.current, kTraderOrePath, value), + onRevert: () => _revert(TraderStockMap.current, kTraderOrePath), + pending: _pendingCountFor(TraderStockMap.current, kTraderOrePath), + ), + const SizedBox(height: 12), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.traderPriceWarning, + style: theme.textTheme.bodySmall, + ), + ), + ], + ), + ), + ), + if (widget.editable && !(list?.canSetStock ?? false)) ...[ + const SizedBox(height: 12), + Text(l10n.traderReadOnlyCore, style: theme.textTheme.bodySmall), + ], + const SizedBox(height: 16), + _StockSection( + title: l10n.traderStockCurrent, + hint: null, + map: TraderStockMap.current, + items: detail.items, + canSet: canSet, + canAdd: canAdd, + canRemove: canRemove, + pendingOf: _pendingCountFor, + isRemovalPending: _isRemovalPending, + onChanged: _queueSet, + onRevert: _revert, + onRemove: _queueRemove, + onAdd: () => _addItem(TraderStockMap.current, detail), + ), + const SizedBox(height: 24), + _StockSection( + title: l10n.traderStockBase, + hint: l10n.traderStockBaseHint, + map: TraderStockMap.base, + items: detail.defaultItems, + canSet: canSet, + canAdd: canAdd, + canRemove: canRemove, + pendingOf: _pendingCountFor, + isRemovalPending: _isRemovalPending, + onChanged: _queueSet, + onRevert: _revert, + onRemove: _queueRemove, + onAdd: () => _addItem(TraderStockMap.base, 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) { + final key = _edit(TraderEditKind.removeItem, map, path).pendingKey; + final pending = ref.read(editorProvider).pendingEdits[key]; + return pending?.edits.firstOrNull?['path'] == 'private.traders.removeItem'; + } + + 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(() {}); + } +} + +class _OreCard extends ConsumerWidget { + const _OreCard({ + required this.detail, + required this.editable, + required this.onChanged, + required this.onRevert, + required this.pending, + }); + + final TraderDetail detail; + final bool editable; + final void Function(int) onChanged; + final VoidCallback onRevert; + 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; + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.savings_outlined, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.traderOre, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text(l10n.traderOreHint, style: theme.textTheme.bodySmall), + const SizedBox(height: 4), + Text( + detail.summary.traded + ? l10n.traderTraded + : l10n.traderNeverTraded, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + if (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) + else + SizedBox( + width: 140, + child: _CountField( + value: ore, + pending: pending, + enabled: editable, + onChanged: onChanged, + onRevert: onRevert, + ), + ), + ], + ), + ), + ); + } +} + +class _StockSection extends StatelessWidget { + const _StockSection({ + required this.title, + required this.hint, + required this.map, + required this.items, + required this.canSet, + required this.canAdd, + required this.canRemove, + required this.pendingOf, + required this.isRemovalPending, + required this.onChanged, + required this.onRevert, + required this.onRemove, + required this.onAdd, + }); + + final String title; + final String? hint; + final TraderStockMap map; + final List items; + final bool canSet; + final bool canAdd; + final bool canRemove; + final int? Function(TraderStockMap, String) pendingOf; + final bool Function(TraderStockMap, String) isRemovalPending; + final void Function(TraderStockMap, String, int) onChanged; + final void Function(TraderStockMap, String) onRevert; + final void Function(TraderStockMap, String) onRemove; + final VoidCallback onAdd; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + Text( + l10n.traderStockLineCount(items.length), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), + ), + if (hint != null) ...[ + const SizedBox(height: 4), + Text(hint!, style: theme.textTheme.bodySmall), + ], + ], + ), + ), + if (canAdd) + OutlinedButton.icon( + icon: const Icon(Icons.add), + label: Text(l10n.traderAddItem), + onPressed: onAdd, + ), + ], + ), + const SizedBox(height: 8), + if (items.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(l10n.traderEmptyStock, style: theme.textTheme.bodyMedium), + ) + else + Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + for (final item in items) + _StockRow( + item: item, + map: map, + canSet: canSet, + canRemove: canRemove, + pending: pendingOf(map, item.path), + removalPending: isRemovalPending(map, item.path), + onChanged: (v) => onChanged(map, item.path, v), + onRevert: () => onRevert(map, item.path), + onRemove: () => onRemove(map, item.path), + ), + ], + ), + ), + ], + ); + } +} + +class _StockRow extends ConsumerWidget { + const _StockRow({ + required this.item, + required this.map, + required this.canSet, + required this.canRemove, + required this.pending, + required this.removalPending, + 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 bool removalPending; + 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; + + return ListTile( + dense: true, + leading: item.isOre + ? Icon(Icons.savings_outlined, color: theme.colorScheme.primary) + : const Icon(Icons.inventory_2_outlined), + title: Text( + label, + style: removalPending + ? theme.textTheme.bodyMedium?.copyWith( + decoration: TextDecoration.lineThrough, + color: theme.colorScheme.outline, + ) + : null, + ), + subtitle: Text( + item.unknownItem ? '${item.id} · ${l10n.traderUnknownItem}' : item.id, + style: theme.textTheme.bodySmall, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 130, + child: _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 && !removalPending && !item.unknownItem, + onChanged: onChanged, + onRevert: onRevert, + ), + ), + if (canRemove) + IconButton( + tooltip: l10n.traderRemoveItem, + icon: Icon( + removalPending ? Icons.undo : Icons.delete_outline, + size: 20, + ), + onPressed: onRemove, + ), + ], + ), + ); + } +} + +/// 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> { + late final TextEditingController _controller = TextEditingController( + text: '${widget.pending ?? widget.value}', + ); + + @override + void didUpdateWidget(covariant _CountField oldWidget) { + super.didUpdateWidget(oldWidget); + final shown = widget.pending ?? widget.value; + // Only overwrite when the field is not the thing that produced the change, + // otherwise typing fights the controller. + if (oldWidget.pending != widget.pending && '$shown' != _controller.text) { + _controller.text = '$shown'; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit(String raw) { + final parsed = int.tryParse(raw.trim()); + if (parsed == null || parsed < 0) { + _controller.text = '${widget.pending ?? widget.value}'; + return; + } + 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, + enabled: widget.enabled, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.end, + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + suffixIcon: dirty + ? IconButton( + icon: const Icon(Icons.undo, size: 16), + onPressed: widget.onRevert, + ) + : null, + ), + onSubmitted: _submit, + onTapOutside: (_) => _submit(_controller.text), + ); + } +} + +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: const Text('Retry')), + ], + ], + ), + ), + ); + } +} diff --git a/apps/save-editor/lib/l10n/app_de.arb b/apps/save-editor/lib/l10n/app_de.arb index 614d62b6e..5b9a03c3e 100644 --- a/apps/save-editor/lib/l10n/app_de.arb +++ b/apps/save-editor/lib/l10n/app_de.arb @@ -131,6 +131,24 @@ "skillNameMagicCircle": "Magischer Kreis", "skillNameOrcish": "Orkisch", "tabInventory": "Inventar", + "tabTrade": "Handel", + "traderNotAMerchant": "Diese Person handelt nicht.", + "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": "Erz ist die Währung der Kolonie. Was ein Händler davon hat, ist das, womit er dich bezahlen kann.", + "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", + "traderNeverTraded": "hier noch nie gehandelt", + "traderTraded": "hier schon gehandelt", + "traderReadOnlyCore": "Dieser Core kann Händlerdaten nur lesen.", + "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.", diff --git a/apps/save-editor/lib/l10n/app_en.arb b/apps/save-editor/lib/l10n/app_en.arb index f994aabcd..3858d3c58 100644 --- a/apps/save-editor/lib/l10n/app_en.arb +++ b/apps/save-editor/lib/l10n/app_en.arb @@ -135,6 +135,26 @@ "skillNameMagicCircle": "Magic Circle", "skillNameOrcish": "Orcish", "tabInventory": "Inventory", + "tabTrade": "Trade", + "traderNotAMerchant": "This character does not trade.", + "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": "Ore is the colony's currency. The amount a merchant holds is what he can pay you with.", + "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", + "traderNeverTraded": "never traded here", + "traderTraded": "already traded here", + "traderReadOnlyCore": "This core build can only read trader data.", + "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.", diff --git a/apps/save-editor/lib/l10n/app_es.arb b/apps/save-editor/lib/l10n/app_es.arb index 39e88f995..a525dd8ed 100644 --- a/apps/save-editor/lib/l10n/app_es.arb +++ b/apps/save-editor/lib/l10n/app_es.arb @@ -129,6 +129,24 @@ "skillNameMagicCircle": "Círculo mágico", "skillNameOrcish": "Idioma orco", "tabInventory": "Inventario", + "tabTrade": "Comercio", + "traderNotAMerchant": "Este personaje no comercia.", + "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": "El mineral es la moneda de la colonia. Lo que un mercader tiene es con lo que puede pagarte.", + "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", + "traderNeverTraded": "nunca has comerciado aquí", + "traderTraded": "ya has comerciado aquí", + "traderReadOnlyCore": "Esta versión del núcleo solo puede leer los datos del mercader.", + "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.", diff --git a/apps/save-editor/lib/l10n/app_fr.arb b/apps/save-editor/lib/l10n/app_fr.arb index 203a820bc..1ee42e3e6 100644 --- a/apps/save-editor/lib/l10n/app_fr.arb +++ b/apps/save-editor/lib/l10n/app_fr.arb @@ -129,6 +129,24 @@ "skillNameMagicCircle": "Cercle de la magie", "skillNameOrcish": "Langue orc", "tabInventory": "Inventaire", + "tabTrade": "Commerce", + "traderNotAMerchant": "Ce personnage ne fait pas de commerce.", + "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": "Le minerai est la monnaie de la colonie. Ce qu'un marchand possède est ce avec quoi il peut vous payer.", + "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", + "traderNeverTraded": "jamais commercé ici", + "traderTraded": "déjà commercé ici", + "traderReadOnlyCore": "Cette version du cœur ne peut que lire les données des marchands.", + "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.", diff --git a/apps/save-editor/lib/l10n/app_it.arb b/apps/save-editor/lib/l10n/app_it.arb index a92b6738e..0a81ec844 100644 --- a/apps/save-editor/lib/l10n/app_it.arb +++ b/apps/save-editor/lib/l10n/app_it.arb @@ -129,6 +129,24 @@ "skillNameMagicCircle": "Cerchio Magico", "skillNameOrcish": "Orchese", "tabInventory": "Inventario", + "tabTrade": "Commercio", + "traderNotAMerchant": "Questo personaggio non commercia.", + "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 minerale è la valuta della colonia. Quello che un mercante possiede è ciò con cui può pagarti.", + "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", + "traderNeverTraded": "mai commerciato qui", + "traderTraded": "già commerciato qui", + "traderReadOnlyCore": "Questa build del core può solo leggere i dati dei mercanti.", + "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.", diff --git a/apps/save-editor/lib/l10n/app_ja.arb b/apps/save-editor/lib/l10n/app_ja.arb index c3f89b7d9..7ec5a02ea 100644 --- a/apps/save-editor/lib/l10n/app_ja.arb +++ b/apps/save-editor/lib/l10n/app_ja.arb @@ -129,6 +129,24 @@ "skillNameMagicCircle": "マジック・サークル", "skillNameOrcish": "オーク語", "tabInventory": "インベントリ", + "tabTrade": "取引", + "traderNotAMerchant": "このキャラクターは取引をしません。", + "traderOre": "鉱石(購買力)", + "traderNoOre": "鉱石なし", + "traderStockCurrent": "在庫", + "traderStockBase": "補充の基準", + "traderStockBaseHint": "商人が補充する基準。ストーリーの進行とともに増えるため、初期状態ではありません。", + "traderOreHint": "鉱石はコロニーの通貨です。商人が持っている量が、あなたに支払える額です。", + "traderPriceWarning": "価格は商人の在庫量と保有鉱石に反応します。これらの数値を変えると、提示価格も動くことがあります。", + "traderAddItem": "アイテムを追加", + "traderRemoveItem": "行を削除", + "traderNeverTraded": "ここで取引したことがない", + "traderTraded": "ここで取引済み", + "traderReadOnlyCore": "このコアは商人データの読み取りのみ可能です。", + "traderEmptyStock": "在庫がありません。", + "traderUnknownItem": "アイテムカタログにありません", + "editorTradersLoadFailed": "商人データの読み込みに失敗しました: {details}", + "traderStockLineCount": "{count} 行", "tabWorld": "ワールド", "tabCharacters": "キャラクター", "characterNoActorBody": "このキャラクターはワールド内のアクターを持たないため、属性、インベントリ、イベントはありません。", diff --git a/apps/save-editor/lib/l10n/app_localizations.dart b/apps/save-editor/lib/l10n/app_localizations.dart index 1ff30a236..5c32e5a3e 100644 --- a/apps/save-editor/lib/l10n/app_localizations.dart +++ b/apps/save-editor/lib/l10n/app_localizations.dart @@ -836,6 +836,114 @@ 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 @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: + /// **'Ore is the colony\'s currency. The amount a merchant holds is what he can pay you with.'** + 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 @traderNeverTraded. + /// + /// In en, this message translates to: + /// **'never traded here'** + String get traderNeverTraded; + + /// No description provided for @traderTraded. + /// + /// In en, this message translates to: + /// **'already traded here'** + String get traderTraded; + + /// 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 @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: diff --git a/apps/save-editor/lib/l10n/app_localizations_de.dart b/apps/save-editor/lib/l10n/app_localizations_de.dart index ba2515714..64cf63e48 100644 --- a/apps/save-editor/lib/l10n/app_localizations_de.dart +++ b/apps/save-editor/lib/l10n/app_localizations_de.dart @@ -418,6 +418,67 @@ 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 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 => + 'Erz ist die Währung der Kolonie. Was ein Händler davon hat, ist das, womit er dich bezahlen kann.'; + + @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 traderNeverTraded => 'hier noch nie gehandelt'; + + @override + String get traderTraded => 'hier schon gehandelt'; + + @override + String get traderReadOnlyCore => 'Dieser Core kann Händlerdaten nur lesen.'; + + @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'; diff --git a/apps/save-editor/lib/l10n/app_localizations_en.dart b/apps/save-editor/lib/l10n/app_localizations_en.dart index be2667bb5..604310f17 100644 --- a/apps/save-editor/lib/l10n/app_localizations_en.dart +++ b/apps/save-editor/lib/l10n/app_localizations_en.dart @@ -417,6 +417,67 @@ 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 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 => + 'Ore is the colony\'s currency. The amount a merchant holds is what he can pay you with.'; + + @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 traderNeverTraded => 'never traded here'; + + @override + String get traderTraded => 'already traded here'; + + @override + String get traderReadOnlyCore => 'This core build can only read trader data.'; + + @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'; diff --git a/apps/save-editor/lib/l10n/app_localizations_es.dart b/apps/save-editor/lib/l10n/app_localizations_es.dart index ba1c0a7f7..aeeb3bc90 100644 --- a/apps/save-editor/lib/l10n/app_localizations_es.dart +++ b/apps/save-editor/lib/l10n/app_localizations_es.dart @@ -419,6 +419,68 @@ 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 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 => + 'El mineral es la moneda de la colonia. Lo que un mercader tiene es con lo que puede pagarte.'; + + @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 traderNeverTraded => 'nunca has comerciado aquí'; + + @override + String get traderTraded => 'ya has comerciado aquí'; + + @override + String get traderReadOnlyCore => + 'Esta versión del núcleo solo puede leer los datos del mercader.'; + + @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'; diff --git a/apps/save-editor/lib/l10n/app_localizations_fr.dart b/apps/save-editor/lib/l10n/app_localizations_fr.dart index 09d84bdcc..2fdfcd361 100644 --- a/apps/save-editor/lib/l10n/app_localizations_fr.dart +++ b/apps/save-editor/lib/l10n/app_localizations_fr.dart @@ -421,6 +421,68 @@ 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 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 => + 'Le minerai est la monnaie de la colonie. Ce qu\'un marchand possède est ce avec quoi il peut vous payer.'; + + @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 traderNeverTraded => 'jamais commercé ici'; + + @override + String get traderTraded => 'déjà commercé ici'; + + @override + String get traderReadOnlyCore => + 'Cette version du cœur ne peut que lire les données des marchands.'; + + @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'; diff --git a/apps/save-editor/lib/l10n/app_localizations_it.dart b/apps/save-editor/lib/l10n/app_localizations_it.dart index 13627f1da..4adedbe18 100644 --- a/apps/save-editor/lib/l10n/app_localizations_it.dart +++ b/apps/save-editor/lib/l10n/app_localizations_it.dart @@ -419,6 +419,68 @@ 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 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 minerale è la valuta della colonia. Quello che un mercante possiede è ciò con cui può pagarti.'; + + @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 traderNeverTraded => 'mai commerciato qui'; + + @override + String get traderTraded => 'già commerciato qui'; + + @override + String get traderReadOnlyCore => + 'Questa build del core può solo leggere i dati dei mercanti.'; + + @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'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ja.dart b/apps/save-editor/lib/l10n/app_localizations_ja.dart index ef05fe7fb..500ddd27a 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ja.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ja.dart @@ -411,6 +411,65 @@ class AppLocalizationsJa extends AppLocalizations { @override String get tabInventory => 'インベントリ'; + @override + String get tabTrade => '取引'; + + @override + String get traderNotAMerchant => 'このキャラクターは取引をしません。'; + + @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 traderNeverTraded => 'ここで取引したことがない'; + + @override + String get traderTraded => 'ここで取引済み'; + + @override + String get traderReadOnlyCore => 'このコアは商人データの読み取りのみ可能です。'; + + @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 => 'ワールド'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pl.dart b/apps/save-editor/lib/l10n/app_localizations_pl.dart index 155b846a8..2fbae7213 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pl.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pl.dart @@ -420,6 +420,68 @@ 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 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 => + 'Ruda jest walutą kolonii. To, ile kupiec jej ma, jest tym, czym może ci zapłacić.'; + + @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 traderNeverTraded => 'nigdy tu nie handlowano'; + + @override + String get traderTraded => 'już tu handlowano'; + + @override + String get traderReadOnlyCore => + 'Ta wersja rdzenia może tylko odczytywać dane kupców.'; + + @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'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pt.dart b/apps/save-editor/lib/l10n/app_localizations_pt.dart index e156d5a2d..a0c9b2a6d 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pt.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pt.dart @@ -419,6 +419,68 @@ 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 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 minério é a moeda da colónia. O que um mercador tem é aquilo com que te pode pagar.'; + + @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 traderNeverTraded => 'nunca negociaste aqui'; + + @override + String get traderTraded => 'já negociaste aqui'; + + @override + String get traderReadOnlyCore => + 'Esta versão do núcleo só consegue ler os dados dos mercadores.'; + + @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'; @@ -3202,6 +3264,68 @@ 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 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 minério é a moeda da colônia. O que um mercador tem é aquilo com que ele pode te pagar.'; + + @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 traderNeverTraded => 'nunca negociou aqui'; + + @override + String get traderTraded => 'já negociou aqui'; + + @override + String get traderReadOnlyCore => + 'Esta versão do núcleo só consegue ler os dados dos mercadores.'; + + @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'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ru.dart b/apps/save-editor/lib/l10n/app_localizations_ru.dart index e82006e75..d00ff3a1f 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ru.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ru.dart @@ -421,6 +421,68 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tabInventory => 'Инвентарь'; + @override + String get tabTrade => 'Торговля'; + + @override + String get traderNotAMerchant => 'Этот персонаж не торгует.'; + + @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 traderNeverTraded => 'здесь ещё не торговали'; + + @override + String get traderTraded => 'здесь уже торговали'; + + @override + String get traderReadOnlyCore => + 'Эта сборка ядра может только читать данные торговцев.'; + + @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 => 'Мир'; diff --git a/apps/save-editor/lib/l10n/app_localizations_zh.dart b/apps/save-editor/lib/l10n/app_localizations_zh.dart index 920d73a90..9495fcff8 100644 --- a/apps/save-editor/lib/l10n/app_localizations_zh.dart +++ b/apps/save-editor/lib/l10n/app_localizations_zh.dart @@ -406,6 +406,64 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tabInventory => '物品栏'; + @override + String get tabTrade => '交易'; + + @override + String get traderNotAMerchant => '该角色不进行交易。'; + + @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 traderNeverTraded => '尚未在此交易'; + + @override + String get traderTraded => '已在此交易过'; + + @override + String get traderReadOnlyCore => '此核心版本只能读取商人数据。'; + + @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 => '世界'; @@ -3091,6 +3149,64 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get tabInventory => '物品栏'; + @override + String get tabTrade => '交易'; + + @override + String get traderNotAMerchant => '该角色不进行交易。'; + + @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 traderNeverTraded => '尚未在此交易'; + + @override + String get traderTraded => '已在此交易过'; + + @override + String get traderReadOnlyCore => '此核心版本只能读取商人数据。'; + + @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 => '世界'; diff --git a/apps/save-editor/lib/l10n/app_pl.arb b/apps/save-editor/lib/l10n/app_pl.arb index 3a1cd9977..3f0b04db4 100644 --- a/apps/save-editor/lib/l10n/app_pl.arb +++ b/apps/save-editor/lib/l10n/app_pl.arb @@ -129,6 +129,24 @@ "skillNameMagicCircle": "Krąg magiczny", "skillNameOrcish": "Język orkowy", "tabInventory": "Ekwipunek", + "tabTrade": "Handel", + "traderNotAMerchant": "Ta postać nie handluje.", + "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": "Ruda jest walutą kolonii. To, ile kupiec jej ma, jest tym, czym może ci zapłacić.", + "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ę", + "traderNeverTraded": "nigdy tu nie handlowano", + "traderTraded": "już tu handlowano", + "traderReadOnlyCore": "Ta wersja rdzenia może tylko odczytywać dane kupców.", + "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ń.", diff --git a/apps/save-editor/lib/l10n/app_pt.arb b/apps/save-editor/lib/l10n/app_pt.arb index 99ceaaf9f..b75424539 100644 --- a/apps/save-editor/lib/l10n/app_pt.arb +++ b/apps/save-editor/lib/l10n/app_pt.arb @@ -129,6 +129,24 @@ "skillNameMagicCircle": "Círculo de Magia", "skillNameOrcish": "Língua dos Orcs", "tabInventory": "Inventário", + "tabTrade": "Comércio", + "traderNotAMerchant": "Esta personagem não comercia.", + "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 minério é a moeda da colónia. O que um mercador tem é aquilo com que te pode pagar.", + "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", + "traderNeverTraded": "nunca negociaste aqui", + "traderTraded": "já negociaste aqui", + "traderReadOnlyCore": "Esta versão do núcleo só consegue ler os dados dos mercadores.", + "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.", diff --git a/apps/save-editor/lib/l10n/app_pt_BR.arb b/apps/save-editor/lib/l10n/app_pt_BR.arb index 44b19523e..3fcc0281d 100644 --- a/apps/save-editor/lib/l10n/app_pt_BR.arb +++ b/apps/save-editor/lib/l10n/app_pt_BR.arb @@ -129,6 +129,24 @@ "skillNameMagicCircle": "Círculo de Magia", "skillNameOrcish": "Língua dos Orcs", "tabInventory": "Inventário", + "tabTrade": "Comércio", + "traderNotAMerchant": "Este personagem não comercia.", + "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 minério é a moeda da colônia. O que um mercador tem é aquilo com que ele pode te pagar.", + "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", + "traderNeverTraded": "nunca negociou aqui", + "traderTraded": "já negociou aqui", + "traderReadOnlyCore": "Esta versão do núcleo só consegue ler os dados dos mercadores.", + "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.", diff --git a/apps/save-editor/lib/l10n/app_ru.arb b/apps/save-editor/lib/l10n/app_ru.arb index 293a35d7b..57ae2e126 100644 --- a/apps/save-editor/lib/l10n/app_ru.arb +++ b/apps/save-editor/lib/l10n/app_ru.arb @@ -129,6 +129,24 @@ "skillNameMagicCircle": "Круг магии", "skillNameOrcish": "Орочий язык", "tabInventory": "Инвентарь", + "tabTrade": "Торговля", + "traderNotAMerchant": "Этот персонаж не торгует.", + "traderOre": "Руда (покупательная способность)", + "traderNoOre": "нет руды", + "traderStockCurrent": "Запас", + "traderStockBase": "База пополнения", + "traderStockBaseHint": "То, к чему торговец пополняет запасы. Растёт по ходу сюжета, поэтому это не исходное состояние.", + "traderOreHint": "Руда — валюта колонии. Сколько её у торговца, тем он и может вам заплатить.", + "traderPriceWarning": "Цены зависят от того, сколько у торговца товара и руды, поэтому изменение этих чисел может сдвинуть и его расценки.", + "traderAddItem": "Добавить предмет", + "traderRemoveItem": "Удалить строку", + "traderNeverTraded": "здесь ещё не торговали", + "traderTraded": "здесь уже торговали", + "traderReadOnlyCore": "Эта сборка ядра может только читать данные торговцев.", + "traderEmptyStock": "Товара нет.", + "traderUnknownItem": "нет в каталоге предметов", + "editorTradersLoadFailed": "Не удалось загрузить торговцев: {details}", + "traderStockLineCount": "{count} строк", "tabWorld": "Мир", "tabCharacters": "Персонажи", "characterNoActorBody": "У этого персонажа нет актёра в мире, поэтому нет атрибутов, инвентаря или событий.", diff --git a/apps/save-editor/lib/l10n/app_zh.arb b/apps/save-editor/lib/l10n/app_zh.arb index 90aec8554..2e956fd58 100644 --- a/apps/save-editor/lib/l10n/app_zh.arb +++ b/apps/save-editor/lib/l10n/app_zh.arb @@ -129,6 +129,24 @@ "skillNameMagicCircle": "魔法环", "skillNameOrcish": "兽人语", "tabInventory": "物品栏", + "tabTrade": "交易", + "traderNotAMerchant": "该角色不进行交易。", + "traderOre": "矿石(购买力)", + "traderNoOre": "无矿石", + "traderStockCurrent": "库存", + "traderStockBase": "补货基准", + "traderStockBaseHint": "商人补货的基准。会随剧情推进而增长,因此不是初始状态。", + "traderOreHint": "矿石是殖民地的货币。商人持有的数量决定了他能付给你多少。", + "traderPriceWarning": "价格会随商人的库存量和持有矿石而变化,因此修改这些数字也可能改变他的开价。", + "traderAddItem": "添加物品", + "traderRemoveItem": "移除条目", + "traderNeverTraded": "尚未在此交易", + "traderTraded": "已在此交易过", + "traderReadOnlyCore": "此核心版本只能读取商人数据。", + "traderEmptyStock": "没有库存。", + "traderUnknownItem": "不在物品目录中", + "editorTradersLoadFailed": "商人数据加载失败:{details}", + "traderStockLineCount": "{count} 条", "tabWorld": "世界", "tabCharacters": "角色", "characterNoActorBody": "该角色在世界中没有对应的实体,因此没有属性、物品栏或事件。", diff --git a/apps/save-editor/lib/l10n/app_zh_Hans.arb b/apps/save-editor/lib/l10n/app_zh_Hans.arb index c8a77a6c5..0e9a9af8e 100644 --- a/apps/save-editor/lib/l10n/app_zh_Hans.arb +++ b/apps/save-editor/lib/l10n/app_zh_Hans.arb @@ -129,6 +129,24 @@ "skillNameMagicCircle": "魔法环", "skillNameOrcish": "兽人语", "tabInventory": "物品栏", + "tabTrade": "交易", + "traderNotAMerchant": "该角色不进行交易。", + "traderOre": "矿石(购买力)", + "traderNoOre": "无矿石", + "traderStockCurrent": "库存", + "traderStockBase": "补货基准", + "traderStockBaseHint": "商人补货的基准。会随剧情推进而增长,因此不是初始状态。", + "traderOreHint": "矿石是殖民地的货币。商人持有的数量决定了他能付给你多少。", + "traderPriceWarning": "价格会随商人的库存量和持有矿石而变化,因此修改这些数字也可能改变他的开价。", + "traderAddItem": "添加物品", + "traderRemoveItem": "移除条目", + "traderNeverTraded": "尚未在此交易", + "traderTraded": "已在此交易过", + "traderReadOnlyCore": "此核心版本只能读取商人数据。", + "traderEmptyStock": "没有库存。", + "traderUnknownItem": "不在物品目录中", + "editorTradersLoadFailed": "商人数据加载失败:{details}", + "traderStockLineCount": "{count} 条", "tabWorld": "世界", "tabCharacters": "角色", "characterNoActorBody": "该角色在世界中没有对应的实体,因此没有属性、物品栏或事件。", 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..a22f41e46 --- /dev/null +++ b/apps/save-editor/test/trader_panel_test.dart @@ -0,0 +1,453 @@ +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/editor/domain/core_service.dart'; +import 'package:goresave/features/editor/domain/editor_settings_store.dart'; +import 'package:goresave/features/editor/domain/trader_models.dart'; +import 'package:goresave/providers/data_providers.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. +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 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('Handel tab', () { + Future pumpApp(WidgetTester tester, GoresaveCoreService core) async { + await tester.binding.setSurfaceSize(const Size(1400, 1000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + coreServiceProvider.overrideWithValue(core), + editorSettingsStoreProvider.overrideWithValue( + const NoopEditorSettingsStore(), + ), + ], + 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(find.widgetWithText(Tab, '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 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(find.widgetWithText(Tab, '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}); + + final bool playerIsTrader; + 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': [ + '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, + 'items': [ + { + 'path': kTraderOrePath, + 'id': 'ItMi_Orenugget', + 'count': 55, + 'unknownItem': false, + }, + { + 'path': '/Script/Angelscript.ItFo_Loaf', + 'id': 'ItFo_Loaf', + 'count': 3, + 'unknownItem': false, + }, + ], + 'defaultItems': [ + { + 'path': kTraderOrePath, + 'id': 'ItMi_Orenugget', + 'count': 64, + 'unknownItem': false, + }, + { + 'path': '/Script/Angelscript.ItFo_Loaf', + 'id': 'ItFo_Loaf', + 'count': 3, + 'unknownItem': false, + }, + ], + 'generatedEvents': ['OnWorldStart'], + 'hasItemsByDifficulty': false, + }, + }; + 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': 0, 'characters': []}, + }; + 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/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..14adc47f3 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) } @@ -9217,6 +9310,17 @@ fn structured_edit_target(edit: &PrivateEdit) -> Option<(&'static str, String)> glossary.segment_asset.as_str(), ]), )), + // Declarative: it sets one stock line to one count. Two of them naming the + // same line would silently discard one. addItem/removeItem are deliberately + // absent — they ADD to or drop from the map, and two of them name two + // different lines, 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(), + ]), + )), _ => None, } } @@ -9313,6 +9417,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 +9433,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 +9469,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 +9596,115 @@ 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(), + ) + })?; + // Sold-out lines are deleted, never negative, so a negative count would be a + // state the game never writes. + if !(0..=i32::MAX as i64).contains(&count) { + return Err(CoreError::InvalidRequest( + "private.traders.setStock value.count must fit a non-negative i32".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 +11231,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/traders.rs b/crates/gore-save/src/traders.rs new file mode 100644 index 000000000..1a328ccd7 --- /dev/null +++ b/crates/gore-save/src/traders.rs @@ -0,0 +1,1129 @@ +//! 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, +} + +/// 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) +} + +/// 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, + 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`. +/// +/// 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 mut matches = summaries.iter().filter(|s| s.unique_name == unique_name); + 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> { + 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 + ))); + } + if edit.count < 0 { + return Err(CoreError::InvalidRequest( + "stock count must not be negative".to_string(), + )); + } + 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, + } + } + + 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())), + prop("m_Items", stock(items)), + prop("m_DefaultItems", stock(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 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 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_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"); + 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_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..cba723298 --- /dev/null +++ b/crates/gore-save/tests/traders.rs @@ -0,0 +1,356 @@ +//! 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 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 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}"); +} From ac681afa10371d38376386354b6b6ebb6982ac18 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 12 Aug 2026 22:09:36 +0200 Subject: [PATCH 02/29] feat(save-editor): browse a merchant's stock the way the inventory is browsed Queued changes now sit above the list rather than inside it. An addition has no row yet and a removal's row is about to stop existing, so drawing either among the saved lines claimed a state the save does not have. Both use the same banner the inventory uses, extracted into pending_structural_row.dart. The two stock maps get a segmented switch instead of sitting on one page. That also settles which map the ore field belongs to: the ore card only appears for the live stock, where the number IS the merchant's purchasing power, and its line leaves the list so it is not shown twice. In the restock baseline ore is an ordinary row, because "purchasing power" means nothing there. Stock is grouped by item category behind the inventory's own sidebar, sorted by the localized name with the class id as tiebreak. Smaller corrections from testing against a real save: - The ore hint said what ore is for. It now says what the user cannot see otherwise: the in-game figure differs, because on load the game adds what accrued since the merchant's last trade. - Class ids followed the show-object-ids setting nowhere; now they do, and are dropped entirely when the title already is the id. - The character list badges a merchant, joined in the core so the list needs no second query. - The price note moved to the top, where it qualifies the ore as much as the counts. Co-Authored-By: Claude Opus 5 --- .../editor/domain/character_index.dart | 5 + .../editor/domain/editor_notifier.dart | 12 + .../editor/ui/character_master_list.dart | 12 +- .../features/editor/ui/characters_tab.dart | 14 +- .../features/editor/ui/inventory_detail.dart | 84 +-- .../editor/ui/pending_structural_row.dart | 87 +++ .../lib/features/editor/ui/trader_detail.dart | 573 +++++++++++++----- apps/save-editor/lib/l10n/app_de.arb | 4 +- apps/save-editor/lib/l10n/app_en.arb | 4 +- apps/save-editor/lib/l10n/app_es.arb | 4 +- apps/save-editor/lib/l10n/app_fr.arb | 4 +- apps/save-editor/lib/l10n/app_it.arb | 4 +- apps/save-editor/lib/l10n/app_ja.arb | 4 +- .../lib/l10n/app_localizations.dart | 14 +- .../lib/l10n/app_localizations_de.dart | 8 +- .../lib/l10n/app_localizations_en.dart | 8 +- .../lib/l10n/app_localizations_es.dart | 8 +- .../lib/l10n/app_localizations_fr.dart | 8 +- .../lib/l10n/app_localizations_it.dart | 8 +- .../lib/l10n/app_localizations_ja.dart | 9 +- .../lib/l10n/app_localizations_pl.dart | 8 +- .../lib/l10n/app_localizations_pt.dart | 16 +- .../lib/l10n/app_localizations_ru.dart | 8 +- .../lib/l10n/app_localizations_zh.dart | 18 +- apps/save-editor/lib/l10n/app_pl.arb | 4 +- apps/save-editor/lib/l10n/app_pt.arb | 4 +- apps/save-editor/lib/l10n/app_pt_BR.arb | 4 +- apps/save-editor/lib/l10n/app_ru.arb | 4 +- apps/save-editor/lib/l10n/app_zh.arb | 4 +- apps/save-editor/lib/l10n/app_zh_Hans.arb | 4 +- apps/save-editor/test/trader_panel_test.dart | 261 +++++++- crates/gore-save/src/npc.rs | 17 + 32 files changed, 854 insertions(+), 372 deletions(-) create mode 100644 apps/save-editor/lib/features/editor/ui/pending_structural_row.dart 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 2fc7ec24e..87625bf1a 100644 --- a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart +++ b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart @@ -4190,6 +4190,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); } @@ -4223,6 +4228,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/ui/character_master_list.dart b/apps/save-editor/lib/features/editor/ui/character_master_list.dart index 587852eff..f4487e875 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 @@ -526,7 +526,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 +548,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 427ce7316..61543e0a2 100644 --- a/apps/save-editor/lib/features/editor/ui/characters_tab.dart +++ b/apps/save-editor/lib/features/editor/ui/characters_tab.dart @@ -118,6 +118,10 @@ class CharactersTab extends ConsumerWidget { 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 @@ -241,6 +245,10 @@ class CharactersTab extends ConsumerWidget { icon: const Icon(Icons.inventory_2_outlined), text: l10n.tabInventory, ), + Tab( + icon: const Icon(Icons.storefront_outlined), + text: l10n.tabTrade, + ), Tab( icon: const Icon(Icons.school_outlined), text: l10n.dialogKnowledge, @@ -249,10 +257,6 @@ class CharactersTab extends ConsumerWidget { icon: const Icon(Icons.history_outlined), text: l10n.sectionEvents, ), - Tab( - icon: const Icon(Icons.storefront_outlined), - text: l10n.tabTrade, - ), Tab( icon: const Icon(Icons.place_outlined), text: l10n.heroTransform, @@ -264,9 +268,9 @@ class CharactersTab extends ConsumerWidget { children: [ _KeepAliveTab(child: attributeBody), _KeepAliveTab(child: inventoryBody), + _KeepAliveTab(child: tradeBody), _KeepAliveTab(child: knowledgeBody), _KeepAliveTab(child: eventsBody), - _KeepAliveTab(child: tradeBody), _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 index 222abf63b..2f3f882b4 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -1,10 +1,14 @@ 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'; @@ -25,6 +29,7 @@ class TraderPanel extends ConsumerStatefulWidget { required this.notifier, required this.actor, required this.editable, + required this.reloadKey, }); final SaveInspection inspection; @@ -35,6 +40,12 @@ class TraderPanel extends ConsumerStatefulWidget { /// (`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(); } @@ -45,9 +56,19 @@ class _TraderPanelState extends ConsumerState { String? _error; bool _loading = true; - /// Which save and actor the currently held data belongs to, so a reload that - /// lands after the user moved on is discarded instead of shown. - String? _loadedFor; + /// 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() { @@ -58,23 +79,18 @@ class _TraderPanelState extends ConsumerState { @override void didUpdateWidget(covariant TraderPanel oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.actor.uniqueName != widget.actor.uniqueName || - oldWidget.notifier.selectedPath != widget.notifier.selectedPath) { - _load(); - } + if (widget.reloadKey != oldWidget.reloadKey) _load(); } - String get _token => '${widget.notifier.selectedPath}|${widget.actor.uniqueName}'; - Future _load() async { - final token = _token; + final epoch = ++_epoch; setState(() { _loading = true; _error = null; _detail = null; }); final list = await widget.notifier.loadTraders(); - if (!mounted || _token != token) return; + if (!mounted || epoch != _epoch) return; if (list.error != null) { setState(() { _loading = false; @@ -90,18 +106,16 @@ class _TraderPanelState extends ConsumerState { _loading = false; _list = list; _detail = null; - _loadedFor = token; }); return; } final detail = await widget.notifier.loadTraderDetail(row.index); - if (!mounted || _token != token) return; + if (!mounted || epoch != _epoch) return; setState(() { _loading = false; _list = list; _error = detail.error; _detail = detail.detail; - _loadedFor = token; }); } @@ -124,7 +138,7 @@ class _TraderPanelState extends ConsumerState { ); } final detail = _detail; - if (detail == null || _loadedFor != _token) { + if (detail == null) { return _Message( icon: Icons.storefront_outlined, title: l10n.tabTrade, @@ -137,73 +151,90 @@ class _TraderPanelState extends ConsumerState { final canAdd = widget.editable && (list?.canAddItem ?? false); final canRemove = widget.editable && (list?.canRemoveItem ?? false); - return ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - children: [ - _OreCard( - detail: detail, - editable: canSet, - onChanged: (value) => _queueSet(TraderStockMap.current, kTraderOrePath, value), - onRevert: () => _revert(TraderStockMap.current, kTraderOrePath), - pending: _pendingCountFor(TraderStockMap.current, kTraderOrePath), - ), - const SizedBox(height: 12), - Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.info_outline, size: 18, color: theme.colorScheme.primary), - const SizedBox(width: 8), - Expanded( - child: Text( - l10n.traderPriceWarning, - style: theme.textTheme.bodySmall, - ), + // 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: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // First, because it qualifies every number below it — the ore as much + // as the stock counts. + _NoteCard(text: l10n.traderPriceWarning), + if (widget.editable && !(list?.canSetStock ?? false)) ...[ + 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, + onChanged: (value) => _queueSet(_map, kTraderOrePath, value), + onRevert: () => _revert(_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), ), ), - ), - if (widget.editable && !(list?.canSetStock ?? false)) ...[ - const SizedBox(height: 12), - Text(l10n.traderReadOnlyCore, style: theme.textTheme.bodySmall), ], - const SizedBox(height: 16), - _StockSection( - title: l10n.traderStockCurrent, - hint: null, - map: TraderStockMap.current, - items: detail.items, - canSet: canSet, - canAdd: canAdd, - canRemove: canRemove, - pendingOf: _pendingCountFor, - isRemovalPending: _isRemovalPending, - onChanged: _queueSet, - onRevert: _revert, - onRemove: _queueRemove, - onAdd: () => _addItem(TraderStockMap.current, detail), - ), - const SizedBox(height: 24), - _StockSection( - title: l10n.traderStockBase, - hint: l10n.traderStockBaseHint, - map: TraderStockMap.base, - items: detail.defaultItems, - canSet: canSet, - canAdd: canAdd, - canRemove: canRemove, - pendingOf: _pendingCountFor, - isRemovalPending: _isRemovalPending, - onChanged: _queueSet, - onRevert: _revert, - onRemove: _queueRemove, - onAdd: () => _addItem(TraderStockMap.base, detail), - ), - ], + ), ); } @@ -235,10 +266,62 @@ class _TraderPanelState extends ConsumerState { return null; } - bool _isRemovalPending(TraderStockMap map, String path) { - final key = _edit(TraderEditKind.removeItem, map, path).pendingKey; - final pending = ref.read(editorProvider).pendingEdits[key]; - return pending?.edits.firstOrNull?['path'] == 'private.traders.removeItem'; + 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) { @@ -321,15 +404,6 @@ class _OreCard extends ConsumerWidget { Text(l10n.traderOre, style: theme.textTheme.titleMedium), const SizedBox(height: 4), Text(l10n.traderOreHint, style: theme.textTheme.bodySmall), - const SizedBox(height: 4), - Text( - detail.summary.traded - ? l10n.traderTraded - : l10n.traderNeverTraded, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, - ), - ), ], ), ), @@ -356,62 +430,119 @@ class _OreCard extends ConsumerWidget { } } -class _StockSection extends StatelessWidget { +class _NoteCard extends StatelessWidget { + const _NoteCard({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded(child: Text(text, style: theme.textTheme.bodySmall)), + ], + ), + ), + ); + } +} + +class _StockSection extends ConsumerWidget { const _StockSection({ - required this.title, - required this.hint, 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.isRemovalPending, required this.onChanged, required this.onRevert, required this.onRemove, + required this.onRevertAdd, required this.onAdd, }); - final String title; - final String? hint; + /// 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; + 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 bool Function(TraderStockMap, String) isRemovalPending; 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) { + 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); + // 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 []; + final nothingToShow = + items.isEmpty && pendingAdds.isEmpty && pendingRemovals.isEmpty; + return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: theme.textTheme.titleMedium), - Text( - l10n.traderStockLineCount(items.length), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, - ), - ), - if (hint != null) ...[ - const SizedBox(height: 4), - Text(hint!, style: theme.textTheme.bodySmall), - ], - ], + child: Text( + l10n.traderStockLineCount(lineCount), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), ), ), if (canAdd) @@ -422,30 +553,96 @@ class _StockSection extends StatelessWidget { ), ], ), + // 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. + 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 (items.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text(l10n.traderEmptyStock, style: theme.textTheme.bodyMedium), + if (nothingToShow) + Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.traderEmptyStock, + style: theme.textTheme.bodyMedium, + ), ) else - Card( - margin: EdgeInsets.zero, - child: Column( - children: [ - for (final item in items) - _StockRow( - item: item, - map: map, - canSet: canSet, - canRemove: canRemove, - pending: pendingOf(map, item.path), - removalPending: isRemovalPending(map, item.path), - onChanged: (v) => onChanged(map, item.path, v), - onRevert: () => onRevert(map, item.path), - onRemove: () => onRemove(map, item.path), - ), - ], + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < _compactBelow; + final rows = compact ? items : 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( + 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), + ), + ), + ), + ], + ); + }, ), ), ], @@ -453,6 +650,78 @@ class _StockSection extends StatelessWidget { } } +/// One category's lines, in [ItemCategory] declaration order. +class _StockGroup { + const _StockGroup({required this.category, required this.items}); + + final ItemCategory category; + final List items; +} + +/// 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); + } + int compare(TraderItem a, TraderItem b) { + final byName = displayNameOf( + a, + ).toLowerCase().compareTo(displayNameOf(b).toLowerCase()); + return byName != 0 ? byName : a.id.compareTo(b.id); + } + + 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({ required this.item, @@ -460,7 +729,6 @@ class _StockRow extends ConsumerWidget { required this.canSet, required this.canRemove, required this.pending, - required this.removalPending, required this.onChanged, required this.onRevert, required this.onRemove, @@ -471,7 +739,6 @@ class _StockRow extends ConsumerWidget { final bool canSet; final bool canRemove; final int? pending; - final bool removalPending; final void Function(int) onChanged; final VoidCallback onRevert; final VoidCallback onRemove; @@ -485,25 +752,24 @@ class _StockRow extends ConsumerWidget { // 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(' · '); return ListTile( dense: true, leading: item.isOre ? Icon(Icons.savings_outlined, color: theme.colorScheme.primary) : const Icon(Icons.inventory_2_outlined), - title: Text( - label, - style: removalPending - ? theme.textTheme.bodyMedium?.copyWith( - decoration: TextDecoration.lineThrough, - color: theme.colorScheme.outline, - ) - : null, - ), - subtitle: Text( - item.unknownItem ? '${item.id} · ${l10n.traderUnknownItem}' : item.id, - style: theme.textTheme.bodySmall, - ), + title: Text(label), + subtitle: subtitle.isEmpty + ? null + : Text(subtitle, style: theme.textTheme.bodySmall), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -514,7 +780,7 @@ class _StockRow extends ConsumerWidget { 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 && !removalPending && !item.unknownItem, + enabled: canSet && !item.unknownItem, onChanged: onChanged, onRevert: onRevert, ), @@ -522,10 +788,7 @@ class _StockRow extends ConsumerWidget { if (canRemove) IconButton( tooltip: l10n.traderRemoveItem, - icon: Icon( - removalPending ? Icons.undo : Icons.delete_outline, - size: 20, - ), + icon: const Icon(Icons.delete_outline, size: 20), onPressed: onRemove, ), ], diff --git a/apps/save-editor/lib/l10n/app_de.arb b/apps/save-editor/lib/l10n/app_de.arb index 5b9a03c3e..f67d8e083 100644 --- a/apps/save-editor/lib/l10n/app_de.arb +++ b/apps/save-editor/lib/l10n/app_de.arb @@ -138,12 +138,10 @@ "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": "Erz ist die Währung der Kolonie. Was ein Händler davon hat, ist das, womit er dich bezahlen kann.", + "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", - "traderNeverTraded": "hier noch nie gehandelt", - "traderTraded": "hier schon gehandelt", "traderReadOnlyCore": "Dieser Core kann Händlerdaten nur lesen.", "traderEmptyStock": "Nichts auf Lager.", "traderUnknownItem": "nicht im Item-Katalog", diff --git a/apps/save-editor/lib/l10n/app_en.arb b/apps/save-editor/lib/l10n/app_en.arb index 3858d3c58..0ea5f6876 100644 --- a/apps/save-editor/lib/l10n/app_en.arb +++ b/apps/save-editor/lib/l10n/app_en.arb @@ -142,12 +142,10 @@ "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": "Ore is the colony's currency. The amount a merchant holds is what he can pay you with.", + "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", - "traderNeverTraded": "never traded here", - "traderTraded": "already traded here", "traderReadOnlyCore": "This core build can only read trader data.", "traderEmptyStock": "Nothing in stock.", "traderUnknownItem": "not in the item catalog", diff --git a/apps/save-editor/lib/l10n/app_es.arb b/apps/save-editor/lib/l10n/app_es.arb index a525dd8ed..746bea4ca 100644 --- a/apps/save-editor/lib/l10n/app_es.arb +++ b/apps/save-editor/lib/l10n/app_es.arb @@ -136,12 +136,10 @@ "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": "El mineral es la moneda de la colonia. Lo que un mercader tiene es con lo que puede pagarte.", + "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", - "traderNeverTraded": "nunca has comerciado aquí", - "traderTraded": "ya has comerciado aquí", "traderReadOnlyCore": "Esta versión del núcleo solo puede leer los datos del mercader.", "traderEmptyStock": "Sin existencias.", "traderUnknownItem": "no está en el catálogo de objetos", diff --git a/apps/save-editor/lib/l10n/app_fr.arb b/apps/save-editor/lib/l10n/app_fr.arb index 1ee42e3e6..02308d5a5 100644 --- a/apps/save-editor/lib/l10n/app_fr.arb +++ b/apps/save-editor/lib/l10n/app_fr.arb @@ -136,12 +136,10 @@ "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": "Le minerai est la monnaie de la colonie. Ce qu'un marchand possède est ce avec quoi il peut vous payer.", + "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", - "traderNeverTraded": "jamais commercé ici", - "traderTraded": "déjà commercé ici", "traderReadOnlyCore": "Cette version du cœur ne peut que lire les données des marchands.", "traderEmptyStock": "Rien en stock.", "traderUnknownItem": "absent du catalogue d'objets", diff --git a/apps/save-editor/lib/l10n/app_it.arb b/apps/save-editor/lib/l10n/app_it.arb index 0a81ec844..8e22d4a27 100644 --- a/apps/save-editor/lib/l10n/app_it.arb +++ b/apps/save-editor/lib/l10n/app_it.arb @@ -136,12 +136,10 @@ "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 minerale è la valuta della colonia. Quello che un mercante possiede è ciò con cui può pagarti.", + "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", - "traderNeverTraded": "mai commerciato qui", - "traderTraded": "già commerciato qui", "traderReadOnlyCore": "Questa build del core può solo leggere i dati dei mercanti.", "traderEmptyStock": "Niente in magazzino.", "traderUnknownItem": "non presente nel catalogo oggetti", diff --git a/apps/save-editor/lib/l10n/app_ja.arb b/apps/save-editor/lib/l10n/app_ja.arb index 7ec5a02ea..b0bba90cf 100644 --- a/apps/save-editor/lib/l10n/app_ja.arb +++ b/apps/save-editor/lib/l10n/app_ja.arb @@ -136,12 +136,10 @@ "traderStockCurrent": "在庫", "traderStockBase": "補充の基準", "traderStockBaseHint": "商人が補充する基準。ストーリーの進行とともに増えるため、初期状態ではありません。", - "traderOreHint": "鉱石はコロニーの通貨です。商人が持っている量が、あなたに支払える額です。", + "traderOreHint": "ゲーム内の数値は異なります。読み込み時に、前回の取引以降に生じた分が加算されます(余剰品を売り、その分で補充します)。この数値は開始値であり、取引画面に表示される額ではありません。", "traderPriceWarning": "価格は商人の在庫量と保有鉱石に反応します。これらの数値を変えると、提示価格も動くことがあります。", "traderAddItem": "アイテムを追加", "traderRemoveItem": "行を削除", - "traderNeverTraded": "ここで取引したことがない", - "traderTraded": "ここで取引済み", "traderReadOnlyCore": "このコアは商人データの読み取りのみ可能です。", "traderEmptyStock": "在庫がありません。", "traderUnknownItem": "アイテムカタログにありません", diff --git a/apps/save-editor/lib/l10n/app_localizations.dart b/apps/save-editor/lib/l10n/app_localizations.dart index 5c32e5a3e..feea539b6 100644 --- a/apps/save-editor/lib/l10n/app_localizations.dart +++ b/apps/save-editor/lib/l10n/app_localizations.dart @@ -881,7 +881,7 @@ abstract class AppLocalizations { /// No description provided for @traderOreHint. /// /// In en, this message translates to: - /// **'Ore is the colony\'s currency. The amount a merchant holds is what he can pay you with.'** + /// **'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. @@ -902,18 +902,6 @@ abstract class AppLocalizations { /// **'Remove line'** String get traderRemoveItem; - /// No description provided for @traderNeverTraded. - /// - /// In en, this message translates to: - /// **'never traded here'** - String get traderNeverTraded; - - /// No description provided for @traderTraded. - /// - /// In en, this message translates to: - /// **'already traded here'** - String get traderTraded; - /// No description provided for @traderReadOnlyCore. /// /// 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 64cf63e48..a55393a9d 100644 --- a/apps/save-editor/lib/l10n/app_localizations_de.dart +++ b/apps/save-editor/lib/l10n/app_localizations_de.dart @@ -442,7 +442,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get traderOreHint => - 'Erz ist die Währung der Kolonie. Was ein Händler davon hat, ist das, womit er dich bezahlen kann.'; + '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 => @@ -454,12 +454,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get traderRemoveItem => 'Zeile entfernen'; - @override - String get traderNeverTraded => 'hier noch nie gehandelt'; - - @override - String get traderTraded => 'hier schon gehandelt'; - @override String get traderReadOnlyCore => 'Dieser Core kann Händlerdaten nur lesen.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_en.dart b/apps/save-editor/lib/l10n/app_localizations_en.dart index 604310f17..98e057a10 100644 --- a/apps/save-editor/lib/l10n/app_localizations_en.dart +++ b/apps/save-editor/lib/l10n/app_localizations_en.dart @@ -441,7 +441,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get traderOreHint => - 'Ore is the colony\'s currency. The amount a merchant holds is what he can pay you with.'; + '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 => @@ -453,12 +453,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get traderRemoveItem => 'Remove line'; - @override - String get traderNeverTraded => 'never traded here'; - - @override - String get traderTraded => 'already traded here'; - @override String get traderReadOnlyCore => 'This core build can only read trader data.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_es.dart b/apps/save-editor/lib/l10n/app_localizations_es.dart index aeeb3bc90..28e71c00b 100644 --- a/apps/save-editor/lib/l10n/app_localizations_es.dart +++ b/apps/save-editor/lib/l10n/app_localizations_es.dart @@ -443,7 +443,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get traderOreHint => - 'El mineral es la moneda de la colonia. Lo que un mercader tiene es con lo que puede pagarte.'; + '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 => @@ -455,12 +455,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get traderRemoveItem => 'Quitar línea'; - @override - String get traderNeverTraded => 'nunca has comerciado aquí'; - - @override - String get traderTraded => 'ya has comerciado aquí'; - @override String get traderReadOnlyCore => 'Esta versión del núcleo solo puede leer los datos del mercader.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_fr.dart b/apps/save-editor/lib/l10n/app_localizations_fr.dart index 2fdfcd361..08fcf6726 100644 --- a/apps/save-editor/lib/l10n/app_localizations_fr.dart +++ b/apps/save-editor/lib/l10n/app_localizations_fr.dart @@ -445,7 +445,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get traderOreHint => - 'Le minerai est la monnaie de la colonie. Ce qu\'un marchand possède est ce avec quoi il peut vous payer.'; + '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 => @@ -457,12 +457,6 @@ class AppLocalizationsFr extends AppLocalizations { @override String get traderRemoveItem => 'Retirer la ligne'; - @override - String get traderNeverTraded => 'jamais commercé ici'; - - @override - String get traderTraded => 'déjà commercé ici'; - @override String get traderReadOnlyCore => 'Cette version du cœur ne peut que lire les données des marchands.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_it.dart b/apps/save-editor/lib/l10n/app_localizations_it.dart index 4adedbe18..bd773449e 100644 --- a/apps/save-editor/lib/l10n/app_localizations_it.dart +++ b/apps/save-editor/lib/l10n/app_localizations_it.dart @@ -443,7 +443,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get traderOreHint => - 'Il minerale è la valuta della colonia. Quello che un mercante possiede è ciò con cui può pagarti.'; + '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 => @@ -455,12 +455,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get traderRemoveItem => 'Rimuovi riga'; - @override - String get traderNeverTraded => 'mai commerciato qui'; - - @override - String get traderTraded => 'già commerciato qui'; - @override String get traderReadOnlyCore => 'Questa build del core può solo leggere i dati dei mercanti.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ja.dart b/apps/save-editor/lib/l10n/app_localizations_ja.dart index 500ddd27a..1f26953c9 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ja.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ja.dart @@ -433,7 +433,8 @@ class AppLocalizationsJa extends AppLocalizations { String get traderStockBaseHint => '商人が補充する基準。ストーリーの進行とともに増えるため、初期状態ではありません。'; @override - String get traderOreHint => '鉱石はコロニーの通貨です。商人が持っている量が、あなたに支払える額です。'; + String get traderOreHint => + 'ゲーム内の数値は異なります。読み込み時に、前回の取引以降に生じた分が加算されます(余剰品を売り、その分で補充します)。この数値は開始値であり、取引画面に表示される額ではありません。'; @override String get traderPriceWarning => @@ -445,12 +446,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get traderRemoveItem => '行を削除'; - @override - String get traderNeverTraded => 'ここで取引したことがない'; - - @override - String get traderTraded => 'ここで取引済み'; - @override String get traderReadOnlyCore => 'このコアは商人データの読み取りのみ可能です。'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pl.dart b/apps/save-editor/lib/l10n/app_localizations_pl.dart index 2fbae7213..facba8424 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pl.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pl.dart @@ -444,7 +444,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String get traderOreHint => - 'Ruda jest walutą kolonii. To, ile kupiec jej ma, jest tym, czym może ci zapłacić.'; + '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 => @@ -456,12 +456,6 @@ class AppLocalizationsPl extends AppLocalizations { @override String get traderRemoveItem => 'Usuń pozycję'; - @override - String get traderNeverTraded => 'nigdy tu nie handlowano'; - - @override - String get traderTraded => 'już tu handlowano'; - @override String get traderReadOnlyCore => 'Ta wersja rdzenia może tylko odczytywać dane kupców.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pt.dart b/apps/save-editor/lib/l10n/app_localizations_pt.dart index a0c9b2a6d..202da94d4 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pt.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pt.dart @@ -443,7 +443,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get traderOreHint => - 'O minério é a moeda da colónia. O que um mercador tem é aquilo com que te pode pagar.'; + '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 => @@ -455,12 +455,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get traderRemoveItem => 'Remover linha'; - @override - String get traderNeverTraded => 'nunca negociaste aqui'; - - @override - String get traderTraded => 'já negociaste aqui'; - @override String get traderReadOnlyCore => 'Esta versão do núcleo só consegue ler os dados dos mercadores.'; @@ -3288,7 +3282,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get traderOreHint => - 'O minério é a moeda da colônia. O que um mercador tem é aquilo com que ele pode te pagar.'; + '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 => @@ -3300,12 +3294,6 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get traderRemoveItem => 'Remover linha'; - @override - String get traderNeverTraded => 'nunca negociou aqui'; - - @override - String get traderTraded => 'já negociou aqui'; - @override String get traderReadOnlyCore => 'Esta versão do núcleo só consegue ler os dados dos mercadores.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ru.dart b/apps/save-editor/lib/l10n/app_localizations_ru.dart index d00ff3a1f..f60b8e29e 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ru.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ru.dart @@ -445,7 +445,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get traderOreHint => - 'Руда — валюта колонии. Сколько её у торговца, тем он и может вам заплатить.'; + 'В игре число другое: при загрузке игра добавляет накопившееся с его последней торговли — он продаёт излишки и пополняет запасы. Это число — отправная точка, а не сумма в окне торговли.'; @override String get traderPriceWarning => @@ -457,12 +457,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get traderRemoveItem => 'Удалить строку'; - @override - String get traderNeverTraded => 'здесь ещё не торговали'; - - @override - String get traderTraded => 'здесь уже торговали'; - @override String get traderReadOnlyCore => 'Эта сборка ядра может только читать данные торговцев.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_zh.dart b/apps/save-editor/lib/l10n/app_localizations_zh.dart index 9495fcff8..b82e7d141 100644 --- a/apps/save-editor/lib/l10n/app_localizations_zh.dart +++ b/apps/save-editor/lib/l10n/app_localizations_zh.dart @@ -428,7 +428,8 @@ class AppLocalizationsZh extends AppLocalizations { String get traderStockBaseHint => '商人补货的基准。会随剧情推进而增长,因此不是初始状态。'; @override - String get traderOreHint => '矿石是殖民地的货币。商人持有的数量决定了他能付给你多少。'; + String get traderOreHint => + '游戏内的数值会不同:载入时游戏会加上自他上次交易以来累积的部分——他会卖掉多余货物并以此补货。这个数字是起点,而非交易界面显示的金额。'; @override String get traderPriceWarning => '价格会随商人的库存量和持有矿石而变化,因此修改这些数字也可能改变他的开价。'; @@ -439,12 +440,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get traderRemoveItem => '移除条目'; - @override - String get traderNeverTraded => '尚未在此交易'; - - @override - String get traderTraded => '已在此交易过'; - @override String get traderReadOnlyCore => '此核心版本只能读取商人数据。'; @@ -3171,7 +3166,8 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get traderStockBaseHint => '商人补货的基准。会随剧情推进而增长,因此不是初始状态。'; @override - String get traderOreHint => '矿石是殖民地的货币。商人持有的数量决定了他能付给你多少。'; + String get traderOreHint => + '游戏内的数值会不同:载入时游戏会加上自他上次交易以来累积的部分——他会卖掉多余货物并以此补货。这个数字是起点,而非交易界面显示的金额。'; @override String get traderPriceWarning => '价格会随商人的库存量和持有矿石而变化,因此修改这些数字也可能改变他的开价。'; @@ -3182,12 +3178,6 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get traderRemoveItem => '移除条目'; - @override - String get traderNeverTraded => '尚未在此交易'; - - @override - String get traderTraded => '已在此交易过'; - @override String get traderReadOnlyCore => '此核心版本只能读取商人数据。'; diff --git a/apps/save-editor/lib/l10n/app_pl.arb b/apps/save-editor/lib/l10n/app_pl.arb index 3f0b04db4..cd6aef0d7 100644 --- a/apps/save-editor/lib/l10n/app_pl.arb +++ b/apps/save-editor/lib/l10n/app_pl.arb @@ -136,12 +136,10 @@ "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": "Ruda jest walutą kolonii. To, ile kupiec jej ma, jest tym, czym może ci zapłacić.", + "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ę", - "traderNeverTraded": "nigdy tu nie handlowano", - "traderTraded": "już tu handlowano", "traderReadOnlyCore": "Ta wersja rdzenia może tylko odczytywać dane kupców.", "traderEmptyStock": "Brak zapasów.", "traderUnknownItem": "brak w katalogu przedmiotów", diff --git a/apps/save-editor/lib/l10n/app_pt.arb b/apps/save-editor/lib/l10n/app_pt.arb index b75424539..a5b77ad32 100644 --- a/apps/save-editor/lib/l10n/app_pt.arb +++ b/apps/save-editor/lib/l10n/app_pt.arb @@ -136,12 +136,10 @@ "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 minério é a moeda da colónia. O que um mercador tem é aquilo com que te pode pagar.", + "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", - "traderNeverTraded": "nunca negociaste aqui", - "traderTraded": "já negociaste aqui", "traderReadOnlyCore": "Esta versão do núcleo só consegue ler os dados dos mercadores.", "traderEmptyStock": "Nada em estoque.", "traderUnknownItem": "não está no catálogo de itens", diff --git a/apps/save-editor/lib/l10n/app_pt_BR.arb b/apps/save-editor/lib/l10n/app_pt_BR.arb index 3fcc0281d..36fdb164d 100644 --- a/apps/save-editor/lib/l10n/app_pt_BR.arb +++ b/apps/save-editor/lib/l10n/app_pt_BR.arb @@ -136,12 +136,10 @@ "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 minério é a moeda da colônia. O que um mercador tem é aquilo com que ele pode te pagar.", + "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", - "traderNeverTraded": "nunca negociou aqui", - "traderTraded": "já negociou aqui", "traderReadOnlyCore": "Esta versão do núcleo só consegue ler os dados dos mercadores.", "traderEmptyStock": "Nada em estoque.", "traderUnknownItem": "não está no catálogo de itens", diff --git a/apps/save-editor/lib/l10n/app_ru.arb b/apps/save-editor/lib/l10n/app_ru.arb index 57ae2e126..46a05a27a 100644 --- a/apps/save-editor/lib/l10n/app_ru.arb +++ b/apps/save-editor/lib/l10n/app_ru.arb @@ -136,12 +136,10 @@ "traderStockCurrent": "Запас", "traderStockBase": "База пополнения", "traderStockBaseHint": "То, к чему торговец пополняет запасы. Растёт по ходу сюжета, поэтому это не исходное состояние.", - "traderOreHint": "Руда — валюта колонии. Сколько её у торговца, тем он и может вам заплатить.", + "traderOreHint": "В игре число другое: при загрузке игра добавляет накопившееся с его последней торговли — он продаёт излишки и пополняет запасы. Это число — отправная точка, а не сумма в окне торговли.", "traderPriceWarning": "Цены зависят от того, сколько у торговца товара и руды, поэтому изменение этих чисел может сдвинуть и его расценки.", "traderAddItem": "Добавить предмет", "traderRemoveItem": "Удалить строку", - "traderNeverTraded": "здесь ещё не торговали", - "traderTraded": "здесь уже торговали", "traderReadOnlyCore": "Эта сборка ядра может только читать данные торговцев.", "traderEmptyStock": "Товара нет.", "traderUnknownItem": "нет в каталоге предметов", diff --git a/apps/save-editor/lib/l10n/app_zh.arb b/apps/save-editor/lib/l10n/app_zh.arb index 2e956fd58..f9470107b 100644 --- a/apps/save-editor/lib/l10n/app_zh.arb +++ b/apps/save-editor/lib/l10n/app_zh.arb @@ -136,12 +136,10 @@ "traderStockCurrent": "库存", "traderStockBase": "补货基准", "traderStockBaseHint": "商人补货的基准。会随剧情推进而增长,因此不是初始状态。", - "traderOreHint": "矿石是殖民地的货币。商人持有的数量决定了他能付给你多少。", + "traderOreHint": "游戏内的数值会不同:载入时游戏会加上自他上次交易以来累积的部分——他会卖掉多余货物并以此补货。这个数字是起点,而非交易界面显示的金额。", "traderPriceWarning": "价格会随商人的库存量和持有矿石而变化,因此修改这些数字也可能改变他的开价。", "traderAddItem": "添加物品", "traderRemoveItem": "移除条目", - "traderNeverTraded": "尚未在此交易", - "traderTraded": "已在此交易过", "traderReadOnlyCore": "此核心版本只能读取商人数据。", "traderEmptyStock": "没有库存。", "traderUnknownItem": "不在物品目录中", diff --git a/apps/save-editor/lib/l10n/app_zh_Hans.arb b/apps/save-editor/lib/l10n/app_zh_Hans.arb index 0e9a9af8e..390d86b86 100644 --- a/apps/save-editor/lib/l10n/app_zh_Hans.arb +++ b/apps/save-editor/lib/l10n/app_zh_Hans.arb @@ -136,12 +136,10 @@ "traderStockCurrent": "库存", "traderStockBase": "补货基准", "traderStockBaseHint": "商人补货的基准。会随剧情推进而增长,因此不是初始状态。", - "traderOreHint": "矿石是殖民地的货币。商人持有的数量决定了他能付给你多少。", + "traderOreHint": "游戏内的数值会不同:载入时游戏会加上自他上次交易以来累积的部分——他会卖掉多余货物并以此补货。这个数字是起点,而非交易界面显示的金额。", "traderPriceWarning": "价格会随商人的库存量和持有矿石而变化,因此修改这些数字也可能改变他的开价。", "traderAddItem": "添加物品", "traderRemoveItem": "移除条目", - "traderNeverTraded": "尚未在此交易", - "traderTraded": "已在此交易过", "traderReadOnlyCore": "此核心版本只能读取商人数据。", "traderEmptyStock": "没有库存。", "traderUnknownItem": "不在物品目录中", diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index a22f41e46..80b80d858 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -2,11 +2,16 @@ 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/trader_models.dart'; +import 'package:goresave/features/editor/ui/pending_structural_row.dart'; +import 'package:goresave/loc/loc_catalog_provider.dart'; import 'package:goresave/providers/data_providers.dart'; +import 'support/ui_settings_test_store.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 — @@ -183,7 +188,12 @@ void main() { }); group('Handel tab', () { - Future pumpApp(WidgetTester tester, GoresaveCoreService core) async { + Future pumpApp( + WidgetTester tester, + GoresaveCoreService core, { + bool showObjectIds = false, + Map>? locCatalog, + }) async { await tester.binding.setSurfaceSize(const Size(1400, 1000)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( @@ -193,6 +203,11 @@ void main() { editorSettingsStoreProvider.overrideWithValue( const NoopEditorSettingsStore(), ), + uiSettingsStoreProvider.overrideWithValue( + TestUiSettingsStore(showObjectIds: showObjectIds), + ), + if (locCatalog != null) + locCatalogProvider.overrideWith((ref) async => locCatalog), ], child: const GoresaveApp(), ), @@ -218,6 +233,232 @@ void main() { ); }); + 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(find.widgetWithText(Tab, '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(find.widgetWithText(Tab, '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(find.widgetWithText(Tab, '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(find.widgetWithText(Tab, '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(find.widgetWithText(Tab, '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(find.widgetWithText(Tab, '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('a merchant shows his ore and both stock sections', ( tester, ) async { @@ -393,6 +634,24 @@ class _TraderCoreService implements GoresaveCoreService { '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': [ { diff --git a/crates/gore-save/src/npc.rs b/crates/gore-save/src/npc.rs index 67d04b0b9..374c6d7d6 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. @@ -688,6 +703,8 @@ pub fn list_characters(root: &RootObject) -> Result, CoreE has_inventory: false, has_knowledge: true, has_events: false, + // An orphan has no actor, so it can own no shop. + is_trader: false, }); } Ok(out) From dca43e6870b258ed14e592b5629e6968db903706 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 12 Aug 2026 22:19:17 +0200 Subject: [PATCH 03/29] fix(save-editor): refuse a zero stock count and re-key the count fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reports from the PR review. A count of 0 was accepted and written in place. Sold-out lines are deleted from the map rather than held at zero — no shipped or played save carries a zero-valued entry — so that write invented a record the game never produces. setStock now requires a positive count and names removeItem in the refusal, which is what "he no longer offers this" actually looks like. addItem already rejected zero for the same reason. The count field stops submitting it. The count field also kept the previous line's value. It refreshed only when the queued count changed, and rows carried no key, so the list reused one line's field state for whichever line landed at that position after a category or map switch — and submitting the leftover queued a setStock against the item now shown. Rows are keyed by item path, and the field now reacts to a changed saved value as well. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 8 +++-- apps/save-editor/test/trader_panel_test.dart | 34 +++++++++++++++++++ crates/gore-save/src/lib.rs | 11 +++--- crates/gore-save/src/traders.rs | 30 ++++++++++++++++ crates/gore-save/tests/traders.rs | 27 +++++++++++++++ 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index 2f3f882b4..a688ee8b4 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -628,6 +628,7 @@ class _StockSection extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 4), itemCount: rows.length, itemBuilder: (context, index) => _StockRow( + key: ValueKey(rows[index].path), item: rows[index], map: map, canSet: canSet, @@ -724,6 +725,7 @@ class _PendingLineRow extends ConsumerWidget { class _StockRow extends ConsumerWidget { const _StockRow({ + super.key, required this.item, required this.map, required this.canSet, @@ -827,9 +829,11 @@ class _CountFieldState extends State<_CountField> { void didUpdateWidget(covariant _CountField oldWidget) { super.didUpdateWidget(oldWidget); 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 (oldWidget.pending != widget.pending && '$shown' != _controller.text) { + if (inputsChanged && '$shown' != _controller.text) { _controller.text = '$shown'; } } @@ -842,7 +846,7 @@ class _CountFieldState extends State<_CountField> { void _submit(String raw) { final parsed = int.tryParse(raw.trim()); - if (parsed == null || parsed < 0) { + if (parsed == null || parsed < 1) { _controller.text = '${widget.pending ?? widget.value}'; return; } diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 80b80d858..bb360b5f5 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -459,6 +459,40 @@ void main() { 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(find.widgetWithText(Tab, '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('a merchant shows his ore and both stock sections', ( tester, ) async { diff --git a/crates/gore-save/src/lib.rs b/crates/gore-save/src/lib.rs index 14adc47f3..0e5bb7d45 100644 --- a/crates/gore-save/src/lib.rs +++ b/crates/gore-save/src/lib.rs @@ -9688,11 +9688,14 @@ fn parse_private_traders_set_stock_edit(edit: &Edit) -> Result 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(|| { @@ -805,6 +813,28 @@ mod tests { 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 diff --git a/crates/gore-save/tests/traders.rs b/crates/gore-save/tests/traders.rs index cba723298..624db95a9 100644 --- a/crates/gore-save/tests/traders.rs +++ b/crates/gore-save/tests/traders.rs @@ -331,6 +331,33 @@ fn add_item_refuses_a_class_the_game_does_not_know() { 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 From 2897b09d21fb5bae0e892bc90a88c4c9865cfe97 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 12 Aug 2026 22:28:29 +0200 Subject: [PATCH 04/29] fix(save-editor): join trader names case-insensitively, keep the ore line removable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three reports from the second review pass. The core badges a merchant by matching lowercased names, while a character's unique name is the stored knowledge key where one exists — whose casing can differ from the trader row's. The app compared exactly, so a character the list badged as a merchant could open on "does not trade". It now folds case the same way the core does. Pulling the ore row out of the live-stock list left no way to drop it, and setStock no longer accepts zero — yet a merchant with no ore line at all is a state the game itself produces. The ore card carries a delete action now, and its field is disabled while that removal is queued. The count field accepted values above i32::MAX, which the core then refused at save time. It applies the same bound the add-item dialog already applies, so the refusal happens in the field instead of at the end of a save. Co-Authored-By: Claude Opus 5 --- .../features/editor/domain/trader_models.dart | 7 +- .../lib/features/editor/ui/trader_detail.dart | 31 +++++++- apps/save-editor/test/trader_panel_test.dart | 75 +++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/apps/save-editor/lib/features/editor/domain/trader_models.dart b/apps/save-editor/lib/features/editor/domain/trader_models.dart index 0a59ffb6b..6de9a5625 100644 --- a/apps/save-editor/lib/features/editor/domain/trader_models.dart +++ b/apps/save-editor/lib/features/editor/domain/trader_models.dart @@ -189,8 +189,13 @@ class TradersResult { /// The record for an NPC, or null when he is not a merchant. Placeholder rows /// belong to no NPC and are deliberately not matched. TraderSummary? forUniqueName(String 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". + final wanted = uniqueName.toLowerCase(); for (final t in traders) { - if (!t.placeholder && t.uniqueName == uniqueName) return t; + if (!t.placeholder && t.uniqueName.toLowerCase() == wanted) return t; } return null; } diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index a688ee8b4..deac59325 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -203,8 +203,11 @@ class _TraderPanelState extends ConsumerState { _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), ), ], @@ -372,15 +375,24 @@ 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 @@ -412,17 +424,26 @@ class _OreCard extends ConsumerWidget { // 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) - else + else ...[ SizedBox( width: 140, child: _CountField( value: ore, pending: pending, - enabled: editable, + // 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, ), ), + if (canRemove) + IconButton( + tooltip: l10n.traderRemoveItem, + icon: const Icon(Icons.delete_outline, size: 20), + onPressed: removalPending ? null : onRemove, + ), + ], ], ), ), @@ -844,9 +865,13 @@ class _CountFieldState extends State<_CountField> { 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 + void _submit(String raw) { final parsed = int.tryParse(raw.trim()); - if (parsed == null || parsed < 1) { + if (parsed == null || parsed < 1 || parsed > _maxCount) { _controller.text = '${widget.pending ?? widget.value}'; return; } diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index bb360b5f5..91d69e5dd 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -119,6 +119,20 @@ void main() { 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); + }); + 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. @@ -188,6 +202,12 @@ void main() { }); 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, { @@ -493,6 +513,61 @@ void main() { ); }); + 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(find.widgetWithText(Tab, '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(find.widgetWithText(Tab, 'Trade')); + await tester.pumpAndSettle(); + + final field = find.descendant(of: oreCard, matching: find.byType(TextField)); + await tester.enterText(field, '2147483648'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + // Rejected in place: the field snaps back and nothing is queued, rather + // than the save failing later on the core's bound. + expect(tester.widget(field).controller?.text, '55'); + expect( + ProviderScope.containerOf(tester.element(find.byType(Scaffold).first)) + .read(editorProvider) + .pendingEdits, + isEmpty, + ); + }); + testWidgets('a merchant shows his ore and both stock sections', ( tester, ) async { From 12a1091446d7e134374e66d94bbbcb14b15b2796 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 12 Aug 2026 22:35:34 +0200 Subject: [PATCH 05/29] fix(save-editor): reject a zero count in the public add-item applier too apply_set_stock already refuses it; apply_add_item only refused a negative, leaving a Rust caller able to insert the zero-valued entry the module treats as an invalid state. The JSON parser was the only thing enforcing a positive count on that path. Co-Authored-By: Claude Opus 5 --- crates/gore-save/src/traders.rs | 34 +++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/gore-save/src/traders.rs b/crates/gore-save/src/traders.rs index 1b3d18fcc..0fab0030e 100644 --- a/crates/gore-save/src/traders.rs +++ b/crates/gore-save/src/traders.rs @@ -512,10 +512,14 @@ pub fn apply_add_item(payload: &mut Vec, edit: &StockLineEdit) -> Result<(), edit.path ))); } - if edit.count < 0 { - return Err(CoreError::InvalidRequest( - "stock count must not be negative".to_string(), - )); + // 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) { @@ -986,6 +990,28 @@ mod tests { 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(); From 8274c5b8be78f11be705322a8978cb0c23b9b6d6 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 12 Aug 2026 23:31:44 +0200 Subject: [PATCH 06/29] fix(save-editor): fold case in the core name resolver, refuse unmodelled stock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit private.traders.detail compared trader names exactly, so a caller passing the uniqueName that private.characters.list returned — the stored knowledge key, whose casing can differ from the trader row's — got "no trader named" for a character the same list had marked a merchant. The Dart-side fold did not cover direct users of the command. The ambiguity check still runs, now on the folded comparison, so two rows differing only in case are still refused. A save whose per-difficulty stock is populated now disables editing and says why. The edits reach only m_Items and m_DefaultItems, so such a save would have taken a change, reported success, and left that stock standing. Empty in every save observed so far, which is exactly why it needed saying. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 48 +++++++++++++++---- apps/save-editor/lib/l10n/app_de.arb | 1 + apps/save-editor/lib/l10n/app_en.arb | 1 + apps/save-editor/lib/l10n/app_es.arb | 1 + apps/save-editor/lib/l10n/app_fr.arb | 1 + apps/save-editor/lib/l10n/app_it.arb | 1 + apps/save-editor/lib/l10n/app_ja.arb | 1 + .../lib/l10n/app_localizations.dart | 6 +++ .../lib/l10n/app_localizations_de.dart | 4 ++ .../lib/l10n/app_localizations_en.dart | 4 ++ .../lib/l10n/app_localizations_es.dart | 4 ++ .../lib/l10n/app_localizations_fr.dart | 4 ++ .../lib/l10n/app_localizations_it.dart | 4 ++ .../lib/l10n/app_localizations_ja.dart | 4 ++ .../lib/l10n/app_localizations_pl.dart | 4 ++ .../lib/l10n/app_localizations_pt.dart | 8 ++++ .../lib/l10n/app_localizations_ru.dart | 4 ++ .../lib/l10n/app_localizations_zh.dart | 8 ++++ apps/save-editor/lib/l10n/app_pl.arb | 1 + apps/save-editor/lib/l10n/app_pt.arb | 1 + apps/save-editor/lib/l10n/app_pt_BR.arb | 1 + apps/save-editor/lib/l10n/app_ru.arb | 1 + apps/save-editor/lib/l10n/app_zh.arb | 1 + apps/save-editor/lib/l10n/app_zh_Hans.arb | 1 + apps/save-editor/test/trader_panel_test.dart | 35 +++++++++++++- crates/gore-save/src/traders.rs | 39 ++++++++++++++- 26 files changed, 177 insertions(+), 11 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index deac59325..ccf57fc0f 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -147,9 +147,16 @@ class _TraderPanelState extends ConsumerState { } final list = _list; - final canSet = widget.editable && (list?.canSetStock ?? false); - final canAdd = widget.editable && (list?.canAddItem ?? false); - final canRemove = widget.editable && (list?.canRemoveItem ?? false); + // 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. + final unsupported = detail.hasItemsByDifficulty; + 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 @@ -168,8 +175,17 @@ class _TraderPanelState extends ConsumerState { children: [ // First, because it qualifies every number below it — the ore as much // as the stock counts. + if (unsupported) ...[ + _NoteCard( + text: l10n.traderDifficultyStockUnsupported, + tone: _NoteTone.warning, + ), + const SizedBox(height: 12), + ], _NoteCard(text: l10n.traderPriceWarning), - if (widget.editable && !(list?.canSetStock ?? false)) ...[ + if (widget.editable && + !unsupported && + !(list?.canSetStock ?? false)) ...[ const SizedBox(height: 12), Text(l10n.traderReadOnlyCore, style: theme.textTheme.bodySmall), ], @@ -451,28 +467,44 @@ class _OreCard extends ConsumerWidget { } } +/// 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}); + 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( - Icons.info_outline, + isWarning ? Icons.warning_amber_outlined : Icons.info_outline, size: 18, - color: theme.colorScheme.primary, + color: isWarning + ? theme.colorScheme.onErrorContainer + : theme.colorScheme.primary, ), const SizedBox(width: 8), - Expanded(child: Text(text, style: theme.textTheme.bodySmall)), + Expanded( + child: Text( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: isWarning ? theme.colorScheme.onErrorContainer : null, + ), + ), + ), ], ), ), diff --git a/apps/save-editor/lib/l10n/app_de.arb b/apps/save-editor/lib/l10n/app_de.arb index f67d8e083..00125cd3d 100644 --- a/apps/save-editor/lib/l10n/app_de.arb +++ b/apps/save-editor/lib/l10n/app_de.arb @@ -143,6 +143,7 @@ "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.", "traderEmptyStock": "Nichts auf Lager.", "traderUnknownItem": "nicht im Item-Katalog", "editorTradersLoadFailed": "Die Händlerdaten konnten nicht geladen werden: {details}", diff --git a/apps/save-editor/lib/l10n/app_en.arb b/apps/save-editor/lib/l10n/app_en.arb index 0ea5f6876..1c5d0c045 100644 --- a/apps/save-editor/lib/l10n/app_en.arb +++ b/apps/save-editor/lib/l10n/app_en.arb @@ -147,6 +147,7 @@ "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.", "traderEmptyStock": "Nothing in stock.", "traderUnknownItem": "not in the item catalog", "editorTradersLoadFailed": "Trader load failed: {details}", diff --git a/apps/save-editor/lib/l10n/app_es.arb b/apps/save-editor/lib/l10n/app_es.arb index 746bea4ca..9273a727f 100644 --- a/apps/save-editor/lib/l10n/app_es.arb +++ b/apps/save-editor/lib/l10n/app_es.arb @@ -141,6 +141,7 @@ "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.", "traderEmptyStock": "Sin existencias.", "traderUnknownItem": "no está en el catálogo de objetos", "editorTradersLoadFailed": "Error al cargar los mercaderes: {details}", diff --git a/apps/save-editor/lib/l10n/app_fr.arb b/apps/save-editor/lib/l10n/app_fr.arb index 02308d5a5..c1c17883c 100644 --- a/apps/save-editor/lib/l10n/app_fr.arb +++ b/apps/save-editor/lib/l10n/app_fr.arb @@ -141,6 +141,7 @@ "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.", "traderEmptyStock": "Rien en stock.", "traderUnknownItem": "absent du catalogue d'objets", "editorTradersLoadFailed": "Échec du chargement des marchands : {details}", diff --git a/apps/save-editor/lib/l10n/app_it.arb b/apps/save-editor/lib/l10n/app_it.arb index 8e22d4a27..8696a7cb2 100644 --- a/apps/save-editor/lib/l10n/app_it.arb +++ b/apps/save-editor/lib/l10n/app_it.arb @@ -141,6 +141,7 @@ "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.", "traderEmptyStock": "Niente in magazzino.", "traderUnknownItem": "non presente nel catalogo oggetti", "editorTradersLoadFailed": "Caricamento dei mercanti non riuscito: {details}", diff --git a/apps/save-editor/lib/l10n/app_ja.arb b/apps/save-editor/lib/l10n/app_ja.arb index b0bba90cf..edeabe591 100644 --- a/apps/save-editor/lib/l10n/app_ja.arb +++ b/apps/save-editor/lib/l10n/app_ja.arb @@ -141,6 +141,7 @@ "traderAddItem": "アイテムを追加", "traderRemoveItem": "行を削除", "traderReadOnlyCore": "このコアは商人データの読み取りのみ可能です。", + "traderDifficultyStockUnsupported": "この商人は難易度ごとの在庫を持っており、エディタはそれを扱えません。変更は成功したように見えても、その追加在庫はそのまま残るため、ここでの編集は無効です。", "traderEmptyStock": "在庫がありません。", "traderUnknownItem": "アイテムカタログにありません", "editorTradersLoadFailed": "商人データの読み込みに失敗しました: {details}", diff --git a/apps/save-editor/lib/l10n/app_localizations.dart b/apps/save-editor/lib/l10n/app_localizations.dart index feea539b6..62758eff9 100644 --- a/apps/save-editor/lib/l10n/app_localizations.dart +++ b/apps/save-editor/lib/l10n/app_localizations.dart @@ -908,6 +908,12 @@ abstract class AppLocalizations { /// **'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 @traderEmptyStock. /// /// 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 a55393a9d..824861cb0 100644 --- a/apps/save-editor/lib/l10n/app_localizations_de.dart +++ b/apps/save-editor/lib/l10n/app_localizations_de.dart @@ -457,6 +457,10 @@ class AppLocalizationsDe extends AppLocalizations { @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 traderEmptyStock => 'Nichts auf Lager.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_en.dart b/apps/save-editor/lib/l10n/app_localizations_en.dart index 98e057a10..cd25a7264 100644 --- a/apps/save-editor/lib/l10n/app_localizations_en.dart +++ b/apps/save-editor/lib/l10n/app_localizations_en.dart @@ -456,6 +456,10 @@ class AppLocalizationsEn extends AppLocalizations { @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 traderEmptyStock => 'Nothing in stock.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_es.dart b/apps/save-editor/lib/l10n/app_localizations_es.dart index 28e71c00b..fd063e034 100644 --- a/apps/save-editor/lib/l10n/app_localizations_es.dart +++ b/apps/save-editor/lib/l10n/app_localizations_es.dart @@ -459,6 +459,10 @@ class AppLocalizationsEs extends AppLocalizations { 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 traderEmptyStock => 'Sin existencias.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_fr.dart b/apps/save-editor/lib/l10n/app_localizations_fr.dart index 08fcf6726..aeed841b5 100644 --- a/apps/save-editor/lib/l10n/app_localizations_fr.dart +++ b/apps/save-editor/lib/l10n/app_localizations_fr.dart @@ -461,6 +461,10 @@ class AppLocalizationsFr extends AppLocalizations { 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 traderEmptyStock => 'Rien en stock.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_it.dart b/apps/save-editor/lib/l10n/app_localizations_it.dart index bd773449e..5281e59af 100644 --- a/apps/save-editor/lib/l10n/app_localizations_it.dart +++ b/apps/save-editor/lib/l10n/app_localizations_it.dart @@ -459,6 +459,10 @@ class AppLocalizationsIt extends AppLocalizations { 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 traderEmptyStock => 'Niente in magazzino.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ja.dart b/apps/save-editor/lib/l10n/app_localizations_ja.dart index 1f26953c9..88c2f48ea 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ja.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ja.dart @@ -449,6 +449,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get traderReadOnlyCore => 'このコアは商人データの読み取りのみ可能です。'; + @override + String get traderDifficultyStockUnsupported => + 'この商人は難易度ごとの在庫を持っており、エディタはそれを扱えません。変更は成功したように見えても、その追加在庫はそのまま残るため、ここでの編集は無効です。'; + @override String get traderEmptyStock => '在庫がありません。'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pl.dart b/apps/save-editor/lib/l10n/app_localizations_pl.dart index facba8424..0adfeb861 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pl.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pl.dart @@ -460,6 +460,10 @@ class AppLocalizationsPl extends AppLocalizations { 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 traderEmptyStock => 'Brak zapasów.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pt.dart b/apps/save-editor/lib/l10n/app_localizations_pt.dart index 202da94d4..bf55d63c2 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pt.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pt.dart @@ -459,6 +459,10 @@ class AppLocalizationsPt extends AppLocalizations { 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 traderEmptyStock => 'Nada em estoque.'; @@ -3298,6 +3302,10 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { 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 traderEmptyStock => 'Nada em estoque.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ru.dart b/apps/save-editor/lib/l10n/app_localizations_ru.dart index f60b8e29e..230d4b66b 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ru.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ru.dart @@ -461,6 +461,10 @@ class AppLocalizationsRu extends AppLocalizations { String get traderReadOnlyCore => 'Эта сборка ядра может только читать данные торговцев.'; + @override + String get traderDifficultyStockUnsupported => + 'У этого торговца есть запас по уровню сложности, который редактор не моделирует. Правка здесь отключена: изменение выглядело бы успешным, но этот дополнительный запас остался бы нетронутым.'; + @override String get traderEmptyStock => 'Товара нет.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_zh.dart b/apps/save-editor/lib/l10n/app_localizations_zh.dart index b82e7d141..5372ac2d1 100644 --- a/apps/save-editor/lib/l10n/app_localizations_zh.dart +++ b/apps/save-editor/lib/l10n/app_localizations_zh.dart @@ -443,6 +443,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get traderReadOnlyCore => '此核心版本只能读取商人数据。'; + @override + String get traderDifficultyStockUnsupported => + '该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。'; + @override String get traderEmptyStock => '没有库存。'; @@ -3181,6 +3185,10 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get traderReadOnlyCore => '此核心版本只能读取商人数据。'; + @override + String get traderDifficultyStockUnsupported => + '该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。'; + @override String get traderEmptyStock => '没有库存。'; diff --git a/apps/save-editor/lib/l10n/app_pl.arb b/apps/save-editor/lib/l10n/app_pl.arb index cd6aef0d7..aa0ee237a 100644 --- a/apps/save-editor/lib/l10n/app_pl.arb +++ b/apps/save-editor/lib/l10n/app_pl.arb @@ -141,6 +141,7 @@ "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.", "traderEmptyStock": "Brak zapasów.", "traderUnknownItem": "brak w katalogu przedmiotów", "editorTradersLoadFailed": "Nie udało się wczytać kupców: {details}", diff --git a/apps/save-editor/lib/l10n/app_pt.arb b/apps/save-editor/lib/l10n/app_pt.arb index a5b77ad32..94f137dfa 100644 --- a/apps/save-editor/lib/l10n/app_pt.arb +++ b/apps/save-editor/lib/l10n/app_pt.arb @@ -141,6 +141,7 @@ "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.", "traderEmptyStock": "Nada em estoque.", "traderUnknownItem": "não está no catálogo de itens", "editorTradersLoadFailed": "Falha ao carregar mercadores: {details}", diff --git a/apps/save-editor/lib/l10n/app_pt_BR.arb b/apps/save-editor/lib/l10n/app_pt_BR.arb index 36fdb164d..cfb337f2b 100644 --- a/apps/save-editor/lib/l10n/app_pt_BR.arb +++ b/apps/save-editor/lib/l10n/app_pt_BR.arb @@ -141,6 +141,7 @@ "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.", "traderEmptyStock": "Nada em estoque.", "traderUnknownItem": "não está no catálogo de itens", "editorTradersLoadFailed": "Falha ao carregar mercadores: {details}", diff --git a/apps/save-editor/lib/l10n/app_ru.arb b/apps/save-editor/lib/l10n/app_ru.arb index 46a05a27a..661fde27d 100644 --- a/apps/save-editor/lib/l10n/app_ru.arb +++ b/apps/save-editor/lib/l10n/app_ru.arb @@ -141,6 +141,7 @@ "traderAddItem": "Добавить предмет", "traderRemoveItem": "Удалить строку", "traderReadOnlyCore": "Эта сборка ядра может только читать данные торговцев.", + "traderDifficultyStockUnsupported": "У этого торговца есть запас по уровню сложности, который редактор не моделирует. Правка здесь отключена: изменение выглядело бы успешным, но этот дополнительный запас остался бы нетронутым.", "traderEmptyStock": "Товара нет.", "traderUnknownItem": "нет в каталоге предметов", "editorTradersLoadFailed": "Не удалось загрузить торговцев: {details}", diff --git a/apps/save-editor/lib/l10n/app_zh.arb b/apps/save-editor/lib/l10n/app_zh.arb index f9470107b..f186f5d94 100644 --- a/apps/save-editor/lib/l10n/app_zh.arb +++ b/apps/save-editor/lib/l10n/app_zh.arb @@ -141,6 +141,7 @@ "traderAddItem": "添加物品", "traderRemoveItem": "移除条目", "traderReadOnlyCore": "此核心版本只能读取商人数据。", + "traderDifficultyStockUnsupported": "该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。", "traderEmptyStock": "没有库存。", "traderUnknownItem": "不在物品目录中", "editorTradersLoadFailed": "商人数据加载失败:{details}", diff --git a/apps/save-editor/lib/l10n/app_zh_Hans.arb b/apps/save-editor/lib/l10n/app_zh_Hans.arb index 390d86b86..1837710a5 100644 --- a/apps/save-editor/lib/l10n/app_zh_Hans.arb +++ b/apps/save-editor/lib/l10n/app_zh_Hans.arb @@ -141,6 +141,7 @@ "traderAddItem": "添加物品", "traderRemoveItem": "移除条目", "traderReadOnlyCore": "此核心版本只能读取商人数据。", + "traderDifficultyStockUnsupported": "该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。", "traderEmptyStock": "没有库存。", "traderUnknownItem": "不在物品目录中", "editorTradersLoadFailed": "商人数据加载失败:{details}", diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 91d69e5dd..a9faeac40 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -568,6 +568,30 @@ void main() { ); }); + 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(find.widgetWithText(Tab, '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('a merchant shows his ore and both stock sections', ( tester, ) async { @@ -601,9 +625,16 @@ class _RecordedRequest { /// 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}); + _TraderCoreService({ + this.playerIsTrader = false, + this.hasItemsByDifficulty = false, + }); 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 @@ -777,7 +808,7 @@ class _TraderCoreService implements GoresaveCoreService { }, ], 'generatedEvents': ['OnWorldStart'], - 'hasItemsByDifficulty': false, + 'hasItemsByDifficulty': hasItemsByDifficulty, }, }; case 'list_backups': diff --git a/crates/gore-save/src/traders.rs b/crates/gore-save/src/traders.rs index 0fab0030e..e8411c9c7 100644 --- a/crates/gore-save/src/traders.rs +++ b/crates/gore-save/src/traders.rs @@ -279,6 +279,12 @@ pub fn trader_detail(root: &RootObject, index: usize) -> Result Result { - let mut matches = summaries.iter().filter(|s| s.unique_name == unique_name); + 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}")))?; @@ -706,6 +715,34 @@ mod tests { 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![ From d1678fd7d729fd18d3028c320f3a77e928f4e377 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 13:25:52 +0200 Subject: [PATCH 07/29] fix(save-editor): bound the queued-change banners so they cannot push the list away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replacing most of a merchant's stock queues enough banners to overflow the column they sat in as fixed children — 25 of them overflowed by 1484px — which hid the stock browser and took the buttons that cancel them with it. They now live in a height-capped scroll area above the list. The cap is a fixed value rather than a fraction of the pane: a LayoutBuilder placed directly in a Column is handed an unbounded height, so a fraction resolved to infinity and bounded nothing. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 51 +++++++++++++------ apps/save-editor/test/trader_panel_test.dart | 35 +++++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index ccf57fc0f..3eba33b08 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -537,6 +537,12 @@ class _StockSection extends ConsumerWidget { /// inventory browser uses. static const double _compactBelow = 600; + /// How much height the queued-change banners may claim before they scroll + /// among themselves, so the stock browser stays visible below them. A fixed + /// value on purpose: a fraction would need the parent's height, and a + /// LayoutBuilder placed directly in a Column is handed an unbounded one. + static const double _pendingMaxHeight = 240; + final TraderStockMap map; /// The rows to draw: saved lines minus the ones queued for removal, and minus @@ -607,23 +613,36 @@ class _StockSection extends ConsumerWidget { ], ), // 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. - 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), + // 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: const BoxConstraints(maxHeight: _pendingMaxHeight), + 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 (nothingToShow) Align( diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index a9faeac40..85e9186d2 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -7,6 +7,7 @@ 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/trader_models.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/providers/data_providers.dart'; @@ -592,6 +593,40 @@ void main() { 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(find.widgetWithText(Tab, '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('a merchant shows his ore and both stock sections', ( tester, ) async { From 66943efe6138f5d9be55b8b93fa9ec710906873b Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 13:34:40 +0200 Subject: [PATCH 08/29] fix(save-editor): size the trade panel against its pane, not against constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The banner cap was a constant, so it took no account of the header above it or the list below: a short window or a large UI scale overflowed the column and collapsed the browser. The cap now comes from the pane — a share of it, capped in absolute terms, and never so large that the list is left nothing. Measuring that share needs a bounded height, which a LayoutBuilder placed directly in a Column is not given; it sits at the section's root now, where the enclosing Expanded does bound it. The panel's own head had the same fault and overflowed a 620px-tall pane by 8px with nothing queued at all. It scrolls among itself once the pane no longer leaves the browser a usable slice. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 434 ++++++++++-------- apps/save-editor/test/trader_panel_test.dart | 37 +- 2 files changed, 283 insertions(+), 188 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index 3eba33b08..c5f2b8c72 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -50,6 +50,16 @@ class TraderPanel extends ConsumerStatefulWidget { 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; @@ -170,89 +180,111 @@ class _TraderPanelState extends ConsumerState { return Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), - 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: l10n.traderDifficultyStockUnsupported, - tone: _NoteTone.warning, - ), - const SizedBox(height: 12), - ], - _NoteCard(text: l10n.traderPriceWarning), - if (widget.editable && - !unsupported && - !(list?.canSetStock ?? false)) ...[ - 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), + 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: l10n.traderDifficultyStockUnsupported, + tone: _NoteTone.warning, + ), + const SizedBox(height: 12), + ], + _NoteCard(text: l10n.traderPriceWarning), + if (widget.editable && + !unsupported && + !(list?.canSetStock ?? false)) ...[ + 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), + ), + ], + ], ), - ], - 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), + 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), + ), ), ], - 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), - ), - ), - ], + ), ), ); } @@ -351,7 +383,9 @@ class _TraderPanelState extends ConsumerState { } void _revert(TraderStockMap map, String path) { - widget.notifier.clearTraderStockEdit(_edit(TraderEditKind.setStock, map, path)); + widget.notifier.clearTraderStockEdit( + _edit(TraderEditKind.setStock, map, path), + ); setState(() {}); } @@ -537,12 +571,33 @@ class _StockSection extends ConsumerWidget { /// inventory browser uses. static const double _compactBelow = 600; - /// How much height the queued-change banners may claim before they scroll - /// among themselves, so the stock browser stays visible below them. A fixed - /// value on purpose: a fraction would need the parent's height, and a - /// LayoutBuilder placed directly in a Column is handed an unbounded one. + /// 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 @@ -583,46 +638,47 @@ class _StockSection extends ConsumerWidget { 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 ?? + final shown = + groups.where((g) => g.category == selected).firstOrNull?.items ?? const []; final nothingToShow = items.isEmpty && pendingAdds.isEmpty && pendingRemovals.isEmpty; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - l10n.traderStockLineCount(lineCount), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.outline, + 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, + 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), ), - ], - ), - // 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: const BoxConstraints(maxHeight: _pendingMaxHeight), - child: SingleChildScrollView( - child: Column( - children: [ + child: SingleChildScrollView( + child: Column( + children: [ for (final item in pendingAdds) ...[ const SizedBox(height: 8), _PendingLineRow( @@ -639,86 +695,86 @@ class _StockSection extends ConsumerWidget { onCancel: () => onRemove(map, item.path), ), ], - ], + ], + ), ), ), - ), - const SizedBox(height: 8), - if (nothingToShow) - Align( - alignment: Alignment.centerLeft, - child: Text( - l10n.traderEmptyStock, - style: theme.textTheme.bodyMedium, - ), - ) - else - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final compact = constraints.maxWidth < _compactBelow; - final rows = compact ? items : 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, + const SizedBox(height: 8), + if (nothingToShow) + Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.traderEmptyStock, + style: theme.textTheme.bodyMedium, + ), + ) + else + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < _compactBelow; + final rows = compact ? items : 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, ), - group.items.length, - ), - selected: group.category == selected, - onTap: () => onSelectCategory( - group.category, + 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( - key: ValueKey(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), + const SizedBox(width: 16), + ], + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: rows.length, + itemBuilder: (context, index) => _StockRow( + key: ValueKey(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), + ), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), - ), - ], + ], + ), ); } } @@ -984,7 +1040,11 @@ class _Message extends StatelessWidget { const SizedBox(height: 12), Text(title, style: theme.textTheme.titleMedium), const SizedBox(height: 8), - Text(body, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium), + Text( + body, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), if (onRetry != null) ...[ const SizedBox(height: 12), OutlinedButton(onPressed: onRetry, child: const Text('Retry')), diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 85e9186d2..1ba60e2c1 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -214,8 +214,9 @@ void main() { GoresaveCoreService core, { bool showObjectIds = false, Map>? locCatalog, + Size surface = const Size(1400, 1000), }) async { - await tester.binding.setSurfaceSize(const Size(1400, 1000)); + await tester.binding.setSurfaceSize(surface); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( ProviderScope( @@ -627,6 +628,40 @@ void main() { expect(find.byType(SidebarTile), findsWidgets); }); + testWidgets('a short pane bounds the banners against its own height', ( + tester, + ) async { + // A cap measured against a constant ignores the header above and the list + // below: on a short window the column still overflowed and the browser + // collapsed to nothing. The cap has to come from the pane. + await pumpApp( + tester, + _TraderCoreService(playerIsTrader: true), + surface: const Size(1400, 620), + ); + await tester.tap(find.widgetWithText(Tab, 'Characters')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(Tab, '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 merchant shows his ore and both stock sections', ( tester, ) async { From 33e8882d55b58272fbc37855dd1f8e7c5af466ef Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 13:48:20 +0200 Subject: [PATCH 09/29] fix(save-editor): make the trade panel fit the smallest window it allows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app's own minimum is 960x600 (main.dart), where the character list leaves the detail pane about 164px wide — narrower than the ore card's field and delete button, and narrower than a stock row's ListTile trailing, both of which ask for a fixed ~180px and overflowed to the right. Both now lay their value under the name below a width threshold, and the field takes what is left instead of a fixed width. The regression test asks for that exact surface: the earlier one forced 1400x1000, which is why a panel that cannot fit the app's declared minimum still passed it. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 208 ++++++++++++------ apps/save-editor/test/trader_panel_test.dart | 11 +- 2 files changed, 144 insertions(+), 75 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index c5f2b8c72..ff980c1a3 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -421,6 +421,15 @@ class _TraderPanelState extends ConsumerState { } } +/// 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, @@ -450,51 +459,76 @@ class _OreCard extends ConsumerWidget { 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: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.savings_outlined, color: theme.colorScheme.primary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.traderOre, style: theme.textTheme.titleMedium), - const SizedBox(height: 4), - Text(l10n.traderOreHint, style: theme.textTheme.bodySmall), - ], - ), - ), - const SizedBox(width: 12), - if (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) - else ...[ - SizedBox( - width: 140, - child: _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, - ), - ), - if (canRemove) - IconButton( - tooltip: l10n.traderRemoveItem, - icon: const Icon(Icons.delete_outline, size: 20), - onPressed: removalPending ? null : onRemove, + 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, + ], + ], + ); + }, ), ), ); @@ -891,38 +925,72 @@ class _StockRow extends ConsumerWidget { if (item.unknownItem) l10n.traderUnknownItem, ].join(' · '); - return ListTile( - dense: true, - leading: item.isOre - ? Icon(Icons.savings_outlined, color: theme.colorScheme.primary) - : const Icon(Icons.inventory_2_outlined), - title: Text(label), - subtitle: subtitle.isEmpty - ? null - : Text(subtitle, style: theme.textTheme.bodySmall), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 130, - child: _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 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]), + ], ), - if (canRemove) - IconButton( - tooltip: l10n.traderRemoveItem, - icon: const Icon(Icons.delete_outline, size: 20), - onPressed: onRemove, - ), - ], - ), + ); + }, ); } } diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 1ba60e2c1..c756d1294 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -628,16 +628,17 @@ void main() { expect(find.byType(SidebarTile), findsWidgets); }); - testWidgets('a short pane bounds the banners against its own height', ( + testWidgets('the smallest supported window bounds the banners', ( tester, ) async { - // A cap measured against a constant ignores the header above and the list - // below: on a short window the column still overflowed and the browser - // collapsed to nothing. The cap has to come from the pane. + // 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(1400, 620), + surface: const Size(960, 600), ); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); From 04dae152165e499b2f7c56874d7e20f4e156d67e Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 13:51:28 +0200 Subject: [PATCH 10/29] fix(save-editor): let a knowledge-only merchant open his shop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trader row is keyed by name alone, so it does not depend on a spawned actor — but the Characters tab sent every orphan to the generic no-actor pane, and the core hardcoded is_trader to false for them on the same unfounded assumption. A knowledge-only row whose name matches a trader was therefore unreachable and unbadged. Orphans reach the panel now, which already shows a clean non-merchant state when no row matches, and the core joins their names like everyone else's. Co-Authored-By: Claude Opus 5 --- .../features/editor/ui/characters_tab.dart | 32 ++++++++----------- crates/gore-save/src/npc.rs | 6 ++-- 2 files changed, 17 insertions(+), 21 deletions(-) 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 61543e0a2..2c4a906d3 100644 --- a/apps/save-editor/lib/features/editor/ui/characters_tab.dart +++ b/apps/save-editor/lib/features/editor/ui/characters_tab.dart @@ -104,25 +104,19 @@ class CharactersTab extends ConsumerWidget { ); // Handel: a merchant's shop, which is NOT his inventory — it lives in a - // global array keyed by uniqueName. Orphans have no such record, and the - // panel itself shows the same empty state for any non-merchant, so it only - // needs the orphan guard the other actor-backed panes take. - final Widget tradeBody = isOrphan - ? _MessagePane( - icon: Icons.storefront_outlined, - title: l10n.tabTrade, - body: l10n.characterNoActorBody, - ) - : 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), - ); + // 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 diff --git a/crates/gore-save/src/npc.rs b/crates/gore-save/src/npc.rs index 374c6d7d6..92066b423 100644 --- a/crates/gore-save/src/npc.rs +++ b/crates/gore-save/src/npc.rs @@ -695,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, @@ -702,9 +703,10 @@ 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, - // An orphan has no actor, so it can own no shop. - is_trader: false, }); } Ok(out) From dc623039f1466a5bf2120637748cd8af64b9014d Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 13:57:25 +0200 Subject: [PATCH 11/29] fix(save-editor): badge a knowledge-only merchant in the list too The orphan tile drew its own trailing and showed only the knowledge icon, so the merchants the core had just started flagging stayed unmarked in the very list meant to find them. It uses the shared aspect badges now. Co-Authored-By: Claude Opus 5 --- .../editor/ui/character_master_list.dart | 14 ++---- apps/save-editor/test/trader_panel_test.dart | 46 ++++++++++++++++++- 2 files changed, 49 insertions(+), 11 deletions(-) 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 f4487e875..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, diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index c756d1294..5081ae647 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -6,6 +6,7 @@ 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/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'; @@ -663,6 +664,28 @@ void main() { 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('a merchant shows his ore and both stock sections', ( tester, ) async { @@ -699,8 +722,13 @@ class _TraderCoreService implements GoresaveCoreService { _TraderCoreService({ this.playerIsTrader = false, this.hasItemsByDifficulty = false, + this.orphanMerchant = false, }); + /// 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 @@ -906,7 +934,23 @@ class _TraderCoreService implements GoresaveCoreService { case 'private.characters.list': return { 'ok': true, - 'data': {'total': 0, 'characters': []}, + '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 { From e0ee06916decd17848499cb2a420ac5fa82b67f7 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 14:01:14 +0200 Subject: [PATCH 12/29] fix(save-editor): refuse an ambiguous trader name instead of guessing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forUniqueName returned the first case-insensitive match, and that index is what every edit is addressed by — so on a save carrying two records under one name the panel would have edited an arbitrary shop. The core already refuses the same case; the app now does too, and says which of the two states it is rather than showing "does not trade" for a name it simply cannot resolve. Co-Authored-By: Claude Opus 5 --- .../features/editor/domain/trader_models.dart | 40 ++++++++++++++----- .../lib/features/editor/ui/trader_detail.dart | 27 ++++++++++--- apps/save-editor/lib/l10n/app_de.arb | 1 + apps/save-editor/lib/l10n/app_en.arb | 1 + apps/save-editor/lib/l10n/app_es.arb | 1 + apps/save-editor/lib/l10n/app_fr.arb | 1 + apps/save-editor/lib/l10n/app_it.arb | 1 + apps/save-editor/lib/l10n/app_ja.arb | 1 + .../lib/l10n/app_localizations.dart | 6 +++ .../lib/l10n/app_localizations_de.dart | 4 ++ .../lib/l10n/app_localizations_en.dart | 4 ++ .../lib/l10n/app_localizations_es.dart | 4 ++ .../lib/l10n/app_localizations_fr.dart | 4 ++ .../lib/l10n/app_localizations_it.dart | 4 ++ .../lib/l10n/app_localizations_ja.dart | 4 ++ .../lib/l10n/app_localizations_pl.dart | 4 ++ .../lib/l10n/app_localizations_pt.dart | 8 ++++ .../lib/l10n/app_localizations_ru.dart | 4 ++ .../lib/l10n/app_localizations_zh.dart | 6 +++ apps/save-editor/lib/l10n/app_pl.arb | 1 + apps/save-editor/lib/l10n/app_pt.arb | 1 + apps/save-editor/lib/l10n/app_pt_BR.arb | 1 + apps/save-editor/lib/l10n/app_ru.arb | 1 + apps/save-editor/lib/l10n/app_zh.arb | 1 + apps/save-editor/lib/l10n/app_zh_Hans.arb | 1 + apps/save-editor/test/trader_panel_test.dart | 19 +++++++++ 26 files changed, 134 insertions(+), 16 deletions(-) diff --git a/apps/save-editor/lib/features/editor/domain/trader_models.dart b/apps/save-editor/lib/features/editor/domain/trader_models.dart index 6de9a5625..69e1c63a5 100644 --- a/apps/save-editor/lib/features/editor/domain/trader_models.dart +++ b/apps/save-editor/lib/features/editor/domain/trader_models.dart @@ -186,19 +186,37 @@ class TradersResult { bool get canAddItem => writable.contains('private.traders.addItem'); bool get canRemoveItem => writable.contains('private.traders.removeItem'); - /// The record for an NPC, or null when he is not a merchant. Placeholder rows - /// belong to no NPC and are deliberately not matched. - TraderSummary? forUniqueName(String 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". + /// 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(); - for (final t in traders) { - if (!t.placeholder && t.uniqueName.toLowerCase() == wanted) return t; - } - return null; + 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`. diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index ff980c1a3..670640957 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -64,6 +64,10 @@ 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 @@ -98,6 +102,7 @@ class _TraderPanelState extends ConsumerState { _loading = true; _error = null; _detail = null; + _ambiguous = false; }); final list = await widget.notifier.loadTraders(); if (!mounted || epoch != _epoch) return; @@ -111,11 +116,13 @@ class _TraderPanelState extends ConsumerState { } final row = list.forUniqueName(widget.actor.uniqueName); if (row == null) { - // Not a merchant. A clean empty state, not an error. + // 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; } @@ -150,9 +157,11 @@ class _TraderPanelState extends ConsumerState { final detail = _detail; if (detail == null) { return _Message( - icon: Icons.storefront_outlined, + icon: _ambiguous + ? Icons.warning_amber_outlined + : Icons.storefront_outlined, title: l10n.tabTrade, - body: l10n.traderNotAMerchant, + body: _ambiguous ? l10n.traderAmbiguousName : l10n.traderNotAMerchant, ); } @@ -969,7 +978,10 @@ class _StockRow extends ConsumerWidget { : Text(subtitle, style: theme.textTheme.bodySmall), trailing: Row( mainAxisSize: MainAxisSize.min, - children: [SizedBox(width: 130, child: field), ?delete], + children: [ + SizedBox(width: 130, child: field), + ?delete, + ], ), ); } @@ -986,7 +998,12 @@ class _StockRow extends ConsumerWidget { ], ), const SizedBox(height: 8), - Row(children: [Expanded(child: field), ?delete]), + Row( + children: [ + Expanded(child: field), + ?delete, + ], + ), ], ), ); diff --git a/apps/save-editor/lib/l10n/app_de.arb b/apps/save-editor/lib/l10n/app_de.arb index 00125cd3d..7e342baf7 100644 --- a/apps/save-editor/lib/l10n/app_de.arb +++ b/apps/save-editor/lib/l10n/app_de.arb @@ -133,6 +133,7 @@ "tabInventory": "Inventar", "tabTrade": "Handel", "traderNotAMerchant": "Diese Person handelt nicht.", + "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", diff --git a/apps/save-editor/lib/l10n/app_en.arb b/apps/save-editor/lib/l10n/app_en.arb index 1c5d0c045..c40a40327 100644 --- a/apps/save-editor/lib/l10n/app_en.arb +++ b/apps/save-editor/lib/l10n/app_en.arb @@ -137,6 +137,7 @@ "tabInventory": "Inventory", "tabTrade": "Trade", "traderNotAMerchant": "This character does not trade.", + "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", diff --git a/apps/save-editor/lib/l10n/app_es.arb b/apps/save-editor/lib/l10n/app_es.arb index 9273a727f..55cba7652 100644 --- a/apps/save-editor/lib/l10n/app_es.arb +++ b/apps/save-editor/lib/l10n/app_es.arb @@ -131,6 +131,7 @@ "tabInventory": "Inventario", "tabTrade": "Comercio", "traderNotAMerchant": "Este personaje no comercia.", + "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", diff --git a/apps/save-editor/lib/l10n/app_fr.arb b/apps/save-editor/lib/l10n/app_fr.arb index c1c17883c..eabae1c3a 100644 --- a/apps/save-editor/lib/l10n/app_fr.arb +++ b/apps/save-editor/lib/l10n/app_fr.arb @@ -131,6 +131,7 @@ "tabInventory": "Inventaire", "tabTrade": "Commerce", "traderNotAMerchant": "Ce personnage ne fait pas de commerce.", + "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", diff --git a/apps/save-editor/lib/l10n/app_it.arb b/apps/save-editor/lib/l10n/app_it.arb index 8696a7cb2..a0f1d6234 100644 --- a/apps/save-editor/lib/l10n/app_it.arb +++ b/apps/save-editor/lib/l10n/app_it.arb @@ -131,6 +131,7 @@ "tabInventory": "Inventario", "tabTrade": "Commercio", "traderNotAMerchant": "Questo personaggio non commercia.", + "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", diff --git a/apps/save-editor/lib/l10n/app_ja.arb b/apps/save-editor/lib/l10n/app_ja.arb index edeabe591..ae69e4836 100644 --- a/apps/save-editor/lib/l10n/app_ja.arb +++ b/apps/save-editor/lib/l10n/app_ja.arb @@ -131,6 +131,7 @@ "tabInventory": "インベントリ", "tabTrade": "取引", "traderNotAMerchant": "このキャラクターは取引をしません。", + "traderAmbiguousName": "同じ名前の商人レコードが複数あるため、どの店がこのキャラクターのものか判別できません。誤って別の店を変更しないよう、編集は無効です。", "traderOre": "鉱石(購買力)", "traderNoOre": "鉱石なし", "traderStockCurrent": "在庫", diff --git a/apps/save-editor/lib/l10n/app_localizations.dart b/apps/save-editor/lib/l10n/app_localizations.dart index 62758eff9..d43f6033f 100644 --- a/apps/save-editor/lib/l10n/app_localizations.dart +++ b/apps/save-editor/lib/l10n/app_localizations.dart @@ -848,6 +848,12 @@ abstract class AppLocalizations { /// **'This character does not trade.'** String get traderNotAMerchant; + /// 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: diff --git a/apps/save-editor/lib/l10n/app_localizations_de.dart b/apps/save-editor/lib/l10n/app_localizations_de.dart index 824861cb0..82c0de436 100644 --- a/apps/save-editor/lib/l10n/app_localizations_de.dart +++ b/apps/save-editor/lib/l10n/app_localizations_de.dart @@ -424,6 +424,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get traderNotAMerchant => 'Diese Person handelt nicht.'; + @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)'; diff --git a/apps/save-editor/lib/l10n/app_localizations_en.dart b/apps/save-editor/lib/l10n/app_localizations_en.dart index cd25a7264..52558e3ae 100644 --- a/apps/save-editor/lib/l10n/app_localizations_en.dart +++ b/apps/save-editor/lib/l10n/app_localizations_en.dart @@ -423,6 +423,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get traderNotAMerchant => 'This character does not trade.'; + @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)'; diff --git a/apps/save-editor/lib/l10n/app_localizations_es.dart b/apps/save-editor/lib/l10n/app_localizations_es.dart index fd063e034..ec4a81248 100644 --- a/apps/save-editor/lib/l10n/app_localizations_es.dart +++ b/apps/save-editor/lib/l10n/app_localizations_es.dart @@ -425,6 +425,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get traderNotAMerchant => 'Este personaje no comercia.'; + @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)'; diff --git a/apps/save-editor/lib/l10n/app_localizations_fr.dart b/apps/save-editor/lib/l10n/app_localizations_fr.dart index aeed841b5..f03c31546 100644 --- a/apps/save-editor/lib/l10n/app_localizations_fr.dart +++ b/apps/save-editor/lib/l10n/app_localizations_fr.dart @@ -427,6 +427,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get traderNotAMerchant => 'Ce personnage ne fait pas de commerce.'; + @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)'; diff --git a/apps/save-editor/lib/l10n/app_localizations_it.dart b/apps/save-editor/lib/l10n/app_localizations_it.dart index 5281e59af..3984c6467 100644 --- a/apps/save-editor/lib/l10n/app_localizations_it.dart +++ b/apps/save-editor/lib/l10n/app_localizations_it.dart @@ -425,6 +425,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get traderNotAMerchant => 'Questo personaggio non commercia.'; + @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)'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ja.dart b/apps/save-editor/lib/l10n/app_localizations_ja.dart index 88c2f48ea..ab8c336f0 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ja.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ja.dart @@ -417,6 +417,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get traderNotAMerchant => 'このキャラクターは取引をしません。'; + @override + String get traderAmbiguousName => + '同じ名前の商人レコードが複数あるため、どの店がこのキャラクターのものか判別できません。誤って別の店を変更しないよう、編集は無効です。'; + @override String get traderOre => '鉱石(購買力)'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pl.dart b/apps/save-editor/lib/l10n/app_localizations_pl.dart index 0adfeb861..7988b9ce7 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pl.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pl.dart @@ -426,6 +426,10 @@ class AppLocalizationsPl extends AppLocalizations { @override String get traderNotAMerchant => 'Ta postać nie handluje.'; + @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)'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pt.dart b/apps/save-editor/lib/l10n/app_localizations_pt.dart index bf55d63c2..44c4acf81 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pt.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pt.dart @@ -425,6 +425,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get traderNotAMerchant => 'Esta personagem não comercia.'; + @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)'; @@ -3268,6 +3272,10 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get traderNotAMerchant => 'Este personagem não comercia.'; + @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)'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ru.dart b/apps/save-editor/lib/l10n/app_localizations_ru.dart index 230d4b66b..16b650386 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ru.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ru.dart @@ -427,6 +427,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get traderNotAMerchant => 'Этот персонаж не торгует.'; + @override + String get traderAmbiguousName => + 'Это имя носит несколько записей торговцев, поэтому нельзя определить, чья это лавка. Правка отключена, чтобы не изменить чужую.'; + @override String get traderOre => 'Руда (покупательная способность)'; diff --git a/apps/save-editor/lib/l10n/app_localizations_zh.dart b/apps/save-editor/lib/l10n/app_localizations_zh.dart index 5372ac2d1..bba44064c 100644 --- a/apps/save-editor/lib/l10n/app_localizations_zh.dart +++ b/apps/save-editor/lib/l10n/app_localizations_zh.dart @@ -412,6 +412,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get traderNotAMerchant => '该角色不进行交易。'; + @override + String get traderAmbiguousName => '有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。'; + @override String get traderOre => '矿石(购买力)'; @@ -3154,6 +3157,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get traderNotAMerchant => '该角色不进行交易。'; + @override + String get traderAmbiguousName => '有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。'; + @override String get traderOre => '矿石(购买力)'; diff --git a/apps/save-editor/lib/l10n/app_pl.arb b/apps/save-editor/lib/l10n/app_pl.arb index aa0ee237a..588078beb 100644 --- a/apps/save-editor/lib/l10n/app_pl.arb +++ b/apps/save-editor/lib/l10n/app_pl.arb @@ -131,6 +131,7 @@ "tabInventory": "Ekwipunek", "tabTrade": "Handel", "traderNotAMerchant": "Ta postać nie handluje.", + "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", diff --git a/apps/save-editor/lib/l10n/app_pt.arb b/apps/save-editor/lib/l10n/app_pt.arb index 94f137dfa..69aa739dc 100644 --- a/apps/save-editor/lib/l10n/app_pt.arb +++ b/apps/save-editor/lib/l10n/app_pt.arb @@ -131,6 +131,7 @@ "tabInventory": "Inventário", "tabTrade": "Comércio", "traderNotAMerchant": "Esta personagem não comercia.", + "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", diff --git a/apps/save-editor/lib/l10n/app_pt_BR.arb b/apps/save-editor/lib/l10n/app_pt_BR.arb index cfb337f2b..dd80a00f6 100644 --- a/apps/save-editor/lib/l10n/app_pt_BR.arb +++ b/apps/save-editor/lib/l10n/app_pt_BR.arb @@ -131,6 +131,7 @@ "tabInventory": "Inventário", "tabTrade": "Comércio", "traderNotAMerchant": "Este personagem não comercia.", + "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", diff --git a/apps/save-editor/lib/l10n/app_ru.arb b/apps/save-editor/lib/l10n/app_ru.arb index 661fde27d..8a57194df 100644 --- a/apps/save-editor/lib/l10n/app_ru.arb +++ b/apps/save-editor/lib/l10n/app_ru.arb @@ -131,6 +131,7 @@ "tabInventory": "Инвентарь", "tabTrade": "Торговля", "traderNotAMerchant": "Этот персонаж не торгует.", + "traderAmbiguousName": "Это имя носит несколько записей торговцев, поэтому нельзя определить, чья это лавка. Правка отключена, чтобы не изменить чужую.", "traderOre": "Руда (покупательная способность)", "traderNoOre": "нет руды", "traderStockCurrent": "Запас", diff --git a/apps/save-editor/lib/l10n/app_zh.arb b/apps/save-editor/lib/l10n/app_zh.arb index f186f5d94..a90960d21 100644 --- a/apps/save-editor/lib/l10n/app_zh.arb +++ b/apps/save-editor/lib/l10n/app_zh.arb @@ -131,6 +131,7 @@ "tabInventory": "物品栏", "tabTrade": "交易", "traderNotAMerchant": "该角色不进行交易。", + "traderAmbiguousName": "有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。", "traderOre": "矿石(购买力)", "traderNoOre": "无矿石", "traderStockCurrent": "库存", diff --git a/apps/save-editor/lib/l10n/app_zh_Hans.arb b/apps/save-editor/lib/l10n/app_zh_Hans.arb index 1837710a5..0f8c615d5 100644 --- a/apps/save-editor/lib/l10n/app_zh_Hans.arb +++ b/apps/save-editor/lib/l10n/app_zh_Hans.arb @@ -131,6 +131,7 @@ "tabInventory": "物品栏", "tabTrade": "交易", "traderNotAMerchant": "该角色不进行交易。", + "traderAmbiguousName": "有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。", "traderOre": "矿石(购买力)", "traderNoOre": "无矿石", "traderStockCurrent": "库存", diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 5081ae647..991e88ec6 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -133,6 +133,25 @@ void main() { }); 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', () { From fe8dc868a6e9be158f006590a0182c83009670ed Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 14:16:48 +0200 Subject: [PATCH 13/29] fix(save-editor): queue a typed stock count per keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field wrote its pending edit only on Enter or a tap outside, so a typed amount left Save disabled while it sat there, and a rebuild could overwrite the text before it was ever registered — the change then simply never happened. It queues on change now, the way the inventory's count editor does. An invalid entry says so under the field and withdraws the queued edit instead of snapping the text back under the cursor, which fought the typing. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 38 +++++++++++++--- apps/save-editor/test/trader_panel_test.dart | 43 ++++++++++++++++--- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index 670640957..be78fa68c 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -1034,6 +1034,9 @@ class _CountField extends StatefulWidget { } class _CountFieldState extends State<_CountField> { + /// Shown under the field while the typed value cannot be queued. + String? _error; + late final TextEditingController _controller = TextEditingController( text: '${widget.pending ?? widget.value}', ); @@ -1061,12 +1064,35 @@ class _CountFieldState extends State<_CountField> { /// time. The add-item dialog already caps at the same value. static const int _maxCount = 2147483647; // i32::MAX - void _submit(String raw) { - final parsed = int.tryParse(raw.trim()); - if (parsed == null || parsed < 1 || parsed > _maxCount) { - _controller.text = '${widget.pending ?? widget.value}'; + /// 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 { @@ -1086,6 +1112,7 @@ class _CountFieldState extends State<_CountField> { decoration: InputDecoration( isDense: true, border: const OutlineInputBorder(), + errorText: _error, suffixIcon: dirty ? IconButton( icon: const Icon(Icons.undo, size: 16), @@ -1093,8 +1120,7 @@ class _CountFieldState extends State<_CountField> { ) : null, ), - onSubmitted: _submit, - onTapOutside: (_) => _submit(_controller.text), + onChanged: _onChanged, ); } } diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 991e88ec6..bfe9cba97 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -576,12 +576,12 @@ void main() { final field = find.descendant(of: oreCard, matching: find.byType(TextField)); await tester.enterText(field, '2147483648'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); + await tester.pump(); - // Rejected in place: the field snaps back and nothing is queued, rather - // than the save failing later on the core's bound. - expect(tester.widget(field).controller?.text, '55'); + // 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) @@ -705,6 +705,39 @@ void main() { ); }); + 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(find.widgetWithText(Tab, '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('a merchant shows his ore and both stock sections', ( tester, ) async { From 44deab1f848a5c4e871531ab35a123e07d49f061 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 14:25:13 +0200 Subject: [PATCH 14/29] fix(save-editor): leave a focused count field alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backspacing through an edited count clears its pending value, and the sync that keeps the field in step with the save then restored the saved number straight back under the cursor — so an edited field could never be emptied to type a fresh one. While the field has focus its text belongs to the user: every sync there is a reaction to a change they just made. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 10 +++++++ apps/save-editor/test/trader_panel_test.dart | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index be78fa68c..2a93e3947 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -1037,6 +1037,10 @@ 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(); + late final TextEditingController _controller = TextEditingController( text: '${widget.pending ?? widget.value}', ); @@ -1044,6 +1048,10 @@ class _CountFieldState extends State<_CountField> { @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; @@ -1057,6 +1065,7 @@ class _CountFieldState extends State<_CountField> { @override void dispose() { _controller.dispose(); + _focus.dispose(); super.dispose(); } @@ -1105,6 +1114,7 @@ class _CountFieldState extends State<_CountField> { final dirty = widget.pending != null && widget.pending != widget.value; return TextField( controller: _controller, + focusNode: _focus, enabled: widget.enabled, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index bfe9cba97..d105e5e95 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -738,6 +738,36 @@ void main() { ); }); + 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(find.widgetWithText(Tab, '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('a merchant shows his ore and both stock sections', ( tester, ) async { From 2341de1f9f58be22c17fb4cc54defacb3a46fccc Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 14:30:46 +0200 Subject: [PATCH 15/29] fix(save-editor): put a count field back in step when it loses focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The focus guard keeps the sync off while the user types, which left an emptied or refused entry standing after they moved on — showing nothing, or a number the save never took, with no pending edit and no revert control to explain it. Leaving the field now restores what the save holds and clears the error. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 20 ++++++++++++ apps/save-editor/test/trader_panel_test.dart | 31 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index 2a93e3947..d00b1a7cf 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -1041,6 +1041,25 @@ class _CountFieldState extends State<_CountField> { /// 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. + 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}', ); @@ -1064,6 +1083,7 @@ class _CountFieldState extends State<_CountField> { @override void dispose() { + _focus.removeListener(_onFocusChanged); _controller.dispose(); _focus.dispose(); super.dispose(); diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index d105e5e95..75defeaa2 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -768,6 +768,37 @@ void main() { ); }); + 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(find.widgetWithText(Tab, '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 merchant shows his ore and both stock sections', ( tester, ) async { From ef5cc0c8a0b3cc294614113fd83f26cbd3380caf Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 14:40:13 +0200 Subject: [PATCH 16/29] fix(save-editor): refuse a trader edit beside an m_Traders array splice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trader edit is addressed by its row's position, and a raw arrayRemove or arrayDuplicate on m_Traders renumbers the rows. Splitting the two into separate writes did not rescue them: the index came from a list read before either ran, so whichever went second resolved it against a layout the first had moved — and if the raw edit removed that very row, the trader change landed on a neighbour while the write reported success. Both orderings are refused now, in the core and in the app that mirrors its rules. Co-Authored-By: Claude Opus 5 --- .../editor/domain/editor_notifier.dart | 9 ++++ apps/save-editor/test/trader_panel_test.dart | 45 +++++++++++++++++++ crates/gore-save/src/lib.rs | 10 +++++ crates/gore-save/tests/traders.rs | 38 ++++++++++++++++ 4 files changed, 102 insertions(+) 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 87625bf1a..9e0b3d004 100644 --- a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart +++ b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart @@ -4130,6 +4130,15 @@ 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 _pathHasName(typedPath, 'm_Traders'); default: return false; } diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 75defeaa2..32c1efec5 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -10,6 +10,7 @@ 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'; @@ -222,6 +223,50 @@ void main() { }); }); + group('trader edit conflicts', () { + // The app mirrors the core's order-independent rule so it refuses the pair + // instead of splitting it into writes that are no safer. + Map arrayRemoveOnTraders() => { + 'path': 'private.typed.arrayRemove', + 'value': { + 'path': ['m_GenericData', '{GameStateDataBase}', 'm_Traders'], + 'index': 0, + }, + }; + + 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('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. diff --git a/crates/gore-save/src/lib.rs b/crates/gore-save/src/lib.rs index 0e5bb7d45..e0ec7c513 100644 --- a/crates/gore-save/src/lib.rs +++ b/crates/gore-save/src/lib.rs @@ -9378,6 +9378,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_has_name(path, "m_Traders"), _ => false, } } diff --git a/crates/gore-save/tests/traders.rs b/crates/gore-save/tests/traders.rs index 624db95a9..460e348e5 100644 --- a/crates/gore-save/tests/traders.rs +++ b/crates/gore-save/tests/traders.rs @@ -308,6 +308,44 @@ fn the_same_pair_is_accepted_the_other_way_round() { 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 add_item_refuses_a_class_the_game_does_not_know() { let path = start_save("badclass"); From 67dda17d9643d93eb57718e3b5bd01807ac0f1ea Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 14:49:09 +0200 Subject: [PATCH 17/29] fix(save-editor): key a stock row by its map as well as its item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same item lives in both maps, so keying a row on the path alone let one row's field state carry across a map switch. What the field then showed depended on something happening to resync it — which is not a property to rely on, least of all beside a guard that deliberately skips syncing while focused. Keying by both makes the two rows distinct outright. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 7 ++- apps/save-editor/test/trader_panel_test.dart | 62 ++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index d00b1a7cf..835fd988b 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -798,7 +798,12 @@ class _StockSection extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 4), itemCount: rows.length, itemBuilder: (context, index) => _StockRow( - key: ValueKey(rows[index].path), + // 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, diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 32c1efec5..35d17277d 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -844,6 +844,64 @@ void main() { ); }); + 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(find.widgetWithText(Tab, '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('a merchant shows his ore and both stock sections', ( tester, ) async { @@ -1058,9 +1116,11 @@ class _TraderCoreService implements GoresaveCoreService { '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': 3, + 'count': 9, 'unknownItem': false, }, ], From 28ce95f648efc9ffae9b9cd5bbff21156167da32 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 14:59:22 +0200 Subject: [PATCH 18/29] fix(save-editor): abort the save on a trader/array pair instead of splitting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core refuses a trade change beside a raw array operation on m_Traders, but the app only fed that predicate to the packer, which treats a conflict as a batch boundary — so the two went out as separate writes, each acceptable on its own, and both were reported as committed while the array operation renumbered the row the trade change was addressed by. A split cannot rescue this pair, so the save now stops before building the worklist and says which two changes to separate. Co-Authored-By: Claude Opus 5 --- .../editor/domain/editor_notifier.dart | 36 +++++++++ apps/save-editor/lib/l10n/app_de.arb | 1 + apps/save-editor/lib/l10n/app_en.arb | 1 + apps/save-editor/lib/l10n/app_es.arb | 1 + apps/save-editor/lib/l10n/app_fr.arb | 1 + apps/save-editor/lib/l10n/app_it.arb | 1 + apps/save-editor/lib/l10n/app_ja.arb | 1 + .../lib/l10n/app_localizations.dart | 6 ++ .../lib/l10n/app_localizations_de.dart | 4 + .../lib/l10n/app_localizations_en.dart | 4 + .../lib/l10n/app_localizations_es.dart | 4 + .../lib/l10n/app_localizations_fr.dart | 4 + .../lib/l10n/app_localizations_it.dart | 4 + .../lib/l10n/app_localizations_ja.dart | 4 + .../lib/l10n/app_localizations_pl.dart | 4 + .../lib/l10n/app_localizations_pt.dart | 8 ++ .../lib/l10n/app_localizations_ru.dart | 4 + .../lib/l10n/app_localizations_zh.dart | 8 ++ apps/save-editor/lib/l10n/app_pl.arb | 1 + apps/save-editor/lib/l10n/app_pt.arb | 1 + apps/save-editor/lib/l10n/app_pt_BR.arb | 1 + apps/save-editor/lib/l10n/app_ru.arb | 1 + apps/save-editor/lib/l10n/app_zh.arb | 1 + apps/save-editor/lib/l10n/app_zh_Hans.arb | 1 + apps/save-editor/test/trader_panel_test.dart | 76 ++++++++++++++++--- 25 files changed, 169 insertions(+), 9 deletions(-) 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 9e0b3d004..5669ad248 100644 --- a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart +++ b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart @@ -1423,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) => @@ -4144,6 +4154,32 @@ bool structuredEditRewrites( } } +/// 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', + }; + for (final edit in edits) { + if (!traderOps.contains(edit['path'])) continue; + for (final other in edits) { + final path = _rawTypedEditPath(other); + if (path != null && _pathHasName(path, 'm_Traders')) { + return (edit, other); + } + } + } + return null; +} + /// 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. diff --git a/apps/save-editor/lib/l10n/app_de.arb b/apps/save-editor/lib/l10n/app_de.arb index 7e342baf7..6b3974124 100644 --- a/apps/save-editor/lib/l10n/app_de.arb +++ b/apps/save-editor/lib/l10n/app_de.arb @@ -708,6 +708,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 c40a40327..59afe49af 100644 --- a/apps/save-editor/lib/l10n/app_en.arb +++ b/apps/save-editor/lib/l10n/app_en.arb @@ -1235,6 +1235,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 55cba7652..830bade0b 100644 --- a/apps/save-editor/lib/l10n/app_es.arb +++ b/apps/save-editor/lib/l10n/app_es.arb @@ -708,6 +708,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 eabae1c3a..ee572494d 100644 --- a/apps/save-editor/lib/l10n/app_fr.arb +++ b/apps/save-editor/lib/l10n/app_fr.arb @@ -719,6 +719,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 a0f1d6234..9b080c5bf 100644 --- a/apps/save-editor/lib/l10n/app_it.arb +++ b/apps/save-editor/lib/l10n/app_it.arb @@ -708,6 +708,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 ae69e4836..3d68f177b 100644 --- a/apps/save-editor/lib/l10n/app_ja.arb +++ b/apps/save-editor/lib/l10n/app_ja.arb @@ -715,6 +715,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 d43f6033f..f449ce898 100644 --- a/apps/save-editor/lib/l10n/app_localizations.dart +++ b/apps/save-editor/lib/l10n/app_localizations.dart @@ -4284,6 +4284,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 82c0de436..c35461282 100644 --- a/apps/save-editor/lib/l10n/app_localizations_de.dart +++ b/apps/save-editor/lib/l10n/app_localizations_de.dart @@ -2791,6 +2791,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 52558e3ae..fa5df2a92 100644 --- a/apps/save-editor/lib/l10n/app_localizations_en.dart +++ b/apps/save-editor/lib/l10n/app_localizations_en.dart @@ -2775,6 +2775,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 ec4a81248..bf759c1b5 100644 --- a/apps/save-editor/lib/l10n/app_localizations_es.dart +++ b/apps/save-editor/lib/l10n/app_localizations_es.dart @@ -2790,6 +2790,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 f03c31546..16f0016ab 100644 --- a/apps/save-editor/lib/l10n/app_localizations_fr.dart +++ b/apps/save-editor/lib/l10n/app_localizations_fr.dart @@ -2806,6 +2806,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 3984c6467..e390dbc4f 100644 --- a/apps/save-editor/lib/l10n/app_localizations_it.dart +++ b/apps/save-editor/lib/l10n/app_localizations_it.dart @@ -2796,6 +2796,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 ab8c336f0..8ec6184f0 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ja.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ja.dart @@ -2724,6 +2724,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 7988b9ce7..73a24d444 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pl.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pl.dart @@ -2808,6 +2808,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 44c4acf81..7b70f4a7e 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pt.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pt.dart @@ -2791,6 +2791,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'; @@ -5637,6 +5641,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 16b650386..cf425622d 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ru.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ru.dart @@ -2801,6 +2801,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 bba44064c..e9d1ec039 100644 --- a/apps/save-editor/lib/l10n/app_localizations_zh.dart +++ b/apps/save-editor/lib/l10n/app_localizations_zh.dart @@ -2691,6 +2691,10 @@ class AppLocalizationsZh extends AppLocalizations { String get editorInventorySlotEditConflict => '对物品栏槽位的直接编辑与占用整个槽位的操作(修复、添加或删除)同时在待保存列表中。后者会覆盖前者 — 请撤销其中一项后再保存。'; + @override + String get editorTraderArrayConflict => + '一项交易修改与对商人数组的直接编辑一同排队。该编辑会重新编号交易修改所依据的行,因此两者之一会落到错误的商人身上——撤销其中一项后再保存。'; + @override String get backupFactFile => '文件'; @@ -5436,6 +5440,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 588078beb..362b3592e 100644 --- a/apps/save-editor/lib/l10n/app_pl.arb +++ b/apps/save-editor/lib/l10n/app_pl.arb @@ -708,6 +708,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 69aa739dc..c7cb1f2da 100644 --- a/apps/save-editor/lib/l10n/app_pt.arb +++ b/apps/save-editor/lib/l10n/app_pt.arb @@ -708,6 +708,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 dd80a00f6..a40619f05 100644 --- a/apps/save-editor/lib/l10n/app_pt_BR.arb +++ b/apps/save-editor/lib/l10n/app_pt_BR.arb @@ -708,6 +708,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 8a57194df..d01b67e5a 100644 --- a/apps/save-editor/lib/l10n/app_ru.arb +++ b/apps/save-editor/lib/l10n/app_ru.arb @@ -708,6 +708,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 a90960d21..7d21d5458 100644 --- a/apps/save-editor/lib/l10n/app_zh.arb +++ b/apps/save-editor/lib/l10n/app_zh.arb @@ -715,6 +715,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 0f8c615d5..80c8a05dc 100644 --- a/apps/save-editor/lib/l10n/app_zh_Hans.arb +++ b/apps/save-editor/lib/l10n/app_zh_Hans.arb @@ -715,6 +715,7 @@ "slotRepairAction": "修复", "slotRepairDiscard": "放弃", "editorInventorySlotEditConflict": "对物品栏槽位的直接编辑与占用整个槽位的操作(修复、添加或删除)同时在待保存列表中。后者会覆盖前者 — 请撤销其中一项后再保存。", + "editorTraderArrayConflict": "一项交易修改与对商人数组的直接编辑一同排队。该编辑会重新编号交易修改所依据的行,因此两者之一会落到错误的商人身上——撤销其中一项后再保存。", "backupFactFile": "文件", "renameBackupTooltip": "为此备份命名", "renameBackupTitle": "备份名称", diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 35d17277d..f2890e825 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -5,6 +5,7 @@ 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'; @@ -20,6 +21,15 @@ import 'support/ui_settings_test_store.dart'; /// 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', () { @@ -224,15 +234,6 @@ void main() { }); group('trader edit conflicts', () { - // The app mirrors the core's order-independent rule so it refuses the pair - // instead of splitting it into writes that are no safer. - Map arrayRemoveOnTraders() => { - 'path': 'private.typed.arrayRemove', - 'value': { - 'path': ['m_GenericData', '{GameStateDataBase}', 'm_Traders'], - 'index': 0, - }, - }; test('a trader edit and an m_Traders splice conflict either way', () { const traderEdit = TraderStockEdit( @@ -248,6 +249,21 @@ void main() { 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 unrelated array splice does not conflict', () { const traderEdit = TraderStockEdit( kind: TraderEditKind.addItem, @@ -902,6 +918,48 @@ void main() { ); }); + 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(find.widgetWithText(Tab, '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('a merchant shows his ore and both stock sections', ( tester, ) async { From 5f39e86c0511acc42cba0857650fd8eafe517241 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 15:07:31 +0200 Subject: [PATCH 19/29] fix(save-editor): scope the trader conflict to the array itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check matched any typed path containing m_Traders, so a value under one row or a container edit inside one — neither of which moves a single row — refused a pair sequential writes apply correctly, and told the user a change would land on the wrong merchant. Only a path that ENDS at the array renumbers its rows, and on the app side only an array operation does. Both sides check that instead. Co-Authored-By: Claude Opus 5 --- .../editor/domain/editor_notifier.dart | 14 +++++- apps/save-editor/test/trader_panel_test.dart | 43 +++++++++++++++++++ crates/gore-save/src/lib.rs | 12 +++++- crates/gore-save/tests/traders.rs | 30 +++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) 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 5669ad248..0c55ee8f0 100644 --- a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart +++ b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart @@ -4148,7 +4148,7 @@ bool structuredEditRewrites( case 'private.traders.setStock': case 'private.traders.addItem': case 'private.traders.removeItem': - return _pathHasName(typedPath, 'm_Traders'); + return _pathTargetsTheTraderArray(typedPath); default: return false; } @@ -4168,11 +4168,16 @@ bool structuredEditRewrites( '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 && _pathHasName(path, 'm_Traders')) { + if (path != null && _pathTargetsTheTraderArray(path)) { return (edit, other); } } @@ -4180,6 +4185,11 @@ bool structuredEditRewrites( 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. diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index f2890e825..13b4f3c94 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -264,6 +264,49 @@ void main() { 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, diff --git a/crates/gore-save/src/lib.rs b/crates/gore-save/src/lib.rs index e0ec7c513..a3703c9f7 100644 --- a/crates/gore-save/src/lib.rs +++ b/crates/gore-save/src/lib.rs @@ -9199,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)) @@ -9387,7 +9397,7 @@ fn structured_edit_rewrites(edit: &PrivateEdit, path: &[properties::PathSeg]) -> // the write reports success. Refuse the pair whichever way round. PrivateEdit::TraderSetStock(_) | PrivateEdit::TraderAddItem(_) - | PrivateEdit::TraderRemoveItem(_) => path_has_name(path, "m_Traders"), + | PrivateEdit::TraderRemoveItem(_) => path_targets_the_trader_array(path), _ => false, } } diff --git a/crates/gore-save/tests/traders.rs b/crates/gore-save/tests/traders.rs index 460e348e5..f96695bf7 100644 --- a/crates/gore-save/tests/traders.rs +++ b/crates/gore-save/tests/traders.rs @@ -346,6 +346,36 @@ fn a_trader_edit_and_an_m_traders_array_splice_are_refused_together() { } } +#[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 add_item_refuses_a_class_the_game_does_not_know() { let path = start_save("badclass"); From e91ee1525859226b5661689f568d43bd7c25ccdb Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 15:10:07 +0200 Subject: [PATCH 20/29] fix(save-editor): let the in-field undo put its own text back Tapping undo inside a focused count field cleared the pending edit but left the discarded number on screen: the sync that would restore it is deliberately off while the field has focus. The user was then looking at a count nothing had queued, with Save disabled, until they clicked away. The undo resets the text and clears the error itself. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 14 ++++++++- apps/save-editor/test/trader_panel_test.dart | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index 835fd988b..441a6cc27 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -1058,6 +1058,18 @@ class _CountFieldState extends State<_CountField> { /// 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}'; @@ -1151,7 +1163,7 @@ class _CountFieldState extends State<_CountField> { suffixIcon: dirty ? IconButton( icon: const Icon(Icons.undo, size: 16), - onPressed: widget.onRevert, + onPressed: _undo, ) : null, ), diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 13b4f3c94..72dd7b098 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -1003,6 +1003,37 @@ void main() { ); }); + 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(find.widgetWithText(Tab, '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 merchant shows his ore and both stock sections', ( tester, ) async { From 3f79776466cdcfea5485870d13f8d4e4a652959e Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 15:22:31 +0200 Subject: [PATCH 21/29] fix(save-editor): treat a record missing a stock list as read-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An omitted map parsed as an empty one, so the panel offered Add and the save then failed: the structural appliers resolve the property and cannot create it. The read reports whether both maps are actually there, and a record without them is read-only with a note saying so. No shipped save is in that shape — all 31 rows carry both maps — so this guards a shape we have not seen rather than a known state. Co-Authored-By: Claude Opus 5 --- .../features/editor/domain/trader_models.dart | 8 ++++++ .../lib/features/editor/ui/trader_detail.dart | 9 +++++-- apps/save-editor/lib/l10n/app_de.arb | 1 + apps/save-editor/lib/l10n/app_en.arb | 1 + apps/save-editor/lib/l10n/app_es.arb | 1 + apps/save-editor/lib/l10n/app_fr.arb | 1 + apps/save-editor/lib/l10n/app_it.arb | 1 + apps/save-editor/lib/l10n/app_ja.arb | 1 + .../lib/l10n/app_localizations.dart | 6 +++++ .../lib/l10n/app_localizations_de.dart | 4 +++ .../lib/l10n/app_localizations_en.dart | 4 +++ .../lib/l10n/app_localizations_es.dart | 4 +++ .../lib/l10n/app_localizations_fr.dart | 4 +++ .../lib/l10n/app_localizations_it.dart | 4 +++ .../lib/l10n/app_localizations_ja.dart | 4 +++ .../lib/l10n/app_localizations_pl.dart | 4 +++ .../lib/l10n/app_localizations_pt.dart | 8 ++++++ .../lib/l10n/app_localizations_ru.dart | 4 +++ .../lib/l10n/app_localizations_zh.dart | 8 ++++++ apps/save-editor/lib/l10n/app_pl.arb | 1 + apps/save-editor/lib/l10n/app_pt.arb | 1 + apps/save-editor/lib/l10n/app_pt_BR.arb | 1 + apps/save-editor/lib/l10n/app_ru.arb | 1 + apps/save-editor/lib/l10n/app_zh.arb | 1 + apps/save-editor/lib/l10n/app_zh_Hans.arb | 1 + apps/save-editor/test/trader_panel_test.dart | 27 +++++++++++++++++++ crates/gore-save/src/traders.rs | 25 +++++++++++++++++ 27 files changed, 133 insertions(+), 2 deletions(-) diff --git a/apps/save-editor/lib/features/editor/domain/trader_models.dart b/apps/save-editor/lib/features/editor/domain/trader_models.dart index 69e1c63a5..f48fa23fb 100644 --- a/apps/save-editor/lib/features/editor/domain/trader_models.dart +++ b/apps/save-editor/lib/features/editor/domain/trader_models.dart @@ -69,6 +69,7 @@ class TraderSummary { required this.traded, required this.generatedEventCount, required this.placeholder, + this.stockMapsPresent = true, }); factory TraderSummary.fromJson(Map json) { @@ -82,6 +83,8 @@ class TraderSummary { 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, ); } @@ -103,6 +106,11 @@ class TraderSummary { /// 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. diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index 441a6cc27..bc1df7ebd 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -169,7 +169,10 @@ class _TraderPanelState extends ConsumerState { // 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. - final unsupported = detail.hasItemsByDifficulty; + // 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 = @@ -206,7 +209,9 @@ class _TraderPanelState extends ConsumerState { // as the stock counts. if (unsupported) ...[ _NoteCard( - text: l10n.traderDifficultyStockUnsupported, + text: incomplete + ? l10n.traderRecordIncomplete + : l10n.traderDifficultyStockUnsupported, tone: _NoteTone.warning, ), const SizedBox(height: 12), diff --git a/apps/save-editor/lib/l10n/app_de.arb b/apps/save-editor/lib/l10n/app_de.arb index 6b3974124..c9e23b601 100644 --- a/apps/save-editor/lib/l10n/app_de.arb +++ b/apps/save-editor/lib/l10n/app_de.arb @@ -145,6 +145,7 @@ "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": "Dem Datensatz dieses Händlers fehlt eine seiner Bestandslisten, die der Editor nicht anlegen 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}", diff --git a/apps/save-editor/lib/l10n/app_en.arb b/apps/save-editor/lib/l10n/app_en.arb index 59afe49af..5be74be3c 100644 --- a/apps/save-editor/lib/l10n/app_en.arb +++ b/apps/save-editor/lib/l10n/app_en.arb @@ -149,6 +149,7 @@ "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 record is missing one of its stock lists, which the editor cannot create. 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}", diff --git a/apps/save-editor/lib/l10n/app_es.arb b/apps/save-editor/lib/l10n/app_es.arb index 830bade0b..0f56c12ac 100644 --- a/apps/save-editor/lib/l10n/app_es.arb +++ b/apps/save-editor/lib/l10n/app_es.arb @@ -143,6 +143,7 @@ "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": "Al registro de este mercader le falta una de sus listas de existencias, que el editor no puede crear. 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}", diff --git a/apps/save-editor/lib/l10n/app_fr.arb b/apps/save-editor/lib/l10n/app_fr.arb index ee572494d..ff8e73864 100644 --- a/apps/save-editor/lib/l10n/app_fr.arb +++ b/apps/save-editor/lib/l10n/app_fr.arb @@ -143,6 +143,7 @@ "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": "La fiche de ce marchand n'a pas l'une de ses listes de stock, que l'éditeur ne peut pas créer. 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}", diff --git a/apps/save-editor/lib/l10n/app_it.arb b/apps/save-editor/lib/l10n/app_it.arb index 9b080c5bf..c9c755e0a 100644 --- a/apps/save-editor/lib/l10n/app_it.arb +++ b/apps/save-editor/lib/l10n/app_it.arb @@ -143,6 +143,7 @@ "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": "Al record di questo mercante manca uno dei suoi elenchi di scorte, che l'editor non può creare. 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}", diff --git a/apps/save-editor/lib/l10n/app_ja.arb b/apps/save-editor/lib/l10n/app_ja.arb index 3d68f177b..2c3ccfdfa 100644 --- a/apps/save-editor/lib/l10n/app_ja.arb +++ b/apps/save-editor/lib/l10n/app_ja.arb @@ -143,6 +143,7 @@ "traderRemoveItem": "行を削除", "traderReadOnlyCore": "このコアは商人データの読み取りのみ可能です。", "traderDifficultyStockUnsupported": "この商人は難易度ごとの在庫を持っており、エディタはそれを扱えません。変更は成功したように見えても、その追加在庫はそのまま残るため、ここでの編集は無効です。", + "traderRecordIncomplete": "この商人のレコードには在庫リストの一方が欠けており、エディタでは作成できません。保存時に失敗しないよう、ここでの編集は無効です。", "traderEmptyStock": "在庫がありません。", "traderUnknownItem": "アイテムカタログにありません", "editorTradersLoadFailed": "商人データの読み込みに失敗しました: {details}", diff --git a/apps/save-editor/lib/l10n/app_localizations.dart b/apps/save-editor/lib/l10n/app_localizations.dart index f449ce898..d83836682 100644 --- a/apps/save-editor/lib/l10n/app_localizations.dart +++ b/apps/save-editor/lib/l10n/app_localizations.dart @@ -920,6 +920,12 @@ abstract class AppLocalizations { /// **'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 record is missing one of its stock lists, which the editor cannot create. 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: diff --git a/apps/save-editor/lib/l10n/app_localizations_de.dart b/apps/save-editor/lib/l10n/app_localizations_de.dart index c35461282..543600a8b 100644 --- a/apps/save-editor/lib/l10n/app_localizations_de.dart +++ b/apps/save-editor/lib/l10n/app_localizations_de.dart @@ -465,6 +465,10 @@ class AppLocalizationsDe extends AppLocalizations { 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 => + 'Dem Datensatz dieses Händlers fehlt eine seiner Bestandslisten, die der Editor nicht anlegen kann. Bearbeiten ist deshalb gesperrt, damit eine Änderung nicht erst beim Speichern scheitert.'; + @override String get traderEmptyStock => 'Nichts auf Lager.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_en.dart b/apps/save-editor/lib/l10n/app_localizations_en.dart index fa5df2a92..fd9b9b239 100644 --- a/apps/save-editor/lib/l10n/app_localizations_en.dart +++ b/apps/save-editor/lib/l10n/app_localizations_en.dart @@ -464,6 +464,10 @@ class AppLocalizationsEn extends AppLocalizations { 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 record is missing one of its stock lists, which the editor cannot create. Editing is disabled here so a change cannot fail at save time.'; + @override String get traderEmptyStock => 'Nothing in stock.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_es.dart b/apps/save-editor/lib/l10n/app_localizations_es.dart index bf759c1b5..a8ad988fb 100644 --- a/apps/save-editor/lib/l10n/app_localizations_es.dart +++ b/apps/save-editor/lib/l10n/app_localizations_es.dart @@ -467,6 +467,10 @@ class AppLocalizationsEs extends AppLocalizations { 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 => + 'Al registro de este mercader le falta una de sus listas de existencias, que el editor no puede crear. La edición está desactivada aquí para que un cambio no falle al guardar.'; + @override String get traderEmptyStock => 'Sin existencias.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_fr.dart b/apps/save-editor/lib/l10n/app_localizations_fr.dart index 16f0016ab..a99e042d4 100644 --- a/apps/save-editor/lib/l10n/app_localizations_fr.dart +++ b/apps/save-editor/lib/l10n/app_localizations_fr.dart @@ -469,6 +469,10 @@ class AppLocalizationsFr extends AppLocalizations { 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 => + 'La fiche de ce marchand n\'a pas l\'une de ses listes de stock, que l\'éditeur ne peut pas créer. L\'édition est désactivée ici pour qu\'une modification n\'échoue pas à l\'enregistrement.'; + @override String get traderEmptyStock => 'Rien en stock.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_it.dart b/apps/save-editor/lib/l10n/app_localizations_it.dart index e390dbc4f..5ab015eca 100644 --- a/apps/save-editor/lib/l10n/app_localizations_it.dart +++ b/apps/save-editor/lib/l10n/app_localizations_it.dart @@ -467,6 +467,10 @@ class AppLocalizationsIt extends AppLocalizations { 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 => + 'Al record di questo mercante manca uno dei suoi elenchi di scorte, che l\'editor non può creare. La modifica è disattivata qui perché un cambiamento non fallisca al salvataggio.'; + @override String get traderEmptyStock => 'Niente in magazzino.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ja.dart b/apps/save-editor/lib/l10n/app_localizations_ja.dart index 8ec6184f0..aaeb70589 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ja.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ja.dart @@ -457,6 +457,10 @@ class AppLocalizationsJa extends AppLocalizations { String get traderDifficultyStockUnsupported => 'この商人は難易度ごとの在庫を持っており、エディタはそれを扱えません。変更は成功したように見えても、その追加在庫はそのまま残るため、ここでの編集は無効です。'; + @override + String get traderRecordIncomplete => + 'この商人のレコードには在庫リストの一方が欠けており、エディタでは作成できません。保存時に失敗しないよう、ここでの編集は無効です。'; + @override String get traderEmptyStock => '在庫がありません。'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pl.dart b/apps/save-editor/lib/l10n/app_localizations_pl.dart index 73a24d444..0c388cfe3 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pl.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pl.dart @@ -468,6 +468,10 @@ class AppLocalizationsPl extends AppLocalizations { 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 => + 'W rekordzie tego kupca brakuje jednej z list zapasów, której edytor nie potrafi utworzyć. Edycja jest wyłączona, aby zmiana nie zawiodła przy zapisie.'; + @override String get traderEmptyStock => 'Brak zapasów.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pt.dart b/apps/save-editor/lib/l10n/app_localizations_pt.dart index 7b70f4a7e..f7da7a1b3 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pt.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pt.dart @@ -467,6 +467,10 @@ class AppLocalizationsPt extends AppLocalizations { 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 => + 'Ao registo deste mercador falta uma das suas listas de existências, que o editor não consegue criar. A edição está desativada aqui para que uma alteração não falhe ao gravar.'; + @override String get traderEmptyStock => 'Nada em estoque.'; @@ -3318,6 +3322,10 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { 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 => + 'Ao registro deste mercador falta uma de suas listas de estoque, que o editor não consegue criar. A edição está desativada aqui para que uma alteração não falhe ao salvar.'; + @override String get traderEmptyStock => 'Nada em estoque.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ru.dart b/apps/save-editor/lib/l10n/app_localizations_ru.dart index cf425622d..ad24c35f9 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ru.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ru.dart @@ -469,6 +469,10 @@ class AppLocalizationsRu extends AppLocalizations { String get traderDifficultyStockUnsupported => 'У этого торговца есть запас по уровню сложности, который редактор не моделирует. Правка здесь отключена: изменение выглядело бы успешным, но этот дополнительный запас остался бы нетронутым.'; + @override + String get traderRecordIncomplete => + 'В записи этого торговца нет одного из списков товара, а создать его редактор не может. Правка отключена, чтобы изменение не сорвалось при сохранении.'; + @override String get traderEmptyStock => 'Товара нет.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_zh.dart b/apps/save-editor/lib/l10n/app_localizations_zh.dart index e9d1ec039..4b785a2dd 100644 --- a/apps/save-editor/lib/l10n/app_localizations_zh.dart +++ b/apps/save-editor/lib/l10n/app_localizations_zh.dart @@ -450,6 +450,10 @@ class AppLocalizationsZh extends AppLocalizations { String get traderDifficultyStockUnsupported => '该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。'; + @override + String get traderRecordIncomplete => + '该商人的记录缺少其中一份库存清单,编辑器无法创建。此处已禁用编辑,以免修改在保存时失败。'; + @override String get traderEmptyStock => '没有库存。'; @@ -3199,6 +3203,10 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get traderDifficultyStockUnsupported => '该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。'; + @override + String get traderRecordIncomplete => + '该商人的记录缺少其中一份库存清单,编辑器无法创建。此处已禁用编辑,以免修改在保存时失败。'; + @override String get traderEmptyStock => '没有库存。'; diff --git a/apps/save-editor/lib/l10n/app_pl.arb b/apps/save-editor/lib/l10n/app_pl.arb index 362b3592e..4a1834320 100644 --- a/apps/save-editor/lib/l10n/app_pl.arb +++ b/apps/save-editor/lib/l10n/app_pl.arb @@ -143,6 +143,7 @@ "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": "W rekordzie tego kupca brakuje jednej z list zapasów, której edytor nie potrafi utworzyć. 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}", diff --git a/apps/save-editor/lib/l10n/app_pt.arb b/apps/save-editor/lib/l10n/app_pt.arb index c7cb1f2da..6276e5aca 100644 --- a/apps/save-editor/lib/l10n/app_pt.arb +++ b/apps/save-editor/lib/l10n/app_pt.arb @@ -143,6 +143,7 @@ "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": "Ao registo deste mercador falta uma das suas listas de existências, que o editor não consegue criar. 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}", diff --git a/apps/save-editor/lib/l10n/app_pt_BR.arb b/apps/save-editor/lib/l10n/app_pt_BR.arb index a40619f05..cd600be2e 100644 --- a/apps/save-editor/lib/l10n/app_pt_BR.arb +++ b/apps/save-editor/lib/l10n/app_pt_BR.arb @@ -143,6 +143,7 @@ "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": "Ao registro deste mercador falta uma de suas listas de estoque, que o editor não consegue criar. 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}", diff --git a/apps/save-editor/lib/l10n/app_ru.arb b/apps/save-editor/lib/l10n/app_ru.arb index d01b67e5a..f903b04bc 100644 --- a/apps/save-editor/lib/l10n/app_ru.arb +++ b/apps/save-editor/lib/l10n/app_ru.arb @@ -143,6 +143,7 @@ "traderRemoveItem": "Удалить строку", "traderReadOnlyCore": "Эта сборка ядра может только читать данные торговцев.", "traderDifficultyStockUnsupported": "У этого торговца есть запас по уровню сложности, который редактор не моделирует. Правка здесь отключена: изменение выглядело бы успешным, но этот дополнительный запас остался бы нетронутым.", + "traderRecordIncomplete": "В записи этого торговца нет одного из списков товара, а создать его редактор не может. Правка отключена, чтобы изменение не сорвалось при сохранении.", "traderEmptyStock": "Товара нет.", "traderUnknownItem": "нет в каталоге предметов", "editorTradersLoadFailed": "Не удалось загрузить торговцев: {details}", diff --git a/apps/save-editor/lib/l10n/app_zh.arb b/apps/save-editor/lib/l10n/app_zh.arb index 7d21d5458..98caabe8b 100644 --- a/apps/save-editor/lib/l10n/app_zh.arb +++ b/apps/save-editor/lib/l10n/app_zh.arb @@ -143,6 +143,7 @@ "traderRemoveItem": "移除条目", "traderReadOnlyCore": "此核心版本只能读取商人数据。", "traderDifficultyStockUnsupported": "该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。", + "traderRecordIncomplete": "该商人的记录缺少其中一份库存清单,编辑器无法创建。此处已禁用编辑,以免修改在保存时失败。", "traderEmptyStock": "没有库存。", "traderUnknownItem": "不在物品目录中", "editorTradersLoadFailed": "商人数据加载失败:{details}", diff --git a/apps/save-editor/lib/l10n/app_zh_Hans.arb b/apps/save-editor/lib/l10n/app_zh_Hans.arb index 80c8a05dc..c3fe0d627 100644 --- a/apps/save-editor/lib/l10n/app_zh_Hans.arb +++ b/apps/save-editor/lib/l10n/app_zh_Hans.arb @@ -143,6 +143,7 @@ "traderRemoveItem": "移除条目", "traderReadOnlyCore": "此核心版本只能读取商人数据。", "traderDifficultyStockUnsupported": "该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。", + "traderRecordIncomplete": "该商人的记录缺少其中一份库存清单,编辑器无法创建。此处已禁用编辑,以免修改在保存时失败。", "traderEmptyStock": "没有库存。", "traderUnknownItem": "不在物品目录中", "editorTradersLoadFailed": "商人数据加载失败:{details}", diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 72dd7b098..50bfbf798 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -1034,6 +1034,27 @@ void main() { ); }); + 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(find.widgetWithText(Tab, 'Trade')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('missing one of its stock lists'), + findsOneWidget, + ); + expect(find.widgetWithText(OutlinedButton, 'Add item'), findsNothing); + expect(find.byIcon(Icons.delete_outline), findsNothing); + }); + testWidgets('a merchant shows his ore and both stock sections', ( tester, ) async { @@ -1071,8 +1092,13 @@ class _TraderCoreService implements GoresaveCoreService { this.playerIsTrader = false, this.hasItemsByDifficulty = false, this.orphanMerchant = false, + this.stockMapsPresent = true, }); + /// 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; @@ -1208,6 +1234,7 @@ class _TraderCoreService implements GoresaveCoreService { 'traded': true, 'generatedEventCount': 11, 'placeholder': false, + 'stockMapsPresent': stockMapsPresent, 'items': [ { 'path': kTraderOrePath, diff --git a/crates/gore-save/src/traders.rs b/crates/gore-save/src/traders.rs index e8411c9c7..a0755e4d8 100644 --- a/crates/gore-save/src/traders.rs +++ b/crates/gore-save/src/traders.rs @@ -79,6 +79,13 @@ pub struct TraderSummary { pub generated_event_count: usize, /// `true` for the unnamed sentinel rows, which belong to no NPC. pub placeholder: bool, + /// Both stock maps are actually present on the record. + /// + /// An omitted map reads as an empty one, which would look editable and then + /// fail at save time: the structural appliers resolve the property and + /// cannot create it. Every shipped save carries both on all 31 rows, so + /// this is a guard against a shape we have not seen, not a known state. + pub stock_maps_present: bool, } /// Everything stored for one trader. @@ -218,6 +225,8 @@ fn summarize( }; let summary = TraderSummary { index, + stock_maps_present: member(props, "m_Items").is_some() + && member(props, "m_DefaultItems").is_some(), placeholder: unique_name == PLACEHOLDER_NAME, unique_name, item_count: items.len(), @@ -701,6 +710,22 @@ mod tests { 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); + + 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 From 5875690dec57cb05c2349a59614039e29b81b8b7 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 15:32:11 +0200 Subject: [PATCH 22/29] fix(save-editor): check the stock maps' descriptors, not just their presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty map has no entry to give its shape away, so read_stock could not tell an ObjectProperty→IntProperty map from any other — and the presence-only check still marked such a record writable. The panel then offered Add, and the save would fail: the appliers encode an object key and patch four bytes. Writability is decided on the descriptor now. The shipped save still reports all 31 rows as writable, so the check is no stricter than the data. Co-Authored-By: Claude Opus 5 --- crates/gore-save/src/traders.rs | 72 ++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 9 deletions(-) diff --git a/crates/gore-save/src/traders.rs b/crates/gore-save/src/traders.rs index a0755e4d8..2fc5b261d 100644 --- a/crates/gore-save/src/traders.rs +++ b/crates/gore-save/src/traders.rs @@ -79,12 +79,15 @@ pub struct TraderSummary { pub generated_event_count: usize, /// `true` for the unnamed sentinel rows, which belong to no NPC. pub placeholder: bool, - /// Both stock maps are actually present on the record. + /// 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, which would look editable and then - /// fail at save time: the structural appliers resolve the property and - /// cannot create it. Every shipped save carries both on all 31 rows, so - /// this is a guard against a shape we have not seen, not a known state. + /// 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, } @@ -144,6 +147,26 @@ 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. +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 @@ -225,8 +248,8 @@ fn summarize( }; let summary = TraderSummary { index, - stock_maps_present: member(props, "m_Items").is_some() - && member(props, "m_DefaultItems").is_some(), + 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(), @@ -633,6 +656,23 @@ mod tests { } } + /// 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, @@ -651,8 +691,8 @@ mod tests { fn trader(name: &str, items: &[(&str, i32)], seconds: f64) -> PropertyValue { PropertyValue::Struct(StructValue::Properties(vec![ prop("m_TradersUniqueName", PropertyValue::Name(name.to_string())), - prop("m_Items", stock(items)), - prop("m_DefaultItems", stock(items)), + stock_prop("m_Items", items), + stock_prop("m_DefaultItems", items), prop( "m_GeneratedEvents", PropertyValue::Array { @@ -722,6 +762,18 @@ mod tests { 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); } @@ -966,6 +1018,8 @@ mod tests { 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, From b6a1d24da5e9d8fcbc357cc9af67d06af039e029 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 16:10:52 +0200 Subject: [PATCH 23/29] fix(save-editor): stop calling an ore-only merchant empty The ore is lifted out of the live stock into its own card, so the filtered list can be empty while the map is not. Reading "nothing in stock" off that list contradicted both the line count in the header and the purse shown right above it. The message follows the map's own line count now, and a view with nothing left to browse simply shows no browser. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 13 +++++++--- apps/save-editor/test/trader_panel_test.dart | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index bc1df7ebd..6ca83166d 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -689,8 +689,11 @@ class _StockSection extends ConsumerWidget { final shown = groups.where((g) => g.category == selected).firstOrNull?.items ?? const []; - final nothingToShow = - items.isEmpty && pendingAdds.isEmpty && pendingRemovals.isEmpty; + // "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( @@ -748,7 +751,7 @@ class _StockSection extends ConsumerWidget { ), ), const SizedBox(height: 8), - if (nothingToShow) + if (mapIsEmpty) Align( alignment: Alignment.centerLeft, child: Text( @@ -756,6 +759,10 @@ class _StockSection extends ConsumerWidget { 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( diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 50bfbf798..20ec84055 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -1055,6 +1055,24 @@ void main() { 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(find.widgetWithText(Tab, '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('a merchant shows his ore and both stock sections', ( tester, ) async { @@ -1093,8 +1111,13 @@ class _TraderCoreService implements GoresaveCoreService { this.hasItemsByDifficulty = false, this.orphanMerchant = false, this.stockMapsPresent = true, + this.oreOnly = false, }); + /// 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; @@ -1242,6 +1265,7 @@ class _TraderCoreService implements GoresaveCoreService { 'count': 55, 'unknownItem': false, }, + if (!oreOnly) ...[ { 'path': '/Script/Angelscript.ItFo_Loaf', 'id': 'ItFo_Loaf', @@ -1266,6 +1290,7 @@ class _TraderCoreService implements GoresaveCoreService { 'count': 18, 'unknownItem': false, }, + ], ], 'defaultItems': [ { From 4f55892e8c5000b1d7d9611638f6bb48cfaaa122 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 16:51:47 +0200 Subject: [PATCH 24/29] fix(save-editor): stop calling a working core read-only, and localize retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core drops setStock from `writable` when no shop holds a line while still offering addItem, so a save full of empty shops announced that the editor could only read trader data — right beside a working Add button. The note waits until none of the three commands is available. The retry button on the load-error pane was the one Trade string still hardcoded in English. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 23 +++++--- apps/save-editor/lib/l10n/app_de.arb | 1 + apps/save-editor/lib/l10n/app_en.arb | 1 + apps/save-editor/lib/l10n/app_es.arb | 1 + apps/save-editor/lib/l10n/app_fr.arb | 1 + apps/save-editor/lib/l10n/app_it.arb | 1 + apps/save-editor/lib/l10n/app_ja.arb | 1 + .../lib/l10n/app_localizations.dart | 6 +++ .../lib/l10n/app_localizations_de.dart | 3 ++ .../lib/l10n/app_localizations_en.dart | 3 ++ .../lib/l10n/app_localizations_es.dart | 3 ++ .../lib/l10n/app_localizations_fr.dart | 3 ++ .../lib/l10n/app_localizations_it.dart | 3 ++ .../lib/l10n/app_localizations_ja.dart | 3 ++ .../lib/l10n/app_localizations_pl.dart | 3 ++ .../lib/l10n/app_localizations_pt.dart | 6 +++ .../lib/l10n/app_localizations_ru.dart | 3 ++ .../lib/l10n/app_localizations_zh.dart | 6 +++ apps/save-editor/lib/l10n/app_pl.arb | 1 + apps/save-editor/lib/l10n/app_pt.arb | 1 + apps/save-editor/lib/l10n/app_pt_BR.arb | 1 + apps/save-editor/lib/l10n/app_ru.arb | 1 + apps/save-editor/lib/l10n/app_zh.arb | 1 + apps/save-editor/lib/l10n/app_zh_Hans.arb | 1 + apps/save-editor/test/trader_panel_test.dart | 53 +++++++++++++++++-- 25 files changed, 118 insertions(+), 12 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index 6ca83166d..7fd801531 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -217,9 +217,15 @@ class _TraderPanelState extends ConsumerState { 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 && - !(list?.canSetStock ?? false)) ...[ + !canSet && + !canAdd && + !canRemove) ...[ const SizedBox(height: 12), Text( l10n.traderReadOnlyCore, @@ -811,11 +817,11 @@ class _StockSection extends ConsumerWidget { 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)), + // 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, @@ -1217,7 +1223,10 @@ class _Message extends StatelessWidget { ), if (onRetry != null) ...[ const SizedBox(height: 12), - OutlinedButton(onPressed: onRetry, child: const Text('Retry')), + 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 c9e23b601..43797e0d2 100644 --- a/apps/save-editor/lib/l10n/app_de.arb +++ b/apps/save-editor/lib/l10n/app_de.arb @@ -133,6 +133,7 @@ "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", diff --git a/apps/save-editor/lib/l10n/app_en.arb b/apps/save-editor/lib/l10n/app_en.arb index 5be74be3c..78f177882 100644 --- a/apps/save-editor/lib/l10n/app_en.arb +++ b/apps/save-editor/lib/l10n/app_en.arb @@ -137,6 +137,7 @@ "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", diff --git a/apps/save-editor/lib/l10n/app_es.arb b/apps/save-editor/lib/l10n/app_es.arb index 0f56c12ac..d6cbbd9e4 100644 --- a/apps/save-editor/lib/l10n/app_es.arb +++ b/apps/save-editor/lib/l10n/app_es.arb @@ -131,6 +131,7 @@ "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", diff --git a/apps/save-editor/lib/l10n/app_fr.arb b/apps/save-editor/lib/l10n/app_fr.arb index ff8e73864..f7e241539 100644 --- a/apps/save-editor/lib/l10n/app_fr.arb +++ b/apps/save-editor/lib/l10n/app_fr.arb @@ -131,6 +131,7 @@ "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", diff --git a/apps/save-editor/lib/l10n/app_it.arb b/apps/save-editor/lib/l10n/app_it.arb index c9c755e0a..98b03f893 100644 --- a/apps/save-editor/lib/l10n/app_it.arb +++ b/apps/save-editor/lib/l10n/app_it.arb @@ -131,6 +131,7 @@ "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", diff --git a/apps/save-editor/lib/l10n/app_ja.arb b/apps/save-editor/lib/l10n/app_ja.arb index 2c3ccfdfa..97db540ca 100644 --- a/apps/save-editor/lib/l10n/app_ja.arb +++ b/apps/save-editor/lib/l10n/app_ja.arb @@ -131,6 +131,7 @@ "tabInventory": "インベントリ", "tabTrade": "取引", "traderNotAMerchant": "このキャラクターは取引をしません。", + "traderRetry": "再試行", "traderAmbiguousName": "同じ名前の商人レコードが複数あるため、どの店がこのキャラクターのものか判別できません。誤って別の店を変更しないよう、編集は無効です。", "traderOre": "鉱石(購買力)", "traderNoOre": "鉱石なし", diff --git a/apps/save-editor/lib/l10n/app_localizations.dart b/apps/save-editor/lib/l10n/app_localizations.dart index d83836682..d8abb2a0a 100644 --- a/apps/save-editor/lib/l10n/app_localizations.dart +++ b/apps/save-editor/lib/l10n/app_localizations.dart @@ -848,6 +848,12 @@ abstract class AppLocalizations { /// **'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: diff --git a/apps/save-editor/lib/l10n/app_localizations_de.dart b/apps/save-editor/lib/l10n/app_localizations_de.dart index 543600a8b..1c7a86067 100644 --- a/apps/save-editor/lib/l10n/app_localizations_de.dart +++ b/apps/save-editor/lib/l10n/app_localizations_de.dart @@ -424,6 +424,9 @@ class AppLocalizationsDe extends AppLocalizations { @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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_en.dart b/apps/save-editor/lib/l10n/app_localizations_en.dart index fd9b9b239..665f53978 100644 --- a/apps/save-editor/lib/l10n/app_localizations_en.dart +++ b/apps/save-editor/lib/l10n/app_localizations_en.dart @@ -423,6 +423,9 @@ class AppLocalizationsEn extends AppLocalizations { @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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_es.dart b/apps/save-editor/lib/l10n/app_localizations_es.dart index a8ad988fb..df3f3ea7a 100644 --- a/apps/save-editor/lib/l10n/app_localizations_es.dart +++ b/apps/save-editor/lib/l10n/app_localizations_es.dart @@ -425,6 +425,9 @@ class AppLocalizationsEs extends AppLocalizations { @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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_fr.dart b/apps/save-editor/lib/l10n/app_localizations_fr.dart index a99e042d4..dfcbe79f9 100644 --- a/apps/save-editor/lib/l10n/app_localizations_fr.dart +++ b/apps/save-editor/lib/l10n/app_localizations_fr.dart @@ -427,6 +427,9 @@ class AppLocalizationsFr extends AppLocalizations { @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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_it.dart b/apps/save-editor/lib/l10n/app_localizations_it.dart index 5ab015eca..e0215431d 100644 --- a/apps/save-editor/lib/l10n/app_localizations_it.dart +++ b/apps/save-editor/lib/l10n/app_localizations_it.dart @@ -425,6 +425,9 @@ class AppLocalizationsIt extends AppLocalizations { @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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ja.dart b/apps/save-editor/lib/l10n/app_localizations_ja.dart index aaeb70589..218162a47 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ja.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ja.dart @@ -417,6 +417,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get traderNotAMerchant => 'このキャラクターは取引をしません。'; + @override + String get traderRetry => '再試行'; + @override String get traderAmbiguousName => '同じ名前の商人レコードが複数あるため、どの店がこのキャラクターのものか判別できません。誤って別の店を変更しないよう、編集は無効です。'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pl.dart b/apps/save-editor/lib/l10n/app_localizations_pl.dart index 0c388cfe3..1fe30abe9 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pl.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pl.dart @@ -426,6 +426,9 @@ class AppLocalizationsPl extends AppLocalizations { @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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pt.dart b/apps/save-editor/lib/l10n/app_localizations_pt.dart index f7da7a1b3..d75ead59a 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pt.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pt.dart @@ -425,6 +425,9 @@ class AppLocalizationsPt extends AppLocalizations { @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.'; @@ -3280,6 +3283,9 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ru.dart b/apps/save-editor/lib/l10n/app_localizations_ru.dart index ad24c35f9..61d9dd1ac 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ru.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ru.dart @@ -427,6 +427,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get traderNotAMerchant => 'Этот персонаж не торгует.'; + @override + String get traderRetry => 'Повторить'; + @override String get traderAmbiguousName => 'Это имя носит несколько записей торговцев, поэтому нельзя определить, чья это лавка. Правка отключена, чтобы не изменить чужую.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_zh.dart b/apps/save-editor/lib/l10n/app_localizations_zh.dart index 4b785a2dd..7e4c69047 100644 --- a/apps/save-editor/lib/l10n/app_localizations_zh.dart +++ b/apps/save-editor/lib/l10n/app_localizations_zh.dart @@ -412,6 +412,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get traderNotAMerchant => '该角色不进行交易。'; + @override + String get traderRetry => '重试'; + @override String get traderAmbiguousName => '有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。'; @@ -3165,6 +3168,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get traderNotAMerchant => '该角色不进行交易。'; + @override + String get traderRetry => '重试'; + @override String get traderAmbiguousName => '有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。'; diff --git a/apps/save-editor/lib/l10n/app_pl.arb b/apps/save-editor/lib/l10n/app_pl.arb index 4a1834320..1a06d9c5a 100644 --- a/apps/save-editor/lib/l10n/app_pl.arb +++ b/apps/save-editor/lib/l10n/app_pl.arb @@ -131,6 +131,7 @@ "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", diff --git a/apps/save-editor/lib/l10n/app_pt.arb b/apps/save-editor/lib/l10n/app_pt.arb index 6276e5aca..0e20de6c6 100644 --- a/apps/save-editor/lib/l10n/app_pt.arb +++ b/apps/save-editor/lib/l10n/app_pt.arb @@ -131,6 +131,7 @@ "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", diff --git a/apps/save-editor/lib/l10n/app_pt_BR.arb b/apps/save-editor/lib/l10n/app_pt_BR.arb index cd600be2e..2ffbbfcee 100644 --- a/apps/save-editor/lib/l10n/app_pt_BR.arb +++ b/apps/save-editor/lib/l10n/app_pt_BR.arb @@ -131,6 +131,7 @@ "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", diff --git a/apps/save-editor/lib/l10n/app_ru.arb b/apps/save-editor/lib/l10n/app_ru.arb index f903b04bc..1418238d6 100644 --- a/apps/save-editor/lib/l10n/app_ru.arb +++ b/apps/save-editor/lib/l10n/app_ru.arb @@ -131,6 +131,7 @@ "tabInventory": "Инвентарь", "tabTrade": "Торговля", "traderNotAMerchant": "Этот персонаж не торгует.", + "traderRetry": "Повторить", "traderAmbiguousName": "Это имя носит несколько записей торговцев, поэтому нельзя определить, чья это лавка. Правка отключена, чтобы не изменить чужую.", "traderOre": "Руда (покупательная способность)", "traderNoOre": "нет руды", diff --git a/apps/save-editor/lib/l10n/app_zh.arb b/apps/save-editor/lib/l10n/app_zh.arb index 98caabe8b..09f398a5a 100644 --- a/apps/save-editor/lib/l10n/app_zh.arb +++ b/apps/save-editor/lib/l10n/app_zh.arb @@ -131,6 +131,7 @@ "tabInventory": "物品栏", "tabTrade": "交易", "traderNotAMerchant": "该角色不进行交易。", + "traderRetry": "重试", "traderAmbiguousName": "有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。", "traderOre": "矿石(购买力)", "traderNoOre": "无矿石", diff --git a/apps/save-editor/lib/l10n/app_zh_Hans.arb b/apps/save-editor/lib/l10n/app_zh_Hans.arb index c3fe0d627..cb54e551d 100644 --- a/apps/save-editor/lib/l10n/app_zh_Hans.arb +++ b/apps/save-editor/lib/l10n/app_zh_Hans.arb @@ -131,6 +131,7 @@ "tabInventory": "物品栏", "tabTrade": "交易", "traderNotAMerchant": "该角色不进行交易。", + "traderRetry": "重试", "traderAmbiguousName": "有多条商人记录使用这个名字,无法判断哪家店属于该角色。已禁用编辑,以免改错。", "traderOre": "矿石(购买力)", "traderNoOre": "无矿石", diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 20ec84055..ae93f1691 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -1073,6 +1073,42 @@ void main() { 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(find.widgetWithText(Tab, '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(find.widgetWithText(Tab, 'Trade')); + await tester.pumpAndSettle(); + expect(find.textContaining('can only read trader data'), findsOneWidget); + }); + testWidgets('a merchant shows his ore and both stock sections', ( tester, ) async { @@ -1112,8 +1148,13 @@ class _TraderCoreService implements GoresaveCoreService { 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; @@ -1235,11 +1276,13 @@ class _TraderCoreService implements GoresaveCoreService { 'placeholder': false, }, ], - 'writable': [ - 'private.traders.addItem', - 'private.traders.setStock', - 'private.traders.removeItem', - ], + 'writable': + writable ?? + const [ + 'private.traders.addItem', + 'private.traders.setStock', + 'private.traders.removeItem', + ], }, }; case 'private.traders.detail': From 0efdf4b4a04fecac27d09035247dfa5c5f34cde2 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 17:22:02 +0200 Subject: [PATCH 25/29] docs(save-editor): say that a stock map can be unsupported, not only missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One flag covers two shapes — a record with no stock map, and one whose descriptor the appliers cannot write — but the note named only the first. On a modded save with an empty map of another shape the behaviour was right and the explanation sent the reader looking for an absent property. Co-Authored-By: Claude Opus 5 --- apps/save-editor/lib/l10n/app_de.arb | 2 +- apps/save-editor/lib/l10n/app_en.arb | 2 +- apps/save-editor/lib/l10n/app_es.arb | 2 +- apps/save-editor/lib/l10n/app_fr.arb | 2 +- apps/save-editor/lib/l10n/app_it.arb | 2 +- apps/save-editor/lib/l10n/app_ja.arb | 2 +- apps/save-editor/lib/l10n/app_localizations.dart | 2 +- apps/save-editor/lib/l10n/app_localizations_de.dart | 2 +- apps/save-editor/lib/l10n/app_localizations_en.dart | 2 +- apps/save-editor/lib/l10n/app_localizations_es.dart | 2 +- apps/save-editor/lib/l10n/app_localizations_fr.dart | 2 +- apps/save-editor/lib/l10n/app_localizations_it.dart | 2 +- apps/save-editor/lib/l10n/app_localizations_ja.dart | 2 +- apps/save-editor/lib/l10n/app_localizations_pl.dart | 2 +- apps/save-editor/lib/l10n/app_localizations_pt.dart | 4 ++-- apps/save-editor/lib/l10n/app_localizations_ru.dart | 2 +- apps/save-editor/lib/l10n/app_localizations_zh.dart | 4 ++-- apps/save-editor/lib/l10n/app_pl.arb | 2 +- apps/save-editor/lib/l10n/app_pt.arb | 2 +- apps/save-editor/lib/l10n/app_pt_BR.arb | 2 +- apps/save-editor/lib/l10n/app_ru.arb | 2 +- apps/save-editor/lib/l10n/app_zh.arb | 2 +- apps/save-editor/lib/l10n/app_zh_Hans.arb | 2 +- apps/save-editor/test/trader_panel_test.dart | 2 +- crates/gore-save/src/traders.rs | 5 ++++- 25 files changed, 30 insertions(+), 27 deletions(-) diff --git a/apps/save-editor/lib/l10n/app_de.arb b/apps/save-editor/lib/l10n/app_de.arb index 43797e0d2..35bf566eb 100644 --- a/apps/save-editor/lib/l10n/app_de.arb +++ b/apps/save-editor/lib/l10n/app_de.arb @@ -146,7 +146,7 @@ "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": "Dem Datensatz dieses Händlers fehlt eine seiner Bestandslisten, die der Editor nicht anlegen kann. Bearbeiten ist deshalb gesperrt, damit eine Änderung nicht erst beim Speichern scheitert.", + "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}", diff --git a/apps/save-editor/lib/l10n/app_en.arb b/apps/save-editor/lib/l10n/app_en.arb index 78f177882..7d44f043d 100644 --- a/apps/save-editor/lib/l10n/app_en.arb +++ b/apps/save-editor/lib/l10n/app_en.arb @@ -150,7 +150,7 @@ "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 record is missing one of its stock lists, which the editor cannot create. Editing is disabled here so a change cannot fail at save time.", + "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}", diff --git a/apps/save-editor/lib/l10n/app_es.arb b/apps/save-editor/lib/l10n/app_es.arb index d6cbbd9e4..360db4f5a 100644 --- a/apps/save-editor/lib/l10n/app_es.arb +++ b/apps/save-editor/lib/l10n/app_es.arb @@ -144,7 +144,7 @@ "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": "Al registro de este mercader le falta una de sus listas de existencias, que el editor no puede crear. La edición está desactivada aquí para que un cambio no falle al guardar.", + "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}", diff --git a/apps/save-editor/lib/l10n/app_fr.arb b/apps/save-editor/lib/l10n/app_fr.arb index f7e241539..d5154de72 100644 --- a/apps/save-editor/lib/l10n/app_fr.arb +++ b/apps/save-editor/lib/l10n/app_fr.arb @@ -144,7 +144,7 @@ "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": "La fiche de ce marchand n'a pas l'une de ses listes de stock, que l'éditeur ne peut pas créer. L'édition est désactivée ici pour qu'une modification n'échoue pas à l'enregistrement.", + "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}", diff --git a/apps/save-editor/lib/l10n/app_it.arb b/apps/save-editor/lib/l10n/app_it.arb index 98b03f893..826cdf026 100644 --- a/apps/save-editor/lib/l10n/app_it.arb +++ b/apps/save-editor/lib/l10n/app_it.arb @@ -144,7 +144,7 @@ "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": "Al record di questo mercante manca uno dei suoi elenchi di scorte, che l'editor non può creare. La modifica è disattivata qui perché un cambiamento non fallisca al salvataggio.", + "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}", diff --git a/apps/save-editor/lib/l10n/app_ja.arb b/apps/save-editor/lib/l10n/app_ja.arb index 97db540ca..4b5ccc00c 100644 --- a/apps/save-editor/lib/l10n/app_ja.arb +++ b/apps/save-editor/lib/l10n/app_ja.arb @@ -144,7 +144,7 @@ "traderRemoveItem": "行を削除", "traderReadOnlyCore": "このコアは商人データの読み取りのみ可能です。", "traderDifficultyStockUnsupported": "この商人は難易度ごとの在庫を持っており、エディタはそれを扱えません。変更は成功したように見えても、その追加在庫はそのまま残るため、ここでの編集は無効です。", - "traderRecordIncomplete": "この商人のレコードには在庫リストの一方が欠けており、エディタでは作成できません。保存時に失敗しないよう、ここでの編集は無効です。", + "traderRecordIncomplete": "この商人の在庫リストが存在しないか、エディタが対応しておらず書き込めない形式です。保存時に失敗しないよう、ここでの編集は無効です。", "traderEmptyStock": "在庫がありません。", "traderUnknownItem": "アイテムカタログにありません", "editorTradersLoadFailed": "商人データの読み込みに失敗しました: {details}", diff --git a/apps/save-editor/lib/l10n/app_localizations.dart b/apps/save-editor/lib/l10n/app_localizations.dart index d8abb2a0a..0a6c64b27 100644 --- a/apps/save-editor/lib/l10n/app_localizations.dart +++ b/apps/save-editor/lib/l10n/app_localizations.dart @@ -929,7 +929,7 @@ abstract class AppLocalizations { /// No description provided for @traderRecordIncomplete. /// /// In en, this message translates to: - /// **'This merchant\'s record is missing one of its stock lists, which the editor cannot create. Editing is disabled here so a change cannot fail at save time.'** + /// **'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. diff --git a/apps/save-editor/lib/l10n/app_localizations_de.dart b/apps/save-editor/lib/l10n/app_localizations_de.dart index 1c7a86067..9fde002c5 100644 --- a/apps/save-editor/lib/l10n/app_localizations_de.dart +++ b/apps/save-editor/lib/l10n/app_localizations_de.dart @@ -470,7 +470,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get traderRecordIncomplete => - 'Dem Datensatz dieses Händlers fehlt eine seiner Bestandslisten, die der Editor nicht anlegen kann. Bearbeiten ist deshalb gesperrt, damit eine Änderung nicht erst beim Speichern scheitert.'; + '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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_en.dart b/apps/save-editor/lib/l10n/app_localizations_en.dart index 665f53978..3cd26150b 100644 --- a/apps/save-editor/lib/l10n/app_localizations_en.dart +++ b/apps/save-editor/lib/l10n/app_localizations_en.dart @@ -469,7 +469,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get traderRecordIncomplete => - 'This merchant\'s record is missing one of its stock lists, which the editor cannot create. Editing is disabled here so a change cannot fail at save time.'; + '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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_es.dart b/apps/save-editor/lib/l10n/app_localizations_es.dart index df3f3ea7a..f30d1257e 100644 --- a/apps/save-editor/lib/l10n/app_localizations_es.dart +++ b/apps/save-editor/lib/l10n/app_localizations_es.dart @@ -472,7 +472,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get traderRecordIncomplete => - 'Al registro de este mercader le falta una de sus listas de existencias, que el editor no puede crear. La edición está desactivada aquí para que un cambio no falle al guardar.'; + '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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_fr.dart b/apps/save-editor/lib/l10n/app_localizations_fr.dart index dfcbe79f9..dc05f866d 100644 --- a/apps/save-editor/lib/l10n/app_localizations_fr.dart +++ b/apps/save-editor/lib/l10n/app_localizations_fr.dart @@ -474,7 +474,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get traderRecordIncomplete => - 'La fiche de ce marchand n\'a pas l\'une de ses listes de stock, que l\'éditeur ne peut pas créer. L\'édition est désactivée ici pour qu\'une modification n\'échoue pas à l\'enregistrement.'; + '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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_it.dart b/apps/save-editor/lib/l10n/app_localizations_it.dart index e0215431d..606c34913 100644 --- a/apps/save-editor/lib/l10n/app_localizations_it.dart +++ b/apps/save-editor/lib/l10n/app_localizations_it.dart @@ -472,7 +472,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get traderRecordIncomplete => - 'Al record di questo mercante manca uno dei suoi elenchi di scorte, che l\'editor non può creare. La modifica è disattivata qui perché un cambiamento non fallisca al salvataggio.'; + '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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ja.dart b/apps/save-editor/lib/l10n/app_localizations_ja.dart index 218162a47..31b5122bc 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ja.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ja.dart @@ -462,7 +462,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get traderRecordIncomplete => - 'この商人のレコードには在庫リストの一方が欠けており、エディタでは作成できません。保存時に失敗しないよう、ここでの編集は無効です。'; + 'この商人の在庫リストが存在しないか、エディタが対応しておらず書き込めない形式です。保存時に失敗しないよう、ここでの編集は無効です。'; @override String get traderEmptyStock => '在庫がありません。'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pl.dart b/apps/save-editor/lib/l10n/app_localizations_pl.dart index 1fe30abe9..c7b301126 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pl.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pl.dart @@ -473,7 +473,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String get traderRecordIncomplete => - 'W rekordzie tego kupca brakuje jednej z list zapasów, której edytor nie potrafi utworzyć. Edycja jest wyłączona, aby zmiana nie zawiodła przy zapisie.'; + '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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_pt.dart b/apps/save-editor/lib/l10n/app_localizations_pt.dart index d75ead59a..4a726d880 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pt.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pt.dart @@ -472,7 +472,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get traderRecordIncomplete => - 'Ao registo deste mercador falta uma das suas listas de existências, que o editor não consegue criar. A edição está desativada aqui para que uma alteração não falhe ao gravar.'; + '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.'; @@ -3330,7 +3330,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get traderRecordIncomplete => - 'Ao registro deste mercador falta uma de suas listas de estoque, que o editor não consegue criar. A edição está desativada aqui para que uma alteração não falhe ao salvar.'; + '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.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_ru.dart b/apps/save-editor/lib/l10n/app_localizations_ru.dart index 61d9dd1ac..e5661fe10 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ru.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ru.dart @@ -474,7 +474,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get traderRecordIncomplete => - 'В записи этого торговца нет одного из списков товара, а создать его редактор не может. Правка отключена, чтобы изменение не сорвалось при сохранении.'; + 'Списков товара этого торговца нет, либо они в форме, которую редактор не поддерживает и не может записать. Правка отключена, чтобы изменение не сорвалось при сохранении.'; @override String get traderEmptyStock => 'Товара нет.'; diff --git a/apps/save-editor/lib/l10n/app_localizations_zh.dart b/apps/save-editor/lib/l10n/app_localizations_zh.dart index 7e4c69047..4b915a997 100644 --- a/apps/save-editor/lib/l10n/app_localizations_zh.dart +++ b/apps/save-editor/lib/l10n/app_localizations_zh.dart @@ -455,7 +455,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get traderRecordIncomplete => - '该商人的记录缺少其中一份库存清单,编辑器无法创建。此处已禁用编辑,以免修改在保存时失败。'; + '该商人的库存清单缺失,或其结构编辑器不支持、无法写入。此处已禁用编辑,以免修改在保存时失败。'; @override String get traderEmptyStock => '没有库存。'; @@ -3211,7 +3211,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get traderRecordIncomplete => - '该商人的记录缺少其中一份库存清单,编辑器无法创建。此处已禁用编辑,以免修改在保存时失败。'; + '该商人的库存清单缺失,或其结构编辑器不支持、无法写入。此处已禁用编辑,以免修改在保存时失败。'; @override String get traderEmptyStock => '没有库存。'; diff --git a/apps/save-editor/lib/l10n/app_pl.arb b/apps/save-editor/lib/l10n/app_pl.arb index 1a06d9c5a..5ec055126 100644 --- a/apps/save-editor/lib/l10n/app_pl.arb +++ b/apps/save-editor/lib/l10n/app_pl.arb @@ -144,7 +144,7 @@ "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": "W rekordzie tego kupca brakuje jednej z list zapasów, której edytor nie potrafi utworzyć. Edycja jest wyłączona, aby zmiana nie zawiodła przy zapisie.", + "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}", diff --git a/apps/save-editor/lib/l10n/app_pt.arb b/apps/save-editor/lib/l10n/app_pt.arb index 0e20de6c6..04fd4a835 100644 --- a/apps/save-editor/lib/l10n/app_pt.arb +++ b/apps/save-editor/lib/l10n/app_pt.arb @@ -144,7 +144,7 @@ "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": "Ao registo deste mercador falta uma das suas listas de existências, que o editor não consegue criar. A edição está desativada aqui para que uma alteração não falhe ao gravar.", + "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}", diff --git a/apps/save-editor/lib/l10n/app_pt_BR.arb b/apps/save-editor/lib/l10n/app_pt_BR.arb index 2ffbbfcee..89a2b3098 100644 --- a/apps/save-editor/lib/l10n/app_pt_BR.arb +++ b/apps/save-editor/lib/l10n/app_pt_BR.arb @@ -144,7 +144,7 @@ "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": "Ao registro deste mercador falta uma de suas listas de estoque, que o editor não consegue criar. A edição está desativada aqui para que uma alteração não falhe ao salvar.", + "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}", diff --git a/apps/save-editor/lib/l10n/app_ru.arb b/apps/save-editor/lib/l10n/app_ru.arb index 1418238d6..76a9df8fb 100644 --- a/apps/save-editor/lib/l10n/app_ru.arb +++ b/apps/save-editor/lib/l10n/app_ru.arb @@ -144,7 +144,7 @@ "traderRemoveItem": "Удалить строку", "traderReadOnlyCore": "Эта сборка ядра может только читать данные торговцев.", "traderDifficultyStockUnsupported": "У этого торговца есть запас по уровню сложности, который редактор не моделирует. Правка здесь отключена: изменение выглядело бы успешным, но этот дополнительный запас остался бы нетронутым.", - "traderRecordIncomplete": "В записи этого торговца нет одного из списков товара, а создать его редактор не может. Правка отключена, чтобы изменение не сорвалось при сохранении.", + "traderRecordIncomplete": "Списков товара этого торговца нет, либо они в форме, которую редактор не поддерживает и не может записать. Правка отключена, чтобы изменение не сорвалось при сохранении.", "traderEmptyStock": "Товара нет.", "traderUnknownItem": "нет в каталоге предметов", "editorTradersLoadFailed": "Не удалось загрузить торговцев: {details}", diff --git a/apps/save-editor/lib/l10n/app_zh.arb b/apps/save-editor/lib/l10n/app_zh.arb index 09f398a5a..db5f896a0 100644 --- a/apps/save-editor/lib/l10n/app_zh.arb +++ b/apps/save-editor/lib/l10n/app_zh.arb @@ -144,7 +144,7 @@ "traderRemoveItem": "移除条目", "traderReadOnlyCore": "此核心版本只能读取商人数据。", "traderDifficultyStockUnsupported": "该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。", - "traderRecordIncomplete": "该商人的记录缺少其中一份库存清单,编辑器无法创建。此处已禁用编辑,以免修改在保存时失败。", + "traderRecordIncomplete": "该商人的库存清单缺失,或其结构编辑器不支持、无法写入。此处已禁用编辑,以免修改在保存时失败。", "traderEmptyStock": "没有库存。", "traderUnknownItem": "不在物品目录中", "editorTradersLoadFailed": "商人数据加载失败:{details}", diff --git a/apps/save-editor/lib/l10n/app_zh_Hans.arb b/apps/save-editor/lib/l10n/app_zh_Hans.arb index cb54e551d..be8f04387 100644 --- a/apps/save-editor/lib/l10n/app_zh_Hans.arb +++ b/apps/save-editor/lib/l10n/app_zh_Hans.arb @@ -144,7 +144,7 @@ "traderRemoveItem": "移除条目", "traderReadOnlyCore": "此核心版本只能读取商人数据。", "traderDifficultyStockUnsupported": "该商人拥有按难度区分的库存,编辑器并未建模。此处已禁用编辑,因为修改看似成功,却会让这部分额外库存原封不动。", - "traderRecordIncomplete": "该商人的记录缺少其中一份库存清单,编辑器无法创建。此处已禁用编辑,以免修改在保存时失败。", + "traderRecordIncomplete": "该商人的库存清单缺失,或其结构编辑器不支持、无法写入。此处已禁用编辑,以免修改在保存时失败。", "traderEmptyStock": "没有库存。", "traderUnknownItem": "不在物品目录中", "editorTradersLoadFailed": "商人数据加载失败:{details}", diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index ae93f1691..d1a9d0576 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -1048,7 +1048,7 @@ void main() { await tester.pumpAndSettle(); expect( - find.textContaining('missing one of its stock lists'), + find.textContaining('does not support and cannot write'), findsOneWidget, ); expect(find.widgetWithText(OutlinedButton, 'Add item'), findsNothing); diff --git a/crates/gore-save/src/traders.rs b/crates/gore-save/src/traders.rs index 2fc5b261d..cd6346633 100644 --- a/crates/gore-save/src/traders.rs +++ b/crates/gore-save/src/traders.rs @@ -151,9 +151,12 @@ 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 +/// 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; From 0e4aa5cd7b914909ea868248eebf8f939efa2f1d Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 17:59:12 +0200 Subject: [PATCH 26/29] fix(save-editor): refuse a set and a removal of the same stock line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only setStock carried a target key, so a set placed before a removal of the same line passed the guards: the core applied the count and then deleted the line it lived in, reporting both edits as applied. A removal is just as declarative about one line, so it shares the key and the same-target rule rejects the pair. Different lines still carry different keys and keep batching. The editor never produced this pair — it drops a queued count when a removal is queued — but a caller coming straight through execute_json does not go through the editor. Co-Authored-By: Claude Opus 5 --- crates/gore-save/src/lib.rs | 17 ++++++--- crates/gore-save/tests/traders.rs | 58 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/crates/gore-save/src/lib.rs b/crates/gore-save/src/lib.rs index a3703c9f7..3447cb55c 100644 --- a/crates/gore-save/src/lib.rs +++ b/crates/gore-save/src/lib.rs @@ -9320,10 +9320,12 @@ fn structured_edit_target(edit: &PrivateEdit) -> Option<(&'static str, String)> glossary.segment_asset.as_str(), ]), )), - // Declarative: it sets one stock line to one count. Two of them naming the - // same line would silently discard one. addItem/removeItem are deliberately - // absent — they ADD to or drop from the map, and two of them name two - // different lines, which is what a batch is for. + // 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([ @@ -9331,6 +9333,13 @@ fn structured_edit_target(edit: &PrivateEdit) -> Option<(&'static str, String)> 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, } } diff --git a/crates/gore-save/tests/traders.rs b/crates/gore-save/tests/traders.rs index f96695bf7..e6d85e586 100644 --- a/crates/gore-save/tests/traders.rs +++ b/crates/gore-save/tests/traders.rs @@ -376,6 +376,64 @@ fn an_edit_inside_a_trader_row_is_not_a_renumbering_splice() { 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"); From 4aaa4368d7e48b30b22cf9ac00b596c098823e27 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 18:18:33 +0200 Subject: [PATCH 27/29] fix(save-editor): drop the sub-tab labels where they cannot fit The character detail bar gained a sixth tab with Trade. A non-scrollable TabBar splits its width evenly, and the detail pane is only a fraction of the window, so each tab was left with far less room than a label needs and the labels clipped instead of shrinking. Below the breakpoint the tabs now carry their icon alone and name themselves on hover. Making the bar scrollable was the other option, but that hides four of the six tabs behind a horizontal scroll at every window size short of very wide, which trades a clipped label for a tab nobody finds. Tests that open a sub-tab go through a finder that accepts either form, since the test surface is always in the narrow regime. Co-Authored-By: Claude Opus 5 --- .../features/editor/ui/characters_tab.dart | 75 +++++++++++------ ...inventory_pending_localized_name_test.dart | 3 +- .../test/inventory_slot_repair_test.dart | 3 +- .../test/knowledge_first_add_race_test.dart | 5 +- .../npc_attribute_pending_per_npc_test.dart | 5 +- ...c_attribute_rehydrate_on_revisit_test.dart | 3 +- .../test/npc_inventory_containers_test.dart | 3 +- ..._inventory_duplicate_stack_count_test.dart | 7 +- .../npc_inventory_pending_per_npc_test.dart | 7 +- ...c_inventory_rehydrate_on_revisit_test.dart | 3 +- .../test/npc_inventory_reset_test.dart | 3 +- .../test/player_events_hero_wiring_test.dart | 11 +-- .../save-editor/test/support/detail_tabs.dart | 16 ++++ .../test/support/npc_position_fake_core.dart | 3 +- apps/save-editor/test/trader_panel_test.dart | 84 +++++++++++++------ apps/save-editor/test/widget_test.dart | 9 +- 16 files changed, 163 insertions(+), 77 deletions(-) create mode 100644 apps/save-editor/test/support/detail_tabs.dart 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 2c4a906d3..1664ee99d 100644 --- a/apps/save-editor/lib/features/editor/ui/characters_tab.dart +++ b/apps/save-editor/lib/features/editor/ui/characters_tab.dart @@ -33,6 +33,24 @@ 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. +Tab _detailTab(IconData icon, String label, bool labelled) => Tab( + icon: labelled + ? Icon(icon) + : Tooltip(message: label, child: Icon(icon)), + text: labelled ? label : null, +); + class CharactersTab extends ConsumerWidget { const CharactersTab({ super.key, @@ -229,33 +247,36 @@ 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.storefront_outlined), - text: l10n.tabTrade, - ), - 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( 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 index d1a9d0576..957bed370 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -15,6 +15,8 @@ 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 @@ -368,7 +370,7 @@ void main() { await pumpApp(tester, core); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); expect(find.text('This character does not trade.'), findsOneWidget); @@ -387,7 +389,7 @@ void main() { await pumpApp(tester, core); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); expect(find.byIcon(Icons.add_circle_outline), findsNothing); @@ -436,7 +438,7 @@ void main() { await pumpApp(tester, core); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); final before = core.requests @@ -483,7 +485,7 @@ void main() { await pumpApp(tester, core); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); final notifier = ProviderScope.containerOf( @@ -523,7 +525,7 @@ void main() { Future queueAdd(WidgetTester tester) async { await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); ProviderScope.containerOf(tester.element(find.byType(Scaffold).first)) .read(editorProvider.notifier) @@ -558,7 +560,7 @@ void main() { await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); // One sidebar entry per populated category, counted. Ore is not among @@ -595,7 +597,7 @@ void main() { ); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); await tester.tap(find.text('Food & potions (2)')); await tester.pumpAndSettle(); @@ -615,7 +617,7 @@ void main() { await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); List fieldTexts() => tester @@ -648,7 +650,7 @@ void main() { await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); final container = ProviderScope.containerOf( @@ -675,7 +677,7 @@ void main() { await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); final field = find.descendant(of: oreCard, matching: find.byType(TextField)); @@ -706,7 +708,7 @@ void main() { ); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); expect(find.textContaining('per-difficulty stock'), findsOneWidget); @@ -727,7 +729,7 @@ void main() { await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); final notifier = ProviderScope.containerOf( @@ -766,7 +768,7 @@ void main() { ); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); final notifier = ProviderScope.containerOf( @@ -818,7 +820,7 @@ void main() { await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); final field = find.descendant(of: oreCard, matching: find.byType(TextField)); @@ -851,7 +853,7 @@ void main() { await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); final field = find.descendant(of: oreCard, matching: find.byType(TextField)); @@ -881,7 +883,7 @@ void main() { await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); final field = find.descendant(of: oreCard, matching: find.byType(TextField)); @@ -917,7 +919,7 @@ void main() { await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); await tester.tap(find.text('Food & potions (2)')); await tester.pumpAndSettle(); @@ -971,7 +973,7 @@ void main() { await pumpApp(tester, core); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); final notifier = ProviderScope.containerOf( @@ -1012,7 +1014,7 @@ void main() { await pumpApp(tester, _TraderCoreService(playerIsTrader: true)); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); final field = find.descendant(of: oreCard, matching: find.byType(TextField)); @@ -1044,7 +1046,7 @@ void main() { ); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); expect( @@ -1065,7 +1067,7 @@ void main() { await pumpApp(tester, _TraderCoreService(playerIsTrader: true, oreOnly: true)); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); expect(find.text('Nothing in stock.'), findsNothing); @@ -1087,7 +1089,7 @@ void main() { ); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); expect(find.widgetWithText(OutlinedButton, 'Add item'), findsOneWidget); @@ -1104,11 +1106,45 @@ void main() { ); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + 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); + }); + + 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 { @@ -1116,7 +1152,7 @@ void main() { await pumpApp(tester, core); await tester.tap(find.widgetWithText(Tab, 'Characters')); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(Tab, 'Trade')); + await tester.tap(detailTab('Trade')); await tester.pumpAndSettle(); expect(find.text('Ore (purchasing power)'), findsOneWidget); 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. From b7200c3ee01fcf946962678ba9fb5586464daf61 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 18:24:17 +0200 Subject: [PATCH 28/29] fix(save-editor): give an icon-only sub-tab an accessible name The tooltip that names a label-less tab lands in the semantics node's `tooltip`, which platforms surface as help text rather than as the control's name, so the tab read out as "Tab 3 of 6". The icon now carries the name as its semantic label, where a labelled tab has it. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/characters_tab.dart | 7 ++++++- apps/save-editor/test/trader_panel_test.dart | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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 1664ee99d..a8f6b9c94 100644 --- a/apps/save-editor/lib/features/editor/ui/characters_tab.dart +++ b/apps/save-editor/lib/features/editor/ui/characters_tab.dart @@ -44,10 +44,15 @@ bool detailTabsCanCarryLabels(double width) => /// 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)), + : Tooltip(message: label, child: Icon(icon, semanticLabel: label)), text: labelled ? label : null, ); diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 957bed370..157884ee7 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -1139,6 +1139,21 @@ void main() { 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); From 8f20b44882b534b41ccdc49544d5d6ef0c3f7fec Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 13 Aug 2026 18:29:27 +0200 Subject: [PATCH 29/29] fix(save-editor): sort the compact stock list by name too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under 600px the stock pane drops the sidebar and lists every line at once. That list was passed straight through from the core, which orders by class id, while the grouped pane sorted by the localized name — so the one view with no categories to lean on was also the one whose order did not match the names on screen. The comparator now lives on its own and both views use it. Co-Authored-By: Claude Opus 5 --- .../lib/features/editor/ui/trader_detail.dart | 25 +++++++++++----- apps/save-editor/test/trader_panel_test.dart | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/trader_detail.dart b/apps/save-editor/lib/features/editor/ui/trader_detail.dart index 7fd801531..ca0f5de5e 100644 --- a/apps/save-editor/lib/features/editor/ui/trader_detail.dart +++ b/apps/save-editor/lib/features/editor/ui/trader_detail.dart @@ -687,6 +687,10 @@ class _StockSection extends ConsumerWidget { 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) @@ -774,7 +778,7 @@ class _StockSection extends ConsumerWidget { child: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < _compactBelow; - final rows = compact ? items : shown; + final rows = compact ? flat : shown; return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -853,6 +857,18 @@ class _StockGroup { 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 @@ -865,12 +881,7 @@ List<_StockGroup> _grouped( for (final item in items) { byCategory.putIfAbsent(itemCategoryFromId(item.id), () => []).add(item); } - int compare(TraderItem a, TraderItem b) { - final byName = displayNameOf( - a, - ).toLowerCase().compareTo(displayNameOf(b).toLowerCase()); - return byName != 0 ? byName : a.id.compareTo(b.id); - } + final compare = _byDisplayName(displayNameOf); return [ for (final category in ItemCategory.values) diff --git a/apps/save-editor/test/trader_panel_test.dart b/apps/save-editor/test/trader_panel_test.dart index 157884ee7..c9d14cf89 100644 --- a/apps/save-editor/test/trader_panel_test.dart +++ b/apps/save-editor/test/trader_panel_test.dart @@ -607,6 +607,35 @@ void main() { 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 {