Current Sprint: 3. 遊戲基本流程完成
repo: https://github.com/side-project-at-SPT/ithome-ironman-2024-san-juan
swagger docs: https://side-project-at-spt.github.io/ithome-ironman-2024-san-juan/
rounds,說明目前是第幾回合steps,說明目前是第幾步預估還有一些時間(?,嘗試把前端的畫面完成一下,大家應該比較容易看出遊戲有什麼功能 XD
phase,用來描述目前是哪個職業階段礦工 階段行動step model 用來儲存遊戲(每一步)紀錄議員 階段行動建築 階段行動生產 階段行動交易 階段行動我們採用 ActionCable 來實現 WebSocket 的功能
config/cable.ymldevelopment:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: ithome_ironman_2024_san_juan_development
test:
  adapter: test
production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: ithome_ironman_2024_san_juan_production
之前的做法是寫死 1 human + 3 bots
我們讓 Game.start_new_game 及其中的 generate_players 增加 players 參數,讓我們可以把 玩家 ID 放進去產生遊戲
# app/models/game.rb
class Game < ApplicationRecord
# ...
  class << self
  # ...
    def start_new_game(seed: nil, game: nil, players: nil)
    # ...
    end
    
    def generate_players(seed: nil, players: nil)
      srand(seed.to_i(16)) if seed
      if players
        human_players = players.map { |player| Player.new(player, [], [], nil, false) }
        bot_players = (4 - players.size).times.map { |i| Player.new("bot_#{i + 1}", [], [], nil, true) }
      else
        human_players = [ Player.new(1, [], [], nil, false) ]
        bot_players = 3.times.map { |i| Player.new(i + 2, [], [], nil, true) }
      end
      (human_players + bot_players).shuffle
    end
    # ...
  end
  # ...
end
# ...
當前端請求建立 WebSocket 連線時,會先由 connection.rb 辨認並註記 identified_by :current_user
這個 current_user 可以在底下所有的 channel 中取用,如此達到識別身份的目的
# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user
    def connect
      self.current_user = find_verified_user
    end
    private
    def find_verified_user
      if verified_user = MockUser.find_by(id: JWT.decode(request.params[:token], Rails.application.secret_key_base).first["sub"])
        verified_user
      else
        reject_unauthorized_connection
      end
    end
  end
end
目前還沒有建立使用者 model,先定義 MockUser 展示用
# app/models/mock_user.rb
class MockUser
  class << self
    def find_by(id:)
      return new("visitor") unless id
      new(id)
    end
  end
  def initialize(id)
    @id = id
  end
  def id
    @id
  end
  def email
    "mock_user_#{@id}@localhost"
  end
end
新增登入的 api
# app/controllers/api/v1/sessions_controller.rb
class Api::V1::SessionsController < ApplicationController
  def create
    id = Time.now.to_i
    token = JWT.encode({ sub: id }, Rails.application.secret_key_base)
    render json: { status: :created, token: token }
  end
end
# config/routes.rb
Rails.application.routes.draw do
# ...
  
  namespace :api do
    namespace :v1 do
      post "login" => "sessions#create", as: :login
      
      # ...
      
      end
    end
  end
end
這裡因為採用前後端不同 host,WebSocket 沒辦法以 cookie 方式驗證,於是簽發 JWT 作為驗證身份手段
再來是 channel
當成功建立連線後,使用者可以訂閱頻道,當有訊息發到頻道時,便能收到通知
也可以呼叫頻道定義的方法,去跟後端互動
我們建立一個 Lobby 的頻道,發送供所有人觀看訊息的地方
可以作為發送訊息
或是一般性的互動
chat
room_list
room_info
rais g channel lobby
第一次寫把 房間的邏輯 也混進去了
有關房間的
從 room_channel 操作會比較適合
而遊戲的訊息推送,可以讓玩家訂閱 game_channel(id, player) 來發送手牌的個人訊息
而從 game_channel(id) 發送公開訊息 (或是旁觀者)
房間的房名、房主、成員等資訊,就紀錄在 redis
# app/channels/lobby_channel.rb
class LobbyChannel < ApplicationCable::Channel
  def subscribed
    stream_from "lobby_channel"
    data = { message: "Hello, #{current_user.email}!" }
    ActionCable.server.broadcast "lobby_channel", data
  end
  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
  def speak(data)
    ActionCable.server.broadcast "lobby_channel", message: data
  end
  def create_room
    room = Kredis.string "room:#{SecureRandom.hex(4)}"
    room.value = "Room #{room.key}"
    room_owner = Kredis.string "#{room.key}:owner"
    room_owner.value = current_user.email
    room_participants = Kredis.set "#{room.key}:participants"
    room_participants.add current_user.email
    message = { message: "#{room.value} created!" }
    ActionCable.server.broadcast "lobby_channel", message
  end
  def get_rooms
    rooms = $redis.scan_each(match: "room:*").map do |room|
      room_name = room.split(":")[1]
      room_name
    end
    message = { rooms: rooms.uniq }
    ActionCable.server.broadcast "lobby_channel", message
  end
  def get_participant_rooms
    # Get all rooms where the current user is a participant
    rooms = $redis.scan_each(match: "room:*:participants").map do |room|
      if $redis.smembers(room).include? current_user.email
        room_name = room.split(":")[1]
        room_name
      end
    end
    rooms.compact!
    message = { rooms: rooms.uniq }
    ActionCable.server.broadcast "lobby_channel", message
  end
  def leave_room(params)
    action = params["action"]
    room_key = params["room"]
    # can not leave room if you are not in the room
    room_participants = Kredis.set "room:#{room_key}:participants"
    if room_participants.include? current_user.email
      # close room if you are the owner
      room_owner = Kredis.string "room:#{room_key}:owner"
      if room_owner.value == current_user.email
        room = Kredis.string "room:#{room_key}"
        room_owner = Kredis.string "room:#{room_key}:owner"
        room_participants.clear
        room_owner.clear
        room.clear
        # Kredis.del "room:#{room_key}"
        # Kredis.del "room:#{room_key}:owner"
        # Kredis.del "room:#{room_key}:participants"
        message = { message: "Room #{room_key} closed!" }
        ActionCable.server.broadcast "lobby_channel", message
      else
        room_participants.remove current_user.email
        message = { message: "You have left room #{room_key}" }
        ActionCable.server.broadcast "lobby_channel", message
      end
    else
      message = { message: "You are not in room #{room_key}" }
      ActionCable.server.broadcast "lobby_channel", message
    end
  end
  def clear_rooms
    count = 0
    $redis.scan_each(match: "room:*").each do |room|
      $redis.del room
      count += 1
    end
    message = { message: "#{count} rooms cleared!" }
    ActionCable.server.broadcast "lobby_channel", message
  end
  def join_room(params)
    room_key = params["room"]
    room_participants = Kredis.set "room:#{room_key}:participants"
    room_participants.add current_user.email
    message = { message: "You have joined room #{room_key}" }
    ActionCable.server.broadcast "lobby_channel", message
  end
  def show_room_info(params)
    room_key = params["room"]
    room = Kredis.string "room:#{room_key}"
    room_owner = Kredis.string "room:#{room_key}:owner"
    room_participants = Kredis.set "room:#{room_key}:participants"
    message = {
      message: "Room #{room.value} info: owner - #{room_owner.value}, participants - #{room_participants.members.to_sentence}",
      owner: room_owner.value,
      participants: room_participants.members
    }
    ActionCable.server.broadcast "lobby_channel", message
  end
  def start_new_game(params)
    room_key = params["room"]
    room_owner = Kredis.string "room:#{room_key}:owner"
    case room_owner.value
    when nil
      # room does not exist
      message = { message: "Room #{room_key} does not exist" }
      ActionCable.server.broadcast "lobby_channel", message
    when current_user.email
      # room owner
      room_participants = Kredis.set "room:#{room_key}:participants"
      game = Game.start_new_game(players: room_participants.members)
      message = { message: "Game started in room #{room_key}", game_id: game.id }
      ActionCable.server.broadcast "lobby_channel", message
    else
      message = { message: "Only the room owner can start the game" }
      ActionCable.server.broadcast "lobby_channel", message
    end
  end
end
如果是透過 zeabur (https://zeabur.com/templates/KQZHXT) 部署的話,可從服務市集選取 redis 即可
並注意
config/environments/production.rb config.action_cable.allowed_request_origins
REDIS_URL => ${REDIS_URI}
請參考 https://github.com/side-project-at-SPT/ithome-ironman-2024-san-juan-frontend-example
過兩天應該會用 vue 改寫
收工.
以上不代表明天會做,如有雷同純屬巧合
SPT (Side Project Taiwan) 的宗旨是藉由Side Project開發來成就自我,透過持續學習和合作,共同推動技術和專業的發展。我們相信每一個參與者,無論是什麼專業,都能在這個社群中找到屬於自己的成長空間。
歡迎所有對Side Project開發有興趣的人加入我們,可以是有點子來找夥伴,也可以是來尋找有興趣的Side Project加入,邀請大家一同打造一個充滿活力且有意義的技術社群!
Discord頻道連結: https://sideproj.tw/dc