Connect4 — 10: לובי, חדרים ושני מושבים


בונים מצב עובד אחד מתוך המסלול

מפת המסלול

בסוף הפרק משתמש יוצר חדר כאדום, משתמש אחר מקבל את מושב הצהוב, ואחרים נכנסים כצופים. החדרים מופיעים בזמן אמת. הלוח מוצג בחדר אך עדיין לא מקבל מהלכי רשת.

זהו פרק ארוך שאפשר ללמד בכמה מפגשים: קודם חוזה החדר, אחר כך repository והאזנה, ולבסוף עסקאות וחיבור למסך. משלימים את המחלקות התלויות זו בזו לפני הבנייה; אין להעתיק את כל חבילת multiplayer מהפרויקט המוגמר.

מה נשמר בענן?

כל חדר נמצא תחת Connect4Rtdb/games/{pushId}. השם הוא תווית; מפתח push הוא הזהות, ולכן מותר לשני חדרים להיקרא באותו שם. נשמרים schemaVersion, name, playerRed, playerYellow, moves, createdAt, updatedAt. בחדר חדש המושב הצהוב וההיסטוריה הם מחרוזות ריקות.

המצבים waiting / playing / finished נגזרים מנתוני החדר. לא שומרים גם לוח וגם תור וגם תוצאה שעלולים לסתור את ההיסטוריה. כבר עכשיו בודקים חדר שנכנס מהשרת לפני שמציגים אותו.

כללי גישה לפני כתיבה

המורה משתמש ב־קטע הכללים של פרק 10. הוא מאפשר קריאה למשתמש מחובר, יצירת חדר שהיוצר הוא האדום, ותפיסה חד־פעמית של מושב צהוב בידי UID אחר. בשלב זה אין הרשאה להוסיף מהלכים.

זהו קטע למיזוג תחת rules במסד המשותף, לא תחליף לכל הכללים. אין לפרסם את emulator.rules.json למסד חי. המורה בודק ב־emulator את המיזוג עם כללי האתר הקיימים, כולל הרשאות אב ו־wildcards. ביצוע השיעור אינו דורש פריסה מחדש של כללי פרויקטים אחרים.

קובצי ה־emulator, קטע הכללים וכלי המיזוג נמצאים גם בחבילת האימות של הפרק. תלמיד צריך להסביר מי רשאי ליצור ולתפוס מושב; אין די בכך שהכפתור מוסתר.

השינויים לפי סדר העבודה

התחילו במצב שעבד בסוף הפרק הקודם. קובץ חדש מוצג במלואו; בקובץ קיים מופיעים רק האזורים שמשתנים. בקטעי diff מסירים את שורות -, מוסיפים את שורות +, ומשאירים את שורות ההקשר. הסימנים עצמם אינם חלק מקוד Java או XML. אין למחוק קוד אחר שאינו מוצג.

build.gradle.kts

Gradle Scripts > build.gradle.kts (Module :app)

הוסיפו את Realtime Database לצד Auth ובצעו Sync. ה־BoM שכבר קיים קובע את הגרסה.

     implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
     implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
     implementation(platform("com.google.firebase:firebase-bom:34.19.0"))
     implementation("com.google.firebase:firebase-auth")
+    implementation("com.google.firebase:firebase-database")
     implementation("com.google.ai.edge.litert:litert:2.1.0")
     implementation("androidx.lifecycle:lifecycle-viewmodel:2.10.0")
     implementation(libs.activity.ktx)
     implementation(libs.appcompat)

FirebaseRefs.java

app > kotlin+java > com.example.connect4 > multiplayer > FirebaseRefs.java

מרכזים את כתובת המסד ואת הנתיב הייעודי למשחק. אין לכתוב לשורש המסד או לשנות נתונים תחת TicTacToeRtdb. המורה מאשר את הכתובת עבור הכיתה.

package com.example.connect4.multiplayer;

import com.google.firebase.database.DatabaseReference;
import com.google.firebase.database.FirebaseDatabase;

/** Centralized, game-scoped Firebase locations. */
public final class FirebaseRefs {
    public static final String DATABASE_URL =
            "https://guysfbfinal-default-rtdb.firebaseio.com";
    public static final String GAME_ROOT = "Connect4Rtdb";
    public static final String GAMES_CHILD = "games";

    private FirebaseRefs() {
    }

    /** Returns the configured shared database, rather than Firebase's default URL. */
    public static FirebaseDatabase database() {
        return FirebaseDatabase.getInstance(DATABASE_URL);
    }

    /** Returns this game's isolated room collection under the shared database. */
    public static DatabaseReference games(FirebaseDatabase database) {
        return database.getReference(GAME_ROOT).child(GAMES_CHILD);
    }

    /** Returns Firebase's read-only connection-status location. */
    public static DatabaseReference connection(FirebaseDatabase database) {
        return database.getReference(".info/connected");
    }
}

RoomData.java

app > kotlin+java > com.example.connect4 > multiplayer > RoomData.java

זהו POJO ל־Firebase: בנאי ריק, getters ו־setters. timestamp מתקבל מהשרת בעת הכתיבה. המחלקה היא ייצוג תעבורה, לא הוכחה שהנתונים חוקיים.

package com.example.connect4.multiplayer;

/** Firebase representation of one Connect Four room. */
public class RoomData {
    private int schemaVersion;
    private String name;
    private String playerRed;
    private String playerYellow;
    private String moves;
    private long createdAt;
    private long updatedAt;

    /** Required by Firebase's object mapper. */
    public RoomData() {
    }

    public RoomData(int schemaVersion, String name, String playerRed,
                    String playerYellow, String moves, long createdAt, long updatedAt) {
        this.schemaVersion = schemaVersion;
        this.name = name;
        this.playerRed = playerRed;
        this.playerYellow = playerYellow;
        this.moves = moves;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }

    public int getSchemaVersion() {
        return schemaVersion;
    }

    public void setSchemaVersion(int schemaVersion) {
        this.schemaVersion = schemaVersion;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPlayerRed() {
        return playerRed;
    }

    public void setPlayerRed(String playerRed) {
        this.playerRed = playerRed;
    }

    public String getPlayerYellow() {
        return playerYellow;
    }

    public void setPlayerYellow(String playerYellow) {
        this.playerYellow = playerYellow;
    }

    public String getMoves() {
        return moves;
    }

    public void setMoves(String moves) {
        this.moves = moves;
    }

    public long getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(long createdAt) {
        this.createdAt = createdAt;
    }

    public long getUpdatedAt() {
        return updatedAt;
    }

    public void setUpdatedAt(long updatedAt) {
        this.updatedAt = updatedAt;
    }
}

RoomRole.java

app > kotlin+java > com.example.connect4 > multiplayer > RoomRole.java

תפקיד משתמש בחדר: RED, YELLOW או SPECTATOR. UID קובע בעלות; שם תצוגה אינו מזהה מאובטח.

package com.example.connect4.multiplayer;

/** The signed-in user's relationship to a room. */
public enum RoomRole {
    RED,
    YELLOW,
    SPECTATOR
}

RoomStatus.java

app > kotlin+java > com.example.connect4 > multiplayer > RoomStatus.java

מצב החדר נגזר מנתוניו. ההפרדה מתפקיד המשתמש מאפשרת לצופה לראות גם משחק ממתין וגם משחק שהסתיים.

package com.example.connect4.multiplayer;

/** Status derived from seats and replayed game state. */
public enum RoomStatus {
    WAITING,
    PLAYING,
    FINISHED
}

RoomValidation.java

app > kotlin+java > com.example.connect4 > multiplayer > RoomValidation.java

מאמתים גרסת סכימה, שם, מושבים שונים, היסטוריה ותאריכים. replay מפעיל כל עמודה דרך המנוע. אם מהלך אחד אסור, כל תמונת החדר נדחית; בשונה משמירה מקומית, לא מציגים קידומת כאילו היא החדר המשותף התקין.

גם בחדר ריק נדרש חוזה מלא. ההכנה ל־replay שימושית כאשר רשימת החדרים כוללת משחקים שכבר שוחקו על ידי אחרים.

package com.example.connect4.multiplayer;

import com.example.connect4.core.GameAction;
import com.example.connect4.core.GameEngine;
import com.example.connect4.core.GameState;

/** Strict validation and deterministic replay for Firebase room data. */
public final class RoomValidation {
    public static final int SCHEMA_VERSION = 1;
    public static final int MAX_NAME_LENGTH = 60;
    public static final int MAX_MOVES = 42;

