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; } /// Coin units from matchmaker rc_prize; used for in-game prize UI. public static int MatchRcPrizeCoins { get; set; } /// Clears match roster/team statics that survive scene unload (logout). public static void ClearMatchSession() { MyTeam = default; RedPlayer = null; BluePlayer = null; MatchRcPrizeCoins = 0; RedFinishedMatch = true; BlueFinishedMatch = true; MatchWinningTeam = default; MatchEndedByForfeit = false; } /// Participation CC amount shown on game over. Actual credit is decided by the winner PATCH. public const int ParticipationCcAmount = 100; /// Server-reported: red was still connected when the match ended. Not inferred from winner. public static bool RedFinishedMatch { get; private set; } = true; /// Server-reported: blue was still connected when the match ended. Not inferred from winner. public static bool BlueFinishedMatch { get; private set; } = true; /// Winning side at settle. Used with for game-over CC display. public static Team MatchWinningTeam { get; private set; } /// True for leave, disconnect, last-disconnect, and skip/auto-forfeit. Not shown in player UI. public static bool MatchEndedByForfeit { get; private set; } /// Local player should see +CC on game over: connected, and not the forfeit loser. public static bool LocalPlayerEarnedParticipationCc { get { bool connected = MyTeam == Team.Blue ? BlueFinishedMatch : RedFinishedMatch; if (!connected) return false; if (!MatchEndedByForfeit) return true; return MyTeam == MatchWinningTeam; } } /// Set by dedicated server startup (e.g. CupidConnector parsing -matchId). PATCH is skipped if id ≤ 0 or secret is empty. public static int DedicatedMatchId { get; private set; } /// Sent as X-Dedicated-Server-Secret. From -dedicatedSecret arg or DEDICATED_SERVER_SECRET env. public static string DedicatedMatchSecret { get; private set; } = ""; /// Base URL for internal match API (no trailing slash), default http://127.0.0.1:2612. 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}"); } /// Outcome of (POST /internal/rematch). 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 ?? ""; } } /// /// Dedicated server only: POST /internal/rematch with X-Dedicated-Server-Secret. /// Blocks until the request finishes (same pattern as ). /// On HTTP 200, returns the same JSON envelope as a successful matchmaker GET room fill: . /// /// users.id for red. /// users.id for blue; must differ from red. /// RC stake per player (non-negative). /// Overrides when non-empty. /// Overrides when non-empty. 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(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(json); if (err != null && !string.IsNullOrEmpty(err.error)) return err.error; } catch { /* ignore */ } return ""; } /// /// If entry fees were collected and no winner was reported yet, settle via blocking /winner /// (remaining player or last-disconnect-wins) before room close / quit. /// public static void SettleWinnerIfNeededBeforeShutdown() { if (instance == null) return; instance.SettleWinnerIfNeededBeforeShutdownInstance(); } /// Sets match status to -1 (room closed). Settles winner first when escrow is open. Synchronous for Application.Quit. 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 _disconnectOrder = new List(); int _consecutiveSkipsRed; int _consecutiveSkipsBlue; public static Action 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 (0–1). >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 pucks = new List(); 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; } /// /// Blends from at the pitch center to /// at the boundary (and beyond), using the max normalized axis distance inside the field rectangle. /// 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; } /// Server-only: called from after myTeam is set. 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(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)"); } } /// Server-only: Mirror disconnect — track order and start forfeit grace after collect + kickoff. 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], forfeit: true); } 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(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"); } } } bool _abortLeaveStarted; void AbortMatchCollectFailed() { RpcAbortMatch("Match cancelled", "The match could not start. Please try again."); StartCoroutine(CoDisconnectAfterAbortRpc()); } [ClientRpc] void RpcAbortMatch(string title, string message) { ShowMatchAbortMessage(title, message); // Host keeps the server up so the Rpc can reach the other client first. if (!NetworkServer.active) LeaveToMenuAfterAbort(); } IEnumerator CoDisconnectAfterAbortRpc() { yield return new WaitForSecondsRealtime(0.5f); if (NetworkServer.active) NetworkServer.DisconnectAll(); if (isClient) LeaveToMenuAfterAbort(); } void NotifyAndLeaveMatch(string title, string message) { ShowMatchAbortMessage(title, message); LeaveToMenuAfterAbort(); } void ShowMatchAbortMessage(string title, string message) { NetManager.MarkIntentionalClientDisconnect(); if (CupidLobby.instance != null) CupidLobby.instance.Cancel(); LoginManager.ClearMatchYourTeam(); MessageBoxDialog.Show(title, message); } void LeaveToMenuAfterAbort() { if (_abortLeaveStarted) return; _abortLeaveStarted = true; if (LevelLoadManager.instance != null) LevelLoadManager.instance.Leave(); else LevelLoadManager.LoadLevel("MainMenu"); } 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, bool redConnected, bool blueConnected, int redScore, int blueScore, bool forfeit) { if (matchId <= 0 || string.IsNullOrEmpty(secret)) yield break; string jsonBody = DedicatedMatschInternalApi.BuildWinnerPatchJson(winnerLower, redConnected, blueConnected, redScore, blueScore, forfeit); using (var req = DedicatedMatschInternalApi.BuildWinnerRequest(baseUrl, matchId, secret, jsonBody)) { yield return req.SendWebRequest(); DedicatedMatschInternalApi.LogWinnerPatchResult(req); } } void ReportDedicatedMatchGameOver(Team winningTeam, bool redConnected, bool blueConnected, bool forfeit) { 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, redConnected, blueConnected, forfeit)); } IEnumerator CoReportDedicatedMatchGameOver(Team winningTeam, bool redConnected, bool blueConnected, bool forfeit) { yield return DedicatedMatchPatch(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, "{\"status\":3}"); string winner = winningTeam == Team.Red ? "red" : "blue"; yield return DedicatedMatchPatchWinner(DedicatedInternalApiBase, DedicatedMatchId, DedicatedMatchSecret, winner, redConnected, blueConnected, redScore, blueScore, forfeit); } /// Score or forfeit end: latch game over, report winner if escrowed, notify clients. void EndMatch(Team winningTeam, bool forfeit) { if (!isServer || isGameEnded) return; isGameEnded = true; ReplayRecorder.StopAndFlush(); if (GameTutorialManager.tutorialModeEnabled) GameTutorialManager.RecordCpuL10Result(winningTeam != MyTeam); bool redConnected = IsSideConnectedForParticipation(Team.Red); bool blueConnected = IsSideConnectedForParticipation(Team.Blue); Logger.Log($"Dedicated match: end winner={winningTeam} red_connected={redConnected} blue_connected={blueConnected} red_score={redScore} blue_score={blueScore} forfeit={forfeit}"); ApplyParticipationCcFlags(redConnected, blueConnected, winningTeam, forfeit); ReportDedicatedMatchGameOver(winningTeam, redConnected, blueConnected, forfeit); RpcGameOver(winningTeam, redConnected, blueConnected, forfeit); gameOver(winningTeam); } /// /// Server-side: participation CC is only for players still connected at settle. /// Voluntary leave clears the connected flag before . /// bool IsSideConnectedForParticipation(Team team) { if (team == Team.Red) { if (!_redConnected) return false; } else if (team == Team.Blue) { if (!_blueConnected) return false; } else { return false; } foreach (var player in FindObjectsByType(FindObjectsSortMode.None)) { if (player == null || player.myTeam != team) continue; if (player.connectionToClient != null) return player.connectionToClient.isReady; // Listen-server host has no connectionToClient. if (player.isLocalPlayer) return true; } return false; } static void ApplyParticipationCcFlags(bool redConnected, bool blueConnected, Team winningTeam, bool forfeit) { RedFinishedMatch = redConnected; BlueFinishedMatch = blueConnected; MatchWinningTeam = winningTeam; MatchEndedByForfeit = forfeit; } 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, forfeit: true); return; } if (!_blueConnected && _redConnected && _blueForfeitDeadline > 0f && now >= _blueForfeitDeadline) { Logger.Log("Dedicated match: blue forfeit grace expired — red wins"); EndMatch(Team.Red, forfeit: true); 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, forfeit: true); } } 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(); bool redConnected = IsSideConnectedForParticipation(Team.Red); bool blueConnected = IsSideConnectedForParticipation(Team.Blue); _winnerSettled = true; isGameEnded = true; ApplyParticipationCcFlags(redConnected, blueConnected, winningTeam, forfeit: true); Logger.Log("Dedicated match: blocking winner settle before shutdown → " + winningTeam + " red_connected=" + redConnected + " blue_connected=" + blueConnected + " red_score=" + redScore + " blue_score=" + blueScore + " forfeit=true"); 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 = DedicatedMatschInternalApi.BuildWinnerPatchJson(winner, redConnected, blueConnected, redScore, blueScore, forfeit: true); 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(); 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(); 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().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(); #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, forfeit: true); 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); } /// Shown when this team starts a turn after already skipping once. 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; CancelInGoalPuckResets(); 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, forfeit: false); }else if(redScore >= 3){ EndMatch(Team.Red, forfeit: false); }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, bool redConnected, bool blueConnected, bool forfeit){ ApplyParticipationCcFlags(redConnected, blueConnected, team, forfeit); gameOver(team); } void gameOver(Team team){ StartCoroutine(CoroutineGameOver(team)); } IEnumerator CoroutineGameOver(Team team){ if (GameCanvas.instance != null) yield return GameCanvas.instance.ShowRandomPostGamePanelThenHide(); else 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; /// /// Called when the local client spawns into a match. If three instances exist /// (matchmaker sent an extra client into a full room), disconnect and return to the main menu. /// public void OnLocalPlayerJoinedMatch() { if (!isClient || _thirdPlayerLeaveTriggered) return; StartCoroutine(CoLeaveIfThirdPlayer()); } /// /// Voluntary leave: forfeit this player immediately so the opponent wins, then return to the menu. /// 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(); if (fromIdentity != null) { leavingTeam = fromIdentity.myTeam; return true; } } foreach (var player in FindObjectsByType(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; if (leavingTeam == Team.Red) _redConnected = false; else if (leavingTeam == Team.Blue) _blueConnected = false; Team winner = GetOpposingTeam(leavingTeam); Logger.Log($"Match: {leavingTeam} forfeited by leave — {winner} wins"); EndMatch(winner, forfeit: true); } IEnumerator CoLeaveIfThirdPlayer() { const int maxFrames = 30; for (int i = 0; i < maxFrames; i++) { yield return null; if (FindObjectsByType(FindObjectsSortMode.None).Length >= 3) { _thirdPlayerLeaveTriggered = true; Logger.Log("Third player in a 2-player match (matchmaker bug); leaving to main menu."); NotifyAndLeaveMatch("Unable to join", "This match is already full."); 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; if (GameTutorialManager.tutorialModeEnabled) GameTutorialManager.ApplyCpuL10(opp); string baseUrl = (DedicatedInternalApiBase ?? "").TrimEnd('/'); if (string.IsNullOrEmpty(baseUrl)) { LevelLoadManager.myPlayer = my; LevelLoadManager.opponentPlayer = opp; yield break; } if (my.UserId > 0){ yield return CoFetchPlayerL10Apply(baseUrl, my); } if (!GameTutorialManager.tutorialModeEnabled && 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(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; void CancelInGoalPuckResets() { foreach (Puck puck in pucks) { if (puck != null) puck.CancelInGoalReset(); } } 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().Reset(resetDuration); if(!kickoff){ foreach(Puck puck in pucks){ puck.Reset(null, resetDuration); } } yield return new WaitForSeconds(resetDuration); freezeInput=false; } } /// Headless dedicated server → matchmaker internal PATCH. Runs only where started (server-side coroutines). 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 string BuildWinnerPatchJson(string winnerLower, bool redConnected, bool blueConnected, int redScore, int blueScore, bool forfeit) { return JsonUtility.ToJson(new DedicatedWinnerPatchRequest { winner = winnerLower, red_connected = redConnected, blue_connected = blueConnected, red_score = redScore, blue_score = blueScore, forfeit = forfeit }); } 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); } } /// Handles PATCH /internal/match/:id/winner: 200 parses economy; 409 idempotent (already paid). 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(body); if (parsed != null && parsed.ok && parsed.economy != null) { GameManager.MatchRcPrizeCoins = parsed.economy.rc_prize; Logger.Log("Dedicated winner PATCH economy: rc_prize=" + parsed.economy.rc_prize + " participant_cc=" + parsed.economy.participant_cc + " cc_awarded_red=" + parsed.economy.cc_awarded_red + " cc_awarded_blue=" + parsed.economy.cc_awarded_blue + " mmr_delta=" + parsed.mmr_delta + " forfeit=" + parsed.forfeit + " already_settled=" + parsed.already_settled); } } 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 DedicatedWinnerPatchRequest { public string winner; public bool red_connected; public bool blue_connected; public int red_score; public int blue_score; public bool forfeit; } [Serializable] class DedicatedWinnerPatchEconomy { public int entry_fee_rc; public int rc_prize; public int participant_cc; public bool cc_awarded_red; public bool cc_awarded_blue; } [Serializable] class DedicatedWinnerPatchResponse { public bool ok; public int id; public int winner_id; public bool already_settled; public DedicatedWinnerPatchEconomy economy; public int mmr_delta; public bool forfeit; } [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; }