From 7f731e1406503d9aae2dfdfa916a33e7e48866c3 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 13 Aug 2024 17:12:28 -0400 Subject: [PATCH] base timer off of game state event --- lib/pages/chat/chat.dart | 44 ++--- lib/pages/chat/chat_event_list.dart | 9 +- lib/pages/chat/chat_view.dart | 3 +- lib/pangea/constants/game_constants.dart | 3 + lib/pangea/constants/model_keys.dart | 4 + lib/pangea/constants/pangea_event_types.dart | 2 + lib/pangea/models/game_state_model.dart | 48 ++++++ .../pages/games/story_game/round_model.dart | 123 +++++++++----- lib/pangea/widgets/chat/round_timer.dart | 151 ++++++++++-------- 9 files changed, 261 insertions(+), 126 deletions(-) create mode 100644 lib/pangea/constants/game_constants.dart create mode 100644 lib/pangea/models/game_state_model.dart diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 6e6a7487f..79824d662 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -15,10 +15,12 @@ import 'package:fluffychat/pages/chat/event_info_dialog.dart'; import 'package:fluffychat/pages/chat/recording_dialog.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/models/choreo_record.dart'; +import 'package:fluffychat/pangea/models/game_state_model.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/pages/games/story_game/round_model.dart'; @@ -26,7 +28,6 @@ import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/utils/firebase_analytics.dart'; import 'package:fluffychat/pangea/utils/report_message.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; -import 'package:fluffychat/pangea/widgets/chat/round_timer.dart'; import 'package:fluffychat/pangea/widgets/igc/pangea_text_controller.dart'; import 'package:fluffychat/utils/error_reporter.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; @@ -115,17 +116,9 @@ class ChatController extends State // #Pangea final PangeaController pangeaController = MatrixState.pangeaController; late Choreographer choreographer = Choreographer(pangeaController, this); - final GlobalKey roundTimerStateKey = - GlobalKey(); - RoundTimer? timer; - final List gameRounds = []; - - List get completedRoundEventIds => gameRounds - .where((round) => round.isCompleted) - .map((round) => round.userMessageIDs) - .expand((x) => x) - .toList(); + /// Model of the current story game round + GameRoundModel? currentRound; // Pangea# Room get room => sendingClient.getRoomById(roomId) ?? widget.room; @@ -308,12 +301,22 @@ class ChatController extends State } // #Pangea - void addRound() { - debugPrint("ADDING A ROUND. Rounds so far: ${gameRounds.length}"); - final newRound = GameRoundModel(controller: this, timer: timer!); - gameRounds.add(newRound); - newRound.roundCompleter.future.then((_) { - if (mounted) addRound(); + /// Recursive function that sets the current round, waits for it to + /// finish, sets it, etc. until the chat view is no longer mounted. + void setRound() { + currentRound?.dispose(); + currentRound = GameRoundModel(room: room); + room.client.onRoomState.stream.firstWhere((update) { + if (update.roomId != roomId) return false; + if (update.state is! Event) return false; + if ((update.state as Event).type != PangeaEventTypes.storyGame) { + return false; + } + + final game = GameModel.fromJson((update.state as Event).content); + return game.previousRoundEndTime != null; + }).then((_) { + if (mounted) setRound(); }); } // Pangea# @@ -333,8 +336,7 @@ class ChatController extends State sendingClient = Matrix.of(context).client; WidgetsBinding.instance.addObserver(this); // #Pangea - timer = RoundTimer(key: roundTimerStateKey); - addRound(); + setRound(); if (!mounted) return; Future.delayed(const Duration(seconds: 1), () async { if (!mounted) return; @@ -421,8 +423,7 @@ class ChatController extends State List get visibleEvents => timeline?.events .where( - (x) => - x.isVisibleInGui && !completedRoundEventIds.contains(x.eventId), + (x) => x.isVisibleInGui, ) .toList() ?? []; @@ -560,6 +561,7 @@ class ChatController extends State //#Pangea choreographer.stateListener.close(); choreographer.dispose(); + currentRound?.dispose(); //Pangea# super.dispose(); } diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 7b9003379..1e216094a 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -6,6 +6,7 @@ import 'package:fluffychat/pages/chat/typing_indicators.dart'; import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/pangea/enum/instructions_enum.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/utils/bot_name.dart'; import 'package:fluffychat/pangea/widgets/chat/locked_chat_message.dart'; import 'package:fluffychat/utils/account_config.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; @@ -32,7 +33,13 @@ class ChatEventList extends StatelessWidget { event.isVisibleInGui // #Pangea && - !controller.completedRoundEventIds.contains(event.eventId) + // In story game, hide messages sent by non-bot users in previous round + (event.type != EventTypes.Message || + event.senderId == BotName.byEnvironment || + controller.currentRound?.previousRoundEnd == null || + event.originServerTs.isAfter( + controller.currentRound!.previousRoundEnd!, + )) // Pangea# , ) diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index ce45043eb..876db6e68 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -13,6 +13,7 @@ import 'package:fluffychat/pangea/choreographer/widgets/it_bar.dart'; import 'package:fluffychat/pangea/choreographer/widgets/start_igc_button.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/widgets/chat/chat_floating_action_button.dart'; +import 'package:fluffychat/pangea/widgets/chat/round_timer.dart'; import 'package:fluffychat/utils/account_config.dart'; import 'package:fluffychat/widgets/chat_settings_popup_menu.dart'; import 'package:fluffychat/widgets/connection_status_header.dart'; @@ -119,7 +120,7 @@ class ChatView extends StatelessWidget { // #Pangea } else { return [ - controller.timer ?? const SizedBox(), + RoundTimer(controller: controller), const SizedBox( width: 10, ), diff --git a/lib/pangea/constants/game_constants.dart b/lib/pangea/constants/game_constants.dart new file mode 100644 index 000000000..6b0b22fbb --- /dev/null +++ b/lib/pangea/constants/game_constants.dart @@ -0,0 +1,3 @@ +class GameConstants { + static const int timerMaxSeconds = 120; +} diff --git a/lib/pangea/constants/model_keys.dart b/lib/pangea/constants/model_keys.dart index b42061446..e427cf098 100644 --- a/lib/pangea/constants/model_keys.dart +++ b/lib/pangea/constants/model_keys.dart @@ -119,4 +119,8 @@ class ModelKey { static const String prevEventId = "prev_event_id"; static const String prevLastUpdated = "prev_last_updated"; + + static const String gameState = "game_state"; + static const String currentRoundStartTime = "start_time"; + static const String previousRoundEndTime = "message_visible_from"; } diff --git a/lib/pangea/constants/pangea_event_types.dart b/lib/pangea/constants/pangea_event_types.dart index 9ca975dc0..ab5d655a7 100644 --- a/lib/pangea/constants/pangea_event_types.dart +++ b/lib/pangea/constants/pangea_event_types.dart @@ -35,4 +35,6 @@ class PangeaEventTypes { /// A record of completion of an activity. There /// can be one per user per activity. static const activityRecord = "pangea.activity_completion"; + + static const storyGame = "p.game.story"; } diff --git a/lib/pangea/models/game_state_model.dart b/lib/pangea/models/game_state_model.dart new file mode 100644 index 000000000..12e1bb695 --- /dev/null +++ b/lib/pangea/models/game_state_model.dart @@ -0,0 +1,48 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/constants/model_keys.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:flutter/foundation.dart'; +import 'package:matrix/matrix_api_lite/generated/model.dart'; + +class GameModel { + DateTime? currentRoundStartTime; + DateTime? previousRoundEndTime; + + GameModel({ + this.currentRoundStartTime, + this.previousRoundEndTime, + }); + + factory GameModel.fromJson(json) { + return GameModel( + currentRoundStartTime: json[ModelKey.currentRoundStartTime] != null + ? DateTime.parse(json[ModelKey.currentRoundStartTime]) + : null, + previousRoundEndTime: json[ModelKey.previousRoundEndTime] != null + ? DateTime.parse(json[ModelKey.previousRoundEndTime]) + : null, + ); + } + + Map toJson() { + final data = {}; + try { + data[ModelKey.currentRoundStartTime] = + currentRoundStartTime?.toIso8601String(); + data[ModelKey.previousRoundEndTime] = + previousRoundEndTime?.toIso8601String(); + return data; + } catch (e, s) { + debugger(when: kDebugMode); + ErrorHandler.logError(e: e, s: s); + return data; + } + } + + StateEvent get toStateEvent => StateEvent( + content: toJson(), + type: PangeaEventTypes.storyGame, + ); +} diff --git a/lib/pangea/pages/games/story_game/round_model.dart b/lib/pangea/pages/games/story_game/round_model.dart index c192c36a6..3b43b86b5 100644 --- a/lib/pangea/pages/games/story_game/round_model.dart +++ b/lib/pangea/pages/games/story_game/round_model.dart @@ -1,62 +1,84 @@ import 'dart:async'; -import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/constants/game_constants.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/extensions/sync_update_extension.dart'; +import 'package:fluffychat/pangea/models/game_state_model.dart'; import 'package:fluffychat/pangea/utils/bot_name.dart'; -import 'package:fluffychat/pangea/widgets/chat/round_timer.dart'; -import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; -enum RoundState { notStarted, inProgress, completed } - +/// A model of a game round. Manages the round's state and duration. class GameRoundModel { - static const int timerMaxSeconds = 180; - final String adminName = BotName.byEnvironment; + final Duration roundDuration = const Duration( + seconds: GameConstants.timerMaxSeconds, + ); + + final Room room; - final ChatController controller; - final Completer roundCompleter = Completer(); + // All the below state variables are used for sending and managing + // round start and end times. Once the bot starts doing that, they should be removed. late DateTime createdAt; - RoundTimer timer; - DateTime? startTime; - DateTime? endTime; - RoundState state = RoundState.notStarted; + Timer? timer; StreamSubscription? syncSubscription; final List userMessageIDs = []; final List botMessageIDs = []; GameRoundModel({ - required this.controller, - required this.timer, + required this.room, }) { createdAt = DateTime.now(); - syncSubscription ??= client.onSync.stream.listen(_handleSync); + + // if, on creation, the current round is already ongoing, + // start the timer (or reset it if the round went over) + if (currentRoundStart != null) { + final currentRoundDuration = DateTime.now().difference( + currentRoundStart!, + ); + final roundFinished = currentRoundDuration > roundDuration; + + if (roundFinished) { + endRound(); + } + } + + // listen to syncs for new bot messages to start and stop rounds + syncSubscription ??= room.client.onSync.stream.listen(_handleSync); } + GameModel get gameState => GameModel.fromJson( + room.getState(PangeaEventTypes.storyGame)?.content ?? {}, + ); + + DateTime? get currentRoundStart => gameState.currentRoundStartTime; + DateTime? get previousRoundEnd => gameState.previousRoundEndTime; + void _handleSync(SyncUpdate update) { final newMessages = update - .messages(controller.room) + .messages(room) .where((msg) => msg.originServerTs.isAfter(createdAt)) .toList(); - final botMessages = - newMessages.where((msg) => msg.senderId == adminName).toList(); - final userMessages = - newMessages.where((msg) => msg.senderId != adminName).toList(); + final botMessages = newMessages + .where((msg) => msg.senderId == BotName.byEnvironment) + .toList(); + final userMessages = newMessages + .where((msg) => msg.senderId != BotName.byEnvironment) + .toList(); final hasNewBotMessage = botMessages.any( (msg) => !botMessageIDs.contains(msg.eventId), ); if (hasNewBotMessage) { - if (state == RoundState.notStarted) { + if (currentRoundStart == null) { startRound(); - } else if (state == RoundState.inProgress) { + } else { endRound(); return; } } - if (state == RoundState.inProgress) { + if (currentRoundStart != null) { for (final message in botMessages) { if (!botMessageIDs.contains(message.eventId)) { botMessageIDs.add(message.eventId); @@ -71,32 +93,53 @@ class GameRoundModel { } } - Client get client => controller.pangeaController.matrixState.client; + /// Set the start and end times of the current and previous rounds. + Future setRoundTimes({ + DateTime? currentRoundStart, + DateTime? previousRoundEnd, + }) async { + final game = GameModel.fromJson( + room.getState(PangeaEventTypes.storyGame)?.content ?? {}, + ); - bool get isCompleted => roundCompleter.isCompleted; + game.currentRoundStartTime = currentRoundStart; + game.previousRoundEndTime = previousRoundEnd; - void startRound() { - debugPrint("starting round"); - state = RoundState.inProgress; - startTime = DateTime.now(); - controller.roundTimerStateKey.currentState?.resetTimer( - roundLength: timerMaxSeconds, + await room.client.setRoomStateWithKey( + room.id, + PangeaEventTypes.storyGame, + '', + game.toJson(), ); - controller.roundTimerStateKey.currentState?.startTimer(); } + /// Start a new round. + void startRound() { + setRoundTimes( + currentRoundStart: DateTime.now(), + previousRoundEnd: null, + ).then((_) => timer = Timer(roundDuration, endRound)); + } + + /// End and cleanup after the current round. void endRound() { - debugPrint( - "ending round, user message IDs: $userMessageIDs, bot message IDs: $botMessageIDs", - ); - endTime = DateTime.now(); - state = RoundState.completed; - controller.roundTimerStateKey.currentState?.resetTimer(); syncSubscription?.cancel(); - roundCompleter.complete(); + syncSubscription = null; + + timer?.cancel(); + timer = null; + + setRoundTimes( + currentRoundStart: null, + previousRoundEnd: DateTime.now(), + ); } void dispose() { syncSubscription?.cancel(); + syncSubscription = null; + + timer?.cancel(); + timer = null; } } diff --git a/lib/pangea/widgets/chat/round_timer.dart b/lib/pangea/widgets/chat/round_timer.dart index 83641983d..5153fee12 100644 --- a/lib/pangea/widgets/chat/round_timer.dart +++ b/lib/pangea/widgets/chat/round_timer.dart @@ -1,17 +1,20 @@ import 'dart:async'; +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/constants/game_constants.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/models/game_state_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; /// Create a timer that counts down to the given time /// Default duration is 180 seconds class RoundTimer extends StatefulWidget { - final int timerMaxSeconds; - final Duration roundDuration; - + final ChatController controller; const RoundTimer({ super.key, - this.timerMaxSeconds = 180, - this.roundDuration = const Duration(seconds: 1), + required this.controller, }); @override @@ -20,90 +23,112 @@ class RoundTimer extends StatefulWidget { class RoundTimerState extends State { int currentSeconds = 0; - Timer? _timer; - bool isTiming = false; - Duration? duration; - int timerMaxSeconds = 180; - - void resetTimer({Duration? roundDuration, int? roundLength}) { - if (_timer != null) { - _timer!.cancel(); - isTiming = false; - } - if (roundDuration != null) { - duration = roundDuration; - } - if (roundLength != null) { - timerMaxSeconds = roundLength; - } - setState(() { - currentSeconds = 0; - }); - } + Timer? timer; + StreamSubscription? stateSubscription; - int get remainingTime => timerMaxSeconds - currentSeconds; + @override + void initState() { + super.initState(); - String get timerText => - '${(remainingTime ~/ 60).toString().padLeft(2, '0')}: ${(remainingTime % 60).toString().padLeft(2, '0')}'; + final roundStartTime = widget.controller.currentRound?.currentRoundStart; + if (roundStartTime != null) { + final roundDuration = DateTime.now().difference(roundStartTime).inSeconds; + if (roundDuration > GameConstants.timerMaxSeconds) return; - startTimer() { - _timer = Timer.periodic(duration ?? widget.roundDuration, (timer) { - setState(() { + currentSeconds = roundDuration; + timer = Timer.periodic(const Duration(seconds: 1), (Timer t) { currentSeconds++; - if (currentSeconds >= timerMaxSeconds) timer.cancel(); + if (currentSeconds >= GameConstants.timerMaxSeconds) { + t.cancel(); + } + setState(() {}); }); - }); - setState(() { - isTiming = true; - }); + } + + stateSubscription = Matrix.of(context) + .client + .onRoomState + .stream + .where(isRoundUpdate) + .listen(onRoundUpdate); } - stopTimer() { - if (_timer != null) { - _timer!.cancel(); - } - setState(() { - isTiming = false; - }); + bool isRoundUpdate(update) { + return update.roomId == widget.controller.room.id && + update.state is Event && + (update.state as Event).type == PangeaEventTypes.storyGame; } - @override - void initState() { - duration = widget.roundDuration; - timerMaxSeconds = widget.timerMaxSeconds; - super.initState(); + void onRoundUpdate(update) { + final GameModel gameState = GameModel.fromJson( + (update.state as Event).content, + ); + final startTime = gameState.currentRoundStartTime; + final endTime = gameState.previousRoundEndTime; + + if (startTime == null && endTime == null) return; + timer?.cancel(); + timer = null; + + // if this update is the start of a round + if (startTime != null) { + timer = Timer.periodic(const Duration(seconds: 1), (Timer t) { + currentSeconds++; + if (currentSeconds >= GameConstants.timerMaxSeconds) { + t.cancel(); + } + setState(() {}); + }); + return; + } + + // if this update is the end of a round + currentSeconds = 0; + setState(() {}); } @override void dispose() { - if (_timer != null) { - _timer!.cancel(); - } super.dispose(); + + stateSubscription?.cancel(); + stateSubscription = null; + + timer?.cancel(); + timer = null; } + int get remainingTime => GameConstants.timerMaxSeconds - currentSeconds; + + String get timerText => + '${(remainingTime ~/ 60).toString().padLeft(2, '0')}: ${(remainingTime % 60).toString().padLeft(2, '0')}'; + @override Widget build(BuildContext context) { return Material( color: const Color.fromARGB(255, 126, 22, 14), child: Padding( - padding: const EdgeInsets.all( - 5, - ), + padding: const EdgeInsets.all(5), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(timerText), - // Row( - // crossAxisAlignment: CrossAxisAlignment.center, - // children: [ - // IconButton( - // onPressed: isTiming ? stopTimeout : startTimeout, - // icon: Icon(isTiming ? Icons.pause_circle : Icons.play_circle), - // ), - // ], - // ), + const Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // IconButton( + // onPressed: widget.currentRound.timer == null + // ? widget.currentRound.startRound + // : null, + // icon: Icon( + // widget.currentRound.timer != null + // ? Icons.pause_circle + // : Icons.play_circle, + // ), + // ), + ], + ), ], ), ),