    private RoomValidation() {
    }

    /** Replays persisted moves and returns either a derived status or a safe validation error. */
    public static Result validate(RoomData room) {
        if (room == null) {
            return Result.invalid("Room data is missing");
        }
        if (room.getSchemaVersion() != SCHEMA_VERSION) {
            return Result.invalid("Unsupported room schema");
        }

        String name = room.getName();
        if (name == null || name.isEmpty() || name.length() > MAX_NAME_LENGTH
                || !name.equals(name.trim())) {
            return Result.invalid("Room name must be trimmed and contain 1-60 characters");
        }

        String red = room.getPlayerRed();
        String yellow = room.getPlayerYellow();
        String moves = room.getMoves();
        if (red == null || red.isEmpty()) {
            return Result.invalid("Room has no Red player");
        }
        if (yellow == null) {
            return Result.invalid("Room has no Yellow seat field");
        }
        if (!yellow.isEmpty() && yellow.equals(red)) {
            return Result.invalid("Red and Yellow must be different users");
        }
        if (moves == null || moves.length() > MAX_MOVES || !moves.matches("[0-6]*")) {
            return Result.invalid("Move history must contain at most 42 columns from 0 to 6");
        }
        if (yellow.isEmpty() && !moves.isEmpty()) {
            return Result.invalid("A waiting room cannot contain moves");
        }
        if (room.getCreatedAt() <= 0 || room.getUpdatedAt() < room.getCreatedAt()) {
            return Result.invalid("Room timestamps are missing or out of order");
        }

        GameEngine engine = new GameEngine();
        for (int index = 0; index < moves.length(); index++) {
            int column = moves.charAt(index) - '0';
            if (!engine.apply(new GameAction(column))) {
                return Result.invalid("Move history is illegal at move " + (index + 1));
            }
        }

        GameState state = engine.snapshot();
        RoomStatus status;
        if (yellow.isEmpty()) {
            status = RoomStatus.WAITING;
        } else if (state.isTerminal()) {
            status = RoomStatus.FINISHED;
        } else {
            status = RoomStatus.PLAYING;
        }
        return Result.valid(state, status);
    }

    /** Immutable result of validating a Firebase room payload. */
    public static final class Result {
        private final GameState state;
        private final RoomStatus status;
        private final String error;

        private Result(GameState state, RoomStatus status, String error) {
            this.state = state;
            this.status = status;
            this.error = error;
        }

        private static Result valid(GameState state, RoomStatus status) {
            return new Result(state, status, null);
        }

        private static Result invalid(String error) {
            return new Result(null, null, error);
        }

        /** Reports whether all schema, seat, timestamp, and replay checks passed. */
        public boolean isValid() {
            return error == null;
        }

        /** Returns the replayed state, or {@code null} when validation failed. */
        public GameState getState() {
            return state;
        }

        public RoomStatus getStatus() {
            return status;
        }

        public String getError() {
            return error;
        }
    }
}

RoomSnapshot.java

app > kotlin+java > com.example.connect4 > multiplayer > RoomSnapshot.java

מחברים נתון חדר שעבר בדיקה, צילום משחק ותפקיד המשתמש. ה־Activity מקבל תוצאה מאומתת במקום לפרש Firebase בעצמו.

package com.example.connect4.multiplayer;

import com.example.connect4.core.GameState;

/** A validated room plus its locally replayed game snapshot. */
public final class RoomSnapshot {
    private final String roomId;
    private final RoomData room;
    private final GameState gameState;
    private final RoomRole role;
    private final RoomStatus status;

    RoomSnapshot(String roomId, RoomData room, GameState gameState,
                 RoomRole role, RoomStatus status) {
        this.roomId = roomId;
        this.room = room;
        this.gameState = gameState;
        this.role = role;
        this.status = status;
    }

    public String getRoomId() {
        return roomId;
    }

    public RoomData getRoom() {
        return room;
    }

    public GameState getGameState() {
        return gameState;
    }

    public RoomRole getRole() {
        return role;
    }

    public RoomStatus getStatus() {
        return status;
    }

}

RoomSummary.java

app > kotlin+java > com.example.connect4 > multiplayer > RoomSummary.java

הלובי צריך רק מפתח, שם, מצב ושגיאה אפשרית. חדר פגום מופיע כלא ניתן לבחירה, במקום להעלים תקלה או לפתוח משחק לא חוקי.

package com.example.connect4.multiplayer;

/** One stable-key lobby entry. Invalid rooms remain visible as errors. */
public final class RoomSummary {
    private final String roomId;
    private final String name;
    private final RoomStatus status;
    private final String error;

    RoomSummary(String roomId, String name, RoomStatus status, String error) {
        this.roomId = roomId;
        this.name = name;
        this.status = status;
        this.error = error;
    }

    public String getRoomId() {
        return roomId;
    }

    public String getName() {
        return name;
    }

    public RoomStatus getStatus() {
        return status;
    }

    public String getError() {
        return error;
    }

    public boolean isValid() {
        return error == null;
    }
}

RepositoryError.java

app > kotlin+java > com.example.connect4 > multiplayer > RepositoryError.java

מרחיבים את קודי השגיאה למצבי חדר: נתון לא חוקי, חדר חסר, התנגשות, הרשאה ופעולה ממתינה. ה־UI עדיין מקבל אובייקט שגיאה אחד.

