├── 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 | <%= get_flash(@conn, :info) %>
4 | <% end %>
5 | <%= if get_flash(@conn, :error) do %>
6 | <%= get_flash(@conn, :error) %>
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 | <%= live_flash(@flash, :info) %>
6 | <% end %>
7 |
8 | <%= if live_flash(@flash, :error) do %>
9 | <%= live_flash(@flash, :error) %>
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 |
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 |
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 |
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 |
34 | "
35 | phx-click="select_region"
36 | phx-value-region_id="<%= region %>"
37 | >
38 |
39 |
40 |
41 | <%= region %>
42 |
43 | <% end %>
44 |
45 |
46 |
47 |
48 |
49 | <%= for {tile_id, region} <- @tiles do %>
50 |
54 | "
55 | id="tile_<%= tile_id %>"
56 | phx-click="toggle_tile"
57 | phx-value-tile_id="<%= tile_id %>"
58 | >
59 |
60 |
61 |
62 | <%= tile_id %>
63 |
64 | <% end %>
65 |
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 | 
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 |
--------------------------------------------------------------------------------