croupier: Buffer Play of Cards and Use Minimum Log Time

Played through a whole round of buffering with no issues.

Players can now play cards even though it is not their turn.
Cards are buffered until they can be played.

Since this makes the non-synchronized clock issues even worse,
the Log Writer now uses a Minimum Log Time to guarantee that
turn-based moves will occur "in-order".
Note: Doing this will potentially make the sync diff collection logs
inaccurate. (Though it's unclear how accurate they were to begin with.)

This also fixes a minor bug with Resume Game; during the initial
watch/scan, the scanner fails to process each scanned item in order.
Stream.forEach doesn't guarantee the order, unlike await for, so
I've moved to using the latter.

Change-Id: I7bb2fc76499df4bba599e047930d0121c27747d3
diff --git a/lib/components/board.dart b/lib/components/board.dart
index 382eb85..84ca7fc 100644
--- a/lib/components/board.dart
+++ b/lib/components/board.dart
@@ -50,6 +50,9 @@
   final Croupier croupier;
   final bool isMini;
   final AcceptCb gameAcceptCallback;
+  final List<logic_card.Card> bufferedPlay;
+
+  HeartsGame get game => super.game;
 
   HeartsBoard(Croupier croupier,
       {double height,
@@ -57,7 +60,8 @@
       double cardHeight,
       double cardWidth,
       this.isMini: false,
-      this.gameAcceptCallback})
+      this.gameAcceptCallback,
+      this.bufferedPlay})
       : super(croupier.game,
             height: height,
             width: width,
@@ -139,15 +143,19 @@
   }
 
   Widget _buildAvatarSlotCombo(int playerNumber) {
-    HeartsGame game = config.game as HeartsGame;
+    HeartsGame game = config.game;
     int p = game.playerNumber;
 
     List<Widget> items = new List<Widget>();
     bool isMe = playerNumber == p;
-    bool isPlayerTurn = playerNumber == game.whoseTurn && !game.allPlayed;
 
     List<logic_card.Card> showCard =
         game.cardCollections[playerNumber + HeartsGame.OFFSET_PLAY];
+    bool hasPlayed = showCard.length > 0;
+    bool isTurn = playerNumber == game.whoseTurn && !hasPlayed;
+    if (isMe && config.bufferedPlay != null) {
+      showCard = config.bufferedPlay;
+    }
 
     items.add(new Positioned(
         top: 0.0,
@@ -156,15 +164,13 @@
             showCard, true, CardCollectionOrientation.show1,
             useKeys: true,
             acceptCallback: config.gameAcceptCallback,
-            acceptType: isMe && isPlayerTurn ? DropType.card : DropType.none,
+            acceptType: isMe && !hasPlayed ? DropType.card : DropType.none,
             widthCard: config.cardWidth - 6.0,
             heightCard: config.cardHeight - 6.0,
             backgroundColor:
-                isPlayerTurn ? style.theme.accentColor : Colors.grey[500],
-            altColor: isPlayerTurn ? Colors.grey[200] : Colors.grey[600])));
+                isTurn ? style.theme.accentColor : Colors.grey[500],
+            altColor: isTurn ? Colors.grey[200] : Colors.grey[600])));
 
