Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
144 changes: 144 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 @@ -1417,6 +1423,16 @@ class EditorNotifier extends StateNotifier<EditorState> {
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) =>
Expand Down Expand Up @@ -2799,6 +2815,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 @@ -4053,11 +4140,56 @@ bool structuredEditRewrites(
// container in the save, so it is not scoped to one actor.
case 'private.inventory.repairSlots':
return _pathWritesASlotId(typedPath);
// A trader edit is addressed by its row's position in m_Traders, and a raw
// array operation ON that array renumbers the rows. Splitting the two into
// separate writes does not rescue them: the index came from a list read
// BEFORE either ran, so whichever goes second resolves it against a layout
// the first moved. The pair is refused whichever way round it comes.
case 'private.traders.setStock':
case 'private.traders.addItem':
case 'private.traders.removeItem':
return _pathTargetsTheTraderArray(typedPath);
default:
return false;
}
}

/// The first pair of pending edits where a trade change meets a raw array
/// operation on the trader array, or null when there is none.
///
/// Separate from the packer's boundary test: this pair is not made safe by a
/// split, so it has to abort the save rather than start a new sub-write.
@visibleForTesting
(Map<String, Object?>, Map<String, Object?>)? traderArrayConflict(
List<Map<String, Object?>> edits,
) {
const traderOps = {
'private.traders.setStock',
'private.traders.addItem',
'private.traders.removeItem',
};
const arrayOps = {'private.typed.arrayRemove', 'private.typed.arrayDuplicate'};
for (final edit in edits) {
if (!traderOps.contains(edit['path'])) continue;
for (final other in edits) {
// Only an array operation ON the array renumbers its rows. An edit that
// merely runs THROUGH it — a value under one row, or a container inside
// one — moves nothing, and refusing those would block safe pairs.
if (!arrayOps.contains(other['path'])) continue;
final path = _rawTypedEditPath(other);
if (path != null && _pathTargetsTheTraderArray(path)) {
return (edit, other);
Comment thread
dh0er marked this conversation as resolved.
}
}
}
return null;
}

/// Whether a raw typed path addresses the trader ARRAY itself rather than
/// something inside one of its rows.
bool _pathTargetsTheTraderArray(List<Object?> 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.
Expand Down Expand Up @@ -4113,6 +4245,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 +4283,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