Connect4 — 07: מחברים יריב מוכן של המורה


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

מפת המסלול

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

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

תחילה הוסיפו את תלות LiteRT ובצעו Sync. לאחר מכן התקינו את חבילת המורה, ורק אז חברו את קוד Java של הפרק. החבילה כוללת מודלים וקוד עזר בלבד; אין להעתיק Activity או פרויקט שלם מהמשחק המוגמר.

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

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

build.gradle.kts

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

מוסיפים את runtime שמריץ את קובץ המודל בטלפון. Python והאימון אינם חלק מאפליקציית Android.

     }
 }
 
 dependencies {
+    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)
     implementation(libs.constraintlayout)

GameEngine.java

app > kotlin+java > com.example.connect4 > core > GameEngine.java

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

         moves = new StringBuilder();
         currentPlayer = GameState.RED;
         winner = GameState.EMPTY;
         winningCells = new ArrayList<>();
+    }
+
+    /**
+     * Copies a trusted, immutable snapshot for restoration or search.
+     * GameState is constructed inside core; untrusted saved/network move histories
+     * must instead be replayed through apply(), as RoomValidation does.
+     */
+    public GameEngine(GameState state) {
+        if (state == null) {
+            throw new NullPointerException("state");
+        }
+        board = state.boardCopy();
+        moves = new StringBuilder(state.moves());
+        currentPlayer = state.currentPlayer();
+        winner = state.winner();
+        terminal = state.isTerminal();
+        winningCells = new ArrayList<>(state.winningCells());
     }
 
     /** Returns a detached immutable snapshot suitable for rendering or AI simulation. */
     public GameState snapshot() {

ComputerMoveService.java

app > kotlin+java > com.example.connect4 > ai > ComputerMoveService.java

זהו החיבור שלנו לחבילת המורה: פותחים evaluator בתוך try-with-resources, מבקשים פעולה מהמחשב וסוגרים אותו באותו worker. Result ממשיך להחזיר פעולה והסבר למסך.

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

 package com.example.connect4.ai;
 
+import android.content.Context;
 import com.example.connect4.core.ComputerPlayer;
 import com.example.connect4.core.GameAction;
 import com.example.connect4.core.GameState;
 import com.example.connect4.core.HeuristicPlayer;
+import com.example.connect4.core.MctsPlayer;
 import java.util.concurrent.CancellationException;
 
 /**
  * Connects an opponent, asks for one action, and returns the action with its status.
 
     public static Result chooseHeuristicMove(GameState snapshot) {
         return new Result(chooseLegalAction(new HeuristicPlayer(), snapshot),
                 "Heuristic · win, block, then center");
+    }
+
+    /** Opens, validates, uses and closes one model on the same worker thread. */
+    public static Result chooseModelMove(
+            Context context, ModelCatalog.Entry model, GameState snapshot, int simulations) {
+        if (simulations < 1) throw new IllegalArgumentException("simulations must be positive");
+        if (snapshot.legalActions().isEmpty()) {
+            throw new IllegalArgumentException("Cannot request a move without a legal action");
+        }
+        throwIfInterrupted();
+        try (TfliteEvaluator evaluator = new TfliteEvaluator(context, model.assets())) {
+            ComputerPlayer player = new MctsPlayer(evaluator, simulations);
+            GameAction action = chooseLegalAction(player, snapshot);
+            return new Result(action, evaluator.modelDescription()
+                    + " · MCTS " + simulations + " simulations");
+        } catch (CancellationException cancelled) {
+            // Restart/backgrounding is not a broken model and must not produce a fallback move.
+            throw cancelled;
+        } catch (Exception | LinkageError failure) {
+            throwIfInterrupted();
+            String reason = failure.getMessage();
+            if (reason == null || reason.isEmpty()) reason = failure.getClass().getSimpleName();
+            return new Result(chooseLegalAction(new HeuristicPlayer(), snapshot),
+                    model.displayName() + " · Heuristic fallback · " + reason);
+        }
     }
 
     private static GameAction chooseLegalAction(ComputerPlayer player, GameState snapshot) {
         throwIfInterrupted();

GameSession.java

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

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

 import android.os.Looper;
 import androidx.annotation.MainThread;
 import androidx.lifecycle.ViewModel;
 import com.example.connect4.ai.ComputerMoveService;
+import com.example.connect4.ai.ModelCatalog;
 import com.example.connect4.core.GameEngine;
 import com.example.connect4.core.GameState;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 /** Survives rotation. Only MainActivity updates the live engine on the main thread. */
 public final class GameSession extends ViewModel {
     public static final int MODE_LOCAL = 0;
     public static final int MODE_HEURISTIC = 1;
+    public static final int MODE_MODEL = 2;
+    public String selectedModelId = ModelCatalog.DEFAULT_MODEL_ID;
 
     public GameEngine engine = new GameEngine();
     public int mode = MODE_LOCAL;
     public int humanSide = GameState.RED;

strings.xml

app > res > values > strings.xml

מוסיפים את אפשרות המודל ומרכזים את הודעות התור במשאבים. %1$s הוא מקום לשם השחקן שמועבר ל־getString; הוא אינו קוד שהמודל מחזיר.

 <resources>
     <string name="app_name">Connect4</string>
+    <string name="new_game">New game</string>
+    <string name="player_red">Red</string>
+    <string name="player_yellow">Yellow</string>
+    <string name="game_draw">Draw — board full</string>
+    <string name="player_wins">%1$s wins!</string>
+    <string name="player_thinking">%1$s is thinking…</string>
+    <string name="player_to_play">%1$s to play</string>
+    <string name="local_play_status">Local play · no account needed</string>
+    <string name="heuristic_status">Heuristic · win, block, then center</string>
     <string-array name="game_modes">
         <item>Two players · this device</item>
         <item>Play heuristic</item>
+        <item>Play model / MCTS</item>
     </string-array>
     <string-array name="player_sides">
         <item>You are Red · play first</item>
         <item>You are Yellow · play second</item>
     </string-array>
+
 </resources>

fragment_game_setup.xml

app > res > layout > fragment_game_setup.xml

בורר המודל מוצג רק כאשר המצב הוא Play model / MCTS. לא משנים את בחירת צבע האדם.

     <Spinner android:id="@+id/mode" android:layout_width="match_parent"
         android:layout_height="48dp" android:contentDescription="Game mode" />
     <Spinner android:id="@+id/side" android:layout_width="match_parent"
         android:layout_height="48dp" android:contentDescription="Your color" />
+    <Spinner android:id="@+id/computerPlayer" android:layout_width="match_parent"
+        android:layout_height="48dp" android:contentDescription="Computer player"
+        android:visibility="gone" />
 </LinearLayout>

GameSetupFragment.java

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

ה־Activity מעביר שמות מהקטלוג ומקבל אינדקס בחירה. ה־Fragment מציג שמות בלבד; הוא אינו טוען מודלים או מחזיק מנוע משחק.

 public class GameSetupFragment extends Fragment {
     interface Listener {
         void onModeSelected(int position);
         void onSideSelected(int position);
+        void onComputerSelected(int position);
     }
 
     private FragmentGameSetupBinding binding;
     private Listener listener;
-    private boolean configured;
+    private String[] computers;
     private int mode;
     private int side;
+    private int computer;
 
     @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle state) {
         binding = FragmentGameSetupBinding.inflate(inflater, container, false);
-        if (configured) bindControls();
+        if (computers != null) bindControls();
         return binding.getRoot();
     }
 
-    void configure(int mode, int side, Listener listener) {
+    void configure(String[] computers, int mode, int side, int computer, Listener listener) {
         this.listener = listener;
-        configured = true;
+        this.computers = computers;
         this.mode = mode;
         this.side = side;
+        this.computer = computer;
         // Restored fragments can receive configuration before their view is recreated.
         if (binding != null) bindControls();
     }
 
     private void bindControls() {
         binding.mode.setAdapter(adapter(getResources().getStringArray(R.array.game_modes)));
         binding.side.setAdapter(adapter(getResources().getStringArray(R.array.player_sides)));
+        binding.computerPlayer.setAdapter(adapter(computers));
         binding.mode.setSelection(mode);
         binding.side.setSelection(side);
+        binding.computerPlayer.setSelection(computer);
         binding.mode.setOnItemSelectedListener(selection(position -> this.listener.onModeSelected(position)));
         binding.side.setOnItemSelectedListener(selection(position -> this.listener.onSideSelected(position)));
+        binding.computerPlayer.setOnItemSelectedListener(selection(position -> this.listener.onComputerSelected(position)));
         showMode(mode);
     }
 
     void showMode(int mode) {
         this.mode = mode;
         if (binding == null) return;
-        binding.side.setVisibility(mode == GameSession.MODE_HEURISTIC ? View.VISIBLE : View.GONE);
+        binding.side.setVisibility(mode == GameSession.MODE_HEURISTIC
+                || mode == GameSession.MODE_MODEL ? View.VISIBLE : View.GONE);
+        binding.computerPlayer.setVisibility(mode == GameSession.MODE_MODEL ? View.VISIBLE : View.GONE);
     }
 
     private ArrayAdapter<String> adapter(String[] items) {
         ArrayAdapter<String> adapter = new ArrayAdapter<>(requireContext(), R.layout.spinner_item, items);

BoardView.java

app > kotlin+java > com.example.connect4 > ui > BoardView.java

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

 public final class BoardView extends View {
     private static final int ROWS = GameState.ROWS;
     private static final int COLUMNS = GameState.COLUMNS;
     private GameState state;
+    private boolean inputEnabled;
     private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
     private ColumnListener listener;
 
     /** Receives a zero-based column; the View does not apply game rules. */
     /** Connects the Activity's response to a board tap. */
     public void setColumnListener(ColumnListener listener) { this.listener = listener; }
 
     /** Replaces the snapshot and requests a redraw. */
-    public void show(GameState state) {
+    public void show(GameState state, boolean enabled) {
+        inputEnabled = enabled;
         this.state = state;
         invalidate();
     }
         return Math.min(getWidth() / (float) COLUMNS, getHeight() / (float) ROWS);
     }
 
     @Override public boolean onTouchEvent(MotionEvent event) {
+        if (!inputEnabled) return false;
         if (event.getAction() == MotionEvent.ACTION_UP) {
             float size = cellSize();
             float x = event.getX(), y = event.getY();
             if (size > 0 && x >= 0 && x < size * COLUMNS && y >= 0 && y < size * ROWS) {

MainActivity.java

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

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

בבקשה לוכדים גם את המצב וגם את המודל שנבחר. שינוי מודל מבטל בקשה קודמת. מצב, צבע ובחירת מודל נשמרים יחד עם ההיסטוריה. רק ה־Activity מחיל את הפעולה החוזרת על המנוע החי. המתודות selectedModel ו־selectedModelPosition ממירות בין ID יציב לבין מיקום בתצוגה.

 package com.example.connect4;
 
 import android.os.Bundle;
-import androidx.lifecycle.ViewModelProvider;
-import com.example.connect4.core.GameAction;
-import com.example.connect4.core.GameEngine;
-import com.example.connect4.core.GameState;
-import com.example.connect4.ai.ComputerMoveService;
-
-import com.example.connect4.databinding.ActivityMainBinding;
-
+import android.content.Context;
+import android.view.View;
 import androidx.activity.EdgeToEdge;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.core.graphics.Insets;
 import androidx.core.view.ViewCompat;
 import androidx.core.view.WindowInsetsCompat;
-
+import androidx.lifecycle.ViewModelProvider;
+import com.example.connect4.core.GameAction;
+import com.example.connect4.core.GameEngine;
+import com.example.connect4.core.GameState;
+import com.example.connect4.core.MctsPlayer;
+import com.example.connect4.ai.ModelCatalog;
+import com.example.connect4.ai.ComputerMoveService;
+import com.example.connect4.databinding.ActivityMainBinding;
+
+/** Coordinates the local game UI, retained session state, AI work, and online screen. */
 public class MainActivity extends AppCompatActivity {
     private ActivityMainBinding binding;
+    private GameSetupFragment setup;
     private GameSession session;
-    private GameSetupFragment setup;
     private boolean started;
-
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
+    private ModelCatalog modelCatalog;
+
+    /** Exposes the activity binding only to the package-local online controller. */
+    ActivityMainBinding viewBinding() {
+        return binding;
+    }
+
+    @Override protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         EdgeToEdge.enable(this);
         binding = ActivityMainBinding.inflate(getLayoutInflater());
         setContentView(binding.getRoot());
+        setup = (GameSetupFragment) getSupportFragmentManager().findFragmentById(R.id.gameSetup);
         ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
-            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
-            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
+            Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
+            v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
             return insets;
         });
         session = new ViewModelProvider(this).get(GameSession.class);
-        setup = (GameSetupFragment) getSupportFragmentManager().findFragmentById(R.id.gameSetup);
+        String catalogFailure = null;
+        try {
+            modelCatalog = ModelCatalog.load(getAssets());
+        } catch (Exception failure) {
+            modelCatalog = ModelCatalog.fallback();
+            catalogFailure = "Model catalog unavailable · " + failure.getMessage();
+        }
+        boolean initializeModelStatus = false;
         if (!session.restored) {
-            session.mode = getPreferences(MODE_PRIVATE).getInt("mode", GameSession.MODE_LOCAL);
-            if (session.mode < 0 || session.mode > GameSession.MODE_HEURISTIC) session.mode = GameSession.MODE_LOCAL;
-            session.humanSide = getPreferences(MODE_PRIVATE).getInt("side", GameState.RED);
-            if (session.humanSide != GameState.RED && session.humanSide != GameState.YELLOW) session.humanSide = GameState.RED;
-            String moves = getPreferences(MODE_PRIVATE).getString("moves", "");
+            android.content.SharedPreferences saved = getPreferences(MODE_PRIVATE);
+            session.mode = saved.getInt("mode", GameSession.MODE_LOCAL);
+            if (session.mode < GameSession.MODE_LOCAL || session.mode > GameSession.MODE_MODEL) {
+                session.mode = GameSession.MODE_LOCAL;
+            }
+            session.humanSide = saved.getInt("side", GameState.RED);
+            if (session.humanSide != GameState.RED && session.humanSide != GameState.YELLOW) {
+                session.humanSide = GameState.RED;
+            }
+            session.selectedModelId = saved.getString(
+                    "computer_player", modelCatalog.defaultModelId());
+            String moves = saved.getString("moves", "");
             GameEngine restored = new GameEngine();
             for (int i = 0; i < moves.length(); i++) {
                 if (!restored.apply(new GameAction(moves.charAt(i) - '0'))) break;
             }
             session.engine = restored;
             session.restored = true;
-        }
-        setup.configure(session.mode, session.humanSide - 1, new GameSetupFragment.Listener() {
+            initializeModelStatus = true;
+        }
+        if (modelCatalog.find(session.selectedModelId) == null) {
+            session.selectedModelId = modelCatalog.defaultModelId();
+            initializeModelStatus = true;
+        }
+        if (catalogFailure != null) {
+            session.modelStatus = catalogFailure;
+        } else if (initializeModelStatus) {
+            session.modelStatus = selectedModel().displayName()
+                    + " loads on the computer’s turn";
+        }
+        setup.configure(modelCatalog.entries().stream()
+                .map(ModelCatalog.Entry::displayName).toArray(String[]::new),
+                session.mode, session.humanSide - 1, selectedModelPosition(),
+                new GameSetupFragment.Listener() {
             @Override public void onModeSelected(int position) {
-                if (session.mode != position) { session.mode = position; newGame(); }
-                setup.showMode(session.mode);
+                if (session.mode != position) {
+                    session.mode = position;
+                    session.modelStatus = selectedModel().displayName()
+                            + " loads on the computer’s turn";
+                    session.cancelComputerMove();
+                    newGame();
+                }
+                render();
             }
             @Override public void onSideSelected(int position) {
-                if (session.humanSide != position + 1) { session.humanSide = position + 1; newGame(); }
+                if (session.humanSide != position + 1) {
+                    session.humanSide = position + 1;
+                    newGame();
+                }
+                render();
+            }
+            @Override public void onComputerSelected(int position) {
+                ModelCatalog.Entry selected = modelCatalog.entries().get(position);
+                if (!selected.id().equals(session.selectedModelId)) {
+                    session.selectedModelId = selected.id();
+                    session.modelStatus = selected.displayName() + " loads on the computer’s turn";
+                    session.cancelComputerMove();
+                    if (session.mode == GameSession.MODE_MODEL) newGame();
+                    else persist();
+                }
+                render();
             }
         });
-        binding.board.setColumnListener(column -> {
-            if (!humanTurn()) return;
-            if (session.engine.apply(new GameAction(column))) { persist(); render(); }
-        });
+        binding.board.setColumnListener(this::playColumn);
         binding.restart.setOnClickListener(v -> newGame());
         render();
     }
-    /** Resets the game after changing a player or pressing New game. */
+
+    com.example.connect4.databinding.FragmentGameSetupBinding setupBinding() {
+        return setup.viewBinding();
+    }
+
     private void newGame() {
         session.cancelComputerMove();
         session.engine = new GameEngine();
         persist();
         render();
     }
 
+
     private boolean humanTurn() {
-        return started && !session.isComputerThinking() && !session.engine.snapshot().isTerminal()
+        GameState state = session.engine.snapshot();
+        return started && !state.isTerminal() && !session.isComputerThinking()
                 && (session.mode == GameSession.MODE_LOCAL
-                || session.engine.snapshot().currentPlayer() == session.humanSide);
-    }
-
-    /** Presents the model; drawing does not change the game. */
+                || state.currentPlayer() == session.humanSide);
+    }
+
+    private void playColumn(int column) {
+        if (!humanTurn()) return;
+        if (session.engine.apply(new GameAction(column))) { persist(); render(); }
+    }
+
     private void render() {
         GameState state = session.engine.snapshot();
-        binding.board.show(state);
-        if (state.isDraw()) binding.status.setText("Draw — board full");
-        else if (state.isTerminal()) binding.status.setText(state.winner() == GameState.RED ? "Red wins!" : "Yellow wins!");
-        else binding.status.setText(state.currentPlayer() == GameState.RED ? "Red to play" : "Yellow to play");
-        if (session.isComputerThinking()) binding.status.setText("Computer is thinking…");
-        binding.aiStatus.setText(session.modelStatus);
+        setup.showMode(session.mode);
+        binding.restart.setText(R.string.new_game);
+        binding.restart.setEnabled(true);
+        String player = playerName(state.currentPlayer());
+        if (state.isDraw()) {
+            binding.status.setText(R.string.game_draw);
+        } else if (state.isTerminal()) {
+            binding.status.setText(getString(R.string.player_wins, playerName(state.winner())));
+        } else {
+            int message = session.isComputerThinking() ? R.string.player_thinking : R.string.player_to_play;
+            binding.status.setText(getString(message, player));
+        }
+        if (session.mode == GameSession.MODE_LOCAL) {
+            binding.aiStatus.setText(R.string.local_play_status);
+        } else if (session.mode == GameSession.MODE_HEURISTIC && !session.hasComputerMoveFailed()) {
+            binding.aiStatus.setText(R.string.heuristic_status);
+        } else {
+            binding.aiStatus.setText(session.modelStatus);
+        }
+        binding.board.show(state, humanTurn());
         requestComputerMoveIfNeeded();
     }
 
-    /** Saves only accepted column history; the engine reconstructs the rest. */
+
+
+
+    private String playerName(int player) {
+        return getString(player == GameState.RED ? R.string.player_red : R.string.player_yellow);
+    }
+
+    /** Captures one turn before handing its immutable state to the computer worker. */
+    private void requestComputerMoveIfNeeded() {
+        GameState requestedState = session.engine.snapshot();
+        if (!started || session.mode == GameSession.MODE_LOCAL
+                || session.isComputerThinking()
+                || session.hasComputerMoveFailed() || requestedState.isTerminal()
+                || requestedState.currentPlayer() == session.humanSide) return;
+
+        int requestedMode = session.mode;
+        ModelCatalog.Entry selectedModel = selectedModel();
+        Context applicationContext = getApplicationContext();
+        binding.status.setText(getString(R.string.player_thinking, playerName(requestedState.currentPlayer())));
+        session.requestComputerMove(
+                () -> {
+                    if (requestedMode == GameSession.MODE_MODEL) {
+                        return ComputerMoveService.chooseModelMove(applicationContext, selectedModel,
+                                requestedState, MctsPlayer.DEFAULT_PHONE_SIMULATIONS);
+                    }
+                    return ComputerMoveService.chooseHeuristicMove(requestedState);
+                },
+                result -> applyComputerMove(requestedState, result),
+                failure -> {
+                    session.modelStatus = "Computer move failed · " + failure.getMessage();
+                    render();
+                });
+    }
+
+    /** Receives a proposed action on the main thread; the live engine has the final say. */
+    private void applyComputerMove(GameState requestedState, ComputerMoveService.Result result) {
+        if (!started || !requestedState.equals(session.engine.snapshot())) return;
+        session.modelStatus = result.status();
+        if (!session.engine.apply(result.action())) {
+            // The service already checked legality against this exact snapshot.
+            throw new IllegalStateException("Computer action was rejected for an unchanged turn");
+        }
+        persist();
+        render();
+    }
+
     private void persist() {
-        getPreferences(MODE_PRIVATE).edit().putString("moves", session.engine.snapshot().moves())
-                .putInt("mode", session.mode).putInt("side", session.humanSide).apply();
+        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() {
+        ModelCatalog.Entry selected = modelCatalog.find(session.selectedModelId);
+        return selected == null ? modelCatalog.require(modelCatalog.defaultModelId()) : selected;
+    }
+
+    private int selectedModelPosition() {
+        for (int index = 0; index < modelCatalog.entries().size(); index++) {
+            if (modelCatalog.entries().get(index).id().equals(session.selectedModelId)) {
+                return index;
+            }
+        }
+        return 0;
     }
 
     @Override protected void onStart() { super.onStart(); started = true; render(); }
-
     @Override protected void onStop() {
         started = false;
         session.cancelComputerMove();
         persist();
         super.onStop();
     }
 
-    ActivityMainBinding viewBinding() { return binding; }
-    com.example.connect4.databinding.FragmentGameSetupBinding setupBinding() { return setup.viewBinding(); }
-
-    /** Captures one position; the worker never receives the live engine. */
-    private void requestComputerMoveIfNeeded() {
-        GameState requestedState = session.engine.snapshot();
-        if (!started || session.mode == GameSession.MODE_LOCAL || requestedState.isTerminal()
-                || session.isComputerThinking() || session.hasComputerMoveFailed()
-                || requestedState.currentPlayer() == session.humanSide) return;
-        binding.status.setText("Computer is thinking…");
-        session.requestComputerMove(
-                () -> ComputerMoveService.chooseHeuristicMove(requestedState),
-                result -> applyComputerMove(requestedState, result),
-                failure -> { session.modelStatus = "Computer move failed · " + failure.getMessage(); render(); });
-    }
-
-    /** Accepts only a result for the still-current position, on the main thread. */
-    private void applyComputerMove(GameState requestedState, ComputerMoveService.Result result) {
-        if (!started || !requestedState.equals(session.engine.snapshot())) return;
-        if (!session.engine.apply(result.action())) throw new IllegalStateException("Illegal computer action");
-        session.modelStatus = result.status();
-        persist();
-        render();
-    }
 }

החבילה שהמורה מספק

הורדת חבילת פרק 07. פתחו את ה־ZIP בתיקייה זמנית והעבירו רק את קבצי החבילה למיקומים המתאימים בפרויקט. אין בחבילה Activity חלופי או פרויקט שלם. צרו חבילת Java דרך קליק ימני על com.example.connect4 ובחירת New > Package; צרו את תיקיית הנכסים דרך app > New > Folder > Assets Folder אם היא חסרה. את קובצי המודל וה־JSON מעתיקים ל־app > assets. קובצי משאבים נכנסים לסוג המשאב המתאים תחת app > res.

  • early_policy_value.json
  • early_policy_value.tflite
  • mock_policy_value.json
  • mock_policy_value.tflite
  • player_catalog.json
  • policy_value.json
  • policy_value.tflite
  • ModelCatalog.java
  • TfliteEvaluator.java
  • MctsPlayer.java
  • PolicyValueEvaluator.java
  • StateEncoder.java

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

  1. שחקו מול כל אחת משלוש הבחירות, כאדום וכצהוב, ללא צורך בחיבור לרשת.
  2. סובבו ופתחו מחדש: הבחירה נשמרת לפי ה־ID.
  3. בעותק בדיקה, שנו זמנית שם של קובץ מודל ובנו מחדש: המצב צריך לציין Heuristic fallback והמשחק ממשיך. החזירו את הקובץ לפני המשך המסלול.
  4. בדקו New game ושינוי מודל בזמן חישוב. תוצאה של המודל הקודם אינה נכנסת למשחק החדש.

המודל הלא מאומן בודק שהחיבור עובד; הוא אינו הוכחה לאיכות משחק. בדיקות המורה מפעילות inference אמיתי, בודקות קובץ חסר ומטא־דאטה שגויה, ובודקות שביטול אינו מפעיל fallback.

בדיקת הבנה

איפה בקוד נקבע אילו עמודות חוקיות — במודל או במנוע? כיצד נבדיל בטקסט המסך בין יריב מאומן שפעל לבין משחק שעבר להיוריסטיקה בגלל תקלה?