-    bool hasPlayed =
-        game.cardCollections[playerNumber + HeartsGame.OFFSET_PLAY].length > 0;
     if (!hasPlayed) {
       items.add(new Positioned(
           top: 0.0,
diff --git a/lib/components/hearts/hearts.part.dart b/lib/components/hearts/hearts.part.dart
index e59b1b3..b8ab205 100644
--- a/lib/components/hearts/hearts.part.dart
+++ b/lib/components/hearts/hearts.part.dart
@@ -9,6 +9,8 @@
       {Key key, double width, double height})
       : super(croupier, cb, key: key, width: width, height: height);
 
+  HeartsGame get game => super.game;
+
   HeartsGameComponentState createState() => new HeartsGameComponentState();
 }
 
@@ -16,6 +18,8 @@
   List<logic_card.Card> passingCards1 = new List<logic_card.Card>();
   List<logic_card.Card> passingCards2 = new List<logic_card.Card>();
   List<logic_card.Card> passingCards3 = new List<logic_card.Card>();
+  List<logic_card.Card> bufferedPlay = new List<logic_card.Card>();
+  bool bufferedPlaying = false;
 
   HeartsType _lastViewType;
   bool _showSplitView = false;
@@ -35,19 +39,45 @@
   @override
   void _reset() {
     super._reset();
-    HeartsGame game = config.game as HeartsGame;
-    _lastViewType = game.viewType;
+    _lastViewType = config.game.viewType;
+  }
+
+  bool get _canBuffer {
+    HeartsGame game = config.game;
+    List<logic_card.Card> playCards =
+        game.cardCollections[HeartsGame.OFFSET_PLAY + game.playerNumber];
+    return game.isPlayer && game.numPlayed >= 1 && playCards.length == 0;
+  }
+
+  bool get _shouldUnbuffer {
+    HeartsGame game = config.game;
+    return game.whoseTurn == game.playerNumber &&
+        bufferedPlay.length > 0 &&
+        !bufferedPlaying;
   }
 
   @override
   Widget build(BuildContext context) {
-    HeartsGame game = config.game as HeartsGame;
+    HeartsGame game = config.game;
 
-    // check if we need to swap out our 's map.
+    // Reset the game's stored ZCards if the view type changes.
     if (_lastViewType != game.viewType) {
       _reset();
     }
 
+    // If it's our turn and buffered play is not empty, let's play it!
+    // Set a flag to ensure that we only play it once.
+    if (_shouldUnbuffer) {
+      _makeGameMoveCallback(bufferedPlay[0],
+          game.cardCollections[HeartsGame.OFFSET_PLAY + game.playerNumber]);
+      bufferedPlaying = true;
+    }
+
+    // If all cards were played, we can safely clear bufferedPlay.
+    if (game.allPlayed) {
+      _clearBufferedPlay();
+    }
+
     // Hearts Widget
     Widget heartsWidget = new Container(
         decoration: new BoxDecoration(backgroundColor: Colors.grey[300]),
@@ -154,7 +184,7 @@
   int _compareCards(logic_card.Card a, logic_card.Card b) {
     if (a == b) return 0;
     assert(a.deck == "classic" && b.deck == "classic");
-    HeartsGame game = config.game as HeartsGame;
+    HeartsGame game = config.game;
     int r = game.getCardSuit(a).compareTo(game.getCardSuit(b));
     if (r != 0) return r;
     return game.getCardValue(a) < game.getCardValue(b) ? -1 : 1;
@@ -174,20 +204,25 @@
     return ls;
   }
 
+  void _clearBufferedPlay() {
+    bufferedPlay.clear();
+    bufferedPlaying = false;
+  }
+
   // This shouldn't always be here, but for now, we have little choice.
   void _switchPlayersCallback() {
     setState(() {
       config.game.playerNumber = (config.game.playerNumber + 1) % 4;
       _clearPassing(); // Just for sanity.
+      _clearBufferedPlay();
     });
   }
 
   void _makeGamePassCallback() {
     setState(() {
       try {
-        HeartsGame game = config.game as HeartsGame;
-        game.passCards(_combinePassing());
-        game.debugString = null;
+        config.game.passCards(_combinePassing());
+        config.game.debugString = null;
       } catch (e) {
         print("You can't do that! ${e.toString()}");
         config.game.debugString = "You must pass 3 cards";
@@ -203,9 +238,8 @@
         // However, since they are never seen outside of the Pass phase, it is
         // also valid to clear them upon taking any cards.
         _clearPassing();
-        HeartsGame game = config.game as HeartsGame;
-        game.takeCards();
-        game.debugString = null;
+        config.game.takeCards();
+        config.game.debugString = null;
       } catch (e) {
         print("You can't do that! ${e.toString()}");
         config.game.debugString = e.toString();
@@ -216,9 +250,19 @@
   void _makeGameMoveCallback(logic_card.Card card, List<logic_card.Card> dest) {
     setState(() {
       HeartsGame game = config.game;
-      String reason = game.canPlay(game.playerNumber, card);
+
+      bool isBufferAttempt = dest == bufferedPlay;
+
+      String reason =
+          game.canPlay(game.playerNumber, card, lenient: isBufferAttempt);
       if (reason == null) {
-        game.move(card, dest);
+        if (isBufferAttempt) {
+          print("Buffering ${card}...");
+          _clearBufferedPlay();
+          bufferedPlay.add(card);
+        } else {
+          game.move(card, dest);
+        }
         game.debugString = null;
       } else {
         print("You can't do that! ${reason}");
@@ -229,9 +273,8 @@
 
   void _endRoundDebugCallback() {
     setState(() {
-      HeartsGame game = config.game as HeartsGame;
-      game.jumpToScorePhaseDebug();
-      game.debugString = null;
+      config.game.jumpToScorePhaseDebug();
+      config.game.debugString = null;
     });
   }
 
@@ -268,13 +311,11 @@
   }
 
   Widget buildHearts() {
-    HeartsGame game = config.game as HeartsGame;
-
-    if (game.viewType == HeartsType.Board) {
+    if (config.game.viewType == HeartsType.Board) {
       return buildHeartsBoard();
     }
 
-    switch (game.phase) {
+    switch (config.game.phase) {
       case HeartsPhase.Deal:
         return showDeal();
       case HeartsPhase.Pass:
@@ -292,9 +333,8 @@
   }
 
   Widget buildHeartsBoard() {
-    HeartsGame game = config.game as HeartsGame;
     List<Widget> kids = new List<Widget>();
-    switch (game.phase) {
+    switch (config.game.phase) {
       case HeartsPhase.Deal:
         kids.add(new Text("Waiting for Deal..."));
         break;
@@ -366,8 +406,8 @@
     }
 
     // Override if there is a debug string.
-    if (config.game.debugString != null) {
-      status = config.game.debugString;
+    if (game.debugString != null) {
+      status = game.debugString;
     }
 
     return status;
@@ -449,15 +489,22 @@
             cardWidth: config.height * 0.1,
             cardHeight: config.height * 0.1,
             isMini: true,
-            gameAcceptCallback: _makeGameMoveCallback));
+            gameAcceptCallback: _makeGameMoveCallback,
+            bufferedPlay: _canBuffer ? bufferedPlay : null));
   }
 
   Widget showPlay() {
-    HeartsGame game = config.game as HeartsGame;
+    HeartsGame game = config.game;
     int p = game.playerNumber;
 
     List<Widget> cardCollections = new List<Widget>();
 
+    List<logic_card.Card> playOrBuffer =
+        game.cardCollections[p + HeartsGame.OFFSET_PLAY];
+    if (playOrBuffer.length == 0) {
+      playOrBuffer = bufferedPlay;
+    }
+
     if (_showSplitView) {
       cardCollections.add(new Container(
           decoration:
@@ -469,13 +516,10 @@
           width: config.width,
           child: new Center(
               child: new CardCollectionComponent(
-                  game.cardCollections[p + HeartsGame.OFFSET_PLAY],
-                  true,
-                  CardCollectionOrientation.show1,
+                  playOrBuffer, true, CardCollectionOrientation.show1,
                   useKeys: true,
                   acceptCallback: _makeGameMoveCallback,
-                  acceptType:
-                      p == game.whoseTurn ? DropType.card : DropType.none,
+                  acceptType: this._canBuffer ? DropType.card : DropType.none,
                   backgroundColor:
                       p == game.whoseTurn ? Colors.white : Colors.grey[500],
                   altColor: p == game.whoseTurn
@@ -489,11 +533,25 @@
     }
 
     List<logic_card.Card> cards = game.cardCollections[p];
+
+    // A buffered card won't show up in the normal hand area.
+    List<logic_card.Card> remainingCards = new List<logic_card.Card>();
+    cards.forEach((logic_card.Card c) {
+      if (!bufferedPlay.contains(c)) {
+        remainingCards.add(c);
+      }
+    });
+
+    // You can start playing/buffering if it's your turn or you can buffer.
+    bool canTap = game.whoseTurn == game.playerNumber || this._canBuffer;
+
     CardCollectionComponent c = new CardCollectionComponent(
-        cards, game.playerNumber == p, CardCollectionOrientation.suit,
+        remainingCards, game.playerNumber == p, CardCollectionOrientation.suit,
         dragChildren: true, // Can drag, but may not have anywhere to drop
-        cardTapCallback: (logic_card.Card card) => (_makeGameMoveCallback(
-            card, game.cardCollections[p + HeartsGame.OFFSET_PLAY])),
+        cardTapCallback: canTap
+            ? (logic_card.Card card) =>
+                (_makeGameMoveCallback(card, playOrBuffer))
+            : null,
         comparator: _compareCards,
         width: config.width,
         useKeys: true);
@@ -504,7 +562,7 @@
   }
 
   Widget showScore() {
-    HeartsGame game = config.game as HeartsGame;
+    HeartsGame game = config.game;
 
     Widget w;
     if (game.hasGameEnded) {
@@ -575,12 +633,10 @@
   }
 
   Widget showDeal() {
-    HeartsGame game = config.game as HeartsGame;
-
     return new Container(
         decoration: new BoxDecoration(backgroundColor: Colors.pink[500]),
         child: new Column([
-          new Text('Player ${game.playerNumber}'),
+          new Text('Player ${config.game.playerNumber}'),
           new Text('Waiting for Deal...'),
           _makeDebugButtons()
         ], justifyContent: FlexJustifyContent.spaceBetween));
@@ -644,7 +700,7 @@
   }
 
   Widget _topCardWidget(List<logic_card.Card> cards, AcceptCb cb) {
-    HeartsGame game = config.game as HeartsGame;
+    HeartsGame game = config.game;
     List<logic_card.Card> passCards =
         game.cardCollections[game.playerNumber + HeartsGame.OFFSET_PASS];
 
@@ -668,7 +724,7 @@
 
   // Pass Phase Screen: Show the cards being passed and the player's remaining cards.
   Widget showPass() {
-    HeartsGame game = config.game as HeartsGame;
+    HeartsGame game = config.game;
 
     List<logic_card.Card> passCards =
         game.cardCollections[game.playerNumber + HeartsGame.OFFSET_PASS];
@@ -697,7 +753,7 @@
 
   // Take Phase Screen: Show the cards the player has received and the player's hand.
   Widget showTake() {
-    HeartsGame game = config.game as HeartsGame;
+    HeartsGame game = config.game;
 
     List<logic_card.Card> playerCards = game.cardCollections[game.playerNumber];
     List<logic_card.Card> takeCards =
diff --git a/lib/logic/hearts/hearts_game.part.dart b/lib/logic/hearts/hearts_game.part.dart
index 53043a4..1da768c 100644
--- a/lib/logic/hearts/hearts_game.part.dart
+++ b/lib/logic/hearts/hearts_game.part.dart
@@ -386,7 +386,7 @@
   }
 
   // Returns null or the reason that the player cannot play the card.
-  String canPlay(int player, Card c) {
+  String canPlay(int player, Card c, {bool lenient: false}) {
     if (phase != HeartsPhase.Play) {
       return "It is not the Play phase of Hearts.";
     }
@@ -396,7 +396,7 @@
     if (this.allPlayed) {
       return "Trick not taken yet.";
     }
-    if (this.whoseTurn != player) {
+    if (this.whoseTurn != player && !lenient) {
       return "It is not Player ${player}'s turn.";
     }
     if (trickNumber == 0 && this.numPlayed == 0 && c != TWO_OF_CLUBS) {
diff --git a/lib/src/syncbase/croupier_client.dart b/lib/src/syncbase/croupier_client.dart
index dd1b57f..ca5feba 100644
--- a/lib/src/syncbase/croupier_client.dart
+++ b/lib/src/syncbase/croupier_client.dart
@@ -179,14 +179,17 @@
 
       sc.SyncbaseTable tb = sbdb.table(tbName);
 
-      await tb
-          .scan(new sc.RowRange.prefix(prefix))
-          .forEach((sc.KeyValue kv) async {
+      Stream<sc.KeyValue> scanStream =
+          await tb.scan(new sc.RowRange.prefix(prefix));
+
+      // Use an await for loop to ensure that each onChange event is processed
+      // in order.
+      await for (sc.KeyValue kv in scanStream) {
         String key = kv.key;
         String value = UTF8.decode(kv.value);
         print("Scan found ${key}, ${value}");
         await onChange(key, value, true);
-      });
+      }
     } finally {
       await sbdb.abort();
     }
diff --git a/lib/src/syncbase/log_writer.dart b/lib/src/syncbase/log_writer.dart
index f9cd9fb..21eecc9 100644
--- a/lib/src/syncbase/log_writer.dart
+++ b/lib/src/syncbase/log_writer.dart
@@ -63,6 +63,12 @@
     _associatedUser = other;
   }
 
+  // The latest timestamp watched so far.
+  // By applying this timestamp (as a minimum), this ensures that turn-based
+  // actions remain in-order, despite clock desynchronization.
+  // Note: This may occasionally affect the diff-log values.
+  DateTime latestTime = new DateTime.now();
+
   // This holds a reference to the syncbase table we're writing to.
   SyncbaseTable tb;
   static final String tbName = util.tableNameGames;
@@ -79,8 +85,27 @@
     _diffFileLog("=========Starting Log Writer=========");
   }
 
-  Future _diffFileLog(String s, [DateTime other]) async {
+  DateTime _getLatestTime() {
     DateTime now = new DateTime.now();
+    if (latestTime.isAfter(now)) {
+      return latestTime.add(new Duration(milliseconds: 1));
+    }
+    return now;
+  }
+
+  // Parses a millisecond string into a DateTime.
+  DateTime _parseTime(String timeStr) {
+    // Since the Dart VM on Android has a bug with parsing large integers, we
+    // are forced to split the string into parts to successfully parse it.
+    // TODO(alexfandrianto): https://github.com/vanadium/issues/issues/1026
+    String front = timeStr.substring(0, 8);
+    String back = timeStr.substring(8);
+    int time = int.parse(front) * math.pow(10, back.length) + int.parse(back);
+    return new DateTime.fromMillisecondsSinceEpoch(time);
+  }
+
+  Future _diffFileLog(String s, [DateTime other]) async {
+    DateTime now = _getLatestTime();
     int diff = other != null ? now.difference(other).inMilliseconds : null;
     String logStr = "${now.millisecondsSinceEpoch}\t${diff}\t${s}\n";
     print(logStr);
@@ -106,11 +131,12 @@
   Future _onChange(String rowKey, String value, bool duringScan) async {
     String key = rowKey.replaceFirst("${this.logPrefix}/", "");
     String timeStr = key.split("-")[0];
-    String front = timeStr.substring(0, 8);
-    String back = timeStr.substring(8);
-    int time = int.parse(front) * math.pow(10, back.length) + int.parse(back);
-    await _diffFileLog("Key: ${key} Value: ${value}",
-        new DateTime.fromMillisecondsSinceEpoch(time));
+    DateTime keyTime = _parseTime(timeStr);
+    await _diffFileLog("Key: ${key} Value: ${value}", keyTime);
+
+    if (keyTime.isAfter(latestTime)) {
+      latestTime = keyTime;
+    }
 
     if (_isProposalKey(key)) {
       if (value != null && !_acceptedProposals.contains(key) && !duringScan) {
@@ -166,7 +192,11 @@
 
   // Helper that returns the log key using a mixture of timestamp + user.
   String _logKey(int user) {
-    int ms = new DateTime.now().millisecondsSinceEpoch;
+    DateTime time = _getLatestTime();
+    if (time.isAfter(latestTime)) {
+      latestTime = time;
+    }
+    int ms = time.millisecondsSinceEpoch;
     String key = "${ms}-${user}";
     return key;
   }