| // Copyright 2015 The Vanadium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| /// Since this file includes Sky/Mojo, it will need to be mocked out for unit |
| /// tests. |
| /// Unfortunately, imports can't be replaced, so the best thing to do is to swap |
| /// out the whole file. |
| /// |
| /// The goal of the SettingsManager is to handle viewing and editing of the |
| /// Croupier Settings. |
| /// loadSettings: Get the settings of the current player or specified userID. |
| /// saveSettings: For the current player and their userID, save settings. |
| /// In the background, these values will be synced. |
| /// When setting up a syncgroup, the userIDs are very important. |
| |
| import '../../logic/game/game.dart' as logic_game; |
| import '../../logic/croupier_settings.dart' show CroupierSettings; |
| import 'croupier_client.dart' show CroupierClient; |
| import 'discovery_client.dart' show DiscoveryClient; |
| import 'util.dart' as util; |
| |
| import 'dart:async'; |
| import 'dart:convert' show UTF8, JSON; |
| |
| import 'package:v23discovery/discovery.dart' as discovery; |
| import 'package:syncbase/syncbase_client.dart' as sc; |
| |
| class SettingsManager { |
| final util.keyValueCallback updateSettingsCallback; |
| final util.keyValueCallback updateGamesCallback; |
| final util.keyValueCallback updatePlayerFoundCallback; |
| final CroupierClient _cc; |
| sc.SyncbaseTable tb; |
| |
| static const String _discoverySettingsKey = "settings"; |
| static const String _personalKey = "personal"; |
| static const String _settingsWatchSyncPrefix = "users"; |
| |
| SettingsManager(this.updateSettingsCallback, this.updateGamesCallback, |
| this.updatePlayerFoundCallback) |
| : _cc = new CroupierClient(); |
| |
| String _settingsDataKey(int userID) { |
| return "${_settingsWatchSyncPrefix}/${userID}/settings"; |
| } |
| |
| String _settingsDataKeyUserID(String dataKey) { |
| List<String> parts = dataKey.split("/"); |
| return parts[parts.length - 2]; |
| } |
| |
| Future _prepareSettingsTable() async { |
| if (tb != null) { |
| return; // Then we're already prepared. |
| } |
| |
| sc.SyncbaseDatabase db = await _cc.createDatabase(); |
| tb = await _cc.createTable(db, util.tableNameSettings); |
| |
| // Start to watch the stream for the shared settings table. |
| Stream<sc.WatchChange> watchStream = db.watch( |
| util.tableNameSettings, _settingsWatchSyncPrefix, UTF8.encode("now")); |
| _startWatchSettings(watchStream); // Don't wait for this future. |
| _loadSettings(tb); // Don't wait for this future. |
| } |
| |
| // Guaranteed to be called when the program starts. |
| // If no Croupier Settings exist, then random ones are created. |
| Future<String> load() async { |
| util.log('SettingsManager.load'); |
| await _prepareSettingsTable(); |
| |
| int userID = await _getUserID(); |
| if (userID == null) { |
| CroupierSettings settings = new CroupierSettings.random(); |
| String jsonStr = settings.toJSONString(); |
| await this.save(settings.userID, jsonStr); |
| return jsonStr; |
| } else { |
| return await _tryReadData(tb, this._settingsDataKey(userID)); |
| } |
| } |
| |
| Future<String> _tryReadData(sc.SyncbaseTable st, String rowkey) async { |
| var row = st.row(rowkey); |
| if (!(await row.exists())) { |
| print("${rowkey} did not exist"); |
| return null; |
| } |
| return UTF8.decode(await row.get()); |
| } |
| |
| // Note: only the current user is allowed to save settings. |
| // This means we can also save their user id. |
| // All other settings will be synced instead. |
| Future save(int userID, String jsonString) async { |
| util.log('SettingsManager.save'); |
| await _prepareSettingsTable(); |
| |
| await tb.row(_personalKey).put(UTF8.encode("${userID}")); |
| await tb.row(this._settingsDataKey(userID)).put(UTF8.encode(jsonString)); |
| } |
| |
| // This watch method ensures that any changes are propagated to the caller. |
| // In the case of the settings manager, we're checking for any changes to |
| // any person's Croupier Settings. |
| Future _startWatchSettings(Stream<sc.WatchChange> watchStream) async { |
| util.log('Settings watching for changes...'); |
| // This stream never really ends, so I guess we'll watch forever. |
| await for (sc.WatchChange wc in watchStream) { |
| assert(wc.tableName == util.tableNameSettings); |
| util.log('Watch Key: ${wc.rowKey}'); |
| util.log('Watch Value ${UTF8.decode(wc.valueBytes)}'); |
| String key = wc.rowKey; |
| String value; |
| switch (wc.changeType) { |
| case sc.WatchChangeTypes.put: |
| value = UTF8.decode(wc.valueBytes); |
| break; |
| case sc.WatchChangeTypes.delete: |
| value = null; |
| break; |
| default: |
| assert(false); |
| } |
| |
| if (this.updateSettingsCallback != null) { |
| this.updateSettingsCallback(_settingsDataKeyUserID(key), value); |
| } |
| } |
| } |
| |
| // Best called after load(), to ensure that there are settings in the table. |
| Future createSettingsSyncgroup() async { |
| int id = await _getUserID(); |
| |
| _cc.createSyncgroup( |
| await _mySettingsSyncgroupName(), util.tableNameSettings, |
| prefix: this._settingsDataKey(id)); |
| } |
| |
| Future<String> _mySettingsSyncgroupName() async { |
| return _cc.makeSyncgroupName(await _syncSettingsSuffix()); |
| } |
| |
| // This watch method ensures that any changes are propagated to the caller. |
| // In this case, we're forwarding any player changes to the Croupier logic. |
| Future _startWatchPlayers(Stream<sc.WatchChange> watchStream) async { |
| util.log('Players watching for changes...'); |
| // This stream never really ends, so I guess we'll watch forever. |
| await for (sc.WatchChange wc in watchStream) { |
| assert(wc.tableName == util.tableNameGames); |
| util.log('Watch Key: ${wc.rowKey}'); |
| util.log('Watch Value ${UTF8.decode(wc.valueBytes)}'); |
| String key = wc.rowKey; |
| String value; |
| switch (wc.changeType) { |
| case sc.WatchChangeTypes.put: |
| value = UTF8.decode(wc.valueBytes); |
| break; |
| case sc.WatchChangeTypes.delete: |
| value = null; |
| break; |
| default: |
| assert(false); |
| } |
| |
| if (this.updatePlayerFoundCallback != null) { |
| String playerID = _getPartFromBack(key, "/", 1); |
| String type = _getPartFromBack(key, "/", 0); |
| switch (type) { |
| case "player_number": |
| // Update the player number for this player. |
| this.updatePlayerFoundCallback(playerID, value); |
| break; |
| case "settings_sg": |
| // Join this player's settings syncgroup. |
| _cc.joinSyncgroup(_cc.makeSyncgroupName(value)); |
| break; |
| default: |
| print("Unexpected key: ${key} with value ${value}"); |
| assert(false); |
| } |
| } |
| } |
| } |
| |
| Future<logic_game.GameStartData> createGameSyncgroup( |
| String type, int gameID) async { |
| print("Creating game syncgroup for ${type} and ${gameID}"); |
| sc.SyncbaseDatabase db = await _cc.createDatabase(); |
| sc.SyncbaseTable gameTable = await _cc.createTable(db, util.tableNameGames); |
| |
| // Watch for the players in the game. |
| Stream<sc.WatchChange> watchStream = db.watch(util.tableNameGames, |
| util.syncgamePrefix(gameID) + "/players", UTF8.encode("now")); |
| _startWatchPlayers(watchStream); // Don't wait for this future. |
| |
| print("Now writing to some rows of ${gameID}"); |
| // Start up the table and write yourself as player 0. |
| await gameTable.row("${gameID}/type").put(UTF8.encode("${type}")); |
| |
| int id = await _getUserID(); |
| await gameTable.row("${gameID}/owner").put(UTF8.encode("${id}")); |
| await gameTable |
| .row("${gameID}/players/${id}/player_number") |
| .put(UTF8.encode("0")); |
| await gameTable |
| .row("${gameID}/players/{$id}/settings_sg") |
| .put(UTF8.encode(await _mySettingsSyncgroupName())); |
| |
| logic_game.GameStartData gsd = |
| new logic_game.GameStartData(type, 0, gameID, id); |
| |
| await _cc.createSyncgroup( |
| _cc.makeSyncgroupName(util.syncgameSuffix("${gsd.gameID}")), |
| util.tableNameGames, |
| prefix: util.syncgamePrefix(gameID)); |
| |
| return gsd; |
| } |
| |
| Future joinGameSyncgroup(String sgName, int gameID) async { |
| print("Now joining game syncgroup at ${sgName} and ${gameID}"); |
| sc.SyncbaseSyncgroup sg = await _cc.joinSyncgroup(sgName); |
| |
| sc.SyncbaseDatabase db = await _cc.createDatabase(); |
| sc.SyncbaseTable gameTable = await _cc.createTable(db, util.tableNameGames); |
| |
| // Watch for the players in the game. |
| Stream<sc.WatchChange> watchStream = db.watch(util.tableNameGames, |
| util.syncgamePrefix(gameID) + "/players", UTF8.encode("now")); |
| _startWatchPlayers(watchStream); // Don't wait for this future. |
| |
| // Also write yourself to the table as player |NUM_PLAYERS - 1| |
| Map<String, sc.SyncgroupMemberInfo> fellowPlayers = await sg.getMembers(); |
| print("I have found! ${fellowPlayers} ${fellowPlayers.length}"); |
| |
| int id = await _getUserID(); |
| int playerNumber = fellowPlayers.length - 1; |
| gameTable |
| .row("${gameID}/players/${id}/player_number") |
| .put(UTF8.encode("${playerNumber}")); |
| } |
| |
| // When starting the settings manager, there may be settings already in the |
| // store. Make sure to load those. |
| Future _loadSettings(sc.SyncbaseTable tb) async { |
| tb |
| .scan(new sc.RowRange.prefix(_settingsWatchSyncPrefix)) |
| .forEach((sc.KeyValue kv) { |
| if (kv.key.endsWith("/settings")) { |
| // Then we can process the value as if it were settings data. |
| this.updateSettingsCallback( |
| _settingsDataKeyUserID(kv.key), UTF8.decode(kv.value)); |
| } |
| }); |
| } |
| |
| // TODO(alexfandrianto): It is possible that the more efficient way of |
| // scanning is to do it for only short bursts. In that case, we should call |
| // stopScanSettings a few seconds after starting it. |
| |
| // Someone who is creating a game should scan for players who wish to join. |
| Future scanSettings() async { |
| SettingsScanHandler ssh = |
| new SettingsScanHandler(_cc, this.updateGamesCallback); |
| return _cc.discoveryClient.scan(_discoverySettingsKey, |
| 'v.InterfaceName="${util.discoveryInterfaceName}"', ssh); |
| } |
| |
| Future stopScanSettings() { |
| return _cc.discoveryClient.stopScan(_discoverySettingsKey); |
| } |
| |
| // Someone who wants to join a game should advertise their presence. |
| Future advertiseSettings(logic_game.GameStartData gsd) async { |
| String settingsSuffix = await _syncSettingsSuffix(); |
| String gameSuffix = util.syncgameSuffix("${gsd.gameID}"); |
| return _cc.discoveryClient.advertise( |
| _discoverySettingsKey, |
| DiscoveryClient.serviceMaker( |
| interfaceName: util.discoveryInterfaceName, |
| attrs: <String, String>{ |
| util.syncgameSettingsAttr: _cc.makeSyncgroupName(settingsSuffix), |
| util.syncgameGameStartDataAttr: gsd.toJSONString() |
| }, |
| addrs: <String>[_cc.makeSyncgroupName(gameSuffix)])); |
| } |
| |
| Future stopAdvertiseSettings() { |
| return _cc.discoveryClient.stopAdvertise(_discoverySettingsKey); |
| } |
| |
| Future<int> _getUserID() async { |
| String result = await _tryReadData(tb, _personalKey); |
| if (result == null) { |
| return null; |
| } |
| return int.parse(result); |
| } |
| |
| Future<String> _syncSettingsSuffix([int userID]) async { |
| int id = userID; |
| if (id == null) { |
| id = await _getUserID(); |
| } |
| |
| return "${util.sgSuffix}-${id}"; |
| } |
| } |
| |
| String _getPartFromBack(String input, String separator, int indexFromLast) { |
| List<String> parts = input.split(separator); |
| return parts[parts.length - 1 - indexFromLast]; |
| } |
| |
| // Implementation of the ScanHandler for Settings information. |
| // Upon finding a settings advertiser, you want to join the syncgroup that |
| // they're advertising. |
| class SettingsScanHandler extends discovery.ScanHandler { |
| CroupierClient _cc; |
| Map<String, String> settingsAddrs; |
| Map<String, String> gameAddrs; |
| util.keyValueCallback updateGamesCallback; |
| |
| SettingsScanHandler(this._cc, this.updateGamesCallback) { |
| settingsAddrs = new Map<String, String>(); |
| gameAddrs = new Map<String, String>(); |
| } |
| |
| void found(discovery.Service s) { |
| util.log( |
| "SettingsScanHandler Found ${s.instanceId} ${s.instanceName} ${s.addrs}"); |
| |
| if (s.addrs.length == 1 && s.attrs != null) { |
| // Note: Assumes 1 address and attributes for the game. |
| settingsAddrs[s.instanceId] = s.attrs[util.syncgameSettingsAttr]; |
| gameAddrs[s.instanceId] = s.addrs[0]; |
| |
| String gameSettingsJSON = s.attrs[util.syncgameGameStartDataAttr]; |
| updateGamesCallback(gameAddrs[s.instanceId], gameSettingsJSON); |
| |
| _cc.joinSyncgroup(settingsAddrs[s.instanceId]); |
| } else { |
| // An unexpected service was found. Who is advertising it? |
| // https://github.com/vanadium/issues/issues/846 |
| util.log("Unexpected service found: ${s.toString()}"); |
| } |
| } |
| |
| void lost(String instanceId) { |
| util.log("SettingsScanHandler Lost ${instanceId}"); |
| |
| // TODO(alexfandrianto): Leave the syncgroup? |
| // Looks like leave isn't actually implemented, so we can't do this. |
| String addr = gameAddrs[instanceId]; |
| if (addr != null) { |
| List<String> parts = addr.split("-"); |
| String gameID = parts[parts.length - 1]; |
| updateGamesCallback(gameID, null); |
| } |
| settingsAddrs.remove(instanceId); |
| gameAddrs.remove(instanceId); |
| } |
| } |