Snippets
HTTP請求
自訂Quantum外掛程式
延遲外掛程式回調,比如在建立遊戲(),以從一個受信任的後端來擷取任何特定的房間,並且立即阻擋房間建立。針對以一個資訊物件(在加入(),...)調用房間回調,同樣的情形是可能的。
C#
public override void OnCreateGame(ICreateGameCallInfo info) {
  var request = new HttpRequest() { Url = "http://microsoft.com", Async = false, Callback = OnCreateGameContinue };
  PluginHost.HttpRequest(request, info);
  // Do not call base.OnCreateGame() to prevent the continuation
}
private void OnCreateGameContinue(IHttpResponse response, object userState) {
  // Complete the OnCreateGame() call
  base.OnCreateGame((ICreateGameCallInfo)response.CallInfo);
}
自訂Quantum伺服器
延遲Quantum伺服器回調,以透過從一個受信任的後端擷取的資料來覆寫RuntimePlayer。
IPluginHost.HttpRequest(request, info)可透過info = null來使用。
請求必須是**Async = true**。
C#
public override bool OnDeterministicPlayerDataSet(DeterministicPluginClient client, SetPlayerData playerData) {
  // Use client.ClientId as unique client id (UserId)
  var request = new HttpRequest() { Url = "http://microsoft.com", Async = true, Callback = OnDeterministicPlayerDataSetContinue, UserState = playerData.Index };
  ((DeterministicPlugin)PluginHost).PluginHost.HttpRequest(request, null);
  // Return false to not conntinue with SetPlayerData request
  return false;
}
private void OnDeterministicPlayerDataSetContinue(IHttpResponse response, object userState) {
  // Reponse sends player data in json for example: deserialize json into RuntimePlayer
  var runtimePlayer = new RuntimePlayer();
  var playerData = RuntimePlayer.ToByteArray(runtimePlayer);
  var playerIndex = (int)userState;
  var actorIdBytes = BitConverter.GetBytes(GetClientActorId(playerIndex));
  // Continue player data request
  Session?.Input.SetPlayerData(playerIndex, ByteUtils.MergeByteBlocks(playerData, actorIdBytes), false);
}
儲存重播/輸入歷史程式碼片段
輸入歷史是一個DeterministicTickInputSet物件的陣列(刷新數目),其進而針對各個玩家儲存輸入:
C#
public struct DeterministicTickInputSet {
  public int Tick;
  public DeterministicTickInput[] Inputs;
}
在OnDeterministicInputConfirmed()回調時,在InputProvider類別中儲存輸入。這是當針對一個玩家的輸入已經被確認。或是建立一個自訂的相似的資料架構。
C#
public override void OnDeterministicSessionConfig(DeterministicPluginClient client, SessionConfig configData)
{
  _config = configData.Config;
}
C#
public override void OnDeterministicStartSession() {
  _inputProvider = new InputProvider(_config);
}
C#
public override void OnDeterministicInputConfirmed(DeterministicPluginClient client, int tick, int playerIndex, DeterministicTickInput input) {
  _inputProvider.InjectInput(input, true);
}
使用ReplayFile資料架構,以透過所需要的組態檔案來建立一個完整的重播。序列化程式是一個QuantumJsonSerializer,其輸出JSON。
C#
private void SaveReplayToFile(int verifiedFrame) {
  var replayFile = new ReplayFile {
    DeterministicConfig = container.DeterministicConfig,
    RuntimeConfig = container.RuntimeConfig,
    InputHistory = _inputProvider.ExportToList(verifiedFrame),
    Length = verifiedFrame
  };
  var filepath = Path.Combine(PluginLocation, "replay.json");
  File.WriteAllBytes(filepath, _serializer.SerializeReplay(replayFile));
}
儲存ReplayFile,而不需要知道最高的已驗證刷新。
C#
private void SaveReplayToFile() {
  // This will not cut out incomplete input in the end, but we should be able to live with it
  var inputSets = _inputProvider.ExportToList(int.MaxValue);
  // Find out what the highest verified tick that has a complete input set (for all players)
  int maxVerifiedTick = 0;
  for (int i = inputSets.Length - 1; i >= 0; i--) {
    if (inputSets[i].IsComplete()) {
      maxVerifiedTick = inputSets[i].Tick;
      break;
    }
  }
  var replayFile = new ReplayFile {
    DeterministicConfig = container.DeterministicConfig,
    RuntimeConfig = container.RuntimeConfig,
    InputHistory = inputSets,
    Length = maxVerifiedTick
  };
  var filepath = Path.Combine(PluginLocation, "replay.json");
  File.WriteAllBytes(filepath, _serializer.SerializeReplay(replayFile));
}
伺服器命令程式碼片段
一個程式碼片段展示了如何攔截客戶端發送的命令,可能會拒絕它們並且從伺服器本身發送命令。
C#
private DeterministicCommandSerializer _cmdSerializer;
public override bool OnDeterministicCommand(DeterministicPluginClient client, Command cmd) {
  if (_cmdSerializer == null) {
    _cmdSerializer = new DeterministicCommandSerializer();
    _cmdSerializer.RegisterFactories(DeterministicCommandSetup.GetCommandFactories(runtimeConfig, null));
    _cmdSerializer.CommandSerializerStreamRead.Reading = true;
    _cmdSerializer.CommandSerializerStreamWrite.Writing = true;
  }
  var stream = _cmdSerializer.CommandSerializerStreamRead;
  stream.SetBuffer(cmd.Data);
  if (_cmdSerializer.ReadNext(stream, out var command)) {
    // handle DeterministicCommand
    // return false if a command should be rejected from the (or any) client
    if (command is TestCommand testCmd) {
      return false;
    }
  }
  return true;
}
public void SendDeterministicCommand(DeterministicCommand cmd) {
  if (_cmdSerializer == null) {
    _cmdSerializer = new DeterministicCommandSerializer();
    _cmdSerializer.RegisterFactories(DeterministicCommandSetup.GetCommandFactories(runtimeConfig, null));
    _cmdSerializer.CommandSerializerStreamRead.Reading = true;
    _cmdSerializer.CommandSerializerStreamWrite.Writing = true;
  }
  var stream = _cmdSerializer.CommandSerializerStreamWrite;
  stream.Reset(stream.Capacity);
  if (_cmdSerializer.PackNext(stream, cmd)) {
    SendDeterministicCommand(new Command {
      Index = 0,
      Data = stream.ToArray(),
    });
    // optional: pool byte arrays and use them instead of allocating with ToArray()
    // Buffer.BlockCopy(stream.Data, stream.Offset, pooledByteArray, 0, stream.BytesRequired);
  }
}
多數決投票
- 客戶端上傳他們的遊戲結果到伺服器,伺服器等到一個多數決投票發出後,才計算一個結果以發布。
- 使用一個自訂的c#類別,來代表結果。在這個實例中,伺服器外掛程式只在二進位資料上操作,其就檢查相同結果而言已經足夠。但是當資訊被轉傳到一個自訂後端(未被執行),如果後端期待其他資料格式,則伺服器外掛程式在發送程式碼之前需要取消序列化的程式碼(比如參照到game.dll)。
更改到自訂Quantum外掛程式類別
- 在關閉房間之前強制進行一個評估
- 覆寫OnRaiseEvent()以篩選出自訂訊息,總是取消訊息,這樣它不會被轉傳到其他客戶端。
C#
using Photon.Deterministic;
using Photon.Deterministic.Server.Interface;
using Photon.Hive.Plugin;
namespace Quantum {
  public class CustomQuantumPlugin : DeterministicPlugin {
    protected CustomQuantumServer _server;
    public CustomQuantumPlugin(IServer server) : base(server) {
      Assert.Check(server is CustomQuantumServer);
      _server = (CustomQuantumServer)server;
    }
    public override void OnCloseGame(ICloseGameCallInfo info) {
      EvaluateMajorityVote(true);
      _majorityVote?.Dispose();
      _majorityVote = null;
      _server.Dispose();
      base.OnCloseGame(info);
    }
    private MajorityVote _majorityVote = new MajorityVote(2);
    private void EvaluateMajorityVote(bool force) {
      if (_majorityVote != null) {
        if (force || _majorityVote.IsReady || _majorityVote.IsWaitingTimeOver) {
          if (_majorityVote.Evaluate(out var results)) {
            // Send data somewhere
            //results[0].Data
            Log.Warn($"Game result accepted with {results[0].Count}");
            _majorityVote.Dispose();
            _majorityVote = null;
          }
        }
      }
    }
    public override void OnRaiseEvent(IRaiseEventCallInfo info) {
      if (info.Request.EvCode == 41) {
        // Cancel the message right away, it should not be send to anyone else
        info.Cancel();
        var client = _server.GetClientForActor(info.ActorNr);
        if (client == null) {
          // Dismiss the message when the client has already left
          return;
        }
        if (info.Request.Data == null) {
          // Client send no data, disconnect
          _server.DisconnectClient(client, "Operation Failed");
          return;
        }
        if (_majorityVote == null) {
          // Vote is over
          return;
        }
        _majorityVote.AddVote(client.ClientId, (byte[])info.Request.Data);
        EvaluateMajorityVote(false);
        // Don't process message any further
        return;
      }
      base.OnRaiseEvent(info);
    }
  }
}
多數決投票類別
最小需求投票及等待時間設定的時機點,取決於實際的遊戲:有多少玩家?遊戲是否有團隊?等等。
可能有些事情比StructuralComparisons.StructuralEqualityComparer更快。雖然Linq提供好的集合工具,但不在伺服器程式碼上使用它。
C#
using System;
using System.Collections;
using System.Collections.Generic;
using System.Security.Cryptography;
namespace Quantum {
  /// <summary>
  /// Trying to not use Linq for performance.
  /// Only compares binary data, serialzed by the client.
  ///   The final result should be send to backend to deserialize or deserialize on custom plugin with correct references.
  /// </summary>
  public class MajorityVote : IDisposable {
    private bool _startWaitTimeSet;
    private DateTime _startWaitTime;
    private double _minWaitTimeSec;
    private double _maxWaitTime;
    private int _minVotesRequired;
    private List<ClientResult> _clientResults;
    private HashSet<string> _clientResultsHashset;
    private MD5CryptoServiceProvider _hashProvider;
    /// <summary>
    /// There is at least one result and at least MinVotesRequired.
    /// The MinWaitingTime has passed.
    /// </summary>
    public bool IsReady {
      get {
        return
          _clientResults.Count >= _minVotesRequired &&
          (DateTime.Now - _startWaitTime).TotalSeconds >= _minWaitTimeSec;
      }
    }
    /// <summary>
    /// The MaxWaitingTime has passed a result be tried to evaluate now.
    /// </summary>
    public bool IsWaitingTimeOver {
      get {
        return
          _maxWaitTime > 0 &&
          (DateTime.Now - _startWaitTime).TotalSeconds >= _maxWaitTime;
      }
    }
    /// <summary>
    /// Number of client votes, indentified by their ClientId.
    /// </summary>
    public int Count => _clientResults.Count;
    /// <summary>
    /// Create a voting machine.
    /// </summary>
    /// <param name="minVotesRequired">The minimal votes required to make IsReady return true</param>
    /// <param name="minWaitTimeSec">The min wait tim in seconds to make IsReady return true</param>
    /// <param name="maxWaitTime">The max wait time in seconds to make IsWaitingTimeOver return true, 0 = undefinately</param>
    public MajorityVote(int minVotesRequired, double minWaitTimeSec = 0, double maxWaitTime = 0) {
      _minVotesRequired = Math.Max(minVotesRequired, 1);
      _minWaitTimeSec = minWaitTimeSec;
      _maxWaitTime = maxWaitTime;
      _clientResults = new List<ClientResult>();
      _clientResultsHashset = new HashSet<string>();
      _hashProvider = new MD5CryptoServiceProvider();
    }
    /// <summary>
    /// Disposes the hash provider object and clears internal lists.
    /// </summary>
    public void Dispose() {
      _clientResults?.Clear();
      _clientResults = null;
      _clientResultsHashset?.Clear();
      _clientResultsHashset = null;
      _hashProvider?.Dispose();
      _hashProvider = null;
    }
    /// <summary>
    /// Add a vote for a ClientId. If a client already passed the vote subsequent times are ignored.
    /// </summary>
    /// <param name="clientId">The clients id</param>
    /// <param name="data">The result as byte[] array</param>
    public void AddVote(string clientId, byte[] data) {
      if (_clientResultsHashset.Contains(clientId) == false) {
        var hash = _hashProvider.ComputeHash(data);
        var result = new ClientResult { ClientId = clientId, Result = data, Hash = hash };
        _clientResults.Add(result);
        _clientResultsHashset.Add(clientId);
      }
      if (_startWaitTimeSet == false && Count >= 2) {
        // Only start the waitime when two votes have been cast. Two votes because one would be too easy to missuse.
        _startWaitTime = DateTime.Now;
        _startWaitTimeSet = true;
      }
    }
    /// <summary>
    /// Run majority vote for the votes that have been cast.
    /// </summary>
    /// <param name="finalResults">Summary of the results, sorted by count</param>
    /// <returns>True if there has been consensus.</returns>
    public bool Evaluate(out List<FinalResult> finalResults) {
      var map = new Dictionary<byte[], FinalResult>(ByteArrayComparer.Default);
      var majority = 0;
      if (_clientResults.Count % 2 == 0) {
        majority = _clientResults.Count / 2 + 1;
      }
      else {
        majority = (int)Math.Ceiling(_clientResults.Count / (double)2);
      }
      // Compare each result hash with eath other
      for (int i = 0; i < _clientResults.Count; i++) {
        var vote = default(FinalResult);
        if (map.TryGetValue(_clientResults[i].Hash, out vote) == false) {
          vote = new FinalResult { ClientIds = new List<string>(), Result = _clientResults[i].Result };
          map.Add(_clientResults[i].Hash, vote);
        }
        vote.ClientIds.Add(_clientResults[i].ClientId);
        vote.Count++;
      }
      // Sort
      finalResults = new List<FinalResult>();
      foreach (var v in map) {
        finalResults.Add(v.Value);
      }
      finalResults.Sort(FinalResult.CompareByCount);
      if (finalResults.Count > 0 && finalResults[0].Count >= majority) {
        return true;
      }
      return false;
    }
    public class FinalResult {
      public byte[] Result;
      public int Count;
      public List<string> ClientIds;
      public static int CompareByCount(FinalResult a, FinalResult b) {
        return a.Count.CompareTo(b.Count);
      }
    }
    private class ClientResult {
      public string ClientId;
      public byte[] Result;
      public byte[] Hash;
    }
    private class ByteArrayComparer : IEqualityComparer<byte[]> {
      private static ByteArrayComparer _default;
      public static ByteArrayComparer Default {
        get {
          if (_default == null) {
            _default = new ByteArrayComparer();
          }
          return _default;
        }
      }
      public bool Equals(byte[] a, byte[] b) {
        return StructuralComparisons.StructuralEqualityComparer.Equals(a, b);
      }
      public int GetHashCode(byte[] obj) {
        return StructuralComparisons.StructuralEqualityComparer.GetHashCode(obj);
      }
    }
  }
}
Unity測試程式碼
- 使用OpRaiseEvent()發送到外掛程式
- 使用ByteArraySlice以發送二進位資料,以減少進一步的配置
- 結果應該從一個已驗證幀來取得,並且它應該在各個(未經篡改的)客戶端上是相同的
C#
using ExitGames.Client.Photon;
using Photon.Realtime;
using Quantum.Demo;
using UnityEngine;
public class SendGameResult : MonoBehaviour {
  private readonly ByteArraySlice _sendSlice = new ByteArraySlice();
  private class GameResult {
    public struct Place {
      public string PlayerId;
      public int Points;
    }
    public Place[] Ranking;
    public void Serialize(Photon.Deterministic.BitStream stream) {
      foreach (var r in Ranking) {
        stream.WriteString(r.PlayerId);
        stream.WriteInt(r.Points);
      }
    }
  }
  private void Update() {
    // Use ByteSliceArray for optimization (non-alloc)
    // Gather results from a verified frame only (otherwise prediciton can differ)
    if (Input.GetKeyDown(KeyCode.Space)) {
      var gameResult = new GameResult { Ranking = new GameResult.Place[] {
        new GameResult.Place { PlayerId = "a", Points = 1 },
        new GameResult.Place { PlayerId = "b", Points = 2 } }
      };
      var stream = new Photon.Deterministic.BitStream(new byte[100 * 1024]);
      gameResult.Serialize(stream);
      _sendSlice.Buffer = stream.Data;
      _sendSlice.Count = stream.BytesRequired;
      _sendSlice.Offset = 0;
      UIMain.Client.OpRaiseEvent(41, _sendSlice, RaiseEventOptions.Default, SendOptions.SendReliable);
    }
  }
}