 package com.example.connect4.multiplayer;
 
-/** Carries a recoverable operation error to the screen. */
+/** Stable error shape for UI recovery without exposing Firebase callbacks. */
 public final class RepositoryError {
-    public enum Code { INVALID_ARGUMENT, UNKNOWN }
+    public enum Code {
+        AUTH_REQUIRED,
+        BUSY,
+        INVALID_ARGUMENT,
+        INVALID_ROOM,
+        MISSING_ROOM,
+        CONFLICT,
+        PERMISSION_DENIED,
+        CANCELLED,
+        NETWORK,
+        UNKNOWN
+    }
+
     private final Code code;
     private final String message;
     private final String roomId;
+
     RepositoryError(Code code, String message, String roomId) {
-        this.code=code; this.message=message; this.roomId=roomId;
+        this.code = code;
+        this.message = message;
+        this.roomId = roomId;
     }
-    public Code getCode() { return code; }
-    public String getMessage() { return message; }
-    public String getRoomId() { return roomId; }
+
+    public Code getCode() {
+        return code;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public String getRoomId() {
+        return roomId;
+    }
 }

MultiplayerRepository.java

app > kotlin+java > com.example.connect4 > multiplayer > MultiplayerRepository.java

עבדו במחלקה לפי האחריות של כל קבוצת מתודות:

  1. הבנאי מחבר Auth ומסד ומייצר reference רק לאוסף החדרים.
  2. subscribeLobby ו־subscribeRoom מפרסמות נתונים שעברו בדיקה ומחזירות Subscription להסרה. getRoom קוראת חדר אחד.
  3. createRoom מקבלת push key וכותבת את כל החדר בפעולה אחת. ממתינים להשלמת הכתיבה לפני הכרזה על יצירה.
  4. joinRoom בודקת מידע התחלתי, ואז runJoinTransaction בודקת שוב את המושב בתוך transaction. שתי בקשות עשויות להתחיל יחד; רק אחת יכולה לתפוס את צהוב. השנייה מקבלת את תפקידה המעודכן כצופה.
  5. פעולות ממתינות מזוהות לפי מפתח חדר ב־repository שנשמר ב־Session; לחיצה כפולה אינה יוצרת בקשה זהה נוספת.
  6. seedEmptyTransaction מטפלת ב־cache ראשוני ריק. הנתונים הם מועמד בלבד: מנגנון compare-and-retry של Firebase קובע מה מותר להתחייב מול השרת.

אחרי עסקת join שהתחייבה, קוראים את החדר מחדש לפני פרסום התפקיד. זהו תיקון ממוקד בפרויקט האימות בעקבות כשל שנמצא בבדיקת timestamps; צילום השלמת transaction עשוי להכיל הערכות זמן מקומיות. פרויקט הייחוס לא שונה.

 package com.example.connect4.multiplayer;
+
 import com.google.firebase.auth.FirebaseAuth;
 import com.google.firebase.auth.FirebaseUser;
 import com.google.firebase.auth.GoogleAuthProvider;
-
-/** Owns Firebase authentication and closes its listeners explicitly. */
+import com.google.firebase.database.DataSnapshot;
+import com.google.firebase.database.DatabaseError;
+import com.google.firebase.database.DatabaseException;
+import com.google.firebase.database.DatabaseReference;
+import com.google.firebase.database.FirebaseDatabase;
+import com.google.firebase.database.MutableData;
+import com.google.firebase.database.ServerValue;
+import com.google.firebase.database.Transaction;
+import com.google.firebase.database.ValueEventListener;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Owns Firebase authentication, scoped room references, subscriptions and atomic writes.
+ * The controller remains the only owner of a live game; this class only publishes replayed
+ * snapshots produced by the pure Java engine.
+ */
 public final class MultiplayerRepository {
     private final FirebaseAuth auth;
-    /** Uses the app registered by the teacher. */
-    public MultiplayerRepository() { this(FirebaseAuth.getInstance()); }
-    /** Allows isolated Auth emulator instances in tests. */
-    public MultiplayerRepository(FirebaseAuth auth) { this.auth=auth; }
+    private final FirebaseDatabase database;
+    private final DatabaseReference gamesReference;
+
+    private String pendingCreateRoomId;
+    private String pendingJoinRoomId;
+
+    /** Creates a repository backed by the configured production Firebase instances. */
+    public MultiplayerRepository() {
+        this(FirebaseAuth.getInstance(), FirebaseRefs.database());
+    }
+
+    /** Allows debug builds and tests to inject Firebase instances configured for emulators. */
+    public MultiplayerRepository(FirebaseAuth auth, FirebaseDatabase database) {
+        this.auth = auth;
+        this.database = database;
+        this.gamesReference = FirebaseRefs.games(database);
+    }
 
     /** Returns the authenticated Firebase user's UID, or {@code null} when signed out. */
     public String currentUid() {
         FirebaseUser user = auth.getCurrentUser();
                     "Email and password are required", null));
             return;
         }
         auth.createUserWithEmailAndPassword(email.trim(), password)
+                .addOnSuccessListener(result -> callback.onSuccess(result.getUser().getUid()))
+                .addOnFailureListener(exception -> callback.onError(firebaseError(exception, null)));
+    }
+
+    /** Exchanges a Google ID token obtained by the Android credential UI for Firebase Auth. */
+    public void signInWithGoogle(String idToken, ResultCallback<String> callback) {
+        if (idToken == null || idToken.trim().isEmpty()) {
+            callback.onError(error(RepositoryError.Code.INVALID_ARGUMENT,
+                    "Google sign-in returned no ID token", null));
+            return;
+        }
+        auth.signInWithCredential(GoogleAuthProvider.getCredential(idToken.trim(), null))
                 .addOnSuccessListener(result -> callback.onSuccess(result.getUser().getUid()))
                 .addOnFailureListener(exception -> callback.onError(firebaseError(exception, null)));
     }
         auth.addAuthStateListener(firebaseListener);
         return once(() -> auth.removeAuthStateListener(firebaseListener));
     }
 
