├── Procfile ├── lib ├── sengoku_web │ ├── templates │ │ ├── game │ │ │ ├── show.html.eex │ │ │ └── new.html.eex │ │ ├── layout │ │ │ ├── app.html.eex │ │ │ ├── live.html.leex │ │ │ ├── _user_menu.html.eex │ │ │ ├── root.html.leex │ │ │ └── game.html.leex │ │ ├── user_reset_password │ │ │ ├── new.html.eex │ │ │ └── edit.html.eex │ │ ├── user_confirmation │ │ │ └── new.html.eex │ │ ├── user_session │ │ │ └── new.html.eex │ │ ├── user_registration │ │ │ └── new.html.eex │ │ └── user_settings │ │ │ └── edit.html.eex │ ├── views │ │ ├── game_view.ex │ │ ├── layout_view.ex │ │ ├── user_session_view.ex │ │ ├── user_settings_view.ex │ │ ├── user_confirmation_view.ex │ │ ├── user_registration_view.ex │ │ ├── user_reset_password_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── controllers │ │ ├── game_controller.ex │ │ ├── user_registration_controller.ex │ │ ├── user_session_controller.ex │ │ ├── user_confirmation_controller.ex │ │ ├── user_reset_password_controller.ex │ │ ├── user_settings_controller.ex │ │ └── user_auth.ex │ ├── plugs │ │ └── put_player_id.ex │ ├── gettext.ex │ ├── channels │ │ └── user_socket.ex │ ├── endpoint.ex │ ├── telemetry.ex │ ├── live │ │ ├── move_units_form.ex │ │ └── board_builder_live.ex │ └── router.ex ├── sengoku │ ├── mailer.ex │ ├── repo.ex │ ├── ai.ex │ ├── ai │ │ ├── passive.ex │ │ ├── random.ex │ │ └── smart.ex │ ├── token.ex │ ├── region.ex │ ├── accounts │ │ ├── user_notifier.ex │ │ ├── user.ex │ │ └── user_token.ex │ ├── application.ex │ ├── authentication.ex │ ├── battle.ex │ ├── email.ex │ ├── tile.ex │ ├── player.ex │ ├── game_server.ex │ ├── board.ex │ ├── accounts.ex │ └── game.ex ├── sengoku.ex ├── mix │ └── tasks │ │ ├── regions.ex │ │ └── ai │ │ └── arena.ex └── sengoku_web.ex ├── .tool-versions ├── assets ├── .babelrc ├── .eslintrc ├── static │ ├── robots.txt │ └── images │ │ └── waves.svg ├── package.json ├── js │ └── app.js └── webpack.config.js ├── phoenix_static_buildpack.config ├── screenshot.png ├── src └── logo.sketch ├── test ├── test_helper.exs ├── sengoku_web │ ├── views │ │ ├── page_view_test.exs │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs │ ├── controllers │ │ ├── page_controller_test.exs │ │ ├── user_registration_controller_test.exs │ │ ├── user_confirmation_controller_test.exs │ │ ├── user_session_controller_test.exs │ │ ├── user_reset_password_controller_test.exs │ │ ├── user_settings_controller_test.exs │ │ └── user_auth_test.exs │ ├── plugs │ │ └── put_player_id_test.exs │ └── live │ │ └── game_live_test.exs ├── sengoku │ ├── player_test.exs │ ├── tile_test.exs │ ├── region_test.exs │ ├── board_test.exs │ ├── battle_test.exs │ ├── authentication_test.exs │ └── ai │ │ ├── random_test.exs │ │ └── smart_test.exs └── support │ ├── fixtures │ └── accounts_fixtures.ex │ ├── channel_case.ex │ ├── data_case.ex │ └── conn_case.ex ├── .iex.exs ├── elixir_buildpack.config ├── .formatter.exs ├── config ├── releases.exs ├── test.exs ├── config.exs ├── dev.exs └── prod.exs ├── priv ├── repo │ ├── migrations │ │ ├── 20200609114921_add_username_to_users.exs │ │ └── 20200607174842_create_users_auth_tables.exs │ └── seeds.exs └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ └── errors.pot ├── .gitignore ├── mix.exs ├── .github └── workflows │ └── verify.yml └── README.md /Procfile: -------------------------------------------------------------------------------- 1 | web: MIX_ENV=prod mix phx.server 2 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/game/show.html.eex: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 23.0.2 2 | elixir 1.10.3 3 | nodejs 14.0.0 4 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /phoenix_static_buildpack.config: -------------------------------------------------------------------------------- 1 | node_version=14.0.0 2 | yarn_version=1.21.1 3 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevegrossi/sengoku/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevegrossi/sengoku/HEAD/src/logo.sketch -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Sengoku.Repo, :manual) 3 | -------------------------------------------------------------------------------- /lib/sengoku/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Mailer do 2 | use Bamboo.Mailer, otp_app: :sengoku 3 | end 4 | -------------------------------------------------------------------------------- /lib/sengoku_web/views/game_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.GameView do 2 | use SengokuWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/sengoku_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.LayoutView do 2 | use SengokuWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | alias SengokuWeb.Router.Helpers, as: Routes 2 | alias Sengoku.{Game, Region, Tile, Player, GameServer, Battle} 3 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | # Erlang version 2 | erlang_version=22.3 3 | 4 | # Elixir version 5 | elixir_version=1.10.2 6 | -------------------------------------------------------------------------------- /lib/sengoku_web/views/user_session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserSessionView do 2 | use SengokuWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | inputs: ["*.{ex,exs}", "{config,lib,priv,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/sengoku_web/views/user_settings_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserSettingsView do 2 | use SengokuWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/sengoku_web/views/user_confirmation_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserConfirmationView do 2 | use SengokuWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/sengoku_web/views/user_registration_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserRegistrationView do 2 | use SengokuWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /test/sengoku_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.PageViewTest do 2 | use SengokuWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /lib/sengoku_web/views/user_reset_password_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserResetPasswordView do 2 | use SengokuWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /test/sengoku_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.LayoutViewTest do 2 | use SengokuWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /lib/sengoku/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Repo do 2 | use Ecto.Repo, 3 | otp_app: :sengoku, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /assets/.eslintrc: -------------------------------------------------------------------------------- 1 | // Use this file as a starting point for your project's .eslintrc. 2 | // Copy this file, and add rule overrides as needed. 3 | {} 4 | -------------------------------------------------------------------------------- /lib/sengoku/ai.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.AI do 2 | @moduledoc """ 3 | The common behaviour all AI modules must implement. 4 | """ 5 | @callback take_action(map) :: %{type: String.t()} 6 | end 7 | -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | User-agent: * 5 | Disallow: / 6 | -------------------------------------------------------------------------------- /test/sengoku_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.PageControllerTest do 2 | use SengokuWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "Sengoku" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /config/releases.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :sengoku, SengokuWeb.Endpoint, 4 | server: true, 5 | http: [port: {:system, "PORT"}], 6 | url: [host: "www.playsengoku.com", port: 443] 7 | 8 | config :sengoku, Sengoku.Repo, url: System.fetch_env!("DATABASE_URL") 9 | -------------------------------------------------------------------------------- /lib/sengoku.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku do 2 | @moduledoc """ 3 | Sengoku keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/sengoku/ai/passive.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.AI.Passive do 2 | @moduledoc """ 3 | An AI that immediately ends its turn as soon as it begins. 4 | For testing, since this AI can never win. 5 | """ 6 | 7 | @behaviour Sengoku.AI 8 | 9 | def take_action(_state), do: %{type: "end_turn"} 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200609114921_add_username_to_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Repo.Migrations.AddUsernameToUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table("users") do 6 | add(:username, :citext, null: false) 7 | end 8 | 9 | create(index("users", [:username], unique: true)) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Sengoku.Repo.insert!(%Sengoku.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 |
2 | <%= if get_flash(@conn, :info) do %> 3 | 4 | <% end %> 5 | <%= if get_flash(@conn, :error) do %> 6 | 7 | <% end %> 8 | 9 | <%= @inner_content %> 10 |
11 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | -------------------------------------------------------------------------------- /lib/sengoku/token.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Token do 2 | @moduledoc """ 3 | A helper module for generating random tokens for identifying protected resources. 4 | """ 5 | 6 | @doc """ 7 | Returns a random hex string of binary length n. The hex length will be double that. 8 | """ 9 | def new(n \\ 16) do 10 | n 11 | |> :crypto.strong_rand_bytes() 12 | |> Base.encode16(case: :lower) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here as no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | -------------------------------------------------------------------------------- /lib/sengoku_web/controllers/game_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.GameController do 2 | use SengokuWeb, :controller 3 | 4 | alias Sengoku.GameServer 5 | 6 | def new(conn, _params) do 7 | render(conn, "new.html") 8 | end 9 | 10 | def create(conn, %{"board" => _board} = options) do 11 | {:ok, game_id} = GameServer.new(options) 12 | redirect(conn, to: Routes.live_path(conn, SengokuWeb.GameLive, game_id)) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/sengoku/player_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.PlayerTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Sengoku.Player 5 | 6 | describe "initialize_state/2" do 7 | test "adds the specified number of players" do 8 | state = Player.initialize_state(%{}, 3) 9 | 10 | assert %{ 11 | 1 => %Player{}, 12 | 2 => %Player{}, 13 | 3 => %Player{} 14 | } = state.players 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/sengoku_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.ErrorView do 2 | use SengokuWeb, :view 3 | 4 | def render("404.html", _assigns) do 5 | "Page not found" 6 | end 7 | 8 | def render("500.html", _assigns) do 9 | "Internal server error" 10 | end 11 | 12 | # In case no render clause matches or no 13 | # template is found, let's render it as 500 14 | def template_not_found(_template, assigns) do 15 | render("500.html", assigns) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/layout/live.html.leex: -------------------------------------------------------------------------------- 1 |
2 | <%= if live_flash(@flash, :info) do %> 3 | 6 | <% end %> 7 | 8 | <%= if live_flash(@flash, :error) do %> 9 | 12 | <% end %> 13 | 14 | <%= @inner_content %> 15 |
16 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/layout/_user_menu.html.eex: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /test/sengoku_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.ErrorViewTest do 2 | use SengokuWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(SengokuWeb.ErrorView, "404.html", []) == "Page not found" 9 | end 10 | 11 | test "render 500.html" do 12 | assert render_to_string(SengokuWeb.ErrorView, "500.html", []) == "Internal server error" 13 | end 14 | 15 | test "render any other" do 16 | assert render_to_string(SengokuWeb.ErrorView, "505.html", []) == "Internal server error" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/user_reset_password/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

Forgot your password?

3 | 4 | <%= form_for :user, Routes.user_reset_password_path(@conn, :create), [class: "Form"], fn f -> %> 5 | <%= label f, :email, class: "Label" %> 6 | <%= text_input f, :email, required: true, class: "TextInput" %> 7 | 8 | <%= submit "Send instructions to reset password", class: "Button Button--primary" %> 9 | <% end %> 10 | 11 |

12 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | 13 | <%= link "Sign In", to: Routes.user_session_path(@conn, :new) %> 14 |

15 |
16 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/user_confirmation/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

Resend confirmation instructions

3 | 4 | <%= form_for :user, Routes.user_confirmation_path(@conn, :create), [class: "Form"], fn f -> %> 5 | <%= label f, :email, class: "Label" %> 6 | <%= text_input f, :email, required: true, class: "TextInput" %> 7 | 8 | <%= submit "Resend confirmation instructions", class: "Button Button--primary" %> 9 | <% end %> 10 | 11 |

12 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | 13 | <%= link "Sign In", to: Routes.user_session_path(@conn, :new) %> 14 |

15 |
16 | -------------------------------------------------------------------------------- /lib/sengoku/region.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Region do 2 | @moduledoc """ 3 | The struct and behavior of the domain-model Region, which is a grouping of 4 | Tiles on the board, controlling all of which grants a bonus. 5 | """ 6 | 7 | @enforce_keys [:value, :tile_ids] 8 | defstruct [:value, :tile_ids] 9 | 10 | def initialize_state(state, regions) do 11 | Map.put(state, :regions, regions) 12 | end 13 | 14 | def containing_tile_ids(%{regions: regions}, tile_ids) do 15 | regions 16 | |> Map.values() 17 | |> Enum.filter(fn region -> 18 | region.tile_ids -- tile_ids == [] 19 | end) 20 | end 21 | 22 | def containing_tile_ids(_, _), do: [] 23 | end 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generated on crash by the VM 8 | erl_crash.dump 9 | 10 | # Generated on crash by NPM 11 | npm-debug.log 12 | 13 | # Static artifacts 14 | /assets/node_modules 15 | 16 | # Since we are building assets from assets/, 17 | # we ignore priv/static. You may want to comment 18 | # this depending on your deployment strategy. 19 | /priv/static/ 20 | 21 | # Files matching config/*.secret.exs pattern contain sensitive 22 | # data and you should not commit them into version control. 23 | # 24 | # Alternatively, you may comment the line below and commit the 25 | # secrets files as long as you replace their contents by environment 26 | # variables. 27 | /config/*.secret.exs 28 | 29 | .elixir_ls/ 30 | 31 | /screenshots/ 32 | -------------------------------------------------------------------------------- /lib/sengoku_web/plugs/put_player_id.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.Plugs.PutPlayerID do 2 | @moduledoc """ 3 | Ensures every client has a unique player_id in the session which GameLive can 4 | use to uniquely identify players. 5 | """ 6 | 7 | alias Sengoku.Token 8 | 9 | def init(options), do: options 10 | 11 | def call(%{assigns: %{current_user: current_user}} = conn, _opts) do 12 | existing_player_id = Plug.Conn.get_session(conn, :player_id) 13 | 14 | case {current_user, existing_player_id} do 15 | {nil, nil} -> 16 | Plug.Conn.put_session(conn, :player_id, "anonymous-#{Token.new()}") 17 | 18 | {%{id: user_id}, id} when user_id != id -> 19 | Plug.Conn.put_session(conn, :player_id, user_id) 20 | 21 | {_, _} -> 22 | conn 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/sengoku_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import SengokuWeb.Gettext 9 | 10 | # Simple translation 11 | gettext "Here is the string to translate" 12 | 13 | # Plural translation 14 | ngettext "Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3 17 | 18 | # Domain-based translation 19 | dgettext "errors", "Here is the error message to translate" 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :sengoku 24 | end 25 | -------------------------------------------------------------------------------- /test/sengoku/tile_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.TileTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Sengoku.{Tile} 5 | 6 | describe "owned_by_player_id?/2" do 7 | test "should return true if the passed tile is owned by the passed player" do 8 | tile = %Tile{owner: 1} 9 | player_id = 1 10 | assert Tile.owned_by_player_id?(tile, player_id) 11 | end 12 | 13 | test "should return false if the passed tile is owned by another player" do 14 | tile = %Tile{owner: 2} 15 | player_id = 1 16 | refute Tile.owned_by_player_id?(tile, player_id) 17 | end 18 | 19 | test "should return false if the passed tile is not owned by anyone" do 20 | tile = %Tile{} 21 | player_id = 1 22 | refute Tile.owned_by_player_id?(tile, player_id) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/sengoku/region_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.RegionTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Sengoku.{Region} 5 | 6 | describe "containing_tile_ids/2" do 7 | test "returns Regions all of whose tile_ids are in the provided list" do 8 | state = %{ 9 | regions: %{ 10 | 1 => %{value: 1, tile_ids: [1, 2, 3]}, 11 | 2 => %{value: 1, tile_ids: [4, 5]}, 12 | 3 => %{value: 1, tile_ids: [6, 7, 8]}, 13 | 4 => %{value: 1, tile_ids: [9, 10]} 14 | } 15 | } 16 | 17 | tile_ids = [1, 4, 5, 6, 7, 8, 10] 18 | result = Region.containing_tile_ids(state, tile_ids) 19 | 20 | assert result == [ 21 | %{value: 1, tile_ids: [4, 5]}, 22 | %{value: 1, tile_ids: [6, 7, 8]} 23 | ] 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/sengoku/board_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.BoardTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Sengoku.{Board} 5 | 6 | describe "new/1" do 7 | test "returns a Board struct with board-specific data" do 8 | %Board{ 9 | name: "japan", 10 | players_count: _players_count, 11 | tiles: _tiles, 12 | regions: _regions 13 | } = Board.new("japan") 14 | end 15 | 16 | test "restricts neighbors to only tiles on the board" do 17 | board = Board.new("wheel") 18 | 19 | assert board.tiles[3].neighbors == [4, 12, 13] 20 | end 21 | 22 | test "allow specifying additional neighbor mappings" do 23 | board = Board.new("earth") 24 | 25 | assert 10 in board.tiles[19].neighbors 26 | assert 19 in board.tiles[10].neighbors 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/support/fixtures/accounts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.AccountsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Sengoku.Accounts` context. 5 | """ 6 | 7 | def unique_user_email, do: "user-#{Sengoku.Token.new(8)}@example.com" 8 | def valid_user_username, do: "#{Sengoku.Token.new(8)}" 9 | def valid_user_password, do: "hello world!" 10 | 11 | def user_fixture(attrs \\ %{}) do 12 | {:ok, user} = 13 | attrs 14 | |> Enum.into(%{ 15 | email: unique_user_email(), 16 | username: valid_user_username(), 17 | password: valid_user_password() 18 | }) 19 | |> Sengoku.Accounts.register_user() 20 | 21 | user 22 | end 23 | 24 | def extract_user_token(fun) do 25 | {:ok, captured} = fun.(&"[TOKEN]#{&1}[TOKEN]") 26 | [_, token, _] = String.split(captured.body, "[TOKEN]") 27 | token 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200607174842_create_users_auth_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Repo.Migrations.CreateUsersAuthTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute("CREATE EXTENSION IF NOT EXISTS citext", "") 6 | 7 | create table(:users) do 8 | add(:email, :citext, null: false) 9 | add(:hashed_password, :string, null: false) 10 | add(:confirmed_at, :naive_datetime) 11 | timestamps() 12 | end 13 | 14 | create(unique_index(:users, [:email])) 15 | 16 | create table(:users_tokens) do 17 | add(:user_id, references(:users, on_delete: :delete_all), null: false) 18 | add(:token, :binary, null: false) 19 | add(:context, :string, null: false) 20 | add(:sent_to, :string) 21 | timestamps(updated_at: false) 22 | end 23 | 24 | create(index(:users_tokens, [:user_id])) 25 | create(unique_index(:users_tokens, [:context, :token])) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "description": " ", 4 | "license": "MIT", 5 | "scripts": { 6 | "deploy": "webpack --mode production", 7 | "watch": "webpack --mode development --watch" 8 | }, 9 | "engines" : { "node" : ">=14.0" }, 10 | "dependencies": { 11 | "phoenix": "file:../deps/phoenix", 12 | "phoenix_html": "file:../deps/phoenix_html", 13 | "phoenix_live_view": "file:../deps/phoenix_live_view" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.0.0", 17 | "@babel/preset-env": "^7.0.0", 18 | "babel-loader": "^8.0.0", 19 | "copy-webpack-plugin": "^5.1.1", 20 | "css-loader": "^3.4.2", 21 | "sass-loader": "^8.0.2", 22 | "node-sass": "^4.13.1", 23 | "mini-css-extract-plugin": "^0.9.0", 24 | "optimize-css-assets-webpack-plugin": "^5.0.1", 25 | "terser-webpack-plugin": "^2.3.2", 26 | "webpack": "4.41.5", 27 | "webpack-cli": "^3.3.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :sengoku, Sengoku.Repo, 9 | database: "sengoku_test#{System.get_env("MIX_TEST_PARTITION")}", 10 | username: "postgres", 11 | password: "postgres", 12 | hostname: "localhost", 13 | pool: Ecto.Adapters.SQL.Sandbox 14 | 15 | # Only in tests, remove the complexity from the password hashing algorithm 16 | config :bcrypt_elixir, :log_rounds, 1 17 | 18 | # We don't run a server during test. If one is required, 19 | # you can enable the server option below. 20 | config :sengoku, SengokuWeb.Endpoint, 21 | http: [port: 4001], 22 | server: true 23 | 24 | # Print only warnings and errors during test 25 | config :logger, level: :warn 26 | 27 | config :sengoku, Sengoku.Mailer, adapter: Bamboo.TestAdapter 28 | -------------------------------------------------------------------------------- /lib/sengoku_web/controllers/user_registration_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserRegistrationController do 2 | use SengokuWeb, :controller 3 | 4 | alias Sengoku.Accounts 5 | alias Sengoku.Accounts.User 6 | alias SengokuWeb.UserAuth 7 | 8 | def new(conn, _params) do 9 | changeset = Accounts.change_user_registration(%User{}) 10 | render(conn, "new.html", changeset: changeset) 11 | end 12 | 13 | def create(conn, %{"user" => user_params}) do 14 | case Accounts.register_user(user_params) do 15 | {:ok, user} -> 16 | {:ok, _} = 17 | Accounts.deliver_user_confirmation_instructions( 18 | user, 19 | &Routes.user_confirmation_url(conn, :confirm, &1) 20 | ) 21 | 22 | conn 23 | |> put_flash(:info, "Welcome!") 24 | |> UserAuth.login_user(user) 25 | 26 | {:error, %Ecto.Changeset{} = changeset} -> 27 | render(conn, "new.html", changeset: changeset) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/sengoku/accounts/user_notifier.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Accounts.UserNotifier do 2 | alias Sengoku.{Email, Mailer} 3 | 4 | @doc """ 5 | Deliver instructions to confirm account. 6 | """ 7 | def deliver_confirmation_instructions(user, url) do 8 | mail = Email.confirmation_instructions(user.email, url) 9 | Mailer.deliver_later(mail) 10 | 11 | {:ok, %{to: user.email, body: mail.text_body}} 12 | end 13 | 14 | @doc """ 15 | Deliver instructions to reset password account. 16 | """ 17 | def deliver_reset_password_instructions(user, url) do 18 | mail = Email.reset_password_instructions(user.email, url) 19 | Mailer.deliver_later(mail) 20 | 21 | {:ok, %{to: user.email, body: mail.text_body}} 22 | end 23 | 24 | @doc """ 25 | Deliver instructions to update your e-mail. 26 | """ 27 | def deliver_update_email_instructions(user, url) do 28 | mail = Email.update_email_instructions(user.email, url) 29 | Mailer.deliver_later(mail) 30 | 31 | {:ok, %{to: user.email, body: mail.text_body}} 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/sengoku_web/controllers/user_session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserSessionController do 2 | use SengokuWeb, :controller 3 | 4 | alias Sengoku.Accounts 5 | alias SengokuWeb.UserAuth 6 | 7 | def new(conn, _params) do 8 | render(conn, "new.html", error_message: nil) 9 | end 10 | 11 | def create(conn, %{"user" => user_params}) do 12 | %{"email_or_username" => email_or_username, "password" => password} = user_params 13 | 14 | user = 15 | if String.contains?(email_or_username, "@") do 16 | Accounts.get_user_by_email_and_password(email_or_username, password) 17 | else 18 | Accounts.get_user_by_username_and_password(email_or_username, password) 19 | end 20 | 21 | if user do 22 | UserAuth.login_user(conn, user, user_params) 23 | else 24 | render(conn, "new.html", error_message: "Invalid e-mail or password") 25 | end 26 | end 27 | 28 | def delete(conn, _params) do 29 | conn 30 | |> put_flash(:info, "Logged out successfully.") 31 | |> UserAuth.logout_user() 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/layout/root.html.leex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <%= live_title_tag assigns[:page_title] || "Play!", suffix: " · Sengoku" %> 9 | 10 | "/> 11 | 12 | 13 | 14 |
15 |

