kimi gone wild

This commit is contained in:
ra
2026-02-14 23:12:33 -07:00
parent bbd205ecbe
commit 0222be36c5
98 changed files with 39726 additions and 309 deletions

View File

@@ -0,0 +1,645 @@
defmodule Odinsea.Game.MiniGame do
@moduledoc """
Mini Game system for Omok and Match Card games in player shops.
Ported from src/server/shops/MapleMiniGame.java
Mini games allow players to:
- Play Omok (5-in-a-row)
- Play Match Card (memory game)
- Track wins/losses/ties
- Earn game points
Game Types:
- 1 = Omok (5-in-a-row)
- 2 = Match Card (memory matching)
Game lifecycle:
1. Owner creates game with type and description
2. Visitor joins and both mark ready
3. Game starts and players take turns
4. Game ends with win/loss/tie
"""
use GenServer
require Logger
# Game type constants
@game_type_omok 1
@game_type_match_card 2
# Shop type constants (from IMaplePlayerShop)
@shop_type_omok 3
@shop_type_match_card 4
# Board size for Omok
@omok_board_size 15
# Default slots for mini games
@max_slots 2
# Struct for the mini game state
defstruct [
:id,
:owner_id,
:owner_name,
:item_id,
:description,
:password,
:game_type,
:piece_type,
:map_id,
:channel,
:visitors,
:ready,
:points,
:exit_after,
:open,
:available,
# Omok specific
:board,
:loser,
:turn,
# Match card specific
:match_cards,
:first_slot,
:tie_requested
]
@doc """
Starts a new mini game GenServer.
"""
def start_link(opts) do
game_id = Keyword.fetch!(opts, :id)
GenServer.start_link(__MODULE__, opts, name: via_tuple(game_id))
end
@doc """
Creates a new mini game.
"""
def create(opts) do
game_type = opts[:game_type] || @game_type_omok
%__MODULE__{
id: opts[:id] || generate_id(),
owner_id: opts[:owner_id],
owner_name: opts[:owner_name],
item_id: opts[:item_id],
description: opts[:description] || "",
password: opts[:password] || "",
game_type: game_type,
piece_type: opts[:piece_type] || 0,
map_id: opts[:map_id],
channel: opts[:channel],
visitors: %{},
ready: {false, false},
points: {0, 0},
exit_after: {false, false},
open: true,
available: true,
# Omok board (15x15 grid)
board: create_empty_board(),
loser: 0,
turn: 1,
# Match card
match_cards: [],
first_slot: 0,
tie_requested: -1
}
end
@doc """
Returns the shop type for this game.
"""
def shop_type(%__MODULE__{game_type: type}) do
case type do
@game_type_omok -> @shop_type_omok
@game_type_match_card -> @shop_type_match_card
_ -> @shop_type_omok
end
end
@doc """
Returns the game type constant.
"""
def game_type_omok, do: @game_type_omok
def game_type_match_card, do: @game_type_match_card
@doc """
Gets the current game state.
"""
def get_state(game_pid) when is_pid(game_pid) do
GenServer.call(game_pid, :get_state)
end
def get_state(game_id) do
case lookup(game_id) do
{:ok, pid} -> get_state(pid)
error -> error
end
end
@doc """
Looks up a game by ID.
"""
def lookup(game_id) do
case Registry.lookup(Odinsea.MiniGameRegistry, game_id) do
[{pid, _}] -> {:ok, pid}
[] -> {:error, :not_found}
end
end
@doc """
Adds a visitor to the game.
Returns the visitor slot or {:error, reason}.
"""
def add_visitor(game_id, character_id, character_pid) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:add_visitor, character_id, character_pid})
end
end
@doc """
Removes a visitor from the game.
"""
def remove_visitor(game_id, character_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:remove_visitor, character_id})
end
end
@doc """
Sets a player as ready/not ready.
"""
def set_ready(game_id, character_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:set_ready, character_id})
end
end
@doc """
Checks if a player is ready.
"""
def is_ready?(game_id, character_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:is_ready, character_id})
end
end
@doc """
Starts the game (if all players ready).
"""
def start_game(game_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, :start_game)
end
end
@doc """
Makes an Omok move.
"""
def make_omok_move(game_id, character_id, x, y, piece_type) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:omok_move, character_id, x, y, piece_type})
end
end
@doc """
Selects a card in Match Card game.
"""
def select_card(game_id, character_id, slot) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:select_card, character_id, slot})
end
end
@doc """
Requests a tie.
"""
def request_tie(game_id, character_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:request_tie, character_id})
end
end
@doc """
Answers a tie request.
"""
def answer_tie(game_id, character_id, accept) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:answer_tie, character_id, accept})
end
end
@doc """
Skips turn (forfeits move).
"""
def skip_turn(game_id, character_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:skip_turn, character_id})
end
end
@doc """
Gives up (forfeits game).
"""
def give_up(game_id, character_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:give_up, character_id})
end
end
@doc """
Sets exit after game flag.
"""
def set_exit_after(game_id, character_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:set_exit_after, character_id})
end
end
@doc """
Checks if player wants to exit after game.
"""
def is_exit_after?(game_id, character_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:is_exit_after, character_id})
end
end
@doc """
Gets the visitor slot for a character.
"""
def get_visitor_slot(game_id, character_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:get_visitor_slot, character_id})
end
end
@doc """
Checks if character is the owner.
"""
def is_owner?(game_id, character_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, {:is_owner, character_id})
end
end
@doc """
Closes the game.
"""
def close_game(game_id) do
with {:ok, pid} <- lookup(game_id) do
GenServer.call(pid, :close_game)
end
end
@doc """
Gets the number of matches needed to win.
"""
def get_matches_to_win(piece_type) do
case piece_type do
0 -> 6
1 -> 10
2 -> 15
_ -> 6
end
end
# ============================================================================
# GenServer Callbacks
# ============================================================================
@impl true
def init(opts) do
state = create(opts)
{:ok, state}
end
@impl true
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
@impl true
def handle_call({:add_visitor, character_id, character_pid}, _from, state) do
visitor_count = map_size(state.visitors)
if visitor_count >= @max_slots - 1 do
{:reply, {:error, :full}, state}
else
slot = visitor_count + 1
new_visitors = Map.put(state.visitors, character_id, %{pid: character_pid, slot: slot})
{:reply, {:ok, slot}, %{state | visitors: new_visitors}}
end
end
@impl true
def handle_call({:remove_visitor, character_id}, _from, state) do
new_visitors = Map.delete(state.visitors, character_id)
{:reply, :ok, %{state | visitors: new_visitors}}
end
@impl true
def handle_call({:set_ready, character_id}, _from, state) do
slot = get_slot_for_character_internal(state, character_id)
if slot > 0 do
{r0, r1} = state.ready
new_ready = if slot == 1, do: {not r0, r1}, else: {r0, not r1}
{:reply, :ok, %{state | ready: new_ready}}
else
{:reply, {:error, :not_visitor}, state}
end
end
@impl true
def handle_call({:is_ready, character_id}, _from, state) do
slot = get_slot_for_character_internal(state, character_id)
{r0, r1} = state.ready
ready = if slot == 1, do: r0, else: r1
{:reply, ready, state}
end
@impl true
def handle_call(:start_game, _from, state) do
{r0, r1} = state.ready
if r0 and r1 do
# Initialize game based on type
new_state =
case state.game_type do
@game_type_omok ->
%{state | board: create_empty_board(), open: false}
@game_type_match_card ->
cards = generate_match_cards(state.piece_type)
%{state | match_cards: cards, open: false}
_ ->
%{state | open: false}
end
{:reply, {:ok, new_state.loser}, new_state}
else
{:reply, {:error, :not_ready}, state}
end
end
@impl true
def handle_call({:omok_move, character_id, x, y, piece_type}, _from, state) do
# Check if it's this player's turn (loser goes first)
slot = get_slot_for_character_internal(state, character_id)
if slot != state.loser + 1 do
{:reply, {:error, :not_your_turn}, state}
else
# Check if position is valid and empty
if x < 0 or x >= @omok_board_size or y < 0 or y >= @omok_board_size do
{:reply, {:error, :invalid_position}, state}
else
current_piece = get_board_piece(state.board, x, y)
if current_piece != 0 do
{:reply, {:error, :position_occupied}, state}
else
# Place piece
new_board = set_board_piece(state.board, x, y, piece_type)
# Check for win
won = check_omok_win(new_board, x, y, piece_type)
# Next turn
next_loser = rem(state.loser + 1, @max_slots)
new_state = %{
state
| board: new_board,
loser: next_loser
}
if won do
# Award point
{p0, p1} = state.points
new_points = if slot == 1, do: {p0 + 1, p1}, else: {p0, p1 + 1}
{:reply, {:win, slot}, %{new_state | points: new_points, open: true}}
else
{:reply, {:ok, won}, new_state}
end
end
end
end
end
@impl true
def handle_call({:select_card, character_id, slot}, _from, state) do
# Match card logic
slot = get_slot_for_character_internal(state, character_id)
if slot != state.loser + 1 do
{:reply, {:error, :not_your_turn}, state}
else
# Simplified match card logic
# In full implementation, track first/second card selection and matching
turn = state.turn
if turn == 1 do
# First card
{:reply, {:first_card, slot}, %{state | first_slot: slot, turn: 0}}
else
# Second card - check match
first_card = Enum.at(state.match_cards, state.first_slot - 1)
second_card = Enum.at(state.match_cards, slot - 1)
if first_card == second_card do
# Match! Award point
{p0, p1} = state.points
new_points = if slot == 1, do: {p0 + 1, p1}, else: {p0, p1 + 1}
# Check for game win
{p0_new, p1_new} = new_points
matches_needed = get_matches_to_win(state.piece_type)
if p0_new >= matches_needed or p1_new >= matches_needed do
{:reply, {:game_win, slot}, %{state | points: new_points, turn: 1, open: true}}
else
{:reply, {:match, slot}, %{state | points: new_points, turn: 1}}
end
else
# No match, switch turns
next_loser = rem(state.loser + 1, @max_slots)
{:reply, {:no_match, slot}, %{state | turn: 1, loser: next_loser}}
end
end
end
end
@impl true
def handle_call({:request_tie, character_id}, _from, state) do
slot = get_slot_for_character_internal(state, character_id)
if state.tie_requested == -1 do
{:reply, :ok, %{state | tie_requested: slot}}
else
{:reply, {:error, :already_requested}, state}
end
end
@impl true
def handle_call({:answer_tie, character_id, accept}, _from, state) do
slot = get_slot_for_character_internal(state, character_id)
if state.tie_requested != -1 and state.tie_requested != slot do
if accept do
# Tie accepted
{p0, p1} = state.points
{:reply, {:tie, slot}, %{state | tie_requested: -1, points: {p0, p1}, open: true}}
else
{:reply, {:deny, slot}, %{state | tie_requested: -1}}
end
else
{:reply, {:error, :invalid_request}, state}
end
end
@impl true
def handle_call({:skip_turn, character_id}, _from, state) do
slot = get_slot_for_character_internal(state, character_id)
if slot == state.loser + 1 do
next_loser = rem(state.loser + 1, @max_slots)
{:reply, :ok, %{state | loser: next_loser}}
else
{:reply, {:error, :not_your_turn}, state}
end
end
@impl true
def handle_call({:give_up, character_id}, _from, state) do
slot = get_slot_for_character_internal(state, character_id)
# Other player wins
winner = if slot == 1, do: 2, else: 1
{:reply, {:give_up, winner}, %{state | open: true}}
end
@impl true
def handle_call({:set_exit_after, character_id}, _from, state) do
slot = get_slot_for_character_internal(state, character_id)
if slot > 0 do
{e0, e1} = state.exit_after
new_exit = if slot == 1, do: {not e0, e1}, else: {e0, not e1}
{:reply, :ok, %{state | exit_after: new_exit}}
else
{:reply, {:error, :not_visitor}, state}
end
end
@impl true
def handle_call({:is_exit_after, character_id}, _from, state) do
slot = get_slot_for_character_internal(state, character_id)
{e0, e1} = state.exit_after
exit = if slot == 1, do: e0, else: e1
{:reply, exit, state}
end
@impl true
def handle_call({:get_visitor_slot, character_id}, _from, state) do
slot = get_slot_for_character_internal(state, character_id)
{:reply, slot, state}
end
@impl true
def handle_call({:is_owner, character_id}, _from, state) do
{:reply, character_id == state.owner_id, state}
end
@impl true
def handle_call(:close_game, _from, state) do
# Remove all visitors
Enum.each(state.visitors, fn {_id, data} ->
send(data.pid, {:game_closed, state.id})
end)
{:stop, :normal, :ok, state}
end
# ============================================================================
# Private Helper Functions
# ============================================================================
defp via_tuple(game_id) do
{:via, Registry, {Odinsea.MiniGameRegistry, game_id}}
end
defp generate_id do
:erlang.unique_integer([:positive])
end
defp get_slot_for_character_internal(state, character_id) do
cond do
character_id == state.owner_id -> 1
true -> Map.get(state.visitors, character_id, %{}) |> Map.get(:slot, -1)
end
end
defp create_empty_board do
for _ <- 1..@omok_board_size do
for _ <- 1..@omok_board_size, do: 0
end
end
defp get_board_piece(board, x, y) do
row = Enum.at(board, y)
Enum.at(row, x)
end
defp set_board_piece(board, x, y, piece) do
row = Enum.at(board, y)
new_row = List.replace_at(row, x, piece)
List.replace_at(board, y, new_row)
end
defp generate_match_cards(piece_type) do
matches_needed = get_matches_to_win(piece_type)
cards =
for i <- 0..(matches_needed - 1) do
[i, i]
end
|> List.flatten()
# Shuffle cards
Enum.shuffle(cards)
end
# Omok win checking - check all directions from the last move
defp check_omok_win(board, x, y, piece_type) do
directions = [
{1, 0}, # Horizontal
{0, 1}, # Vertical
{1, 1}, # Diagonal \
{1, -1} # Diagonal /
]
Enum.any?(directions, fn {dx, dy} ->
count = count_in_direction(board, x, y, dx, dy, piece_type) +
count_in_direction(board, x, y, -dx, -dy, piece_type) - 1
count >= 5
end)
end
defp count_in_direction(board, x, y, dx, dy, piece_type, count \\ 0) do
if x < 0 or x >= @omok_board_size or y < 0 or y >= @omok_board_size do
count
else
piece = get_board_piece(board, x, y)
if piece == piece_type do
count_in_direction(board, x + dx, y + dy, dx, dy, piece_type, count + 1)
else
count
end
end
end
end