+
+    /** Subscribes to the lobby's validated room summaries. */
+    public Subscription subscribeLobby(LobbyListener listener) {
+        if (currentUid() == null) {
+            listener.onError(error(RepositoryError.Code.AUTH_REQUIRED,
+                    "Sign in to view online rooms", null));
+            return Subscription.EMPTY;
+        }
+
+        ValueEventListener firebaseListener = new ValueEventListener() {
+            @Override
+            public void onDataChange(DataSnapshot snapshot) {
+                List<RoomSummary> rooms = new ArrayList<>();
+                for (DataSnapshot roomSnapshot : snapshot.getChildren()) {
+                    String roomId = roomSnapshot.getKey();
+                    ParsedRoom parsed = parseRoom(roomSnapshot);
+                    if (parsed.error != null) {
+                        String name = parsed.room == null || parsed.room.getName() == null
+                                ? "Invalid room" : parsed.room.getName();
+                        rooms.add(new RoomSummary(roomId, name, null, parsed.error));
+                    } else {
+                        rooms.add(new RoomSummary(roomId, parsed.room.getName(),
+                                parsed.validation.getStatus(), null));
+                    }
+                }
+                listener.onRooms(Collections.unmodifiableList(rooms));
+            }
+
+            @Override
+            public void onCancelled(DatabaseError databaseError) {
+                listener.onError(databaseError(databaseError, null));
+            }
+        };
+        gamesReference.addValueEventListener(firebaseListener);
+        return once(() -> gamesReference.removeEventListener(firebaseListener));
+    }
+
+    /** Subscribes to one room after validating its data and replaying its move history. */
+    public Subscription subscribeRoom(String roomId, RoomListener listener) {
+        if (!validRoomId(roomId)) {
+            listener.onError(error(RepositoryError.Code.INVALID_ARGUMENT,
+                    "Room ID is missing or invalid", roomId));
+            return Subscription.EMPTY;
+        }
+        String uid = currentUid();
+        if (uid == null) {
+            listener.onError(error(RepositoryError.Code.AUTH_REQUIRED,
+                    "Sign in to open an online room", roomId));
+            return Subscription.EMPTY;
+        }
+
+        DatabaseReference roomReference = gamesReference.child(roomId);
+        ValueEventListener firebaseListener = new ValueEventListener() {
+            @Override
+            public void onDataChange(DataSnapshot snapshot) {
+                if (!snapshot.exists()) {
+                    listener.onError(error(RepositoryError.Code.MISSING_ROOM,
+                            "This room no longer exists", roomId));
+                    return;
+                }
+                ParsedRoom parsed = parseRoom(snapshot);
+                if (parsed.error != null) {
+                    listener.onError(error(RepositoryError.Code.INVALID_ROOM,
+                            parsed.error, roomId));
+                    return;
+                }
+                RoomRole role = roleFor(parsed.room, uid);
+                listener.onRoom(new RoomSnapshot(roomId, parsed.room,
+                        parsed.validation.getState(), role, parsed.validation.getStatus()));
+            }
+
+            @Override
+            public void onCancelled(DatabaseError databaseError) {
+                listener.onError(databaseError(databaseError, roomId));
+            }
+        };
+        roomReference.addValueEventListener(firebaseListener);
+        return once(() -> roomReference.removeEventListener(firebaseListener));
+    }
+
+    /** Performs a one-shot server read used to gate input after reconnecting. */
+    public void getRoom(String roomId, ResultCallback<RoomSnapshot> callback) {
+        if (!validRoomId(roomId)) {
+            callback.onError(error(RepositoryError.Code.INVALID_ARGUMENT,
+                    "Room ID is missing or invalid", roomId));
+            return;
+        }
+        String uid = currentUid();
+        if (uid == null) {
+            callback.onError(error(RepositoryError.Code.AUTH_REQUIRED,
+                    "Sign in to open an online room", roomId));
+            return;
+        }
+        gamesReference.child(roomId).get().addOnCompleteListener(task -> {
+            if (!task.isSuccessful()) {
+                callback.onError(firebaseError(task.getException(), roomId));
+                return;
+            }
+            DataSnapshot snapshot = task.getResult();
+            if (!snapshot.exists()) {
+                callback.onError(error(RepositoryError.Code.MISSING_ROOM,
+                        "This room no longer exists", roomId));
+                return;
+            }
+            ParsedRoom parsed = parseRoom(snapshot);
+            if (parsed.error != null) {
+                callback.onError(error(RepositoryError.Code.INVALID_ROOM,
+                        parsed.error, roomId));
+                return;
+            }
+            callback.onSuccess(new RoomSnapshot(roomId, parsed.room,
+                    parsed.validation.getState(), roleFor(parsed.room, uid),
+                    parsed.validation.getStatus()));
+        });
+    }
+
+    /** Creates a Red-owned waiting room with a server-assigned identifier. */
+    public void createRoom(String rawName, ResultCallback<String> callback) {
+        String uid = currentUid();
+        if (uid == null) {
+            callback.onError(error(RepositoryError.Code.AUTH_REQUIRED,
+                    "Sign in before creating a room", null));
+            return;
+        }
+        String name = rawName == null ? "" : rawName.trim();
+        if (name.isEmpty() || name.length() > RoomValidation.MAX_NAME_LENGTH) {
+            callback.onError(error(RepositoryError.Code.INVALID_ARGUMENT,
+                    "Room name must contain 1-60 characters", null));
+            return;
+        }
+
+        DatabaseReference newRoomReference;
+        synchronized (this) {
+            if (pendingCreateRoomId != null) {
+                callback.onError(error(RepositoryError.Code.BUSY,
+                        "A room creation is already pending", pendingCreateRoomId));
+                return;
+            }
+            newRoomReference = gamesReference.push();
+            pendingCreateRoomId = newRoomReference.getKey();
+        }
+
+        String roomId = newRoomReference.getKey();
+        Map<String, Object> room = new LinkedHashMap<>();
+        room.put("schemaVersion", RoomValidation.SCHEMA_VERSION);
+        room.put("name", name);
+        room.put("playerRed", uid);
+        room.put("playerYellow", "");
+        room.put("moves", "");
+        room.put("createdAt", ServerValue.TIMESTAMP);
+        room.put("updatedAt", ServerValue.TIMESTAMP);
+
+        newRoomReference.setValue(room).addOnCompleteListener(task -> {
+            clearPendingCreate(roomId);
+            if (task.isSuccessful()) {
+                callback.onSuccess(roomId);
+            } else {
+                callback.onError(firebaseError(task.getException(), roomId));
+            }
+        });
+    }
+
+    /** Claims Yellow atomically, or returns the caller's existing/spectator role. */
+    public void joinRoom(String roomId, ResultCallback<RoomRole> callback) {
+        String uid = currentUid();
+        if (uid == null) {
+            callback.onError(error(RepositoryError.Code.AUTH_REQUIRED,
+                    "Sign in before joining a room", roomId));
+            return;
+        }
+        if (!validRoomId(roomId)) {
+            callback.onError(error(RepositoryError.Code.INVALID_ARGUMENT,
+                    "Room ID is missing or invalid", roomId));
+            return;
+        }
+        synchronized (this) {
+            if (pendingJoinRoomId != null) {
+                callback.onError(error(RepositoryError.Code.BUSY,
+                        "A room join is already pending", pendingJoinRoomId));
+                return;
+            }
+            pendingJoinRoomId = roomId;
+        }
+
+        DatabaseReference roomReference = gamesReference.child(roomId);
+        roomReference.get().addOnCompleteListener(readTask -> {
+            if (!readTask.isSuccessful()) {
+                clearPendingJoin(roomId);
+                callback.onError(firebaseError(readTask.getException(), roomId));
+                return;
+            }
+            if (!readTask.getResult().exists()) {
+                clearPendingJoin(roomId);
+                callback.onError(error(RepositoryError.Code.MISSING_ROOM,
+                        "This room no longer exists", roomId));
+                return;
+            }
+            ParsedRoom baseline = parseRoom(readTask.getResult());
+            if (baseline.error != null) {
+                clearPendingJoin(roomId);
+                callback.onError(error(RepositoryError.Code.INVALID_ROOM,
+                        baseline.error, roomId));
+                return;
+            }
+            RoomRole existingRole = roleFor(baseline.room, uid);
+            if (existingRole != RoomRole.SPECTATOR
+                    || !baseline.room.getPlayerYellow().isEmpty()) {
+                clearPendingJoin(roomId);
+                callback.onSuccess(existingRole);
+                return;
+            }
+            runJoinTransaction(roomReference, roomId, uid, baseline.room, callback);
+        });
+    }
+
+    private void runJoinTransaction(DatabaseReference roomReference, String roomId, String uid,
+                                    RoomData baseline,
+                                    ResultCallback<RoomRole> callback) {
+        AtomicReference<String> rejection = new AtomicReference<>("Room changed while joining");
+        AtomicReference<RoomRole> resolvedRole = new AtomicReference<>();
+        roomReference.runTransaction(new Transaction.Handler() {
+            @Override
+            public Transaction.Result doTransaction(MutableData currentData) {
+                seedEmptyTransaction(currentData, baseline);
+                MutableRoom parsed = parseMutableRoom(currentData);
+                if (parsed.error != null) {
+                    rejection.set(parsed.error);
+                    return Transaction.abort();
+                }
+                RoomData room = parsed.room;
+                if (uid.equals(room.getPlayerRed())) {
+                    resolvedRole.set(RoomRole.RED);
+                    return Transaction.abort();
+                }
+                if (uid.equals(room.getPlayerYellow())) {
+                    resolvedRole.set(RoomRole.YELLOW);
+                    return Transaction.abort();
+                }
+                if (!room.getPlayerYellow().isEmpty()) {
+                    resolvedRole.set(RoomRole.SPECTATOR);
+                    return Transaction.abort();
+                }
+                if (!room.getMoves().isEmpty()) {
+                    rejection.set("A room cannot be joined after moves have started");
+                    return Transaction.abort();
+                }
+                currentData.child("playerYellow").setValue(uid);
+                currentData.child("updatedAt").setValue(ServerValue.TIMESTAMP);
+                rejection.set(null);
+                return Transaction.success(currentData);
+            }
+
+            @Override
+            public void onComplete(DatabaseError databaseError, boolean committed,
+                                   DataSnapshot snapshot) {
+                clearPendingJoin(roomId);
+                if (databaseError != null) {
+                    callback.onError(databaseError(databaseError, roomId));
+                    return;
+                }
+                if (!committed) {
+                    if (resolvedRole.get() != null) {
+                        callback.onSuccess(resolvedRole.get());
+                        return;
+                    }
+                    callback.onError(error(RepositoryError.Code.CONFLICT,
+                            rejection.get(), roomId));
+                    return;
+                }
+                // Re-read committed data: transaction snapshots can contain estimated timestamps.
+                getRoom(roomId, new ResultCallback<RoomSnapshot>() {
+                    @Override public void onSuccess(RoomSnapshot room) {
+                        callback.onSuccess(room.getRole());
+                    }
+                    @Override public void onError(RepositoryError failure) {
+                        callback.onError(failure);
+                    }
+                });
+            }
+        }, false);
+    }
+
+
+
+    public synchronized String pendingCreateRoomId() {
+        return pendingCreateRoomId;
+    }
+
+    public synchronized String pendingJoinRoomId() {
+        return pendingJoinRoomId;
+    }
+
+
+    private synchronized void clearPendingCreate(String roomId) {
+        if (roomId != null && roomId.equals(pendingCreateRoomId)) {
+            pendingCreateRoomId = null;
+        }
+    }
+
+    private synchronized void clearPendingJoin(String roomId) {
+        if (roomId.equals(pendingJoinRoomId)) {
+            pendingJoinRoomId = null;
+        }
+    }
+
+
+    private static ParsedRoom parseRoom(DataSnapshot snapshot) {
+        RoomData room;
+        try {
+            room = snapshot.getValue(RoomData.class);
+        } catch (DatabaseException exception) {
+            return new ParsedRoom(null, null, "Room fields have invalid types");
+        }
+        if (!hasExactFields(snapshot)) {
+            return new ParsedRoom(room, null, "Room fields are missing or unexpected");
+        }
+        RoomValidation.Result validation = RoomValidation.validate(room);
+        return validation.isValid()
+                ? new ParsedRoom(room, validation, null)
+                : new ParsedRoom(room, validation, validation.getError());
+    }
+
+    private static MutableRoom parseMutableRoom(MutableData data) {
+        if (data == null || data.getValue() == null) {
+            return new MutableRoom(null, null, "Room data is not available");
+        }
+        if (!hasExactFields(data)) {
+            return new MutableRoom(null, null, "Room fields are missing or unexpected");
+        }
+        RoomData room;
+        try {
+            room = data.getValue(RoomData.class);
+        } catch (DatabaseException exception) {
+            return new MutableRoom(null, null, "Room fields have invalid types");
+        }
+        RoomValidation.Result validation = RoomValidation.validate(room);
+        return validation.isValid()
+                ? new MutableRoom(room, validation, null)
+                : new MutableRoom(room, validation, validation.getError());
+    }
+
+    /**
+     * Transactions may make an initial callback with an empty local cache. The prior server read
+     * supplies a candidate for that attempt. Firebase's compare-and-retry protocol replaces it
+     * with current server data before any stale candidate can commit.
+     */
+    private static void seedEmptyTransaction(MutableData data, RoomData baseline) {
+        if (data.getValue() == null) {
+            data.setValue(baseline);
+        }
+    }
+
+    private static boolean hasExactFields(DataSnapshot snapshot) {
+        return snapshot.getChildrenCount() == 7
+                && snapshot.hasChild("schemaVersion")
+                && snapshot.hasChild("name")
+                && snapshot.hasChild("playerRed")
+                && snapshot.hasChild("playerYellow")
+                && snapshot.hasChild("moves")
+                && snapshot.hasChild("createdAt")
+                && snapshot.hasChild("updatedAt");
+    }
+
+    private static boolean hasExactFields(MutableData data) {
+        return data.getChildrenCount() == 7
+                && data.hasChild("schemaVersion")
+                && data.hasChild("name")
+                && data.hasChild("playerRed")
+                && data.hasChild("playerYellow")
+                && data.hasChild("moves")
+                && data.hasChild("createdAt")
+                && data.hasChild("updatedAt");
+    }
+
+    private static RoomRole roleFor(RoomData room, String uid) {
+        if (uid.equals(room.getPlayerRed())) {
+            return RoomRole.RED;
+        }
+        if (uid.equals(room.getPlayerYellow())) {
+            return RoomRole.YELLOW;
+        }
+        return RoomRole.SPECTATOR;
+    }
+
+    private static boolean validRoomId(String roomId) {
+        return roomId != null && !roomId.isEmpty()
+                && roomId.indexOf('/') < 0 && roomId.indexOf('.') < 0
+                && roomId.indexOf('#') < 0 && roomId.indexOf('$') < 0
+                && roomId.indexOf('[') < 0 && roomId.indexOf(']') < 0;
+    }
+
+    private static RepositoryError databaseError(DatabaseError databaseError, String roomId) {
+        RepositoryError.Code code;
+        if (databaseError.getCode() == DatabaseError.PERMISSION_DENIED) {
+            code = RepositoryError.Code.PERMISSION_DENIED;
+        } else if (databaseError.getCode() == DatabaseError.DISCONNECTED
+                || databaseError.getCode() == DatabaseError.NETWORK_ERROR) {
+            code = RepositoryError.Code.NETWORK;
+        } else {
+            code = RepositoryError.Code.CANCELLED;
+        }
+        return error(code, databaseError.getMessage(), roomId);
+    }
+
     private static RepositoryError firebaseError(Exception exception, String roomId) {
         String message = exception == null || exception.getLocalizedMessage() == null
                 ? "Firebase operation failed" : exception.getLocalizedMessage();
         return error(RepositoryError.Code.UNKNOWN, message, roomId);
             }
         };
     }
 
