Files
soccar2d/Assets/Scripts/GameManager.cs
T

1636 lines
55 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.Networking;
using UnityEngine.Serialization;
using TMPro;
using Mirror;
public class GameManager : NetworkBehaviour
{
public static Team MyTeam { get; set; }
public static MatchmadePlayer RedPlayer { get; set; }
public static MatchmadePlayer BluePlayer { get; set; }
/// <summary>Coin units from matchmaker <c>rc_prize</c>; used for in-game prize UI.</summary>
public static int MatchRcPrizeCoins { get; set; }
/// <summary>Set by dedicated server startup (e.g. CupidConnector parsing <c>-matchId</c>). PATCH is skipped if id ≤ 0 or secret is empty.</summary>
public static int DedicatedMatchId { get; private set; }
/// <summary>Sent as <c>X-Dedicated-Server-Secret</c>. From <c>-dedicatedSecret</c> arg or <c>DEDICATED_SERVER_SECRET</c> env.</summary>
public static string DedicatedMatchSecret { get; private set; } = "";
/// <summary>Base URL for internal match API (no trailing slash), default <c>http://127.0.0.1:2612</c>.</summary>
public static string DedicatedInternalApiBase { get; private set; } = LoginManager.AuthBaseUrl;
public static void ConfigureDedicatedMatchReporting(int matchId, string secret=null, string internalApiBaseUrl = null)
{
DedicatedMatchId = matchId;
if (string.IsNullOrEmpty(secret))
secret = Environment.GetEnvironmentVariable("DEDICATED_SERVER_SECRET");
DedicatedMatchSecret = string.IsNullOrEmpty(secret)
? "38516e39d3406225b7c3015a66e5d25c54e9b2024c0755212122bdcea3e3a328"
: secret;
if (!string.IsNullOrWhiteSpace(internalApiBaseUrl))
DedicatedInternalApiBase = internalApiBaseUrl.TrimEnd('/');
Logger.Log($"Configured dedicated match reporting for match id {matchId} with secret {DedicatedMatchSecret} and internal api base {DedicatedInternalApiBase}");
}
/// <summary>Outcome of <see cref="RequestDedicatedRematch"/> (POST <c>/internal/rematch</c>).</summary>
public readonly struct DedicatedRematchResult
{
public readonly bool Success;
public readonly long HttpStatusCode;
public readonly MatchmadeResponse Response;
public readonly string ErrorMessage;
public DedicatedRematchResult(bool success, long httpStatusCode, MatchmadeResponse response, string errorMessage)
{
Success = success;
HttpStatusCode = httpStatusCode;
Response = response;
ErrorMessage = errorMessage ?? "";
}
}
/// <summary>
/// Dedicated server only: POST <c>/internal/rematch</c> with <c>X-Dedicated-Server-Secret</c>.
/// Blocks until the request finishes (same pattern as <see cref="ReportDedicatedMatchRoomClosed"/>).
/// On HTTP 200, returns the same JSON envelope as a successful matchmaker GET room fill: <see cref="MatchmadeResponse"/>.
/// </summary>
/// <param name="userRedId"><c>users.id</c> for red.</param>
/// <param name="userBlueId"><c>users.id</c> for blue; must differ from red.</param>
/// <param name="entryFee">RC stake per player (non-negative).</param>
/// <param name="secret">Overrides <see cref="DedicatedMatchSecret"/> when non-empty.</param>
/// <param name="internalApiBaseUrl">Overrides <see cref="DedicatedInternalApiBase"/> when non-empty.</param>
public static DedicatedRematchResult RequestDedicatedRematch(int userRedId, int userBlueId, int entryFee, string secret = null, string internalApiBaseUrl = null)
{
string s = string.IsNullOrEmpty(secret) ? DedicatedMatchSecret : secret;
string baseUrl = string.IsNullOrEmpty(internalApiBaseUrl) ? DedicatedInternalApiBase : internalApiBaseUrl.TrimEnd('/');
if (string.IsNullOrEmpty(s))
return new DedicatedRematchResult(false, 0, null, "Missing dedicated server secret");
if (string.IsNullOrEmpty(baseUrl))
return new DedicatedRematchResult(false, 0, null, "Missing internal API base URL");
string json = JsonUtility.ToJson(new DedicatedRematchRequestBody
{
user_red_id = userRedId,
user_blue_id = userBlueId,
entry_fee = entryFee
});
using (var req = DedicatedMatschInternalApi.BuildRematchRequest(baseUrl, s, json))
{
var op = req.SendWebRequest();
while (!op.isDone)
System.Threading.Thread.Sleep(16);
string text = req.downloadHandler != null ? req.downloadHandler.text : "";
long code = req.responseCode;
if (req.result != UnityWebRequest.Result.Success &&
req.result != UnityWebRequest.Result.ProtocolError)
{
string netErr = string.IsNullOrEmpty(req.error) ? "Network error" : req.error;
Logger.Log("Dedicated rematch POST failed (transport): " + netErr + " " + req.url);
return new DedicatedRematchResult(false, code, null, netErr);
}
if (code == 200)
{
try
{
var env = JsonUtility.FromJson<MatchmadeResponse>(text);
if (env != null && env.ok)
return new DedicatedRematchResult(true, code, env, "");
string apiErr = TryParseRematchErrorMessage(text);
Logger.Log("Dedicated rematch POST 200 but ok=false or parse issue: " + text);
return new DedicatedRematchResult(false, code, env, string.IsNullOrEmpty(apiErr) ? "Unexpected response" : apiErr);
}
catch (Exception e)
{
Logger.Log("Dedicated rematch POST: could not parse success body: " + e.Message + " body=" + text);
return new DedicatedRematchResult(false, code, null, e.Message);
}
}
string err = TryParseRematchErrorMessage(text);
if (string.IsNullOrEmpty(err))
err = string.IsNullOrEmpty(text) ? "HTTP " + code : text;
Logger.Log("Dedicated rematch POST failed: " + code + " " + err);
return new DedicatedRematchResult(false, code, null, err);
}
}
static string TryParseRematchErrorMessage(string json)
{
if (string.IsNullOrEmpty(json))
return "";
try
{
var err = JsonUtility.FromJson<DedicatedRematchErrorEnvelope>(json);
if (err != null && !string.IsNullOrEmpty(err.error))
return err.error;
}
catch
{
/* ignore */
}
return "";
}
/// <summary>
/// If entry fees were collected and no winner was reported yet, settle via blocking <c>/winner</c>
/// (remaining player or last-disconnect-wins) before room close / quit.
/// </summary>
public static void SettleWinnerIfNeededBeforeShutdown()
{
if (instance == null)
return;
instance.SettleWinnerIfNeededBeforeShutdownInstance();
}
/// <summary>Sets match <c>status</c> to <c>-1</c> (room closed). Settles winner first when escrow is open. Synchronous for <c>Application.Quit</c>.</summary>
public static void ReportDedicatedMatchRoomClosed()
{
SettleWinnerIfNeededBeforeShutdown();
if (_dedicatedRoomClosedReported || DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret))
return;
_dedicatedRoomClosedReported = true;
using (var req = DedicatedMatschInternalApi.BuildRequest(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, "{\"status\":-1}"))
{
var op = req.SendWebRequest();
while (!op.isDone)
System.Threading.Thread.Sleep(16);
DedicatedMatschInternalApi.LogIfFailed(req);
}
}
static bool _dedicatedRoomClosedReported;
static string Iso8601UtcNow() => DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", System.Globalization.CultureInfo.InvariantCulture);
const float DisconnectForfeitGraceSeconds = 15f;
const int ConsecutiveSkipsToForfeit = 2;
bool _dedicatedReportedRedJoin;
bool _dedicatedReportedBlueJoin;
bool _entriesCollected;
bool _collectFailed;
bool _winnerSettled;
bool _replayRecordingStarted;
bool _redConnected;
bool _blueConnected;
float _redForfeitDeadline = -1f;
float _blueForfeitDeadline = -1f;
readonly List<Team> _disconnectOrder = new List<Team>();
int _consecutiveSkipsRed;
int _consecutiveSkipsBlue;
public static Action<Team> OnTeamChanged;
public static bool isMoving{
get{
return instance.IsMoving();
}
}
[SyncVar]
public bool m_isMoving =false;
[SyncVar(hook = nameof(OnGameStartedChanged))]
public bool gameStarted = false;
[SyncVar]
public bool isGameEnded = false;
void OnGameStartedChanged(bool oldStarted, bool newStarted){
if(newStarted){
GameCanvas.instance.HideWaitingForOpponentPanel();
if (AudioManager.instance != null)
AudioManager.instance.SetCrowdNoiseType(CrowdNoiseType.Normal);
}else{
GameCanvas.instance.ShowWaitingForOpponentPanel();
}
}
[SyncVar(hook = nameof(OnSelectedTeamChanged))]
public Team SelectedTeam = Team.Red;
void OnSelectedTeamChanged(Team oldTeam, Team newTeam){
Logger.Log($"Selected team changed from {oldTeam} to {newTeam}");
OnTeamChanged?.Invoke(newTeam);
if (!isClient || !gameStarted || oldTeam == newTeam)
return;
if (AudioManager.instance != null)
AudioManager.instance.PlayRefereeWhistle();
}
public static bool NoPuckSelected {
get{
return instance.selectedPuck == null;
}
}
public int turnTimer = 10;
[SyncVar]
public float turnTimerCounter;
public Rigidbody2D ball;
public float puckForce = 20f;
public AnimationCurve puckForceCurve = AnimationCurve.Linear(0,3,1,1);
public float ballMoveTime = 3f;
public float puckDragClampMax = 5f;
public float puckDragMinClamp = 1f;
public float puckSelectMaxDistance = 4f;
[Header("Field bounds")]
[Tooltip("Pitch width (X). Length (Y) is fieldSize × 2. Origin-centered; used for boundary-based camera zoom while dragging.")]
public float fieldSize = 10f;
[Tooltip("Exponent on distance-to-boundary (01). >1 = subtle at center, ramps up mostly near the boundary; 1 = linear.")]
[Min(0.01f)] public float cameraStretchBoundaryExponent = 2.5f;
[SyncVar(hook = nameof(OnScoreChanged))]
public int redScore=0;
[SyncVar(hook = nameof(OnScoreChanged))]
public int blueScore=0;
[Header("Effects")]
public ParticleSystem[] redGoalEffects;
public ParticleSystem[] blueGoalEffects;
[Header("UI")]
public TMP_Text redScoreText;
public TMP_Text blueScoreText;
public CanvasGroupUtils blueTimerGroup;
[FormerlySerializedAs("timerGroup")] public CanvasGroupUtils redTimerGroup;
[Header("Timer Flash Settings")]
[Tooltip("Minimum flash speed when timer is at 5 seconds")]
public float minFlashSpeed = 10f;
[Tooltip("Maximum flash speed when timer is at 0 seconds")]
public float maxFlashSpeed = 20f;
[Header("Misc")]
public List<Puck> pucks = new List<Puck>();
public float curPuckPullForce;
[SyncVar]
public int hitCounter = 0;
public static GameManager instance;
public float puckMass{
get{
if(pucks.Count > 0){return pucks[0].rb.mass;}else{return 1f;}
}set{
foreach(Puck puck in pucks){
puck.rb.mass = value;
}
}
}
public float puckDrag{
get{
if(pucks.Count > 0){return pucks[0].rb.linearDamping;}else{return 0f;}
}set{
foreach(Puck puck in pucks){
puck.rb.linearDamping = value;
}
}
}
void Awake()
{
instance = this;
}
/// <summary>
/// Blends from <paramref name="centerMultiplier"/> at the pitch center to <paramref name="boundaryMultiplier"/>
/// at the boundary (and beyond), using the max normalized axis distance inside the field rectangle.
/// </summary>
public float GetCameraStretchDistanceMultiplier(Vector3 puckWorldPosition, float centerMultiplier = 0.3f, float boundaryMultiplier = 1f)
{
if (fieldSize <= Mathf.Epsilon)
return boundaryMultiplier;
float halfWidth = fieldSize * 0.5f;
float halfHeight = fieldSize;
float nx = Mathf.Abs(puckWorldPosition.x) / halfWidth;
float ny = Mathf.Abs(puckWorldPosition.y) / halfHeight;
float t = Mathf.Clamp01(Mathf.Max(nx, ny));
float shaped = Mathf.Pow(t, cameraStretchBoundaryExponent);
return Mathf.Lerp(centerMultiplier, boundaryMultiplier, shaped);
}
void OnDrawGizmos()
{
if (fieldSize <= Mathf.Epsilon)
return;
float halfWidth = fieldSize * 0.5f;
float halfHeight = fieldSize;
Vector3 c = Vector3.zero;
Vector3 right = new Vector3(halfWidth, 0f, 0f);
Vector3 up = new Vector3(0f, halfHeight, 0f);
Vector3 a = c - right - up;
Vector3 b = c + right - up;
Vector3 d = c + right + up;
Vector3 e = c - right + up;
Color prev = Gizmos.color;
Gizmos.color = new Color(1f, 0.92f, 0.016f, 0.85f);
Gizmos.DrawLine(a, b);
Gizmos.DrawLine(b, d);
Gizmos.DrawLine(d, e);
Gizmos.DrawLine(e, a);
Gizmos.color = prev;
}
/// <summary>Server-only: called from <see cref="NetPlayer.CmdSetTeam"/> after <c>myTeam</c> is set.</summary>
public void OnMatchPlayerTeamAssigned(Team team)
{
Logger.Log($"Dedicated join: team assigned {team} isServer={isServer} matchId={DedicatedMatchId} secretEmpty={string.IsNullOrEmpty(DedicatedMatchSecret)} redJoin={_dedicatedReportedRedJoin} blueJoin={_dedicatedReportedBlueJoin} collected={_entriesCollected}");
if (!isServer)
return;
if (team == Team.Red)
{
_redConnected = true;
_redForfeitDeadline = -1f;
}
else if (team == Team.Blue)
{
_blueConnected = true;
_blueForfeitDeadline = -1f;
}
if (DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret))
{
Logger.Log($"Dedicated join: skipping PATCH (matchId={DedicatedMatchId}, secretEmpty={string.IsNullOrEmpty(DedicatedMatchSecret)})");
return;
}
int n = FindObjectsByType<NetPlayer>(FindObjectsSortMode.None).Length;
string ts = Iso8601UtcNow();
if (team == Team.Red && !_dedicatedReportedRedJoin)
{
_dedicatedReportedRedJoin = true;
int status = n >= 2 ? 2 : 1;
bool collectAttempt = _dedicatedReportedRedJoin && _dedicatedReportedBlueJoin;
string json = "{\"red_joined_at\":\"" + ts + "\",\"status\":" + status + "}";
Logger.Log($"Dedicated join: starting red PATCH collectAttempt={collectAttempt} players={n} body={json}");
StartCoroutine(CoDedicatedMatchJoinPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, json, collectAttempt));
}
else if (team == Team.Blue && !_dedicatedReportedBlueJoin)
{
_dedicatedReportedBlueJoin = true;
int status = n >= 2 ? 2 : 1;
bool collectAttempt = _dedicatedReportedRedJoin && _dedicatedReportedBlueJoin;
string json = "{\"blue_joined_at\":\"" + ts + "\",\"status\":" + status + "}";
Logger.Log($"Dedicated join: starting blue PATCH collectAttempt={collectAttempt} players={n} body={json}");
StartCoroutine(CoDedicatedMatchJoinPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, json, collectAttempt));
}
else
{
Logger.Log($"Dedicated join: no new PATCH for {team} (already reported or unknown team)");
}
}
/// <summary>Server-only: Mirror disconnect — track order and start forfeit grace after collect + kickoff.</summary>
public void OnDedicatedMatchPlayerDisconnected(Team team)
{
if (!isServer)
return;
if (team != Team.Red && team != Team.Blue)
return;
if (team == Team.Red)
{
if (!_redConnected)
return;
_redConnected = false;
_redForfeitDeadline = Time.realtimeSinceStartup + DisconnectForfeitGraceSeconds;
}
else
{
if (!_blueConnected)
return;
_blueConnected = false;
_blueForfeitDeadline = Time.realtimeSinceStartup + DisconnectForfeitGraceSeconds;
}
_disconnectOrder.Add(team);
Logger.Log($"Dedicated match: {team} disconnected (order count={_disconnectOrder.Count})");
if (!_entriesCollected || _winnerSettled || !gameStarted)
return;
// Both gone → last player who disconnected wins immediately.
if (!_redConnected && !_blueConnected && _disconnectOrder.Count > 0)
EndMatch(_disconnectOrder[_disconnectOrder.Count - 1]);
}
IEnumerator CoDedicatedMatchJoinPatch(string baseUrl, int matchId, string secret, string json, bool collectAttempt)
{
if (matchId <= 0 || string.IsNullOrEmpty(secret))
{
Logger.Log($"Dedicated join PATCH: abort before send (matchId={matchId}, secretEmpty={string.IsNullOrEmpty(secret)})");
yield break;
}
using (var req = DedicatedMatschInternalApi.BuildRequest(baseUrl, matchId, secret, json))
{
Logger.Log("Dedicated join PATCH BEFORE: url=" + req.url
+ " method=" + req.method
+ " collectAttempt=" + collectAttempt
+ " body=" + json
+ " base=" + baseUrl);
yield return req.SendWebRequest();
long code = req.responseCode;
string body = req.downloadHandler != null ? req.downloadHandler.text : "";
Logger.Log("Dedicated join PATCH AFTER: result=" + req.result
+ " http=" + code
+ " error=" + (req.error ?? "")
+ " collectAttempt=" + collectAttempt
+ " body=" + body);
if (code >= 200 && code < 300)
{
DedicatedMatschInternalApi.LogIfFailed(req);
try
{
var parsed = JsonUtility.FromJson<DedicatedJoinPatchResponse>(body);
bool collected = parsed != null && (parsed.entries_collected || parsed.already_collected);
Logger.Log("Dedicated join PATCH parsed: ok=" + (parsed != null && parsed.ok)
+ " id=" + (parsed != null ? parsed.id : 0)
+ " entries_collected=" + (parsed != null && parsed.entries_collected)
+ " already_collected=" + (parsed != null && parsed.already_collected)
+ " escrow_user_id=" + (parsed != null ? parsed.escrow_user_id : 0)
+ " willSetCollected=" + collected);
if (collected)
{
_entriesCollected = true;
Logger.Log("Dedicated match: entry fees collected into escrow"
+ (parsed.already_collected ? " (already_collected)" : ""));
}
else
{
Logger.Log("Dedicated join PATCH: 2xx but entries not collected yet (waiting for other join or unexpected body)");
}
}
catch (Exception e)
{
Logger.Log("Dedicated join PATCH: could not parse body: " + e.Message + " body=" + body);
}
yield break;
}
DedicatedMatschInternalApi.LogIfFailed(req);
// Collect runs on the join that sets both timestamps; non-2xx then is fatal.
if (collectAttempt)
{
_collectFailed = true;
Logger.Log("Dedicated match: collect failed (HTTP " + code + ") — aborting kickoff, no winner call");
AbortMatchCollectFailed();
}
else
{
Logger.Log("Dedicated join PATCH: non-2xx on first join (http=" + code + ") — not aborting collect yet");
}
}
}
void AbortMatchCollectFailed()
{
if (NetworkServer.active)
NetworkServer.DisconnectAll();
}
IEnumerator DedicatedMatchPatch(string baseUrl, int matchId, string secret, string json)
{
if (matchId <= 0 || string.IsNullOrEmpty(secret))
yield break;
using (var req = DedicatedMatschInternalApi.BuildRequest(baseUrl, matchId, secret, json))
{
yield return req.SendWebRequest();
DedicatedMatschInternalApi.LogIfFailed(req);
}
}
public IEnumerator DedicatedMatchPatchWinner(string baseUrl, int matchId, string secret, string winnerLower)
{
if (matchId <= 0 || string.IsNullOrEmpty(secret))
yield break;
string jsonBody = "{\"winner\":\"" + winnerLower + "\"}";
using (var req = DedicatedMatschInternalApi.BuildWinnerRequest(baseUrl, matchId, secret, jsonBody))
{
yield return req.SendWebRequest();
DedicatedMatschInternalApi.LogWinnerPatchResult(req);
}
}
void ReportDedicatedMatchGameOver(Team winningTeam)
{
if (!isServer || DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret) || _winnerSettled)
return;
if (!_entriesCollected)
{
Logger.Log("Dedicated match: skipping winner PATCH (entries not collected)");
return;
}
_winnerSettled = true;
StartCoroutine(CoReportDedicatedMatchGameOver(winningTeam));
}
IEnumerator CoReportDedicatedMatchGameOver(Team winningTeam)
{
yield return DedicatedMatchPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, "{\"status\":3}");
string winner = winningTeam == Team.Red ? "red" : "blue";
yield return DedicatedMatchPatchWinner(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, winner);
}
/// <summary>Score or forfeit end: latch game over, report winner if escrowed, notify clients.</summary>
void EndMatch(Team winningTeam)
{
if (!isServer || isGameEnded)
return;
isGameEnded = true;
ReplayRecorder.StopAndFlush();
ReportDedicatedMatchGameOver(winningTeam);
RpcGameOver(winningTeam);
gameOver(winningTeam);
}
void HandleDisconnectForfeitTimers()
{
if (!_entriesCollected || _winnerSettled || !gameStarted || isGameEnded)
return;
float now = Time.realtimeSinceStartup;
if (!_redConnected && _blueConnected && _redForfeitDeadline > 0f && now >= _redForfeitDeadline)
{
Logger.Log("Dedicated match: red forfeit grace expired — blue wins");
EndMatch(Team.Blue);
return;
}
if (!_blueConnected && _redConnected && _blueForfeitDeadline > 0f && now >= _blueForfeitDeadline)
{
Logger.Log("Dedicated match: blue forfeit grace expired — red wins");
EndMatch(Team.Red);
return;
}
if (!_redConnected && !_blueConnected
&& _redForfeitDeadline > 0f && now >= _redForfeitDeadline
&& _blueForfeitDeadline > 0f && now >= _blueForfeitDeadline
&& _disconnectOrder.Count > 0)
{
Team winner = _disconnectOrder[_disconnectOrder.Count - 1];
Logger.Log("Dedicated match: both forfeit graces expired — last disconnect wins: " + winner);
EndMatch(winner);
}
}
Team ResolveForfeitWinnerForShutdown()
{
if (_redConnected && !_blueConnected)
return Team.Red;
if (_blueConnected && !_redConnected)
return Team.Blue;
if (_disconnectOrder.Count > 0)
return _disconnectOrder[_disconnectOrder.Count - 1];
Logger.Log("Dedicated match: shutdown settle with no disconnect order — defaulting to red");
return Team.Red;
}
void SettleWinnerIfNeededBeforeShutdownInstance()
{
if (!_entriesCollected || _winnerSettled)
return;
if (DedicatedMatchId <= 0 || string.IsNullOrEmpty(DedicatedMatchSecret))
return;
Team winningTeam = ResolveForfeitWinnerForShutdown();
_winnerSettled = true;
isGameEnded = true;
Logger.Log("Dedicated match: blocking winner settle before shutdown → " + winningTeam);
using (var statusReq = DedicatedMatschInternalApi.BuildRequest(
DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, "{\"status\":3}"))
{
var op = statusReq.SendWebRequest();
while (!op.isDone)
System.Threading.Thread.Sleep(16);
DedicatedMatschInternalApi.LogIfFailed(statusReq);
}
string winner = winningTeam == Team.Red ? "red" : "blue";
string jsonBody = "{\"winner\":\"" + winner + "\"}";
using (var winReq = DedicatedMatschInternalApi.BuildWinnerRequest(
DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, jsonBody))
{
var op = winReq.SendWebRequest();
while (!op.isDone)
System.Threading.Thread.Sleep(16);
DedicatedMatschInternalApi.LogWinnerPatchResult(winReq);
}
}
void Start()
{
GameEvents.OnSelectedPuckChanged?.Invoke(null);
#if UNITY_EDITOR
#else
Application.targetFrameRate = isServer ? 30 : 100;
#endif
}
public void RegisterPuck(Puck puck){
pucks.Add(puck);
}
public void DisposePuck(Puck puck){
pucks.Remove(puck);
}
int resetCollisionLockCount;
public bool AreResetCollisionsDisabled => resetCollisionLockCount > 0;
public void PushResetCollisionLock()
{
resetCollisionLockCount++;
if (resetCollisionLockCount == 1)
SetPlayCollisionsEnabled(false);
}
public void PopResetCollisionLock()
{
resetCollisionLockCount = Mathf.Max(0, resetCollisionLockCount - 1);
if (resetCollisionLockCount == 0)
SetPlayCollisionsEnabled(true);
}
void SetPlayCollisionsEnabled(bool enabled)
{
foreach (Puck puck in pucks)
{
if (puck != null)
puck.SetCollisionsEnabled(enabled);
}
if (ball != null)
{
Ball ballComp = ball.GetComponent<Ball>();
if (ballComp != null)
ballComp.SetCollisionsEnabled(enabled);
}
}
[SyncVar(hook = nameof(OnSelectedPuckChanged))]
[SerializeField]Puck selectedPuck;
void OnSelectedPuckChanged(Puck oldPuck, Puck newPuck){
// Logger.Log($"Selected puck changed from {oldPuck} to {newPuck}");
GameEvents.OnSelectedPuckChanged?.Invoke(newPuck);
if (newPuck == null && isClient && CameraEffects.instance != null){
CameraEffects.instance.ReleaseStretchFactor();
}
}
public static Puck SelectedPuck{
get{
return instance != null ? instance.selectedPuck : null;
}
set{
if (instance == null)
return;
instance.SetSelectedPuck(value);
}
}
void SetSelectedPuck(Puck puck)
{
selectedPuck = puck;
GameEvents.OnSelectedPuckChanged?.Invoke(puck);
}
public Puck GetClosestPuck(Vector2 position){
if(IsMoving()){return null;} //Do not do anything if shits moving
// Convert screen position to world position
// Vector3 worldPosition = Camera.main.ScreenToWorldPoint(new Vector3(position.x, position.y, Camera.main.nearClipPlane));
// Find all pucks in the scene
Puck[] pucks = FindObjectsOfType<Puck>();
float minDistance = float.MaxValue;
Puck closestPuck = null;
foreach (Puck puck in pucks)
{
if (puck == null)
continue;
if(puck.team != SelectedTeam){continue;}
float dist = Vector2.Distance(position, puck.transform.position);
if (dist < minDistance && dist <= puckSelectMaxDistance)
{
minDistance = dist;
closestPuck = puck;
}
}
return closestPuck;
}
Coroutine coroutinePostLaunch;
public void OnPointerUp(Vector2 direction)
{
if(selectedPuck == null){
return;
}
if(direction.magnitude < puckDragMinClamp){
selectedPuck = null;
GameEvents.OnSelectedPuckChanged?.Invoke(null);
return;
}
if(coroutinePostLaunch != null){
StopCoroutine(coroutinePostLaunch);
}
coroutinePostLaunch = StartCoroutine(CoroutinePostLaunch());
curPuckPullForce = direction.magnitude;
float force = puckForce * puckForceCurve.Evaluate(curPuckPullForce);
Logger.Log($"force = {puckForce} * {puckForceCurve.Evaluate(curPuckPullForce)} = {force}");
Logger.Log($"launching puck at {direction} with {force * -direction} force");
selectedPuck.GetComponent<Rigidbody2D>().AddForce(-direction * force, ForceMode2D.Impulse);
hitCounter++;
selectedPuck = null;
GameEvents.OnSelectedPuckChanged?.Invoke(null);
if (isServer)
{
bool wasWarned = GetConsecutiveSkips(SelectedTeam) > 0;
ClearConsecutiveSkips(SelectedTeam);
if (wasWarned)
RpcHideSkipForfeitWarning(SelectedTeam);
}
SwitchTeams();
}
public void SwitchTeams(){
if(!isServer){
Debug.LogWarning("SwitchTeams called on client, skipping");
return;
}
StartCoroutine(CoroutineSwitchTeams());
}
bool switchingTeams=false;
IEnumerator CoroutineSwitchTeams(){
switchingTeams=true;
for(int i=0; i < 3; i++){
yield return null;
}
while(m_isMoving){
yield return null;
}
turnTimerCounter = 0;
// Clear any active drag/selection before changing turns.
// This prevents stale selected-puck visuals when the timer expires mid-hold.
if (selectedPuck != null){
SetSelectedPuck(null);
}
SelectedTeam = SelectedTeam == Team.Red ? Team.Blue : Team.Red;
OnTeamChanged?.Invoke(SelectedTeam);
MaybeWarnIfAtRiskOfSkipForfeit(SelectedTeam);
switchingTeams=false;
}
IEnumerator CoroutinePostLaunch(){
float t = 0;
while ( t < 1){
t += Time.deltaTime / ballMoveTime;
ball.linearDamping = Mathf.Lerp(0, 10, t);
yield return null;
}
}
bool IsMoving(){
if(freezeInput){return true;}
float minMagnitude = 0.01f;
foreach(Puck puck in pucks){
if(puck.rb.linearVelocity.magnitude > minMagnitude){
return true;
}
}
if(ball.linearVelocity.magnitude > minMagnitude){
return true;
}
return false;
}
void Update()
{
if(isServer){
m_isMoving=IsMoving();
HandleTurnTimer();
if(!gameStarted){
if(GameTutorialManager.tutorialModeEnabled){
gameStarted = true;
GameCanvas.instance.HideWaitingForOpponentPanel();
Logger.Log("Game started On localhost, tutorial mode enabled");
}
NetPlayer[] players = FindObjectsOfType<NetPlayer>();
#if UNITY_EDITOR
if(Input.GetKeyDown(KeyCode.Space)){
gameStarted = true;
GameCanvas.instance.HideWaitingForOpponentPanel();
Logger.Log("Game started On Server, EDITOR ONLY, DEBUG ONLY");
}
#endif
if (players.Length == 2)
{
// Dedicated matches: wait until API collected entry fees into escrow.
if (DedicatedMatchId > 0)
{
if (_entriesCollected && !_collectFailed)
{
gameStarted = true;
GameCanvas.instance.HideWaitingForOpponentPanel();
Logger.Log("Game started On Server (entries collected)");
}
else if (Time.frameCount % 300 == 0)
{
Logger.Log("Dedicated kickoff waiting: players=2 collected=" + _entriesCollected
+ " collectFailed=" + _collectFailed
+ " redJoin=" + _dedicatedReportedRedJoin
+ " blueJoin=" + _dedicatedReportedBlueJoin
+ " matchId=" + DedicatedMatchId);
}
}
else
{
gameStarted = true;
GameCanvas.instance.HideWaitingForOpponentPanel();
Logger.Log("Game started On Server");
}
}
}
HandleDisconnectForfeitTimers();
// Host practice (tutorial) and dedicated matches both record once NetworkServer is ready.
// Retry until StartRecording succeeds — StartHost can lag one frame behind gameStarted.
if (gameStarted && !_replayRecordingStarted && NetworkServer.active)
{
if (ReplayRecorder.StartRecording(DedicatedMatchId, this))
_replayRecordingStarted = true;
}
}
UpdateLocalTurnTimerBeep();
UpdateTimerColor();
}
void UpdateLocalTurnTimerBeep()
{
if (!isClient || NetPlayer.localPlayer == null)
return;
if (!gameStarted || SelectedTeam != NetPlayer.localPlayer.myTeam)
{
_lastTurnTimerBeepFloor = -1;
return;
}
if (m_isMoving || turnTimerCounter >= turnTimer)
return;
float remaining = turnTimer - turnTimerCounter;
int floorSec = Mathf.FloorToInt(remaining);
if (floorSec != _lastTurnTimerBeepFloor)
{
if (_lastTurnTimerBeepFloor >= 0 && floorSec < _lastTurnTimerBeepFloor
&& AudioManager.instance != null)
AudioManager.instance.PlayTimerBeep();
_lastTurnTimerBeepFloor = floorSec;
}
}
bool turnTimerPaused;
public void SetTurnTimerPaused(bool paused)
{
if (!isServer)
{
CmdSetTurnTimerPaused(paused);
return;
}
turnTimerPaused = paused;
}
[Command(requiresAuthority = false)]
void CmdSetTurnTimerPaused(bool paused)
{
turnTimerPaused = paused;
}
void HandleTurnTimer(){
if (turnTimerPaused || isGameEnded)
return;
if(turnTimerCounter < turnTimer){
if(!isMoving && !switchingTeams && gameStarted){
turnTimerCounter += Time.deltaTime;
}
}else{
OnTurnTimerExpired();
turnTimerCounter = 0;
}
}
void OnTurnTimerExpired()
{
if (!isServer || !gameStarted || isGameEnded)
return;
if (GameTutorialManager.tutorialModeEnabled)
{
SwitchTeams();
return;
}
Team skippingTeam = SelectedTeam;
int skips = IncrementConsecutiveSkips(skippingTeam);
Logger.Log($"Match: {skippingTeam} skipped turn ({skips}/{ConsecutiveSkipsToForfeit})");
if (skips >= ConsecutiveSkipsToForfeit)
{
Team winner = GetOpposingTeam(skippingTeam);
Logger.Log($"Match: {skippingTeam} forfeited for skipping two consecutive turns — {winner} wins");
RpcHideSkipForfeitWarning(skippingTeam);
EndMatch(winner);
return;
}
SwitchTeams();
}
int GetConsecutiveSkips(Team team)
{
return team == Team.Red ? _consecutiveSkipsRed : _consecutiveSkipsBlue;
}
int IncrementConsecutiveSkips(Team team)
{
if (team == Team.Red)
return ++_consecutiveSkipsRed;
return ++_consecutiveSkipsBlue;
}
void ClearConsecutiveSkips(Team team)
{
if (team == Team.Red)
_consecutiveSkipsRed = 0;
else
_consecutiveSkipsBlue = 0;
}
void MaybeWarnIfAtRiskOfSkipForfeit(Team team)
{
if (!isServer || !gameStarted || isGameEnded)
return;
if (GameTutorialManager.tutorialModeEnabled)
return;
if (GetConsecutiveSkips(team) <= 0)
return;
RpcShowSkipForfeitWarning(team);
}
[ClientRpc]
void RpcShowSkipForfeitWarning(Team team)
{
ShowSkipForfeitWarning(team);
}
[ClientRpc]
void RpcHideSkipForfeitWarning(Team team)
{
HideSkipForfeitWarning(team);
}
/// <summary>Shown when this team starts a turn after already skipping once.</summary>
public void ShowSkipForfeitWarning(Team team)
{
if (NetPlayer.localPlayer != null && NetPlayer.localPlayer.myTeam != team)
return;
if (GameCanvas.instance == null)
return;
GameCanvas.instance.ShowKickWarning();
}
public void HideSkipForfeitWarning(Team team)
{
if (NetPlayer.localPlayer != null && NetPlayer.localPlayer.myTeam != team)
return;
if (GameCanvas.instance == null)
return;
GameCanvas.instance.HideKickWarning();
}
float flashPhase = 0f;
float lastUpdateTime = 0f;
float lastRemainingTime = 10f;
int _lastTurnTimerBeepFloor = -1;
void UpdateTimerColor(){
float remainingTime = turnTimer - turnTimerCounter;
// Detect timer reset: if remainingTime jumped from low to high, reset phase
if(remainingTime > lastRemainingTime + 2f){
flashPhase = 0f;
lastUpdateTime = 0f;
}
lastRemainingTime = remainingTime;
CanvasGroupUtils activeTimerGroup = SelectedTeam == Team.Red ? redTimerGroup : blueTimerGroup;
CanvasGroupUtils inactiveTimerGroup = SelectedTeam == Team.Red ? blueTimerGroup : redTimerGroup;
if(inactiveTimerGroup != null){
inactiveTimerGroup.overrideColor = Color.white;
}
if(activeTimerGroup == null){
if(remainingTime >= 5f || !gameStarted){
flashPhase = 0f;
lastUpdateTime = 0f;
}
return;
}
if(remainingTime < 5f && gameStarted){
// Calculate intensity based on how close timer is to 0
// Intensity increases from 0 to 1 as timer goes from 5 to 0
float intensity = 1f - (remainingTime / 5f);
// Create flashing effect using sine wave
// Flash speed increases as timer approaches 0
float flashSpeed = Mathf.Lerp(minFlashSpeed, maxFlashSpeed, intensity);
// Update phase based on delta time and current flash speed
// This ensures smooth flashing that resets with the timer
if(lastUpdateTime > 0f){
float deltaTime = Time.time - lastUpdateTime;
flashPhase += flashSpeed * deltaTime;
} else {
// First frame in warning zone, initialize phase
flashPhase = 0f;
}
lastUpdateTime = Time.time;
float flashValue = (Mathf.Sin(flashPhase) + 1f) * 0.5f; // 0 to 1
// Combine intensity with flash for final red intensity
// As timer approaches 0, intensity increases and flash becomes more prominent
float finalRedIntensity = intensity * (0.3f + flashValue * 0.7f); // Range: intensity*0.3 to intensity*1
// Lerp between white and red based on final intensity, keeping alpha at 1
Color white = Color.white;
Color red = Color.red;
activeTimerGroup.overrideColor = Color.Lerp(white, red, finalRedIntensity);
}else{
// Reset phase when timer is above 5 seconds or game hasn't started
flashPhase = 0f;
lastUpdateTime = 0f;
activeTimerGroup.overrideColor = Color.white;
}
}
Team GetOpposingTeam(Team team){
return team == Team.Red ? Team.Blue : Team.Red;
}
void SetKickoffTeam(Team kickoffTeam){
turnTimerCounter = 0;
SelectedTeam = kickoffTeam;
OnTeamChanged?.Invoke(SelectedTeam);
MaybeWarnIfAtRiskOfSkipForfeit(SelectedTeam);
}
public void OnGoal(Team team){
if(!isServer){
Debug.LogWarning("OnGoal called on client, skipping");
return;}
if(!CanProcessGoal){
return;
}
RpcPlayGoalScoredSfx();
freezeInput=true;
Team concedingTeam = team;
if(hitCounter == 1){
//kickoff goal
Logger.Log("Kickoff goal");
ReplayRecorder.RecordGoal(concedingTeam, redScore, blueScore, kickoff: true);
SetKickoffTeam(concedingTeam);
StartCoroutine(CoroutineOnGoal(team,true));//true = Kickoff goal, reset only the ball
hitCounter = 0;
return;
}
if(team == Team.Blue){
blueScore++;
blueScoreText.text = blueScore.ToString();
PlayGoalEffects(Team.Blue);
Logger.Log($"Blue goal scored, blue score is now {blueScore}");
}else{
redScore++;
redScoreText.text = redScore.ToString();
PlayGoalEffects(Team.Red);
Logger.Log($"Red goal scored, red score is now {redScore}");
}
ReplayRecorder.RecordGoal(concedingTeam, redScore, blueScore, kickoff: false);
SetKickoffTeam(concedingTeam);
if(blueScore >= 3){
EndMatch(Team.Blue);
}else if(redScore >= 3){
EndMatch(Team.Red);
}else{
StartCoroutine(CoroutineOnGoal(team));
}
hitCounter = 0;
}
void PlayGoalEffects(Team team){
if(isServer){
m_PlayGoalEffects(team);
RpcPlayGoalEffects(team);
}else{
CmdPlayGoalEffects(team);
}
}
[Command]
void CmdPlayGoalEffects(Team team){
m_PlayGoalEffects(team);
RpcPlayGoalEffects(team);
}
[ClientRpc]
void RpcPlayGoalEffects(Team team){
m_PlayGoalEffects(team);
}
void m_PlayGoalEffects(Team team){
if(team == Team.Blue){
foreach(ParticleSystem effect in blueGoalEffects){
effect.Play();
}
}else{
foreach(ParticleSystem effect in redGoalEffects){
effect.Play();
}
}
}
[ClientRpc]
void RpcPlayGoalScoredSfx()
{
if (AudioManager.instance != null)
AudioManager.instance.PlayGoalScoredSfxSequence();
}
[ClientRpc]
void RpcGameOver(Team team){
gameOver(team);
}
void gameOver(Team team){
StartCoroutine(CoroutineGameOver(team));
}
IEnumerator CoroutineGameOver(Team team){
yield return new WaitForSeconds(2f);
yield return CoroutineFetchBothPlayersL10();
// StopClient();
LevelLoadManager.instance.SetupGameOver(team,redScore,blueScore);
GameOverCanvas.instance.Show(team, MyTeam, redScore, blueScore);
// LevelLoadManager.LoadLevel("MainMenu");
}
bool _thirdPlayerLeaveTriggered;
bool _leaveRequested;
/// <summary>
/// Called when the local client spawns into a match. If three <see cref="NetPlayer"/> instances exist
/// (matchmaker sent an extra client into a full room), disconnect and return to the main menu.
/// </summary>
public void OnLocalPlayerJoinedMatch()
{
if (!isClient || _thirdPlayerLeaveTriggered)
return;
StartCoroutine(CoLeaveIfThirdPlayer());
}
/// <summary>
/// Voluntary leave: forfeit this player immediately so the opponent wins, then return to the menu.
/// </summary>
public void Leave()
{
if (_leaveRequested)
return;
_leaveRequested = true;
bool shouldForfeit = gameStarted && !isGameEnded;
if (shouldForfeit)
{
if (isServer)
ForfeitLeavingTeam(ResolveLocalLeavingTeam());
else
CmdForfeit();
StartCoroutine(CoLeaveAfterForfeitSent());
return;
}
DoLeaveToMenu();
}
IEnumerator CoLeaveAfterForfeitSent()
{
// Let the forfeit Command / game-over RPC flush before tearing down the connection.
yield return null;
yield return new WaitForSecondsRealtime(0.1f);
DoLeaveToMenu();
}
void DoLeaveToMenu()
{
if (LevelLoadManager.instance != null)
LevelLoadManager.instance.Leave();
}
[Command(requiresAuthority = false)]
void CmdForfeit(NetworkConnectionToClient sender = null)
{
Team leavingTeam;
if (!TryResolveLeavingTeam(sender, out leavingTeam))
{
Logger.Log("Forfeit: could not resolve leaving team from sender");
return;
}
ForfeitLeavingTeam(leavingTeam);
}
bool TryResolveLeavingTeam(NetworkConnectionToClient sender, out Team leavingTeam)
{
leavingTeam = Team.Red;
if (sender != null)
{
if (sender.identity != null)
{
var fromIdentity = sender.identity.GetComponent<NetPlayer>();
if (fromIdentity != null)
{
leavingTeam = fromIdentity.myTeam;
return true;
}
}
foreach (var player in FindObjectsByType<NetPlayer>(FindObjectsSortMode.None))
{
if (player != null && player.connectionToClient == sender)
{
leavingTeam = player.myTeam;
return true;
}
}
}
if (isServer && isClient)
{
leavingTeam = ResolveLocalLeavingTeam();
return leavingTeam == Team.Red || leavingTeam == Team.Blue;
}
return false;
}
Team ResolveLocalLeavingTeam()
{
if (NetPlayer.localPlayer != null)
return NetPlayer.localPlayer.myTeam;
return MyTeam;
}
void ForfeitLeavingTeam(Team leavingTeam)
{
if (!isServer || isGameEnded)
return;
if (!gameStarted)
return;
if (leavingTeam != Team.Red && leavingTeam != Team.Blue)
return;
Team winner = GetOpposingTeam(leavingTeam);
Logger.Log($"Match: {leavingTeam} forfeited by leave — {winner} wins");
EndMatch(winner);
}
IEnumerator CoLeaveIfThirdPlayer()
{
const int maxFrames = 30;
for (int i = 0; i < maxFrames; i++)
{
yield return null;
if (FindObjectsByType<NetPlayer>(FindObjectsSortMode.None).Length >= 3)
{
_thirdPlayerLeaveTriggered = true;
Logger.Log("Third player in a 2-player match (matchmaker bug); leaving to main menu.");
if (LevelLoadManager.instance != null)
LevelLoadManager.instance.Leave();
else
LevelLoadManager.LoadLevel("MainMenu");
yield break;
}
}
}
public void StopClient(){
try{
NetManager.StopNetworkingForSceneChange();
}catch(Exception e){
Logger.Log("Error stopping client: " + e.Message);
}
}
IEnumerator CoroutineFetchBothPlayersL10()
{
var my = LevelLoadManager.myPlayer;
var opp = LevelLoadManager.opponentPlayer;
if (my == null || opp == null)
yield break;
string baseUrl = (DedicatedInternalApiBase ?? "").TrimEnd('/');
if (string.IsNullOrEmpty(baseUrl))
yield break;
if (my.UserId > 0){
yield return CoFetchPlayerL10Apply(baseUrl, my);
}
if (opp.UserId > 0 && opp.UserId != my.UserId){
yield return CoFetchPlayerL10Apply(baseUrl, opp);
}
LevelLoadManager.myPlayer = my;
LevelLoadManager.opponentPlayer = opp;
}
static IEnumerator CoFetchPlayerL10Apply(string baseUrl, MatchmadePlayer player)
{
string url = baseUrl + "/players/" + player.UserId + "/l10";
using (var req = UnityWebRequest.Get(url))
{
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
Logger.Log("Player L10 fetch failed: " + req.error + " " + url);
yield break;
}
var resp = JsonUtility.FromJson<PlayerL10Response>(req.downloadHandler.text);
if (resp != null && resp.ok)
{
player.l10_wins = resp.l10_wins;
player.l10_losses = resp.l10_losses;
}
}
}
[Serializable]
class PlayerL10Response
{
public bool ok;
public int player_id;
public int l10_wins;
public int l10_losses;
}
bool freezeInput = false;
public bool CanProcessGoal => isServer && !freezeInput && !isGameEnded;
IEnumerator CoroutineOnGoal(Team team, bool kickoff = false){
if(coroutinePostLaunch!=null){
StopCoroutine(coroutinePostLaunch);
}
float t=0;
while(t < 1){
t +=Time.deltaTime * 2f;
ball.linearDamping = Mathf.Lerp(0, 100, t);
yield return null;
}
yield return new WaitForSeconds(2f);
Reset();
}
void OnScoreChanged(int oldScore, int newScore){
Logger.Log($"Score changed from {oldScore} to {newScore}");
redScoreText.text = redScore.ToString();
blueScoreText.text = blueScore.ToString();
}
public void Reset(bool kickoff = false)
{
StartCoroutine(CoroutineReset(kickoff));
}
IEnumerator CoroutineReset(bool kickoff = false){
ReplayRecorder.RecordReset(kickoff);
float resetDuration = 0.5f;
ball.GetComponent<Ball>().Reset(resetDuration);
if(!kickoff){
foreach(Puck puck in pucks){
puck.Reset(null, resetDuration);
}
}
yield return new WaitForSeconds(resetDuration);
freezeInput=false;
}
}
/// <summary>Headless dedicated server → matchmaker internal PATCH. Runs only where started (server-side coroutines).</summary>
static class DedicatedMatschInternalApi
{
public static UnityWebRequest BuildRequest(string baseUrl, int matchId, string secret, string jsonBody)
{
string url = baseUrl.TrimEnd('/') + "/internal/match/" + matchId;
return BuildPatchJson(url, secret, jsonBody);
}
public static UnityWebRequest BuildWinnerRequest(string baseUrl, int matchId, string secret, string jsonBody)
{
string url = baseUrl.TrimEnd('/') + "/internal/match/" + matchId + "/winner";
return BuildPatchJson(url, secret, jsonBody);
}
public static UnityWebRequest BuildRematchRequest(string baseUrl, string secret, string jsonBody)
{
string url = baseUrl.TrimEnd('/') + "/internal/rematch";
byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonBody);
var req = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST)
{
uploadHandler = new UploadHandlerRaw(bodyRaw),
downloadHandler = new DownloadHandlerBuffer()
};
req.SetRequestHeader("Content-Type", "application/json");
req.SetRequestHeader("X-Dedicated-Server-Secret", secret);
return req;
}
static UnityWebRequest BuildPatchJson(string url, string secret, string jsonBody)
{
byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonBody);
var req = new UnityWebRequest(url, "PATCH")
{
uploadHandler = new UploadHandlerRaw(bodyRaw),
downloadHandler = new DownloadHandlerBuffer()
};
req.SetRequestHeader("Content-Type", "application/json");
req.SetRequestHeader("X-Dedicated-Server-Secret", secret);
return req;
}
public static void LogIfFailed(UnityWebRequest req)
{
if (req.result == UnityWebRequest.Result.Success){
Logger.Log("Dedicated match PATCH success: " + req.responseCode + " " + (req.downloadHandler != null ? req.downloadHandler.text : ""));
}else{
Logger.Log("Dedicated match PATCH failed: " + req.responseCode + " " + req.error + " " + (req.downloadHandler != null ? req.downloadHandler.text : ""));
Logger.Log("More details : " + req.url + " " + req.method + " " + req.uploadHandler.data + " " + req.downloadHandler.text);
}
}
/// <summary>Handles <c>PATCH /internal/match/:id/winner</c>: 200 parses economy; 409 idempotent (already paid).</summary>
public static void LogWinnerPatchResult(UnityWebRequest req)
{
long code = req.responseCode;
string body = req.downloadHandler != null ? req.downloadHandler.text : "";
if (code == 200 && req.result == UnityWebRequest.Result.Success)
{
try
{
var parsed = JsonUtility.FromJson<DedicatedWinnerPatchResponse>(body);
if (parsed != null && parsed.ok && parsed.economy != null)
GameManager.MatchRcPrizeCoins = parsed.economy.rc_prize;
}
catch (Exception e)
{
Logger.Log("Dedicated winner PATCH: could not parse economy: " + e.Message + " body=" + body);
}
Logger.Log("Dedicated match winner PATCH success: " + code + " " + body);
return;
}
if (code == 409)
{
Logger.Log("Dedicated match winner PATCH: winner already recorded (409 idempotent), no second payout. " + body);
return;
}
Logger.Log("Dedicated match winner PATCH failed: " + code + " " + req.error + " " + body);
Logger.Log("More details : " + req.url + " " + req.method + " " + body);
}
}
[Serializable]
class DedicatedJoinPatchResponse
{
public bool ok;
public int id;
public bool entries_collected;
public bool already_collected;
public int escrow_user_id;
}
[Serializable]
class DedicatedWinnerPatchEconomy
{
public int entry_fee_rc;
public int rc_prize;
public int participant_cc;
}
[Serializable]
class DedicatedWinnerPatchResponse
{
public bool ok;
public int id;
public int winner_id;
public DedicatedWinnerPatchEconomy economy;
}
[Serializable]
class DedicatedRematchRequestBody
{
public int user_red_id;
public int user_blue_id;
public int entry_fee;
}
[Serializable]
class DedicatedRematchErrorEnvelope
{
public bool ok;
public string error;
}