Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d7aa887
feat(save-editor): edit what a merchant sells and how much ore he has
dh0er Aug 11, 2026
ac681af
feat(save-editor): browse a merchant's stock the way the inventory is…
dh0er Aug 12, 2026
dca43e6
fix(save-editor): refuse a zero stock count and re-key the count fields
dh0er Aug 12, 2026
2897b09
fix(save-editor): join trader names case-insensitively, keep the ore …
dh0er Aug 12, 2026
12a1091
fix(save-editor): reject a zero count in the public add-item applier too
dh0er Aug 12, 2026
8274c5b
fix(save-editor): fold case in the core name resolver, refuse unmodel…
dh0er Aug 12, 2026
d1678fd
fix(save-editor): bound the queued-change banners so they cannot push…
dh0er Aug 13, 2026
66943ef
fix(save-editor): size the trade panel against its pane, not against …
dh0er Aug 13, 2026
33e8882
fix(save-editor): make the trade panel fit the smallest window it allows
dh0er Aug 13, 2026
04dae15
fix(save-editor): let a knowledge-only merchant open his shop
dh0er Aug 13, 2026
dc62303
fix(save-editor): badge a knowledge-only merchant in the list too
dh0er Aug 13, 2026
e0ee069
fix(save-editor): refuse an ambiguous trader name instead of guessing
dh0er Aug 13, 2026
fe8dc86
fix(save-editor): queue a typed stock count per keystroke
dh0er Aug 13, 2026
44deab1
fix(save-editor): leave a focused count field alone
dh0er Aug 13, 2026
2341de1
fix(save-editor): put a count field back in step when it loses focus
dh0er Aug 13, 2026
ef5cc0c
fix(save-editor): refuse a trader edit beside an m_Traders array splice
dh0er Aug 13, 2026
67dda17
fix(save-editor): key a stock row by its map as well as its item
dh0er Aug 13, 2026
28ce95f
fix(save-editor): abort the save on a trader/array pair instead of sp…
dh0er Aug 13, 2026
5f39e86
fix(save-editor): scope the trader conflict to the array itself
dh0er Aug 13, 2026
e91ee15
fix(save-editor): let the in-field undo put its own text back
dh0er Aug 13, 2026
3f79776
fix(save-editor): treat a record missing a stock list as read-only
dh0er Aug 13, 2026
5875690
fix(save-editor): check the stock maps' descriptors, not just their p…
dh0er Aug 13, 2026
b6a1d24
fix(save-editor): stop calling an ore-only merchant empty
dh0er Aug 13, 2026
4f55892
fix(save-editor): stop calling a working core read-only, and localize…
dh0er Aug 13, 2026
0efdf4b
docs(save-editor): say that a stock map can be unsupported, not only …
dh0er Aug 13, 2026
0e4aa5c
fix(save-editor): refuse a set and a removal of the same stock line
dh0er Aug 13, 2026
4aaa436
fix(save-editor): drop the sub-tab labels where they cannot fit
dh0er Aug 13, 2026
b7200c3
fix(save-editor): give an icon-only sub-tab an accessible name
dh0er Aug 13, 2026
8f20b44
fix(save-editor): sort the compact stock list by name too
dh0er Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/save-editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class CharacterRow {
required this.hasInventory,
required this.hasKnowledge,
required this.hasEvents,
this.isTrader = false,
});

factory CharacterRow.fromJson(Map<String, Object?> json) {
Expand All @@ -19,6 +20,7 @@ class CharacterRow {
hasInventory: json['hasInventory'] == true,
hasKnowledge: json['hasKnowledge'] == true,
hasEvents: json['hasEvents'] == true,
isTrader: json['isTrader'] == true,
);
}

Expand All @@ -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;
}

Expand Down
89 changes: 89 additions & 0 deletions apps/save-editor/lib/features/editor/domain/editor_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1279,6 +1280,11 @@ class EditorNotifier extends StateNotifier<EditorState> {
'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 —
Expand Down Expand Up @@ -2799,6 +2805,77 @@ class EditorNotifier extends StateNotifier<EditorState> {
}
}

/// 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<TradersResult> 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<String, Object?>(),
);
} 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<TraderDetailResult> 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<String, Object?>(),
),
);
} 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.
Expand Down Expand Up @@ -4113,6 +4190,11 @@ bool _mayInvalidateOrdinals(Map<String, Object?> 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);
}
Expand Down Expand Up @@ -4146,6 +4228,13 @@ bool _carriesCallerOrdinal(Map<String, Object?> 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') {
Comment thread
dh0er marked this conversation as resolved.
// Every trader edit addresses its row by an index into the trader array that
// the user's view supplied.
return true;
}
return false;
}

Expand Down
Loading
Loading