16 | 17 |

18 | 19 | <%= render "_user_menu.html", assigns %> 20 |
21 | 22 |
23 | <%= @inner_content %> 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/user_session/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

Sign In

3 | 4 | <%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user, class: "Form"], fn f -> %> 5 | <%= if @error_message do %> 6 |

<%= @error_message %>

7 | <% end %> 8 | 9 | <%= label f, :email_or_username, class: "Label" %> 10 | <%= text_input f, :email_or_username, required: true, class: "TextInput" %> 11 | 12 | <%= label f, :password, class: "Label" %> 13 | <%= password_input f, :password, required: true, class: "TextInput" %> 14 | 15 |
16 | <%= checkbox f, :remember_me %> 17 | <%= label f, :remember_me, "Remember me for 60 days" %> 18 |
19 | 20 | <%= submit "Sign In", class: "Button Button--primary" %> 21 | <% end %> 22 | 23 |

24 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | 25 | <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %> 26 |

27 |
28 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | import "../css/app.scss" 5 | 6 | // webpack automatically bundles all modules in your 7 | // entry points. Those entry points can be configured 8 | // in "webpack.config.js". 9 | // 10 | // Import deps with the dep name or local files with a relative path, for example: 11 | // 12 | // import {Socket} from "phoenix" 13 | // import socket from "./socket" 14 | // 15 | import "phoenix_html" 16 | import {Socket} from "phoenix" 17 | import {LiveSocket} from "phoenix_live_view" 18 | 19 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 20 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) 21 | 22 | // connect if there are any LiveViews on the page 23 | liveSocket.connect() 24 | 25 | // expose liveSocket on window for web console debug logs and latency simulation: 26 | // >> liveSocket.enableDebug() 27 | // >> liveSocket.enableLatencySim(1000) 28 | window.liveSocket = liveSocket 29 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/user_reset_password/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

Reset Your Password

3 | 4 | <%= form_for @changeset, Routes.user_reset_password_path(@conn, :update, @token), [class: "Form"], fn f -> %> 5 | <%= if @changeset.action do %> 6 |

Oops, something went wrong! Please check the errors below.

7 | <% end %> 8 | 9 | <%= label f, :password, "New password", class: "Label" %> 10 | <%= password_input f, :password, required: true, class: "TextInput" %> 11 | <%= error_tag f, :password %> 12 | 13 | <%= label f, :password_confirmation, "Confirm new password", class: "Label" %> 14 | <%= password_input f, :password_confirmation, required: true, class: "TextInput" %> 15 | <%= error_tag f, :password_confirmation %> 16 | 17 | <%= submit "Reset password", class: "Button Button--primary" %> 18 | <% end %> 19 | 20 |

21 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> 22 | | 23 | <%= link "Sign In", to: Routes.user_session_path(@conn, :new) %> 24 |

25 |
26 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | use Mix.Config 9 | 10 | config :sengoku, 11 | ecto_repos: [Sengoku.Repo] 12 | 13 | # Configures the endpoint 14 | config :sengoku, SengokuWeb.Endpoint, 15 | url: [host: "localhost"], 16 | secret_key_base: "gc5W5mN7aMEscZoF4Dk3tAST02cWY0sWndlgT2LxQjVKneDYrZY/hN3dOoOM/hY5", 17 | render_errors: [view: SengokuWeb.ErrorView, accepts: ~w(html json), layout: false], 18 | pubsub_server: Sengoku.PubSub, 19 | live_view: [signing_salt: "jBygkI9e"] 20 | 21 | # Configures Elixir's Logger 22 | config :logger, :console, 23 | format: "$time $metadata[$level] $message\n", 24 | metadata: [:request_id] 25 | 26 | # Use Jason for JSON parsing in Phoenix 27 | config :phoenix, :json_library, Jason 28 | 29 | # Import environment specific config. This must remain at the bottom 30 | # of this file so it overrides the configuration defined above. 31 | import_config "#{Mix.env()}.exs" 32 | -------------------------------------------------------------------------------- /lib/sengoku/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | children = [ 10 | # Start the Ecto repository 11 | Sengoku.Repo, 12 | # Start the Telemetry supervisor 13 | SengokuWeb.Telemetry, 14 | # Start the PubSub system 15 | {Phoenix.PubSub, name: Sengoku.PubSub}, 16 | # Start the Endpoint (http/https) 17 | SengokuWeb.Endpoint, 18 | # Start a worker by calling: Sengoku.Worker.start_link(arg) 19 | {Registry, keys: :unique, name: :game_server_registry} 20 | ] 21 | 22 | # See https://hexdocs.pm/elixir/Supervisor.html 23 | # for other strategies and supported options 24 | opts = [strategy: :one_for_one, name: Sengoku.Supervisor] 25 | Supervisor.start_link(children, opts) 26 | end 27 | 28 | # Tell Phoenix to update the endpoint configuration 29 | # whenever the application is updated. 30 | def config_change(changed, _new, removed) do 31 | SengokuWeb.Endpoint.config_change(changed, removed) 32 | :ok 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/user_registration/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

Create an Account

3 | 4 | <%= form_for @changeset, Routes.user_registration_path(@conn, :create), [class: "Form"], fn f -> %> 5 | <%= if @changeset.action do %> 6 |

Oops, something went wrong! Please check the errors below.

7 | <% end %> 8 | 9 | <%= label f, :email, class: "Label" %> 10 | <%= text_input f, :email, required: true, class: "TextInput" %> 11 | <%= error_tag f, :email %> 12 | 13 | <%= label f, :username, "Username (3–20 characters)", class: "Label" %> 14 | <%= text_input f, :username, required: true, class: "TextInput" %> 15 | <%= error_tag f, :username %> 16 | 17 | <%= label f, :password, class: "Label" %> 18 | <%= password_input f, :password, required: true, class: "TextInput" %> 19 | <%= error_tag f, :password %> 20 | 21 | <%= submit "Register", class: "Button Button--primary" %> 22 | <% end %> 23 | 24 |

25 | <%= link "Sign In", to: Routes.user_session_path(@conn, :new) %> 26 | | 27 | <%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %> 28 |

29 |
30 | -------------------------------------------------------------------------------- /lib/sengoku_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", SengokuWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | @impl true 19 | def connect(_params, socket, _connect_info) do 20 | {:ok, socket} 21 | end 22 | 23 | # Socket id's are topics that allow you to identify all sockets for a given user: 24 | # 25 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 26 | # 27 | # Would allow you to broadcast a "disconnect" event and terminate 28 | # all active sockets and channels for a given user: 29 | # 30 | # SengokuWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 31 | # 32 | # Returning `nil` makes this socket anonymous. 33 | @impl true 34 | def id(_socket), do: nil 35 | end 36 | -------------------------------------------------------------------------------- /lib/sengoku/authentication.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Authentication do 2 | @moduledoc """ 3 | Responible for determining and recording which human players (identified by a 4 | unique player_id) correspond to which integer player numbers in the 5 | GameServer’s state. 6 | """ 7 | 8 | alias Sengoku.Player 9 | 10 | def initialize_state(state) do 11 | Map.put(state, :player_ids, %{}) 12 | end 13 | 14 | def authenticate_player(state, player_id, name) do 15 | existing_player_id = state.player_ids[player_id] 16 | 17 | if existing_player_id do 18 | {:ok, {existing_player_id, player_id}, state} 19 | else 20 | if state.turn == 0 do 21 | first_available_player_id = 22 | state 23 | |> Player.ai_ids() 24 | |> List.first() 25 | 26 | if is_nil(first_available_player_id) do 27 | {:error, :full} 28 | else 29 | state = 30 | state 31 | |> put_in([:player_ids, player_id], first_available_player_id) 32 | |> Player.update_attributes(first_available_player_id, %{ai: false, name: name}) 33 | 34 | {:ok, {first_available_player_id, player_id}, state} 35 | end 36 | else 37 | {:error, :in_progress} 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use SengokuWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import SengokuWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint SengokuWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Sengoku.Repo) 33 | 34 | unless tags[:async] do 35 | Ecto.Adapters.SQL.Sandbox.mode(Sengoku.Repo, {:shared, self()}) 36 | end 37 | 38 | :ok 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/sengoku/battle.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Battle do 2 | @moduledoc """ 3 | Responsible for the logic of one Player attacking another’s neighboring Tile. 4 | """ 5 | 6 | def decide(attacker_count, defender_count) do 7 | attacker_rolls = 8 | attacker_count 9 | |> min(3) 10 | |> roll_n_times 11 | 12 | defender_rolls = 13 | defender_count 14 | |> min(2) 15 | |> roll_n_times 16 | 17 | compare_rolls(attacker_rolls, defender_rolls) 18 | end 19 | 20 | def compare_rolls(a_rolls, d_rolls) do 21 | compare_rolls(a_rolls, d_rolls, {0, 0}) 22 | end 23 | 24 | def compare_rolls([], _d_rolls, losses) do 25 | losses 26 | end 27 | 28 | def compare_rolls(_a_rolls, [], losses) do 29 | losses 30 | end 31 | 32 | def compare_rolls([a_hd | a_tl], [d_hd | d_tl], {a_losses, d_losses}) 33 | when a_hd > d_hd do 34 | compare_rolls(a_tl, d_tl, {a_losses, d_losses + 1}) 35 | end 36 | 37 | def compare_rolls([a_hd | a_tl], [d_hd | d_tl], {a_losses, d_losses}) 38 | when a_hd <= d_hd do 39 | compare_rolls(a_tl, d_tl, {a_losses + 1, d_losses}) 40 | end 41 | 42 | defp roll_n_times(n) do 43 | 1..n 44 | |> Enum.map(&roll_die/1) 45 | |> Enum.sort(&(&1 >= &2)) 46 | end 47 | 48 | defp roll_die(_i) do 49 | :rand.uniform(6) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/sengoku_web/controllers/user_confirmation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserConfirmationController do 2 | use SengokuWeb, :controller 3 | 4 | alias Sengoku.Accounts 5 | 6 | def new(conn, _params) do 7 | render(conn, "new.html") 8 | end 9 | 10 | def create(conn, %{"user" => %{"email" => email}}) do 11 | if user = Accounts.get_user_by_email(email) do 12 | Accounts.deliver_user_confirmation_instructions( 13 | user, 14 | &Routes.user_confirmation_url(conn, :confirm, &1) 15 | ) 16 | end 17 | 18 | # Regardless of the outcome, show an impartial success/error message. 19 | conn 20 | |> put_flash( 21 | :info, 22 | "If your e-mail is in our system and it has not been confirmed yet, " <> 23 | "you will receive an e-mail with instructions shortly." 24 | ) 25 | |> redirect(to: "/") 26 | end 27 | 28 | # Do not login the user after confirmation to avoid a 29 | # leaked token giving the user access to the account. 30 | def confirm(conn, %{"token" => token}) do 31 | case Accounts.confirm_user(token) do 32 | {:ok, _} -> 33 | conn 34 | |> put_flash(:info, "Account confirmed successfully.") 35 | |> redirect(to: "/") 36 | 37 | :error -> 38 | conn 39 | |> put_flash(:error, "Confirmation link is invalid or it has expired.") 40 | |> redirect(to: "/") 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/sengoku/email.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Email do 2 | import Bamboo.Email 3 | 4 | def confirmation_instructions(recipient_email, url) do 5 | base_email() 6 | |> to(recipient_email) 7 | |> subject("Confirm Your Sengoku Account") 8 | |> text_body(""" 9 | Hi #{recipient_email}, 10 | 11 | You can confirm your account by visiting the url below: 12 | 13 | #{url} 14 | 15 | If you didn't create an account with us, please ignore this. 16 | """) 17 | end 18 | 19 | def reset_password_instructions(recipient_email, url) do 20 | base_email() 21 | |> to(recipient_email) 22 | |> subject("Reset Your Sengoku Password") 23 | |> text_body(""" 24 | Hi #{recipient_email}, 25 | 26 | You can reset your password by visiting the url below: 27 | 28 | #{url} 29 | 30 | If you didn't request this change, please ignore this. 31 | """) 32 | end 33 | 34 | def update_email_instructions(recipient_email, url) do 35 | base_email() 36 | |> to(recipient_email) 37 | |> subject("Update Your Sengoku Email") 38 | |> text_body(""" 39 | Hi #{recipient_email}, 40 | 41 | You can change your e-mail by visiting the url below: 42 | 43 | #{url} 44 | 45 | If you didn't request this change, please ignore this. 46 | """) 47 | end 48 | 49 | defp base_email do 50 | new_email() 51 | |> from("admin@playsengoku.com") 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/sengoku_web/plugs/put_player_id_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.Plugs.PutPlayerIDTest do 2 | use SengokuWeb.ConnCase 3 | 4 | alias SengokuWeb.Plugs.PutPlayerID 5 | 6 | @opts PutPlayerID.init([]) 7 | @session Plug.Session.init( 8 | store: :cookie, 9 | key: "_app", 10 | encryption_salt: "secret", 11 | signing_salt: "secret", 12 | encrypt: false 13 | ) 14 | 15 | defp setup_session(conn) do 16 | conn 17 | |> Plug.Session.call(@session) 18 | |> fetch_session() 19 | end 20 | 21 | test "errors when assigns.current_user is missing", %{conn: conn} do 22 | assert_raise FunctionClauseError, fn -> 23 | conn 24 | |> setup_session() 25 | |> PutPlayerID.call(@opts) 26 | end 27 | end 28 | 29 | test "stores an anonymous ID when the user is unrecognized", %{conn: conn} do 30 | conn = 31 | conn 32 | |> setup_session() 33 | |> Plug.Conn.assign(:current_user, nil) 34 | |> PutPlayerID.call(@opts) 35 | 36 | assert String.starts_with?(get_session(conn)["player_id"], "anonymous-") 37 | end 38 | 39 | test "stores the current_user’s ID when present", %{conn: conn} do 40 | conn = 41 | conn 42 | |> setup_session() 43 | |> Plug.Conn.assign(:current_user, %{id: 123}) 44 | |> PutPlayerID.call(@opts) 45 | 46 | assert get_session(conn)["player_id"] == 123 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/sengoku_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), class: "help-block") 14 | end) 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # Because error messages were defined within Ecto, we must 22 | # call the Gettext module passing our Gettext backend. We 23 | # also use the "errors" domain as translations are placed 24 | # in the errors.po file. 25 | # Ecto will pass the :count keyword if the error message is 26 | # meant to be pluralized. 27 | # On your own code and templates, depending on whether you 28 | # need the message to be pluralized or not, this could be 29 | # written simply as: 30 | # 31 | # dngettext "errors", "1 file", "%{count} files", count 32 | # dgettext "errors", "is invalid" 33 | # 34 | if count = opts[:count] do 35 | Gettext.dngettext(SengokuWeb.Gettext, "errors", msg, msg, count, opts) 36 | else 37 | Gettext.dgettext(SengokuWeb.Gettext, "errors", msg, opts) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/sengoku/tile.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Tile do 2 | @moduledoc """ 3 | The struct and behavior of the domain-model Tile, which corresponds to a 4 | controllable territory on the game board. 5 | """ 6 | 7 | defstruct owner: nil, units: 1, neighbors: [] 8 | 9 | def new(neighbors) do 10 | %__MODULE__{neighbors: neighbors} 11 | end 12 | 13 | def initialize_state(state, tiles) do 14 | Map.put(state, :tiles, tiles) 15 | end 16 | 17 | def update_attributes(state, tile_id, %{} = new_atts) do 18 | update_in(state, [:tiles, tile_id], fn tile -> 19 | Map.merge(tile, new_atts) 20 | end) 21 | end 22 | 23 | def ids_owned_by(state, player_id) do 24 | state 25 | |> filter_ids(&(&1.owner == player_id)) 26 | end 27 | 28 | def set_owner(state, tile_id, player_id) do 29 | update_in(state, [:tiles, tile_id], fn tile -> 30 | Map.put(tile, :owner, player_id) 31 | end) 32 | end 33 | 34 | def adjust_units(state, tile_id, count) do 35 | update_in(state, [:tiles, tile_id], fn tile -> 36 | Map.update!(tile, :units, &(&1 + count)) 37 | end) 38 | end 39 | 40 | def unowned_ids(state) do 41 | state 42 | |> filter_ids(&is_nil(&1.owner)) 43 | end 44 | 45 | def filter_ids(state, func) do 46 | state.tiles 47 | |> Enum.filter(fn {_id, tile} -> func.(tile) end) 48 | |> Enum.into(%{}) 49 | |> Map.keys() 50 | end 51 | 52 | def owned_by_player_id?(%__MODULE__{owner: owner}, player_id) do 53 | owner == player_id 54 | end 55 | 56 | def get(state, tile_id) do 57 | state.tiles[tile_id] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const TerserPlugin = require('terser-webpack-plugin'); 5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | 8 | module.exports = (env, options) => { 9 | const devMode = options.mode !== 'production'; 10 | 11 | return { 12 | optimization: { 13 | minimizer: [ 14 | new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }), 15 | new OptimizeCSSAssetsPlugin({}) 16 | ] 17 | }, 18 | entry: { 19 | 'app': glob.sync('./vendor/**/*.js').concat(['./js/app.js']) 20 | }, 21 | output: { 22 | filename: '[name].js', 23 | path: path.resolve(__dirname, '../priv/static/js'), 24 | publicPath: '/js/' 25 | }, 26 | devtool: devMode ? 'source-map' : undefined, 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.js$/, 31 | exclude: /node_modules/, 32 | use: { 33 | loader: 'babel-loader' 34 | } 35 | }, 36 | { 37 | test: /\.[s]?css$/, 38 | use: [ 39 | MiniCssExtractPlugin.loader, 40 | 'css-loader', 41 | 'sass-loader', 42 | ], 43 | } 44 | ] 45 | }, 46 | plugins: [ 47 | new MiniCssExtractPlugin({ filename: '../css/app.css' }), 48 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }]) 49 | ] 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use Sengoku.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias Sengoku.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import Sengoku.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Sengoku.Repo) 32 | 33 | unless tags[:async] do 34 | Ecto.Adapters.SQL.Sandbox.mode(Sengoku.Repo, {:shared, self()}) 35 | end 36 | 37 | :ok 38 | end 39 | 40 | @doc """ 41 | A helper that transforms changeset errors into a map of messages. 42 | 43 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 44 | assert "password is too short" in errors_on(changeset).password 45 | assert %{password: ["password is too short"]} = errors_on(changeset) 46 | 47 | """ 48 | def errors_on(changeset) do 49 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 50 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 51 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 52 | end) 53 | end) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/sengoku/battle_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.BattleTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Sengoku.{Battle} 5 | 6 | describe "decide/2" do 7 | test "caps losses at 2 with only 2 attackers" do 8 | {a_losses, d_losses} = Battle.decide(99, 99) 9 | assert a_losses + d_losses == 2 10 | end 11 | 12 | test "caps losses at 1 with only 1 attacker" do 13 | {a_losses, d_losses} = Battle.decide(1, 99) 14 | assert a_losses + d_losses == 1 15 | end 16 | 17 | test "caps losses at 1 with only 1 defender" do 18 | {a_losses, d_losses} = Battle.decide(99, 1) 19 | assert a_losses + d_losses == 1 20 | end 21 | 22 | test "caps losses at 1 with only 1 unit on each side" do 23 | {a_losses, d_losses} = Battle.decide(1, 1) 24 | assert a_losses + d_losses == 1 25 | end 26 | end 27 | 28 | describe "compare_rolls/2" do 29 | test "when the attacker wins 3" do 30 | assert {0, 2} == Battle.compare_rolls([6, 6, 6], [2, 1]) 31 | end 32 | 33 | test "when the defender wins 2" do 34 | assert {2, 0} == Battle.compare_rolls([3, 2, 1], [6, 6]) 35 | end 36 | 37 | test "the defender wins draws" do 38 | assert {2, 0} == Battle.compare_rolls([3, 2, 1], [3, 2]) 39 | end 40 | 41 | test "when the attacker has fewer than the maximum" do 42 | assert {0, 1} == Battle.compare_rolls([6], [3, 2]) 43 | end 44 | 45 | test "when the defender has fewer than the maximum" do 46 | assert {1, 0} == Battle.compare_rolls([3, 2, 1], [6]) 47 | end 48 | 49 | test "when the attacker has 0" do 50 | assert {0, 0} == Battle.compare_rolls([], [6, 6]) 51 | end 52 | 53 | test "when the defender has 0" do 54 | assert {0, 0} == Battle.compare_rolls([1, 1, 1], []) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/sengoku_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :sengoku 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_sengoku_key", 10 | signing_salt: "4F8Q8pDe" 11 | ] 12 | 13 | socket "/socket", SengokuWeb.UserSocket, 14 | websocket: true, 15 | longpoll: false 16 | 17 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 18 | 19 | # Serve at "/" the static files from "priv/static" directory. 20 | # 21 | # You should set gzip to true if you are running phx.digest 22 | # when deploying your static files in production. 23 | plug Plug.Static, 24 | at: "/", 25 | from: :sengoku, 26 | gzip: false, 27 | only: ~w(css fonts images js favicon.ico robots.txt) 28 | 29 | # Code reloading can be explicitly enabled under the 30 | # :code_reloader configuration of your endpoint. 31 | if code_reloading? do 32 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 33 | plug Phoenix.LiveReloader 34 | plug Phoenix.CodeReloader 35 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :sengoku 36 | end 37 | 38 | plug Phoenix.LiveDashboard.RequestLogger, 39 | param_key: "request_logger", 40 | cookie_key: "request_logger" 41 | 42 | plug Plug.RequestId 43 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 44 | 45 | plug Plug.Parsers, 46 | parsers: [:urlencoded, :multipart, :json], 47 | pass: ["*/*"], 48 | json_decoder: Phoenix.json_library() 49 | 50 | plug Plug.MethodOverride 51 | plug Plug.Head 52 | plug Plug.Session, @session_options 53 | plug SengokuWeb.Router 54 | end 55 | -------------------------------------------------------------------------------- /lib/sengoku_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 13 | # Add reporters as children of your supervision tree. 14 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 15 | ] 16 | 17 | Supervisor.init(children, strategy: :one_for_one) 18 | end 19 | 20 | def metrics do 21 | [ 22 | # Phoenix Metrics 23 | summary("phoenix.endpoint.stop.duration", 24 | unit: {:native, :millisecond} 25 | ), 26 | summary("phoenix.router_dispatch.stop.duration", 27 | tags: [:route], 28 | unit: {:native, :millisecond} 29 | ), 30 | 31 | # Database Metrics 32 | summary("sengoku.repo.query.total_time", unit: {:native, :millisecond}), 33 | summary("sengoku.repo.query.decode_time", unit: {:native, :millisecond}), 34 | summary("sengoku.repo.query.query_time", unit: {:native, :millisecond}), 35 | summary("sengoku.repo.query.queue_time", unit: {:native, :millisecond}), 36 | summary("sengoku.repo.query.idle_time", unit: {:native, :millisecond}), 37 | 38 | # VM Metrics 39 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 40 | summary("vm.total_run_queue_lengths.total"), 41 | summary("vm.total_run_queue_lengths.cpu"), 42 | summary("vm.total_run_queue_lengths.io") 43 | ] 44 | end 45 | 46 | defp periodic_measurements do 47 | [ 48 | # A module, function and arguments to be invoked periodically. 49 | # This function must call :telemetry.execute/3 and a metric must be added above. 50 | # {SengokuWeb, :count_users, []} 51 | ] 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | import Plug.Conn 22 | import Phoenix.ConnTest 23 | import SengokuWeb.ConnCase 24 | alias SengokuWeb.Router.Helpers, as: Routes 25 | 26 | # The default endpoint for testing 27 | @endpoint SengokuWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Sengoku.Repo) 33 | 34 | unless tags[:async] do 35 | Ecto.Adapters.SQL.Sandbox.mode(Sengoku.Repo, {:shared, self()}) 36 | end 37 | 38 | {:ok, conn: Phoenix.ConnTest.build_conn()} 39 | end 40 | 41 | @doc """ 42 | Setup helper that registers and logs in users. 43 | 44 | setup :register_and_login_user 45 | 46 | It stores an updated connection and a registered user in the 47 | test context. 48 | """ 49 | def register_and_login_user(%{conn: conn}) do 50 | user = Sengoku.AccountsFixtures.user_fixture() 51 | %{conn: login_user(conn, user), user: user} 52 | end 53 | 54 | @doc """ 55 | Logs the given `user` into the `conn`. 56 | 57 | It returns an updated `conn`. 58 | """ 59 | def login_user(conn, user) do 60 | token = Sengoku.Accounts.generate_user_session_token(user) 61 | 62 | conn 63 | |> Phoenix.ConnTest.init_test_session(%{}) 64 | |> Plug.Conn.put_session(:user_token, token) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/sengoku_web/controllers/user_reset_password_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserResetPasswordController do 2 | use SengokuWeb, :controller 3 | 4 | alias Sengoku.Accounts 5 | 6 | plug :get_user_by_reset_password_token when action in [:edit, :update] 7 | 8 | def new(conn, _params) do 9 | render(conn, "new.html") 10 | end 11 | 12 | def create(conn, %{"user" => %{"email" => email}}) do 13 | if user = Accounts.get_user_by_email(email) do 14 | Accounts.deliver_user_reset_password_instructions( 15 | user, 16 | &Routes.user_reset_password_url(conn, :edit, &1) 17 | ) 18 | end 19 | 20 | # Regardless of the outcome, show an impartial success/error message. 21 | conn 22 | |> put_flash( 23 | :info, 24 | "If your e-mail is in our system, you will receive instructions to reset your password shortly." 25 | ) 26 | |> redirect(to: "/") 27 | end 28 | 29 | def edit(conn, _params) do 30 | render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user)) 31 | end 32 | 33 | # Do not login the user after reset password to avoid a 34 | # leaked token giving the user access to the account. 35 | def update(conn, %{"user" => user_params}) do 36 | case Accounts.reset_user_password(conn.assigns.user, user_params) do 37 | {:ok, _} -> 38 | conn 39 | |> put_flash(:info, "Password reset successfully.") 40 | |> redirect(to: Routes.user_session_path(conn, :new)) 41 | 42 | {:error, changeset} -> 43 | render(conn, "edit.html", changeset: changeset) 44 | end 45 | end 46 | 47 | defp get_user_by_reset_password_token(conn, _opts) do 48 | %{"token" => token} = conn.params 49 | 50 | if user = Accounts.get_user_by_reset_password_token(token) do 51 | conn |> assign(:user, user) |> assign(:token, token) 52 | else 53 | conn 54 | |> put_flash(:error, "Reset password link is invalid or it has expired.") 55 | |> redirect(to: "/") 56 | |> halt() 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/user_settings/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

