This document is about: QUANTUM 2
SWITCH TO

코드

리플레이 저장 / 입력 기록 코드

입력 기록은 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 데이터 구조체를 사용하여 필요한 구성 파일로 완전한 리플레이를 생성합니다. 직렬 화기는 JSON을 출력하는 QuantumJsonSerializer입니다.

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);
      }
    }
  }
}

유니티 테스트 코드

  • 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);
    }
  }
}
Back to top