+    private static final class ParsedRoom {
+        private final RoomData room;
+        private final RoomValidation.Result validation;
+        private final String error;
+
+        private ParsedRoom(RoomData room, RoomValidation.Result validation, String error) {
+            this.room = room;
+            this.validation = validation;
+            this.error = error;
+        }
+    }
+
+    private static final class MutableRoom {
+        private final RoomData room;
+        private final RoomValidation.Result validation;
+        private final String error;
+
+        private MutableRoom(RoomData room, RoomValidation.Result validation, String error) {
+            this.room = room;
+            this.validation = validation;
+            this.error = error;
+        }
+    }
+
     public interface Subscription extends AutoCloseable {
         Subscription EMPTY = () -> { };
-        @Override void close();
-    }
+
+        @Override
+        void close();
+    }
+
     public interface ResultCallback<T> {
         void onSuccess(T result);
+
         void onError(RepositoryError error);
     }
-    public interface AuthListener { void onAuthChanged(String uid); }
-
-    /** Exchanges a Google ID token obtained by the Android credential UI for Firebase Auth. */
-    public void signInWithGoogle(String idToken, ResultCallback<String> callback) {
-        if (idToken == null || idToken.trim().isEmpty()) {
-            callback.onError(error(RepositoryError.Code.INVALID_ARGUMENT,
-                    "Google sign-in returned no ID token", null));
-            return;
-        }
-        auth.signInWithCredential(GoogleAuthProvider.getCredential(idToken.trim(), null))
-                .addOnSuccessListener(result -> callback.onSuccess(result.getUser().getUid()))
-                .addOnFailureListener(exception -> callback.onError(firebaseError(exception, null)));
+
+    public interface AuthListener {
+        void onAuthChanged(String uid);
+    }
+
+    public interface LobbyListener {
+        void onRooms(List<RoomSummary> rooms);
+
+        void onError(RepositoryError error);
+    }
+
+    public interface RoomListener {
+        void onRoom(RoomSnapshot room);
+
+        void onError(RepositoryError error);
     }
 }

