fromJson static method
Restores progress from a serialized payload.
Sanitises rather than trusts. A save file is host-controlled and, on a
device the player owns, player-controlled: wrong types are dropped,
unparseable level keys are skipped, a negative unlock pointer becomes 0
and a pointer beyond what the payload has earned is clamped down to it.
What comes back is always a self-consistent SagaProgress, never a
faithful echo of the bytes on disk.
The ceiling is folded over completed records only, not over every
key. A record's presence proves nothing — a tampered save could name
level 999998 as locked and, folding over keys, lift the pointer to
999999 with it. A completed record is the only one the package itself
writes as a consequence of play, so it is the only one the ceiling may
rest on. With no completed record the ceiling is 0: nothing has been
cleared, so nothing past the first level has been opened.
One pointer above that ceiling survives anyway: the one the payload
records as reachable at the pointer itself, in unlocked or
completed state. That is the host that jumped ahead deliberately — a
chapter-skip purchase, a debug build, enforceUnlockOrder: false — and
wrote the record to say so, and its saves must round-trip. The rule this
leaves is simply: a pointer must be justified by its own record or by
a completion below it, never by a record for some unrelated level.
The whole contract, in one list. fromJson will:
- drop a wrong-typed
levels,extraor record, and skip an unparseable level key; - skip a negative level key, which is an impossible state everywhere else in this class;
- correct a record whose
levelIddisagrees with its key to the key, which is the identity the map is built on; - raise a negative unlock pointer to
0, and clamp one that the payload does not justify (see above); - read an empty
levelsmap as uninitialised and substitute SagaProgress.initial's levels; - clamp
spentStarsinto[0, totalStars]; - clamp each record's own fields — see LevelProgress.fromJson.
Pass onClamp to be told when the pointer actually moved. A 1.x save
with a sparse levels map — 1.x let a host persist the pointer without a
record per level — will clamp here, and with enforceUnlockOrder on by
default in 2.0.0 every level above the new pointer then refuses to
complete. That reads to the player as erased progress. Use
migrateFrom1x once, at upgrade time, to convert such a save instead.
Implementation
static SagaProgress fromJson(
Map<String, dynamic> json, {
void Function(SagaProgressClamp clamp)? onClamp,
}) {
final levelsRaw = json['levels'];
final Map<int, LevelProgress> levels = {};
if (levelsRaw is Map) {
for (final e in levelsRaw.entries) {
final rawKey = e.key;
final key = rawKey is int ? rawKey : int.tryParse(rawKey.toString());
if (key == null || e.value is! Map<String, dynamic>) continue;
// A negative level is an impossible state everywhere else in this
// file — the unlock pointer is clamped up to 0, and
// `CompleteLevelUseCase` refuses a negative id whatever the order
// guard says. A negative *key* was the one door left open.
if (key < 0) continue;
final record = LevelProgress.fromJson(e.value as Map<String, dynamic>);
// The key is the identity; the record's own `levelId` is data that
// must agree with it. `{'-5': {'levelId': 7}}` used to load with the
// two never compared, after which code that looked a level up by key
// and code that read `levelId` gave different answers about the same
// record — a silent inconsistency that is painful to reproduce.
// Corrected rather than dropped, matching how every other field here
// is sanitised: a save that loads honest beats one that loses a
// record.
levels[key] =
record.levelId == key ? record : record.copyWith(levelId: key);
}
}
// `is num` guards a wrong-typed value; a negative unlock pointer is an
// impossible state — a tampered save must not make the map think level -5
// is unlocked.
final rawUnlocked = json['currentMaxUnlockedLevelId'];
final unlocked = rawUnlocked is num ? rawUnlocked.toInt() : 0;
final extraRaw = json['extra'];
Map<String, dynamic> extra = {};
if (extraRaw is Map<String, dynamic>) {
extra = Map<String, dynamic>.from(extraRaw);
} else if (extraRaw is Map) {
extra = Map<String, dynamic>.from(extraRaw);
}
// An empty level map means "uninitialised", not "deliberately empty", and
// does not round-trip as one. That is the deliberate choice of the two the
// asymmetry with `extra` invites — `extra: {}` survives, `levels: {}` does
// not. Since 2.0.0 a level with no record reads as locked, so loading an
// empty map faithfully would produce a map on which nothing is tappable:
// an unplayable save is a worse answer to a truncated file than a fresh
// one. Hosts that need "no levels" as a state should carry it in `extra`.
final resolvedLevels =
levels.isNotEmpty ? levels : SagaProgress.initial().levels;
// Reconcile the pointer with the map it points into. A save claiming
// `currentMaxUnlockedLevelId: 9999` next to two recorded levels used to
// load happily, and with `enforceUnlockOrder` on that single integer is the
// only thing standing between a player and completing any level id. The
// rule: the pointer may reach one past the highest *completed* level (the
// successor that completion opens) and no further. Clamped rather than
// thrown, matching the negative-pointer clamp above — a save that will not
// load is worse for a player than one that loads honest.
var highestCompleted = -1;
for (final entry in resolvedLevels.entries) {
if (entry.value.state == LevelCompletionState.completed &&
entry.key > highestCompleted) {
highestCompleted = entry.key;
}
}
final ceiling = highestCompleted + 1;
// A pointer above the earned ceiling still stands if the payload records
// the pointed-at level as reachable in its own right — that is the host
// that deliberately jumped ahead (a chapter-skip purchase, a debug build)
// and wrote the record to prove it. What no longer works is lifting the
// pointer with a record for some *other* level.
final atPointer = resolvedLevels[unlocked];
final selfJustified =
atPointer != null && atPointer.state != LevelCompletionState.locked;
final int clamped;
if (unlocked < 0) {
clamped = 0;
} else if (unlocked > ceiling && !selfJustified) {
clamped = ceiling;
} else {
clamped = unlocked;
}
if (clamped != unlocked) {
onClamp?.call(SagaProgressClamp(
storedPointer: unlocked,
clampedTo: clamped,
completedCeiling: ceiling,
));
}
final loaded = SagaProgress(
currentMaxUnlockedLevelId: clamped,
levels: resolvedLevels,
extra: extra,
);
// Spent stars are bounded by earned ones, for the reason the pointer is
// bounded by completions: a save is player-controlled. A negative
// `spentStars` is a free top-up, and one above the total is a balance
// below zero that no purchase check was written to expect. Clamped rather
// than thrown, matching every other field here — a save that will not load
// is worse for a player than one that loads honest.
final rawSpent = json['spentStars'];
final storedSpent = rawSpent is num ? rawSpent.toInt() : 0;
final earned = loaded.totalStars;
final spent =
storedSpent < 0 ? 0 : (storedSpent > earned ? earned : storedSpent);
return spent == 0 ? loaded : loaded.copyWith(spentStars: spent);
}