בסוף הפרק שני אנשים יוכלו להשלים משחק דרך Firebase, וצופה יראה את אותו לוח ואותה תוצאה. מהלך רשת יתקבל רק אם השרת עדיין מחזיק את ההיסטוריה שהייתה מול עיני הלוחץ.
נניח שההיסטוריה היא 34: אדום הכניס לעמודה 3 וצהוב לעמודה 4. בקשת אדום להוסיף 2 נושאת גם את הקידומת 34. אם השרת כבר עבר ל־345, זו בקשה ישנה; אסור לפרש אותה כפעולה חדשה בתור מאוחר יותר.
הרחבת כללי הגישה
המורה ממזג את קטע הכללים של פרק 11 במקום הקטע הקודם, לאחר בדיקתו ב־emulator. הענף החדש מאפשר להוסיף תו אחד להיסטוריה, רק למשתמש שיושב בצבע שתורו נגזר מזוגיות אורך ההיסטוריה. המפתח, השם, המושבים וזמן היצירה נשארים קבועים. אין reset או מחיקה של חדר מהלקוח.
הכללים מגינים על בעלות, צורת נתונים וסדר תורות. חוקי כוח הכבידה והניצחון נבדקים במנוע Java של הלקוח הרגיל. אין כאן שרת משחק סמכותי שמונע כל רמאות אפשרית.
השינויים לפי סדר העבודה
התחילו במצב שעבד בסוף הפרק הקודם. קובץ חדש מוצג במלואו; בקובץ קיים מופיעים רק האזורים שמשתנים. בקטעי diff מסירים את שורות -, מוסיפים את שורות +, ומשאירים את שורות ההקשר. הסימנים עצמם אינם חלק מקוד Java או XML. אין למחוק קוד אחר שאינו מוצג.
RoomSnapshot.java
app > kotlin+java > com.example.connect4 > multiplayer > RoomSnapshot.java
מוסיפים canMove שמחברת בין מצב החדר, התפקיד ותור המנוע. צופה אינו רשאי לשחק, גם אם החדר פעיל.
public RoomStatus getStatus() {
return status;
}
+ /** Reports whether this role owns the side to move in an active, validated room. */
+ public boolean canMove() {
+ if (status != RoomStatus.PLAYING || role == RoomRole.SPECTATOR) {
+ return false;
+ }
+ int player = gameState.currentPlayer();
+ return (role == RoomRole.RED && player == 1)
+ || (role == RoomRole.YELLOW && player == 2);
+ }
}
MultiplayerRepository.java
app > kotlin+java > com.example.connect4 > multiplayer > MultiplayerRepository.java
הפעולה submitMove מקבלת מפתח חדר, עמודה וקידומת צפויה. PendingMove שומרת את שלושת הערכים יחד. לפני העסקה קוראים baseline, וב־runMoveTransaction בודקים שוב את המצב העדכני:
- הסכימה וההיסטוריה תקינות.
- הקידומת תואמת בדיוק לבקשה.
- יש שחקן צהוב ושני מושבים שונים.
- ה־UID המבקש הוא בעל התור.
- עותק של המנוע מקבל את הפעולה — כלומר העמודה אינה מלאה והמשחק לא הסתיים.
רק לאחר כל הבדיקות מוסיפים את העמודה ומעדכנים timestamp. ה־transaction יכול לרוץ שוב; לכן לא משנים בתוכו את המנוע החי ולא מציגים Toast. ב־onComplete מסירים pending ומחזירים הצלחה או שגיאה.
package com.example.connect4.multiplayer;
+import com.example.connect4.core.GameAction;
+import com.example.connect4.core.GameState;
import com.google.firebase.auth.FirebaseAuth;
import com.google.firebase.auth.FirebaseUser;
import com.google.firebase.auth.GoogleAuthProvider;
import com.google.firebase.database.DataSnapshot;
private final DatabaseReference gamesReference;
private String pendingCreateRoomId;
private String pendingJoinRoomId;
+ private PendingMove pendingMove;
/** Creates a repository backed by the configured production Firebase instances. */
public MultiplayerRepository() {
this(FirebaseAuth.getInstance(), FirebaseRefs.database());
}
}, false);
}
-
+ /** Appends one column only when the server still has the captured expected history. */
+ public void submitMove(String roomId, int column, String expectedMoves,
+ ResultCallback<String> callback) {
+ String uid = currentUid();
+ if (uid == null) {
+ callback.onError(error(RepositoryError.Code.AUTH_REQUIRED,
+ "Sign in before playing a move", roomId));
+ return;
+ }
+ if (!validRoomId(roomId) || column < 0 || column > 6
+ || expectedMoves == null || !expectedMoves.matches("[0-6]{0,42}")) {
+ callback.onError(error(RepositoryError.Code.INVALID_ARGUMENT,
+ "Move request is invalid", roomId));
+ return;
+ }
+ PendingMove operation = new PendingMove(roomId, expectedMoves, column);
+ synchronized (this) {
+ if (pendingMove != null) {
+ callback.onError(error(RepositoryError.Code.BUSY,
+ "A move is already pending", pendingMove.roomId));
+ return;
+ }
+ pendingMove = operation;
+ }
+
+ DatabaseReference roomReference = gamesReference.child(roomId);
+ roomReference.get().addOnCompleteListener(readTask -> {
+ if (!readTask.isSuccessful()) {
+ clearPendingMove(operation);
+ callback.onError(firebaseError(readTask.getException(), roomId));
+ return;
+ }
+ if (!readTask.getResult().exists()) {
+ clearPendingMove(operation);
+ callback.onError(error(RepositoryError.Code.MISSING_ROOM,
+ "This room no longer exists", roomId));
+ return;
+ }
+ ParsedRoom baseline = parseRoom(readTask.getResult());
+ if (baseline.error != null) {
+ clearPendingMove(operation);
+ callback.onError(error(RepositoryError.Code.INVALID_ROOM,
+ baseline.error, roomId));
+ return;
+ }
+ runMoveTransaction(roomReference, uid, baseline.room, operation, callback);
+ });
+ }
+
+ private void runMoveTransaction(DatabaseReference roomReference, String uid, RoomData baseline,
+ PendingMove operation, ResultCallback<String> callback) {
+ AtomicReference<String> rejection = new AtomicReference<>("Room changed before the move");
+ 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;
+ RoomValidation.Result validation = parsed.validation;
+ if (!operation.expectedMoves.equals(room.getMoves())) {
+ rejection.set("Move history changed; refresh the room");
+ return Transaction.abort();
+ }
+ if (room.getPlayerYellow().isEmpty()) {
+ rejection.set("Wait for a Yellow player before moving");
+ return Transaction.abort();
+ }
+ int player = validation.getState().currentPlayer();
+ boolean ownsTurn = (player == GameState.RED && uid.equals(room.getPlayerRed()))
+ || (player == GameState.YELLOW && uid.equals(room.getPlayerYellow()));
+ if (!ownsTurn) {
+ rejection.set("It is not this user's turn");
+ return Transaction.abort();
+ }
+ if (!new com.example.connect4.core.GameEngine(validation.getState())
+ .apply(new GameAction(operation.column))) {
+ rejection.set("That column is full or the game is finished");
+ return Transaction.abort();
+ }
+ String newMoves = room.getMoves() + operation.column;
+ currentData.child("moves").setValue(newMoves);
+ currentData.child("updatedAt").setValue(ServerValue.TIMESTAMP);
+ rejection.set(null);
+ return Transaction.success(currentData);
+ }
+
+ @Override
+ public void onComplete(DatabaseError databaseError, boolean committed,
+ DataSnapshot snapshot) {
+ clearPendingMove(operation);
+ if (databaseError != null) {
+ callback.onError(databaseError(databaseError, operation.roomId));
+ } else if (!committed) {
+ callback.onError(error(RepositoryError.Code.CONFLICT,
+ rejection.get(), operation.roomId));
+ } else {
+ callback.onSuccess(operation.expectedMoves + operation.column);
+ }
+ }
+ }, false);
+ }
public synchronized String pendingCreateRoomId() {
return pendingCreateRoomId;
}
public synchronized String pendingJoinRoomId() {
return pendingJoinRoomId;
}
+ public synchronized String pendingMoveRoomId() {
+ return pendingMove == null ? null : pendingMove.roomId;
+ }
private synchronized void clearPendingCreate(String roomId) {
if (roomId != null && roomId.equals(pendingCreateRoomId)) {
pendingCreateRoomId = null;
pendingJoinRoomId = null;
}
}
+ private synchronized void clearPendingMove(PendingMove operation) {
+ if (pendingMove == operation) {
+ pendingMove = null;
+ }
+ }
private static ParsedRoom parseRoom(DataSnapshot snapshot) {
RoomData room;
try {
this.error = error;
}
}
+ private static final class PendingMove {
+ private final String roomId;
+ private final String expectedMoves;
+ private final int column;
+
+ private PendingMove(String roomId, String expectedMoves, int column) {
+ this.roomId = roomId;
+ this.expectedMoves = expectedMoves;
+ this.column = column;
+ }
+ }
+
public interface Subscription extends AutoCloseable {
Subscription EMPTY = () -> { };
@Override
OnlineController.java
app > kotlin+java > com.example.connect4 > OnlineController.java
בלחיצה לוכדים את ההיסטוריה מהצילום המוצג ומעבירים אותה ל־submitMove. pending() מונעת לחיצה חוזרת לפני סיום הבקשה. ההאזנה לחדר מביאה את ההיסטוריה שהתקבלה בפועל.
אותו UID בשני מכשירים אינו מקבל שני תורות: שתי בקשות עם אותה קידומת מתחרות, ולכל היותר אחת מתחייבת. ה־callback הישן אינו יכול להחליף מסך אחרי חזרה ללובי.
session.onlineMessage = "Choose a room or create a new one. Previous rooms are preserved.";
refreshScreen();
}
-
+ void move(int column) {
+ if (!canMove()) return;
+ long token = session.onlineGeneration;
+ String prefix = session.room.getRoom().getMoves();
+ session.onlineMessage = "Sending move…";
+ session.repository.submitMove(session.roomId, column, prefix, new MultiplayerRepository.ResultCallback<String>() {
+ @Override public void onSuccess(String moves) {
+ if (token != session.onlineGeneration) return;
+ session.onlineMessage = "Move accepted";
+ refreshLatestScreen();
+ }
+ @Override public void onError(RepositoryError error) {
+ if (token != session.onlineGeneration) return;
+ showError(error);
+ refreshLatestScreen();
+ }
+ });
+ host.changed();
+ }
+
+ boolean canMove() {
+ return active && !pending()
+ && session.room != null && session.room.canMove();
+ }
private boolean pending() {
MultiplayerRepository repo = session.repository;
- return repo != null && (repo.pendingCreateRoomId() != null || repo.pendingJoinRoomId() != null);
+ return repo != null && (repo.pendingCreateRoomId() != null || repo.pendingJoinRoomId() != null || repo.pendingMoveRoomId() != null);
}
private void showError(RepositoryError error) {
session.onlineMessage = error.getMessage();
MainActivity.java
app > kotlin+java > com.example.connect4 > MainActivity.java
במצב online מעבירים את העמודה ל־OnlineController וחוזרים מיד. אין session.engine.apply מקומי אחרי השליחה. רק צילום החדר המאומת שהגיע בחזרה קובע מה יוצג. משחק מקומי ומחשב ממשיכים להשתמש בנתיב הקודם.
private boolean humanTurn() {
GameState state = session.engine.snapshot();
- if (session.mode == GameSession.MODE_ONLINE) return false;
+ if (session.mode == GameSession.MODE_ONLINE) return online != null && online.canMove();
return started && !state.isTerminal() && !session.isComputerThinking()
&& (session.mode == GameSession.MODE_LOCAL
|| state.currentPlayer() == session.humanSide);
}
private void playColumn(int column) {
if (!humanTurn()) return;
+ if (session.mode == GameSession.MODE_ONLINE) { online.move(column); return; }
if (session.engine.apply(new GameAction(column))) { persist(); render(); }
}
private void render() {
מריצים ובודקים
- חברו שני שחקנים וצופה. שחקו
0011223: בכל המסכים אדום מנצח אופקית. - נסו ללחוץ בצד שאינו בתורו או כצופה: ההיסטוריה אינה משתנה.
- לחצו במהירות פעמיים: אין תור כפול.
- פתחו שני לקוחות של אותו משתמש ושלחו שתי פעולות עם אותה קידומת: רק אחת מתקבלת.
- חזרו ללובי ופתחו חדר גמור: התוצאה והרביעייה נשארות.
בדיקות המורה כוללות שלושה לקוחות עם listeners, סיום משחק זהה אצל כולם, דחיית צופה ומרוץ שתי פעולות מאותו UID. כל שמונה בדיקות הכללים רצות ב־emulator; אין צורך לשנות את מסד הנתונים החי כדי להריץ אותן.
בדיקת הבנה
למה תור חוקי ועמודה פנויה אינם מספיקים בלי בדיקת הקידומת? תארו מקרה שבו נגיעה ישנה הייתה יכולה להפוך בטעות למהלך בתור הבא של אותו שחקן.