GameSession.java

app > kotlin+java > com.example.connect4 > GameSession.java

שומרים את מפתח החדר ואת הצילום האחרון. מספר דור online מונע מבקשה שהחלה במסך קודם להעביר אותנו לחדר אחרי שכבר חזרנו ללובי.

 import android.os.Handler;
 import android.os.Looper;
 import androidx.annotation.MainThread;
 import androidx.lifecycle.ViewModel;
+import com.example.connect4.ai.ModelCatalog;
 import com.example.connect4.ai.ComputerMoveService;
-import com.example.connect4.ai.ModelCatalog;
-import com.example.connect4.multiplayer.MultiplayerRepository;
 import com.example.connect4.core.GameEngine;
 import com.example.connect4.core.GameState;
+import com.example.connect4.multiplayer.MultiplayerRepository;
+import com.example.connect4.multiplayer.RoomSnapshot;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.function.Consumer;
     public static final int MODE_LOCAL = 0;
     public static final int MODE_HEURISTIC = 1;
     public static final int MODE_MODEL = 2;
     public static final int MODE_ONLINE = 3;
-    public MultiplayerRepository repository;
-    public boolean authPending;
-    public String onlineMessage = "";
-    public String onlineUid;
-    public long onlineGeneration;
-    public long googleRequestGeneration;
-    public Runnable refreshOnline;
-    public String selectedModelId = ModelCatalog.DEFAULT_MODEL_ID;
 
     public GameEngine engine = new GameEngine();
     public int mode = MODE_LOCAL;
     public int humanSide = GameState.RED;
     public boolean restored;
-    public String modelStatus = "Heuristic · win, block, then center";
+    public String modelStatus = "Model loads on the computer’s turn";
+    public String selectedModelId = ModelCatalog.DEFAULT_MODEL_ID;
+    public String roomId;
+    public MultiplayerRepository repository;
+    public RoomSnapshot room;
+    public String onlineMessage = "";
+    public String onlineUid;
+    public long onlineGeneration;
+    public boolean authPending;
+    public long googleRequestGeneration;
+    public Runnable refreshOnline;
     private final ExecutorService computerWorker = Executors.newSingleThreadExecutor();
     private final Handler mainThread = new Handler(Looper.getMainLooper());
     private Future<?> pendingComputerMove;
     private long computerRequestId;

activity_main.xml

app > res > layout > activity_main.xml

מוסיפים שדה שם, כפתור יצירה, אוסף כפתורי חדרים וחזרה ללובי. אין ListView או RecyclerView חדש: זהו לובי קטן עם כפתורים שנבנים מתוך הרשימה העדכנית.

                 android:hint="Password" android:inputType="textPassword" android:autofillHints="password" android:saveEnabled="false" />
             <Button android:id="@+id/signIn" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Sign in" />
             <Button android:id="@+id/signUp" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Create account" />
         </LinearLayout>
+        <LinearLayout android:id="@+id/lobbyPanel" android:orientation="vertical"
+            android:layout_width="match_parent" android:layout_height="wrap_content" android:visibility="gone">
+            <EditText android:id="@+id/roomName" android:layout_width="match_parent" android:layout_height="wrap_content"
+                android:hint="Room name" android:maxLength="60" android:inputType="textCapSentences" />
+            <Button android:id="@+id/createRoom" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Create room" />
+            <LinearLayout android:id="@+id/rooms" android:orientation="vertical"
+                android:layout_width="match_parent" android:layout_height="wrap_content" />
+        </LinearLayout>
+        <Button android:id="@+id/backToLobby" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Return to lobby" />
         <Button android:id="@+id/signOut" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Sign out" />
     </LinearLayout>
     </LinearLayout>
 </ScrollView>

OnlineController.java

app > kotlin+java > com.example.connect4 > OnlineController.java

בכל מעבר סוגרים את Subscription של המסך הקודם. בלובי יוצרים כפתור לכל RoomSummary; בחדר מאזינים ל־RoomSnapshot. ה־Host מוסר ל־Activity צילום מאומת. יציאה ללובי אינה מחיקה או reset של החדר.

ה־callback בודק גם שהמסך פעיל וגם שמספר הדור תואם. השלמת כתיבה מבקשת רענון מהמסך העדכני דרך Session, כך שסיבוב לא שולח עדכון ל־View שכבר הושמד. זיהוי ניתוק וחסימת קלט עד סנכרון שרת יתווספו בפרק 12.

 package com.example.connect4;
 
 import android.view.View;
+import android.widget.Button;
 import com.example.connect4.databinding.ActivityMainBinding;
 import com.example.connect4.multiplayer.*;
