איקס עיגול בClojure – חלק ב׳ שרת

אחריי שבפוסט הקודם מימשתי את הלוגיקה של המשחק ולולאה קצרה שמבצעת משחק מלא, בפוסט הזה אני מסביר איך לחבר את הלוגיקה הזאת לשרת http כך שכל לקוח REST יוכל לשחק מול השרת.
הגדרת הAPI ומימוש של לקוח טיפש בNode.JS
את הAPI בחרתי לממש קודם כל בצד הלקוח מכיוון שככה יש לי דרך נוחה לבדוק את השרת מהרגע הראשון ומכיוון שככה אני חושב על הAPI מלמטה ומבין מה הצורך של הלקוח. הבחירה במימוש בjavaScript היא כהכנה לפוסט הבא בו אני מתעד לממש ממשק ווב קטן למשחק.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
async function startNewGame() { const response = await fetch(`${URL}/start_new_game`); return await response.json(); // => {"game_id": "1234567890"} } async function getGameUpdate(game_id) { const response = await fetch(`${URL}/get_game_update/${game_id}`) return await response.json(); // => { // "board": [["X",null,null],["O",null,null],["X",null,null]], // "player": "X"|"O", // "turn": "X"|"O", // "winner": "X"|"O"|"TIE", // } } async function move(game_id, x, y) { const response = await fetch(`${URL}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({x, y, game_id}) }) return await response.json() // => {"status": "OK"|"ERROR", "error": "ERROR_MSG"} } |
בחרתי לממש את הממשק בשלוש קריאות:
- התחלת משחק – יוצר אובייקט של משחק חדש בשרת ומחזיר חזרה מזהה של המשחק.
- קבלת עדכון – מחזיר חזרה את לוח המשחק, איזה שחקן משחק המשתמש, תור מי עכשיו ומי ניתח במשחק.
- ביצוע מהלך – קריאה ששולחת את המשבצת בה אנחנו רוצים לבצע מהלך ומחזירה האם המהלך התבצע או הסבר למה לא היה ניתן לבצע את המהלך (לדוגמא אם המשבצת כבר נתפסה או שזה לא התור של השחקן).
כעת על מנת לבדוק את הממשק באופן מלא, מימשתי אלגוריתם קטן שמשחק באופן הבא:
- להתחיל משחק חדש.
- לבקש עדכונים עד שמגיע התור של השחקן או שהמשחק נגמר, אם המשחק נגמר אז סיימנו. אחרת נמשיך לשלב הבא.
- לנסות לבצע את כל המהלכים האפשריים לפי הסדר של המשבצות עד שמצליחים לבצע מהלך ונחזור לשלב הקודם.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
async function sleep(seconds) { return new Promise((resolve) => setTimeout(resolve, seconds)) } async function play() { // STEP 1: start new game const {game_id} = await startNewGame() console.log(`[+] strating game_id: ${game_id}`) while (true) { // STEP 2: wait for client turn while (true) { const {board, player, turn, winner} = await getGameUpdate(game_id) console.log(`\n[+] ${player} ${turn} ${winner}`) // print board board.forEach(row => console.log(row)) if(winner) { console.log(`[+] winner is: ${winner}`) return } if(player === turn) { break } await sleep(750) } // STEP 3: try to move all option until move succeed for(let i=0;i<9;i++) { const {status, error} = await move(game_id, Math.floor(i/3), i%3) console.log(`[+] ${status} ${error}`) if (status === "OK") { break } } } } await play() |
בסיס הנתונים – שמירה על מצבי המשחק
מה זה atom?
כאשר אנחנו כותבים שרת אנחנו צריכים לממש דרך השומרת על המצב של המשחק בין הקריאות השונות (או שיש לנו משחק שמתבצע בקריאה אחת בלבד…). בשביל הפשטות, וכדי להתנסות בעדכון של משתנים בשפה, שכן בClojure יש מנגנון מעניין של שמירת משתנים (באופן גורף כל המשתנים הם immutable למעט atom) בחרתי לממש את בסיס הנתונים בזיכרון ה RAM. כמו כן כתבתי את הפונקציות באופן נפרד משאר השרת כך שיהיה ניתן בקלות לכתוב ממשק אחר שמשתמש באיזה בסיס נתונים אחר שארצה בעתיד.
בClojure יש דרך ממש מתוחכמת לעדכון של משתנים באמצעות atom אשר ניתן להדגים בקצרה באופן הבא:
1 2 3 4 |
(def x (atom 0)) ; @x => 0 (reset! x 10) ; @x => 10 (reset! x (inc @x)) ; @x => 11 (swap! x inc) ; @x is now 12 |
כאן הגדרתי atom בשם x עם ערך התחלתי 0, לאחר מכן איפסתי אותו לערך 10, בשורה הבאה איפסתי אותו לאותו ערך בתוספת של 1. בשורה האחרונה השתמשתי בswap שהוא כבר פקודה מתוחכמת יותר שבמקום לקבל ערך היא מקבלת פונקציה אותה היא מריצה על הקלט ורק ובמידה ולאחר ההרצה הקלט המקורי הוא עדיין אותו הערך שיש ב atom אזי היא מעדכנת את הערך בתוצאה של הפונקציה – אחרת היא לוקחת את הערך החדש של ה atom (ששונה ע״י thread אחר) ואז היא מנסה שוב לעדכן אותו (ע״י הרצת הפונקציה על הקלט החדש) עד שהיא מצליחה לעדכן את המשתנה. ובכך אנחנו מקבלים מנגנון שהוא thread-safe לעדכון של משתנה. ניתן לראות זאת בדוגמא הבאה:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
(def x (atom 0)) ; @x => 0 (defn use_reset [] (reset! x (inc @x))) (dotimes [i 10000] (.start (Thread. (fn [] (use_reset))))) @x ; => 9951 (def x (atom 0)) ; @x => 0 (defn use_swap [] (swap! x inc)) (dotimes [i 10000] (.start (Thread. (fn [] (use_swap))))) @x ; => 10000 |
כאן הרצתי 10,000 threads שונים פעם אחת ע״י איפוס של הatom ופעם אחת ע״י החלפה, וניתן לראות באופן ברור שבשימוש בפונקצית האיפוש יש לנו התנגשויות בין thread שונים ואנחנו מגיעים לתוצאה לא אמינה. לעומת השימוש בהחלפה ע״י פונקציה שמוביל בכל פעם לתוצאה זהה ואמינה.
מימוש מבנה הנתונים
הפונקציות הדרושות ממבנה הנתונים הינם:
- שמירה של נתוני המשחק על פי מזהה.
- החזרת המידע של המשחק.
- מחיקה של משחק.
- עדכון של שדה במידע המשחק (על פי שם שדה וערך חדש).
- הרצת פונקציה על כל משחק פעיל.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
(def games-db (atom {})) (defn create-game [game-id game-data] (swap! games-db assoc game-id game-data)) (defn get-game-data [game-id] (get @games-db game-id)) (defn delete-game [game-id] (swap! games-db dissoc game-id)) (defn update-game-data-field [game-id k v] (swap! games-db assoc game-id (update (get @games-db game-id) k (constantly v)))) (defn call-func-on-every-game [call-game-func] (doseq [[game-id game-data] @games-db] (call-game-func game-id game-data))) ; *** TEST *** (create-game "ID_1" {:data 20 :time 0}) (create-game "ID_2" {:data 10 :time 20}) @games-db ; => {"ID_1" {:data 20, :time 0}, "ID_2" {:data 10, :time 20}} (get-game-data "ID_1") ; => {:data 20, :time 0} (update-game-data-field "ID_1" :data 100) (get-game-data "ID_1") ; => {:data 100, :time 0} (defn print-game-id-and-data [game-id {:keys [data time]}] (print game-id data time ",")) (call-func-on-every-game print-game-id-and-data) ;; => ID_1 100 0 ,ID_2 10 20 , (delete-game "ID_1") ; => (call-func-on-every-game print-game-id-and-data) ; => ID_2 10 20 , |
בחרתי לממש את זה ע״י מפה של מפות – כך שיש לנו מפה שמחזיקה את המידע של המשחק ואנחנו מזהים את המשחק לפי מזהה ייחודי. ובתוך המזהה אנחנו מחזיקים מפה שלבסיס הנתונים אין שום ידיעה לאיברים שיש במפה (ע״מ לשמור על הפרדה משאר חלקי השרת).
כאשר גם כאן שווה לשים לב שבכל שינוי אנחנו מחליפים את המפה הראשית במפה חדשה בא אנחנו משנים משהו אחד במפה (מוחקים/ מוסיפים/ מעדכנים ערך).
והיה כשרת המשרת – מימוש הקריאות בצד השרת
את השרת עצמו בניתי בעזרת ring תוך שימוש בjson-middleware כדאי לאפשר לי קבלה ושליחה של הודעות בjson. מוזמנים לקרוא כאן הסבר על איך ליצור שרת פשוט. וכעת נעבור לקריאות עצמם בשרת:
יצירת משחק חדש
1 2 3 4 5 6 7 8 |
(GET "/start_new_game" [] (let [game_id (.toString (java.util.UUID/randomUUID))] (db/create-game game_id {:board (gamelogic/get_initial_board) :player (gamelogic/get_player_sign) :start-time (System/currentTimeMillis)}) (response {:game_id game_id}))) |
כאן בעצם אני מכניס משחק חדש לבסיס הנתונים, כאשר בעזרת הלוגיקה של המשחק מקבלים את הלוח הראשוני והסימן של השחקן (50% איקס ו50% עיגול כאשר איקס תמיד ראשון) וכמו כן מסמנים את זמן ההתחלה לטובת מחיקה של משחקים מבסיס המידע לאחר זמן מוגדר.
שימו לב לדרך בה נוצר המזהה הרנדומלי של המשחק ע״י קריאה לפונקציית java (שכן Clojure רץ על JVM).
קבלת עדכון על מצב המשחק
1 2 3 4 5 6 7 8 |
(GET "/start_new_game" [] (let [game_id (.toString (java.util.UUID/randomUUID))] (db/create-game game_id {:board (gamelogic/get_initial_board) :player (gamelogic/get_player_sign) :start-time (System/currentTimeMillis)}) (response {:game_id game_id}))) |
כאן מקבלים את האובייקט של המשחק מבסיס הנתונים, ובעזרת הלוגיקה בודקים של מי התור והאם יש כבר מנצח (או תיקו) במשחק.
השחקן מבצע מהלך
1 2 3 4 5 6 7 8 9 10 11 12 13 |
(POST "/move" {:keys [params]} (let [{:keys [x y game_id]} params] (let [{:keys [board player]} (db/get-game-data game_id)] (prn x y) (if (not= (gamelogic/get_current_player board) player) (response {:status "ERROR" :error "NOT YOUR TURN"}) (if (gamelogic/is_game_ended board) (response {:status "ERROR" :error "GAME ENDED"}) (if (not (gamelogic/is_cord_empty board [x y])) (response {:status "ERROR" :error "SQUARE NOT EMPTY"}) (do (db/update-game-data-field game_id :board (gamelogic/move board player [x y])) (response {:status "OK"})))))))) |
כאן מימשתי קריאת POST שמקבלת את מזהה המשחק והמשבצת בא השחקן מעוניין לבצע את התור. ואז מתבצעות הבדיקות הבאות:
- האם זה לא התור של השחקן?
- האם המשחק נגמר?
- האם המשבצת הזאת תפוסה?
במידה והתשובה לכל אחת מהשאלות הקודמות היא כן אז מחזירים שגיאה, אחרת מבצעים את התור בעזרת הלוגיקה, שומרים את התוצאה ומחזירים למשתמש הודעה של הצלחה.
לולאת המשחק – Deep-Blue
כעת כל מה שנותר לממש זה את לולאת המשחק של השחקן השני (המחשב) אותה נממש באופן הבא:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
(defn play-single-game [game_id {:keys [board player start-time]}] (if (< (+ start-time TTL) (System/currentTimeMillis)) (do (prn "delete game" game_id) (db/delete-game game_id)) (if (and (not= (gamelogic/get_current_player board) player) (not (gamelogic/is_game_ended board))) (db/update-game-data-field game_id :board (gamelogic/move board (gamelogic/get_current_player board) (gamelogic/get_random_move board))) nil))) (defn cpu_play_loop [] ;; move to db and send function that get full game-state (db/call-func-on-every-game play-single-game) (Thread/sleep (+ 250 (rand-int 1500))) (future (cpu_play_loop))) (future (cpu_play_loop)) |
כאשר הפונקציה הראשונה זו הפונקציה שנקראת עבור כל משחק, אנחנו בודקים האם צריך למחוק את המשחק (כדי לפנות את הזיכרון ממשחקים שנגמרו) אחרת אנחנו בודקים האם זה לא תור השחקן והמשחק לא נגמר ואז המחשב מבצע מהלך רנדומלי על הלוח.
future – זה דרך פשוטה להריץ thread חדש ובמידת הצורך ניתן לחכות עד שמתקבלת תוצאה מהפונקציה.
סוף דבר
לאחר שסיימנו הרצה של הקליינט הטיפש שכתבנו בהתחלה תיתן לנו:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
[+] strating game_id: d0e770f9-db37-4493-a591-7334344df930 [+] O X null [ null, null, null ] [ null, null, null ] [ null, null, null ] [+] O O null [ null, null, null ] [ null, 'X', null ] [ null, null, null ] 0 0 [+] OK undefined [+] O X null [ 'O', null, null ] [ null, 'X', null ] [ null, null, null ] [+] O O null [ 'O', null, 'X' ] [ null, 'X', null ] [ null, null, null ] 0 0 [+] ERROR SQUARE NOT EMPTY 0 1 [+] OK undefined [+] O X null [ 'O', 'O', 'X' ] [ null, 'X', null ] [ null, null, null ] [+] O X null [ 'O', 'O', 'X' ] [ null, 'X', null ] [ null, null, null ] [+] O O X [ 'O', 'O', 'X' ] [ null, 'X', null ] [ 'X', null, null ] [+] winner is: X |
מה הייתי עושה שונה?
- לשם הפשטות בחרתי לשמור את לוח המשחק כלוח אך בדיעבד על פי הפילוסופיה של Clojure היה יותר הגיוני לשמור מערך המכיל את כל הצעדים שבוצעו על הלוח, כך שכל צעד לא גורם לשינוי אלא רק להוספה (כמו בסיס-נתונים של בלוקצ׳יין), זה יכול להיות נחמד לשדרג במימוש עתידי.
- להוסיף הפרדה ברורה יותר בין הריצה של המשחק לבין הקוד של השרת, שכן בסופו של דבר שרת REST זה רק דרך אחת להריץ משחק.
את הקוד המלא ניתן לראות כאן.
בפוסט הבא אני מתעד להתנסות בSvelte לממש קליינט web פשוט שמאפשר לשחק במשחק.
כתיבת תגובה