diff --git a/core/src/main/java/com/group14/regicidechess/network/CircuitBreaker.java b/core/src/main/java/com/group14/regicidechess/network/CircuitBreaker.java new file mode 100644 index 0000000..8fab597 --- /dev/null +++ b/core/src/main/java/com/group14/regicidechess/network/CircuitBreaker.java @@ -0,0 +1,77 @@ +package com.group14.regicidechess.network; + +/** + * Simple circuit breaker with three states: CLOSED, OPEN, HALF_OPEN. + * + * CLOSED: All requests pass. Consecutive failures are counted. + * OPEN: Requests blocked. After cooldown, transitions to HALF_OPEN. + * HALF_OPEN: One probe request allowed. Success -> CLOSED, failure -> OPEN. + */ +public class CircuitBreaker { + + public enum State { CLOSED, OPEN, HALF_OPEN } + + private State state = State.CLOSED; + private int failureCount = 0; + private long openedAt = 0L; + private boolean halfOpenProbeUsed = false; + + private final int failureThreshold; + private final long cooldownMs; + + public CircuitBreaker(int failureThreshold, long cooldownMs) { + this.failureThreshold = failureThreshold; + this.cooldownMs = cooldownMs; + } + + public synchronized boolean allowRequest() { + advanceIfCooldownElapsed(); + switch (state) { + case CLOSED: + return true; + case HALF_OPEN: + if (!halfOpenProbeUsed) { + halfOpenProbeUsed = true; + return true; + } + return false; + case OPEN: + default: + return false; + } + } + + public synchronized void recordSuccess() { + failureCount = 0; + state = State.CLOSED; + halfOpenProbeUsed = false; + } + + public synchronized void recordFailure() { + failureCount++; + if (state == State.HALF_OPEN || failureCount >= failureThreshold) { + state = State.OPEN; + openedAt = System.currentTimeMillis(); + halfOpenProbeUsed = false; + } + } + + public synchronized State getState() { + advanceIfCooldownElapsed(); + return state; + } + + public synchronized void reset() { + state = State.CLOSED; + failureCount = 0; + openedAt = 0L; + halfOpenProbeUsed = false; + } + + private void advanceIfCooldownElapsed() { + if (state == State.OPEN && System.currentTimeMillis() - openedAt >= cooldownMs) { + state = State.HALF_OPEN; + halfOpenProbeUsed = false; + } + } +} diff --git a/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java b/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java index 98d6475..a30b012 100644 --- a/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java +++ b/core/src/main/java/com/group14/regicidechess/screens/game/GameNetworkHandler.java @@ -5,6 +5,7 @@ import com.badlogic.gdx.math.Vector2; import com.badlogic.gdx.scenes.scene2d.ui.Image; import com.badlogic.gdx.scenes.scene2d.ui.Label; +import com.group14.regicidechess.network.CircuitBreaker; import com.group14.regicidechess.database.FirebaseAPI; import com.group14.regicidechess.model.Move; import com.group14.regicidechess.model.Player; @@ -49,6 +50,7 @@ public interface Listener { // Connection UI refs — updated directly here to keep GameScreen clean private final Image connectionIcon; private final Label connectionLabel; + private final CircuitBreaker circuitBreaker = new CircuitBreaker(3, 10_000L); public GameNetworkHandler(String gameId, Player localPlayer, Listener listener, Image connectionIcon, Label connectionLabel, FirebaseAPI api) { @@ -78,13 +80,16 @@ public void stop() { // Outgoing // ───────────────────────────────────────────────────────────────────────── - /** Saves a completed move (with optional promotion) to Firebase. */ public void saveMove(Move move) { + if (!circuitBreaker.allowRequest()) { + Gdx.app.log("GameNetworkHandler", "Circuit breaker OPEN — move send blocked"); + return; + } api.saveMove(gameId, move, () -> {}); } - /** Sends a heartbeat pulse. Call every HEARTBEAT_INTERVAL seconds from render(). */ public void sendHeartbeat() { + if (!circuitBreaker.allowRequest()) return; api.sendHeartbeat(gameId, localPlayer.isWhite()); } @@ -122,18 +127,18 @@ private void startGameOverListener() { } private void startHeartbeat() { - // Send our first beat immediately - api.sendHeartbeat(gameId, localPlayer.isWhite()); - + sendHeartbeat(); api.listenForHeartbeat( gameId, - !localPlayer.isWhite(), // watch opponent's heartbeat + !localPlayer.isWhite(), HEARTBEAT_TIMEOUT_MS, latency -> Gdx.app.postRunnable(() -> { + circuitBreaker.recordSuccess(); updateConnectionUI(latency); listener.onHeartbeatLatency(latency); }), () -> Gdx.app.postRunnable(() -> { + circuitBreaker.recordFailure(); connectionIcon.setColor(Color.RED); connectionLabel.setText("Lost"); listener.onOpponentDisconnected(); @@ -146,6 +151,17 @@ private void startHeartbeat() { // ───────────────────────────────────────────────────────────────────────── private void updateConnectionUI(long latencyMs) { + CircuitBreaker.State cbState = circuitBreaker.getState(); + if (cbState == CircuitBreaker.State.OPEN) { + connectionIcon.setColor(Color.RED); + connectionLabel.setText("Disconnected"); + return; + } + if (cbState == CircuitBreaker.State.HALF_OPEN) { + connectionIcon.setColor(Color.ORANGE); + connectionLabel.setText("Reconnecting..."); + return; + } if (latencyMs < 150) connectionIcon.setColor(Color.GREEN); else if (latencyMs < 500) connectionIcon.setColor(Color.ORANGE); else connectionIcon.setColor(Color.RED);