-
-/** Connects the login controls to retained authentication state. */
+import java.util.ArrayList;
+import java.util.List;
+
+/** Screen subscriptions are Activity-owned; pending writes stay in the retained repository. */
 final class OnlineController {
-    interface Host { void changed(); }
+    interface Host { void changed(); void received(RoomSnapshot room); }
     private final ActivityMainBinding ui;
     private final GameSession session;
     private final Host host;
-    private boolean active;
     private final GoogleSignIn googleSignIn;
     private boolean pickingGoogle;
-    private MultiplayerRepository.Subscription auth = MultiplayerRepository.Subscription.EMPTY;
+    private final List<MultiplayerRepository.Subscription> subscriptions = new ArrayList<>();
+    private MultiplayerRepository.Subscription screen = MultiplayerRepository.Subscription.EMPTY;
+    private boolean active;
+    private long screenGeneration;
     private final Runnable refreshCallback = this::refreshScreen;
 
     OnlineController(android.app.Activity activity, ActivityMainBinding ui, GameSession session, Host host) {
-        this.ui=ui; this.session=session; this.host=host;
-        googleSignIn=new GoogleSignIn(activity);
+        this.ui = ui;
+        this.session = session;
+        this.host = host;
+        googleSignIn = new GoogleSignIn(activity);
         ui.googleSignIn.setOnClickListener(v -> authenticateGoogle());
         ui.signIn.setOnClickListener(v -> authenticate(false));
         ui.signUp.setOnClickListener(v -> authenticate(true));
         ui.signOut.setOnClickListener(v -> {
             session.onlineGeneration++;
+            session.roomId = null;
+            session.room = null;
             if (session.repository != null) session.repository.signOut();
             googleSignIn.clearSession();
             refreshScreen();
         });
-    }
-
-    /** Attaches a single auth listener while the online screen is visible. */
+        ui.createRoom.setOnClickListener(v -> createRoom());
+        ui.backToLobby.setOnClickListener(v -> lobby());
+    }
+
     void start() {
         if (active || session.mode != GameSession.MODE_ONLINE) return;
-        active=true;
-        session.refreshOnline=refreshCallback;
+        active = true;
+        session.refreshOnline = refreshCallback;
         try {
-            if (session.repository==null) session.repository=new MultiplayerRepository();
-            auth=session.repository.subscribeAuth(uid -> {
+            if (session.repository == null) session.repository = new MultiplayerRepository();
+            MultiplayerRepository repo = session.repository;
+            subscriptions.add(repo.subscribeAuth(uid -> {
                 if (!active) return;
-                session.onlineUid=uid;
+                if (!java.util.Objects.equals(uid, session.onlineUid)) {
+                    session.onlineGeneration++;
+                    if (session.onlineUid != null) { session.roomId = null; session.room = null; }
+                    session.onlineUid = uid;
+                }
                 refreshScreen();
-            });
-        } catch (IllegalStateException failure) {
-            session.onlineMessage="Firebase setup unavailable. Add the registered Connect4 google-services.json and rebuild.";
-        }
-        refreshScreen();
-    }
-
-    /** Releases Activity-owned callbacks without signing the user out. */
+            }));
+            refreshScreen();
+        } catch (IllegalStateException exception) {
+            session.onlineMessage = "Firebase setup unavailable. Add the registered Connect4 google-services.json and rebuild.";
+            host.changed();
+        }
+    }
+
     void stop() {
-        active=false;
-        auth.close();
-        if (session.refreshOnline==refreshCallback) session.refreshOnline=null;
-    }
+        active = false;
+        if (session.refreshOnline == refreshCallback) session.refreshOnline = null;
+        screenGeneration++;
+        screen.close();
+        screen = MultiplayerRepository.Subscription.EMPTY;
+        for (MultiplayerRepository.Subscription subscription : subscriptions) subscription.close();
+        subscriptions.clear();
+    }
+
     void modeChanged() {
         session.onlineGeneration++;
-        if (session.mode==GameSession.MODE_ONLINE) start(); else stop();
-    }
-    private void refreshScreen() { if (active) host.changed(); }
-    private void refreshLatestScreen() { if (session.refreshOnline!=null) session.refreshOnline.run(); }
-    private void showError(RepositoryError error) { session.onlineMessage=error.getMessage(); }
-    void destroy() {
-        if (pickingGoogle) {
-            session.googleRequestGeneration++;
-            session.authPending = false;
-            pickingGoogle = false;
-        }
-        googleSignIn.cancel();
-    }
-
-    /** Shows the session's identity and enables only available actions. */
-    void render() {
-        boolean signedIn=session.repository!=null && session.repository.currentUid()!=null;
-        ui.authPanel.setVisibility(signedIn ? View.GONE : View.VISIBLE);
-        ui.signOut.setVisibility(signedIn ? View.VISIBLE : View.GONE);
-        ui.signIn.setEnabled(session.repository!=null && !session.authPending);
-        ui.signUp.setEnabled(session.repository!=null && !session.authPending);
-        ui.googleSignIn.setEnabled(session.repository!=null && !session.authPending);
-        ui.onlineStatus.setText(signedIn ? "Signed in: " + session.repository.currentUid() : session.onlineMessage);
+        if (session.mode == GameSession.MODE_ONLINE) start(); else stop();
+    }
+
+    private void refreshScreen() {
+        screen.close();
+        screen = MultiplayerRepository.Subscription.EMPTY;
+        long token = ++screenGeneration;
+        if (!active || session.repository == null || session.repository.currentUid() == null) { host.changed(); return; }
+        if (session.roomId == null) {
+            screen = session.repository.subscribeLobby(new MultiplayerRepository.LobbyListener() {
+                @Override public void onRooms(List<RoomSummary> rooms) {
+                    if (!active || token != screenGeneration) return;
+                    ui.rooms.removeAllViews();
+                    for (RoomSummary room : rooms) {
+                        Button button = new Button(ui.getRoot().getContext());
+                        button.setText(room.getName() + " · " + (room.isValid() ? room.getStatus().toString().toLowerCase(java.util.Locale.ROOT) : "invalid room"));
+                        button.setEnabled(room.isValid() && !pending());
+                        button.setOnClickListener(v -> open(room.getRoomId()));
+                        ui.rooms.addView(button);
+                    }
+                    host.changed();
+                }
+                @Override public void onError(RepositoryError error) { if (token == screenGeneration) showError(error); }
+            });
+        } else {
+            screen = session.repository.subscribeRoom(session.roomId, new MultiplayerRepository.RoomListener() {
+                @Override public void onRoom(RoomSnapshot room) {
+                    if (!active || token != screenGeneration) return;
+                    session.room = room;
+                    host.received(room);
+                }
+                @Override public void onError(RepositoryError error) { if (token == screenGeneration) showError(error); }
+            });
+        }
+        host.changed();
+    }
+
+    private void refreshLatestScreen() {
+        if (session.refreshOnline != null) session.refreshOnline.run();
     }
 
     private void authenticate(boolean create) {
         if (session.repository == null || session.authPending) return;
             }
         });
         host.changed();
     }
