From 5c6e5455722c3966455b1032b53eb0fe59ab3c21 Mon Sep 17 00:00:00 2001 From: mytja Date: Wed, 1 Jan 2025 00:47:59 +0100 Subject: [PATCH] feat: app: add game log Resolves https://github.com/mytja/Tarok/issues/62 --- tarok/lib/constants.dart | 1 + tarok/lib/game/game.dart | 4 +- tarok/lib/game/game_controller.dart | 761 ++++++++++++++++-- tarok/lib/game_log/game_log_card_widget.dart | 66 ++ tarok/lib/game_log/game_log_event_widget.dart | 95 +++ tarok/lib/game_log/game_log_object.dart | 117 +++ tarok/lib/game_log/game_log_tab.dart | 375 +++++++++ tarok/lib/internationalization/languages.dart | 188 +++++ tarok/lib/main.dart | 4 + tarok/lib/settings.dart | 82 +- tarok/lib/stub/game_log_tab_stub.dart | 11 + tarok/lib/stub/path_provider.dart | 5 + tarok/lib/ui/main_page.dart | 9 + tarok/pubspec.lock | 6 +- tarok/pubspec.yaml | 3 + 15 files changed, 1647 insertions(+), 80 deletions(-) create mode 100644 tarok/lib/game_log/game_log_card_widget.dart create mode 100644 tarok/lib/game_log/game_log_event_widget.dart create mode 100644 tarok/lib/game_log/game_log_object.dart create mode 100644 tarok/lib/game_log/game_log_tab.dart create mode 100644 tarok/lib/stub/game_log_tab_stub.dart create mode 100644 tarok/lib/stub/path_provider.dart diff --git a/tarok/lib/constants.dart b/tarok/lib/constants.dart index b049f23..0269baa 100644 --- a/tarok/lib/constants.dart +++ b/tarok/lib/constants.dart @@ -53,6 +53,7 @@ bool COUNTERCLOCKWISE_GAME = false; bool POINTS_TOOLTIP = false; bool SHOW_EVALUATION = true; bool SHOW_MOST_POWERFUL_CARD = false; +bool SAVE_GAME_LOGS = false; String THEME = ""; Locale LOCALE = Get.deviceLocale ?? const Locale("sl", "SI"); diff --git a/tarok/lib/game/game.dart b/tarok/lib/game/game.dart index 5114787..640d3f7 100644 --- a/tarok/lib/game/game.dart +++ b/tarok/lib/game/game.dart @@ -96,10 +96,10 @@ class Game extends StatelessWidget { width: fullWidth / 5.5, child: DefaultTabController( length: controller.replay - ? 7 - + ? 9 - (DEVELOPER_MODE ? 0 : 1) - (controller.bots ? 2 : 0) - : 6 - + : 7 - (DEVELOPER_MODE ? 0 : 1) - (controller.bots ? 2 : 0), child: Scaffold( diff --git a/tarok/lib/game/game_controller.dart b/tarok/lib/game/game_controller.dart index e10707c..f92cba0 100644 --- a/tarok/lib/game/game_controller.dart +++ b/tarok/lib/game/game_controller.dart @@ -27,10 +27,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_initicon/flutter_initicon.dart'; import 'package:get/get.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:rounded_background_text/rounded_background_text.dart'; +import 'package:safe_local_storage/safe_local_storage.dart'; import 'package:stockskis/stockskis.dart' as stockskis; import 'package:tarok/constants.dart'; import 'package:tarok/game/variables.dart'; +import 'package:tarok/game_log/game_log_object.dart'; import 'package:tarok/messages/messages.pb.dart' as Messages; import 'package:tarok/sounds.dart'; import 'package:tarok/stockskis_compatibility/interfaces/predictions.dart'; @@ -38,6 +41,7 @@ import 'package:tarok/stockskis_compatibility/interfaces/results.dart'; import 'package:tarok/stockskis_compatibility/interfaces/start_predictions.dart'; import 'package:tarok/timer.dart'; import 'package:twemoji_v2/twemoji_v2.dart'; +import 'package:uuid/uuid.dart'; import 'package:web_socket_client/web_socket_client.dart'; class GameController extends GetxController { @@ -126,6 +130,8 @@ class GameController extends GetxController { late WebSocket socket; + List gameLog = []; + /* INIT FUNCTIONS */ @@ -440,22 +446,38 @@ class GameController extends GetxController { } void bKlopTalon() { - if (stockskisContext!.gamemode == -1 && - stockskisContext!.talon.isNotEmpty) { - stockskis.Card card = stockskisContext!.talon.first; - stockskisContext!.talon.removeAt(0); - card.user = "talon"; - stockskisContext!.stihi.last.add(card); - cardStih.add(card.card.asset); - stih.add(CardWidget( - position: 100, - widget: Image( - image: AssetImage("assets/tarok${card.card.asset}.webp"), - ), - angle: (Random().nextDouble() - 0.5) / ANGLE, - asset: card.card.asset, - )); + if (stockskisContext!.gamemode != -1 || stockskisContext!.talon.isEmpty) { + return; } + + stockskis.Card card = stockskisContext!.talon.first; + stockskisContext!.talon.removeAt(0); + + // game log: Card dropped + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_DROPPED, + userId: "system", + userName: "Talon", + isPlaying: false, + userType: 2, + userCards: [], + action: stockskisContext!.stihiCount, + card: card.card.asset, + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + + card.user = "talon"; + stockskisContext!.stihi.last.add(card); + cardStih.add(card.card.asset); + stih.add(CardWidget( + position: 100, + widget: Image( + image: AssetImage("assets/tarok${card.card.asset}.webp"), + ), + angle: (Random().nextDouble() - 0.5) / ANGLE, + asset: card.card.asset, + )); } String getKontraPlayerFormatted(String kontraType) { @@ -594,51 +616,67 @@ class GameController extends GetxController { Future predict() async { if (currentPredictions.value == null) return; + + List changed = []; + if (trula.value) { currentPredictions.value!.trula = Messages.User(id: playerId.value, name: name); + changed.add("trula/prediction"); } if (kralji.value) { currentPredictions.value!.kralji = Messages.User(id: playerId.value, name: name); + changed.add("kings/prediction"); } if (kraljUltimo.value) { currentPredictions.value!.kraljUltimo = Messages.User(id: playerId.value, name: name); + changed.add("king_ultimo/prediction"); } if (pagatUltimo.value) { currentPredictions.value!.pagatUltimo = Messages.User(id: playerId.value, name: name); + changed.add("pagat_ultimo/prediction"); } if (valat.value) { currentPredictions.value!.valat = Messages.User(id: playerId.value, name: name); + changed.add("valat/prediction"); } if (barvic.value) { currentPredictions.value!.barvniValat = Messages.User(id: playerId.value, name: name); + changed.add("color_valat/prediction"); } if (mondfang.value) { currentPredictions.value!.mondfang = Messages.User(id: playerId.value, name: name); + changed.add("mondfang/prediction"); } // kontre dal if (kontraKralj.value) { currentPredictions.value!.kraljUltimoKontraDal = Messages.User(id: playerId.value, name: name); + changed.add( + "king_ultimo/kontra${currentPredictions.value!.kraljUltimoKontra}"); } if (kontraPagat.value) { currentPredictions.value!.pagatUltimoKontraDal = Messages.User(id: playerId.value, name: name); + changed.add( + "pagat_ultimo/kontra${currentPredictions.value!.pagatUltimoKontra}"); } if (kontraIgra.value) { currentPredictions.value!.igraKontraDal = Messages.User(id: playerId.value, name: name); + changed.add("game/kontra${currentPredictions.value!.igraKontra}"); } if (kontraMondfang.value) { currentPredictions.value!.mondfangKontraDal = Messages.User(id: playerId.value, name: name); + changed.add("mondfang/kontra${currentPredictions.value!.mondfangKontra}"); } currentPredictions.value!.changed = kontraMondfang.value || @@ -660,6 +698,20 @@ class GameController extends GetxController { stockskisContext!.predictions = PredictionsCompLayer.messagesToStockSkis(currentPredictions.value!); myPredictions.value = null; + // game log: User predicted + stockskis.User u = stockskisContext!.users["player"]!; + gameLog.add(GameLog( + actionType: GameLogTypes.USER_PREDICTED, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: 0, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: -1, + card: "", + additionalData: changed, + actionTime: DateTime.now().millisecondsSinceEpoch, + )); await bPredict(bAfterPlayer()); return; } @@ -689,9 +741,55 @@ class GameController extends GetxController { if (card.card.asset != stashedCards[i].asset) continue; stockskisContext!.stihi[0].add(card); stockskisContext!.users["player"]!.cards.removeAt(n); + // game log: One card stashed + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_STASHED, + userId: "player", + userName: stockskisContext!.users["player"]!.user.name, + isPlaying: true, + userType: 1, + userCards: stockskisContext!.users["player"]!.cards + .map((e) => e.card.asset) + .toList(), + action: -1, + card: card.card.asset, + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); break; } } + // game log: Card stashing done + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_STASHING_DONE, + userId: "player", + userName: stockskisContext!.users["player"]!.user.name, + isPlaying: true, + userType: 0, + userCards: stockskisContext!.users["player"]!.cards + .map((e) => e.card.asset) + .toList(), + action: stashedCards.length, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + // game log: Talon closed + gameLog.add(GameLog( + actionType: GameLogTypes.TALON_CLOSED, + userId: "player", + userName: stockskisContext!.users["player"]!.user.name, + isPlaying: true, + userType: 0, + userCards: stockskisContext!.users["player"]!.cards + .map((e) => e.card.asset) + .toList(), + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + stockskisContext!.stihi.add([]); stash.value = false; turn.value = false; @@ -740,6 +838,21 @@ class GameController extends GetxController { cards.remove(card); turn.value = false; + // game log: Card dropped + stockskis.User u = stockskisContext!.users["player"]!; + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_DROPPED, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: 0, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: stockskisContext!.stihiCount, + card: card.asset, + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + bool early = await addToStih("player", "player", card.asset); if (early) return; @@ -792,6 +905,20 @@ class GameController extends GetxController { break; } } + + stockskis.User u = stockskisContext!.users["player"]!; + gameLog.add(GameLog( + actionType: GameLogTypes.USER_LICITATED_SOMETHING, + userId: "player", + userName: u.user.name, + isPlaying: false, + userType: 0, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: game.id, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); int next = bAfterPlayer(); bLicitate(next); removeInvalidGames("player", game.id); @@ -819,6 +946,22 @@ class GameController extends GetxController { stockskisContext!.talon.removeAt(n); break; } + // game log: Card dropped + stockskis.User u = stockskisContext!.users["player"]!; + gameLog.add(GameLog( + actionType: GameLogTypes.TALON_SELECTED, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: 0, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: -1, + card: c.asset, + additionalData: + stockskisContext!.talon.map((e) => e.card.asset).toList(), + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + cards.add(c); } sortCards(); @@ -830,6 +973,23 @@ class GameController extends GetxController { kingSelect.value = false; kingSelection.value = false; debugPrint("$stashAmount"); + + // game log: Card stashing started + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_STASHING_STARTED, + userId: "player", + userName: stockskisContext!.users["player"]!.user.name, + isPlaying: true, + userType: 0, + userCards: stockskisContext!.users["player"]!.cards + .map((e) => e.card.asset) + .toList(), + action: stashAmount.value, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + validCards(); return; } @@ -847,6 +1007,21 @@ class GameController extends GetxController { kingSelect.value = false; if (bots) { + // game log: Card dropped + stockskis.User u = stockskisContext!.users["player"]!; + gameLog.add(GameLog( + actionType: GameLogTypes.KING_SELECTED_KING_SELECTION_ENDED, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: 0, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: -1, + card: king, + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + selectedKing.value = king; stockskisContext!.selectSecretlyPlaying(king); await bStartTalon("player"); @@ -1672,12 +1847,60 @@ class GameController extends GetxController { } } + Future saveGameLog() async { + if (kIsWeb) return; + var directory = await getApplicationDocumentsDirectory(); + directory = Directory("${directory.path}/PalckaGames"); + await directory.create(); + var uuid = const Uuid(); + String uid = uuid.v4(); + final storage = SafeLocalStorage("${directory.path}/gamelog-$uid.json"); + await storage.write(jsonEncode(gameLog, + toEncodable: (Object? value) => value is GameLog + ? value.toJson() + : throw UnsupportedError('Cannot convert to JSON: $value'))); + } + + void resetGameLog() { + gameLog = []; + } + /* IGRE Z BOTI */ Future bStartGame() async { logger.d("bStartGame() called"); + if (gameLog.isNotEmpty) { + // Igra se je končala + gameLog.add(GameLog( + actionType: GameLogTypes.GAME_END, + userId: "system", + userName: "", + isPlaying: false, + userType: 2, + userCards: [], + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + await saveGameLog(); + resetGameLog(); + } + gameLog.add(GameLog( + actionType: GameLogTypes.GAME_START, + userId: "system", + userName: "", + isPlaying: false, + userType: 2, + userCards: [], + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + selectedKing.value = ""; premovedCard.value = null; licitiranje.value = true; @@ -1819,6 +2042,22 @@ class GameController extends GetxController { games.removeAt(1); } + // game log: Start licitation + gameLog.add(GameLog( + actionType: GameLogTypes.START_LICITATION, + userId: users[0].id, + userName: users[0].name, + isPlaying: false, + userType: users[0].id == "player" ? 0 : 1, + userCards: stockskisContext!.users[users[0].id]!.cards + .map((e) => e.card.asset) + .toList(), + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + bLicitate(0); } @@ -1864,11 +2103,44 @@ class GameController extends GetxController { PredictionsCompLayer.messagesToStockSkis( currentPredictions.value!); licitiranje.value = false; + + // game log: Licitations done + gameLog.add(GameLog( + actionType: GameLogTypes.LICITATIONS_DONE, + userId: users[i].id, + userName: users[i].name, + isPlaying: false, + userType: users[i].id == "player" ? 0 : 1, + userCards: stockskisContext!.users[users[i].id]!.cards + .map((e) => e.card.asset) + .toList(), + action: m, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + await bStartKingSelection(users[i].id); return; } } if (m == -1) { + // game log: Licitations done + gameLog.add(GameLog( + actionType: GameLogTypes.LICITATIONS_DONE, + userId: users.last.id, + userName: users.last.name, + isPlaying: false, + userType: users.last.id == "player" ? 0 : 1, + userCards: stockskisContext!.users[users.last.id]!.cards + .map((e) => e.card.asset) + .toList(), + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + debugPrint("začenjam rundo klopa"); // začnemo klopa pri obveznem stockskisContext!.users[users.last.id]!.licitiral = true; @@ -1881,6 +2153,24 @@ class GameController extends GetxController { currentPredictions.value!.gamemode = -1; stockskisContext!.predictions = PredictionsCompLayer.messagesToStockSkis(currentPredictions.value!); + + stockskis.User u = + stockskisContext!.users[stockskisContext!.userPositions.first]!; + + // game log: Predictions started + gameLog.add(GameLog( + actionType: GameLogTypes.PREDICTIONS_STARTED, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: 1, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + bPredict(0); } return; @@ -1945,6 +2235,23 @@ class GameController extends GetxController { stockskisContext!.predictions = PredictionsCompLayer.messagesToStockSkis( currentPredictions.value!); + + // game log: Licitations done + gameLog.add(GameLog( + actionType: GameLogTypes.LICITATIONS_DONE, + userId: users[n].id, + userName: users[n].name, + isPlaying: false, + userType: users[n].id == "player" ? 0 : 1, + userCards: stockskisContext!.users[users[n].id]!.cards + .map((e) => e.card.asset) + .toList(), + action: stockskisContext!.gamemode, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + await bStartKingSelection(users[n].id); }); return; @@ -1955,6 +2262,22 @@ class GameController extends GetxController { if (users[n].id == user.id) { if (botSuggestions.isEmpty) { + // game log: User licitated something + gameLog.add(GameLog( + actionType: GameLogTypes.USER_LICITATED_SOMETHING, + userId: user.id, + userName: user.name, + isPlaying: false, + userType: user.id == "player" ? 0 : 1, + userCards: stockskisContext!.users[user.id]!.cards + .map((e) => e.card.asset) + .toList(), + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + users[n].licitiral = -1; } else { bool canLicitate = true; @@ -1969,8 +2292,26 @@ class GameController extends GetxController { } } if (canLicitate) { - users[n].licitiral = botSuggestions.last; - stockskisContext!.gamemode = botSuggestions.last; + int licitated = botSuggestions.last; + users[n].licitiral = licitated; + stockskisContext!.gamemode = licitated; + + // game log: User licitated something + gameLog.add(GameLog( + actionType: GameLogTypes.USER_LICITATED_SOMETHING, + userId: user.id, + userName: user.name, + isPlaying: false, + userType: user.id == "player" ? 0 : 1, + userCards: stockskisContext!.users[user.id]!.cards + .map((e) => e.card.asset) + .toList(), + action: licitated, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + removeInvalidGames( "player", botSuggestions.last, @@ -1978,6 +2319,22 @@ class GameController extends GetxController { imaPrednost: isPlayerMandatory("player"), ); } else { + // game log: User licitated something + gameLog.add(GameLog( + actionType: GameLogTypes.USER_LICITATED_SOMETHING, + userId: user.id, + userName: user.name, + isPlaying: false, + userType: user.id == "player" ? 0 : 1, + userCards: stockskisContext!.users[user.id]!.cards + .map((e) => e.card.asset) + .toList(), + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + users[n].licitiral = -1; } } @@ -2071,6 +2428,21 @@ class GameController extends GetxController { int len = stockskisContext!.talon.length; for (int i = 0; i < len; i++) { stockskis.Card karta = stockskisContext!.talon[0]; + + // game log: Card dropped + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_DROPPED, + userId: "system", + userName: "Talon", + isPlaying: false, + userType: 2, + userCards: [], + action: stockskisContext!.stihiCount, + card: karta.card.asset, + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + debugPrint( "Dodeljujem karto ${karta.card.asset} z vrednostjo ${karta.card.worth} zarufancu. stockskisContext!.stihi[0].length = ${stockskisContext!.stihi[0].length}", ); @@ -2084,34 +2456,64 @@ class GameController extends GetxController { bKlopTalon(); - await Future.delayed(Duration(milliseconds: CARD_CLEANUP_DELAY), () { - List zadnjiStih = stockskisContext!.stihi.last; - if (zadnjiStih.isEmpty) return; - String pickedUpBy = stockskisContext!.stihPickedUpBy(zadnjiStih); - int k = 0; - for (int i = 1; i < users.length; i++) { - if (users[i].id == pickedUpBy) { - k = i; - break; - } + await Future.delayed(Duration(milliseconds: CARD_CLEANUP_DELAY), () {}); + + List zadnjiStih = stockskisContext!.stihi.last; + if (zadnjiStih.isEmpty) return; + String pickedUpBy = stockskisContext!.stihPickedUpBy(zadnjiStih); + int k = 0; + for (int i = 1; i < users.length; i++) { + if (users[i].id == pickedUpBy) { + k = i; + break; } - final analysis = stockskis.StockSkis.analyzeStih( - zadnjiStih, - getGamemode(), - ); - debugPrint( - "Cleaning. Picked up by $pickedUpBy. ${analysis!.cardPicks.card.asset}/${analysis.cardPicks.user}", - ); + } + final analysis = stockskis.StockSkis.analyzeStih( + zadnjiStih, + getGamemode(), + ); + debugPrint( + "Cleaning. Picked up by $pickedUpBy. ${analysis!.cardPicks.card.asset}/${analysis.cardPicks.user}", + ); - // preveri, kdo je dubu ta štih in naj on začne - stih.value = []; - cardStih.value = []; - stihBoolValues.value = {}; - firstCard.value = null; - stockskisContext!.stihi.add([]); - validCards(); - bPlay(k); - }); + // game log: Card round done + stockskis.User u = stockskisContext!.users[pickedUpBy]!; + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_ROUND_DONE, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: u.botType == "NAB" ? 0 : 1, + userCards: [], + action: stockskisContext!.stihiCount, + card: analysis.cardPicks.card.asset, + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + if (u.cards.isNotEmpty) { + // game log: Card round started + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_ROUND_STARTED, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: u.botType == "NAB" ? 0 : 1, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: stockskisContext!.stihiCount + 1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + } + + // preveri, kdo je dubu ta štih in naj on začne + stih.value = []; + cardStih.value = []; + stihBoolValues.value = {}; + firstCard.value = null; + stockskisContext!.stihi.add([]); + validCards(); + bPlay(k); } Future bPlay(int startAt) async { @@ -2136,6 +2538,8 @@ class GameController extends GetxController { bResults(); return; } + + // User is placing a card if (pos.user.id == "player") { if (pos.cards.length == 1) { logger.d( @@ -2157,6 +2561,8 @@ class GameController extends GetxController { } return; } + + // Bots are placing a card List moves = stockskisContext!.evaluateMoves(pos.user.id); //print(moves); //print(stockskisContext!.stihi.last); @@ -2172,6 +2578,22 @@ class GameController extends GetxController { } stockskisContext!.stihi.last.add(bestMove.card); stockskisContext!.users[pos.user.id]!.cards.remove(bestMove.card); + + // game log: Card dropped + stockskis.User u = stockskisContext!.users[pos.user.id]!; + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_DROPPED, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: 1, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: stockskisContext!.stihiCount, + card: bestMove.card.card.asset, + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + await Future.delayed(Duration(milliseconds: BOT_DELAY), () async {}); debugPrint("Dodajam v štih"); Sounds.card(); @@ -2221,17 +2643,63 @@ class GameController extends GetxController { if (sinceLastPrediction >= playingCount.value) { logger.i("Gamemode: ${stockskisContext!.gamemode}"); + int pp = users.length - 1; if (stockskisContext!.gamemode >= 6) { - bPlay(stockskisContext!.playingPerson()); - return; + pp = stockskisContext!.playingPerson(); } - bPlay(users.length - 1); + + stockskis.User u = + stockskisContext!.users[stockskisContext!.userPositions[pp]]!; + // game log: Predictions done + gameLog.add(GameLog( + actionType: GameLogTypes.PREDICTIONS_DONE, + userId: "system", + userName: "", + isPlaying: false, + userType: 2, + userCards: [], + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + // game log: Card game started + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_GAME_STARTED, + userId: "system", + userName: "", + isPlaying: false, + userType: 2, + userCards: [], + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + // game log: Card round started + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_ROUND_STARTED, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: u.botType == "NAB" ? 0 : 1, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: 1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + + bPlay(pp); return; } + stockskis.User u = stockskisContext!.users[stockskisContext!.userPositions[k]]!; debugPrint( "User with ID ${u.user.id}. k=$k, sinceLastPrediction=$sinceLastPrediction"); + + // Player is predicting if (u.user.id == "player") { myPredictions.value = StartPredictionsCompLayer.stockSkisToMessages( stockskisContext!.getStartPredictions("player"), @@ -2240,10 +2708,27 @@ class GameController extends GetxController { sinceLastPrediction++; return; } - bool changed = stockskisContext!.predict(u.user.id); - if (changed) { + + // Bots are predicting + List changed = stockskisContext!.predict(u.user.id); + if (changed.isNotEmpty) { sinceLastPrediction.value = 0; } + + // game log: User predicted + gameLog.add(GameLog( + actionType: GameLogTypes.USER_PREDICTED, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: 1, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: -1, + card: "", + additionalData: changed, + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + currentPredictions.value = PredictionsCompLayer.stockSkisToMessages( stockskisContext!.predictions, ); @@ -2260,12 +2745,46 @@ class GameController extends GetxController { talon.value = []; int game = bGetPlayedGame(); if (game == -1 || game >= 6) { - bPredict(stockskisContext!.playingPerson()); + int pp = stockskisContext!.playingPerson(); + stockskis.User u = + stockskisContext!.users[stockskisContext!.userPositions[pp]]!; + + // game log: Predictions started + gameLog.add(GameLog( + actionType: GameLogTypes.PREDICTIONS_STARTED, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: 1, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + + bPredict(pp); return; } debugPrint("Talon1"); showTalon.value = true; + // game log: Talon open + gameLog.add(GameLog( + actionType: GameLogTypes.TALON_OPEN, + userId: playerId, + userName: stockskisContext!.users[playerId]!.user.name, + isPlaying: true, + userType: 1, + userCards: stockskisContext!.users[playerId]!.cards + .map((e) => e.card.asset) + .toList(), + action: game % 3 == 0 ? 3 : (game % 3 == 1 ? 2 : 1), + card: "", + additionalData: stockskisContext!.talon.map((e) => e.card.asset).toList(), + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + var (stockskisTalon, talon1, zaruf1) = stockskisContext!.getStockskisTalon(); talon.value = talon1; @@ -2279,6 +2798,23 @@ class GameController extends GetxController { talonSelected.value = stockskisContext!.selectDeck(playerId, stockskisTalon); List selectedCards = stockskisTalon[talonSelected.value]; + + // game log: Talon selected + gameLog.add(GameLog( + actionType: GameLogTypes.TALON_SELECTED, + userId: playerId, + userName: stockskisContext!.users[playerId]!.user.name, + isPlaying: true, + userType: 1, + userCards: stockskisContext!.users[playerId]!.cards + .map((e) => e.card.asset) + .toList(), + action: game % 3 == 0 ? 3 : (game % 3 == 1 ? 2 : 1), + card: selectedCards.map((e) => e.card.asset).toList().join(";"), + additionalData: stockskisContext!.talon.map((e) => e.card.asset).toList(), + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + debugPrint( "Izbrane karte: ${selectedCards.map((e) => e.card.asset).join(" ")}", ); @@ -2293,6 +2829,22 @@ class GameController extends GetxController { ); String king = selectedKing.value == "" ? "" : selectedKing.split("/")[1]; + // game log: Card stashing started + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_STASHING_STARTED, + userId: playerId, + userName: stockskisContext!.users[playerId]!.user.name, + isPlaying: true, + userType: 1, + userCards: stockskisContext!.users[playerId]!.cards + .map((e) => e.card.asset) + .toList(), + action: game % 3 == 0 ? 3 : (game % 3 == 1 ? 2 : 1), + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + Sounds.click(); int m = 0; @@ -2303,7 +2855,25 @@ class GameController extends GetxController { stockskisContext!.stashCards(playerId, (6 / m).round(), king); for (int i = 0; i < stash.length; i++) { stockskis.Card s = stash[i]; + stockskisContext!.users[playerId]!.cards.remove(s); + + // game log: One card stashed + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_STASHED, + userId: playerId, + userName: stockskisContext!.users[playerId]!.user.name, + isPlaying: true, + userType: 1, + userCards: stockskisContext!.users[playerId]!.cards + .map((e) => e.card.asset) + .toList(), + action: -1, + card: s.card.asset, + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + s.user = playerId; stockskisContext!.stihi[0].add(stash[i]); if (!stash[i].card.asset.contains("taroki")) { @@ -2317,14 +2887,80 @@ class GameController extends GetxController { debugPrint( "Talon: ${stockskisContext!.talon.map((e) => e.card.asset).join(" ")}", ); - await Future.delayed(Duration(milliseconds: (BOT_DELAY * 5).round()), - () async { - await pauseInterrupt(); - await bPredict(stockskisContext!.playingPerson()); - }); + + // game log: Card stashing done + gameLog.add(GameLog( + actionType: GameLogTypes.CARD_STASHING_DONE, + userId: playerId, + userName: stockskisContext!.users[playerId]!.user.name, + isPlaying: true, + userType: 1, + userCards: stockskisContext!.users[playerId]!.cards + .map((e) => e.card.asset) + .toList(), + action: game % 3 == 0 ? 3 : (game % 3 == 1 ? 2 : 1), + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + // game log: Talon closed + gameLog.add(GameLog( + actionType: GameLogTypes.TALON_CLOSED, + userId: playerId, + userName: stockskisContext!.users[playerId]!.user.name, + isPlaying: true, + userType: 1, + userCards: stockskisContext!.users[playerId]!.cards + .map((e) => e.card.asset) + .toList(), + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + + await Future.delayed( + Duration(milliseconds: (BOT_DELAY * 5).round()), () async {}); + await pauseInterrupt(); + + int pp = stockskisContext!.playingPerson(); + stockskis.User u = + stockskisContext!.users[stockskisContext!.userPositions[pp]]!; + + // game log: Predictions started + gameLog.add(GameLog( + actionType: GameLogTypes.PREDICTIONS_STARTED, + userId: u.user.id, + userName: u.user.name, + isPlaying: u.playing || u.secretlyPlaying, + userType: 1, + userCards: u.cards.map((e) => e.card.asset).toList(), + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + + await bPredict(pp); } Future bStartKingSelection(String playerId) async { + // game log: King selection started + gameLog.add(GameLog( + actionType: GameLogTypes.KING_SELECTION_STARTED, + userId: playerId, + userName: stockskisContext!.users[playerId]!.user.name, + isPlaying: false, + userType: playerId == "player" ? 0 : 1, + userCards: stockskisContext!.users[playerId]!.cards + .map((e) => e.card.asset) + .toList(), + action: -1, + card: "", + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + int game = bGetPlayedGame(); if (game == -1 || game >= 3 || users.length == 3) { bStartTalon(playerId); @@ -2338,6 +2974,23 @@ class GameController extends GetxController { kingSelect.value = false; Sounds.click(); selectedKing.value = stockskisContext!.selectKing(playerId); + + // game log: King selected, king selection ended + gameLog.add(GameLog( + actionType: GameLogTypes.KING_SELECTED_KING_SELECTION_ENDED, + userId: playerId, + userName: stockskisContext!.users[playerId]!.user.name, + isPlaying: true, + userType: 1, + userCards: stockskisContext!.users[playerId]!.cards + .map((e) => e.card.asset) + .toList(), + action: -1, + card: selectedKing.value, + additionalData: [], + actionTime: DateTime.now().millisecondsSinceEpoch, + )); + await Future.delayed(const Duration(seconds: 2), () async { await pauseInterrupt(); kingSelection.value = false; diff --git a/tarok/lib/game_log/game_log_card_widget.dart b/tarok/lib/game_log/game_log_card_widget.dart new file mode 100644 index 0000000..c7686a4 --- /dev/null +++ b/tarok/lib/game_log/game_log_card_widget.dart @@ -0,0 +1,66 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class CardWidget extends StatelessWidget { + const CardWidget({super.key, required this.cards}); + + final List cards; + + @override + Widget build(BuildContext context) { + final fullHeight = MediaQuery.of(context).size.height; + final popupCardSize = max(70, fullHeight * 0.1); + final border = popupCardSize * 0.008; + + return SizedBox( + width: (popupCardSize * 0.8 * 1.1 * (cards.length - 1) + + popupCardSize * 1.1) * + 0.57, + height: popupCardSize * 1.1, + child: Stack( + children: [ + ...cards.asMap().entries.map( + (king) => Positioned( + left: popupCardSize * 1.1 * king.key * 0.8 * 0.57, + child: Stack( + children: [ + Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10 * border), + child: SizedBox( + height: popupCardSize * 1.1, + width: popupCardSize * 0.57 * 1.1, + child: Stack( + children: [ + Container( + color: Colors.white, + height: popupCardSize * 1.1, + width: popupCardSize * 0.57 * 1.1, + ), + Center( + child: Image.asset( + "assets/tarok${king.value}.webp", + height: popupCardSize * 1.1, + width: popupCardSize * 0.57 * 1.1, + ), + ), + ], + ), + ), + ), + const SizedBox( + width: 10, + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/tarok/lib/game_log/game_log_event_widget.dart b/tarok/lib/game_log/game_log_event_widget.dart new file mode 100644 index 0000000..f862660 --- /dev/null +++ b/tarok/lib/game_log/game_log_event_widget.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:stockskis/stockskis.dart' show GAMES; +import 'package:tarok/game_log/game_log_card_widget.dart'; +import 'package:tarok/game_log/game_log_object.dart'; + +class GameLogEvent extends StatelessWidget { + const GameLogEvent({ + super.key, + required this.title, + required this.subtitle, + required this.log, + required this.rightDisplay, + required this.icon, + this.showCards = true, + }); + + final String title; + final String subtitle; + final GameLog log; + final int rightDisplay; + final Icon icon; + final bool showCards; + + @override + Widget build(BuildContext context) { + final fullWidth = MediaQuery.of(context).size.width; + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + width: (rightDisplay == 1 || rightDisplay == 3) + ? fullWidth * 0.3 + : (rightDisplay == -1 + ? fullWidth * 0.6 + : fullWidth * 0.4), + child: ListTile( + leading: icon, + title: Text(title), + subtitle: Text(subtitle), + isThreeLine: true, + ), + ), + const Spacer(), + if (rightDisplay == 0) + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + GAMES[log.action + 1].name.tr, + style: const TextStyle( + fontSize: 32, + ), + ), + ), + if (rightDisplay == 3 || rightDisplay == 4) + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + "round_number" + .trParams({"roundNumber": log.action.toString()}), + style: const TextStyle( + fontSize: 32, + ), + ), + ), + if (rightDisplay == 1 || rightDisplay == 3) + CardWidget( + cards: log.additionalData.isEmpty + ? [log.card] + : log.additionalData, + ), + if (rightDisplay == 2) + CardWidget( + cards: log.card.split(";"), + ), + ], + ), + if (log.userCards.isNotEmpty && showCards) + const SizedBox( + height: 10, + ), + if (log.userCards.isNotEmpty && showCards) + CardWidget(cards: log.userCards), + ], + ), + ), + ); + } +} diff --git a/tarok/lib/game_log/game_log_object.dart b/tarok/lib/game_log/game_log_object.dart new file mode 100644 index 0000000..84e8b52 --- /dev/null +++ b/tarok/lib/game_log/game_log_object.dart @@ -0,0 +1,117 @@ +class GameLogTypes { + static const int GAME_END = -1; + static const int GAME_START = 0; + static const int START_LICITATION = 1; + static const int USER_LICITATED_SOMETHING = 2; + static const int LICITATIONS_DONE = 3; + static const int KING_SELECTION_STARTED = 4; + static const int KING_SELECTED_KING_SELECTION_ENDED = 5; + static const int TALON_OPEN = 6; + static const int TALON_SELECTED = 7; + static const int CARD_STASHING_STARTED = 8; + static const int CARD_STASHED = 9; + static const int CARD_STASHING_DONE = 10; + static const int TALON_CLOSED = 11; + static const int PREDICTIONS_STARTED = 12; + static const int USER_PREDICTED = 13; + static const int PREDICTIONS_DONE = 14; + static const int CARD_GAME_STARTED = 15; + static const int CARD_ROUND_STARTED = 16; + static const int CARD_DROPPED = 17; + static const int CARD_ROUND_DONE = 18; + static const int RESULTS = 19; +} + +class GameLog { + /// [actionType] is one of [GameLogTypes]. + final int actionType; + + /// [userId] is a **unique** identifier of a user, meaning + /// that no other player in the game can have this identifier + final String userId; + + /// [userName] is not a unique identifier of a user, therefore + /// it can be repeated, although it isn't optimal, as that's the name + /// displayed to the viewer of the game log. + final String userName; + + /// Is user one of the playing users? + /// It doesn't matter if the user is secretly playing. + /// If [userType] is 2 (system), this is always false. + final bool isPlaying; + + /// [userType] is one of the following: + /// - 0 = actual user + /// - 1 = bot + /// - 2 = system + final int userType; + + /// [userCards] is used on following [actionType]s: + /// - 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 16, 17 + final List userCards; + + /// [action] is used on following [actionType]s: + /// - [GameLogTypes.USER_LICITATED_SOMETHING] = what game has the user licitated + /// - [GameLogTypes.LICITATIONS_DONE] = the highest licitation + /// - [GameLogTypes.TALON_OPEN] = how many cards are in each selection + /// - [GameLogTypes.TALON_SELECTED] = how many cards are in each selection + /// - [GameLogTypes.CARD_ROUND_STARTED] = round number + /// - [GameLogTypes.CARD_DROPPED] = round number + /// - [GameLogTypes.CARD_ROUND_DONE] = round number + /// + /// When value is -1, it means it's not applicable + final int action; + + /// [card] is used on following [actionType]s: + /// - [GameLogTypes.KING_SELECTED_KING_SELECTION_ENDED] = which king was selected + /// - [GameLogTypes.TALON_SELECTED] = which talon cards were selected (separated with ;) + /// - [GameLogTypes.CARD_STASHED] = which card was stashed + /// - [GameLogTypes.CARD_DROPPED] = which card was dropped + final String card; + + /// [additionalData] is used on following [actionType]s: + /// - [GameLogTypes.TALON_OPEN] = what's in the talon (as in cards) + /// - [GameLogTypes.TALON_SELECTED] = which talon cards were available + /// - [GameLogTypes.USER_PREDICTED] = what has the user predicted + final List additionalData; + + final int actionTime; + + GameLog.fromJson(Map json) + : actionType = json['actionType'] as int, + userId = json['userId'] as String, + userName = json['userName'] as String, + isPlaying = json['isPlaying'] as bool, + userType = json['userType'] as int, + userCards = List.from(json['userCards'] as List), + action = json['action'] as int, + card = json['card'] as String, + additionalData = List.from(json['additionalData'] as List), + actionTime = json['actionTime'] as int; + + Map toJson() => { + 'actionType': actionType, + 'userId': userId, + 'userName': userName, + 'isPlaying': isPlaying, + 'userType': userType, + 'userCards': userCards, + 'action': action, + 'card': card, + 'additionalData': additionalData, + 'actionTime': actionTime, + }; + + GameLog({ + required this.actionType, + required this.userId, + required this.userName, + required this.isPlaying, + required this.userType, + required this.userCards, + required this.action, + required this.card, + required this.additionalData, + required this.actionTime, + }); +} diff --git a/tarok/lib/game_log/game_log_tab.dart b/tarok/lib/game_log/game_log_tab.dart new file mode 100644 index 0000000..45a2610 --- /dev/null +++ b/tarok/lib/game_log/game_log_tab.dart @@ -0,0 +1,375 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:safe_local_storage/safe_local_storage.dart'; +import 'package:tarok/game_log/game_log_event_widget.dart'; +import 'package:tarok/game_log/game_log_object.dart'; + +class GameLogFile { + final String path; + final int creation; + + GameLogFile({required this.path, required this.creation}); +} + +class GameLogTab extends StatefulWidget { + const GameLogTab({super.key}); + + @override + _GameLogTabState createState() => _GameLogTabState(); +} + +class _GameLogTabState extends State { + @override + void initState() { + super.initState(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + + @override + void dispose() { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + super.dispose(); + } + + String? selectedGame; + List gameLog = []; + + Future> loadGames() async { + List files = []; + var directory = await getApplicationDocumentsDirectory(); + directory = Directory("${directory.path}/PalckaGames"); + await directory.create(); + await for (FileSystemEntity entity in directory.list(followLinks: false)) { + if (!entity.path.contains("gamelog-")) continue; + FileStat stats = await entity.stat(); + files.add(GameLogFile( + path: entity.path, + creation: stats.modified.millisecondsSinceEpoch, + )); + } + files.sort((a, b) => a.creation.compareTo(b.creation)); + return files; + } + + Future loadGame(String gamePath) async { + final storage = SafeLocalStorage(gamePath); + gameLog = jsonDecode(await storage.read()); + } + + Widget convertGameLogTypes(BuildContext context, GameLog log) { + switch (log.actionType) { + case GameLogTypes.GAME_END: + return GameLogEvent( + title: "game_ended".tr, + subtitle: "game_ended_subtitle".tr, + log: log, + rightDisplay: -1, + icon: const Icon(Icons.stop, size: 48), + ); + case GameLogTypes.GAME_START: + return GameLogEvent( + title: "game_started".tr, + subtitle: "game_started_subtitle".tr, + log: log, + rightDisplay: -1, + icon: const Icon(Icons.play_arrow, size: 48), + ); + case GameLogTypes.START_LICITATION: + return GameLogEvent( + title: "licitation_started".tr, + subtitle: + "licitation_started_subtitle".trParams({"player": log.userName}), + log: log, + rightDisplay: -1, + icon: const Icon(Icons.speaker, size: 48), + showCards: false, + ); + case GameLogTypes.USER_LICITATED_SOMETHING: + return GameLogEvent( + title: "user_licitated".tr, + subtitle: + "user_licitated_subtitle".trParams({"player": log.userName}), + log: log, + rightDisplay: 0, + icon: const Icon(Icons.speaker, size: 48), + ); + case GameLogTypes.LICITATIONS_DONE: + return GameLogEvent( + title: "licitations_done".tr, + subtitle: "licitations_done_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: 0, + icon: const Icon(Icons.speaker, size: 48), + ); + case GameLogTypes.KING_SELECTION_STARTED: + return GameLogEvent( + title: "king_selection_started".tr, + subtitle: "king_selection_started_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: -1, + icon: const Icon(Icons.how_to_reg, size: 48), + showCards: false, + ); + case GameLogTypes.KING_SELECTED_KING_SELECTION_ENDED: + return GameLogEvent( + title: "king_selection_done".tr, + subtitle: "king_selection_done_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: 1, + icon: const Icon(Icons.how_to_reg, size: 48), + ); + case GameLogTypes.TALON_OPEN: + return GameLogEvent( + title: "talon_open".tr, + subtitle: "talon_open_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: 1, + icon: const Icon(Icons.style, size: 48), + ); + case GameLogTypes.TALON_SELECTED: + return GameLogEvent( + title: "talon_selected".tr, + subtitle: "talon_selected_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: 2, + icon: const Icon(Icons.style, size: 48), + ); + case GameLogTypes.CARD_STASHING_STARTED: + return GameLogEvent( + title: "card_stashing_started".tr, + subtitle: "card_stashing_started_subtitle".trParams({ + "player": log.userName, + "cardAmount": log.action.toString(), + }), + log: log, + rightDisplay: -1, + icon: const Icon(Icons.reorder, size: 48), + ); + case GameLogTypes.CARD_STASHED: + return GameLogEvent( + title: "card_stashed".tr, + subtitle: "card_stashed_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: 1, + icon: const Icon(Icons.reorder, size: 48), + ); + case GameLogTypes.CARD_STASHING_DONE: + return GameLogEvent( + title: "card_stashing_done".tr, + subtitle: "card_stashing_done_subtitle".trParams({ + "player": log.userName, + "cardAmount": log.action.toString(), + }), + log: log, + rightDisplay: -1, + icon: const Icon(Icons.reorder, size: 48), + ); + case GameLogTypes.TALON_CLOSED: + return GameLogEvent( + title: "talon_closed".tr, + subtitle: "talon_closed_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: -1, + icon: const Icon(Icons.style, size: 48), + showCards: false, + ); + case GameLogTypes.PREDICTIONS_STARTED: + return GameLogEvent( + title: "predictions_started".tr, + subtitle: "predictions_started_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: -1, + icon: const Icon(Icons.question_mark, size: 48), + showCards: false, + ); + case GameLogTypes.USER_PREDICTED: + final predictions = log.additionalData; + List finalNames = []; + for (String prediction in predictions) { + String finalName = ""; + List data = prediction.split("/"); + String predictionName = data[0].tr; + if (data[0] == "game") predictionName = "game_prediction".tr; + String d = data[1].replaceFirst("kontra", ""); + if (d == "prediction") { + finalName = "${'predicted'.tr}$predictionName"; + } else { + finalName = + "${'kontra_predicted'.tr}$predictionName (${2 << int.parse(d)})"; + } + finalNames.add(finalName); + } + return GameLogEvent( + title: "user_predicted".tr, + subtitle: "user_predicted_subtitle".trParams({ + "player": log.userName, + "finalNames": finalNames.join("\n"), + }), + log: log, + rightDisplay: -1, + icon: const Icon(Icons.question_mark, size: 48), + showCards: false, + ); + case GameLogTypes.PREDICTIONS_DONE: + return GameLogEvent( + title: "predictions_done".tr, + subtitle: "predictions_done_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: -1, + icon: const Icon(Icons.question_mark, size: 48), + showCards: false, + ); + case GameLogTypes.CARD_GAME_STARTED: + return GameLogEvent( + title: "card_game_started".tr, + subtitle: "card_game_started_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: -1, + icon: const Icon(Icons.note, size: 48), + ); + case GameLogTypes.CARD_ROUND_STARTED: + return GameLogEvent( + title: "card_round_started".tr, + subtitle: "card_round_started_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: 4, + icon: const Icon(Icons.description, size: 48), + ); + case GameLogTypes.CARD_DROPPED: + return GameLogEvent( + title: "card_dropped".tr, + subtitle: log.userId == "system" + ? "system_card_dropped_subtitle".trParams({ + "player": log.userName, + }) + : "card_dropped_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: 1, + icon: const Icon(Icons.description, size: 48), + ); + case GameLogTypes.CARD_ROUND_DONE: + return GameLogEvent( + title: "card_round_done".tr, + subtitle: "card_round_done_subtitle".trParams({ + "player": log.userName, + }), + log: log, + rightDisplay: 3, + icon: const Icon(Icons.description, size: 48), + showCards: false, + ); + default: + return const SizedBox(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text("palcka".tr), + actions: [ + IconButton( + icon: const Icon(Icons.cancel), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + body: Row( + children: [ + Container( + width: 200, + color: Colors.black54, + child: FutureBuilder( + future: loadGames(), + initialData: const [], + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + List w = []; + for (int i = 0; i < snapshot.data.length; i++) { + GameLogFile file = snapshot.data[i]; + w.add( + ListTile( + leading: const Icon(Icons.games), + title: Text('game_number'.trParams({ + "gameNumber": i.toString(), + })), + onTap: () async { + selectedGame = file.path; + await loadGame(file.path); + setState(() {}); + }, + selected: selectedGame == file.path, + ), + ); + } + return ListView( + padding: const EdgeInsets.all(8), + children: w, + ); + } + return const Center( + child: SizedBox( + width: 60, + height: 60, + child: CircularProgressIndicator(), + ), + ); + }, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: ListView( + children: [ + ...gameLog.map( + (e) => convertGameLogTypes(context, GameLog.fromJson(e)), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/tarok/lib/internationalization/languages.dart b/tarok/lib/internationalization/languages.dart index 8489fc1..8a109e3 100644 --- a/tarok/lib/internationalization/languages.dart +++ b/tarok/lib/internationalization/languages.dart @@ -480,6 +480,68 @@ class Messages extends Translations { "show_most_powerful_card": "Show most powerful card", "show_most_powerful_card_desc": "Marks the currently most powerful card in the round with red.", + "game_number": "Game #@gameNumber", + "game_log_beta": "Game log (beta)", + "game_log": "Game log", + "game_ended": "Game has ended", + "game_ended_subtitle": "The system has ended the game.", + "game_started": "Game has started", + "game_started_subtitle": "The system has started the game.", + "licitation_started": "Licitation has started", + "licitation_started_subtitle": "@player has started with licitation.", + "user_licitated": "User has licitated", + "user_licitated_subtitle": "@player has licitated.", + "licitations_done": "Licitations have come to an end", + "licitations_done_subtitle": "@player has won licitations.", + "king_selection_started": "King selection has started", + "king_selection_started_subtitle": "@player is selecting a king.", + "king_selection_done": "King was selected", + "king_selection_done_subtitle": + "@player has selected a king. King selection has come to an end.", + "talon_open": "Talon has opened", + "talon_open_subtitle": + "Talon has opened for everyone to see. @player is now selecting which part of talon they want.", + "talon_selected": "Talon was selected", + "talon_selected_subtitle": + "@player has selected a part of talon they want. Talon selection has come to an end.", + "card_stashing_started": "Card stashing has started", + "card_stashing_started_subtitle": + "@player is now thinking which card to stash. @player needs to stash @cardAmount card(s).", + "card_stashed": "Card was stashed", + "card_stashed_subtitle": "@player has stashed a card.", + "card_stashing_done": "Card stashing has ended", + "card_stashing_done_subtitle": + "Card stashing has come to an end. @player has stashed @cardAmount card(s).", + "talon_closed": "Talon has closed", + "talon_closed_subtitle": + "Talon cannot be seen anymore until the end of the game", + "predictions_started": "Predictions have started", + "predictions_started_subtitle": + "@player is the first person to predict the possible outcomes of the game.", + "predicted": "Predicted: ", + "kontra_predicted": "Kontra: ", + "user_predicted": "User has predicted", + "user_predicted_subtitle": + "@player has predicted either nothing or the following.\n@finalNames", + "predictions_done": "Predictions have ended", + "predictions_done_subtitle": "Predictions have come to an end", + "card_game_started": "Card game has started", + "card_game_started_subtitle": "System has started the card game.", + "card_round_started": "Card round has started", + "card_round_started_subtitle": "@player starts the round.", + "card_dropped": "Card was dropped", + "system_card_dropped_subtitle": + "System has dropped this card either due to a zaruf (the user has picked up the deck with called king) or because the game is Klop (everybody went onwards during the licitation).", + "card_dropped_subtitle": "@player has dropped the card.", + "card_round_done": "Card round has ended", + "card_round_done_subtitle": + "@player has won this round and therefore starts the next round.", + "game_prediction": "Game", + "round_number": "Round @roundNumber", + "save_game_logs": "Save game logs", + "save_game_logs_desc": + "If enabled, your offline games with bots will be saved. Games will never leave your device and will be saved locally for you to see later through the Game Log feature. Note that game data can quickly become big and you might need to clean it up at times.", + "clear_game_logs": "Clear game logs", }, "fr_FR": { "login": "Connexion", @@ -951,6 +1013,68 @@ class Messages extends Translations { "show_most_powerful_card": "Montrer la carte la plus puissante", "show_most_powerful_card_desc": "Marque en rouge la carte qui est actuellement la plus puissante du pli.", + "game_number": "Game #@gameNumber", + "game_log_beta": "Game log (beta)", + "game_log": "Game log", + "game_ended": "Game has ended", + "game_ended_subtitle": "The system has ended the game.", + "game_started": "Game has started", + "game_started_subtitle": "The system has started the game.", + "licitation_started": "Licitation has started", + "licitation_started_subtitle": "@player has started with licitation.", + "user_licitated": "User has licitated", + "user_licitated_subtitle": "@player has licitated.", + "licitations_done": "Licitations have come to an end", + "licitations_done_subtitle": "@player has won licitations.", + "king_selection_started": "King selection has started", + "king_selection_started_subtitle": "@player is selecting a king.", + "king_selection_done": "King was selected", + "king_selection_done_subtitle": + "@player has selected a king. King selection has come to an end.", + "talon_open": "Talon has opened", + "talon_open_subtitle": + "Talon has opened for everyone to see. @player is now selecting which part of talon they want.", + "talon_selected": "Talon was selected", + "talon_selected_subtitle": + "@player has selected a part of talon they want. Talon selection has come to an end.", + "card_stashing_started": "Card stashing has started", + "card_stashing_started_subtitle": + "@player is now thinking which card to stash. @player needs to stash @cardAmount card(s).", + "card_stashed": "Card was stashed", + "card_stashed_subtitle": "@player has stashed a card.", + "card_stashing_done": "Card stashing has ended", + "card_stashing_done_subtitle": + "Card stashing has come to an end. @player has stashed @cardAmount card(s).", + "talon_closed": "Talon has closed", + "talon_closed_subtitle": + "Talon cannot be seen anymore until the end of the game", + "predictions_started": "Predictions have started", + "predictions_started_subtitle": + "@player is the first person to predict the possible outcomes of the game.", + "predicted": "Predicted: ", + "kontra_predicted": "Kontra: ", + "user_predicted": "User has predicted", + "user_predicted_subtitle": + "@player has predicted either nothing or the following.\n@finalNames", + "predictions_done": "Predictions have ended", + "predictions_done_subtitle": "Predictions have come to an end", + "card_game_started": "Card game has started", + "card_game_started_subtitle": "System has started the card game.", + "card_round_started": "Card round has started", + "card_round_started_subtitle": "@player starts the round.", + "card_dropped": "Card was dropped", + "system_card_dropped_subtitle": + "System has dropped this card either due to a zaruf (the user has picked up the deck with called king) or because the game is Klop (everybody went onwards during the licitation).", + "card_dropped_subtitle": "@player has dropped the card.", + "card_round_done": "Card round has ended", + "card_round_done_subtitle": + "@player has won this round and therefore starts the next round.", + "game_prediction": "Game", + "round_number": "Round @roundNumber", + "save_game_logs": "Save game logs", + "save_game_logs_desc": + "If enabled, your offline games with bots will be saved. Games will never leave your device and will be saved locally for you to see later through the Game Log feature. Note that game data can quickly become big and you might need to clean it up at times.", + "clear_game_logs": "Clear game logs", }, "sl_SI": { "login": "Prijava", @@ -1410,6 +1534,70 @@ class Messages extends Translations { "show_most_powerful_card": "Prikaži najbolj močno karto", "show_most_powerful_card_desc": "Označi trenutno najbolj močno karto v rundi z rdečo.", + "game_number": "Igra #@gameNumber", + "game_log_beta": "Zgodovina igre (beta)", + "game_log": "Zgodovina igre", + "game_ended": "Igra se je končala", + "game_ended_subtitle": "Sistem je končal igro.", + "game_started": "Igra se je začela", + "game_started_subtitle": "Sistem je začel igro.", + "licitation_started": "Licitacija se je začela", + "licitation_started_subtitle": + "Uporabnik @player je začel z licitacijo.", + "user_licitated": "Uporabnik je licitiral", + "user_licitated_subtitle": "Uporabnik @player je licitiral.", + "licitations_done": "Licitacije so se končale", + "licitations_done_subtitle": + "Uporabnik @player je zmagal licitacije.", + "king_selection_started": "Klicanje kralja se je začelo", + "king_selection_started_subtitle": "Uporabnik @player kliče kralja.", + "king_selection_done": "Kralj je bil klican", + "king_selection_done_subtitle": + "Uporabnik @player je izbral kralja. Izbira kralja se je s tem končala.", + "talon_open": "Talon se je odprl", + "talon_open_subtitle": + "Talon se je odprl vsem. Uporabnik @player sedaj izbira del talona, ki ga želi.", + "talon_selected": "Talon je bil izbran", + "talon_selected_subtitle": + "Uporabnik @player je izbral del talona, ki ga želi. S tem se je zaključila izbira talona.", + "card_stashing_started": "Zalaganje kart se je začelo", + "card_stashing_started_subtitle": + "Uporabnik @player zdaj razmišlja, katere karte bi založil. Uporabnik @player mora založiti @cardAmount kart.", + "card_stashed": "Karta je bila založena", + "card_stashed_subtitle": "Uporabnik @player je založil karte.", + "card_stashing_done": "Zalaganje kart se je končalo", + "card_stashing_done_subtitle": + "Zalaganje kart se je končalo. Uporabnik @player je založil @cardAmount kart.", + "talon_closed": "Talon se je zaprl", + "talon_closed_subtitle": "Talona ne more videti nihče do konca igre.", + "predictions_started": "Napovedi so se začele", + "predictions_started_subtitle": + "Uporabnik @player je prva oseba, ki lahko napove potencialne izide igre.", + "predicted": "Napoved: ", + "kontra_predicted": "Kontra: ", + "user_predicted": "Uporabnik se je odločil za napovedi", + "user_predicted_subtitle": + "Uporabnik @player se ni odločil za kakršnekoli napovedi ali pa se je odločil za naslednje napovedi.\n@finalNames", + "predictions_done": "Napovedi so se končale", + "predictions_done_subtitle": + "Vsi igralci so napovedali svoje. S tem so se napovedi končale.", + "card_game_started": "Igra s kartami se je začela", + "card_game_started_subtitle": "Sistem je začel igro s kartami.", + "card_round_started": "Runda se je začela", + "card_round_started_subtitle": "Uporabnik @player začne rundo.", + "card_dropped": "Karta je padla", + "system_card_dropped_subtitle": + "Sistem je vrgel to karto ali zaradi zarufa (uporabnik je pobral štih s klicanim kraljem) ali ker se igra klopa (vsi igralci so klicali dalje/naprej med licitiranjem).", + "card_dropped_subtitle": "Uporabnik @player je položil karto.", + "card_round_done": "Runda se je končala", + "card_round_done_subtitle": + "Uporabnik @player je zmagal to rundo in zato začne naslednjo rundo.", + "game_prediction": "Igra", + "round_number": "Runda @roundNumber", + "save_game_logs": "Shranjuj zgodovino iger", + "save_game_logs_desc": + "Če je to vključeno, bodo vaše igre z boti shranjene. Igre ne bodo nikoli zapustile vaše naprave in bodo shranjene za kasnejši ogled preko funkcije Zgodovina iger. Pazite, ker lahko shranjene igre hitro postanejo velike, zaradi česar jih boste morali občasno počistiti.", + "clear_game_logs": "Počisti zgodovino lokalnih iger", } }; } diff --git a/tarok/lib/main.dart b/tarok/lib/main.dart index 8602d0d..e2466bc 100644 --- a/tarok/lib/main.dart +++ b/tarok/lib/main.dart @@ -28,6 +28,8 @@ import 'package:tarok/about.dart'; import 'package:tarok/admin/users.dart'; import 'package:tarok/constants.dart'; import 'package:tarok/game/game.dart'; +import 'package:tarok/stub/game_log_tab_stub.dart' + if (dart.library.io) "package:tarok/game_log/game_log_tab.dart"; import 'package:tarok/guide/guide.dart'; import 'package:tarok/internationalization/languages.dart'; import 'package:tarok/lobby/friends.dart'; @@ -88,6 +90,7 @@ void main() async { POINTS_TOOLTIP = prefs.getBool("points_tooltip") ?? false; SHOW_EVALUATION = prefs.getBool("show_evaluation") ?? true; SHOW_MOST_POWERFUL_CARD = prefs.getBool("show_most_powerful_card") ?? false; + SAVE_GAME_LOGS = prefs.getBool("save_game_logs") ?? false; if (kReleaseMode) { BACKEND_URL = prefs.getString("api_url") ?? "https://palcka.si/api"; @@ -140,6 +143,7 @@ void main() async { getPages: [ GetPage(name: '/', page: () => const Lobby()), GetPage(name: '/game', page: () => const Game()), + GetPage(name: '/game_log', page: () => const GameLogTab()), GetPage(name: '/settings', page: () => const Settings()), GetPage(name: '/login', page: () => const Login()), GetPage(name: '/registration', page: () => const Register()), diff --git a/tarok/lib/settings.dart b/tarok/lib/settings.dart index 9696b02..23f0979 100644 --- a/tarok/lib/settings.dart +++ b/tarok/lib/settings.dart @@ -19,6 +19,8 @@ import 'package:dart_discord_rpc/dart_discord_rpc.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:tarok/stub/path_provider.dart' + if (dart.library.io) 'package:path_provider/path_provider.dart'; import 'package:settings_ui/settings_ui.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:stockskis/stockskis.dart'; @@ -207,36 +209,74 @@ class _SettingsState extends State { ), ], ), - if (!kIsWeb && (Platform.isLinux || Platform.isWindows)) + if (!kIsWeb) SettingsSection( title: Text("other".tr), tiles: [ + if (Platform.isLinux || Platform.isWindows) + SettingsTile.switchTile( + onToggle: (value) async { + final SharedPreferences prefs = + await SharedPreferences.getInstance(); + await prefs.setBool("discordRpc", value); + DISCORD_RPC = prefs.getBool("discordRpc") ?? true; + if (DISCORD_RPC) { + DiscordRPC.initialize(); + rpc.start(autoRegister: true); + rpc.updatePresence( + DiscordPresence( + details: 'Spreminja nastavitve', + startTimeStamp: + DateTime.now().millisecondsSinceEpoch, + largeImageKey: 'palcka_logo', + largeImageText: 'Tarok Palčka', + ), + ); + } else { + rpc.shutDown(); + } + setState(() {}); + }, + initialValue: DISCORD_RPC, + leading: const Icon(Icons.discord), + title: Text("discord_rpc".tr), + description: Text("enable_discord_rpc".tr), + ), SettingsTile.switchTile( onToggle: (value) async { final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setBool("discordRpc", value); - DISCORD_RPC = prefs.getBool("discordRpc") ?? true; - if (DISCORD_RPC) { - DiscordRPC.initialize(); - rpc.start(autoRegister: true); - rpc.updatePresence( - DiscordPresence( - details: 'Spreminja nastavitve', - startTimeStamp: DateTime.now().millisecondsSinceEpoch, - largeImageKey: 'palcka_logo', - largeImageText: 'Tarok Palčka', - ), - ); - } else { - rpc.shutDown(); - } + await prefs.setBool("save_game_logs", value); + SAVE_GAME_LOGS = prefs.getBool("save_game_logs") ?? false; setState(() {}); }, - initialValue: DISCORD_RPC, - leading: const Icon(Icons.discord), - title: Text("discord_rpc".tr), - description: Text("enable_discord_rpc".tr), + initialValue: SAVE_GAME_LOGS, + leading: const Icon(Icons.gamepad), + title: Text("save_game_logs".tr), + description: Text("save_game_logs_desc".tr), + ), + SettingsTile( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("clear_game_logs".tr), + ElevatedButton( + onPressed: () async { + var directory = + await getApplicationDocumentsDirectory(); + directory = + Directory("${directory.path}/PalckaGames"); + await directory.create(); + await for (FileSystemEntity entity + in directory.list(followLinks: false)) { + if (!entity.path.contains("gamelog-")) continue; + await entity.delete(); + } + }, + child: Text("clear_game_logs".tr), + ), + ], + ), ), ], ), diff --git a/tarok/lib/stub/game_log_tab_stub.dart b/tarok/lib/stub/game_log_tab_stub.dart new file mode 100644 index 0000000..19e3f3a --- /dev/null +++ b/tarok/lib/stub/game_log_tab_stub.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +// Empty [GameLogTab] for web. +class GameLogTab extends StatelessWidget { + const GameLogTab({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox(); + } +} diff --git a/tarok/lib/stub/path_provider.dart b/tarok/lib/stub/path_provider.dart new file mode 100644 index 0000000..cf89f15 --- /dev/null +++ b/tarok/lib/stub/path_provider.dart @@ -0,0 +1,5 @@ +import 'dart:io'; + +Directory getApplicationDocumentsDirectory() { + return Directory(""); +} diff --git a/tarok/lib/ui/main_page.dart b/tarok/lib/ui/main_page.dart index 8a082db..7fbbe5e 100644 --- a/tarok/lib/ui/main_page.dart +++ b/tarok/lib/ui/main_page.dart @@ -13,6 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; @@ -86,6 +87,14 @@ class PalckaHome extends StatelessWidget { await Get.toNamed("/tournaments"); }, ), + if (!kIsWeb) + ListTile( + leading: const Icon(Icons.games), + title: Text("game_log_beta".tr), + onTap: () async { + await Get.toNamed("/game_log"); + }, + ), ListTile( leading: const FaIcon(FontAwesomeIcons.discord), title: Text("discord".tr), diff --git a/tarok/pubspec.lock b/tarok/pubspec.lock index 7d3c905..522136e 100644 --- a/tarok/pubspec.lock +++ b/tarok/pubspec.lock @@ -744,7 +744,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -888,7 +888,7 @@ packages: source: hosted version: "0.28.0" safe_local_storage: - dependency: transitive + dependency: "direct main" description: name: safe_local_storage sha256: ede4eb6cb7d88a116b3d3bf1df70790b9e2038bc37cb19112e381217c74d9440 @@ -1181,7 +1181,7 @@ packages: source: hosted version: "0.3.0" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff diff --git a/tarok/pubspec.yaml b/tarok/pubspec.yaml index 106a6e3..732f611 100644 --- a/tarok/pubspec.yaml +++ b/tarok/pubspec.yaml @@ -48,6 +48,9 @@ dependencies: url: https://github.com/mytja/twemoji_v2 ref: main web_socket_client: ^0.1.4 + safe_local_storage: ^1.0.2 + path_provider: ^2.1.5 + uuid: ^4.5.1 dependency_overrides: web: ^1.0.0