Settings

3 | 4 |

Change E-mail

5 | 6 | <%= form_for @email_changeset, Routes.user_settings_path(@conn, :update_email), [class: "Form"], fn f -> %> 7 | <%= if @email_changeset.action do %> 8 |

Oops, something went wrong! Please check the errors below.

9 | <% end %> 10 | 11 | <%= label f, :email, class: "Label" %> 12 | <%= text_input f, :email, required: true, class: "TextInput" %> 13 | <%= error_tag f, :email %> 14 | 15 | <%= label f, :current_password, for: "current_password_for_email", class: "Label" %> 16 | <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email", class: "TextInput" %> 17 | <%= error_tag f, :current_password %> 18 | 19 | <%= submit "Change e-mail", class: "Button Button--primary" %> 20 | <% end %> 21 | 22 |
23 | 24 |

Change Password

25 | 26 | <%= form_for @password_changeset, Routes.user_settings_path(@conn, :update_password), [class: "Form"], fn f -> %> 27 | <%= if @password_changeset.action do %> 28 |

Oops, something went wrong! Please check the errors below.

29 | <% end %> 30 | 31 | <%= label f, :current_password, for: "current_password_for_password", class: "Label" %> 32 | <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password", class: "TextInput" %> 33 | <%= error_tag f, :current_password %> 34 | 35 | <%= label f, :password, "New password", class: "Label" %> 36 | <%= password_input f, :password, required: true, class: "TextInput" %> 37 | <%= error_tag f, :password %> 38 | 39 | <%= label f, :password_confirmation, "Confirm new password", class: "Label" %> 40 | <%= password_input f, :password_confirmation, required: true, class: "TextInput" %> 41 | <%= error_tag f, :password_confirmation %> 42 | 43 | <%= submit "Change password", class: "Button Button--primary" %> 44 | <% end %> 45 |
46 | -------------------------------------------------------------------------------- /lib/sengoku/player.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Player do 2 | @moduledoc """ 3 | The struct and behavior of the domain-model Player, which corresponds to a 4 | human- or computer-controller actor in the game. 5 | """ 6 | 7 | @default_ai Sengoku.AI.Smart 8 | 9 | defstruct unplaced_units: 0, active: true, ai: @default_ai, name: nil, color: nil 10 | 11 | @colors %{ 12 | # Red 13 | 1 => "#cc241d", 14 | # Green 15 | 2 => "#98971a", 16 | # Yellow 17 | 3 => "#d79921", 18 | # Blue 19 | 4 => "#458588", 20 | # Purple 21 | 5 => "#b16286", 22 | # Orange 23 | 6 => "#d65d0e" 24 | } 25 | 26 | def new(atts \\ %{}) do 27 | struct(__MODULE__, atts) 28 | end 29 | 30 | def initialize_state(state, count) when is_integer(count) do 31 | Map.put(state, :players, Enum.reduce(1..count, %{}, &add_player/2)) 32 | end 33 | 34 | defp add_player(id, map) do 35 | Map.put(map, id, new(%{name: "Player #{id}", color: @colors[id]})) 36 | end 37 | 38 | def update_attributes(state, player_id, %{} = new_atts) do 39 | update_in(state, [:players, player_id], fn player -> 40 | Map.merge(player, new_atts) 41 | end) 42 | end 43 | 44 | def use_reinforcement(state, player_id) do 45 | update(state, player_id, :unplaced_units, &(&1 - 1)) 46 | end 47 | 48 | def grant_reinforcements(state, player_id, count) do 49 | update(state, player_id, :unplaced_units, &(&1 + count)) 50 | end 51 | 52 | def deactivate(state, player_id) do 53 | state 54 | |> update_attributes(player_id, %{active: false, unplaced_units: 0}) 55 | end 56 | 57 | def ai_ids(state) do 58 | state 59 | |> filter_ids(&(&1.ai != false)) 60 | end 61 | 62 | def active_ids(state) do 63 | state 64 | |> filter_ids(& &1.active) 65 | end 66 | 67 | defp update(state, player_id, key, func) do 68 | update_in(state, [:players, player_id], fn player -> 69 | Map.update!(player, key, func) 70 | end) 71 | end 72 | 73 | defp filter_ids(state, func) do 74 | state.players 75 | |> Enum.filter(fn {_id, player} -> func.(player) end) 76 | |> Enum.into(%{}) 77 | |> Map.keys() 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/game/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Play

4 |

Up to 8 players: play against friends online, the computer, or both.

5 | 14 |
15 | 16 | <%= form_tag(Routes.game_path(@conn, :create)) do %> 17 |

Map

18 |

19 | 20 | 23 | 24 | 27 | 28 | 31 | 32 | 35 | 36 | 39 |

40 |

41 | <% end %> 42 |
43 | -------------------------------------------------------------------------------- /lib/sengoku_web/live/move_units_form.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.MoveUnitsForm do 2 | use Phoenix.LiveComponent 3 | 4 | @impl true 5 | def update(%{pending_move: %{min: min, max: max}} = assigns, socket) do 6 | default_count = midpoint(min, max) 7 | {:ok, assign(socket, Map.merge(assigns, %{count: default_count}))} 8 | end 9 | 10 | @impl true 11 | def render(assigns) do 12 | ~L""" 13 | 57 | """ 58 | end 59 | 60 | @impl true 61 | def handle_event("update_count", %{"count" => count_string}, socket) do 62 | {count, _} = Integer.parse(count_string) 63 | {:noreply, assign(socket, count: count)} 64 | end 65 | 66 | defp midpoint(low, high) do 67 | low + floor((high - low) / 2) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/sengoku/authentication_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.AuthenticationTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Sengoku.{Authentication, Player} 5 | 6 | describe "authenticate_player/3" do 7 | test "with no player_id, replaces the first AI player" do 8 | old_state = %{ 9 | turn: 0, 10 | players: %{ 11 | 1 => %Player{ai: false}, 12 | 2 => %Player{ai: Sengoku.AI.Smart} 13 | }, 14 | player_ids: %{ 15 | "foo" => 1 16 | } 17 | } 18 | 19 | assert {:ok, {2, new_player_id}, new_state} = 20 | Authentication.authenticate_player(old_state, nil, "Steve") 21 | 22 | assert new_state.players[2].ai == false 23 | assert new_state.players[2].name == "Steve" 24 | assert new_state.player_ids[new_player_id] == 2 25 | end 26 | 27 | test "with no player_id, prevents adding new players when the game is in progress" do 28 | player_id = nil 29 | 30 | old_state = %{ 31 | turn: 1, 32 | players: %{ 33 | 1 => %Player{ai: false}, 34 | 2 => %Player{ai: Sengoku.AI.Smart} 35 | }, 36 | player_ids: %{ 37 | "foo" => 1 38 | } 39 | } 40 | 41 | assert {:error, :in_progress} = 42 | Authentication.authenticate_player(old_state, player_id, "Steve") 43 | end 44 | 45 | test "with no player_id, errors when no AI players left" do 46 | player_id = nil 47 | 48 | old_state = %{ 49 | turn: 0, 50 | players: %{ 51 | 1 => %Player{ai: false}, 52 | 2 => %Player{ai: false} 53 | }, 54 | player_ids: %{ 55 | "foo" => 1, 56 | "bar" => 2 57 | } 58 | } 59 | 60 | assert {:error, :full} = Authentication.authenticate_player(old_state, player_id, "Steve") 61 | end 62 | 63 | test "with a player_id, returns the existing player_id for the player_id" do 64 | player_id = "abcdef" 65 | 66 | old_state = %{ 67 | turn: 0, 68 | players: %{ 69 | 1 => %Player{ai: false}, 70 | 2 => %Player{ai: Sengoku.AI.Smart} 71 | }, 72 | player_ids: %{player_id => 1} 73 | } 74 | 75 | assert {:ok, {1, ^player_id}, ^old_state} = 76 | Authentication.authenticate_player(old_state, player_id, "Steve") 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/sengoku_web/controllers/user_registration_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserRegistrationControllerTest do 2 | use SengokuWeb.ConnCase, async: true 3 | 4 | import Sengoku.AccountsFixtures 5 | 6 | describe "GET /users/register" do 7 | test "renders registration page", %{conn: conn} do 8 | conn = get(conn, Routes.user_registration_path(conn, :new)) 9 | response = html_response(conn, 200) 10 | assert response =~ "Create an Account" 11 | assert response =~ "Sign In" 12 | assert response =~ "Register" 13 | end 14 | 15 | test "redirects if already logged in", %{conn: conn} do 16 | conn = conn |> login_user(user_fixture()) |> get(Routes.user_registration_path(conn, :new)) 17 | assert redirected_to(conn) == "/" 18 | end 19 | end 20 | 21 | describe "POST /users/register" do 22 | @tag :capture_log 23 | test "creates account and logs the user in", %{conn: conn} do 24 | email = unique_user_email() 25 | username = valid_user_username() 26 | 27 | conn = 28 | post(conn, Routes.user_registration_path(conn, :create), %{ 29 | "user" => %{ 30 | "email" => email, 31 | "username" => username, 32 | "password" => valid_user_password() 33 | } 34 | }) 35 | 36 | assert get_session(conn, :user_token) 37 | assert redirected_to(conn) =~ "/" 38 | 39 | # Now do a logged in request and assert on the menu 40 | conn = get(conn, "/") 41 | response = html_response(conn, 200) 42 | assert response =~ "Hi, #{username}" 43 | assert response =~ "Settings" 44 | assert response =~ "Sign Out" 45 | end 46 | 47 | test "render errors for invalid data", %{conn: conn} do 48 | conn = 49 | post(conn, Routes.user_registration_path(conn, :create), %{ 50 | "user" => %{ 51 | "email" => "with spaces", 52 | "username" => "this is much too long for a username", 53 | "password" => "too short" 54 | } 55 | }) 56 | 57 | response = html_response(conn, 200) 58 | assert response =~ "Create an Account" 59 | assert response =~ "must have the @ sign and no spaces" 60 | assert response =~ "should be at least 12 character" 61 | assert response =~ "must be between 3–20 characters without spaces" 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :sengoku, Sengoku.Repo, 5 | database: "sengoku_dev", 6 | username: "postgres", 7 | password: "postgres", 8 | hostname: "localhost", 9 | show_sensitive_data_on_connection_error: true, 10 | pool_size: 10 11 | 12 | # For development, we disable any cache and enable 13 | # debugging and code reloading. 14 | # 15 | # The watchers configuration can be used to run external 16 | # watchers to your application. For example, we use it 17 | # with webpack to recompile .js and .css sources. 18 | config :sengoku, SengokuWeb.Endpoint, 19 | http: [port: 4000], 20 | debug_errors: true, 21 | code_reloader: true, 22 | check_origin: false, 23 | watchers: [ 24 | node: [ 25 | "node_modules/webpack/bin/webpack.js", 26 | "--mode", 27 | "development", 28 | "--watch-stdin", 29 | cd: Path.expand("../assets", __DIR__) 30 | ] 31 | ] 32 | 33 | # ## SSL Support 34 | # 35 | # In order to use HTTPS in development, a self-signed 36 | # certificate can be generated by running the following 37 | # Mix task: 38 | # 39 | # mix phx.gen.cert 40 | # 41 | # Note that this task requires Erlang/OTP 20 or later. 42 | # Run `mix help phx.gen.cert` for more information. 43 | # 44 | # The `http:` config above can be replaced with: 45 | # 46 | # https: [ 47 | # port: 4001, 48 | # cipher_suite: :strong, 49 | # keyfile: "priv/cert/selfsigned_key.pem", 50 | # certfile: "priv/cert/selfsigned.pem" 51 | # ], 52 | # 53 | # If desired, both `http:` and `https:` keys can be 54 | # configured to run both http and https servers on 55 | # different ports. 56 | 57 | # Watch static and templates for browser reloading. 58 | config :sengoku, SengokuWeb.Endpoint, 59 | live_reload: [ 60 | patterns: [ 61 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 62 | ~r"priv/gettext/.*(po)$", 63 | ~r"lib/sengoku_web/(live|views)/.*(ex)$", 64 | ~r"lib/sengoku_web/templates/.*(eex)$" 65 | ] 66 | ] 67 | 68 | # Do not include metadata nor timestamps in development logs 69 | config :logger, :console, format: "[$level] $message\n" 70 | 71 | # Set a higher stacktrace during development. Avoid configuring such 72 | # in production as building large stacktraces may be expensive. 73 | config :phoenix, :stacktrace_depth, 20 74 | 75 | # Initialize plugs at runtime for faster development compilation 76 | config :phoenix, :plug_init_mode, :runtime 77 | 78 | config :sengoku, Sengoku.Mailer, adapter: Bamboo.LocalAdapter 79 | -------------------------------------------------------------------------------- /lib/sengoku_web/controllers/user_settings_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserSettingsController do 2 | use SengokuWeb, :controller 3 | 4 | alias Sengoku.Accounts 5 | alias SengokuWeb.UserAuth 6 | 7 | plug :assign_email_and_password_changesets 8 | 9 | def edit(conn, _params) do 10 | render(conn, "edit.html") 11 | end 12 | 13 | def update_email(conn, %{"current_password" => password, "user" => user_params}) do 14 | user = conn.assigns.current_user 15 | 16 | case Accounts.apply_user_email(user, password, user_params) do 17 | {:ok, applied_user} -> 18 | Accounts.deliver_update_email_instructions( 19 | applied_user, 20 | user.email, 21 | &Routes.user_settings_url(conn, :confirm_email, &1) 22 | ) 23 | 24 | conn 25 | |> put_flash( 26 | :info, 27 | "A link to confirm your e-mail change has been sent to the new address." 28 | ) 29 | |> redirect(to: Routes.user_settings_path(conn, :edit)) 30 | 31 | {:error, changeset} -> 32 | render(conn, "edit.html", email_changeset: changeset) 33 | end 34 | end 35 | 36 | def confirm_email(conn, %{"token" => token}) do 37 | case Accounts.update_user_email(conn.assigns.current_user, token) do 38 | :ok -> 39 | conn 40 | |> put_flash(:info, "E-mail changed successfully.") 41 | |> redirect(to: Routes.user_settings_path(conn, :edit)) 42 | 43 | :error -> 44 | conn 45 | |> put_flash(:error, "Email change link is invalid or it has expired.") 46 | |> redirect(to: Routes.user_settings_path(conn, :edit)) 47 | end 48 | end 49 | 50 | def update_password(conn, %{"current_password" => password, "user" => user_params}) do 51 | user = conn.assigns.current_user 52 | 53 | case Accounts.update_user_password(user, password, user_params) do 54 | {:ok, user} -> 55 | conn 56 | |> put_flash(:info, "Password updated successfully.") 57 | |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit)) 58 | |> UserAuth.login_user(user) 59 | 60 | {:error, changeset} -> 61 | render(conn, "edit.html", password_changeset: changeset) 62 | end 63 | end 64 | 65 | defp assign_email_and_password_changesets(conn, _opts) do 66 | user = conn.assigns.current_user 67 | 68 | conn 69 | |> assign(:email_changeset, Accounts.change_user_email(user)) 70 | |> assign(:password_changeset, Accounts.change_user_password(user)) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :sengoku, 7 | version: "0.0.1", 8 | elixir: "~> 1.10", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix, :gettext] ++ Mix.compilers(), 11 | build_embedded: Mix.env() == :prod, 12 | start_permanent: Mix.env() == :prod, 13 | aliases: aliases(), 14 | deps: deps() 15 | ] 16 | end 17 | 18 | # Configuration for the OTP application. 19 | # 20 | # Type `mix help compile.app` for more information. 21 | def application do 22 | [ 23 | mod: {Sengoku.Application, []}, 24 | extra_applications: [:logger, :runtime_tools, :os_mon] 25 | ] 26 | end 27 | 28 | # Specifies which paths to compile per environment. 29 | defp elixirc_paths(:test), do: ["lib", "test/support"] 30 | defp elixirc_paths(_), do: ["lib"] 31 | 32 | # Specifies your project dependencies. 33 | # 34 | # Type `mix help deps` for examples and options. 35 | defp deps do 36 | [ 37 | {:bcrypt_elixir, "~> 2.0"}, 38 | {:phoenix, "~> 1.5.3"}, 39 | {:phoenix_ecto, "~> 4.1"}, 40 | {:ecto_sql, "~> 3.4"}, 41 | {:postgrex, ">= 0.0.0"}, 42 | {:phoenix_live_view, "~> 0.13"}, 43 | {:floki, ">= 0.0.0", only: :test}, 44 | {:phoenix_html, "~> 2.14"}, 45 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 46 | {:phoenix_live_dashboard, "~> 0.2"}, 47 | {:telemetry_metrics, "~> 0.4"}, 48 | {:telemetry_poller, "~> 0.4"}, 49 | {:gettext, "~> 0.11"}, 50 | {:jason, "~> 1.0"}, 51 | {:plug, "~> 1.10"}, 52 | {:plug_cowboy, "~> 2.2"}, 53 | {:bamboo, "~> 1.5"}, 54 | {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, 55 | {:mix_test_watch, "~> 0.9", only: :dev, runtime: false}, 56 | {:phx_gen_auth, "~> 0.3.0", only: [:dev], runtime: false} 57 | ] 58 | end 59 | 60 | # Aliases are shortcuts or tasks specific to the current project. 61 | # For example, to install project dependencies and perform other setup tasks, run: 62 | # 63 | # $ mix setup 64 | # 65 | # See the documentation for `Mix` for more info on aliases. 66 | defp aliases do 67 | [ 68 | setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"], 69 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 70 | "ecto.reset": ["ecto.drop", "ecto.setup"], 71 | test: ["ecto.create --quiet", "ecto.migrate", "test"] 72 | ] 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/mix/tasks/regions.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Regions do 2 | @moduledoc """ 3 | A mix task provides information about the costs and benefits of controlling 4 | each region, which can be helpful to ensure balance when designing regions for 5 | new boards. 6 | 7 | Usage: 8 | 9 | $ mix regions japan 10 | Region | Value | Tiles | Border Tiles | “Free” Tiles | Value % 11 | -------|-------|-------|--------------|--------------|-------- 12 | 1 | 2 | 4 | 2 | 2 | 1.0 13 | 2 | 2 | 3 | 2 | 1 | 1.0 14 | 3 | 5 | 6 | 5 | 1 | 1.0 15 | 4 | 5 | 5 | 5 | 0 | 1.0 16 | 5 | 3 | 3 | 3 | 0 | 1.0 17 | 6 | 2 | 3 | 2 | 1 | 1.0 18 | 19 | """ 20 | 21 | use Mix.Task 22 | 23 | alias Sengoku.{Board} 24 | 25 | @shortdoc "Region stats" 26 | def run([board]) do 27 | IO.puts("") 28 | IO.puts("Region | Value | Tiles | Border Tiles | “Free” Tiles | Value %") 29 | IO.puts("-------|-------|-------|--------------|--------------|--------") 30 | 31 | Enum.each(stats(board), fn {id, value, tiles_count, border_tiles_count, free_tiles, 32 | value_ratio} -> 33 | [ 34 | String.pad_leading(Integer.to_string(id), 6), 35 | String.pad_leading(Integer.to_string(value), 5), 36 | String.pad_leading(Integer.to_string(tiles_count), 5), 37 | String.pad_leading(Integer.to_string(border_tiles_count), 12), 38 | String.pad_leading(Integer.to_string(free_tiles), 12), 39 | String.pad_leading(Float.to_string(Float.round(value_ratio, 3)), 8) 40 | ] 41 | |> Enum.join(" | ") 42 | |> IO.puts() 43 | end) 44 | 45 | IO.puts("") 46 | end 47 | 48 | defp stats(board) when is_binary(board) do 49 | board 50 | |> Board.new() 51 | |> stats 52 | end 53 | 54 | defp stats(%Board{} = board) do 55 | Enum.map(board.regions, fn {id, region} -> 56 | border_tile_count = 57 | Enum.count(region.tile_ids, fn tile_id -> 58 | neighbor_ids = board.tiles[tile_id].neighbors 59 | neighbor_ids -- region.tile_ids != [] 60 | end) 61 | 62 | value_ratio = region.value / border_tile_count 63 | free_tiles = length(region.tile_ids) - border_tile_count 64 | 65 | {id, region.value, length(region.tile_ids), border_tile_count, free_tiles, value_ratio} 66 | end) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/mix/tasks/ai/arena.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Ai.Arena do 2 | @moduledoc """ 3 | For playing AIs against each other. 4 | 5 | mix ai.arena 6 | """ 7 | 8 | use Mix.Task 9 | 10 | alias Sengoku.{Board, GameServer} 11 | 12 | @games_to_play 2_000 13 | @max_turns 1_000 14 | @game_opts %{"board" => "westeros"} 15 | @default_ai Sengoku.AI.Smart 16 | @custom_ai_player_number 1 17 | 18 | def run([ai_name]) do 19 | Registry.start_link(keys: :unique, name: :game_server_registry) 20 | ai_module = String.to_existing_atom("Elixir.#{ai_name}") 21 | 22 | IO.puts(""" 23 | Starting #{@games_to_play} games with #{inspect(ai_module)} as Player 1 24 | against #{inspect(@default_ai)} as all other players 25 | """) 26 | 27 | @games_to_play 28 | |> start_n_games(ai_module) 29 | |> tally_winners() 30 | |> print_results(ai_module) 31 | end 32 | 33 | defp start_n_games(num, ai_module) when is_integer(num) do 34 | Enum.map(1..num, fn _ -> 35 | {:ok, game_id} = GameServer.new(@game_opts, true) 36 | GameServer.update_ai_player(game_id, @custom_ai_player_number, ai_module) 37 | GameServer.action(game_id, nil, %{type: "start_game"}) 38 | game_id 39 | end) 40 | end 41 | 42 | defp tally_winners(game_ids) when is_list(game_ids) do 43 | Enum.reduce(game_ids, %{}, &tally_winner/2) 44 | end 45 | 46 | defp tally_winner(game_id, results) do 47 | game_state = GameServer.get_state(game_id) 48 | winning_player = game_state.winning_player 49 | 50 | if is_nil(winning_player) && game_state.turn < @max_turns do 51 | Process.sleep(1_000) 52 | tally_winner(game_id, results) 53 | else 54 | Map.update(results, winning_player, 1, &(&1 + 1)) 55 | end 56 | end 57 | 58 | defp print_results(results, ai_module) do 59 | IO.puts(" Player | Win % ") 60 | IO.puts("--------------------------------|-------") 61 | players_count = Board.new(@game_opts["board"]).players_count 62 | 63 | Enum.each(1..players_count, fn player_id -> 64 | win_count = results[player_id] || 0 65 | win_percent = win_count / @games_to_play * 100 66 | 67 | player = 68 | case player_id do 69 | @custom_ai_player_number -> "#{player_id} (#{inspect(ai_module)})" 70 | id when is_integer(id) -> "#{id} (#{inspect(@default_ai)})" 71 | nil -> "Draw" 72 | end 73 | 74 | IO.puts( 75 | " #{String.pad_trailing(player, 30)} | #{ 76 | String.pad_leading(Float.to_string(Float.round(win_percent, 1)), 5) 77 | }%" 78 | ) 79 | end) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, we often load configuration from external 4 | # sources, such as your system environment. For this reason, 5 | # you won't find the :http configuration below, but set inside 6 | # SengokuWeb.Endpoint.init/2 when load_from_system_env is 7 | # true. Any dynamic configuration should be done there. 8 | # 9 | # Don't forget to configure the url host to something meaningful, 10 | # Phoenix uses this information when generating URLs. 11 | # 12 | # Finally, we also include the path to a cache manifest 13 | # containing the digested version of static files. This 14 | # manifest is generated by the mix phx.digest task 15 | # which you typically run after static files are built. 16 | config :sengoku, SengokuWeb.Endpoint, 17 | url: [host: "www.playsengoku.com", port: 443], 18 | cache_static_manifest: "priv/static/cache_manifest.json", 19 | check_origin: ["//www.playsengoku.com"], 20 | force_ssl: [rewrite_on: [:x_forwarded_proto]] 21 | 22 | # Do not print debug messages in production 23 | config :logger, level: :info 24 | 25 | # ## SSL Support 26 | # 27 | # To get SSL working, you will need to add the `https` key 28 | # to the previous section and set your `:url` port to 443: 29 | # 30 | # config :sengoku, SengokuWeb.Endpoint, 31 | # ... 32 | # url: [host: "example.com", port: 443], 33 | # https: [:inet6, 34 | # port: 443, 35 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 36 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 37 | # 38 | # Where those two env variables return an absolute path to 39 | # the key and cert in disk or a relative path inside priv, 40 | # for example "priv/ssl/server.key". 41 | # 42 | # We also recommend setting `force_ssl`, ensuring no data is 43 | # ever sent via http, always redirecting to https: 44 | # 45 | # config :sengoku, SengokuWeb.Endpoint, 46 | # force_ssl: [hsts: true] 47 | # 48 | # Check `Plug.SSL` for all available options in `force_ssl`. 49 | 50 | # ## Using releases 51 | # 52 | # If you are doing OTP releases, you need to instruct Phoenix 53 | # to start the server for all endpoints: 54 | # 55 | # config :phoenix, :serve_endpoints, true 56 | # 57 | # Alternatively, you can configure exactly which server to 58 | # start per endpoint: 59 | # 60 | # config :sengoku, SengokuWeb.Endpoint, server: true 61 | # 62 | 63 | config :sengoku, Sengoku.Mailer, 64 | adapter: Bamboo.SendGridAdapter, 65 | api_key: {:system, "SENDGRID_API_KEY"}, 66 | hackney_opts: [ 67 | recv_timeout: :timer.minutes(1) 68 | ] 69 | 70 | config :sengoku, Sengoku.Repo, 71 | adapter: Ecto.Adapters.Postgres, 72 | url: {:system, "DATABASE_URL"}, 73 | ssl: true, 74 | pool_size: 2 75 | -------------------------------------------------------------------------------- /lib/sengoku_web.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use SengokuWeb, :controller 9 | use SengokuWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: SengokuWeb 23 | 24 | import Plug.Conn 25 | import SengokuWeb.Gettext 26 | alias SengokuWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/sengoku_web/templates", 34 | namespace: SengokuWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1] 38 | 39 | # Include shared imports and aliases for views 40 | unquote(view_helpers()) 41 | end 42 | end 43 | 44 | def live_view do 45 | quote do 46 | use Phoenix.LiveView, 47 | layout: {SengokuWeb.LayoutView, "live.html"} 48 | 49 | unquote(view_helpers()) 50 | end 51 | end 52 | 53 | def live_component do 54 | quote do 55 | use Phoenix.LiveComponent 56 | 57 | unquote(view_helpers()) 58 | end 59 | end 60 | 61 | def router do 62 | quote do 63 | use Phoenix.Router 64 | 65 | import Plug.Conn 66 | import Phoenix.Controller 67 | import Phoenix.LiveView.Router 68 | end 69 | end 70 | 71 | def channel do 72 | quote do 73 | use Phoenix.Channel 74 | import SengokuWeb.Gettext 75 | end 76 | end 77 | 78 | defp view_helpers do 79 | quote do 80 | # Use all HTML functionality (forms, tags, etc) 81 | use Phoenix.HTML 82 | 83 | # Import LiveView helpers (live_render, live_component, live_patch, etc) 84 | import Phoenix.LiveView.Helpers 85 | 86 | # Import basic rendering functionality (render, render_layout, etc) 87 | import Phoenix.View 88 | 89 | import SengokuWeb.ErrorHelpers 90 | import SengokuWeb.Gettext 91 | alias SengokuWeb.Router.Helpers, as: Routes 92 | end 93 | end 94 | 95 | @doc """ 96 | When used, dispatch to the appropriate controller/view/etc. 97 | """ 98 | defmacro __using__(which) when is_atom(which) do 99 | apply(__MODULE__, which, []) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | jobs: 4 | verify: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | otp: [22.3] 9 | elixir: [1.10.2] 10 | 11 | services: 12 | db: 13 | image: postgres:12 14 | env: 15 | POSTGRES_USER: postgres 16 | POSTGRES_PASSWORD: postgres 17 | POSTGRES_DB: sengoku_test 18 | ports: ['5432:5432'] 19 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: actions/setup-elixir@v1 24 | with: 25 | otp-version: ${{ matrix.otp }} 26 | elixir-version: ${{ matrix.elixir }} 27 | 28 | - name: Setup Node 29 | uses: actions/setup-node@v1 30 | with: 31 | node-version: 14.0.0 32 | 33 | - uses: actions/cache@v1 34 | id: deps-cache 35 | with: 36 | path: deps 37 | key: ${{ runner.os }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 38 | 39 | - uses: actions/cache@v1 40 | id: build-cache 41 | with: 42 | path: _build 43 | key: ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 44 | 45 | - name: Find yarn cache location 46 | id: yarn-cache 47 | run: echo "::set-output name=dir::$(yarn cache dir)" 48 | 49 | - uses: actions/cache@v1 50 | with: 51 | path: ${{ steps.yarn-cache.outputs.dir }} 52 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 53 | restore-keys: | 54 | ${{ runner.os }}-yarn- 55 | 56 | - name: Install deps 57 | run: | 58 | mix deps.get 59 | (cd assets && yarn) 60 | 61 | - run: mix format --check-formatted 62 | - run: mix test 63 | 64 | deploy: 65 | # only run this job if the verify job succeeds 66 | needs: verify 67 | 68 | # only run this job if the workflow is running on the master branch 69 | if: github.ref == 'refs/heads/master' 70 | 71 | runs-on: ubuntu-latest 72 | 73 | steps: 74 | - uses: actions/checkout@v2 75 | 76 | # actions/checkout@v2 only checks out the latest commit, 77 | # so we need to tell it to check out the entire master branch 78 | with: 79 | ref: master 80 | fetch-depth: 0 81 | 82 | # configure the gigalixir-actions with our credentials and app name 83 | - uses: mhanberg/gigalixir-action@v0.1.0 84 | with: 85 | GIGALIXIR_USERNAME: ${{ secrets.GIGALIXIR_USERNAME }} 86 | GIGALIXIR_PASSWORD: ${{ secrets.GIGALIXIR_PASSWORD }} 87 | GIGALIXIR_APP: sengoku-prod 88 | SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} 89 | -------------------------------------------------------------------------------- /lib/sengoku_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.Router do 2 | use SengokuWeb, :router 3 | 4 | import SengokuWeb.UserAuth 5 | 6 | import Plug.BasicAuth 7 | import Phoenix.LiveDashboard.Router 8 | 9 | pipeline :browser do 10 | plug :accepts, ["html"] 11 | plug :fetch_session 12 | plug :fetch_live_flash 13 | plug :put_root_layout, {SengokuWeb.LayoutView, :root} 14 | plug :protect_from_forgery 15 | plug :put_secure_browser_headers 16 | plug :fetch_current_user 17 | plug SengokuWeb.Plugs.PutPlayerID 18 | end 19 | 20 | pipeline :admins_only do 21 | if Mix.env() == :prod do 22 | creds = 23 | System.get_env("ADMIN_CREDS") || raise("You must set ADMIN_CREDS in the environment") 24 | 25 | [username, password] = String.split(creds, ":") 26 | plug :basic_auth, username: username, password: password 27 | end 28 | end 29 | 30 | scope "/", SengokuWeb do 31 | pipe_through :browser 32 | 33 | # Accounts 34 | delete "/users/logout", UserSessionController, :delete 35 | get "/users/confirm", UserConfirmationController, :new 36 | post "/users/confirm", UserConfirmationController, :create 37 | get "/users/confirm/:token", UserConfirmationController, :confirm 38 | 39 | # Games 40 | get "/", GameController, :new 41 | post "/games", GameController, :create 42 | live "/games/:game_id", GameLive, layout: {SengokuWeb.LayoutView, :game} 43 | live "/builder", BoardBuilderLive, layout: {SengokuWeb.LayoutView, :game} 44 | end 45 | 46 | scope "/" do 47 | pipe_through [:browser, :admins_only] 48 | 49 | live_dashboard "/dashboard", metrics: SengokuWeb.Telemetry 50 | end 51 | 52 | ## Authentication routes 53 | 54 | scope "/", SengokuWeb do 55 | pipe_through [:browser, :redirect_if_user_is_authenticated] 56 | 57 | get "/users/register", UserRegistrationController, :new 58 | post "/users/register", UserRegistrationController, :create 59 | get "/users/login", UserSessionController, :new 60 | post "/users/login", UserSessionController, :create 61 | get "/users/reset_password", UserResetPasswordController, :new 62 | post "/users/reset_password", UserResetPasswordController, :create 63 | get "/users/reset_password/:token", UserResetPasswordController, :edit 64 | put "/users/reset_password/:token", UserResetPasswordController, :update 65 | end 66 | 67 | scope "/", SengokuWeb do 68 | pipe_through [:browser, :require_authenticated_user] 69 | 70 | get "/users/settings", UserSettingsController, :edit 71 | put "/users/settings/update_password", UserSettingsController, :update_password 72 | put "/users/settings/update_email", UserSettingsController, :update_email 73 | get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email 74 | end 75 | 76 | if Mix.env() == :dev do 77 | forward "/sent_emails", Bamboo.SentEmailViewerPlug 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/sengoku_web/templates/layout/game.html.leex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <%= live_title_tag assigns[:page_title] || "Play!", suffix: " · Sengoku" %> 9 | 10 | "/> 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | <%= @inner_content %> 27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /lib/sengoku_web/live/board_builder_live.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.BoardBuilderLive do 2 | use SengokuWeb, :live_view 3 | 4 | @impl Phoenix.LiveView 5 | def mount(_params, _session, socket) do 6 | {:ok, 7 | assign(socket, 8 | tiles: build_tiles(), 9 | regions: 1..8, 10 | current_region: 1 11 | )} 12 | end 13 | 14 | @impl Phoenix.LiveView 15 | def render(assigns) do 16 | ~L""" 17 |
18 |
19 |

20 | 21 | " alt="Sengoku" /> 22 | 23 |

24 | 25 |

Board Builder

26 |

Select a region below, then click a tile at right to add it to the board in the selected region.

27 |
    28 | <%= for region <- @regions do %> 29 |
  1. 34 | " 35 | phx-click="select_region" 36 | phx-value-region_id="<%= region %>" 37 | > 38 | 39 | 40 | 41 | <%= region %> 42 |
  2. 43 | <% end %> 44 |
45 |
46 | 47 |
48 | 66 |
67 |
68 | """ 69 | end 70 | 71 | @impl Phoenix.LiveView 72 | def handle_event("toggle_tile", %{"tile_id" => tile_id_string}, socket) do 73 | tile_id = String.to_integer(tile_id_string) 74 | current_region = socket.assigns.current_region 75 | 76 | new_tiles = 77 | socket.assigns.tiles 78 | |> Map.update!(tile_id, fn tile_region -> 79 | case tile_region do 80 | ^current_region -> 81 | nil 82 | 83 | _ -> 84 | socket.assigns.current_region 85 | end 86 | end) 87 | 88 | {:noreply, assign(socket, tiles: new_tiles)} 89 | end 90 | 91 | @impl Phoenix.LiveView 92 | def handle_event("select_region", %{"region_id" => region_id_string}, socket) do 93 | region_id = String.to_integer(region_id_string) 94 | 95 | {:noreply, assign(socket, current_region: region_id)} 96 | end 97 | 98 | defp build_tiles do 99 | 1..85 100 | |> Enum.to_list() 101 | |> Enum.map(fn id -> 102 | {id, nil} 103 | end) 104 | |> Enum.into(%{}) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /test/sengoku_web/controllers/user_confirmation_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserConfirmationControllerTest do 2 | use SengokuWeb.ConnCase, async: true 3 | 4 | alias Sengoku.Accounts 5 | alias Sengoku.Repo 6 | import Sengoku.AccountsFixtures 7 | 8 | setup do 9 | %{user: user_fixture()} 10 | end 11 | 12 | describe "GET /users/confirm" do 13 | test "renders the confirmation page", %{conn: conn} do 14 | conn = get(conn, Routes.user_confirmation_path(conn, :new)) 15 | response = html_response(conn, 200) 16 | assert response =~ "Resend confirmation instructions" 17 | end 18 | end 19 | 20 | describe "POST /users/confirm" do 21 | @tag :capture_log 22 | test "sends a new confirmation token", %{conn: conn, user: user} do 23 | conn = 24 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 25 | "user" => %{"email" => user.email} 26 | }) 27 | 28 | assert redirected_to(conn) == "/" 29 | assert get_flash(conn, :info) =~ "If your e-mail is in our system" 30 | assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" 31 | end 32 | 33 | test "does not send confirmation token if account is confirmed", %{conn: conn, user: user} do 34 | Repo.update!(Accounts.User.confirm_changeset(user)) 35 | 36 | conn = 37 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 38 | "user" => %{"email" => user.email} 39 | }) 40 | 41 | assert redirected_to(conn) == "/" 42 | assert get_flash(conn, :info) =~ "If your e-mail is in our system" 43 | refute Repo.get_by(Accounts.UserToken, user_id: user.id) 44 | end 45 | 46 | test "does not send confirmation token if email is invalid", %{conn: conn} do 47 | conn = 48 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 49 | "user" => %{"email" => "unknown@example.com"} 50 | }) 51 | 52 | assert redirected_to(conn) == "/" 53 | assert get_flash(conn, :info) =~ "If your e-mail is in our system" 54 | assert Repo.all(Accounts.UserToken) == [] 55 | end 56 | end 57 | 58 | describe "GET /users/confirm/:token" do 59 | test "confirms the given token once", %{conn: conn, user: user} do 60 | token = 61 | extract_user_token(fn url -> 62 | Accounts.deliver_user_confirmation_instructions(user, url) 63 | end) 64 | 65 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token)) 66 | assert redirected_to(conn) == "/" 67 | assert get_flash(conn, :info) =~ "Account confirmed successfully" 68 | assert Accounts.get_user!(user.id).confirmed_at 69 | refute get_session(conn, :user_token) 70 | assert Repo.all(Accounts.UserToken) == [] 71 | 72 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token)) 73 | assert redirected_to(conn) == "/" 74 | assert get_flash(conn, :error) =~ "Confirmation link is invalid or it has expired" 75 | end 76 | 77 | test "does not confirm email with invalid token", %{conn: conn, user: user} do 78 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, "oops")) 79 | assert redirected_to(conn) == "/" 80 | assert get_flash(conn, :error) =~ "Confirmation link is invalid or it has expired" 81 | refute Accounts.get_user!(user.id).confirmed_at 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sengoku 2 | 3 | Unite feudal Japan in this Risk-like strategy game! (This is extremely alpha and in active development.) 4 | 5 | ![a screenshot of the game map](https://github.com/stevegrossi/sengoku/raw/master/screenshot.png) 6 | 7 | ## Gameplay 8 | 9 | Up to 8 players: play against friends online, the computer, or both. 10 | 11 | - Provinces are randomly divided amongst all players at the start of the game. 12 | - Each player receives one unit for every 3 provinces they hold (with a minimum of 3) at the start of each turn. 13 | - Receive bonus units for holding all provinces within a marked region. 14 | - On your turn you may attack neighboring provinces (see below). 15 | - At the end of your turn, you may move units from one of your provinces to one of its neighbors you control. 16 | - A player is defeated when they no longer control any provinces. 17 | - A player wins when all other players are defeated. 18 | 19 | ### Rules for Battle 20 | 21 | - Up to 3 units will attack at once, and up to 2 units will defend. A six-sided die is rolled for each. 22 | - Rolls are sorted from highest to lowest and paired off: e.g. the highest attacker roll with the highest defender roll. Defending units win ties. The losing unit is removed from play. 23 | - If all defending units are removed, the attacker takes control of that province and their number of attacking units moves in. 24 | 25 | ## Development 26 | 27 | To start your Phoenix server: 28 | 29 | * Setup the project with `mix setup` 30 | * Start Phoenix endpoint with `mix phx.server` 31 | 32 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser to play! 33 | 34 | ### AI Development 35 | 36 | Want to improve the AI? Great! Computer players implement the `Sengoku.AI` behaviour. The current AI is the `Sengoku.AI.Smart` module. 37 | 38 | 1. Copy the current AI module and its tests. From the command line: 39 | 40 | ``` 41 | cp lib/sengoku/ai/smart.ex lib/sengoku/ai/smarter.ex 42 | cp test/sengoku/ai/smart_test.exs test/sengoku/ai/smarter_test.exs 43 | ``` 44 | 45 | 2. I’d like to automate this, but with `lib/sengoku/ai/smarter.ex` manually rename `Sengoku.AI.Smart` to `Sengoku.AI.Smarter`, and within `test/sengoku/ai/smarter_test.exs` rename `Sengoku.AI.SmartTest` to `Sengoku.AI.SmarterTest`. 46 | 3. Run `mix test` to ensure everything’s working. 47 | 4. Run `mix ai.arena Sengoku.AI.Smarter` with your new AI module: 48 | 49 | ``` 50 | Starting 2000 games with Sengoku.AI.Smarter as Player 1 51 | against Sengoku.AI.Smart as all other players 52 | 53 | Player | Win % 54 | --------------------------------|------- 55 | 1 (Sengoku.AI.Smarter) | 11.9% 56 | 2 (Sengoku.AI.Smart) | 11.8% 57 | 3 (Sengoku.AI.Smart) | 13.6% 58 | 4 (Sengoku.AI.Smart) | 13.7% 59 | 5 (Sengoku.AI.Smart) | 12.4% 60 | 6 (Sengoku.AI.Smart) | 12.3% 61 | 7 (Sengoku.AI.Smart) | 11.5% 62 | 8 (Sengoku.AI.Smart) | 12.8% 63 | ``` 64 | 65 | (With `mix ai.arena`, at least on the default `"westeros"` map, win percentages should be even within a few percentage points.) 66 | 67 | 5. Now change how `Sengoku.AI.Smarter` works! Continue running `mix ai.arena Sengoku.AI.Smarter` to test the impact of your changes against the default AI. 68 | 6. When you’ve made an improvement, merge your changes back into `Sengoku.AI.Smart` and consider opening a Pull Request with the improvements! 69 | -------------------------------------------------------------------------------- /test/sengoku/ai/random_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.AI.RandomTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Sengoku.{AI, Player, Tile} 5 | 6 | test "places a unit when unplaced units" do 7 | state = %{ 8 | current_player_number: 1, 9 | players: %{ 10 | 1 => %Player{unplaced_units: 1, active: true, ai: true}, 11 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 12 | }, 13 | tiles: %{ 14 | 1 => %Tile{owner: 1, units: 1, neighbors: [2]}, 15 | 2 => %Tile{owner: nil, units: 1, neighbors: [1]} 16 | }, 17 | pending_move: nil 18 | } 19 | 20 | action = AI.Random.take_action(state) 21 | 22 | assert action == %{type: "place_unit", tile_id: 1} 23 | end 24 | 25 | test "attacks when a neighbor has fewer units" do 26 | state = %{ 27 | current_player_number: 1, 28 | players: %{ 29 | 1 => %Player{unplaced_units: 0, active: true, ai: true}, 30 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 31 | }, 32 | tiles: %{ 33 | 1 => %Tile{owner: 1, units: 2, neighbors: [2]}, 34 | 2 => %Tile{owner: nil, units: 1, neighbors: [1]} 35 | }, 36 | pending_move: nil 37 | } 38 | 39 | action = AI.Random.take_action(state) 40 | 41 | assert action == %{type: "attack", from_id: 1, to_id: 2} 42 | end 43 | 44 | test "moves the maximum number of units away from non-border tiles" do 45 | state = %{ 46 | current_player_number: 1, 47 | players: %{ 48 | 1 => %Player{unplaced_units: 0, active: true, ai: true}, 49 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 50 | }, 51 | tiles: %{ 52 | 1 => %Tile{owner: 1, units: 7, neighbors: [2]}, 53 | 2 => %Tile{owner: 1, units: 1, neighbors: [1, 3]}, 54 | 3 => %Tile{owner: 2, units: 1, neighbors: [2]} 55 | }, 56 | pending_move: nil 57 | } 58 | 59 | action = AI.Random.take_action(state) 60 | 61 | assert %{ 62 | type: "move", 63 | from_id: _from_id, 64 | to_id: _to_id, 65 | count: _count 66 | } = action 67 | end 68 | 69 | test "makes a required move when necessary" do 70 | state = %{ 71 | current_player_number: 1, 72 | players: %{ 73 | 1 => %Player{unplaced_units: 0, active: true, ai: true}, 74 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 75 | }, 76 | tiles: %{ 77 | 1 => %Tile{owner: 1, units: 0, neighbors: [2]}, 78 | 2 => %Tile{owner: 1, units: 5, neighbors: [1, 3]}, 79 | 3 => %Tile{owner: 2, units: 1, neighbors: [2]} 80 | }, 81 | pending_move: %{ 82 | from_id: 2, 83 | to_id: 1, 84 | min: 3, 85 | max: 4 86 | } 87 | } 88 | 89 | action = AI.Random.take_action(state) 90 | 91 | assert %{ 92 | type: "move", 93 | from_id: 2, 94 | to_id: 1, 95 | count: _count 96 | } = action 97 | end 98 | 99 | test "ends turn when no other action" do 100 | state = %{ 101 | current_player_number: 1, 102 | players: %{ 103 | 1 => %Player{unplaced_units: 0, active: true, ai: true}, 104 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 105 | }, 106 | tiles: %{ 107 | 1 => %Tile{owner: 1, units: 1, neighbors: [2]}, 108 | 2 => %Tile{owner: nil, units: 1, neighbors: [1]} 109 | }, 110 | pending_move: nil 111 | } 112 | 113 | action = AI.Random.take_action(state) 114 | 115 | assert action == %{type: "end_turn"} 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/sengoku_web/controllers/user_session_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserSessionControllerTest do 2 | use SengokuWeb.ConnCase, async: true 3 | 4 | import Sengoku.AccountsFixtures 5 | 6 | setup do 7 | %{user: user_fixture()} 8 | end 9 | 10 | describe "GET /users/login" do 11 | test "renders login page", %{conn: conn} do 12 | conn = get(conn, Routes.user_session_path(conn, :new)) 13 | response = html_response(conn, 200) 14 | assert response =~ "Sign In" 15 | assert response =~ "Sign In" 16 | assert response =~ "Register" 17 | end 18 | 19 | test "redirects if already logged in", %{conn: conn, user: user} do 20 | conn = conn |> login_user(user) |> get(Routes.user_session_path(conn, :new)) 21 | assert redirected_to(conn) == "/" 22 | end 23 | end 24 | 25 | describe "POST /users/login" do 26 | test "logs the user in with an email", %{conn: conn, user: user} do 27 | conn = 28 | post(conn, Routes.user_session_path(conn, :create), %{ 29 | "user" => %{"email_or_username" => user.email, "password" => valid_user_password()} 30 | }) 31 | 32 | assert get_session(conn, :user_token) 33 | assert redirected_to(conn) =~ "/" 34 | 35 | # Now do a logged in request and assert on the menu 36 | conn = get(conn, "/") 37 | response = html_response(conn, 200) 38 | assert response =~ "Hi, #{user.username}" 39 | assert response =~ "Settings" 40 | assert response =~ "Sign Out" 41 | end 42 | 43 | test "logs the user in with a username", %{conn: conn, user: user} do 44 | conn = 45 | post(conn, Routes.user_session_path(conn, :create), %{ 46 | "user" => %{"email_or_username" => user.username, "password" => valid_user_password()} 47 | }) 48 | 49 | assert get_session(conn, :user_token) 50 | assert redirected_to(conn) =~ "/" 51 | 52 | # Now do a logged in request and assert on the menu 53 | conn = get(conn, "/") 54 | response = html_response(conn, 200) 55 | assert response =~ "Hi, #{user.username}" 56 | assert response =~ "Settings" 57 | assert response =~ "Sign Out" 58 | end 59 | 60 | test "logs the user in with remember me", %{conn: conn, user: user} do 61 | conn = 62 | post(conn, Routes.user_session_path(conn, :create), %{ 63 | "user" => %{ 64 | "email_or_username" => user.email, 65 | "password" => valid_user_password(), 66 | "remember_me" => "true" 67 | } 68 | }) 69 | 70 | assert conn.resp_cookies["user_remember_me"] 71 | assert redirected_to(conn) =~ "/" 72 | end 73 | 74 | test "emits error message with invalid credentials", %{conn: conn, user: user} do 75 | conn = 76 | post(conn, Routes.user_session_path(conn, :create), %{ 77 | "user" => %{"email_or_username" => user.email, "password" => "invalid_password"} 78 | }) 79 | 80 | response = html_response(conn, 200) 81 | assert response =~ "Sign In" 82 | assert response =~ "Invalid e-mail or password" 83 | end 84 | end 85 | 86 | describe "DELETE /users/logout" do 87 | test "logs the user out", %{conn: conn, user: user} do 88 | conn = conn |> login_user(user) |> delete(Routes.user_session_path(conn, :delete)) 89 | assert redirected_to(conn) == "/" 90 | refute get_session(conn, :user_token) 91 | assert get_flash(conn, :info) =~ "Logged out successfully" 92 | end 93 | 94 | test "succeeds even if the user is not logged in", %{conn: conn} do 95 | conn = delete(conn, Routes.user_session_path(conn, :delete)) 96 | assert redirected_to(conn) == "/" 97 | refute get_session(conn, :user_token) 98 | assert get_flash(conn, :info) =~ "Logged out successfully" 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /assets/static/images/waves.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /lib/sengoku/game_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.GameServer do 2 | @moduledoc """ 3 | A GenServer responsible for maintaining the entire state of a single game, 4 | including dispatching players’ actions to change that state. 5 | """ 6 | 7 | use GenServer 8 | 9 | require Logger 10 | 11 | alias Sengoku.{Authentication, Game, Token} 12 | 13 | @default_ai_wait_time_ms 100 14 | 15 | def new(%{} = options, arena \\ false) do 16 | game_id = Token.new(8) 17 | start_link(game_id, options, arena) 18 | {:ok, game_id} 19 | end 20 | 21 | def start_link(game_id, options, arena) do 22 | GenServer.start_link(__MODULE__, {game_id, options, arena}) 23 | end 24 | 25 | def init({game_id, options, arena}) do 26 | case Registry.register(:game_server_registry, game_id, :ok) do 27 | {:ok, _pid} -> {:ok, game_id} 28 | {:error, reason} -> {:error, reason} 29 | end 30 | 31 | {:ok, Map.put(Game.initialize_state(game_id, options), :arena, arena)} 32 | end 33 | 34 | # API 35 | 36 | def alive?(game_id) do 37 | Registry.lookup(:game_server_registry, game_id) != [] 38 | end 39 | 40 | def authenticate_player(game_id, token, name \\ nil) do 41 | GenServer.call(via_tuple(game_id), {:authenticate_player, token, name}) 42 | end 43 | 44 | def action(game_id, player_id, %{type: _type} = action) do 45 | GenServer.cast(via_tuple(game_id), {:action, player_id, action}) 46 | end 47 | 48 | def get_state(game_id) do 49 | GenServer.call(via_tuple(game_id), :get_state) 50 | end 51 | 52 | def update_ai_player(game_id, player_number, ai_type) do 53 | GenServer.call(via_tuple(game_id), {:update_ai_player, player_number, ai_type}) 54 | end 55 | 56 | # Server 57 | 58 | def handle_call({:authenticate_player, token, name}, _from, state) do 59 | case Authentication.authenticate_player(state, token, name) do 60 | {:ok, {player_id, token}, new_state} -> 61 | state_updated(new_state) 62 | {:reply, {:ok, player_id, token}, new_state} 63 | 64 | {:error, reason} -> 65 | {:reply, {:error, reason}, state} 66 | end 67 | end 68 | 69 | def handle_call(:get_state, _from, state) do 70 | {:reply, state, state} 71 | end 72 | 73 | def handle_call({:update_ai_player, player_number, ai_type}, _from, state) do 74 | new_state = Game.update_ai_player(state, player_number, ai_type) 75 | state_updated(new_state) 76 | 77 | {:reply, new_state, new_state} 78 | end 79 | 80 | def handle_cast({:action, _player_id, %{type: "start_game"}}, state) do 81 | new_state = Game.start_game(state) 82 | state_updated(new_state) 83 | {:noreply, new_state} 84 | end 85 | 86 | def handle_cast({:action, player_id, %{} = action}, state) do 87 | if player_id == state.current_player_number do 88 | new_state = Game.handle_action(state, action) 89 | state_updated(new_state) 90 | {:noreply, new_state} 91 | else 92 | if is_nil(player_id) do 93 | Logger.info("You’re not even playing.") 94 | else 95 | Logger.info("It’s not your turn, player " <> Integer.to_string(player_id)) 96 | end 97 | 98 | {:noreply, state} 99 | end 100 | end 101 | 102 | def handle_info(:take_ai_move_if_necessary, state) do 103 | if Game.current_player(state) && Game.current_player(state).ai && !state.winning_player do 104 | unless state.arena, do: Process.sleep(ai_wait_time()) 105 | action = Game.current_player(state).ai.take_action(state) 106 | action(state.id, state.current_player_number, action) 107 | end 108 | 109 | {:noreply, state} 110 | end 111 | 112 | defp ai_wait_time do 113 | case System.get_env("AI_WAIT_TIME_MS") do 114 | nil -> @default_ai_wait_time_ms 115 | string -> String.to_integer(string) 116 | end 117 | end 118 | 119 | defp state_updated(state) do 120 | send(self(), :take_ai_move_if_necessary) 121 | 122 | unless state.arena do 123 | Phoenix.PubSub.broadcast(Sengoku.PubSub, "game:" <> state.id, {:game_updated, state}) 124 | end 125 | end 126 | 127 | defp via_tuple(game_id) do 128 | {:via, Registry, {:game_server_registry, game_id}} 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/sengoku/ai/random.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.AI.Random do 2 | @moduledoc """ 3 | An AI that chooses moves more-or-less randomly. 4 | A baseline against which to test other AIs. 5 | """ 6 | 7 | @behaviour Sengoku.AI 8 | 9 | alias Sengoku.{Game, Tile} 10 | 11 | def take_action(state) do 12 | cond do 13 | has_unplaced_units?(state) -> place_unit(state) 14 | has_pending_move?(state) -> make_pending_move(state) 15 | has_attackable_neighbor?(state) -> attack(state) 16 | can_move?(state) -> move(state) 17 | true -> end_turn() 18 | end 19 | end 20 | 21 | defp has_unplaced_units?(state) do 22 | Game.current_player(state).unplaced_units > 0 23 | end 24 | 25 | defp place_unit(state) do 26 | tile_id = 27 | state 28 | |> owned_tile_ids 29 | |> Enum.random() 30 | 31 | %{type: "place_unit", tile_id: tile_id} 32 | end 33 | 34 | defp has_pending_move?(state) do 35 | not is_nil(state.pending_move) 36 | end 37 | 38 | defp make_pending_move(%{pending_move: pending_move}) do 39 | %{ 40 | type: "move", 41 | from_id: pending_move.from_id, 42 | to_id: pending_move.to_id, 43 | count: Enum.random(pending_move.min..pending_move.max) 44 | } 45 | end 46 | 47 | defp has_attackable_neighbor?(state) do 48 | state 49 | |> tile_ids_with_attackable_neighbors 50 | |> length > 0 51 | end 52 | 53 | defp attack(state) do 54 | tile_with_attackable_neighbor_id = 55 | state 56 | |> tile_ids_with_attackable_neighbors 57 | |> Enum.random() 58 | 59 | attackable_neighbor_id = 60 | tile_with_attackable_neighbor_id 61 | |> find_attackable_neighbor_id(state) 62 | 63 | %{ 64 | type: "attack", 65 | from_id: tile_with_attackable_neighbor_id, 66 | to_id: attackable_neighbor_id 67 | } 68 | end 69 | 70 | defp tile_ids_with_attackable_neighbors(state) do 71 | state 72 | |> Tile.filter_ids(fn tile -> 73 | tile.owner == state.current_player_number && tile.units > 1 && 74 | tile.neighbors 75 | |> Enum.any?(fn neighbor_id -> 76 | neighbor = state.tiles[neighbor_id] 77 | neighbor.owner !== state.current_player_number 78 | end) 79 | end) 80 | end 81 | 82 | defp find_attackable_neighbor_id(tile_id, state) do 83 | state.tiles[tile_id].neighbors 84 | |> Enum.filter(fn neighbor_id -> 85 | neighbor = state.tiles[neighbor_id] 86 | neighbor.owner !== state.current_player_number 87 | end) 88 | |> Enum.random() 89 | end 90 | 91 | defp owned_tile_ids(state) do 92 | state 93 | |> Tile.filter_ids(fn tile -> 94 | tile.owner == state.current_player_number 95 | end) 96 | end 97 | 98 | defp end_turn do 99 | %{type: "end_turn"} 100 | end 101 | 102 | def can_move?(state) do 103 | state 104 | |> tile_ids_with_friendly_neighbors 105 | |> length > 0 106 | end 107 | 108 | def move(state) do 109 | tile_with_friendly_neighbor_id = 110 | state 111 | |> tile_ids_with_friendly_neighbors 112 | |> Enum.random() 113 | 114 | tile_with_friendly_neighbor_units = state.tiles[tile_with_friendly_neighbor_id].units 115 | 116 | friendly_neighbor_id = 117 | tile_with_friendly_neighbor_id 118 | |> find_friendly_neighbor_id(state) 119 | 120 | %{ 121 | type: "move", 122 | from_id: tile_with_friendly_neighbor_id, 123 | to_id: friendly_neighbor_id, 124 | count: tile_with_friendly_neighbor_units 125 | } 126 | end 127 | 128 | defp tile_ids_with_friendly_neighbors(state) do 129 | state 130 | |> Tile.filter_ids(fn tile -> 131 | tile.owner == state.current_player_number && tile.units > 0 && 132 | tile.neighbors 133 | |> Enum.any?(fn neighbor_id -> 134 | neighbor = state.tiles[neighbor_id] 135 | neighbor.owner == state.current_player_number 136 | end) 137 | end) 138 | end 139 | 140 | defp find_friendly_neighbor_id(tile_id, state) do 141 | state.tiles[tile_id].neighbors 142 | |> Enum.filter(fn neighbor_id -> 143 | neighbor = state.tiles[neighbor_id] 144 | neighbor.owner == state.current_player_number 145 | end) 146 | |> Enum.random() 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/sengoku_web/controllers/user_reset_password_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserResetPasswordControllerTest do 2 | use SengokuWeb.ConnCase, async: true 3 | 4 | alias Sengoku.Accounts 5 | alias Sengoku.Repo 6 | import Sengoku.AccountsFixtures 7 | 8 | setup do 9 | %{user: user_fixture()} 10 | end 11 | 12 | describe "GET /users/reset_password" do 13 | test "renders the reset password page", %{conn: conn} do 14 | conn = get(conn, Routes.user_reset_password_path(conn, :new)) 15 | response = html_response(conn, 200) 16 | assert response =~ "Forgot your password?" 17 | end 18 | end 19 | 20 | describe "POST /users/reset_password" do 21 | @tag :capture_log 22 | test "sends a new reset password token", %{conn: conn, user: user} do 23 | conn = 24 | post(conn, Routes.user_reset_password_path(conn, :create), %{ 25 | "user" => %{"email" => user.email} 26 | }) 27 | 28 | assert redirected_to(conn) == "/" 29 | assert get_flash(conn, :info) =~ "If your e-mail is in our system" 30 | assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "reset_password" 31 | end 32 | 33 | test "does not send reset password token if email is invalid", %{conn: conn} do 34 | conn = 35 | post(conn, Routes.user_reset_password_path(conn, :create), %{ 36 | "user" => %{"email" => "unknown@example.com"} 37 | }) 38 | 39 | assert redirected_to(conn) == "/" 40 | assert get_flash(conn, :info) =~ "If your e-mail is in our system" 41 | assert Repo.all(Accounts.UserToken) == [] 42 | end 43 | end 44 | 45 | describe "GET /users/reset_password/:token" do 46 | setup %{user: user} do 47 | token = 48 | extract_user_token(fn url -> 49 | Accounts.deliver_user_reset_password_instructions(user, url) 50 | end) 51 | 52 | %{token: token} 53 | end 54 | 55 | test "renders reset password", %{conn: conn, token: token} do 56 | conn = get(conn, Routes.user_reset_password_path(conn, :edit, token)) 57 | assert html_response(conn, 200) =~ "Reset Your Password" 58 | end 59 | 60 | test "does not render reset password with invalid token", %{conn: conn} do 61 | conn = get(conn, Routes.user_reset_password_path(conn, :edit, "oops")) 62 | assert redirected_to(conn) == "/" 63 | assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" 64 | end 65 | end 66 | 67 | describe "PUT /users/reset_password/:token" do 68 | setup %{user: user} do 69 | token = 70 | extract_user_token(fn url -> 71 | Accounts.deliver_user_reset_password_instructions(user, url) 72 | end) 73 | 74 | %{token: token} 75 | end 76 | 77 | test "resets password once", %{conn: conn, user: user, token: token} do 78 | conn = 79 | put(conn, Routes.user_reset_password_path(conn, :update, token), %{ 80 | "user" => %{ 81 | "password" => "new valid password", 82 | "password_confirmation" => "new valid password" 83 | } 84 | }) 85 | 86 | assert redirected_to(conn) == "/users/login" 87 | refute get_session(conn, :user_token) 88 | assert get_flash(conn, :info) =~ "Password reset successfully" 89 | assert Accounts.get_user_by_email_and_password(user.email, "new valid password") 90 | end 91 | 92 | test "does not reset password on invalid data", %{conn: conn, token: token} do 93 | conn = 94 | put(conn, Routes.user_reset_password_path(conn, :update, token), %{ 95 | "user" => %{ 96 | "password" => "too short", 97 | "password_confirmation" => "does not match" 98 | } 99 | }) 100 | 101 | response = html_response(conn, 200) 102 | assert response =~ "Reset Your Password" 103 | assert response =~ "should be at least 12 character(s)" 104 | assert response =~ "does not match password" 105 | end 106 | 107 | test "does not reset password with invalid token", %{conn: conn} do 108 | conn = put(conn, Routes.user_reset_password_path(conn, :update, "oops")) 109 | assert redirected_to(conn) == "/" 110 | assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/sengoku/accounts/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Accounts.User do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @derive {Inspect, except: [:password]} 6 | schema "users" do 7 | field(:email, :string) 8 | field(:username, :string) 9 | field(:password, :string, virtual: true) 10 | field(:hashed_password, :string) 11 | field(:confirmed_at, :naive_datetime) 12 | 13 | timestamps() 14 | end 15 | 16 | @doc """ 17 | A user changeset for registration. 18 | 19 | It is important to validate the length of both e-mail and password. 20 | Otherwise databases may truncate the e-mail without warnings, which 21 | could lead to unpredictable or insecure behaviour. Long passwords may 22 | also be very expensive to hash for certain algorithms. 23 | """ 24 | def registration_changeset(user, attrs) do 25 | user 26 | |> cast(attrs, [:email, :username, :password]) 27 | |> validate_email() 28 | |> validate_username() 29 | |> validate_password() 30 | end 31 | 32 | defp validate_email(changeset) do 33 | changeset 34 | |> validate_required([:email]) 35 | |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") 36 | |> validate_length(:email, max: 160) 37 | |> unsafe_validate_unique(:email, Sengoku.Repo) 38 | |> unique_constraint(:email) 39 | end 40 | 41 | defp validate_username(changeset) do 42 | changeset 43 | |> validate_required([:username]) 44 | |> validate_format(:username, ~r/\A\w{3,20}\z/, 45 | message: "must be between 3–20 characters without spaces" 46 | ) 47 | |> unsafe_validate_unique(:username, Sengoku.Repo) 48 | |> unique_constraint(:username) 49 | end 50 | 51 | defp validate_password(changeset) do 52 | changeset 53 | |> validate_required([:password]) 54 | |> validate_length(:password, min: 12, max: 80) 55 | # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") 56 | # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") 57 | # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") 58 | |> prepare_changes(&hash_password/1) 59 | end 60 | 61 | defp hash_password(changeset) do 62 | password = get_change(changeset, :password) 63 | 64 | changeset 65 | |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) 66 | |> delete_change(:password) 67 | end 68 | 69 | @doc """ 70 | A user changeset for changing the e-mail. 71 | 72 | It requires the e-mail to change otherwise an error is added. 73 | """ 74 | def email_changeset(user, attrs) do 75 | user 76 | |> cast(attrs, [:email]) 77 | |> validate_email() 78 | |> case do 79 | %{changes: %{email: _}} = changeset -> changeset 80 | %{} = changeset -> add_error(changeset, :email, "did not change") 81 | end 82 | end 83 | 84 | @doc """ 85 | A user changeset for changing the password. 86 | """ 87 | def password_changeset(user, attrs) do 88 | user 89 | |> cast(attrs, [:password]) 90 | |> validate_confirmation(:password, message: "does not match password") 91 | |> validate_password() 92 | end 93 | 94 | @doc """ 95 | Confirms the account by setting `confirmed_at`. 96 | """ 97 | def confirm_changeset(user) do 98 | now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) 99 | change(user, confirmed_at: now) 100 | end 101 | 102 | @doc """ 103 | Verifies the password. 104 | 105 | If there is no user or the user doesn't have a password, we call 106 | `Bcrypt.no_user_verify/0` to avoid timing attacks. 107 | """ 108 | def valid_password?(%Sengoku.Accounts.User{hashed_password: hashed_password}, password) 109 | when is_binary(hashed_password) and byte_size(password) > 0 do 110 | Bcrypt.verify_pass(password, hashed_password) 111 | end 112 | 113 | def valid_password?(_, _) do 114 | Bcrypt.no_user_verify() 115 | false 116 | end 117 | 118 | @doc """ 119 | Validates the current password otherwise adds an error to the changeset. 120 | """ 121 | def validate_current_password(changeset, password) do 122 | if valid_password?(changeset.data, password) do 123 | changeset 124 | else 125 | add_error(changeset, :current_password, "is not valid") 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/sengoku/accounts/user_token.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Accounts.UserToken do 2 | use Ecto.Schema 3 | import Ecto.Query 4 | 5 | @hash_algorithm :sha256 6 | @rand_size 32 7 | 8 | # It is very important to keep the reset password token expiry short, 9 | # since someone with access to the e-mail may take over the account. 10 | @reset_password_validity_in_days 1 11 | @confirm_validity_in_days 7 12 | @change_email_validity_in_days 7 13 | @session_validity_in_days 60 14 | 15 | schema "users_tokens" do 16 | field(:token, :binary) 17 | field(:context, :string) 18 | field(:sent_to, :string) 19 | belongs_to(:user, Sengoku.Accounts.User) 20 | 21 | timestamps(updated_at: false) 22 | end 23 | 24 | @doc """ 25 | Generates a token that will be stored in a signed place, 26 | such as session or cookie. As they are signed, those 27 | tokens do not need to be hashed. 28 | """ 29 | def build_session_token(user) do 30 | token = :crypto.strong_rand_bytes(@rand_size) 31 | {token, %Sengoku.Accounts.UserToken{token: token, context: "session", user_id: user.id}} 32 | end 33 | 34 | @doc """ 35 | Checks if the token is valid and returns its underlying lookup query. 36 | 37 | The query returns the user found by the token. 38 | """ 39 | def verify_session_token_query(token) do 40 | query = 41 | from(token in token_and_context_query(token, "session"), 42 | join: user in assoc(token, :user), 43 | where: token.inserted_at > ago(@session_validity_in_days, "day"), 44 | select: user 45 | ) 46 | 47 | {:ok, query} 48 | end 49 | 50 | @doc """ 51 | Builds a token with a hashed counter part. 52 | 53 | The non-hashed token is sent to the user e-mail while the 54 | hashed part is stored in the database, to avoid reconstruction. 55 | The token is valid for a week as long as users don't change 56 | their email. 57 | """ 58 | def build_email_token(user, context) do 59 | build_hashed_token(user, context, user.email) 60 | end 61 | 62 | defp build_hashed_token(user, context, sent_to) do 63 | token = :crypto.strong_rand_bytes(@rand_size) 64 | hashed_token = :crypto.hash(@hash_algorithm, token) 65 | 66 | {Base.url_encode64(token, padding: false), 67 | %Sengoku.Accounts.UserToken{ 68 | token: hashed_token, 69 | context: context, 70 | sent_to: sent_to, 71 | user_id: user.id 72 | }} 73 | end 74 | 75 | @doc """ 76 | Checks if the token is valid and returns its underlying lookup query. 77 | 78 | The query returns the user found by the token. 79 | """ 80 | def verify_email_token_query(token, context) do 81 | case Base.url_decode64(token, padding: false) do 82 | {:ok, decoded_token} -> 83 | hashed_token = :crypto.hash(@hash_algorithm, decoded_token) 84 | days = days_for_context(context) 85 | 86 | query = 87 | from(token in token_and_context_query(hashed_token, context), 88 | join: user in assoc(token, :user), 89 | where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, 90 | select: user 91 | ) 92 | 93 | {:ok, query} 94 | 95 | :error -> 96 | :error 97 | end 98 | end 99 | 100 | defp days_for_context("confirm"), do: @confirm_validity_in_days 101 | defp days_for_context("reset_password"), do: @reset_password_validity_in_days 102 | 103 | @doc """ 104 | Checks if the token is valid and returns its underlying lookup query. 105 | 106 | The query returns the user token record. 107 | """ 108 | def verify_change_email_token_query(token, context) do 109 | case Base.url_decode64(token, padding: false) do 110 | {:ok, decoded_token} -> 111 | hashed_token = :crypto.hash(@hash_algorithm, decoded_token) 112 | 113 | query = 114 | from(token in token_and_context_query(hashed_token, context), 115 | where: token.inserted_at > ago(@change_email_validity_in_days, "day") 116 | ) 117 | 118 | {:ok, query} 119 | 120 | :error -> 121 | :error 122 | end 123 | end 124 | 125 | @doc """ 126 | Returns the given token with the given context. 127 | """ 128 | def token_and_context_query(token, context) do 129 | from(Sengoku.Accounts.UserToken, where: [token: ^token, context: ^context]) 130 | end 131 | 132 | @doc """ 133 | Gets all tokens for the given user for the given contexts. 134 | """ 135 | def user_and_contexts_query(user, :all) do 136 | from(t in Sengoku.Accounts.UserToken, where: t.user_id == ^user.id) 137 | end 138 | 139 | def user_and_contexts_query(user, [_ | _] = contexts) do 140 | from(t in Sengoku.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts) 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/sengoku_web/controllers/user_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserAuth do 2 | import Plug.Conn 3 | import Phoenix.Controller 4 | 5 | alias Sengoku.Accounts 6 | alias SengokuWeb.Router.Helpers, as: Routes 7 | 8 | # Make the remember me cookie valid for 60 days. 9 | # If you want bump or reduce this value, also change 10 | # the token expiry itself in UserToken. 11 | @max_age 60 * 60 * 24 * 60 12 | @remember_me_cookie "user_remember_me" 13 | @remember_me_options [sign: true, max_age: @max_age] 14 | 15 | @doc """ 16 | Logs the user in. 17 | 18 | It renews the session ID and clears the whole session 19 | to avoid fixation attacks. See the renew_session 20 | function to customize this behaviour. 21 | 22 | It also sets a `:live_socket_id` key in the session, 23 | so LiveView sessions are identified and automatically 24 | disconnected on logout. The line can be safely removed 25 | if you are not using LiveView. 26 | """ 27 | def login_user(conn, user, params \\ %{}) do 28 | token = Accounts.generate_user_session_token(user) 29 | user_return_to = get_session(conn, :user_return_to) 30 | 31 | conn 32 | |> renew_session() 33 | |> put_session(:user_token, token) 34 | |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") 35 | |> maybe_write_remember_me_cookie(token, params) 36 | |> redirect(to: user_return_to || signed_in_path(conn)) 37 | end 38 | 39 | defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do 40 | put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) 41 | end 42 | 43 | defp maybe_write_remember_me_cookie(conn, _token, _params) do 44 | conn 45 | end 46 | 47 | # This function renews the session ID and erases the whole 48 | # session to avoid fixation attacks. If there is any data 49 | # in the session you may want to preserve after login/logout, 50 | # you must explicitly fetch the session data before clearing 51 | # and then immediately set it after clearing, for example: 52 | # 53 | # defp renew_session(conn) do 54 | # preferred_locale = get_session(conn, :preferred_locale) 55 | # 56 | # conn 57 | # |> configure_session(renew: true) 58 | # |> clear_session() 59 | # |> put_session(:preferred_locale, preferred_locale) 60 | # end 61 | # 62 | defp renew_session(conn) do 63 | conn 64 | |> configure_session(renew: true) 65 | |> clear_session() 66 | end 67 | 68 | @doc """ 69 | Logs the user out. 70 | 71 | It clears all session data for safety. See renew_session. 72 | """ 73 | def logout_user(conn) do 74 | user_token = get_session(conn, :user_token) 75 | user_token && Accounts.delete_session_token(user_token) 76 | 77 | if live_socket_id = get_session(conn, :live_socket_id) do 78 | SengokuWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) 79 | end 80 | 81 | conn 82 | |> renew_session() 83 | |> delete_resp_cookie(@remember_me_cookie) 84 | |> redirect(to: "/") 85 | end 86 | 87 | @doc """ 88 | Authenticates the user by looking into the session 89 | and remember me token. 90 | """ 91 | def fetch_current_user(conn, _opts) do 92 | {user_token, conn} = ensure_user_token(conn) 93 | user = user_token && Accounts.get_user_by_session_token(user_token) 94 | assign(conn, :current_user, user) 95 | end 96 | 97 | defp ensure_user_token(conn) do 98 | if user_token = get_session(conn, :user_token) do 99 | {user_token, conn} 100 | else 101 | conn = fetch_cookies(conn, signed: [@remember_me_cookie]) 102 | 103 | if user_token = conn.cookies[@remember_me_cookie] do 104 | {user_token, put_session(conn, :user_token, user_token)} 105 | else 106 | {nil, conn} 107 | end 108 | end 109 | end 110 | 111 | @doc """ 112 | Used for routes that require the user to not be authenticated. 113 | """ 114 | def redirect_if_user_is_authenticated(conn, _opts) do 115 | if conn.assigns[:current_user] do 116 | conn 117 | |> redirect(to: signed_in_path(conn)) 118 | |> halt() 119 | else 120 | conn 121 | end 122 | end 123 | 124 | @doc """ 125 | Used for routes that require the user to be authenticated. 126 | 127 | If you want to enforce the user e-mail is confirmed before 128 | they use the application at all, here would be a good place. 129 | """ 130 | def require_authenticated_user(conn, _opts) do 131 | if conn.assigns[:current_user] do 132 | conn 133 | else 134 | conn 135 | |> put_flash(:error, "You must login to access this page.") 136 | |> maybe_store_return_to() 137 | |> redirect(to: Routes.user_session_path(conn, :new)) 138 | |> halt() 139 | end 140 | end 141 | 142 | defp maybe_store_return_to(%{method: "GET", request_path: request_path} = conn) do 143 | put_session(conn, :user_return_to, request_path) 144 | end 145 | 146 | defp maybe_store_return_to(conn), do: conn 147 | 148 | defp signed_in_path(_conn), do: "/" 149 | end 150 | -------------------------------------------------------------------------------- /test/sengoku_web/live/game_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.GameLiveTest do 2 | use SengokuWeb.ConnCase 3 | import Phoenix.LiveViewTest 4 | @endpoint SengokuWeb.Endpoint 5 | 6 | test "redirects when the GameServer is unavailable", %{conn: conn} do 7 | assert {:error, {:redirect, %{to: "/"}}} = live(conn, "/games/no-game-here") 8 | end 9 | 10 | test "connected mount", %{conn: conn} do 11 | {:ok, game_id} = Sengoku.GameServer.new(%{"board" => "japan"}) 12 | {:ok, _view, html} = live(conn, Routes.live_path(conn, SengokuWeb.GameLive, game_id)) 13 | assert html =~ ~s(
) 14 | end 15 | 16 | @session Plug.Session.init( 17 | store: :cookie, 18 | key: "_app", 19 | encryption_salt: "secret", 20 | signing_salt: "secret", 21 | encrypt: false 22 | ) 23 | 24 | defp setup_session(conn) do 25 | conn 26 | |> Plug.Session.call(@session) 27 | |> fetch_session() 28 | end 29 | 30 | test "joining a game when logged in uses your username", %{conn: conn} do 31 | user = Sengoku.AccountsFixtures.user_fixture(%{username: "tokugawa"}) 32 | {:ok, game_id} = Sengoku.GameServer.new(%{"board" => "japan"}) 33 | 34 | {:ok, view, _html} = 35 | conn 36 | |> setup_session() 37 | |> put_session(:player_id, user.id) 38 | |> live(Routes.live_path(conn, SengokuWeb.GameLive, game_id)) 39 | 40 | refute has_element?(view, ~s([name="player_name"])) 41 | 42 | render_submit(view, :join) 43 | 44 | assert has_element?(view, ".Player.player-bg-1", user.username) 45 | end 46 | 47 | test "joining and playing a game", %{conn: conn} do 48 | {:ok, game_id} = Sengoku.GameServer.new(%{"board" => "japan"}) 49 | {:ok, view, _html} = live(conn, Routes.live_path(conn, SengokuWeb.GameLive, game_id)) 50 | 51 | # Ensure nothing on the board is interactive before the game starts 52 | refute has_element?(view, ~s([phx-click="place_unit"])) 53 | refute has_element?(view, ~s([phx-click="select_tile"])) 54 | refute has_element?(view, ~s([phx-click="start_move"])) 55 | refute has_element?(view, ~s([phx-click="attack"])) 56 | 57 | assert render(view) =~ "You are currently spectating. Join the game or wait and watch." 58 | 59 | render_submit(view, :join, %{"player_name" => "Yojimbo"}) 60 | 61 | assert has_element?(view, ".Player.player-bg-1", "Yojimbo") 62 | 63 | assert render(view) =~ 64 | "You’re in! Share the URL with a friend to invite them, or start the game when ready." 65 | 66 | render_click(element(view, ~s([phx-click="start"]))) 67 | 68 | assert has_element?(view, ".player-bg-1 .Player-unplacedUnits", "3") 69 | assert render(view) =~ "You have 3 units to place" 70 | 71 | view 72 | |> first_matching_tile(~s([phx-click="place_unit"])) 73 | |> render_click 74 | 75 | assert has_element?(view, ".player-bg-1 .Player-unplacedUnits", "2") 76 | assert render(view) =~ "You have 2 units to place" 77 | 78 | view 79 | |> first_matching_tile(~s([phx-click="place_unit"])) 80 | |> render_click 81 | 82 | assert has_element?(view, ".player-bg-1 .Player-unplacedUnits", "1") 83 | assert render(view) =~ "You have 1 units to place" 84 | 85 | view 86 | |> first_matching_tile(~s([phx-click="place_unit"])) 87 | |> render_click 88 | 89 | refute has_element?(view, ".player-bg-1 .Player-unplacedUnits") 90 | assert render(view) =~ "Select one of your territories" 91 | 92 | view 93 | |> first_matching_tile(~s([phx-click="select_tile"])) 94 | |> render_click 95 | 96 | assert has_element?(view, ".Tile--selected") 97 | assert render(view) =~ "Select an adjacent territory" 98 | end 99 | 100 | test "the Region Bonuses module", %{conn: conn} do 101 | {:ok, game_id} = Sengoku.GameServer.new(%{"board" => "japan"}) 102 | {:ok, view, html} = live(conn, Routes.live_path(conn, SengokuWeb.GameLive, game_id)) 103 | 104 | assert html =~ "Region Bonuses" 105 | 106 | game_state = Sengoku.GameServer.get_state(game_id) 107 | game_state = put_in(game_state.tiles[49].owner, 1) 108 | game_state = put_in(game_state.tiles[50].owner, 1) 109 | game_state = put_in(game_state.tiles[58].owner, 1) 110 | game_state = put_in(game_state.tiles[59].owner, 1) 111 | send(view.pid, {:game_updated, game_state}) 112 | 113 | assert has_element?(view, ".region-1.region-ownedby-1") 114 | end 115 | 116 | # element() requires a single match, but because tiles are assigned randomly, 117 | # we sometimes can’t uniquely identify a tile for the active player to 118 | # interact with, so this function returns the result of element() for the 119 | # first tile matching a selector 120 | # 121 | # Ultimately, I should have a predefined, deterministic game state for tests. 122 | # 123 | defp first_matching_tile(view, selector) do 124 | matching_tile_id = 125 | 1..126 126 | |> Enum.to_list() 127 | |> Enum.find(fn tile_id -> 128 | has_element?(view, "#tile_#{tile_id}#{selector}") 129 | end) 130 | 131 | element(view, "#tile_#{matching_tile_id}") 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/sengoku_web/controllers/user_settings_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserSettingsControllerTest do 2 | use SengokuWeb.ConnCase, async: true 3 | 4 | alias Sengoku.Accounts 5 | import Sengoku.AccountsFixtures 6 | 7 | setup :register_and_login_user 8 | 9 | describe "GET /users/settings" do 10 | test "renders settings page", %{conn: conn} do 11 | conn = get(conn, Routes.user_settings_path(conn, :edit)) 12 | response = html_response(conn, 200) 13 | assert response =~ "Settings" 14 | end 15 | 16 | test "redirects if user is not logged in" do 17 | conn = build_conn() 18 | conn = get(conn, Routes.user_settings_path(conn, :edit)) 19 | assert redirected_to(conn) == "/users/login" 20 | end 21 | end 22 | 23 | describe "PUT /users/settings/update_password" do 24 | test "updates the user password and resets tokens", %{conn: conn, user: user} do 25 | new_password_conn = 26 | put(conn, Routes.user_settings_path(conn, :update_password), %{ 27 | "current_password" => valid_user_password(), 28 | "user" => %{ 29 | "password" => "new valid password", 30 | "password_confirmation" => "new valid password" 31 | } 32 | }) 33 | 34 | assert redirected_to(new_password_conn) == "/users/settings" 35 | assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) 36 | assert get_flash(new_password_conn, :info) =~ "Password updated successfully" 37 | assert Accounts.get_user_by_email_and_password(user.email, "new valid password") 38 | end 39 | 40 | test "does not update password on invalid data", %{conn: conn} do 41 | old_password_conn = 42 | put(conn, Routes.user_settings_path(conn, :update_password), %{ 43 | "current_password" => "invalid", 44 | "user" => %{ 45 | "password" => "too short", 46 | "password_confirmation" => "does not match" 47 | } 48 | }) 49 | 50 | response = html_response(old_password_conn, 200) 51 | assert response =~ "Settings" 52 | assert response =~ "should be at least 12 character(s)" 53 | assert response =~ "does not match password" 54 | assert response =~ "is not valid" 55 | 56 | assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token) 57 | end 58 | end 59 | 60 | describe "PUT /users/settings/update_email" do 61 | @tag :capture_log 62 | test "updates the user email", %{conn: conn, user: user} do 63 | conn = 64 | put(conn, Routes.user_settings_path(conn, :update_email), %{ 65 | "current_password" => valid_user_password(), 66 | "user" => %{"email" => unique_user_email()} 67 | }) 68 | 69 | assert redirected_to(conn) == "/users/settings" 70 | assert get_flash(conn, :info) =~ "A link to confirm your e-mail" 71 | assert Accounts.get_user_by_email(user.email) 72 | end 73 | 74 | test "does not update email on invalid data", %{conn: conn} do 75 | conn = 76 | put(conn, Routes.user_settings_path(conn, :update_email), %{ 77 | "current_password" => "invalid", 78 | "user" => %{"email" => "with spaces"} 79 | }) 80 | 81 | response = html_response(conn, 200) 82 | assert response =~ "Settings" 83 | assert response =~ "must have the @ sign and no spaces" 84 | assert response =~ "is not valid" 85 | end 86 | end 87 | 88 | describe "GET /users/settings/confirm_email/:token" do 89 | setup %{user: user} do 90 | email = unique_user_email() 91 | 92 | token = 93 | extract_user_token(fn url -> 94 | Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url) 95 | end) 96 | 97 | %{token: token, email: email} 98 | end 99 | 100 | test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do 101 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 102 | assert redirected_to(conn) == "/users/settings" 103 | assert get_flash(conn, :info) =~ "E-mail changed successfully" 104 | refute Accounts.get_user_by_email(user.email) 105 | assert Accounts.get_user_by_email(email) 106 | 107 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 108 | assert redirected_to(conn) == "/users/settings" 109 | assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" 110 | end 111 | 112 | test "does not update email with invalid token", %{conn: conn, user: user} do 113 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops")) 114 | assert redirected_to(conn) == "/users/settings" 115 | assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" 116 | assert Accounts.get_user_by_email(user.email) 117 | end 118 | 119 | test "redirects if user is not logged in", %{token: token} do 120 | conn = build_conn() 121 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 122 | assert redirected_to(conn) == "/users/login" 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/sengoku/ai/smart.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.AI.Smart do 2 | @moduledoc """ 3 | An AI that chooses moves procedurally, more-or-less as a human would. 4 | """ 5 | 6 | @behaviour Sengoku.AI 7 | 8 | alias Sengoku.{Game, Tile} 9 | 10 | def take_action(state) do 11 | cond do 12 | has_unplaced_units?(state) -> place_unit(state) 13 | has_pending_move?(state) -> make_pending_move(state) 14 | has_attackable_neighbor?(state) -> attack(state) 15 | can_move?(state) -> move(state) 16 | true -> end_turn() 17 | end 18 | end 19 | 20 | defp has_unplaced_units?(state) do 21 | Game.current_player(state).unplaced_units > 0 22 | end 23 | 24 | defp place_unit(state) do 25 | preferred_regions = get_preferred_regions(state) 26 | 27 | tile_id = 28 | state 29 | |> owned_border_tile_ids() 30 | |> sort_tile_ids_by_region_preference(preferred_regions) 31 | |> List.first() 32 | 33 | %{type: "place_unit", tile_id: tile_id} 34 | end 35 | 36 | defp has_pending_move?(state) do 37 | not is_nil(state.pending_move) 38 | end 39 | 40 | defp make_pending_move(%{pending_move: pending_move}) do 41 | %{ 42 | type: "move", 43 | from_id: pending_move.from_id, 44 | to_id: pending_move.to_id, 45 | count: pending_move.max 46 | } 47 | end 48 | 49 | defp has_attackable_neighbor?(state) do 50 | state 51 | |> tile_ids_with_attackable_neighbors() 52 | |> length > 0 53 | end 54 | 55 | defp attack(state) do 56 | preferred_regions = get_preferred_regions(state) 57 | 58 | tile_with_attackable_neighbor_id = 59 | state 60 | |> tile_ids_with_attackable_neighbors() 61 | |> sort_tile_ids_by_region_preference(preferred_regions) 62 | |> List.first() 63 | 64 | attackable_neighbor_id = 65 | tile_with_attackable_neighbor_id 66 | |> find_attackable_neighbor_id(state) 67 | 68 | %{ 69 | type: "attack", 70 | from_id: tile_with_attackable_neighbor_id, 71 | to_id: attackable_neighbor_id 72 | } 73 | end 74 | 75 | defp tile_ids_with_attackable_neighbors(%{current_player_number: current_player_number} = state) do 76 | state 77 | |> Tile.filter_ids(fn tile -> 78 | Tile.owned_by_player_id?(tile, current_player_number) and tile.units > 2 and 79 | Enum.any?(tile.neighbors, fn neighbor_id -> 80 | neighbor = Tile.get(state, neighbor_id) 81 | !Tile.owned_by_player_id?(tile, neighbor.owner) && neighbor.units <= tile.units 82 | end) 83 | end) 84 | end 85 | 86 | defp find_attackable_neighbor_id( 87 | tile_id, 88 | %{current_player_number: current_player_number} = state 89 | ) do 90 | Tile.get(state, tile_id).neighbors 91 | |> Enum.filter(fn neighbor_id -> 92 | neighbor = Tile.get(state, neighbor_id) 93 | !Tile.owned_by_player_id?(neighbor, current_player_number) 94 | end) 95 | |> Enum.random() 96 | end 97 | 98 | defp owned_border_tile_ids(%{current_player_number: current_player_number} = state) do 99 | state 100 | |> Tile.filter_ids(fn tile -> 101 | Tile.owned_by_player_id?(tile, current_player_number) and 102 | tile.neighbors 103 | |> Enum.any?(fn neighbor_id -> 104 | neighbor = Tile.get(state, neighbor_id) 105 | !Tile.owned_by_player_id?(neighbor, tile.owner) 106 | end) 107 | end) 108 | end 109 | 110 | defp sort_tile_ids_by_region_preference(tile_ids, region_preference) do 111 | Enum.sort(tile_ids, fn tile_id_1, tile_id_2 -> 112 | tile_index(region_preference, tile_id_1) < tile_index(region_preference, tile_id_2) 113 | end) 114 | end 115 | 116 | defp tile_index(regions, tile_id) do 117 | Enum.find_index(regions, fn region -> 118 | tile_id in region.tile_ids 119 | end) 120 | end 121 | 122 | # Returns regions sorted by how close you are to owning it 123 | def get_preferred_regions(%{current_player_number: current_player_number} = state) do 124 | owned_tile_ids = Tile.ids_owned_by(state, current_player_number) 125 | 126 | state.regions 127 | |> Enum.sort(fn {_id_1, region_1}, {_id_2, region_2} -> 128 | length(region_2.tile_ids -- owned_tile_ids) > length(region_1.tile_ids -- owned_tile_ids) 129 | end) 130 | |> Enum.map(fn {_id, region} -> region end) 131 | end 132 | 133 | defp end_turn do 134 | %{type: "end_turn"} 135 | end 136 | 137 | def can_move?(state) do 138 | state 139 | |> safe_owned_tiles() 140 | |> length() > 0 141 | end 142 | 143 | def move(state) do 144 | safe_owned_tile_id_with_most_units = 145 | state 146 | |> safe_owned_tiles() 147 | |> Enum.max_by(fn tile_id -> 148 | Tile.get(state, tile_id).units 149 | end) 150 | 151 | units_in_safe_owned_tile_id_with_most_units = 152 | Tile.get(state, safe_owned_tile_id_with_most_units).units 153 | 154 | friendly_neighbor_id = 155 | safe_owned_tile_id_with_most_units 156 | |> find_friendly_neighbor_id(state) 157 | 158 | %{ 159 | type: "move", 160 | from_id: safe_owned_tile_id_with_most_units, 161 | to_id: friendly_neighbor_id, 162 | count: units_in_safe_owned_tile_id_with_most_units - 1 163 | } 164 | end 165 | 166 | defp safe_owned_tiles(state) do 167 | state 168 | |> Tile.filter_ids(fn tile -> 169 | tile.owner == state.current_player_number && tile.units > 1 && 170 | tile.neighbors 171 | |> Enum.all?(fn neighbor_id -> 172 | neighbor = Tile.get(state, neighbor_id) 173 | neighbor.owner == state.current_player_number 174 | end) 175 | end) 176 | end 177 | 178 | # TODO: this should prioritize tiles with hostile neighbors 179 | defp find_friendly_neighbor_id(tile_id, state) do 180 | Tile.get(state, tile_id).neighbors 181 | |> Enum.filter(fn neighbor_id -> 182 | neighbor = Tile.get(state, neighbor_id) 183 | neighbor.owner == state.current_player_number 184 | end) 185 | |> Enum.random() 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /test/sengoku_web/controllers/user_auth_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SengokuWeb.UserAuthTest do 2 | use SengokuWeb.ConnCase, async: true 3 | 4 | alias Sengoku.Accounts 5 | alias SengokuWeb.UserAuth 6 | import Sengoku.AccountsFixtures 7 | 8 | setup %{conn: conn} do 9 | conn = 10 | conn 11 | |> Map.replace!(:secret_key_base, SengokuWeb.Endpoint.config(:secret_key_base)) 12 | |> init_test_session(%{}) 13 | 14 | %{user: user_fixture(), conn: conn} 15 | end 16 | 17 | describe "login_user/3" do 18 | test "stores the user token in the session", %{conn: conn, user: user} do 19 | conn = UserAuth.login_user(conn, user) 20 | assert token = get_session(conn, :user_token) 21 | assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" 22 | assert redirected_to(conn) == "/" 23 | assert Accounts.get_user_by_session_token(token) 24 | end 25 | 26 | test "clears everything previously stored in the session", %{conn: conn, user: user} do 27 | conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.login_user(user) 28 | refute get_session(conn, :to_be_removed) 29 | end 30 | 31 | test "redirects to the configured path", %{conn: conn, user: user} do 32 | conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.login_user(user) 33 | assert redirected_to(conn) == "/hello" 34 | end 35 | 36 | test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do 37 | conn = conn |> fetch_cookies() |> UserAuth.login_user(user, %{"remember_me" => "true"}) 38 | assert get_session(conn, :user_token) == conn.cookies["user_remember_me"] 39 | 40 | assert %{value: signed_token, max_age: max_age} = conn.resp_cookies["user_remember_me"] 41 | assert signed_token != get_session(conn, :user_token) 42 | assert max_age == 5_184_000 43 | end 44 | end 45 | 46 | describe "logout_user/1" do 47 | test "erases session and cookies", %{conn: conn, user: user} do 48 | user_token = Accounts.generate_user_session_token(user) 49 | 50 | conn = 51 | conn 52 | |> put_session(:user_token, user_token) 53 | |> put_req_cookie("user_remember_me", user_token) 54 | |> fetch_cookies() 55 | |> UserAuth.logout_user() 56 | 57 | refute get_session(conn, :user_token) 58 | refute conn.cookies["user_remember_me"] 59 | assert %{max_age: 0} = conn.resp_cookies["user_remember_me"] 60 | assert redirected_to(conn) == "/" 61 | refute Accounts.get_user_by_session_token(user_token) 62 | end 63 | 64 | test "broadcasts to the given live_socket_id", %{conn: conn} do 65 | live_socket_id = "users_sessions:abcdef-token" 66 | SengokuWeb.Endpoint.subscribe(live_socket_id) 67 | 68 | conn 69 | |> put_session(:live_socket_id, live_socket_id) 70 | |> UserAuth.logout_user() 71 | 72 | assert_receive %Phoenix.Socket.Broadcast{ 73 | event: "disconnect", 74 | topic: "users_sessions:abcdef-token" 75 | } 76 | end 77 | 78 | test "works even if user is already logged out", %{conn: conn} do 79 | conn = conn |> fetch_cookies() |> UserAuth.logout_user() 80 | refute get_session(conn, :user_token) 81 | assert %{max_age: 0} = conn.resp_cookies["user_remember_me"] 82 | assert redirected_to(conn) == "/" 83 | end 84 | end 85 | 86 | describe "fetch_current_user/2" do 87 | test "authenticates user from session", %{conn: conn, user: user} do 88 | user_token = Accounts.generate_user_session_token(user) 89 | conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) 90 | assert conn.assigns.current_user.id == user.id 91 | end 92 | 93 | test "authenticates user from cookies", %{conn: conn, user: user} do 94 | logged_in_conn = 95 | conn |> fetch_cookies() |> UserAuth.login_user(user, %{"remember_me" => "true"}) 96 | 97 | user_token = logged_in_conn.cookies["user_remember_me"] 98 | %{value: signed_token} = logged_in_conn.resp_cookies["user_remember_me"] 99 | 100 | conn = 101 | conn 102 | |> put_req_cookie("user_remember_me", signed_token) 103 | |> UserAuth.fetch_current_user([]) 104 | 105 | assert get_session(conn, :user_token) == user_token 106 | assert conn.assigns.current_user.id == user.id 107 | end 108 | 109 | test "does not authenticate if data is missing", %{conn: conn, user: user} do 110 | _ = Accounts.generate_user_session_token(user) 111 | conn = UserAuth.fetch_current_user(conn, []) 112 | refute get_session(conn, :user_token) 113 | refute conn.assigns.current_user 114 | end 115 | end 116 | 117 | describe "redirect_if_user_is_authenticated/2" do 118 | test "redirects if user is authenticated", %{conn: conn, user: user} do 119 | conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) 120 | assert conn.halted 121 | assert redirected_to(conn) == "/" 122 | end 123 | 124 | test "does not redirect if user is not authenticated", %{conn: conn} do 125 | conn = UserAuth.redirect_if_user_is_authenticated(conn, []) 126 | refute conn.halted 127 | refute conn.status 128 | end 129 | end 130 | 131 | describe "require_authenticated_user/2" do 132 | test "redirects if user is not authenticated", %{conn: conn} do 133 | conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) 134 | assert conn.halted 135 | assert redirected_to(conn) == "/users/login" 136 | assert get_flash(conn, :error) == "You must login to access this page." 137 | end 138 | 139 | test "stores the path to redirect to on GET", %{conn: conn} do 140 | halted_conn = 141 | %{conn | request_path: "/foo?bar"} 142 | |> fetch_flash() 143 | |> UserAuth.require_authenticated_user([]) 144 | 145 | assert halted_conn.halted 146 | assert get_session(halted_conn, :user_return_to) == "/foo?bar" 147 | 148 | halted_conn = 149 | %{conn | request_path: "/foo?bar", method: "POST"} 150 | |> fetch_flash() 151 | |> UserAuth.require_authenticated_user([]) 152 | 153 | assert halted_conn.halted 154 | refute get_session(halted_conn, :user_return_to) 155 | end 156 | 157 | test "does not redirect if user is authenticated", %{conn: conn, user: user} do 158 | conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) 159 | refute conn.halted 160 | refute conn.status 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /test/sengoku/ai/smart_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.AI.SmartTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Sengoku.{AI, Player, Tile, Region} 5 | 6 | test "places a unit when unplaced units" do 7 | state = %{ 8 | current_player_number: 1, 9 | players: %{ 10 | 1 => %Player{unplaced_units: 1, active: true, ai: true}, 11 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 12 | }, 13 | tiles: %{ 14 | 1 => %Tile{owner: 1, units: 1, neighbors: [2]}, 15 | 2 => %Tile{owner: nil, units: 1, neighbors: [1]} 16 | }, 17 | regions: %{ 18 | 1 => %Region{value: 1, tile_ids: [1]}, 19 | 2 => %Region{value: 1, tile_ids: [2]} 20 | }, 21 | pending_move: nil 22 | } 23 | 24 | action = AI.Smart.take_action(state) 25 | 26 | assert action == %{type: "place_unit", tile_id: 1} 27 | end 28 | 29 | test "attacks when a neighbor has at least two fewer units" do 30 | state = %{ 31 | current_player_number: 1, 32 | players: %{ 33 | 1 => %Player{unplaced_units: 0, active: true, ai: true}, 34 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 35 | }, 36 | tiles: %{ 37 | 1 => %Tile{owner: 1, units: 3, neighbors: [2]}, 38 | 2 => %Tile{owner: 2, units: 1, neighbors: [1]} 39 | }, 40 | regions: %{ 41 | 1 => %Region{value: 1, tile_ids: [1]}, 42 | 2 => %Region{value: 1, tile_ids: [2]} 43 | }, 44 | pending_move: nil 45 | } 46 | 47 | action = AI.Smart.take_action(state) 48 | 49 | assert action == %{type: "attack", from_id: 1, to_id: 2} 50 | end 51 | 52 | test "does not attack when a neighbor has one fewer units" do 53 | state = %{ 54 | current_player_number: 1, 55 | players: %{ 56 | 1 => %Player{unplaced_units: 0, active: true, ai: true}, 57 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 58 | }, 59 | tiles: %{ 60 | 1 => %Tile{owner: 1, units: 2, neighbors: [2]}, 61 | 2 => %Tile{owner: 2, units: 1, neighbors: [1]} 62 | }, 63 | regions: %{ 64 | 1 => %Region{value: 1, tile_ids: [1]}, 65 | 2 => %Region{value: 1, tile_ids: [2]} 66 | }, 67 | pending_move: nil 68 | } 69 | 70 | action = AI.Smart.take_action(state) 71 | 72 | assert action != %{type: "attack", from_id: 1, to_id: 2} 73 | end 74 | 75 | test "does not attack when a neighbor has more units" do 76 | state = %{ 77 | current_player_number: 1, 78 | players: %{ 79 | 1 => %Player{unplaced_units: 0, active: true, ai: true}, 80 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 81 | }, 82 | tiles: %{ 83 | 1 => %Tile{owner: 1, units: 5, neighbors: [2]}, 84 | 2 => %Tile{owner: 2, units: 6, neighbors: [1]} 85 | }, 86 | regions: %{ 87 | 1 => %Region{value: 1, tile_ids: [1]}, 88 | 2 => %Region{value: 1, tile_ids: [2]} 89 | }, 90 | pending_move: nil 91 | } 92 | 93 | action = AI.Smart.take_action(state) 94 | 95 | assert action != %{type: "attack", from_id: 1, to_id: 2} 96 | end 97 | 98 | test "moves the maximum number of units away from non-border tiles" do 99 | state = %{ 100 | current_player_number: 1, 101 | players: %{ 102 | 1 => %Player{unplaced_units: 0, active: true, ai: true}, 103 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 104 | }, 105 | tiles: %{ 106 | 1 => %Tile{owner: 1, units: 7, neighbors: [2]}, 107 | 2 => %Tile{owner: 1, units: 1, neighbors: [1, 3]}, 108 | 3 => %Tile{owner: 2, units: 1, neighbors: [2]} 109 | }, 110 | pending_move: nil 111 | } 112 | 113 | action = AI.Smart.take_action(state) 114 | 115 | assert action == %{ 116 | type: "move", 117 | from_id: 1, 118 | to_id: 2, 119 | count: 6 120 | } 121 | end 122 | 123 | test "makes a required move when necessary" do 124 | state = %{ 125 | current_player_number: 1, 126 | players: %{ 127 | 1 => %Player{unplaced_units: 0, active: true, ai: true}, 128 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 129 | }, 130 | tiles: %{ 131 | 1 => %Tile{owner: 1, units: 0, neighbors: [2]}, 132 | 2 => %Tile{owner: 1, units: 5, neighbors: [1, 3]}, 133 | 3 => %Tile{owner: 2, units: 1, neighbors: [2]} 134 | }, 135 | pending_move: %{ 136 | from_id: 2, 137 | to_id: 1, 138 | min: 3, 139 | max: 4 140 | } 141 | } 142 | 143 | action = AI.Smart.take_action(state) 144 | 145 | assert action == %{ 146 | type: "move", 147 | from_id: 2, 148 | to_id: 1, 149 | count: 4 150 | } 151 | end 152 | 153 | test "ends turn when no other action" do 154 | state = %{ 155 | current_player_number: 1, 156 | players: %{ 157 | 1 => %Player{unplaced_units: 0, active: true, ai: true}, 158 | 2 => %Player{unplaced_units: 0, active: true, ai: true} 159 | }, 160 | tiles: %{ 161 | 1 => %Tile{owner: 1, units: 1, neighbors: [2]}, 162 | 2 => %Tile{owner: nil, units: 1, neighbors: [1]} 163 | }, 164 | pending_move: nil 165 | } 166 | 167 | action = AI.Smart.take_action(state) 168 | 169 | assert action == %{type: "end_turn"} 170 | end 171 | 172 | describe "get_preferred_regions/1" do 173 | test "returns regions sorted by the percentage you control, favoring smaller regions" do 174 | state = %{ 175 | current_player_number: 1, 176 | tiles: %{ 177 | 1 => %Tile{owner: 1}, 178 | 2 => %Tile{owner: 2}, 179 | 3 => %Tile{owner: 2}, 180 | 4 => %Tile{owner: 1}, 181 | 5 => %Tile{owner: 2}, 182 | 6 => %Tile{owner: 1}, 183 | 7 => %Tile{owner: 1}, 184 | 8 => %Tile{owner: 2}, 185 | 9 => %Tile{owner: 2} 186 | }, 187 | regions: %{ 188 | 1 => %Region{value: 1, tile_ids: [1, 2, 3]}, 189 | 2 => %Region{value: 1, tile_ids: [4, 5]}, 190 | 3 => %Region{value: 1, tile_ids: [6, 7, 8, 9]} 191 | } 192 | } 193 | 194 | assert AI.Smart.get_preferred_regions(state) == [ 195 | # 2 196 | %Region{value: 1, tile_ids: [4, 5]}, 197 | # 3 198 | %Region{value: 1, tile_ids: [6, 7, 8, 9]}, 199 | # 1 200 | %Region{value: 1, tile_ids: [1, 2, 3]} 201 | ] 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /lib/sengoku/board.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Board do 2 | @moduledoc """ 3 | Holds any and all board-specific data on the server. 4 | """ 5 | 6 | defstruct [:players_count, :regions, :tiles, :name] 7 | 8 | alias Sengoku.{Region, Tile} 9 | 10 | @all_neighbor_ids %{ 11 | 1 => [2, 10, 11], 12 | 2 => [1, 3, 11, 12], 13 | 3 => [2, 4, 12, 13], 14 | 4 => [3, 5, 13, 14], 15 | 5 => [4, 6, 14, 15], 16 | 6 => [5, 7, 15, 16], 17 | 7 => [6, 8, 16, 17], 18 | 8 => [7, 9, 17, 18], 19 | 9 => [8, 18, 19], 20 | 10 => [1, 11, 20], 21 | 11 => [1, 2, 10, 12, 20, 21], 22 | 12 => [2, 3, 11, 13, 21, 22], 23 | 13 => [3, 4, 12, 14, 22, 23], 24 | 14 => [4, 5, 13, 15, 23, 24], 25 | 15 => [5, 6, 14, 16, 24, 25], 26 | 16 => [6, 7, 15, 17, 25, 26], 27 | 17 => [7, 8, 16, 18, 26, 27], 28 | 18 => [8, 9, 17, 19, 27, 28], 29 | 19 => [9, 18, 28], 30 | 20 => [10, 11, 21, 29, 30], 31 | 21 => [11, 12, 20, 22, 30, 31], 32 | 22 => [12, 13, 21, 23, 31, 32], 33 | 23 => [13, 14, 22, 24, 32, 33], 34 | 24 => [14, 15, 23, 25, 33, 34], 35 | 25 => [15, 16, 24, 26, 34, 35], 36 | 26 => [16, 17, 25, 27, 35, 36], 37 | 27 => [17, 18, 26, 28, 36, 37], 38 | 28 => [18, 19, 27, 37, 38], 39 | 29 => [20, 30, 39], 40 | 30 => [20, 21, 29, 31, 39, 40], 41 | 31 => [21, 22, 30, 32, 40, 41], 42 | 32 => [22, 23, 31, 33, 41, 42], 43 | 33 => [23, 24, 32, 34, 42, 43], 44 | 34 => [24, 25, 33, 35, 43, 44], 45 | 35 => [25, 26, 34, 36, 44, 45], 46 | 36 => [26, 27, 35, 37, 45, 46], 47 | 37 => [27, 28, 36, 38, 46, 47], 48 | 38 => [28, 37, 47], 49 | 39 => [29, 30, 40, 48, 49], 50 | 40 => [30, 31, 39, 41, 49, 50], 51 | 41 => [31, 32, 40, 42, 50, 51], 52 | 42 => [32, 33, 41, 43, 51, 52], 53 | 43 => [33, 34, 42, 44, 52, 53], 54 | 44 => [34, 35, 43, 45, 53, 54], 55 | 45 => [35, 36, 44, 46, 54, 55], 56 | 46 => [36, 37, 45, 47, 55, 56], 57 | 47 => [37, 38, 46, 56, 57], 58 | 48 => [39, 49, 58], 59 | 49 => [39, 40, 48, 50, 58, 59], 60 | 50 => [40, 41, 49, 51, 59, 60], 61 | 51 => [41, 42, 50, 52, 60, 61], 62 | 52 => [42, 43, 51, 53, 61, 62], 63 | 53 => [43, 44, 52, 54, 62, 63], 64 | 54 => [44, 45, 53, 55, 63, 64], 65 | 55 => [45, 46, 54, 56, 64, 65], 66 | 56 => [46, 47, 55, 57, 65, 66], 67 | 57 => [47, 56, 66], 68 | 58 => [48, 49, 59, 67, 68], 69 | 59 => [49, 50, 58, 60, 68, 69], 70 | 60 => [50, 51, 59, 61, 69, 70], 71 | 61 => [51, 52, 60, 62, 70, 71], 72 | 62 => [52, 53, 61, 63, 71, 72], 73 | 63 => [53, 54, 62, 64, 72, 73], 74 | 64 => [54, 55, 63, 65, 73, 74], 75 | 65 => [55, 56, 64, 66, 74, 75], 76 | 66 => [56, 57, 65, 75, 76], 77 | 67 => [58, 68, 77], 78 | 68 => [58, 59, 67, 69, 77, 78], 79 | 69 => [59, 60, 68, 70, 78, 79], 80 | 70 => [60, 61, 69, 71, 79, 80], 81 | 71 => [61, 62, 70, 72, 80, 81], 82 | 72 => [62, 63, 71, 73, 81, 82], 83 | 73 => [63, 64, 72, 74, 82, 83], 84 | 74 => [64, 65, 73, 75, 83, 84], 85 | 75 => [65, 66, 74, 76, 84, 85], 86 | 76 => [66, 75, 85], 87 | 77 => [67, 68, 78], 88 | 78 => [68, 69, 77, 79], 89 | 79 => [69, 70, 78, 80], 90 | 80 => [70, 71, 79, 81], 91 | 81 => [71, 72, 80, 82], 92 | 82 => [72, 73, 81, 83], 93 | 83 => [73, 74, 82, 84], 94 | 84 => [74, 75, 83, 85], 95 | 85 => [75, 76, 84] 96 | } 97 | 98 | @doc """ 99 | Returns a Board struct with the data specific to a given board. 100 | """ 101 | def new("japan") do 102 | %__MODULE__{ 103 | name: "japan", 104 | players_count: 4, 105 | regions: %{ 106 | 1 => %Region{value: 2, tile_ids: [49, 50, 58, 59]}, 107 | 2 => %Region{value: 2, tile_ids: [51, 52, 61]}, 108 | 3 => %Region{value: 5, tile_ids: [32, 33, 41, 42, 43]}, 109 | 4 => %Region{value: 4, tile_ids: [34, 35, 44, 45, 53, 54]}, 110 | 5 => %Region{value: 3, tile_ids: [46, 55, 56]}, 111 | 6 => %Region{value: 2, tile_ids: [18, 27, 36, 37]} 112 | } 113 | } 114 | |> build_tiles() 115 | end 116 | 117 | def new("earth") do 118 | # Alaska <=> Kamchatka 119 | additional_neighbors = [{10, 19}] 120 | 121 | %__MODULE__{ 122 | name: "earth", 123 | players_count: 6, 124 | regions: %{ 125 | 1 => %Region{value: 5, tile_ids: [10, 11, 12, 20, 21, 22, 30, 31, 40]}, 126 | 2 => %Region{value: 2, tile_ids: [50, 51, 59, 60]}, 127 | 3 => %Region{value: 3, tile_ids: [42, 43, 52, 53, 62, 63]}, 128 | 4 => %Region{value: 5, tile_ids: [14, 15, 23, 24, 25, 33, 34]}, 129 | 5 => %Region{value: 7, tile_ids: [16, 17, 18, 19, 26, 27, 28, 35, 36, 37, 44, 46]}, 130 | 6 => %Region{value: 2, tile_ids: [56, 57, 65, 66]} 131 | } 132 | } 133 | |> build_tiles(additional_neighbors) 134 | end 135 | 136 | @doc """ 137 | Returns a Board struct with the data specific to a given board. 138 | """ 139 | def new("wheel") do 140 | %__MODULE__{ 141 | name: "wheel", 142 | players_count: 6, 143 | regions: %{ 144 | 1 => %Region{value: 3, tile_ids: [3, 12, 13, 21, 23, 30]}, 145 | 2 => %Region{value: 3, tile_ids: [4, 5, 6, 7, 16, 25]}, 146 | 3 => %Region{value: 3, tile_ids: [17, 27, 37, 45, 46, 47]}, 147 | 4 => %Region{value: 3, tile_ids: [56, 63, 65, 73, 74, 83]}, 148 | 5 => %Region{value: 3, tile_ids: [61, 70, 79, 80, 81, 82]}, 149 | 6 => %Region{value: 3, tile_ids: [39, 40, 41, 49, 59, 69]}, 150 | 7 => %Region{value: 6, tile_ids: [33, 34, 42, 43, 44, 52, 53]} 151 | } 152 | } 153 | |> build_tiles() 154 | end 155 | 156 | def new("europe") do 157 | %__MODULE__{ 158 | name: "europe", 159 | players_count: 8, 160 | regions: %{ 161 | 1 => %Region{value: 2, tile_ids: [10, 20, 29, 30]}, 162 | 2 => %Region{value: 6, tile_ids: [31, 32, 40, 41, 42, 50, 51]}, 163 | 3 => %Region{value: 2, tile_ids: [58, 59, 60, 67, 68, 69, 77, 78]}, 164 | 4 => %Region{value: 3, tile_ids: [52, 53, 61, 62, 72, 81]}, 165 | 5 => %Region{value: 5, tile_ids: [24, 25, 33, 34, 43, 44]}, 166 | 6 => %Region{value: 6, tile_ids: [26, 27, 35, 36, 37, 45, 46]}, 167 | 7 => %Region{value: 4, tile_ids: [54, 55, 56, 64, 65, 66, 74, 75, 84]}, 168 | 8 => %Region{value: 5, tile_ids: [18, 19, 28, 38, 47, 57]} 169 | } 170 | } 171 | |> build_tiles() 172 | end 173 | 174 | def new("westeros") do 175 | %__MODULE__{ 176 | name: "westeros", 177 | players_count: 8, 178 | regions: %{ 179 | 1 => %Region{value: 2, tile_ids: [3, 4, 5, 6, 13, 14]}, 180 | 2 => %Region{value: 4, tile_ids: [24, 25, 26, 34]}, 181 | 3 => %Region{value: 3, tile_ids: [35, 44, 45]}, 182 | 4 => %Region{value: 6, tile_ids: [22, 23, 32, 33, 42, 43]}, 183 | 5 => %Region{value: 7, tile_ids: [51, 52, 53, 60, 61, 62, 70]}, 184 | 6 => %Region{value: 3, tile_ids: [31, 40, 41, 50]}, 185 | 7 => %Region{value: 3, tile_ids: [71, 72, 80, 81, 82, 83]}, 186 | 8 => %Region{value: 3, tile_ids: [54, 55, 63, 64, 65]} 187 | } 188 | } 189 | |> build_tiles() 190 | end 191 | 192 | defp build_tiles(board, additional_neighbors \\ []) do 193 | tile_ids_for_map = 194 | Enum.reduce(board.regions, [], fn {_id, region}, tile_ids -> 195 | tile_ids ++ region.tile_ids 196 | end) 197 | 198 | tiles = 199 | tile_ids_for_map 200 | |> Enum.map(fn tile_id -> 201 | neighbors = 202 | Enum.filter(@all_neighbor_ids[tile_id], fn neighbor_id -> 203 | neighbor_id in tile_ids_for_map 204 | end) 205 | 206 | neighbors = maybe_add_additional_neighbors(tile_id, neighbors, additional_neighbors) 207 | 208 | {tile_id, Tile.new(neighbors)} 209 | end) 210 | |> Enum.into(%{}) 211 | 212 | Map.put(board, :tiles, tiles) 213 | end 214 | 215 | defp maybe_add_additional_neighbors(tile_id, neighbors, additional_neighbor_pairs) do 216 | additional_neighbors = 217 | Enum.reduce(additional_neighbor_pairs, [], fn pair, acc -> 218 | case pair do 219 | {^tile_id, neighbor} -> 220 | acc ++ [neighbor] 221 | 222 | {neighbor, ^tile_id} -> 223 | acc ++ [neighbor] 224 | 225 | _ -> 226 | acc 227 | end 228 | end) 229 | 230 | neighbors ++ additional_neighbors 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /lib/sengoku/accounts.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Accounts do 2 | @moduledoc """ 3 | The Accounts context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias Sengoku.Repo 8 | alias Sengoku.Accounts.{User, UserToken, UserNotifier} 9 | 10 | ## Database getters 11 | 12 | @doc """ 13 | Gets a user by email. 14 | 15 | ## Examples 16 | 17 | iex> get_user_by_email("foo@example.com") 18 | %User{} 19 | 20 | iex> get_user_by_email("unknown@example.com") 21 | nil 22 | 23 | """ 24 | def get_user_by_email(email) when is_binary(email) do 25 | Repo.get_by(User, email: email) 26 | end 27 | 28 | @doc """ 29 | Gets a user by email and password. 30 | 31 | ## Examples 32 | 33 | iex> get_user_by_email_and_password("foo@example.com", "correct_password") 34 | %User{} 35 | 36 | iex> get_user_by_email_and_password("foo@example.com", "invalid_password") 37 | nil 38 | 39 | """ 40 | def get_user_by_email_and_password(email, password) 41 | when is_binary(email) and is_binary(password) do 42 | user = Repo.get_by(User, email: email) 43 | if User.valid_password?(user, password), do: user 44 | end 45 | 46 | @doc """ 47 | Gets a user by username and password. 48 | 49 | ## Examples 50 | 51 | iex> get_user_by_username_and_password("tokugawa", "correct_password") 52 | %User{} 53 | 54 | iex> get_user_by_username_and_password("tokugawa", "invalid_password") 55 | nil 56 | 57 | """ 58 | def get_user_by_username_and_password(username, password) 59 | when is_binary(username) and is_binary(password) do 60 | user = Repo.get_by(User, username: username) 61 | if User.valid_password?(user, password), do: user 62 | end 63 | 64 | @doc """ 65 | Gets a single user. 66 | 67 | Raises `Ecto.NoResultsError` if the User does not exist. 68 | 69 | ## Examples 70 | 71 | iex> get_user!(123) 72 | %User{} 73 | 74 | iex> get_user!(456) 75 | ** (Ecto.NoResultsError) 76 | 77 | """ 78 | def get_user!(id), do: Repo.get!(User, id) 79 | 80 | ## User registration 81 | 82 | @doc """ 83 | Registers a user. 84 | 85 | ## Examples 86 | 87 | iex> register_user(%{field: value}) 88 | {:ok, %User{}} 89 | 90 | iex> register_user(%{field: bad_value}) 91 | {:error, %Ecto.Changeset{}} 92 | 93 | """ 94 | def register_user(attrs) do 95 | %User{} 96 | |> User.registration_changeset(attrs) 97 | |> Repo.insert() 98 | end 99 | 100 | @doc """ 101 | Returns an `%Ecto.Changeset{}` for tracking user changes. 102 | 103 | ## Examples 104 | 105 | iex> change_user_registration(user) 106 | %Ecto.Changeset{data: %User{}} 107 | 108 | """ 109 | def change_user_registration(%User{} = user, attrs \\ %{}) do 110 | User.registration_changeset(user, attrs) 111 | end 112 | 113 | ## Settings 114 | 115 | @doc """ 116 | Returns an `%Ecto.Changeset{}` for changing the user e-mail. 117 | 118 | ## Examples 119 | 120 | iex> change_user_email(user) 121 | %Ecto.Changeset{data: %User{}} 122 | 123 | """ 124 | def change_user_email(user, attrs \\ %{}) do 125 | User.email_changeset(user, attrs) 126 | end 127 | 128 | @doc """ 129 | Emulates that the e-mail will change without actually changing 130 | it in the database. 131 | 132 | ## Examples 133 | 134 | iex> apply_user_email(user, "valid password", %{email: ...}) 135 | {:ok, %User{}} 136 | 137 | iex> apply_user_email(user, "invalid password", %{email: ...}) 138 | {:error, %Ecto.Changeset{}} 139 | 140 | """ 141 | def apply_user_email(user, password, attrs) do 142 | user 143 | |> User.email_changeset(attrs) 144 | |> User.validate_current_password(password) 145 | |> Ecto.Changeset.apply_action(:update) 146 | end 147 | 148 | @doc """ 149 | Updates the user e-mail in token. 150 | 151 | If the token matches, the user email is updated and the token is deleted. 152 | The confirmed_at date is also updated to the current time. 153 | """ 154 | def update_user_email(user, token) do 155 | context = "change:#{user.email}" 156 | 157 | with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), 158 | %UserToken{sent_to: email} <- Repo.one(query), 159 | {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do 160 | :ok 161 | else 162 | _ -> :error 163 | end 164 | end 165 | 166 | defp user_email_multi(user, email, context) do 167 | changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset() 168 | 169 | Ecto.Multi.new() 170 | |> Ecto.Multi.update(:user, changeset) 171 | |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) 172 | end 173 | 174 | @doc """ 175 | Delivers the update e-mail instructions to the given user. 176 | 177 | ## Examples 178 | 179 | iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1)) 180 | {:ok, %{to: ..., body: ...}} 181 | 182 | """ 183 | def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun) 184 | when is_function(update_email_url_fun, 1) do 185 | {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") 186 | 187 | Repo.insert!(user_token) 188 | UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) 189 | end 190 | 191 | @doc """ 192 | Returns an `%Ecto.Changeset{}` for changing the user password. 193 | 194 | ## Examples 195 | 196 | iex> change_user_password(user) 197 | %Ecto.Changeset{data: %User{}} 198 | 199 | """ 200 | def change_user_password(user, attrs \\ %{}) do 201 | User.password_changeset(user, attrs) 202 | end 203 | 204 | @doc """ 205 | Updates the user password. 206 | 207 | ## Examples 208 | 209 | iex> update_user_password(user, "valid password", %{password: ...}) 210 | {:ok, %User{}} 211 | 212 | iex> update_user_password(user, "invalid password", %{password: ...}) 213 | {:error, %Ecto.Changeset{}} 214 | 215 | """ 216 | def update_user_password(user, password, attrs) do 217 | changeset = 218 | user 219 | |> User.password_changeset(attrs) 220 | |> User.validate_current_password(password) 221 | 222 | Ecto.Multi.new() 223 | |> Ecto.Multi.update(:user, changeset) 224 | |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) 225 | |> Repo.transaction() 226 | |> case do 227 | {:ok, %{user: user}} -> {:ok, user} 228 | {:error, :user, changeset, _} -> {:error, changeset} 229 | end 230 | end 231 | 232 | ## Session 233 | 234 | @doc """ 235 | Generates a session token. 236 | """ 237 | def generate_user_session_token(user) do 238 | {token, user_token} = UserToken.build_session_token(user) 239 | Repo.insert!(user_token) 240 | token 241 | end 242 | 243 | @doc """ 244 | Gets the user with the given signed token. 245 | """ 246 | def get_user_by_session_token(token) do 247 | {:ok, query} = UserToken.verify_session_token_query(token) 248 | Repo.one(query) 249 | end 250 | 251 | @doc """ 252 | Deletes the signed token with the given context. 253 | """ 254 | def delete_session_token(token) do 255 | Repo.delete_all(UserToken.token_and_context_query(token, "session")) 256 | :ok 257 | end 258 | 259 | ## Confirmation 260 | 261 | @doc """ 262 | Delivers the confirmation e-mail instructions to the given user. 263 | 264 | ## Examples 265 | 266 | iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1)) 267 | {:ok, %{to: ..., body: ...}} 268 | 269 | iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1)) 270 | {:error, :already_confirmed} 271 | 272 | """ 273 | def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) 274 | when is_function(confirmation_url_fun, 1) do 275 | if user.confirmed_at do 276 | {:error, :already_confirmed} 277 | else 278 | {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") 279 | Repo.insert!(user_token) 280 | UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) 281 | end 282 | end 283 | 284 | @doc """ 285 | Confirms a user by the given token. 286 | 287 | If the token matches, the user account is marked as confirmed 288 | and the token is deleted. 289 | """ 290 | def confirm_user(token) do 291 | with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), 292 | %User{} = user <- Repo.one(query), 293 | {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do 294 | {:ok, user} 295 | else 296 | _ -> :error 297 | end 298 | end 299 | 300 | defp confirm_user_multi(user) do 301 | Ecto.Multi.new() 302 | |> Ecto.Multi.update(:user, User.confirm_changeset(user)) 303 | |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) 304 | end 305 | 306 | ## Reset password 307 | 308 | @doc """ 309 | Delivers the reset password e-mail to the given user. 310 | 311 | ## Examples 312 | 313 | iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1)) 314 | {:ok, %{to: ..., body: ...}} 315 | 316 | """ 317 | def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) 318 | when is_function(reset_password_url_fun, 1) do 319 | {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") 320 | Repo.insert!(user_token) 321 | UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) 322 | end 323 | 324 | @doc """ 325 | Gets the user by reset password token. 326 | 327 | ## Examples 328 | 329 | iex> get_user_by_reset_password_token("validtoken") 330 | %User{} 331 | 332 | iex> get_user_by_reset_password_token("invalidtoken") 333 | nil 334 | 335 | """ 336 | def get_user_by_reset_password_token(token) do 337 | with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), 338 | %User{} = user <- Repo.one(query) do 339 | user 340 | else 341 | _ -> nil 342 | end 343 | end 344 | 345 | @doc """ 346 | Resets the user password. 347 | 348 | ## Examples 349 | 350 | iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) 351 | {:ok, %User{}} 352 | 353 | iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) 354 | {:error, %Ecto.Changeset{}} 355 | 356 | """ 357 | def reset_user_password(user, attrs) do 358 | Ecto.Multi.new() 359 | |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) 360 | |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) 361 | |> Repo.transaction() 362 | |> case do 363 | {:ok, %{user: user}} -> {:ok, user} 364 | {:error, :user, changeset, _} -> {:error, changeset} 365 | end 366 | end 367 | end 368 | -------------------------------------------------------------------------------- /lib/sengoku/game.ex: -------------------------------------------------------------------------------- 1 | defmodule Sengoku.Game do 2 | @moduledoc """ 3 | Primarily responsible for all state transitions of the GameServer’s state, 4 | which in practice means the rules of the game. 5 | """ 6 | 7 | require Logger 8 | 9 | alias Sengoku.{Authentication, Tile, Player, Region, Battle, Board} 10 | 11 | @min_new_units 3 12 | @tiles_per_new_unit 3 13 | @initial_state %{ 14 | turn: 0, 15 | current_player_number: nil, 16 | winning_player: nil, 17 | pending_move: nil, 18 | selected_tile_id: nil 19 | } 20 | 21 | def initialize_state(game_id, %{"board" => board}) do 22 | initialize_state(game_id, Board.new(board)) 23 | end 24 | 25 | def initialize_state(game_id, %Board{} = board) do 26 | @initial_state 27 | |> Map.put(:id, game_id) 28 | |> Map.put(:board, board.name) 29 | |> Player.initialize_state(board.players_count) 30 | |> Tile.initialize_state(board.tiles) 31 | |> Authentication.initialize_state() 32 | |> Region.initialize_state(board.regions) 33 | end 34 | 35 | def handle_action(state, %{type: "end_turn"}) do 36 | end_turn(state) 37 | end 38 | 39 | def handle_action(state, %{type: "place_unit", tile_id: tile_id}) do 40 | place_unit(state, tile_id) 41 | end 42 | 43 | def handle_action(state, %{type: "select_tile", tile_id: tile_id}) do 44 | select_tile(state, tile_id) 45 | end 46 | 47 | def handle_action(state, %{type: "unselect_tile"}) do 48 | unselect_tile(state) 49 | end 50 | 51 | def handle_action(state, %{type: "attack", from_id: from_id, to_id: to_id}) do 52 | attack(state, from_id, to_id) 53 | end 54 | 55 | def handle_action(state, %{type: "start_move", from_id: from_id, to_id: to_id}) do 56 | start_move(state, from_id, to_id) 57 | end 58 | 59 | def handle_action(state, %{type: "move", from_id: from_id, to_id: to_id, count: count}) do 60 | move(state, from_id, to_id, count) 61 | end 62 | 63 | def handle_action(state, %{type: "cancel_move"}) do 64 | cancel_move(state) 65 | end 66 | 67 | def handle_action(state, action) do 68 | Logger.info("Unrecognized action `#{inspect(action)}`") 69 | state 70 | end 71 | 72 | def start_game(state) do 73 | if length(Player.active_ids(state)) > 1 do 74 | state 75 | |> assign_tiles() 76 | |> increment_turn() 77 | |> setup_first_turn() 78 | |> begin_turn() 79 | else 80 | Logger.info("Tried to start game without enough players") 81 | state 82 | end 83 | end 84 | 85 | def begin_turn(state) do 86 | state 87 | |> grant_new_units() 88 | |> grant_region_bonuses() 89 | end 90 | 91 | def update_ai_player(state, player_number, ai_type) do 92 | state 93 | |> Player.update_attributes(player_number, %{ai: ai_type}) 94 | end 95 | 96 | defp grant_new_units(%{current_player_number: current_player_number} = state) do 97 | new_units_count = 98 | state 99 | |> Tile.ids_owned_by(current_player_number) 100 | |> length() 101 | |> Integer.floor_div(@tiles_per_new_unit) 102 | |> max(@min_new_units) 103 | 104 | Player.grant_reinforcements(state, current_player_number, new_units_count) 105 | end 106 | 107 | defp grant_region_bonuses(%{current_player_number: current_player_number} = state) do 108 | owned_tile_ids = Tile.ids_owned_by(state, current_player_number) 109 | 110 | case Region.containing_tile_ids(state, owned_tile_ids) do 111 | [] -> 112 | state 113 | 114 | regions -> 115 | bonus = 116 | Enum.reduce(regions, 0, fn region, acc -> 117 | acc + region.value 118 | end) 119 | 120 | state 121 | |> Player.grant_reinforcements(current_player_number, bonus) 122 | end 123 | end 124 | 125 | def end_turn(%{pending_move: %{}} = state) do 126 | # Don’t end turn if a move is pending 127 | state 128 | end 129 | 130 | def end_turn(state) do 131 | state 132 | |> Map.put(:selected_tile_id, nil) 133 | |> rotate_current_player() 134 | |> begin_turn() 135 | end 136 | 137 | defp rotate_current_player(%{current_player_number: current_player_number} = state) do 138 | active_player_ids = Player.active_ids(state) 139 | 140 | next_player_id = 141 | active_player_ids 142 | |> Enum.at(Enum.find_index(active_player_ids, &(&1 == current_player_number)) + 1) 143 | 144 | if next_player_id in active_player_ids do 145 | state 146 | |> Map.put(:current_player_number, next_player_id) 147 | else 148 | state 149 | |> Map.update!(:turn, &(&1 + 1)) 150 | |> Map.put(:current_player_number, hd(active_player_ids)) 151 | end 152 | end 153 | 154 | def place_unit(%{current_player_number: current_player_number} = state, tile_id) do 155 | if current_player(state).unplaced_units > 0 and is_nil(state.pending_move) do 156 | tile = state.tiles[tile_id] 157 | 158 | if Tile.owned_by_player_id?(tile, current_player_number) do 159 | state 160 | |> Player.use_reinforcement(current_player_number) 161 | |> Tile.adjust_units(tile_id, 1) 162 | else 163 | Logger.info("Tried to place unit in unowned tile") 164 | state 165 | end 166 | else 167 | Logger.info("Tried to place unit when you have none") 168 | state 169 | end 170 | end 171 | 172 | def select_tile(%{selected_tile_id: nil} = state, tile_id) do 173 | %{state | selected_tile_id: tile_id} 174 | end 175 | 176 | def unselect_tile(%{selected_tile_id: tile_id} = state) when is_integer(tile_id) do 177 | %{state | selected_tile_id: nil} 178 | end 179 | 180 | def attack( 181 | %{current_player_number: current_player_number} = state, 182 | from_id, 183 | to_id, 184 | outcome \\ nil 185 | ) do 186 | from_tile = state.tiles[from_id] 187 | to_tile = state.tiles[to_id] 188 | defender_id = to_tile.owner 189 | attacking_units = from_tile.units - 1 190 | defending_units = to_tile.units 191 | 192 | if attacking_units > 0 and from_tile.owner == current_player_number and 193 | defender_id != current_player_number and to_id in from_tile.neighbors and 194 | is_nil(state.pending_move) do 195 | {attacker_losses, defender_losses} = 196 | outcome || Battle.decide(attacking_units, defending_units) 197 | 198 | state 199 | |> Tile.adjust_units(from_id, -attacker_losses) 200 | |> Tile.adjust_units(to_id, -defender_losses) 201 | |> check_for_capture(from_id, to_id, min(attacking_units, 3)) 202 | |> deactivate_player_if_defeated(defender_id) 203 | |> check_for_winner() 204 | else 205 | Logger.info( 206 | "Invalid attack from `#{from_id}` to `#{to_id}` by player `#{current_player_number}`" 207 | ) 208 | 209 | state 210 | end 211 | end 212 | 213 | defp check_for_capture(state, from_id, to_id, attacking_units) do 214 | if state.tiles[to_id].units == 0 do 215 | movable_units = state.tiles[from_id].units - 1 216 | 217 | if movable_units > attacking_units do 218 | state 219 | |> Tile.set_owner(to_id, state.current_player_number) 220 | |> Tile.adjust_units(to_id, 0) 221 | |> Map.put(:pending_move, %{ 222 | from_id: from_id, 223 | to_id: to_id, 224 | min: 3, 225 | max: movable_units, 226 | required: true 227 | }) 228 | else 229 | state 230 | |> Tile.adjust_units(from_id, -attacking_units) 231 | |> Tile.set_owner(to_id, state.current_player_number) 232 | |> Tile.adjust_units(to_id, attacking_units) 233 | |> Map.put(:selected_tile_id, nil) 234 | end 235 | else 236 | state 237 | end 238 | end 239 | 240 | def start_move(state, from_id, to_id) do 241 | state 242 | |> Map.put(:pending_move, %{ 243 | from_id: from_id, 244 | to_id: to_id, 245 | min: 1, 246 | max: state.tiles[from_id].units - 1, 247 | required: false 248 | }) 249 | end 250 | 251 | def move(%{pending_move: %{required: required}} = state, from_id, to_id, count) do 252 | if from_id == state.pending_move.from_id and to_id == state.pending_move.to_id and 253 | count >= state.pending_move.min do 254 | state 255 | |> Tile.adjust_units(from_id, -count) 256 | |> Tile.adjust_units(to_id, count) 257 | |> Map.put(:pending_move, nil) 258 | |> Map.put(:selected_tile_id, nil) 259 | |> end_turn_unless_required_move(required) 260 | else 261 | Logger.info("Invalid move of `#{count}` units from `#{from_id}` to `#{to_id}`") 262 | state 263 | end 264 | end 265 | 266 | def move( 267 | %{pending_move: nil, current_player_number: current_player_number} = state, 268 | from_id, 269 | to_id, 270 | count 271 | ) do 272 | if state.tiles[from_id].owner == current_player_number and 273 | state.tiles[to_id].owner == current_player_number and count < state.tiles[from_id].units and 274 | from_id in state.tiles[to_id].neighbors do 275 | state 276 | |> Tile.adjust_units(from_id, -count) 277 | |> Tile.adjust_units(to_id, count) 278 | |> Map.put(:selected_tile_id, nil) 279 | |> end_turn 280 | else 281 | Logger.info("Invalid move of `#{count}` units from `#{from_id}` to `#{to_id}`") 282 | state 283 | end 284 | end 285 | 286 | defp end_turn_unless_required_move(state, false), do: end_turn(state) 287 | defp end_turn_unless_required_move(state, true), do: state 288 | 289 | def cancel_move(%{pending_move: nil} = state) do 290 | state 291 | end 292 | 293 | def cancel_move(%{pending_move: %{required: true}} = state) do 294 | state 295 | end 296 | 297 | def cancel_move(state) do 298 | state 299 | |> Map.put(:selected_tile_id, nil) 300 | |> Map.put(:pending_move, nil) 301 | end 302 | 303 | defp increment_turn(state) do 304 | state 305 | |> Map.update!(:turn, &(&1 + 1)) 306 | end 307 | 308 | defp setup_first_turn(state) do 309 | state 310 | |> Map.put(:current_player_number, List.first(Map.keys(state.players))) 311 | end 312 | 313 | defp assign_tiles(state) do 314 | player_ids = Player.active_ids(state) 315 | available_tile_ids = Tile.unowned_ids(state) 316 | 317 | if length(available_tile_ids) >= length(player_ids) do 318 | state 319 | |> assign_random_tile_to_each(player_ids) 320 | |> assign_tiles 321 | else 322 | state 323 | end 324 | end 325 | 326 | def assign_random_tile_to_each(state, []), do: state 327 | 328 | def assign_random_tile_to_each(state, [player_id | rest]) do 329 | tile_id = 330 | state 331 | |> Tile.unowned_ids() 332 | |> Enum.random() 333 | 334 | new_state = Tile.set_owner(state, tile_id, player_id) 335 | assign_random_tile_to_each(new_state, rest) 336 | end 337 | 338 | defp deactivate_player_if_defeated(state, nil), do: state 339 | 340 | defp deactivate_player_if_defeated(state, player_id) do 341 | if Tile.ids_owned_by(state, player_id) == [] do 342 | Player.deactivate(state, player_id) 343 | else 344 | state 345 | end 346 | end 347 | 348 | defp check_for_winner(state) do 349 | active_player_ids = Player.active_ids(state) 350 | 351 | if length(active_player_ids) == 1 do 352 | state 353 | |> Map.put(:winning_player, hd(active_player_ids)) 354 | else 355 | state 356 | end 357 | end 358 | 359 | def current_player(state) do 360 | state.players[state.current_player_number] 361 | end 362 | end 363 | --------------------------------------------------------------------------------