+
+    void destroy() {
+        if (pickingGoogle) {
+            session.googleRequestGeneration++;
+            session.authPending = false;
+            pickingGoogle = false;
+        }
+        googleSignIn.cancel();
+    }
+
+    void createRoom() {
+        if (pending() || session.repository == null) return;
+        long token = session.onlineGeneration;
+        session.onlineMessage = "Creating room…";
+        session.repository.createRoom(ui.roomName.getText().toString(), new MultiplayerRepository.ResultCallback<String>() {
+            @Override public void onSuccess(String roomId) {
+                if (token != session.onlineGeneration) return;
+                session.roomId = roomId;
+                session.room = null;
+                session.onlineMessage = "Room created";
+                refreshLatestScreen();
+            }
+            @Override public void onError(RepositoryError error) {
+                if (token == session.onlineGeneration) { showError(error); refreshLatestScreen(); }
+            }
+        });
+        host.changed();
+    }
+
+    void newRoom() {
+        if (session.room != null && !pending()) {
+            ui.roomName.setText(session.room.getRoom().getName());
+            createRoom();
+        } else {
+            lobby();
+        }
+    }
+
+    private void open(String roomId) {
+        if (pending()) return;
+        session.onlineGeneration++;
+        long token = session.onlineGeneration;
+        session.roomId = roomId;
+        session.room = null;
+        session.onlineMessage = "Opening room…";
+        session.repository.joinRoom(roomId, new MultiplayerRepository.ResultCallback<RoomRole>() {
+            @Override public void onSuccess(RoomRole role) {
+                if (token != session.onlineGeneration) return;
+                session.onlineMessage = "Joined as " + role.toString().toLowerCase(java.util.Locale.ROOT);
+                refreshLatestScreen();
+            }
+            @Override public void onError(RepositoryError error) {
+                if (token != session.onlineGeneration) return;
+                showError(error);
+                refreshLatestScreen();
+            }
+        });
+        refreshScreen();
+    }
+
+    void lobby() {
+        session.onlineGeneration++;
+        session.roomId = null;
+        session.room = null;
+        session.onlineMessage = "Choose a room or create a new one. Previous rooms are preserved.";
+        refreshScreen();
+    }
+
+
+
+    private boolean pending() {
+        MultiplayerRepository repo = session.repository;
+        return repo != null && (repo.pendingCreateRoomId() != null || repo.pendingJoinRoomId() != null);
+    }
+
+    private void showError(RepositoryError error) {
+        session.onlineMessage = error.getMessage();
+        if (active) host.changed();
+    }
+
+    void render() {
+        boolean signedIn = session.repository != null && session.repository.currentUid() != null;
+        boolean inRoom = signedIn && session.roomId != null;
+        ui.authPanel.setVisibility(signedIn ? View.GONE : View.VISIBLE);
+        ui.lobbyPanel.setVisibility(signedIn && !inRoom ? View.VISIBLE : View.GONE);
+        ui.backToLobby.setVisibility(inRoom ? View.VISIBLE : View.GONE);
+        ui.signOut.setVisibility(signedIn ? View.VISIBLE : View.GONE);
+        ui.signIn.setEnabled(session.repository != null && !session.authPending);
+        ui.signUp.setEnabled(session.repository != null && !session.authPending);
+        ui.googleSignIn.setEnabled(session.repository != null && !session.authPending);
+        ui.createRoom.setEnabled(!pending());
+        ui.signOut.setEnabled(!pending() && !session.authPending);
+        for (int i = 0; i < ui.rooms.getChildCount(); i++) {
+            // Invalid rooms are never actionable; each button also guards connection in open().
+            ui.rooms.getChildAt(i).setAlpha(!pending() ? 1f : .5f);
+        }
+        String roomLabel = session.room == null || !inRoom ? "" : session.room.getRoom().getName() + " · "
+                + session.room.getRole().toString().toLowerCase(java.util.Locale.ROOT) + " · "
+                + session.room.getStatus().toString().toLowerCase(java.util.Locale.ROOT) + "\n";
+        ui.onlineStatus.setText(roomLabel + "Room screen"
+                + (pending() ? " · write pending / uncertain" : "") + "\n" + session.onlineMessage);
+        ui.offlineGame.setVisibility(inRoom ? View.VISIBLE : View.GONE);
+        ui.restart.setText("New room");
+        ui.restart.setEnabled(session.room != null && !pending());
+        ui.aiStatus.setText("Online · " + (session.room == null ? "reconciling room" : session.room.getRole().toString().toLowerCase(java.util.Locale.ROOT)));
+    }
 }

MainActivity.java

app > kotlin+java > com.example.connect4 > MainActivity.java

מחברים את צילום החדר לאותו מנוע ולאותו BoardView. בשלב זה humanTurn מחזירה false במצב online; נרשה תור רשת רק בפרק הבא. שומרים את מפתח החדר כדי שאפשר יהיה לפתוח אותו מחדש.

             session.humanSide = saved.getInt("side", GameState.RED);
             if (session.humanSide != GameState.RED && session.humanSide != GameState.YELLOW) {
                 session.humanSide = GameState.RED;
             }
+            session.roomId = saved.getString("room", null);
             session.selectedModelId = saved.getString(
                     "computer_player", modelCatalog.defaultModelId());
             String moves = saved.getString("moves", "");
             GameEngine restored = new GameEngine();
                     + " loads on the computer’s turn";
         }
         online = new OnlineController(this, binding, session, new OnlineController.Host() {
             @Override public void changed() { persist(); render(); }
+            @Override public void received(com.example.connect4.multiplayer.RoomSnapshot room) {
+                session.engine = new GameEngine(room.getGameState());
+                persist();
+                render();
+            }
         });
         setup.configure(modelCatalog.entries().stream()
                 .map(ModelCatalog.Entry::displayName).toArray(String[]::new),
                 session.mode, session.humanSide - 1, selectedModelPosition(),
     }
 
     private void newGame() {
         session.cancelComputerMove();
+        if (session.mode == GameSession.MODE_ONLINE) { online.newRoom(); return; }
         session.engine = new GameEngine();
         persist();
         render();
     }

לפני

}

private void persist() {
    getPreferences(MODE_PRIVATE).edit().putInt("mode", session.mode).putInt("side", session.humanSide)
            .putString("moves", session.engine.snapshot().moves())
            .putString("computer_player", session.selectedModelId).apply();
}

private ModelCatalog.Entry selectedModel() {

אחרי

}

private void persist() {
    getPreferences(MODE_PRIVATE).edit().putInt("mode", session.mode).putInt("side", session.humanSide)
            .putString("moves", session.engine.snapshot().moves()).putString("room", session.roomId)
            .putString("computer_player", session.selectedModelId).apply();
}

private ModelCatalog.Entry selectedModel() {

מריצים ובודקים

  1. עם שני חשבונות בדיקה, צרו חדר במכשיר אחד ובדקו שהוא מופיע בלובי של השני.
  2. היוצר הוא Red; המשתמש השני הוא Yellow. שלישי הוא Spectator.
  3. נסו לתפוס את הצהוב בשני לקוחות בו־זמנית: בדיוק אחד מקבל Yellow.
  4. חזרו ללובי ופתחו את אותו החדר: התפקיד לא משתנה. שם זהה לחדר אחר אינו מחליף את המפתח.
  5. הלוח בחדר עדיין אינו מקבל נגיעות. אל תעקפו זאת על ידי הפעלה מקומית של המנוע.

בדיקת Android יוצרת שלושה משתמשים ב־emulator ובודקת תפיסה מתחרה ותפקידים. בדיקות הכללים בודקות יצירה, בעלות והיעדר פגיעה בהרשאות המייצגות של היישומים האחרים. אלה בדיקות מקומיות, לא בדיקת פריסה חיה.

בדיקת הבנה

מדוע בדיקה רגילה של playerYellow ואז setValue אינה מספיקה? תארו שתי בקשות שמגלות בו־זמנית שהמושב ריק, ואיך transaction מכריעה ביניהן.