Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions lib/collab/convex_strategy_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,12 @@ class ConvexStrategyRepository {
/// strategy whose move finished, kept; or nothing.
enum AbandonedMove { deleted, finished, missing }

/// Whether [error] is the server's answer from running a function that
/// threw, so its transaction wrote nothing; not a lost answer (a timeout,
/// the connection), after which the call may still land.
bool isConvexFunctionError(Object error) =>
error is ConvexFunctionException || error is ConvexClientFunctionError;

bool isTypedConvexUnauthenticatedError(Object error) {
return (error is ConvexFunctionException &&
error.code == ConvexErrorCode.unauthenticated) ||
Expand Down
239 changes: 239 additions & 0 deletions lib/collab/replay_capture_record.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import 'dart:convert';

import 'package:icarus/collab/collab_models.dart';
import 'package:uuid/uuid.dart';

/// Why a replay capture couldn't be saved.
enum CaptureFailureKind {
/// The server refused it. Retry sends it again under new op ids.
refused,

/// Its strategy was deleted. Nothing more can go there.
deleted,

/// Its strategy is on another map now, so the replay's positions would be
/// wrong there.
mapChanged,

/// Its page reached the strategy and was deleted there before all of its
/// items did.
pageDeleted,
}

/// One replay moment captured into a cloud strategy and not yet saved
/// there: the page, everything on it, and every id the server will see,
/// fixed when it was captured. Until it is delivered this is the only copy
/// of the player's capture, so it is kept like library data.
///
/// It is stored as one JSON string in its own box, keyed by [id]. A record
/// this build can't read stays in the box as it is.
class ReplayCaptureRecord {
const ReplayCaptureRecord({
required this.id,
required this.accountId,
required this.strategyId,
required this.clientId,
required this.strategyName,
required this.mapData,
required this.folderId,
required this.themeProfileId,
required this.index,
required this.createsStrategy,
required this.createdAt,
required this.pageId,
required this.pageName,
required this.isAttack,
required this.settings,
required this.pageOpId,
required this.items,
this.pageLanded = false,
this.landedItemOpIds = const {},
this.uncertain = false,
this.failure,
this.failureDetail,
});

static const version = 1;

final String id;

/// The account whose library it goes to. It is sent only while that
/// account is signed in.
final String accountId;

/// The strategy the replay session fills, and the client id its batches
/// are sent under (the server's op dedupe is per client id).
final String strategyId;
final String clientId;

/// What the strategy is made with, if this capture makes it.
final String strategyName;
final String mapData;
final String? folderId;
final String? themeProfileId;

/// Its place among the session's captures. Captures of one strategy are
/// delivered in this order.
final int index;

/// Whether this capture makes the strategy, with its page as the first.
final bool createsStrategy;
final DateTime createdAt;

final String pageId;
final String pageName;
final bool isAttack;
final Map<String, dynamic> settings;

/// The op id the page is added under. A new one is made when the server
/// refused the last (it would replay that refusal otherwise).
final String pageOpId;

/// The page's players and utility, as the ops that add them.
final List<StrategyOp> items;

final bool pageLanded;
final Set<String> landedItemOpIds;

/// The last send's answer was lost (a timeout, the connection): it may
/// have landed. It is sent again as it was.
final bool uncertain;

final CaptureFailureKind? failure;

/// The server's reason, when it refused.
final String? failureDetail;

bool get isFailed => failure != null;

/// Whether some of it may already be in the cloud library.
bool get mayHaveLanded =>
uncertain || pageLanded || landedItemOpIds.isNotEmpty;

List<StrategyOp> get unlandedItems => [
for (final op in items)
if (!landedItemOpIds.contains(op.opId)) op,
];

ReplayCaptureRecord copyWith({
bool? createsStrategy,
String? pageOpId,
List<StrategyOp>? items,
bool? pageLanded,
Set<String>? landedItemOpIds,
bool? uncertain,
CaptureFailureKind? failure,
String? failureDetail,
bool clearFailure = false,
bool clearFolder = false,
}) {
return ReplayCaptureRecord(
id: id,
accountId: accountId,
strategyId: strategyId,
clientId: clientId,
strategyName: strategyName,
mapData: mapData,
folderId: clearFolder ? null : folderId,
themeProfileId: themeProfileId,
index: index,
createsStrategy: createsStrategy ?? this.createsStrategy,
createdAt: createdAt,
pageId: pageId,
pageName: pageName,
isAttack: isAttack,
settings: settings,
pageOpId: pageOpId ?? this.pageOpId,
items: items ?? this.items,
pageLanded: pageLanded ?? this.pageLanded,
landedItemOpIds: landedItemOpIds ?? this.landedItemOpIds,
uncertain: uncertain ?? this.uncertain,
failure: clearFailure ? null : failure ?? this.failure,
failureDetail: clearFailure ? null : failureDetail ?? this.failureDetail,
);
}

/// Ready to send again after the server refused it: whatever didn't land
/// goes under new op ids, since the server answers a repeated op id with
/// its first answer.
ReplayCaptureRecord forRetry() {
const uuid = Uuid();
return copyWith(
pageOpId: pageLanded ? pageOpId : uuid.v4(),
items: [
for (final op in items)
landedItemOpIds.contains(op.opId)
? op
: StrategyOp.fromJson({...op.toConvexJson(), 'opId': uuid.v4()}),
],
uncertain: false,
clearFailure: true,
);
}

String encode() => jsonEncode({
'version': version,
'id': id,
'accountId': accountId,
'strategyId': strategyId,
'clientId': clientId,
'strategyName': strategyName,
'mapData': mapData,
'folderId': folderId,
'themeProfileId': themeProfileId,
'index': index,
'createsStrategy': createsStrategy,
'createdAt': createdAt.toUtc().toIso8601String(),
'pageId': pageId,
'pageName': pageName,
'isAttack': isAttack,
'settings': settings,
'pageOpId': pageOpId,
'items': [for (final op in items) op.toConvexJson()],
'pageLanded': pageLanded,
'landedItemOpIds': landedItemOpIds.toList(),
'uncertain': uncertain,
'failure': failure?.name,
'failureDetail': failureDetail,
});

/// Reads a stored record. Throws on anything this build doesn't know,
/// including a newer version, so the caller keeps it untouched.
static ReplayCaptureRecord decode(String raw) {
final json = jsonDecode(raw) as Map<String, dynamic>;
if (json['version'] != version) {
throw FormatException('Unknown capture record version', json['version']);
}
final failure = json['failure'] as String?;
return ReplayCaptureRecord(
id: json['id'] as String,
accountId: json['accountId'] as String,
strategyId: json['strategyId'] as String,
clientId: json['clientId'] as String,
strategyName: json['strategyName'] as String,
mapData: json['mapData'] as String,
folderId: json['folderId'] as String?,
themeProfileId: json['themeProfileId'] as String?,
index: json['index'] as int,
createsStrategy: json['createsStrategy'] as bool,
createdAt: DateTime.parse(json['createdAt'] as String),
pageId: json['pageId'] as String,
pageName: json['pageName'] as String,
isAttack: json['isAttack'] as bool,
settings: Map<String, dynamic>.from(json['settings'] as Map),
pageOpId: json['pageOpId'] as String,
items: [
for (final op in json['items'] as List)
StrategyOp.fromJson(Map<String, dynamic>.from(op as Map)),
],
pageLanded: json['pageLanded'] as bool,
landedItemOpIds: {
for (final opId in json['landedItemOpIds'] as List) opId as String,
},
uncertain: json['uncertain'] as bool,
failure:
failure == null ? null : CaptureFailureKind.values.byName(failure),
failureDetail: json['failureDetail'] as String?,
);
}
}
1 change: 1 addition & 0 deletions lib/const/hive_boxes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class HiveBoxNames {
static const strategyOutboxBox = "strategy_outbox_box";
static const cloudMediaOutboxBox = "cloud_media_outbox_box";
static const cloudMovesBox = "cloud_moves_box";
static const replayCapturesBox = "replay_captures_box";
static const pendingMediaBytesBox = "pending_media_bytes_box";
static const pinnedItemsBox = "pinned_items_box";
}
3 changes: 3 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import 'package:icarus/services/auth_callback_uri.dart';
import 'package:icarus/services/browser_url.dart';
import 'package:icarus/services/app_error_reporter.dart';
import 'package:icarus/services/analytics_service.dart';
import 'package:icarus/providers/collab/replay_capture_queue_provider.dart';
import 'package:icarus/services/cloud_sign_out_coordinator.dart';
import 'package:icarus/services/discord_presence_service.dart';
import 'package:icarus/services/guarded_sign_out.dart';
Expand Down Expand Up @@ -438,6 +439,8 @@ class _MyAppState extends ConsumerState<MyApp> {
ref.read(cloudMediaCacheProvider);
// Cleans up after a move to the cloud the app quit in the middle of.
ref.read(moveToCloudProvider);
// Sends replay captures the app quit before saving.
ref.read(replayCaptureQueueProvider);

_discordPresence = DiscordPresenceWorker();
_discordStrategySub = ref.listenManual(
Expand Down
13 changes: 10 additions & 3 deletions lib/page_transition/transition_planner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ class TransitionPlanner {
/// Items of [next] paired with an item of [prev] by the id they were
/// copied from (see page_copy_id.dart), next id to prev id. Items with
/// the same id pair first. Then a root pairs two items only when it is on
/// each page exactly once and both are the same kind of item; anything
/// else would be a guess, so those items appear and disappear instead.
/// each page exactly once and both are the same kind of item (see
/// [_kind]); anything else would be a guess, so those items appear and
/// disappear instead.
static Map<String, String> _pairedCopies(
Map<String, PlacedWidget> prev,
Map<String, PlacedWidget> next,
Expand All @@ -114,12 +115,18 @@ class TransitionPlanner {
final prevId = prevIds.single;
if (nextId == prevId) return;
if (next.containsKey(prevId) || prev.containsKey(nextId)) return;
if (next[nextId]!.runtimeType != prev[prevId]!.runtimeType) return;
if (_kind(next[nextId]!) != _kind(prev[prevId]!)) return;
pairs[nextId] = prevId;
});
return pairs;
}

/// What an item is, for pairing copies. An agent is one kind with or
/// without its view cone: a player alive on one page and dead on the
/// next (a replay capture) is the same player.
static Object _kind(PlacedWidget widget) =>
widget is PlacedAgentNode ? PlacedAgentNode : widget.runtimeType;

/// Whether the drawing layer changes between two pages, and therefore
/// whether it should fade in early during the transition. Compares the
/// serialized form so geometry/style edits count, not just added or
Expand Down
Loading
Loading