├── apps
├── demon_spirit_game
│ ├── test
│ │ ├── test_helper.exs
│ │ ├── card_test.exs
│ │ ├── chat_supervisor_test.exs
│ │ ├── chat_server_test.exs
│ │ ├── game_win_check_test.exs
│ │ ├── game_supervisor_test.exs
│ │ ├── game_server_test.exs
│ │ └── ai_test.exs
│ ├── .formatter.exs
│ ├── lib
│ │ └── demon_spirit_game
│ │ │ ├── move.ex
│ │ │ ├── application.ex
│ │ │ ├── chat.ex
│ │ │ ├── chat_supervisor.ex
│ │ │ ├── game_supervisor.ex
│ │ │ ├── game_win_check.ex
│ │ │ ├── chat_server.ex
│ │ │ ├── game_server.ex
│ │ │ ├── ai.ex
│ │ │ └── card.ex
│ ├── README.md
│ ├── .gitignore
│ └── mix.exs
├── demon_spirit_web
│ ├── assets
│ │ ├── css
│ │ │ ├── custom-utilities.css
│ │ │ ├── custom-base-styles.css
│ │ │ ├── custom-components.css
│ │ │ ├── components
│ │ │ │ ├── alert.css
│ │ │ │ ├── form.css
│ │ │ │ ├── card.css
│ │ │ │ └── button.css
│ │ │ └── app.css
│ │ ├── .babelrc
│ │ ├── static
│ │ │ ├── favicon.ico
│ │ │ ├── sounds
│ │ │ │ └── move.mp3
│ │ │ ├── images
│ │ │ │ ├── phoenix.png
│ │ │ │ ├── example_screenshot.png
│ │ │ │ ├── black_pawn.svg
│ │ │ │ ├── white_pawn.svg
│ │ │ │ ├── white_king.svg
│ │ │ │ ├── black_king.svg
│ │ │ │ └── bg-blue3.svg
│ │ │ └── robots.txt
│ │ ├── postcss.config.js
│ │ ├── js
│ │ │ ├── hooks
│ │ │ │ ├── update_ding.js
│ │ │ │ ├── draggable_droppable.js
│ │ │ │ ├── phone_number.js
│ │ │ │ ├── draggable.js
│ │ │ │ ├── droppable.js
│ │ │ │ └── chat_scroll.js
│ │ │ ├── app.js
│ │ │ └── socket.js
│ │ ├── package.json
│ │ ├── tailwind.config.js
│ │ └── webpack.config.js
│ ├── lib
│ │ ├── demon_spirit_web
│ │ │ ├── templates
│ │ │ │ ├── page
│ │ │ │ │ └── index.html.heex
│ │ │ │ ├── game
│ │ │ │ │ ├── show
│ │ │ │ │ │ ├── timer.html.heex
│ │ │ │ │ │ ├── player.html.heex
│ │ │ │ │ │ ├── card.html.heex
│ │ │ │ │ │ ├── ready_player_column.html.heex
│ │ │ │ │ │ ├── ready.html.heex
│ │ │ │ │ │ └── board.html.heex
│ │ │ │ │ ├── live_index.html.heex
│ │ │ │ │ ├── new.html.heex
│ │ │ │ │ ├── index
│ │ │ │ │ │ ├── game_table.html.heex
│ │ │ │ │ │ └── about.html.heex
│ │ │ │ │ ├── live_show.html.leex.bak
│ │ │ │ │ └── live_show.html.heex
│ │ │ │ ├── session
│ │ │ │ │ └── new.html.heex
│ │ │ │ ├── chat
│ │ │ │ │ └── live_index.html.heex
│ │ │ │ └── layout
│ │ │ │ │ └── app.html.heex
│ │ │ ├── views
│ │ │ │ ├── page_view.ex
│ │ │ │ ├── layout_view.ex
│ │ │ │ ├── session_view.ex
│ │ │ │ ├── chat_view.ex
│ │ │ │ ├── error_view.ex
│ │ │ │ ├── card_view.ex
│ │ │ │ ├── error_helpers.ex
│ │ │ │ └── game_view.ex
│ │ │ ├── presence.ex
│ │ │ ├── controllers
│ │ │ │ ├── chat_controller.ex
│ │ │ │ ├── page_controller.ex
│ │ │ │ ├── session_controller.ex
│ │ │ │ └── game_controller.ex
│ │ │ ├── authenticator.ex
│ │ │ ├── gettext.ex
│ │ │ ├── live
│ │ │ │ ├── live_game_index.ex
│ │ │ │ ├── live_chat_index.ex
│ │ │ │ └── live_game_show.ex
│ │ │ ├── channels
│ │ │ │ └── user_socket.ex
│ │ │ ├── application.ex
│ │ │ ├── router.ex
│ │ │ ├── endpoint.ex
│ │ │ └── name_generator.ex
│ │ ├── demon_spirit_game_ui
│ │ │ ├── game_info.ex
│ │ │ ├── game_ui_options.ex
│ │ │ ├── game_ui_supervisor.ex
│ │ │ ├── game_registry.ex
│ │ │ └── game_timer.ex
│ │ └── demon_spirit_web.ex
│ ├── test
│ │ ├── test_helper.exs
│ │ ├── demon_spirit_web
│ │ │ ├── views
│ │ │ │ ├── page_view_test.exs
│ │ │ │ ├── layout_view_test.exs
│ │ │ │ └── error_view_test.exs
│ │ │ └── controllers
│ │ │ │ └── page_controller_test.exs
│ │ ├── support
│ │ │ ├── channel_case.ex
│ │ │ └── conn_case.ex
│ │ └── demon_spirit_game_ui
│ │ │ ├── game_ui_test.exs
│ │ │ ├── game_ui_supervisor_test.exs
│ │ │ └── game_registry_test.exs
│ ├── .formatter.exs
│ ├── README.md
│ ├── .gitignore
│ ├── mix.exs
│ └── priv
│ │ └── gettext
│ │ ├── en
│ │ └── LC_MESSAGES
│ │ │ └── errors.po
│ │ └── errors.pot
└── demon_spirit
│ ├── README.md
│ ├── priv
│ └── repo
│ │ ├── migrations
│ │ └── .formatter.exs
│ │ └── seeds.exs
│ ├── test
│ ├── test_helper.exs
│ └── support
│ │ └── data_case.ex
│ ├── .formatter.exs
│ ├── lib
│ ├── demon_spirit
│ │ └── application.ex
│ ├── chat_message.ex
│ ├── guest.ex
│ ├── demon_spirit.ex
│ └── metrics.ex
│ ├── .gitignore
│ └── mix.exs
├── thumbnail.png
├── screenshot.png
├── .dockerignore
├── renovate.json
├── .formatter.exs
├── test_prod.sh
├── config
├── test.exs
├── config.exs
├── releases.exs
├── prod.exs
└── dev.exs
├── next_tasks.txt
├── .gitignore
├── deprecated.txt
├── LICENSE
├── Issues.md
├── mix.exs
├── Dockerfile
├── README.md
└── .github
└── workflows
└── elixir.yaml
/apps/demon_spirit_game/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/css/custom-utilities.css:
--------------------------------------------------------------------------------
1 | /* */
2 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/css/custom-base-styles.css:
--------------------------------------------------------------------------------
1 | /* */
2 |
--------------------------------------------------------------------------------
/apps/demon_spirit/README.md:
--------------------------------------------------------------------------------
1 | # DemonSpirit
2 |
3 | **TODO: Add description**
4 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/page/index.html.heex:
--------------------------------------------------------------------------------
1 | Index
2 |
--------------------------------------------------------------------------------
/thumbnail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mreishus/demon_spirit_umbrella/HEAD/thumbnail.png
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mreishus/demon_spirit_umbrella/HEAD/screenshot.png
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/apps/demon_spirit/priv/repo/migrations/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto_sql],
3 | inputs: ["*.exs"]
4 | ]
5 |
--------------------------------------------------------------------------------
/apps/demon_spirit/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 | # Ecto.Adapters.SQL.Sandbox.mode(DemonSpirit.Repo, :manual)
3 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 | # Ecto.Adapters.SQL.Sandbox.mode(DemonSpirit.Repo, :manual)
3 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:phoenix],
3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/views/page_view.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.PageView do
2 | use DemonSpiritWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/views/layout_view.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.LayoutView do
2 | use DemonSpiritWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/views/session_view.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.SessionView do
2 | use DemonSpiritWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/.formatter.exs:
--------------------------------------------------------------------------------
1 | # Used by "mix format"
2 | [
3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mreishus/demon_spirit_umbrella/HEAD/apps/demon_spirit_web/assets/static/favicon.ico
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/static/sounds/move.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mreishus/demon_spirit_umbrella/HEAD/apps/demon_spirit_web/assets/static/sounds/move.mp3
--------------------------------------------------------------------------------
/apps/demon_spirit_web/test/demon_spirit_web/views/page_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.PageViewTest do
2 | use DemonSpiritWeb.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/test/demon_spirit_web/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.LayoutViewTest do
2 | use DemonSpiritWeb.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | Dockerfile*
4 | docker-compose*
5 | .dockerignore
6 | .git
7 | .gitignore
8 | README.md
9 | LICENSE
10 | .vscode
11 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/static/images/phoenix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mreishus/demon_spirit_umbrella/HEAD/apps/demon_spirit_web/assets/static/images/phoenix.png
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:recommended",
5 | "schedule:weekends"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/static/images/example_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mreishus/demon_spirit_umbrella/HEAD/apps/demon_spirit_web/assets/static/images/example_screenshot.png
--------------------------------------------------------------------------------
/apps/demon_spirit/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto],
3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
4 | subdirectories: ["priv/*/migrations"]
5 | ]
6 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/css/custom-components.css:
--------------------------------------------------------------------------------
1 | @import "./components/button";
2 | @import "./components/alert";
3 | @import "./components/form";
4 | @import "./components/card";
5 |
6 |
7 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require("postcss-import"),
4 | require("tailwindcss"),
5 | require("autoprefixer"),
6 | ],
7 | };
8 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto, :ecto_sql, :phoenix],
3 | subdirectories: ["apps/*"],
4 | plugins: [Phoenix.LiveView.HTMLFormatter],
5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
6 | ]
7 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/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 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/css/components/alert.css:
--------------------------------------------------------------------------------
1 | .alert {
2 | @apply border rounded-lg px-4 py-3;
3 | }
4 | .alert-error {
5 | @apply bg-red-100 border-red-300 text-red-700;
6 | }
7 | .alert-info {
8 | @apply bg-blue-100 border-blue-300 text-blue-700;
9 | }
10 |
--------------------------------------------------------------------------------
/test_prod.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | docker build --tag=demon-spirit:test .
3 | export SECRET_KEY_BASE="XoF7n0TfVhLJRYVNWUYqPMyW8nbDYWG7wva8ZtPTFaUrOR6D+AojVaWXJwnjdukz" # This isn't a 'real' secret
4 | docker run --publish 4000:4000 -e "SECRET_KEY_BASE=$SECRET_KEY_BASE" demon-spirit:test
5 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/test/demon_spirit_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.PageControllerTest do
2 | use DemonSpiritWeb.ConnCase
3 |
4 | test "GET /", %{conn: conn} do
5 | conn = get(conn, "/")
6 | assert html_response(conn, 302) =~ "redirected"
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/css/components/form.css:
--------------------------------------------------------------------------------
1 | .form-control {
2 | @apply bg-white border border-gray-400 rounded-lg py-1 px-2 mx-2 appearance-none leading-normal;
3 | }
4 | .form-control:focus {
5 | @apply outline-none shadow
6 | }
7 | /*
8 | w-full
9 | block
10 | focus:outline-none
11 | focus:shadow
12 | */
13 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/views/chat_view.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.ChatView do
2 | use DemonSpiritWeb, :view
3 |
4 | @doc """
5 | date_to_hms/1: Turn a DateTime into a string representing the hour and minute in UTC.
6 | """
7 | def date_to_hms(a) do
8 | DemonSpiritWeb.GameView.date_to_hms(a)
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/lib/demon_spirit_game/move.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritGame.Move do
2 | @moduledoc """
3 | Represents a move.
4 | from: {x, y} tuple of two ints: Destination coords
5 | to: {x, y} tuple of two ints: Destination coords
6 | card: Card being used to make the move
7 | """
8 | defstruct from: {}, to: {}, card: nil
9 | end
10 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/presence.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.Presence do
2 | @moduledoc """
3 | Standard implementation of Phoenix.Presence behaviour.
4 | See https://hexdocs.pm/phoenix/Phoenix.Presence.html
5 | """
6 | use Phoenix.Presence,
7 | otp_app: :demon_spirit_web,
8 | pubsub_server: DemonSpiritWeb.PubSub
9 | end
10 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/css/components/card.css:
--------------------------------------------------------------------------------
1 |
2 | .flip {
3 | transform: rotate(180deg);
4 | }
5 |
6 | td.card_cell.center {
7 | @apply bg-gray-800
8 | }
9 | .card.green td.card_cell.move {
10 | @apply bg-green-600
11 | }
12 | .card.blue td.card_cell.move {
13 | @apply bg-blue-600
14 | }
15 | .card.red td.card_cell.move {
16 | @apply bg-red-600
17 | }
18 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/show/timer.html.heex:
--------------------------------------------------------------------------------
1 | <%= if @active do %>
2 |
3 | <%= display_ms(@timer) %>
4 |
5 | <% else %>
6 |
7 | <%= display_ms(@timer) %>
8 |
9 | <% end %>
10 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/js/hooks/update_ding.js:
--------------------------------------------------------------------------------
1 | let move_audio = new Audio();
2 | move_audio.src = "/sounds/move.mp3";
3 |
4 | let moves = 0;
5 |
6 | let UpdateDing = {
7 | updated() {
8 | let new_moves = this.el.getAttribute("data-moves");
9 | if (new_moves != moves) {
10 | moves = new_moves;
11 | move_audio.play();
12 | }
13 | }
14 | };
15 | export default UpdateDing;
16 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/js/hooks/draggable_droppable.js:
--------------------------------------------------------------------------------
1 | // This is a workaround to phx-hook only allowing one hook.
2 | import {draggableMounted} from "./draggable.js";
3 | import {droppableMounted} from "./droppable.js";
4 | let DraggableDroppable = {
5 | mounted() {
6 | draggableMounted.apply(this);
7 | droppableMounted.apply(this);
8 | }
9 | };
10 |
11 | export default DraggableDroppable;
12 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/controllers/chat_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.ChatController do
2 | use DemonSpiritWeb, :controller
3 | alias DemonSpiritWeb.{LiveChatIndex}
4 | alias Phoenix.LiveView
5 |
6 | def index(conn, _params) do
7 | LiveView.Controller.live_render(conn, LiveChatIndex,
8 | session: %{"chat_name" => "chat_controller"}
9 | )
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/apps/demon_spirit/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 | # DemonSpirit.Repo.insert!(%DemonSpirit.SomeSchema{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/show/player.html.heex:
--------------------------------------------------------------------------------
1 | <%= case @player do %>
2 | <% nil -> %>
3 | (empty)
4 | <% %DemonSpirit.Guest{} -> %>
5 | <%= @player.name %> (g)
6 | <% %{type: :computer} -> %>
7 | <%= @player.name %>
8 | <% _ -> %>
9 | Unknown
10 | <% end %>
11 |
--------------------------------------------------------------------------------
/apps/demon_spirit/lib/demon_spirit/application.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpirit.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 | # DemonSpirit.Repo # Removing DB for now -MR 9/27/19
10 | children = []
11 |
12 | Supervisor.start_link(children, strategy: :one_for_one, name: DemonSpirit.Supervisor)
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_game_ui/game_info.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.GameInfo do
2 | @moduledoc """
3 | GameInfo holds an abbreviated version of a game in progress. It's what's everyone
4 | sees a list of in the lobby. We periodically update our GameInfo struct in the GameRegistry
5 | so others in the lobby can see the state of this game (did someone sit down, etc.)
6 | """
7 | defstruct name: nil, created_at: nil, white: nil, black: nil, winner: nil, status: nil
8 | end
9 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/css/app.css:
--------------------------------------------------------------------------------
1 | /* This file is for your main application css. */
2 |
3 | /* Removed Default phoenix CSS: @import "./phoenix.css"; */
4 | /* Live View Animations - Now Removed. Not sure if needed? Check docs for replacement. */
5 |
6 | /* Tailwind + Customizations */
7 | @import "tailwindcss/base";
8 | @import "./custom-base-styles.css";
9 |
10 | @import "tailwindcss/components";
11 | @import "./custom-components.css";
12 |
13 | @import "tailwindcss/utilities";
14 | @import "./custom-utilities.css";
15 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/js/hooks/phone_number.js:
--------------------------------------------------------------------------------
1 | let PhoneNumber = {
2 | mounted() {
3 | console.log("mounted running");
4 | this.el.addEventListener("input", e => {
5 | console.log("match running");
6 | let match = this.el.value
7 | .replace(/\D/g, "")
8 | .match(/^(\d{3})(\d{3})(\d{4})$/);
9 | if (match) {
10 | console.log("trying to set");
11 | this.el.value = `${match[1]}-${match[2]}-${match[3]}`;
12 | }
13 | });
14 | }
15 | };
16 | export default PhoneNumber;
17 |
18 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/test/demon_spirit_web/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.ErrorViewTest do
2 | use DemonSpiritWeb.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(DemonSpiritWeb.ErrorView, "404.html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(DemonSpiritWeb.ErrorView, "500.html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | # config :demon_spirit, DemonSpirit.Repo,
5 | # username: "postgres",
6 | # password: "postgres",
7 | # database: "demon_spirit_test",
8 | # hostname: "localhost",
9 | # pool: Ecto.Adapters.SQL.Sandbox
10 |
11 | # We don't run a server during test. If one is required,
12 | # you can enable the server option below.
13 | config :demon_spirit_web, DemonSpiritWeb.Endpoint,
14 | http: [port: 4002],
15 | server: false
16 |
17 | # Print only warnings and errors during test
18 | config :logger, level: :warning
19 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.ErrorView do
2 | use DemonSpiritWeb, :view
3 |
4 | # If you want to customize a particular status code
5 | # for a certain format, you may uncomment below.
6 | # def render("500.html", _assigns) do
7 | # "Internal Server Error"
8 | # end
9 |
10 | # By default, Phoenix returns the status message from
11 | # the template name. For example, "404.html" becomes
12 | # "Not Found".
13 | def template_not_found(template, _assigns) do
14 | Phoenix.Controller.status_message_from_template(template)
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_game_ui/game_ui_options.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.GameUIOptions do
2 | @moduledoc """
3 | Represents selected options while creating a game.
4 | """
5 | use Ecto.Schema
6 | import Ecto.Changeset
7 |
8 | schema "gameuioptions" do
9 | # Either "computer" or "human"
10 | field(:vs, :string)
11 | field(:computer_skill, :integer)
12 | end
13 |
14 | def changeset(base, params \\ %{}) do
15 | base
16 | |> cast(params, [:vs, :computer_skill])
17 | |> validate_required([:vs])
18 | |> validate_inclusion(:vs, ~w(human computer))
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/next_tasks.txt:
--------------------------------------------------------------------------------
1 | See also README.md for larger tasks.
2 |
3 | TODO
4 | - GameRegistry tests
5 | - (Partially done) Seating system (Track people leaving game in progress)
6 | - (Partially done) Let people stand up or switch seats if the game hasn't started yet
7 | - First move / Has Game Started
8 | - Clean up: comments on all functions
9 | - Types: %Game -> t.Game() ?
10 |
11 | DONE
12 | - game controller / list: Looks at GameRegistry and lists games using liveview
13 | - Render cards better (show the moves that can be done, flip for black)
14 | - CSS Revamp
15 | - Split out GameUIServer: Create GameUI module
16 | - Board flip
17 | - AI
18 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.PageController do
2 | use DemonSpiritWeb, :controller
3 |
4 | def index(conn, _params) do
5 | case conn do
6 | %{assigns: %{current_guest: _current_guest}} ->
7 | redirect(conn, to: Routes.game_path(conn, :index))
8 |
9 | %{assigns: %{current_user: _current_user}} ->
10 | redirect(conn, to: Routes.game_path(conn, :index))
11 |
12 | _ ->
13 | conn
14 | |> put_session(:redir_to, conn.request_path)
15 | |> redirect(to: Routes.session_path(conn, :new))
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/apps/demon_spirit/lib/chat_message.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpirit.ChatMessage do
2 | @moduledoc """
3 | Chat message.
4 | """
5 | use Ecto.Schema
6 | import Ecto.Changeset
7 |
8 | schema "chat_messages" do
9 | field(:name, :string)
10 | field(:message, :string)
11 | field(:created, :utc_datetime)
12 | # timestamps(type: :utc_datetime) # I can't seem to get these to work w/o a database
13 | end
14 |
15 | def changeset(base, params \\ %{}) do
16 | base
17 | |> cast(params, [:name, :message, :created])
18 | |> validate_required([:name, :message, :created])
19 | |> validate_length(:message, max: 256)
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/apps/demon_spirit/lib/guest.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpirit.Guest do
2 | @moduledoc """
3 | Guest represents a low-friction, ephemeral login.
4 | """
5 | use Ecto.Schema
6 | import Ecto.Changeset
7 |
8 | schema "guests" do
9 | field(:name, :string)
10 | end
11 |
12 | def changeset(base, params \\ %{}) do
13 | base
14 | |> cast(params, [:name, :id])
15 | |> validate_required([:name, :id])
16 | |> validate_length(:name, min: 3)
17 | end
18 |
19 | # Do I need an ID to prevent Name conflicts?
20 | # @primary_key {:id, :binary_id, autogenerate: false}
21 | # %DemonSpirit.Guest{name: name, id: Ecto.UUID.generate()}
22 | end
23 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/README.md:
--------------------------------------------------------------------------------
1 | # DemonSpiritGame
2 |
3 | **TODO: Add description**
4 |
5 | ## Installation
6 |
7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed
8 | by adding `demon_spirit_game` to your list of dependencies in `mix.exs`:
9 |
10 | ```elixir
11 | def deps do
12 | [
13 | {:demon_spirit_game, "~> 0.1.0"}
14 | ]
15 | end
16 | ```
17 |
18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
20 | be found at [https://hexdocs.pm/demon_spirit_game](https://hexdocs.pm/demon_spirit_game).
21 |
22 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/js/hooks/draggable.js:
--------------------------------------------------------------------------------
1 | let Draggable = {
2 | mounted() {
3 | let pushEvent = (x, y) => this.pushEvent(x, y);
4 |
5 | this.el.addEventListener('dragstart', e => {
6 | let sx = parseInt(this.el.dataset.x, 10);
7 | let sy = parseInt(this.el.dataset.y, 10);
8 | e.dataTransfer.setData('sx', sx);
9 | e.dataTransfer.setData('sy', sy);
10 | pushEvent('drag-piece', {sx, sy});
11 | });
12 | this.el.addEventListener('dragend', e => {
13 | pushEvent('drag-end', null);
14 | });
15 | }
16 | };
17 | let m = Draggable.mounted;
18 | export { m as draggableMounted };
19 | export default Draggable;
20 |
--------------------------------------------------------------------------------
/apps/demon_spirit/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | demon_spirit-*.tar
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Vim
2 | *.swp
3 | *.swo
4 |
5 | # The directory Mix will write compiled artifacts to.
6 | /_build/
7 |
8 | # If you run "mix test --cover", coverage assets end up here.
9 | /cover/
10 |
11 | # The directory Mix downloads your dependencies sources to.
12 | /deps/
13 |
14 | # Where 3rd-party dependencies like ExDoc output generated docs.
15 | /doc/
16 |
17 | # Ignore .fetch files in case you like to edit your project deps locally.
18 | /.fetch
19 |
20 | # If the VM crashes, it generates a dump, let's ignore it too.
21 | erl_crash.dump
22 |
23 | # Also ignore archive artifacts (built via "mix archive.build").
24 | *.ez
25 |
26 | # Sobelow
27 | .sobelow
28 |
29 | # language server
30 | .elixir_ls
31 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where third-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | demon_spirit_game-*.tar
24 |
25 |
--------------------------------------------------------------------------------
/deprecated.txt:
--------------------------------------------------------------------------------
1 | warning: DemonSpiritWeb.Endpoint.subscribe/1 is deprecated.
2 | DemonSpiritWeb.Endpoint.subscribe/2 is deprecated, please call Phoenix.PubSub
3 | directly instead lib/demon_spirit_web/live/live_game_index.ex:18:
4 | DemonSpiritWeb.LiveGameIndex.mount/3
5 |
6 | warning: DemonSpiritWeb.Endpoint.subscribe/1 is deprecated.
7 | DemonSpiritWeb.Endpoint.subscribe/2 is deprecated, please call Phoenix.PubSub
8 | directly instead lib/demon_spirit_web/live/live_chat_index.ex:16:
9 | DemonSpiritWeb.LiveChatIndex.mount/3
10 |
11 | warning: DemonSpiritWeb.Endpoint.subscribe/1 is deprecated.
12 | DemonSpiritWeb.Endpoint.subscribe/2 is deprecated, please call Phoenix.PubSub
13 | directly instead lib/demon_spirit_web/live/live_game_show.ex:18:
14 | DemonSpiritWeb.LiveGameShow.mount/3
15 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/js/hooks/droppable.js:
--------------------------------------------------------------------------------
1 | let Droppable = {
2 | mounted() {
3 |
4 | let pushEvent = (x, y) => this.pushEvent(x, y);
5 |
6 | this.el.addEventListener('dragover', function(e) {
7 | e.preventDefault();
8 | });
9 |
10 | this.el.addEventListener('drop', function(e) {
11 | // Source X, Y ( What piece was picked up)
12 | let sx = parseInt(e.dataTransfer.getData('sx'), 10);
13 | let sy = parseInt(e.dataTransfer.getData('sy'), 10);
14 |
15 | // Target X, Y (What square was dropped on)
16 | let tx = parseInt(e.target.dataset.x, 10);
17 | let ty = parseInt(e.target.dataset.y, 10);
18 | pushEvent('drop-piece', {sx, sy, tx, ty});
19 | });
20 | }
21 | };
22 | let m = Droppable.mounted;
23 | export { m as droppableMounted };
24 | export default Droppable;
25 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/README.md:
--------------------------------------------------------------------------------
1 | # DemonSpiritWeb
2 |
3 | To start your Phoenix server:
4 |
5 | * Install dependencies with `mix deps.get`
6 | * Create and migrate your database with `mix ecto.setup`
7 | * Install Node.js dependencies with `cd assets && npm install`
8 | * Start Phoenix endpoint with `mix phx.server`
9 |
10 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
11 |
12 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
13 |
14 | ## Learn more
15 |
16 | * Official website: http://www.phoenixframework.org/
17 | * Guides: https://hexdocs.pm/phoenix/overview.html
18 | * Docs: https://hexdocs.pm/phoenix
19 | * Mailing list: http://groups.google.com/group/phoenix-talk
20 | * Source: https://github.com/phoenixframework/phoenix
21 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/authenticator.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.Authenticator do
2 | @moduledoc """
3 | Authenticator looks for guest or user information in the
4 | session, and moves it to "assigns" of the connection.
5 |
6 | Note: The entire Guest object is stored in the session,
7 | because it is not persisted anywhere!
8 | """
9 | import Plug.Conn
10 | def init(opts), do: opts
11 |
12 | def call(conn, _opts) do
13 | # User case (?)
14 | # user =
15 | # conn
16 | # |> get_session(:user_id)
17 | # |> case do
18 | # nil -> nil
19 | # id -> Auction.get_user(id)
20 | # end
21 | user = nil
22 | guest = conn |> get_session(:current_guest)
23 |
24 | conn
25 | |> assign(:current_user, user)
26 | |> assign(:current_guest, guest)
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/static/images/black_pawn.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/static/images/white_pawn.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.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 DemonSpiritWeb.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: :demon_spirit_web
24 | end
25 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/show/card.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%= @card.name %>
5 |
6 |
7 |
8 |
9 | <%= for i <- 0..4 do %>
10 |
11 | <%= for j <- 0..4 do %>
12 | <%
13 | x = j - 2
14 | y = (i * -1) + 2
15 | %>
16 |
17 |
18 | <% end %>
19 |
20 | <% end %>
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/show/ready_player_column.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
" rounded h-32 flex flex-col justify-between py-2"}>
3 |
4 | <%= @color %>
5 |
6 |
7 | <%= render "show/player.html", player: @player %>
8 |
9 |
10 | <%= if @player != nil do %>
11 |
12 | <%= if @ready do %>
13 |
14 | Ready.
15 |
16 | <% else %>
17 |
18 | Not Ready.
19 |
20 | <% end %>
21 |
22 | <% end %>
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/apps/demon_spirit/lib/demon_spirit.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpirit do
2 | alias DemonSpirit.{Guest, ChatMessage}
3 | alias Ecto.Changeset
4 |
5 | @moduledoc """
6 | DemonSpirit keeps the contexts that define your domain
7 | and business logic.
8 |
9 | Contexts are also responsible for managing your data, regardless
10 | if it comes from the database, an external API or others.
11 | """
12 |
13 | def new_guest, do: Guest.changeset(%Guest{})
14 |
15 | def fake_insert_guest(params) do
16 | %Guest{}
17 | |> Guest.changeset(params)
18 | |> Changeset.apply_action(:insert)
19 | end
20 |
21 | def new_chat_message, do: ChatMessage.changeset(%ChatMessage{})
22 |
23 | def fake_insert_chat_message(params) do
24 | params = params |> Map.put("created", DateTime.utc_now())
25 |
26 | %ChatMessage{}
27 | |> ChatMessage.changeset(params)
28 | |> Changeset.apply_action(:insert)
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/lib/demon_spirit_game/application.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritGame.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 | {Registry, keys: :unique, name: DemonSpiritGame.GameRegistry},
11 | {Registry, keys: :unique, name: DemonSpiritGame.ChatRegistry},
12 | DemonSpiritGame.GameSupervisor,
13 | DemonSpiritGame.ChatSupervisor
14 | # Starts a worker by calling: DemonSpiritGame.Worker.start_link(arg)
15 | # {DemonSpiritGame.Worker, arg}
16 | ]
17 |
18 | :ets.new(:games, [:public, :named_table])
19 |
20 | # See https://hexdocs.pm/elixir/Supervisor.html
21 | # for other strategies and supported options
22 | opts = [strategy: :one_for_one, name: DemonSpiritGame.Supervisor]
23 | Supervisor.start_link(children, opts)
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/lib/demon_spirit_game/chat.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritGame.Chat do
2 | @moduledoc """
3 | Chat: A structure and some stateless functions for defining
4 | a chat room.
5 |
6 | A chat room is essentially an append-only log of messages that
7 | can be queried. If the number of messages goes over the new limit,
8 | the newest message will kick out the oldest message.
9 | """
10 | defstruct messages: [], limit: 250, chat_name: ""
11 |
12 | @doc """
13 | messages/1: Get a list of chat messages.
14 | Input: %Chat{}
15 | Output: [ any ], newest at last.
16 | """
17 | def messages(chat) do
18 | chat.messages |> Enum.reverse()
19 | end
20 |
21 | @doc """
22 | add_message/2: Add a message to the chat.
23 | Input: %Chat{}
24 | Input: message (any)
25 | Output: %Chat{}
26 | """
27 | def add_message(chat, message) do
28 | new_messages = [message | chat.messages] |> Enum.take(chat.limit)
29 | %{chat | messages: new_messages}
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritGame.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :demon_spirit_game,
7 | version: "0.1.0",
8 | build_path: "../../_build",
9 | config_path: "../../config/config.exs",
10 | deps_path: "../../deps",
11 | lockfile: "../../mix.lock",
12 | elixir: "~> 1.9",
13 | start_permanent: Mix.env() == :prod,
14 | deps: deps()
15 | ]
16 | end
17 |
18 | # Run "mix help compile.app" to learn about applications.
19 | def application do
20 | [
21 | extra_applications: [:logger],
22 | mod: {DemonSpiritGame.Application, []}
23 | ]
24 | end
25 |
26 | # Run "mix help deps" to learn about dependencies.
27 | defp deps do
28 | [
29 | # {:dep_from_hexpm, "~> 0.3.0"},
30 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"},
31 | # {:sibling_app_in_umbrella, in_umbrella: true}
32 | ]
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/css/components/button.css:
--------------------------------------------------------------------------------
1 | .btn {
2 | @apply inline-block px-3 py-2 rounded-lg text-sm tracking-wider font-semibold;
3 | }
4 | .btn:focus {
5 | @apply outline-none shadow;
6 | }
7 |
8 | .btn-primary {
9 | @apply bg-blue-700 text-white;
10 | }
11 | .btn-primary:hover {
12 | @apply bg-blue-400;
13 | }
14 | .btn-primary:active {
15 | @apply bg-blue-600;
16 | }
17 |
18 | .btn-red {
19 | @apply bg-red-700 text-white;
20 | }
21 | .btn-red:hover {
22 | @apply bg-red-400;
23 | }
24 | .btn-red:active {
25 | @apply bg-red-600;
26 | }
27 |
28 | .btn-green {
29 | @apply bg-green-700 text-white;
30 | }
31 | .btn-green:hover {
32 | @apply bg-green-400;
33 | }
34 | .btn-green:active {
35 | @apply bg-green-600;
36 | }
37 |
38 | .btn-gray {
39 | @apply bg-gray-400 text-gray-900;
40 | }
41 | .btn-gray:hover {
42 | @apply bg-gray-300;
43 | }
44 | .btn-gray:active {
45 | @apply bg-gray-500;
46 | }
47 |
48 |
49 | @screen sm {
50 | .xxxxbtn {
51 | @apply text-base
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/test/card_test.exs:
--------------------------------------------------------------------------------
1 | defmodule CardTest do
2 | use ExUnit.Case, async: true
3 |
4 | doctest DemonSpiritGame.Card, import: true
5 | alias DemonSpiritGame.{Card}
6 |
7 | setup do
8 | drake = %Card{
9 | id: 5,
10 | name: "Drake",
11 | moves: [{-2, 1}, {2, 1}, {-1, -1}, {1, -1}],
12 | color: :green
13 | }
14 |
15 | hiero = %Card{
16 | id: 7,
17 | name: "Hierodula",
18 | moves: [{-1, 1}, {1, 1}, {0, -1}],
19 | color: :green
20 | }
21 |
22 | %{drake: drake, hiero: hiero}
23 | end
24 |
25 | describe "flip/1" do
26 | test "flips a card", %{drake: drake} do
27 | flipped_drake = Card.flip(drake)
28 | assert flipped_drake.moves == [{2, -1}, {-2, -1}, {1, 1}, {-1, 1}]
29 | end
30 | end
31 |
32 | describe "by_name/1" do
33 | test "finds a card by name", %{drake: drake, hiero: hiero} do
34 | assert Card.by_name("Hierodula") == {:ok, hiero}
35 | assert Card.by_name("Drake") == {:ok, drake}
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "repository": {},
3 | "license": "MIT",
4 | "scripts": {
5 | "deploy": "webpack --mode production",
6 | "watch": "webpack --mode development --watch"
7 | },
8 | "dependencies": {
9 | "debounce": "^1.2.0",
10 | "phoenix": "file:../../../deps/phoenix",
11 | "phoenix_html": "file:../../../deps/phoenix_html",
12 | "phoenix_live_view": "file:../../../deps/phoenix_live_view"
13 | },
14 | "devDependencies": {
15 | "autoprefixer": "^10.4.16",
16 | "@babel/core": "^7.11.1",
17 | "babel-loader": "^8.1.0",
18 | "@babel/preset-env": "^7.11.0",
19 | "copy-webpack-plugin": "^6.0.3",
20 | "css-loader": "^6.8.1",
21 | "mini-css-extract-plugin": "^1.6.2",
22 | "css-minimizer-webpack-plugin": "^5.0.1",
23 | "postcss": "^8.4.31",
24 | "postcss-import": "^14.0.0",
25 | "postcss-loader": "^4.3.0",
26 | "tailwindcss": "^3.3.5",
27 | "terser-webpack-plugin": "^5.3.9",
28 | "webpack": "^5.89.0",
29 | "webpack-cli": "^5.1.4"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/views/card_view.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.CardView do
2 | use DemonSpiritWeb, :view
3 |
4 | def outer_card_class(card_class, card_color) do
5 | "card #{card_class} bg-gray-200 border shadow-md rounded inline-block w-20 md:w-24 lg:w-32 #{card_color} text-sm"
6 | end
7 |
8 | def inner_card_class(flip) do
9 | base_class = "p-2 pt-0"
10 | flip_class = if flip, do: " flip", else: ""
11 |
12 | "#{base_class}#{flip_class}"
13 | end
14 |
15 | def span_class(card_name) do
16 | base_class = "title font-semibold py-1 inline-block"
17 | size_class = if String.length(card_name) > 8, do: " text-md", else: " text-lg"
18 |
19 | "#{base_class}#{size_class}"
20 | end
21 |
22 | def cell_class(x, y, card_moves) do
23 | base_class = "card_cell border border-black w-1/5 p-0"
24 | move_class = if {x, y} in card_moves, do: " move", else: ""
25 | center_class = if {x, y} == {0, 0}, do: " center", else: ""
26 |
27 | "#{base_class}#{move_class}#{center_class}"
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | demon_spirit_web-*.tar
24 |
25 | # If NPM crashes, it generates a log, let's ignore it too.
26 | npm-debug.log
27 |
28 | # The directory NPM downloads your dependencies sources to.
29 | /assets/node_modules/
30 |
31 | # Since we are building assets from web/static,
32 | # we ignore priv/static. You may want to comment
33 | # this depending on your deployment strategy.
34 | /priv/static/
35 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: [
3 | "./js/**/*.html",
4 | "./js/**/*.jsx",
5 | "./js/**/*.js",
6 | "./js/**/*.tsx",
7 | "./js/**/*.ts",
8 | "../lib/demon_spirit_web/templates/**/*.eex",
9 | "../lib/demon_spirit_web/templates/**/*.leex",
10 | "../lib/demon_spirit_web/templates/**/*.heex",
11 | "../lib/demon_spirit_web/views/**/*.ex",
12 | "./css/components/*.css",
13 | // etc.
14 | ],
15 | theme: {
16 | extend: {
17 | colors: {
18 | "black-80": "rgba(0,0,0,0.8)",
19 | "black-70": "rgba(0,0,0,0.7)",
20 | "black-60": "rgba(0,0,0,0.6)",
21 | "black-50": "rgba(0,0,0,0.5)",
22 | "black-40": "rgba(0,0,0,0.4)",
23 | "blue-400-50": "rgba(99,179,237,0.5)",
24 | },
25 | spacing: {
26 | 72: "18rem",
27 | 96: "24rem",
28 | 112: "28rem",
29 | 128: "32rem",
30 | },
31 | zIndex: {
32 | 2000: 2000,
33 | 10000: 10000,
34 | },
35 | },
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/apps/demon_spirit/lib/metrics.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpirit.Metrics do
2 | @moduledoc """
3 | Send Data to honeycomb when certain events occur.
4 | """
5 | def game_created() do
6 | Task.async(fn ->
7 | %{
8 | event: :game_created,
9 | when: DateTime.utc_now(),
10 | env: env()
11 | }
12 | |> send_to_honeycomb()
13 | end)
14 | end
15 |
16 | def send_to_honeycomb(params) do
17 | with {:ok, api_key} <- System.fetch_env("HONEYCOMB_APIKEY"),
18 | {:ok, dataset} <- System.fetch_env("HONEYCOMB_DATASET") do
19 | send_to_honeycomb_(params, api_key, dataset)
20 | end
21 | end
22 |
23 | defp send_to_honeycomb_(params, api_key, dataset) do
24 | body = Poison.encode!(params)
25 | url = "https://api.honeycomb.io/1/events/" <> dataset
26 |
27 | headers = [
28 | {"Content-type", "application/json"},
29 | {"X-Honeycomb-Team", api_key}
30 | ]
31 |
32 | Task.async(fn ->
33 | HTTPoison.post(url, body, headers, [])
34 | end)
35 | end
36 |
37 | defp env do
38 | Application.get_env(:demon_spirit, :env)
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Matthew Reishus
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/session/new.html.heex:
--------------------------------------------------------------------------------
1 | Welcome!
2 |
3 | Please log in as a guest account.
4 |
5 |
6 | <%= form_for @guest, Routes.session_path(@conn, :create), fn f -> %>
7 | <%= label f, :name, class: "block font-semibold mb-2" %>
8 | <%= text_input f, :name, placeholder: "Name", autofocus: true, required: true, class: 'form-control ml-0' %>
9 | <%= error_tag f, :name %>
10 |
11 | <%= submit "Log In As Guest", class: "btn btn-primary" %>
12 |
13 | <% end %>
14 |
15 |
16 |
What's this site?
17 |
18 | Demon Spirit is an
19 | abstract strategy game , something like chess with different rules, a smaller board, and random starting conditions.
20 |
21 |
22 | Play against a friend or the computer AI.
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.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 | 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 channels
21 | use Phoenix.ChannelTest
22 |
23 | # The default endpoint for testing
24 | @endpoint DemonSpiritWeb.Endpoint
25 | end
26 | end
27 |
28 | # setup tags do
29 | # # :ok = Ecto.Adapters.SQL.Sandbox.checkout(DemonSpirit.Repo)
30 |
31 | # # unless tags[:async] do
32 | # # Ecto.Adapters.SQL.Sandbox.mode(DemonSpirit.Repo, {:shared, self()})
33 | # # end
34 |
35 | # :ok
36 | # end
37 | end
38 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/test/chat_supervisor_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ChatSupervisorTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias DemonSpiritGame.{ChatSupervisor, ChatServer}
5 |
6 | describe "start_chat/1" do
7 | test "spawns a chat server process" do
8 | chat_name = "chat-#{:rand.uniform(1000)}"
9 | assert {:ok, _pid} = ChatSupervisor.start_chat(chat_name)
10 |
11 | via = ChatServer.via_tuple(chat_name)
12 | assert GenServer.whereis(via) |> Process.alive?()
13 | end
14 |
15 | test "returns an error if chat is already started" do
16 | chat_name = "chat-#{:rand.uniform(1000)}"
17 |
18 | assert {:ok, pid} = ChatSupervisor.start_chat(chat_name)
19 | assert {:error, {:already_started, ^pid}} = ChatSupervisor.start_chat(chat_name)
20 | end
21 | end
22 |
23 | describe "stop_chat" do
24 | test "terminates the process normally" do
25 | chat_name = "chat-#{:rand.uniform(1000)}"
26 | {:ok, _pid} = ChatSupervisor.start_chat(chat_name)
27 | via = ChatServer.via_tuple(chat_name)
28 |
29 | assert :ok = ChatSupervisor.stop_chat(chat_name)
30 | refute GenServer.whereis(via)
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/live/live_game_index.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.LiveGameIndex do
2 | @moduledoc """
3 | LiveGameIndex: LiveView for the index action of the /game page.
4 | Essentially, it's the lobby that shows all games that have been created.
5 | """
6 |
7 | use Phoenix.LiveView
8 | require Logger
9 | alias DemonSpiritWeb.{Endpoint, GameRegistry, GameView}
10 |
11 | @topic "game-registry"
12 |
13 | def render(assigns) do
14 | GameView.render("live_index.html", assigns)
15 | end
16 |
17 | def mount(_params, %{"guest" => guest}, socket) do
18 | if connected?(socket), do: Endpoint.subscribe(@topic)
19 | games = GameRegistry.list()
20 |
21 | {:ok,
22 | assign(socket,
23 | games: games,
24 | guest: guest
25 | )}
26 | end
27 |
28 | # Interestingly, the message format I get here is different
29 | # than in live_game_show. Has something to do with Endpoint.broadcast
30 | # vs PubSub.broadcast.. not sure of the diffs here, need to research.
31 | def handle_info({:state_update, _map}, socket) do
32 | games = GameRegistry.list()
33 | {:noreply, assign(socket, games: games)}
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/live_index.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
New Game
5 |
6 | <%= link "Create a new game", to: Routes.game_path(@socket, :new),
7 | class: "btn btn-primary mt-4 text-xl shadow-md" %>
8 |
9 |
10 |
Current Games
11 | <%= render "index/game_table.html", socket: @socket, games: @games %>
12 |
13 |
14 | <%= render "index/about.html", socket: @socket %>
15 |
16 |
17 |
18 |
19 |
20 |
Lobby Chat
21 |
22 | <%= live_render(@socket, DemonSpiritWeb.LiveChatIndex,
23 | session: %{"chat_name" => "lobby", "guest" => @guest},
24 | id: "lobby",
25 | container: {:div, class: "h-full mt-4"}
26 | ) %>
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_game_ui/game_ui_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.GameUISupervisor do
2 | @moduledoc """
3 | A supervisor that starts `GameUIServer` processes dynamically.
4 | """
5 |
6 | use DynamicSupervisor
7 |
8 | alias DemonSpiritWeb.{GameUIServer, GameUIOptions}
9 |
10 | def start_link(_arg) do
11 | DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
12 | end
13 |
14 | def init(:ok) do
15 | DynamicSupervisor.init(strategy: :one_for_one)
16 | end
17 |
18 | @doc """
19 | Starts a `GameUIServer` process and supervises it.
20 | """
21 | def start_game(game_name, game_opts = %GameUIOptions{}) do
22 | child_spec = %{
23 | id: GameUIServer,
24 | start: {GameUIServer, :start_link, [game_name, game_opts]},
25 | restart: :transient
26 | }
27 |
28 | DynamicSupervisor.start_child(__MODULE__, child_spec)
29 | end
30 |
31 | @doc """
32 | Terminates the `GameUIServer` process normally. It won't be restarted.
33 | """
34 | def stop_game(game_name) do
35 | # :ets.delete(:games, game_name)
36 |
37 | child_pid = GameUIServer.gameui_pid(game_name)
38 | DynamicSupervisor.terminate_child(__MODULE__, child_pid)
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/js/hooks/chat_scroll.js:
--------------------------------------------------------------------------------
1 | import { debounce } from "debounce";
2 |
3 | let isAtBottom = {};
4 |
5 | const scrollHandler = e => {
6 | let chatId = e.target.dataset.chatId;
7 | if (!chatId) {
8 | return;
9 | }
10 |
11 | isAtBottom[chatId] =
12 | e.target.scrollTop + e.target.clientHeight > e.target.scrollHeight - 12;
13 | };
14 |
15 | let ChatScroll = {
16 | mounted() {
17 | let el = this.el;
18 | if (!el.dataset.chatId) {
19 | console.warn(
20 | "ChatScroll hook: Please place a unique [data-chat-id] element on the hooked div. Refusing to operate."
21 | );
22 | return;
23 | }
24 |
25 | // Scroll to bottom when first mounting
26 | el.scrollTop = el.scrollHeight;
27 |
28 | // Whenever scroll position changes,
29 | // mark if we're at the bottom or not
30 | el.addEventListener("scroll", debounce(scrollHandler, 150));
31 | },
32 | updated() {
33 | // Scroll to bottom when a new message comes in
34 | let el = this.el;
35 | let chatId = el.dataset.chatId;
36 | if (!chatId) {
37 | return;
38 | }
39 |
40 | if (isAtBottom[chatId]) {
41 | el.scrollTop = el.scrollHeight;
42 | }
43 | }
44 | };
45 | export default ChatScroll;
46 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | # channel "room:*", DemonSpiritWeb.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 | def connect(_params, socket, _connect_info) do
19 | {:ok, socket}
20 | end
21 |
22 | # Socket id's are topics that allow you to identify all sockets for a given user:
23 | #
24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}"
25 | #
26 | # Would allow you to broadcast a "disconnect" event and terminate
27 | # all active sockets and channels for a given user:
28 | #
29 | # DemonSpiritWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
30 | #
31 | # Returning `nil` makes this socket anonymous.
32 | def id(_socket), do: nil
33 | end
34 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/chat/live_index.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
26 |
27 |
28 | <%= form_for @chat_message, "#", [phx_submit: :message], fn f -> %>
29 | <%= text_input f, :message,
30 | placeholder: "your message..",
31 | class: "form-control mt-2 mx-0 bg-orange-200 w-full text-sm",
32 | autofocus: true
33 | %>
34 | <% end %>
35 |
36 |
37 |
--------------------------------------------------------------------------------
/Issues.md:
--------------------------------------------------------------------------------
1 | # Issues
2 |
3 | ## Most Important Issues
4 |
5 | - No indication when a player has left the game (closed the browser window).
6 | You could be waiting a long time for a missing opponent.
7 | - Can't specify time controls when making a game
8 | - Automated tests missing on web layer.
9 | - No CI/CD pipeline.
10 | - Kubernetes hosting does not use Deployment resource, only ReplicaSet. No
11 | clear strategy for rolling out new deployments at the moment.
12 | - Misalignment of squares (Highlight doesn't line up properly)
13 |
14 | ## Fixed Issues
15 |
16 | - If you have a move that could apply to either card, when you use it, you
17 | don't get to pick which card you want to use.
18 | - No way to move pieces on the iphone: Clicking does nothing.
19 | - Link to game in waiting window is missing domain name.
20 | - Drag and drop does not work.
21 | - Players should not see what piece their opponents are selecting.
22 | - Cards with longer names have a smaller font to avoid line wrapping, however
23 | there is still some minor visual shifting that's annoying.
24 | - Add a chat window?
25 | - Games don't seem to be dying out even when left alone for hours. Could be
26 | related to the :ping for vs computer games? (Possible fix implemented)
27 | - No chess timer.
28 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/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 CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
6 | const CopyWebpackPlugin = require("copy-webpack-plugin");
7 |
8 | module.exports = (env, options) => ({
9 | optimization: {
10 | minimizer: [new TerserPlugin({ parallel: true }), new CssMinimizerPlugin()],
11 | },
12 | entry: {
13 | "./js/app.js": glob.sync("./vendor/**/*.js").concat(["./js/app.js"]),
14 | },
15 | output: {
16 | filename: "app.js",
17 | path: path.resolve(__dirname, "../priv/static/js"),
18 | },
19 | module: {
20 | rules: [
21 | {
22 | test: /\.js$/,
23 | exclude: /node_modules/,
24 | use: {
25 | loader: "babel-loader",
26 | },
27 | },
28 | {
29 | test: /\.css$/,
30 | use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"],
31 | },
32 | ],
33 | },
34 | plugins: [
35 | new MiniCssExtractPlugin({ filename: "../css/app.css" }),
36 | new CopyWebpackPlugin({
37 | patterns: [{ from: "static/", to: "../" }],
38 | }),
39 | ],
40 | });
41 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.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 data structures 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 | alias DemonSpiritWeb.Router.Helpers, as: Routes
24 |
25 | # The default endpoint for testing
26 | @endpoint DemonSpiritWeb.Endpoint
27 | end
28 | end
29 |
30 | setup _tags do
31 | # :ok = Ecto.Adapters.SQL.Sandbox.checkout(DemonSpirit.Repo)
32 |
33 | # unless tags[:async] do
34 | # Ecto.Adapters.SQL.Sandbox.mode(DemonSpirit.Repo, {:shared, self()})
35 | # end
36 |
37 | {:ok, conn: Phoenix.ConnTest.build_conn()}
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/application.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.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 | # List all child processes to be supervised
10 | children = [
11 | {Phoenix.PubSub, name: DemonSpiritWeb.PubSub},
12 | # Start the endpoint when the application starts
13 | DemonSpiritWeb.Endpoint,
14 | # Starts a worker by calling: DemonSpiritWeb.Worker.start_link(arg)
15 | # {DemonSpiritWeb.Worker, arg},
16 | {Registry, keys: :unique, name: DemonSpiritWeb.GameUIRegistry},
17 | DemonSpiritWeb.GameUISupervisor,
18 | DemonSpiritWeb.GameRegistry,
19 | DemonSpiritWeb.Presence
20 | ]
21 |
22 | # See https://hexdocs.pm/elixir/Supervisor.html
23 | # for other strategies and supported options
24 | opts = [strategy: :one_for_one, name: DemonSpiritWeb.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 | DemonSpiritWeb.Endpoint.config_change(changed, removed)
32 | :ok
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/lib/demon_spirit_game/chat_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritGame.ChatSupervisor do
2 | @moduledoc """
3 | A supervisor that starts `ChatServer` processes dynamically.
4 | """
5 |
6 | use DynamicSupervisor
7 |
8 | alias DemonSpiritGame.ChatServer
9 |
10 | def start_link(_arg) do
11 | DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
12 | end
13 |
14 | def init(:ok) do
15 | DynamicSupervisor.init(strategy: :one_for_one)
16 | end
17 |
18 | @doc """
19 | Starts a `ChatServer` process and supervises it.
20 | """
21 | def start_chat(chat_name) do
22 | child_spec = %{
23 | id: ChatServer,
24 | start: {ChatServer, :start_link, [chat_name]},
25 | restart: :transient
26 | }
27 |
28 | DynamicSupervisor.start_child(__MODULE__, child_spec)
29 | end
30 |
31 | def start_chat_if_needed(chat_name) do
32 | pid = ChatServer.chat_pid(chat_name)
33 |
34 | if pid == nil do
35 | start_chat(chat_name)
36 | else
37 | {:ok, pid}
38 | end
39 | end
40 |
41 | @doc """
42 | Terminates the `ChatServer` process normally. It won't be restarted.
43 | """
44 | def stop_chat(chat_name) do
45 | child_pid = ChatServer.chat_pid(chat_name)
46 | DynamicSupervisor.terminate_child(__MODULE__, child_pid)
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/controllers/session_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.SessionController do
2 | use DemonSpiritWeb, :controller
3 |
4 | def new(conn, _params) do
5 | guest = DemonSpirit.new_guest()
6 | render(conn, "new.html", guest: guest)
7 | end
8 |
9 | def create(conn, %{"guest" => params}) do
10 | # Guests get a hardcoded random id
11 | params = Map.put(params, "id", :rand.uniform(10_000_000))
12 |
13 | case DemonSpirit.fake_insert_guest(params) do
14 | {:ok, guest} ->
15 | conn
16 | |> put_session(:current_guest, guest)
17 | |> put_flash(:info, "Logged in as #{guest.name} (guest).")
18 | |> redirect_to_destination
19 |
20 | {:error, guest} ->
21 | conn
22 | |> put_flash(:error, "Unable to log in as guest.")
23 | |> render("new.html", guest: guest)
24 | end
25 | end
26 |
27 | def delete(conn, _params) do
28 | conn
29 | |> clear_session()
30 | |> configure_session(drop: true)
31 | |> put_flash(:info, "Logged out.")
32 | |> redirect(to: "/")
33 | end
34 |
35 | def redirect_to_destination(conn) do
36 | destination = get_session(conn, :redir_to) || Routes.game_path(conn, :index)
37 |
38 | conn
39 | |> put_session(:redir_to, nil)
40 | |> redirect(to: destination)
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/static/images/white_king.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule DemonSpirit.Umbrella.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | apps_path: "apps",
7 | start_permanent: Mix.env() == :prod,
8 | deps: deps(),
9 | version: "0.1.0",
10 | releases: [
11 | demon_spirit_umbrella: [
12 | applications: [
13 | demon_spirit_web: :permanent,
14 | demon_spirit_game: :permanent,
15 | demon_spirit: :permanent
16 | ]
17 | ]
18 | ]
19 | ]
20 | end
21 |
22 | # Dependencies can be Hex packages:
23 | #
24 | # {:mydep, "~> 0.3.0"}
25 | #
26 | # Or git/path repositories:
27 | #
28 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"}
29 | #
30 | # Type "mix help deps" for more examples and options.
31 | #
32 | # Dependencies listed here are available only for this project
33 | # and cannot be accessed from applications inside the apps folder
34 | defp deps do
35 | [
36 | {:ex_check, ">= 0.0.0", only: :dev, runtime: false},
37 | {:credo, "~> 1.7.0", only: [:dev, :test], runtime: false},
38 | {:dialyxir, ">= 1.0.0", only: :dev, runtime: false},
39 | # Temporarily set the manager option for this so it compiles
40 | # https://elixirforum.com/t/elixir-v1-15-0-released/56584/4?u=axelson
41 | {:ssl_verify_fun, ">= 0.0.0", manager: :rebar3, override: true}
42 | ]
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/test/demon_spirit_game_ui/game_ui_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GameUiTest do
2 | use ExUnit.Case, async: true
3 | alias DemonSpiritWeb.{GameUI, GameUIOptions}
4 |
5 | describe "clarify_cancel/2" do
6 | test "current player allowed to cancel clarification" do
7 | game_name = generate_game_name()
8 | g = GameUI.new(game_name, %GameUIOptions{vs: "human"})
9 |
10 | g =
11 | %{
12 | g
13 | | white: :p1,
14 | black: :p2,
15 | selected: :selected_junk,
16 | move_dest: :move_dest_junk,
17 | moves_need_clarify: :moves_need_clarify_junk
18 | }
19 | |> GameUI.clarify_cancel(:p1)
20 |
21 | assert g.moves_need_clarify == nil
22 | assert g.selected == nil
23 | assert g.move_dest == []
24 | end
25 |
26 | test "opponent not allowed to cancel clarification" do
27 | game_name = generate_game_name()
28 | g = GameUI.new(game_name, %GameUIOptions{vs: "human"})
29 |
30 | g = %{
31 | g
32 | | white: :p1,
33 | black: :p2,
34 | selected: :selected_junk,
35 | move_dest: :move_dest_junk,
36 | moves_need_clarify: :moves_need_clarify_junk
37 | }
38 |
39 | g_new = g |> GameUI.clarify_cancel(:p2)
40 |
41 | assert g == g_new
42 | end
43 | end
44 |
45 | defp generate_game_name do
46 | "game-#{:rand.uniform(1_000_000)}"
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/static/images/black_king.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/test/chat_server_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ChatServerTest do
2 | use ExUnit.Case, async: true
3 | alias DemonSpiritGame.{ChatServer}
4 |
5 | describe "start_link/1" do
6 | test "spawns a process" do
7 | chat_name = generate_chat_name()
8 |
9 | assert {:ok, _pid} = ChatServer.start_link(chat_name)
10 | end
11 |
12 | test "each name can only have one process" do
13 | chat_name = generate_chat_name()
14 |
15 | assert {:ok, _pid} = ChatServer.start_link(chat_name)
16 | assert {:error, _reason} = ChatServer.start_link(chat_name)
17 | end
18 | end
19 |
20 | describe "add_message/2 and messages/1" do
21 | test "general message persistance" do
22 | chat_name = generate_chat_name()
23 | assert {:ok, _pid} = ChatServer.start_link(chat_name)
24 |
25 | ChatServer.add_message(chat_name, "m1")
26 | ChatServer.add_message(chat_name, "m2")
27 | ChatServer.add_message(chat_name, "m3")
28 | messages = ChatServer.messages(chat_name)
29 | assert messages == ["m1", "m2", "m3"]
30 | end
31 |
32 | test "message limit 250" do
33 | chat_name = generate_chat_name()
34 | assert {:ok, _pid} = ChatServer.start_link(chat_name)
35 |
36 | 1..1000
37 | |> Enum.each(fn _ ->
38 | ChatServer.add_message(chat_name, "hi")
39 | end)
40 |
41 | messages = ChatServer.messages(chat_name)
42 | assert length(messages) == 250
43 | end
44 | end
45 |
46 | defp generate_chat_name do
47 | "chat-#{:rand.uniform(1_000_000)}"
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/lib/demon_spirit_game/game_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritGame.GameSupervisor do
2 | @moduledoc """
3 | A supervisor that starts `GameServer` processes dynamically.
4 | """
5 |
6 | use DynamicSupervisor
7 |
8 | alias DemonSpiritGame.GameServer
9 |
10 | def start_link(_arg) do
11 | DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
12 | end
13 |
14 | def init(:ok) do
15 | DynamicSupervisor.init(strategy: :one_for_one)
16 | end
17 |
18 | @doc """
19 | Starts a `GameServer` process and supervises it.
20 | """
21 | def start_game(game_name) do
22 | child_spec = %{
23 | id: GameServer,
24 | start: {GameServer, :start_link, [game_name]},
25 | restart: :transient
26 | }
27 |
28 | DynamicSupervisor.start_child(__MODULE__, child_spec)
29 | end
30 |
31 | @doc """
32 | Starts a `GameServer` process and supervises it.
33 | Uses a hardcoded set of cards (needed for testing).
34 | """
35 | def start_game(game_name, :hardcoded_cards) do
36 | child_spec = %{
37 | id: GameServer,
38 | start: {GameServer, :start_link, [game_name, :hardcoded_cards]},
39 | restart: :transient
40 | }
41 |
42 | DynamicSupervisor.start_child(__MODULE__, child_spec)
43 | end
44 |
45 | @doc """
46 | Terminates the `GameServer` process normally. It won't be restarted.
47 | """
48 | def stop_game(game_name) do
49 | :ets.delete(:games, game_name)
50 |
51 | child_pid = GameServer.game_pid(game_name)
52 | DynamicSupervisor.terminate_child(__MODULE__, child_pid)
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/new.html.heex:
--------------------------------------------------------------------------------
1 |
9 | New Game Options
10 | <%= form_for @conn, Routes.game_path(@conn, :create), [as: :game_opts], fn f -> %>
11 | <%= label f, "Play against human or computer?", class: "block font-semibold mt-6" %>
12 | <%= select(f, :vs, ["Human": "human", "Computer": "computer"], class: "mt-4 rounded p-1 border") %>
13 | <%= select(f, :computer_skill, [
14 | "Level 0 (Easiest)": 1,
15 | "Level 1": 10,
16 | "Level 2": 20,
17 | "Level 3": 30,
18 | "Level 4": 40,
19 | "Level 5": 50,
20 | "Level 6": 60,
21 | "Level 7": 70,
22 | "Level 8": 80,
23 | "Level 9": 90,
24 | "Level 10 (Hardest)": 100,
25 | ], class: "hidden mt-4 p-1 rounded block border") %>
26 | <%= submit "Start Game", class: "btn btn-primary mt-4 block" %>
27 | <% end %>
28 |
29 |
34 |
35 |
36 |
53 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/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 from "../css/app.css";
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 dependencies
11 | //
12 | import "phoenix_html";
13 |
14 | // Import local files
15 | //
16 | // Local files can be imported directly using relative paths, for example:
17 | // import socket from "./socket"
18 |
19 | import { Socket } from "phoenix";
20 |
21 | //import LiveSocket from "phoenix_live_view";
22 | import * as LiveView from "phoenix_live_view";
23 | const { LiveSocket } = LiveView;
24 |
25 | let Hooks = {};
26 |
27 | import PhoneNumber from "./hooks/phone_number";
28 | Hooks.PhoneNumber = PhoneNumber;
29 |
30 | import Draggable from "./hooks/draggable";
31 | Hooks.Draggable = Draggable;
32 |
33 | import Droppable from "./hooks/droppable";
34 | Hooks.Droppable = Droppable;
35 |
36 | import DraggableDroppable from "./hooks/draggable_droppable";
37 | Hooks.DraggableDroppable = DraggableDroppable;
38 |
39 | import UpdateDing from "./hooks/update_ding";
40 | Hooks.UpdateDing = UpdateDing;
41 |
42 | import ChatScroll from "./hooks/chat_scroll";
43 | Hooks.ChatScroll = ChatScroll;
44 |
45 | let csrfToken = document
46 | .querySelector("meta[name='csrf-token']")
47 | .getAttribute("content");
48 |
49 | let liveSocket = new LiveSocket("/live", Socket, {
50 | hooks: Hooks,
51 | params: { _csrf_token: csrfToken },
52 | });
53 | liveSocket.connect();
54 |
--------------------------------------------------------------------------------
/apps/demon_spirit/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpirit.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 | it cannot be async. For this reason, every test runs
11 | inside a transaction which is reset at the beginning
12 | of the test unless the test case is marked as async.
13 | """
14 |
15 | use ExUnit.CaseTemplate
16 |
17 | using do
18 | quote do
19 | alias DemonSpirit.Repo
20 |
21 | import Ecto
22 | import Ecto.Changeset
23 | import Ecto.Query
24 | import DemonSpirit.DataCase
25 | end
26 | end
27 |
28 | # setup tags do
29 | # # :ok = Ecto.Adapters.SQL.Sandbox.checkout(DemonSpirit.Repo)
30 |
31 | # # unless tags[:async] do
32 | # # Ecto.Adapters.SQL.Sandbox.mode(DemonSpirit.Repo, {:shared, self()})
33 | # # end
34 |
35 | # :ok
36 | # end
37 |
38 | @doc """
39 | A helper that transforms changeset errors into a map of messages.
40 |
41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
42 | assert "password is too short" in errors_on(changeset).password
43 | assert %{password: ["password is too short"]} = errors_on(changeset)
44 |
45 | """
46 | def errors_on(changeset) do
47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
48 | Regex.replace(~r"%{(\w+)}", message, fn _, key ->
49 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
50 | end)
51 | end)
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.Router do
2 | use DemonSpiritWeb, :router
3 | import Plug.BasicAuth
4 | import Phoenix.LiveDashboard.Router
5 |
6 | pipeline :browser do
7 | plug(:accepts, ["html"])
8 | plug(:fetch_session)
9 | plug(:fetch_flash)
10 | plug(:protect_from_forgery)
11 | plug(:put_secure_browser_headers)
12 | plug(DemonSpiritWeb.Authenticator)
13 | end
14 |
15 | pipeline :api do
16 | plug(:accepts, ["json"])
17 | end
18 |
19 | ## BEGIN Phoenix LiveDashboard ###
20 | scope "/" do
21 | if Mix.env() == :dev do
22 | pipe_through([:browser])
23 | else
24 | pipe_through([:browser, :dash_admins_only])
25 | end
26 |
27 | live_dashboard("/dashboard")
28 | end
29 |
30 | pipeline :dash_admins_only do
31 | # Compile_env here fixes a warning, but causes the docker build to not work
32 | # plug(:basic_auth, Application.compile_env(:demon_spirit_web, :dash_basic_auth, nil))
33 | plug(:basic_auth, Application.fetch_env!(:demon_spirit_web, :dash_basic_auth))
34 | end
35 |
36 | ## END Phoenix LiveDashboard ###
37 |
38 | scope "/", DemonSpiritWeb do
39 | pipe_through(:browser)
40 |
41 | get("/", PageController, :index)
42 |
43 | get("/game/live_test", GameController, :live_test)
44 | resources("/game", GameController, only: [:new, :create, :show, :index])
45 | resources("/session", SessionController, only: [:new, :create, :delete], singleton: true)
46 | resources("/chat", ChatController, only: [:index])
47 | end
48 |
49 | # Other scopes may use custom stacks.
50 | # scope "/api", DemonSpiritWeb do
51 | # pipe_through :api
52 | # end
53 | end
54 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.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 | # When using gettext, we typically pass the strings we want
22 | # to translate as a static argument:
23 | #
24 | # # Translate "is invalid" in the "errors" domain
25 | # dgettext("errors", "is invalid")
26 | #
27 | # # Translate the number of files with plural rules
28 | # dngettext("errors", "1 file", "%{count} files", count)
29 | #
30 | # Because the error messages we show in our forms and APIs
31 | # are defined inside Ecto, we need to translate them dynamically.
32 | # This requires us to call the Gettext module passing our gettext
33 | # backend as first argument.
34 | #
35 | # Note we use the "errors" domain, which means translations
36 | # should be written to the errors.po file. The :count option is
37 | # set by Ecto and indicates we should also apply plural rules.
38 | if count = opts[:count] do
39 | Gettext.dngettext(DemonSpiritWeb.Gettext, "errors", msg, msg, count, opts)
40 | else
41 | Gettext.dgettext(DemonSpiritWeb.Gettext, "errors", msg, opts)
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/test/demon_spirit_game_ui/game_ui_supervisor_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GameUiSupervisorTest do
2 | use ExUnit.Case, async: true
3 | doctest DemonSpiritWeb.GameUISupervisor
4 |
5 | alias DemonSpiritWeb.{GameUISupervisor, GameUIServer, GameUIOptions}
6 |
7 | defp default_options do
8 | %GameUIOptions{
9 | vs: "human"
10 | }
11 | end
12 |
13 | describe "start_game" do
14 | test "spawns a game server process" do
15 | game_name = "game-#{:rand.uniform(1000)}"
16 | assert {:ok, _pid} = GameUISupervisor.start_game(game_name, default_options())
17 |
18 | via = GameUIServer.via_tuple(game_name)
19 | assert GenServer.whereis(via) |> Process.alive?()
20 | end
21 |
22 | test "returns an error if game is already started" do
23 | game_name = "game-#{:rand.uniform(1000)}"
24 |
25 | assert {:ok, pid} = GameUISupervisor.start_game(game_name, default_options())
26 |
27 | assert {:error, {:already_started, ^pid}} =
28 | GameUISupervisor.start_game(game_name, default_options())
29 | end
30 | end
31 |
32 | describe "stop_game" do
33 | test "terminates the process normally" do
34 | game_name = "game-#{:rand.uniform(1000)}"
35 | {:ok, _pid} = GameUISupervisor.start_game(game_name, default_options())
36 | via = GameUIServer.via_tuple(game_name)
37 |
38 | assert :ok = GameUISupervisor.stop_game(game_name)
39 | refute GenServer.whereis(via)
40 | end
41 | end
42 |
43 | # describe "ets preservation of crashed processes" do
44 | # test "supervised game gets restarted and retains status after crashing" do
45 | #
46 | # .... GameUISupervisor / GameUIServer does not currently do this .....
47 | #
48 | # end
49 | # end
50 | end
51 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your umbrella
2 | # and **all applications** and their dependencies with the
3 | # help of Mix.Config.
4 | #
5 | # Note that all applications in your umbrella share the
6 | # same configuration and dependencies, which is why they
7 | # all use the same configuration file. If you want different
8 | # configurations or dependencies per app, it is best to
9 | # move said applications out of the umbrella.
10 | import Config
11 |
12 | config :demon_spirit, env: Mix.env()
13 |
14 | # Configure Mix tasks and generators
15 | config :demon_spirit,
16 | ecto_repos: [DemonSpirit.Repo]
17 |
18 | config :demon_spirit_web,
19 | ecto_repos: [DemonSpirit.Repo],
20 | generators: [context_app: :demon_spirit]
21 |
22 | # Configures the endpoint
23 | config :demon_spirit_web, DemonSpiritWeb.Endpoint,
24 | url: [host: "localhost"],
25 | secret_key_base: "V2POCUrmSoTzr8ri1xDPLNlDU0cWruIl6kZFJCnssRbPxe471biGMvn70pfmcmpn",
26 | render_errors: [view: DemonSpiritWeb.ErrorView, accepts: ~w(html json)],
27 | pubsub_server: DemonSpiritWeb.PubSub,
28 | live_view: [
29 | signing_salt: "l5JjWQ6UpCgG+H5FItedIQEjMdxX3QW7"
30 | ]
31 |
32 | # Configures Elixir's Logger
33 | config :logger, :console,
34 | format: "$time $metadata[$level] $message\n",
35 | metadata: [:request_id]
36 |
37 | # Use Jason for JSON parsing in Phoenix
38 | config :phoenix, :json_library, Jason
39 |
40 | # Basic Auth for /dashboard - :dash_basic_auth - set during runtime in releases.exs
41 | config :demon_spirit_web, :dash_basic_auth,
42 | username: "admin",
43 | password: "EyAU6Ax8cyDkVcNA"
44 |
45 | # Import environment specific config. This must remain at the bottom
46 | # of this file so it overrides the configuration defined above.
47 | import_config "#{Mix.env()}.exs"
48 |
--------------------------------------------------------------------------------
/apps/demon_spirit/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule DemonSpirit.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :demon_spirit,
7 | version: "0.1.0",
8 | build_path: "../../_build",
9 | config_path: "../../config/config.exs",
10 | deps_path: "../../deps",
11 | lockfile: "../../mix.lock",
12 | elixir: "~> 1.5",
13 | elixirc_paths: elixirc_paths(Mix.env()),
14 | start_permanent: Mix.env() == :prod,
15 | aliases: aliases(),
16 | deps: deps()
17 | ]
18 | end
19 |
20 | # Configuration for the OTP application.
21 | #
22 | # Type `mix help compile.app` for more information.
23 | def application do
24 | [
25 | mod: {DemonSpirit.Application, []},
26 | extra_applications: [:logger, :runtime_tools]
27 | ]
28 | end
29 |
30 | # Specifies which paths to compile per environment.
31 | defp elixirc_paths(:test), do: ["lib", "test/support"]
32 | defp elixirc_paths(_), do: ["lib"]
33 |
34 | # Specifies your project dependencies.
35 | #
36 | # Type `mix help deps` for examples and options.
37 | defp deps do
38 | [
39 | {:ecto_sql, "~> 3.11.0"},
40 | {:postgrex, ">= 0.0.0"},
41 | {:jason, "~> 1.0"},
42 | {:poison, "~> 4.0"},
43 | {:httpoison, "~> 2.0"}
44 | ]
45 | end
46 |
47 | # Aliases are shortcuts or tasks specific to the current project.
48 | # For example, to create, migrate and run the seeds file at once:
49 | #
50 | # $ mix ecto.setup
51 | #
52 | # See the documentation for `Mix` for more info on aliases.
53 | defp aliases do
54 | [
55 | # "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
56 | # "ecto.reset": ["ecto.drop", "ecto.setup"],
57 | # test: ["ecto.create --quiet", "ecto.migrate", "test"]
58 | ]
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/index/game_table.html.heex:
--------------------------------------------------------------------------------
1 | <%= if length(@games) > 0 do %>
2 |
3 | <%= for game <- sort_gameinfos(@games) do %>
4 |
5 | <%= link game.name, to: Routes.game_path(@socket, :show, game.name), class: "text-blue-600 underline" %>
6 |
7 | <%= render "show/player.html", player: game.white %>
8 | <%= if game.winner == :white do %>
9 | winner
10 | <% end %>
11 | vs.
12 | <%= render "show/player.html", player: game.black %>
13 | <%= if game.winner == :black do %>
14 | winner
15 | <% end %>
16 |
17 |
18 | <%= case game.status do %>
19 | <% :playing -> %>
20 | in progress
21 | <% :staging -> %>
22 | staging
23 | <% :done -> %>
24 | won
25 | <% _ -> %>
26 | unknown
27 | <% end %>
28 |
29 |
30 | <%= date_to_hms(game.created_at) %>
31 | <%= date_to_md(game.created_at) %>
32 |
33 |
34 | <% end %>
35 |
36 | <% else %>
37 |
38 | No games are currently running.
39 |
40 | <% end %>
41 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :demon_spirit_web
3 |
4 | @session_options [
5 | store: :cookie,
6 | key: "_demon_spirit_web_key",
7 | signing_salt: "xh+R/LzW"
8 | ]
9 |
10 | socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
11 |
12 | socket("/socket", DemonSpiritWeb.UserSocket,
13 | websocket: true,
14 | longpoll: false
15 | )
16 |
17 | # Serve at "/" the static files from "priv/static" directory.
18 | #
19 | # You should set gzip to true if you are running phx.digest
20 | # when deploying your static files in production.
21 | plug(Plug.Static,
22 | at: "/",
23 | from: :demon_spirit_web,
24 | gzip: false,
25 | only: ~w(css fonts images sounds js favicon.ico robots.txt)
26 | )
27 |
28 | # Code reloading can be explicitly enabled under the
29 | # :code_reloader configuration of your endpoint.
30 | if code_reloading? do
31 | socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
32 | plug(Phoenix.LiveReloader)
33 | plug(Phoenix.CodeReloader)
34 | end
35 |
36 | ## LiveDashboard Plugins
37 | plug(Phoenix.LiveDashboard.RequestLogger,
38 | param_key: "request_logger",
39 | cookie_key: "request_logger"
40 | )
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 |
51 | plug(Plug.MethodOverride)
52 | plug(Plug.Head)
53 |
54 | # The session will be stored in the cookie and signed,
55 | # this means its contents can be read but not tampered with.
56 | # Set :encryption_salt if you would also like to encrypt it.
57 | plug(Plug.Session, @session_options)
58 |
59 | plug(DemonSpiritWeb.Router)
60 | end
61 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/name_generator.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.NameGenerator do
2 | @moduledoc """
3 | NameGenerator: Makes random room names.
4 | """
5 |
6 | @doc """
7 | generate/1: Takes no input. Outputs a string of a random room name.
8 | """
9 | def generate do
10 | [
11 | Enum.random(adj()),
12 | Enum.random(noun()),
13 | :rand.uniform(9999)
14 | ]
15 | |> Enum.join("-")
16 | end
17 |
18 | defp adj do
19 | ~w[
20 | becoming
21 | bucolic
22 | chatoyant
23 | comely
24 | demure
25 | desultory
26 | diaphanous
27 | dulcet
28 | effervescent
29 | ephemeral
30 | ethereal
31 | evanescent
32 | evocative
33 | fetching
34 | fugacious
35 | furtive
36 | gossamer
37 | halcyon
38 | incipient
39 | ineffable
40 | labyrinthine
41 | lissome
42 | lithe
43 | mellifluous
44 | murmurous
45 | opulent
46 | pyrrhic
47 | quintessential
48 | redolent
49 | riparian
50 | sempiternal
51 | summery
52 | sumptuous
53 | surreptitious
54 | susurrous
55 | untoward
56 | vestigial
57 | woebegone
58 | ]
59 | end
60 |
61 | defp noun do
62 | ~w[
63 | ailurophile
64 | assemblage
65 | brood
66 | bungalow
67 | cynosure
68 | dalliance
69 | demesne
70 | denouement
71 | desuetude
72 | ebullience
73 | elision
74 | elixir
75 | eloquence
76 | epiphany
77 | felicity
78 | forbearance
79 | glamour
80 | harbinger
81 | imbrication
82 | imbroglio
83 | ingénue
84 | inglenook
85 | insouciance
86 | lagniappe
87 | lagoon
88 | languor
89 | lassitude
90 | leisure
91 | love
92 | moiety
93 | mondegreen
94 | nemesis
95 | offing
96 | onomatopoeia
97 | palimpsest
98 | panacea
99 | panoply
100 | pastiche
101 | penumbra
102 | petrichor
103 | plethora
104 | propinquity
105 | ratatouille
106 | ripple
107 | scintilla
108 | serendipity
109 | susquehanna
110 | talisman
111 | tintinnabulation
112 | umbrella
113 | wafture
114 | ]
115 | end
116 | end
117 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_game_ui/game_registry.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.GameRegistry do
2 | @moduledoc """
3 | GameRegistry: A centralized list of all games going on, and a little information about them.
4 | It's stored in key value format, in ETS.
5 | keys = game_name (string)
6 | values = %GameInfo{}, which is a short summary about the game
7 | """
8 | use GenServer
9 | require Logger
10 |
11 | @topic "game-registry"
12 |
13 | ######### Public API
14 |
15 | def start_link(_) do
16 | GenServer.start_link(__MODULE__, nil, name: __MODULE__)
17 | end
18 |
19 | def add(game_name, info) do
20 | Logger.info("GameRegistry: Asked to add #{game_name}")
21 | put(game_name, info)
22 | notify()
23 | end
24 |
25 | def update(game_name, info) do
26 | put(game_name, info)
27 | notify()
28 | end
29 |
30 | def remove(game_name) do
31 | Logger.info("GameRegistry: Asked to remove #{game_name}")
32 | :ets.delete(__MODULE__, game_name)
33 | notify()
34 | end
35 |
36 | def list() do
37 | :ets.match(__MODULE__, :"$1")
38 | |> Enum.map(fn [{_k, v}] -> v end)
39 | |> Enum.sort_by(
40 | fn gi ->
41 | if datetime?(gi.created_at), do: DateTime.to_iso8601(gi.created_at), else: nil
42 | end,
43 | &>=/2
44 | )
45 | end
46 |
47 | ###### Private Implementation Helpers
48 |
49 | def datetime?(%DateTime{}), do: true
50 | def datetime?(_), do: false
51 |
52 | def init(_) do
53 | :ets.new(
54 | __MODULE__,
55 | [:named_table, :public, write_concurrency: true, read_concurrency: true]
56 | )
57 |
58 | {:ok, nil}
59 | end
60 |
61 | defp notify() do
62 | Phoenix.PubSub.broadcast(
63 | DemonSpiritWeb.PubSub,
64 | @topic,
65 | {:state_update, %{}}
66 | )
67 | end
68 |
69 | # Basic put function. See git history for "get" if needed.
70 | defp put(key, value) do
71 | :ets.insert(__MODULE__, {key, value})
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/show/ready.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Waiting for players..
6 |
7 |
8 |
9 |
10 | <%= render "show/ready_player_column.html", player: @state.white, ready: @state.white_ready, color: "White", bg: "bg-gray-300" %>
11 | <%= render "show/ready_player_column.html", player: @state.black, ready: @state.black_ready, color: "Black", bg: "bg-gray-400" %>
12 |
13 |
14 |
15 |
16 |
17 | Room name:
18 | <%= @state.game_name %>
19 |
20 |
21 |
Link:
22 |
Routes.game_path(@socket, :show, @state.game_name)}
24 | />
25 |
26 | Send the link to a friend to have them join the game.
27 |
28 |
29 |
30 |
31 | <%= if show_ready_button?(@state, @guest) do %>
32 |
33 | Ready
34 |
35 | <% end %>
36 |
37 | <%= if show_not_ready_button?(@state, @guest) do %>
38 |
39 | Not Ready
40 |
41 | <% end %>
42 |
43 |
44 | Leave Room
45 |
46 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM elixir:1.15-alpine as build
2 |
3 | # install build dependencies
4 | RUN apk add --update git build-base nodejs python3 npm
5 |
6 | # prepare build dir
7 | RUN mkdir /app
8 | WORKDIR /app
9 |
10 | # install hex + rebar
11 | RUN mix local.hex --force && \
12 | mix local.rebar --force
13 |
14 | # set build ENV
15 | ENV MIX_ENV=prod
16 |
17 | # install mix dependencies
18 | COPY mix.exs mix.lock ./
19 | ## Umbrella Version
20 | COPY ./apps/demon_spirit_web/mix.exs ./apps/demon_spirit_web/mix.exs
21 | COPY ./apps/demon_spirit/mix.exs ./apps/demon_spirit/mix.exs
22 | COPY ./apps/demon_spirit_game/mix.exs ./apps/demon_spirit_game/mix.exs
23 |
24 | COPY config config
25 | RUN mix deps.get
26 | RUN mix deps.compile
27 |
28 | # build assets
29 | # COPY assets assets
30 | # RUN cd assets && npm install && npm run deploy
31 | # RUN mix phx.digest
32 |
33 | ## Umbrella Version
34 | COPY apps/demon_spirit_web/assets apps/demon_spirit_web/assets
35 |
36 | # build project
37 | #COPY priv priv
38 | ## Umbrella Version
39 | COPY ./apps/demon_spirit_web/priv ./apps/demon_spirit_web/priv
40 |
41 | #COPY lib lib
42 | ## Umbrella Version
43 | COPY apps/demon_spirit_game/lib/ apps/demon_spirit_game/lib/
44 | COPY apps/demon_spirit/lib/ apps/demon_spirit/lib/
45 | COPY apps/demon_spirit_web/lib/ apps/demon_spirit_web/lib/
46 |
47 | RUN cd apps/demon_spirit_web/assets && npm install && npm run deploy
48 | RUN mix phx.digest
49 |
50 |
51 | RUN mix compile
52 |
53 | # build release
54 | RUN mix release
55 | #COPY rel rel
56 |
57 | # prepare release image
58 | FROM alpine:3.18.4 AS app
59 | RUN apk add --update bash openssl libstdc++ libgcc
60 |
61 | RUN mkdir /app
62 | WORKDIR /app
63 |
64 | # Change the 'demon_spirit_umbrella' here to the name of the app
65 | COPY --from=build /app/_build/prod/rel/demon_spirit_umbrella ./
66 | RUN chown -R nobody: /app
67 | USER nobody
68 |
69 | ENV HOME=/app
70 |
71 | ## Set on runtime (Preferably this is done out of container)
72 | #ENV SECRET_KEY_BASE ...secret here...
73 |
74 | CMD ["bin/demon_spirit_umbrella","start"]
75 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :demon_spirit_web,
7 | version: "0.1.0",
8 | build_path: "../../_build",
9 | config_path: "../../config/config.exs",
10 | deps_path: "../../deps",
11 | lockfile: "../../mix.lock",
12 | elixir: "~> 1.5",
13 | elixirc_paths: elixirc_paths(Mix.env()),
14 | start_permanent: Mix.env() == :prod,
15 | aliases: aliases(),
16 | deps: deps()
17 | ]
18 | end
19 |
20 | # Configuration for the OTP application.
21 | #
22 | # Type `mix help compile.app` for more information.
23 | def application do
24 | [
25 | mod: {DemonSpiritWeb.Application, []},
26 | extra_applications: [:logger, :runtime_tools]
27 | ]
28 | end
29 |
30 | # Specifies which paths to compile per environment.
31 | defp elixirc_paths(:test), do: ["lib", "test/support"]
32 | defp elixirc_paths(_), do: ["lib"]
33 |
34 | # Specifies your project dependencies.
35 | #
36 | # Type `mix help deps` for examples and options.
37 | defp deps do
38 | [
39 | {:phoenix, "~> 1.7.0"},
40 | {:phoenix_view, "~> 2.0"},
41 | {:phoenix_html, "~> 3.0"},
42 | {:phoenix_live_view, "~> 0.18.18"},
43 | {:phoenix_live_dashboard, "~> 0.7.2"},
44 | {:phoenix_pubsub, "~> 2.0"},
45 | {:phoenix_ecto, "~> 4.2"},
46 | {:phoenix_live_reload, "~> 1.2", only: :dev},
47 | {:gettext, "~> 0.23"},
48 | {:demon_spirit, in_umbrella: true},
49 | {:demon_spirit_game, in_umbrella: true},
50 | {:jason, "~> 1.0"},
51 | {:plug_cowboy, "~> 2.1"},
52 | {:accessible, "~> 0.3.0"},
53 | {:sobelow, ">= 0.0.0", only: :dev, runtime: false}
54 | ]
55 | end
56 |
57 | # Aliases are shortcuts or tasks specific to the current project.
58 | # For example, we extend the test task to create and migrate the database.
59 | #
60 | # See the documentation for `Mix` for more info on aliases.
61 | defp aliases do
62 | # [test: ["ecto.create --quiet", "ecto.migrate", "test"]]
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/test/demon_spirit_game_ui/game_registry_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GameRegistryTest do
2 | use ExUnit.Case, async: true
3 | alias DemonSpiritWeb.{GameRegistry, GameInfo}
4 |
5 | describe "start_link/1" do
6 | test "Already started by application" do
7 | # assert {:error, {:already_started, _pid}} = GameRegistry.start_link(nil)
8 | end
9 | end
10 |
11 | describe "add/2" do
12 | test "Adding works" do
13 | GameRegistry.start_link(nil)
14 | game_name = generate_game_name()
15 |
16 | count_before = GameRegistry.list() |> length()
17 | GameRegistry.add(game_name, %GameInfo{})
18 | count_after = GameRegistry.list() |> length()
19 | assert count_before + 1 == count_after
20 | end
21 | end
22 |
23 | describe "remove/2" do
24 | test "Removing works" do
25 | GameRegistry.start_link(nil)
26 | game_name = generate_game_name()
27 |
28 | count_before = GameRegistry.list() |> length()
29 |
30 | GameRegistry.add(game_name, %GameInfo{})
31 | count_after_add = GameRegistry.list() |> length()
32 | assert count_before + 1 == count_after_add
33 |
34 | GameRegistry.remove(game_name)
35 | count_after_remove = GameRegistry.list() |> length()
36 | assert count_after_remove == count_before
37 | end
38 | end
39 |
40 | describe "update/2" do
41 | test "Updating works" do
42 | GameRegistry.start_link(nil)
43 | game_name = generate_game_name()
44 | game_info_1 = %GameInfo{name: game_name, status: :staging}
45 | game_info_2 = %GameInfo{name: game_name, status: :playing}
46 |
47 | GameRegistry.add(game_name, game_info_1)
48 | game = GameRegistry.list() |> Enum.filter(fn g -> g.name == game_name end) |> Enum.at(0)
49 | assert game.status == :staging
50 | GameRegistry.update(game_name, game_info_2)
51 | game = GameRegistry.list() |> Enum.filter(fn g -> g.name == game_name end) |> Enum.at(0)
52 | assert game.status == :playing
53 | end
54 | end
55 |
56 | defp generate_game_name do
57 | "game-#{:rand.uniform(1_000_000)}"
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/index/about.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
About
3 |
4 | Demon Spirit is an abstract strategy game, something like chess with different rules, smaller board, and random starting conditions.
5 |
6 |
7 | Play against a friend or the computer AI.
8 |
9 |
10 | Show text rules .
11 |
12 |
13 |
14 |
Rules
15 |
16 | The pieces move according to 2 move cards you have.
17 | The king and pawns move the same.
18 | Using a move card places that card on the side, where your opponent will get it next turn.
19 |
20 | There are two ways to win:
21 |
22 | Capture your opponent's king.
23 | Move your king to the square where your opponent's king started.
24 |
25 |
26 |
27 |
28 |
Development
29 |
30 | <%= link "Check out the GitHub", to: "https://github.com/mreishus/demon_spirit_umbrella", class: "underline text-blue-700" %>.
31 |
32 |
33 | Made with: Elixir, Phoenix, LiveView, Tailwind CSS, Docker, Kubernetes, Caddy, Proxmox, Cloudflare.
34 |
35 |
36 | By: <%= link "Matthew Reishus", to: "https://matthewreishus.com", class: "underline text-blue-700" %>
37 |
38 |
39 |
40 |
41 |
52 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/controllers/game_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.GameController do
2 | use DemonSpiritWeb, :controller
3 |
4 | alias DemonSpiritWeb.{
5 | GameUIServer,
6 | GameUISupervisor,
7 | LiveGameIndex,
8 | LiveGameShow,
9 | NameGenerator
10 | }
11 |
12 | alias DemonSpirit.Metrics
13 | alias Phoenix.LiveView
14 |
15 | plug(:require_logged_in)
16 |
17 | def index(conn, _params) do
18 | guest = conn.assigns.current_guest
19 | LiveView.Controller.live_render(conn, LiveGameIndex, session: %{"guest" => guest})
20 | end
21 |
22 | def new(conn, _params) do
23 | render(conn, "new.html")
24 | end
25 |
26 | def create(conn, %{"game_opts" => game_opts}) do
27 | game_name = NameGenerator.generate()
28 | {:ok, game_opts} = DemonSpiritWeb.validate_game_ui_options(game_opts)
29 |
30 | case GameUISupervisor.start_game(game_name, game_opts) do
31 | {:ok, _pid} ->
32 | Metrics.game_created()
33 | redirect(conn, to: Routes.game_path(conn, :show, game_name))
34 |
35 | {:error, _} ->
36 | conn
37 | |> put_flash(:error, "Unable to start game.")
38 | |> redirect(to: Routes.game_path(conn, :new))
39 | end
40 | end
41 |
42 | def show(conn, %{"id" => game_name}) do
43 | state = GameUIServer.state(game_name)
44 | guest = conn.assigns.current_guest
45 |
46 | case state do
47 | nil ->
48 | conn
49 | |> put_flash(:error, "Game does not exist")
50 | |> redirect(to: Routes.game_path(conn, :new))
51 |
52 | _ ->
53 | LiveView.Controller.live_render(conn, LiveGameShow,
54 | session: %{"game_name" => game_name, "guest" => guest}
55 | )
56 | end
57 | end
58 |
59 | defp require_logged_in(conn = %{assigns: %{current_user: current_user}}, _opts)
60 | when not is_nil(current_user) do
61 | conn
62 | end
63 |
64 | defp require_logged_in(conn = %{assigns: %{current_guest: current_guest}}, _opts)
65 | when not is_nil(current_guest) do
66 | conn
67 | end
68 |
69 | defp require_logged_in(conn, _opts) do
70 | conn
71 | |> put_flash(:info, "Please log in first.")
72 | |> put_session(:redir_to, conn.request_path)
73 | |> redirect(to: Routes.session_path(conn, :new))
74 | |> halt()
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/layout/app.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | DemonSpirit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | <%= link "Demon Spirit", to: Routes.game_path(@conn, :index), class: "underline hover:text-blue-300" %> (1.6.2)
19 |
20 |
21 |
22 |
23 | <%= if @current_guest do %>
24 |
25 | Logged in as <%= @current_guest.name %> (guest).
26 |
27 | <%= link "Log Out", to: Routes.session_path(@conn, :delete), method: :delete, class: "hover:text-red-600 underline" %>
28 | <% else %>
29 | <%= link "Log In", to: Routes.session_path(@conn, :new), class: "underline hover:text-blue-300" %>
30 | <% end %>
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | <%= if get_flash(@conn, :info) != nil do %>
40 | <%= get_flash(@conn, :info) %>
41 | <% end %>
42 | <%= if get_flash(@conn, :error) != nil do %>
43 | <%= get_flash(@conn, :error) %>
44 | <% end %>
45 | <%= @inner_content %>
46 |
47 | <%= csrf_meta_tag() %>
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/config/releases.exs:
--------------------------------------------------------------------------------
1 | # In this file, we load production configuration and secrets
2 | # from environment variables. You can also hardcode secrets,
3 | # although such is generally not recommended and you have to
4 | # remember to add this file to your .gitignore.
5 | import Config
6 |
7 | ##
8 | ## Removed database_url and config of DemonSpirit.Repo,
9 | ## Since the DB is removed -MR 9/27/19
10 | ##
11 |
12 | # database_url =
13 | # System.get_env("DATABASE_URL") ||
14 | # raise """
15 | # environment variable DATABASE_URL is missing.
16 | # For example: ecto://USER:PASS@HOST/DATABASE
17 | # """
18 |
19 | # config :demon_spirit, DemonSpirit.Repo,
20 | # # ssl: true,
21 | # url: database_url,
22 | # pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
23 |
24 | secret_key_base =
25 | System.get_env("SECRET_KEY_BASE") ||
26 | raise """
27 | environment variable SECRET_KEY_BASE is missing.
28 | You can generate one by calling: mix phx.gen.secret
29 | """
30 |
31 | config :demon_spirit_web, DemonSpiritWeb.Endpoint,
32 | http: [:inet6, port: String.to_integer(System.get_env("PORT") || "4000")],
33 | secret_key_base: secret_key_base
34 |
35 | ### Begin Dashboard Auth ###
36 | # Set Username/Password for Dashboard Basic Auth (/dashboard)
37 | #
38 | # Read from env variables DASH_BASIC_USER and DASH_BASIC_PASS
39 | # If none is set, instead of refusing to run, simply set to random strings.
40 | defmodule ReleaseUtil do
41 | def random_string(length) do
42 | :crypto.strong_rand_bytes(length) |> Base.url_encode64() |> binary_part(0, length)
43 | end
44 | end
45 |
46 | # Note: This doesn't actually work.
47 | # Spent a lot of time on it and I'm stuck..
48 | dash_basic_username = System.get_env("DASH_BASIC_USER") || ReleaseUtil.random_string(32)
49 | dash_basic_password = System.get_env("DASH_BASIC_PASS") || ReleaseUtil.random_string(32)
50 |
51 | config :demon_spirit_web, :dash_basic_auth,
52 | username: dash_basic_username,
53 | password: dash_basic_password
54 |
55 | ### End Dashboard Auth ###
56 |
57 | # ## Using releases (Elixir v1.9+)
58 | #
59 | # If you are doing OTP releases, you need to instruct Phoenix
60 | # to start each relevant endpoint:
61 | #
62 | config :demon_spirit_web, DemonSpiritWeb.Endpoint, server: true
63 | #
64 | # Then you can assemble a release by calling `mix release`.
65 | # See `mix help release` for more information.
66 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/lib/demon_spirit_game/game_win_check.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritGame.GameWinCheck do
2 | @moduledoc """
3 | Checks a DemonSpiritGame.Game for a winner and returns
4 | a copy of the game with the winner set if applicable.
5 | """
6 | alias DemonSpiritGame.{Game}
7 |
8 | @doc """
9 | check/1: Looks for a winner in a Game and
10 | sets the :winner key if needed.
11 | Input: %Game{}
12 | Output: %Game{}
13 | """
14 | @spec check(%Game{}) :: %Game{}
15 | def check(game) do
16 | game
17 | |> check_winner_kings()
18 | |> check_winner_temple()
19 | end
20 |
21 | _ = """
22 | check_winner_kings/1: Looks for a winner in a Game and
23 | sets the :winner key if needed.
24 |
25 | Only checks the condition of a king being killed.
26 | :winner set to :error in the case that both kings are missing.
27 |
28 | Input: %Game{}
29 | Output: %Game{}, possibly with :winner set to :white or :black
30 | """
31 |
32 | @spec check_winner_kings(%Game{}) :: %Game{}
33 | defp check_winner_kings(game) do
34 | king_colors =
35 | game.board
36 | |> Map.values()
37 | |> Enum.filter(fn p -> p.type == :king end)
38 | |> Enum.map(fn p -> p.color end)
39 |
40 | white_missing = :white not in king_colors
41 | black_missing = :black not in king_colors
42 |
43 | cond do
44 | white_missing && black_missing ->
45 | game |> mark_winner(:error)
46 |
47 | white_missing ->
48 | game |> mark_winner(:black)
49 |
50 | black_missing ->
51 | game |> mark_winner(:white)
52 |
53 | true ->
54 | game
55 | end
56 | end
57 |
58 | _ = """
59 | check_winner_kings/1: Looks for a winner in a Game and
60 | """
61 |
62 | defp check_winner_temple(game) do
63 | black_ascended = game.board[{2, 0}] == %{color: :black, type: :king}
64 | white_ascended = game.board[{2, 4}] == %{color: :white, type: :king}
65 |
66 | cond do
67 | white_ascended && black_ascended ->
68 | game |> mark_winner(:error)
69 |
70 | white_ascended ->
71 | game |> mark_winner(:white)
72 |
73 | black_ascended ->
74 | game |> mark_winner(:black)
75 |
76 | true ->
77 | game
78 | end
79 | end
80 |
81 | defp mark_winner(game = %Game{winner: winner}, _) when not is_nil(winner), do: game
82 |
83 | defp mark_winner(game, color) do
84 | %Game{game | winner: color}
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For production, don't forget to configure the url host
4 | # to something meaningful, Phoenix uses this information
5 | # when generating URLs.
6 | #
7 | # Note we also include the path to a cache manifest
8 | # containing the digested version of static files. This
9 | # manifest is generated by the `mix phx.digest` task,
10 | # which you should run after static files are built and
11 | # before starting your production server.
12 | config :demon_spirit_web, DemonSpiritWeb.Endpoint,
13 | url: [host: "demonspirit.xyz", port: 80],
14 | code_reloader: false,
15 | cache_static_manifest: "priv/static/cache_manifest.json",
16 | check_origin: [
17 | "//demonspirit.xyz",
18 | "//example.com",
19 | "//localhost",
20 | "//172.22.2.30:31229",
21 | "//172.22.2.31:31229",
22 | "//172.22.2.32:31229",
23 | "//172.22.2.33:31229"
24 | ]
25 |
26 | # ## SSL Support
27 | #
28 | # To get SSL working, you will need to add the `https` key
29 | # to the previous section and set your `:url` port to 443:
30 | #
31 | # config :demon_spirit_web, DemonSpiritWeb.Endpoint,
32 | # ...
33 | # url: [host: "example.com", port: 443],
34 | # https: [
35 | # :inet6,
36 | # port: 443,
37 | # cipher_suite: :strong,
38 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
39 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
40 | # ]
41 | #
42 | # The `cipher_suite` is set to `:strong` to support only the
43 | # latest and more secure SSL ciphers. This means old browsers
44 | # and clients may not be supported. You can set it to
45 | # `:compatible` for wider support.
46 | #
47 | # `:keyfile` and `:certfile` expect an absolute path to the key
48 | # and cert in disk or a relative path inside priv, for example
49 | # "priv/ssl/server.key". For all supported SSL configuration
50 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
51 | #
52 | # We also recommend setting `force_ssl` in your endpoint, ensuring
53 | # no data is ever sent via http, always redirecting to https:
54 | #
55 | # config :demon_spirit_web, DemonSpiritWeb.Endpoint,
56 | # force_ssl: [hsts: true]
57 | #
58 | # Check `Plug.SSL` for all available options in `force_ssl`.
59 |
60 | # Do not print debug messages in production
61 | config :logger, level: :info
62 |
63 | # No longer using prod.secret.exs - Letting
64 | # the release system check releases.exs at runtime
65 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/test/game_win_check_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GameWinCheckTest do
2 | use ExUnit.Case, async: true
3 |
4 | doctest DemonSpiritGame.GameWinCheck, import: true
5 | alias DemonSpiritGame.{Game, Card, GameWinCheck}
6 |
7 | setup do
8 | # Static list of cards, use when creating a new game to remove RNG from tests
9 | cards = Card.cards() |> Enum.sort_by(fn card -> card.name end) |> Enum.take(5)
10 | game = Game.new(cards)
11 |
12 | %{
13 | game: game
14 | }
15 | end
16 |
17 | describe "check/1" do
18 | test "game with no winner is returned unchanged", %{game: game} do
19 | new_game = GameWinCheck.check(game)
20 | assert game == new_game
21 | assert new_game.winner == nil
22 | end
23 |
24 | test "white king elimination", %{game: game} do
25 | {_piece, new_board} = game.board |> Map.pop({2, 0})
26 | game = %Game{game | board: new_board} |> GameWinCheck.check()
27 | assert game.winner == :black
28 | end
29 |
30 | test "black king elimination", %{game: game} do
31 | {_piece, new_board} = game.board |> Map.pop({2, 4})
32 | game = %Game{game | board: new_board} |> GameWinCheck.check()
33 | assert game.winner == :white
34 | end
35 |
36 | test "white king ascends", %{game: game} do
37 | # Move Black King to {2, 2}
38 | {black_king, new_board} = game.board |> Map.pop({2, 4})
39 | new_board = new_board |> Map.put({2, 2}, black_king)
40 | game = %Game{game | board: new_board}
41 |
42 | # Move White King to {2, 4}
43 | {white_king, new_board} = game.board |> Map.pop({2, 0})
44 | new_board = new_board |> Map.put({2, 4}, white_king)
45 | game = %Game{game | board: new_board}
46 |
47 | # Check
48 | game = GameWinCheck.check(game)
49 | assert game.winner == :white
50 | end
51 |
52 | test "black king ascends", %{game: game} do
53 | # Move White King to {2, 2}
54 | {white_king, new_board} = game.board |> Map.pop({2, 0})
55 | new_board = new_board |> Map.put({2, 2}, white_king)
56 | game = %Game{game | board: new_board}
57 |
58 | # Move Black King to {2, 0}
59 | {black_king, new_board} = game.board |> Map.pop({2, 4})
60 | new_board = new_board |> Map.put({2, 0}, black_king)
61 | game = %Game{game | board: new_board}
62 |
63 | # Check
64 | game = GameWinCheck.check(game)
65 | assert game.winner == :black
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/js/socket.js:
--------------------------------------------------------------------------------
1 | // NOTE: The contents of this file will only be executed if
2 | // you uncomment its entry in "assets/js/app.js".
3 |
4 | // To use Phoenix channels, the first step is to import Socket,
5 | // and connect at the socket path in "lib/web/endpoint.ex".
6 | //
7 | // Pass the token on params as below. Or remove it
8 | // from the params if you are not using authentication.
9 | import {Socket} from "phoenix"
10 |
11 | let socket = new Socket("/socket", {params: {token: window.userToken}})
12 |
13 | // When you connect, you'll often need to authenticate the client.
14 | // For example, imagine you have an authentication plug, `MyAuth`,
15 | // which authenticates the session and assigns a `:current_user`.
16 | // If the current user exists you can assign the user's token in
17 | // the connection for use in the layout.
18 | //
19 | // In your "lib/web/router.ex":
20 | //
21 | // pipeline :browser do
22 | // ...
23 | // plug MyAuth
24 | // plug :put_user_token
25 | // end
26 | //
27 | // defp put_user_token(conn, _) do
28 | // if current_user = conn.assigns[:current_user] do
29 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id)
30 | // assign(conn, :user_token, token)
31 | // else
32 | // conn
33 | // end
34 | // end
35 | //
36 | // Now you need to pass this token to JavaScript. You can do so
37 | // inside a script tag in "lib/web/templates/layout/app.html.eex":
38 | //
39 | //
40 | //
41 | // You will need to verify the user token in the "connect/3" function
42 | // in "lib/web/channels/user_socket.ex":
43 | //
44 | // def connect(%{"token" => token}, socket, _connect_info) do
45 | // # max_age: 1209600 is equivalent to two weeks in seconds
46 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
47 | // {:ok, user_id} ->
48 | // {:ok, assign(socket, :user, user_id)}
49 | // {:error, reason} ->
50 | // :error
51 | // end
52 | // end
53 | //
54 | // Finally, connect to the socket:
55 | socket.connect()
56 |
57 | // Now that you are connected, you can join channels with a topic:
58 | let channel = socket.channel("topic:subtopic", {})
59 | channel.join()
60 | .receive("ok", resp => { console.log("Joined successfully", resp) })
61 | .receive("error", resp => { console.log("Unable to join", resp) })
62 |
63 | export default socket
64 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Demon Spirit
2 |
3 | _Demon Spirit_, an abstract board game on a 5x5 grid served over the web. You
4 | can play against other players or a computer AI with programmable difficulty.
5 | Built in Elixir, Phoenix, and Phoenix Live View.
6 |
7 | ## Running the project (Development)
8 |
9 | ```
10 | mix deps.get
11 | cd ./apps/demon_spirit_web/assets
12 | npm install
13 | cd ../../../
14 | mix phx.server
15 | ```
16 |
17 | Visit [http://localhost:4000](http://localhost:4000) in web browser
18 |
19 | ## Running the project (Production Test)
20 |
21 | ```
22 | ./test_prod.sh
23 | # Visit http://localhost:4000 in your browser
24 | ```
25 |
26 | This will create a new docker image named `demon-spirit:test`, and run it,
27 | published to port 4000 on localhost.
28 |
29 | ## Running the project (Production)
30 |
31 | Create a Docker Image:
32 |
33 | ```
34 | edit build.sh with your favorite text editor - change docker tag to your namespace
35 | ./build.sh
36 | ```
37 |
38 | Run the docker image via your favorite method. Expose port 4000 to anywhere
39 | you want, typically 80. Since it doesn't require a database, that's all you
40 | need.
41 |
42 | ## Linting / Tests / Etc
43 |
44 | Run `mix check` to run all tests and linting.
45 |
46 | ## Online Version
47 |
48 | **Newest version is deployed at
49 | [https://demonspirit.xyz/](https://demonspirit.xyz/).**
50 |
51 | ## Issues
52 |
53 | Some issues are tracked in [Issues.MD](./Issues.md).
54 |
55 | ## Organization
56 |
57 | - DemonSpiritGame.\*
58 |
59 | Game logic. All functions for tracking and changing game state.
60 |
61 | - DemonSpiritWeb.\*
62 |
63 | Phoenix app that will allow users to play the game online. Uses Phoenix
64 | LiveView.
65 |
66 | - DemonSpirit
67 |
68 | If the web app ever tracks user accounts, win records, etc, the data models
69 | and associated functions will be here. Current implementation is anonymous
70 | sessions only.
71 |
72 | - Lessons Learned
73 |
74 | If I were making this app again, I don't think I would use an umbrella
75 | project. I'm not sure the apps are separated cleanly enough to warrant it.
76 |
77 | Current view:
78 |
79 | 
80 |
81 | ## Optional Honeycomb Configuration
82 |
83 | To report metrics to honeycomb.io, set these environment variables when running:
84 |
85 | - HONEYCOMB_APIKEY
86 | - HONEYCOMB_DATASET
87 |
88 | Example:
89 |
90 | ```bash
91 | export HONEYCOMB_APIKEY="012345678912345678abcde123456789"
92 | export HONEYCOMB_DATASET="demonspirit-elixir"
93 | ```
94 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | config :demon_spirit, DemonSpirit.Repo,
5 | username: "postgres",
6 | password: "postgres",
7 | database: "demon_spirit_dev",
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 :demon_spirit_web, DemonSpiritWeb.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",
29 | cd: Path.expand("../apps/demon_spirit_web/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 :demon_spirit_web, DemonSpiritWeb.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/demon_spirit_web/{live,views}/.*(ex)$",
64 | ~r"lib/demon_spirit_web/templates/.*(eex)$",
65 | ~r{lib/demon_spirit_web/live/.*(ex)$}
66 | ]
67 | ]
68 |
69 | # Do not include metadata nor timestamps in development logs
70 | config :logger, :console, format: "[$level] $message\n"
71 |
72 | # Initialize plugs at runtime for faster development compilation
73 | config :phoenix, :plug_init_mode, :runtime
74 |
75 | # Set a higher stacktrace during development. Avoid configuring such
76 | # in production as building large stacktraces may be expensive.
77 | config :phoenix, :stacktrace_depth, 20
78 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb 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 DemonSpiritWeb, :controller
9 | use DemonSpiritWeb, :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 | alias DemonSpiritWeb.GameUIOptions
21 |
22 | def controller do
23 | quote do
24 | use Phoenix.Controller, namespace: DemonSpiritWeb
25 | import Plug.Conn
26 | import DemonSpiritWeb.Gettext
27 | alias DemonSpiritWeb.Router.Helpers, as: Routes
28 | import Phoenix.LiveView.Controller, only: [live_render: 3]
29 | end
30 | end
31 |
32 | def view do
33 | quote do
34 | use Phoenix.View,
35 | root: "lib/demon_spirit_web/templates",
36 | namespace: DemonSpiritWeb,
37 | pattern: "**/*"
38 |
39 | # Import convenience functions from controllers
40 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]
41 |
42 | # Use all HTML functionality (forms, tags, etc)
43 | use Phoenix.HTML
44 |
45 | import DemonSpiritWeb.ErrorHelpers
46 | import DemonSpiritWeb.Gettext
47 | alias DemonSpiritWeb.Router.Helpers, as: Routes
48 |
49 | import Phoenix.LiveView.Helpers
50 | end
51 | end
52 |
53 | def router do
54 | quote do
55 | use Phoenix.Router
56 | import Plug.Conn
57 | import Phoenix.Controller
58 | import Phoenix.LiveView.Router
59 | end
60 | end
61 |
62 | def channel do
63 | quote do
64 | use Phoenix.Channel
65 | import DemonSpiritWeb.Gettext
66 | end
67 | end
68 |
69 | @doc """
70 | When used, dispatch to the appropriate controller/view/etc.
71 | """
72 | defmacro __using__(which) when is_atom(which) do
73 | apply(__MODULE__, which, [])
74 | end
75 |
76 | @doc """
77 | Use ecto w/o DB to validate incoming game options
78 | Input: %{"vs" => "computer", ... other options ... }
79 | OUTPUT: {:ok, %GameUIOptions{}}
80 | """
81 | def validate_game_ui_options(params) do
82 | %GameUIOptions{}
83 | |> GameUIOptions.changeset(params)
84 | |> Ecto.Changeset.apply_action(:insert)
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/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 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | ## From Ecto.Changeset.validate_length/3
53 | msgid "should be %{count} character(s)"
54 | msgid_plural "should be %{count} character(s)"
55 | msgstr[0] ""
56 | msgstr[1] ""
57 |
58 | msgid "should have %{count} item(s)"
59 | msgid_plural "should have %{count} item(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be at least %{count} character(s)"
64 | msgid_plural "should be at least %{count} character(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at most %{count} character(s)"
74 | msgid_plural "should be at most %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should have at most %{count} item(s)"
79 | msgid_plural "should have at most %{count} item(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | ## From Ecto.Changeset.validate_number/3
84 | msgid "must be less than %{number}"
85 | msgstr ""
86 |
87 | msgid "must be greater than %{number}"
88 | msgstr ""
89 |
90 | msgid "must be less than or equal to %{number}"
91 | msgstr ""
92 |
93 | msgid "must be greater than or equal to %{number}"
94 | msgstr ""
95 |
96 | msgid "must be equal to %{number}"
97 | msgstr ""
98 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This 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 has no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 | ## From Ecto.Changeset.cast/4
12 | msgid "can't be blank"
13 | msgstr ""
14 |
15 | ## From Ecto.Changeset.unique_constraint/3
16 | msgid "has already been taken"
17 | msgstr ""
18 |
19 | ## From Ecto.Changeset.put_change/3
20 | msgid "is invalid"
21 | msgstr ""
22 |
23 | ## From Ecto.Changeset.validate_acceptance/3
24 | msgid "must be accepted"
25 | msgstr ""
26 |
27 | ## From Ecto.Changeset.validate_format/3
28 | msgid "has invalid format"
29 | msgstr ""
30 |
31 | ## From Ecto.Changeset.validate_subset/3
32 | msgid "has an invalid entry"
33 | msgstr ""
34 |
35 | ## From Ecto.Changeset.validate_exclusion/3
36 | msgid "is reserved"
37 | msgstr ""
38 |
39 | ## From Ecto.Changeset.validate_confirmation/3
40 | msgid "does not match confirmation"
41 | msgstr ""
42 |
43 | ## From Ecto.Changeset.no_assoc_constraint/3
44 | msgid "is still associated with this entry"
45 | msgstr ""
46 |
47 | msgid "are still associated with this entry"
48 | msgstr ""
49 |
50 | ## From Ecto.Changeset.validate_length/3
51 | msgid "should be %{count} character(s)"
52 | msgid_plural "should be %{count} character(s)"
53 | msgstr[0] ""
54 | msgstr[1] ""
55 |
56 | msgid "should have %{count} item(s)"
57 | msgid_plural "should have %{count} item(s)"
58 | msgstr[0] ""
59 | msgstr[1] ""
60 |
61 | msgid "should be at least %{count} character(s)"
62 | msgid_plural "should be at least %{count} character(s)"
63 | msgstr[0] ""
64 | msgstr[1] ""
65 |
66 | msgid "should have at least %{count} item(s)"
67 | msgid_plural "should have at least %{count} item(s)"
68 | msgstr[0] ""
69 | msgstr[1] ""
70 |
71 | msgid "should be at most %{count} character(s)"
72 | msgid_plural "should be at most %{count} character(s)"
73 | msgstr[0] ""
74 | msgstr[1] ""
75 |
76 | msgid "should have at most %{count} item(s)"
77 | msgid_plural "should have at most %{count} item(s)"
78 | msgstr[0] ""
79 | msgstr[1] ""
80 |
81 | ## From Ecto.Changeset.validate_number/3
82 | msgid "must be less than %{number}"
83 | msgstr ""
84 |
85 | msgid "must be greater than %{number}"
86 | msgstr ""
87 |
88 | msgid "must be less than or equal to %{number}"
89 | msgstr ""
90 |
91 | msgid "must be greater than or equal to %{number}"
92 | msgstr ""
93 |
94 | msgid "must be equal to %{number}"
95 | msgstr ""
96 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/live/live_chat_index.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.LiveChatIndex do
2 | @moduledoc """
3 | LiveChatIndex: A Live view for showing a chat room on a webpage.
4 | This is intended to be nestable inside other live views.
5 | """
6 | use Phoenix.LiveView
7 | alias DemonSpiritWeb.{ChatView, Endpoint, Presence}
8 | alias DemonSpiritGame.{ChatServer, ChatSupervisor}
9 |
10 | def render(assigns) do
11 | ChatView.render("live_index.html", assigns)
12 | end
13 |
14 | def mount(_params, %{"chat_name" => chat_name, "guest" => guest}, socket) do
15 | topic = topic_for(chat_name)
16 | if connected?(socket), do: Endpoint.subscribe(topic)
17 | {:ok, _} = Presence.track(self(), topic, guest.id, guest)
18 | ChatSupervisor.start_chat_if_needed(chat_name)
19 |
20 | {:ok,
21 | assign(socket,
22 | guest: guest,
23 | chat_name: chat_name,
24 | chat_message: DemonSpirit.new_chat_message(),
25 | messages: ChatServer.messages(chat_name),
26 | topic: topic,
27 | users: []
28 | )}
29 | end
30 |
31 | def handle_event(
32 | "message",
33 | %{"chat_message" => params},
34 | socket = %{assigns: %{chat_name: chat_name, guest: guest}}
35 | ) do
36 | msg_tuple =
37 | params
38 | |> Map.put("name", guest.name)
39 | |> DemonSpirit.fake_insert_chat_message()
40 |
41 | case msg_tuple do
42 | {:ok, this_msg} ->
43 | ChatServer.add_message(chat_name, this_msg)
44 |
45 | notify(socket.assigns.topic)
46 |
47 | {:noreply,
48 | assign(socket,
49 | chat_message: DemonSpirit.new_chat_message(),
50 | messages: ChatServer.messages(chat_name)
51 | )}
52 |
53 | _ ->
54 | # Silent failure if e.g. blank message
55 | {:noreply, socket}
56 | end
57 | end
58 |
59 | def notify(topic) do
60 | Endpoint.broadcast_from(self(), topic, "state_update", %{})
61 | end
62 |
63 | def topic_for(chat_name) do
64 | "chat-topic:" <> chat_name
65 | end
66 |
67 | def handle_info(
68 | %{event: "state_update"},
69 | socket = %{assigns: %{chat_name: chat_name}}
70 | ) do
71 | messages = ChatServer.messages(chat_name)
72 | {:noreply, assign(socket, messages: messages)}
73 | end
74 |
75 | # Handle "presence_diff", someone joined or left
76 | def handle_info(%{event: "presence_diff"}, socket = %{assigns: %{topic: topic}}) do
77 | users =
78 | Presence.list(topic)
79 | |> Enum.map(fn {_user_id, data} ->
80 | data[:metas]
81 | |> List.first()
82 | end)
83 |
84 | {:noreply, assign(socket, users: users)}
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_game_ui/game_timer.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.GameTimer do
2 | @moduledoc """
3 | GameTimer: Represents a chess timer containing a "time left" for both
4 | white and black players.
5 | """
6 | alias DemonSpiritGame.{Game}
7 | alias DemonSpiritWeb.GameTimer
8 | require Logger
9 |
10 | defstruct white_time: 0,
11 | white_time_current: 0,
12 | black_time: 0,
13 | black_time_current: 0,
14 | last_move: nil,
15 | started: false
16 |
17 | @initial_time 5 * 60 * 1000
18 |
19 | def new do
20 | %GameTimer{
21 | white_time: @initial_time,
22 | black_time: @initial_time,
23 | white_time_current: @initial_time,
24 | black_time_current: @initial_time,
25 | last_move: nil,
26 | started: false
27 | }
28 | end
29 |
30 | def apply_move(timer = %GameTimer{}, game = %Game{}) do
31 | if timer.started == false do
32 | %{timer | started: true, last_move: DateTime.utc_now()}
33 | else
34 | apply_move_started(timer, game)
35 | end
36 | end
37 |
38 | defp apply_move_started(timer = %GameTimer{}, game = %Game{}) do
39 | time_elapsed = DateTime.diff(DateTime.utc_now(), timer.last_move, :millisecond)
40 |
41 | case game.turn do
42 | :white ->
43 | t = timer.white_time - time_elapsed
44 | %{timer | white_time: t, white_time_current: t, last_move: DateTime.utc_now()}
45 |
46 | :black ->
47 | t = timer.black_time - time_elapsed
48 | %{timer | black_time: t, black_time_current: t, last_move: DateTime.utc_now()}
49 |
50 | _ ->
51 | Logger.warning("GameTimer: Don't know whose turn it is.", [])
52 | %{timer | last_move: DateTime.utc_now()}
53 | end
54 | end
55 |
56 | def get_current_time(timer, game) do
57 | if timer.started == false do
58 | timer
59 | else
60 | get_current_time_started(timer, game)
61 | end
62 | end
63 |
64 | defp get_current_time_started(timer = %GameTimer{}, game = %Game{}) do
65 | time_elapsed = DateTime.diff(DateTime.utc_now(), timer.last_move, :millisecond)
66 |
67 | case game.turn do
68 | :white ->
69 | t = timer.white_time - time_elapsed
70 | %{timer | white_time_current: t}
71 |
72 | :black ->
73 | t = timer.black_time - time_elapsed
74 | %{timer | black_time_current: t}
75 |
76 | _ ->
77 | Logger.warning("GameTimer: Don't know whose turn it is [2].", [])
78 | timer
79 | end
80 | end
81 |
82 | def check_winner(timer = %GameTimer{}) do
83 | cond do
84 | timer.white_time_current < 0 -> :black
85 | timer.black_time_current < 0 -> :white
86 | true -> nil
87 | end
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/test/game_supervisor_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GameSupervisorTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias DemonSpiritGame.{GameSupervisor, GameServer}
5 |
6 | describe "start_game/1" do
7 | test "spawns a game server process" do
8 | game_name = "game-#{:rand.uniform(1000)}"
9 | assert {:ok, _pid} = GameSupervisor.start_game(game_name)
10 |
11 | via = GameServer.via_tuple(game_name)
12 | assert GenServer.whereis(via) |> Process.alive?()
13 | end
14 |
15 | test "returns an error if game is already started" do
16 | game_name = "game-#{:rand.uniform(1000)}"
17 |
18 | assert {:ok, pid} = GameSupervisor.start_game(game_name)
19 | assert {:error, {:already_started, ^pid}} = GameSupervisor.start_game(game_name)
20 | end
21 | end
22 |
23 | describe "start_game/2" do
24 | test "spawns a game server process" do
25 | game_name = "game-#{:rand.uniform(1000)}"
26 | assert {:ok, _pid} = GameSupervisor.start_game(game_name, :hardcoded_cards)
27 |
28 | via = GameServer.via_tuple(game_name)
29 | assert GenServer.whereis(via) |> Process.alive?()
30 | end
31 |
32 | test "returns an error if game is already started" do
33 | game_name = "game-#{:rand.uniform(1000)}"
34 |
35 | assert {:ok, pid} = GameSupervisor.start_game(game_name, :hardcoded_cards)
36 |
37 | assert {:error, {:already_started, ^pid}} =
38 | GameSupervisor.start_game(game_name, :hardcoded_cards)
39 | end
40 | end
41 |
42 | describe "stop_game" do
43 | test "terminates the process normally" do
44 | game_name = "game-#{:rand.uniform(1000)}"
45 | {:ok, _pid} = GameSupervisor.start_game(game_name)
46 | via = GameServer.via_tuple(game_name)
47 |
48 | assert :ok = GameSupervisor.stop_game(game_name)
49 | refute GenServer.whereis(via)
50 | end
51 | end
52 |
53 | describe "ets preservation of crashed processes" do
54 | test "supervised game gets restarted and retains status after crashing" do
55 | # Start a supervised game
56 | game_name = "game-#{:rand.uniform(1000)}"
57 | {:ok, pid} = GameSupervisor.start_game(game_name)
58 |
59 | # Move a single piece, and record the state before/after
60 | initial_state = GameServer.state(game_name)
61 | move = GameServer.all_valid_moves(game_name) |> Enum.at(0)
62 | GameServer.move(game_name, move)
63 | new_state = GameServer.state(game_name)
64 | assert initial_state != new_state
65 |
66 | # Make the game crash
67 | Process.exit(pid, :kaboom)
68 | # Give supervisor time to restart
69 | :timer.sleep(10)
70 |
71 | # Check the restored state and verify it's not the initial state
72 | restored_state = GameServer.state(game_name)
73 | assert restored_state == new_state
74 | assert restored_state != initial_state
75 | end
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/show/board.html.heex:
--------------------------------------------------------------------------------
1 | <% game = @state.game %>
2 | <% my_turn = @state[game.turn] == @guest %>
3 |
4 | <%# Game Won Modal %>
5 | <%= if game.winner != nil do %>
6 |
7 |
8 |
9 |
10 | <%= game.winner %> wins!
11 |
12 |
13 | <%= link "Back to Lobby", to: Routes.game_path(@socket, :index), class: "btn btn-primary" %>
14 |
15 |
16 |
17 |
18 | <% end %>
19 |
20 | <%# Move Clarification Modal %>
21 | <%= if my_turn and needs_clarify?(@state) do %>
22 |
23 |
24 |
25 | Use which move?
26 |
27 | <%= for {i, card} <- clarify_moves(@state) do %>
28 |
32 |
33 |
34 | <%= render "show/card.html", card: card, flip: false, class: "" %>
35 |
36 | <% end %>
37 |
38 | Cancel
39 |
40 |
41 |
42 |
43 |
44 | <% end %>
45 |
46 |
47 | <%# Render Pieces %>
48 | <%= for {{x, y}, piece} <- game.board do %>
49 |
to_string(x) <> "-" <> to_string(y) <> "-" <> Atom.to_string(piece.type) <> "-" <> Atom.to_string(piece.color) }
51 | phx-click={phx_click_value(x, y)}
52 | phx-hook="DraggableDroppable"
53 | draggable='true'
54 | onclick="event.preventDefault(); return false;"
55 | data-x={x}
56 | data-y={y}
57 | class={"cursor-pointer absolute piece " <> Atom.to_string(piece.type) <> " " <> Atom.to_string(piece.color)}
58 | style={piece_style(x, y, @flip_per)}>
59 |
60 | <% end %>
61 |
62 | <%# Render Square Targets %>
63 | <%= for x <- [0, 1, 2, 3, 4] do %>
64 | <%= for y <- [0, 1, 2, 3, 4] do %>
65 |
to_string(x) <> "-" <> to_string(y) }
67 | phx-click={phx_click_value(x, y)}
68 | phx-hook="Droppable"
69 | onclick="event.preventDefault(); return false;"
70 | data-x={x}
71 | data-y={y}
72 | class={square_class(x, y, @state, my_turn)}
73 | style={piece_style(x, y, @flip_per)}
74 | >
77 | <% end %>
78 | <% end %>
79 |
80 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/lib/demon_spirit_game/chat_server.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritGame.ChatServer do
2 | @moduledoc """
3 | ChatServer is a GenServer for holding a %Chat{}.
4 | You can start one up with a specified chat_name (string),
5 | then use add_message/2 to add messages to the chat,
6 | and messages/1 to get a list of messages in the chat.
7 |
8 | The chat messages cap at out 250 per room. At 250,
9 | adding a new message will delete the oldest one.
10 | Currently not configurable.
11 | """
12 | use GenServer
13 | require Logger
14 | alias DemonSpiritGame.Chat
15 |
16 | @timeout :timer.hours(4)
17 |
18 | def start_link(chat_name) do
19 | GenServer.start_link(__MODULE__, {chat_name}, name: via_tuple(chat_name))
20 | end
21 |
22 | @doc """
23 | via_tuple/1: Given a game name string, generate a via tuple for addressing the game.
24 | """
25 | def via_tuple(chat_name),
26 | do: {:via, Registry, {DemonSpiritGame.ChatRegistry, {__MODULE__, chat_name}}}
27 |
28 | @doc """
29 | chat_pid/1: Returns the `pid` of the chat server process registered
30 | under the given `chat_name`, or `nil` if no process is registered.
31 | """
32 | def chat_pid(chat_name) do
33 | chat_name
34 | |> via_tuple()
35 | |> GenServer.whereis()
36 | end
37 |
38 | @doc """
39 | add_message/2: Add a message to the chat room.
40 | Input: chat_name (String)
41 | Input: message (Any)
42 | Output: list of messages ( [ any ] ), newest at last.
43 | """
44 | def add_message(chat_name, message) do
45 | GenServer.call(via_tuple(chat_name), {:add_message, message})
46 | end
47 |
48 | @doc """
49 | messages/1: Get a list of chat messages.
50 | Input: chat_name (String)
51 | Output: list of messages ( [ any ] ), newest at last.
52 | """
53 | def messages(chat_name) do
54 | GenServer.call(via_tuple(chat_name), :messages)
55 | end
56 |
57 | #####################################
58 | ########### IMPLEMENTATION ##########
59 | #####################################
60 |
61 | def init({chat_name}) do
62 | Logger.info("ChatServer: Starting a server for chat named [#{chat_name}].")
63 | chat = %Chat{chat_name: chat_name}
64 | {:ok, chat, @timeout}
65 | end
66 |
67 | def handle_call({:add_message, message}, _from, chat) do
68 | chat = Chat.add_message(chat, message)
69 | {:reply, Chat.messages(chat), chat, @timeout}
70 | end
71 |
72 | def handle_call(:messages, _from, chat) do
73 | {:reply, Chat.messages(chat), chat, @timeout}
74 | end
75 |
76 | # When timing out, the order is handle_info(:timeout, _) -> terminate({:shutdown, :timeout}, _)
77 | def handle_info(:timeout, chat) do
78 | {:stop, {:shutdown, :timeout}, chat}
79 | end
80 |
81 | def terminate({:shutdown, :timeout}, chat) do
82 | Logger.info("ChatServer: Terminate (Timeout) running for #{chat.chat_name}")
83 | :ets.delete(:chats, chat.chat_name)
84 | :ok
85 | end
86 |
87 | def terminate(_reason, chat) do
88 | Logger.info("chatServer: Strange termination for [#{chat.chat_name}].")
89 | :ok
90 | end
91 | end
92 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/assets/static/images/bg-blue3.svg:
--------------------------------------------------------------------------------
1 |
2 |
17 |
19 |
20 |
22 | image/svg+xml
23 |
25 |
26 |
27 |
28 |
29 |
31 |
34 |
40 |
41 |
42 |
62 |
66 |
68 |
70 |
72 |
74 |
81 |
89 |
96 |
104 |
105 |
113 |
114 |
122 |
123 |
131 |
132 |
140 |
141 |
142 |
--------------------------------------------------------------------------------
/.github/workflows/elixir.yaml:
--------------------------------------------------------------------------------
1 | name: Elixir CI
2 |
3 | # Define workflow that runs when changes are pushed to the
4 | # `main` branch or pushed to a PR branch that targets the `main`
5 | # branch. Change the branch name if your project uses a
6 | # different name for the main branch like "master" or "production".
7 | on:
8 | push:
9 | branches: [ "master" ]
10 | pull_request:
11 | branches: [ "master" ]
12 |
13 | # Sets the ENV `MIX_ENV` to `test` for running tests
14 | env:
15 | MIX_ENV: test
16 |
17 | permissions:
18 | contents: read
19 |
20 | jobs:
21 | test:
22 | # Set up a Postgres DB service. By default, Phoenix applications
23 | # use Postgres. This creates a database for running tests.
24 | # Additional services can be defined here if required.
25 | # services:
26 | # db:
27 | # image: postgres:12
28 | # ports: ['5432:5432']
29 | # env:
30 | # POSTGRES_PASSWORD: postgres
31 | # options: >-
32 | # --health-cmd pg_isready
33 | # --health-interval 10s
34 | # --health-timeout 5s
35 | # --health-retries 5
36 |
37 | runs-on: ubuntu-latest
38 | name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}
39 | strategy:
40 | # Specify the OTP and Elixir versions to use when building
41 | # and running the workflow steps.
42 | matrix:
43 | otp: ['26.1.1'] # Define the OTP version [required]
44 | elixir: ['1.15.7'] # Define the elixir version [required]
45 | steps:
46 | # Step: Setup Elixir + Erlang image as the base.
47 | - name: Set up Elixir
48 | uses: erlef/setup-beam@v1
49 | with:
50 | otp-version: ${{matrix.otp}}
51 | elixir-version: ${{matrix.elixir}}
52 |
53 | # Step: Check out the code.
54 | - name: Checkout code
55 | uses: actions/checkout@v4
56 |
57 | # Step: Define how to cache deps. Restores existing cache if present.
58 | - name: Cache deps
59 | id: cache-deps
60 | uses: actions/cache@v3
61 | env:
62 | cache-name: cache-elixir-deps
63 | with:
64 | path: deps
65 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
66 | restore-keys: |
67 | ${{ runner.os }}-mix-${{ env.cache-name }}-
68 |
69 | # Step: Define how to cache the `_build` directory. After the first run,
70 | # this speeds up tests runs a lot. This includes not re-compiling our
71 | # project's downloaded deps every run.
72 | - name: Cache compiled build
73 | id: cache-build
74 | uses: actions/cache@v3
75 | env:
76 | cache-name: cache-compiled-build
77 | with:
78 | path: _build
79 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
80 | restore-keys: |
81 | ${{ runner.os }}-mix-${{ env.cache-name }}-
82 | ${{ runner.os }}-mix-
83 |
84 | # Step: Conditionally bust the cache when job is re-run.
85 | # Sometimes, we may have issues with incremental builds that are fixed by
86 | # doing a full recompile. In order to not waste dev time on such trivial
87 | # issues (while also reaping the time savings of incremental builds for
88 | # *most* day-to-day development), force a full recompile only on builds
89 | # that are retried.
90 | - name: Clean to rule out incremental build as a source of flakiness
91 | if: github.run_attempt != '1'
92 | run: |
93 | mix deps.clean --all
94 | mix clean
95 | shell: sh
96 |
97 | # Step: Download project dependencies. If unchanged, uses
98 | # the cached version.
99 | - name: Install dependencies
100 | run: mix deps.get
101 |
102 | # Step: Compile the project treating any warnings as errors.
103 | # Customize this step if a different behavior is desired.
104 |
105 | - name: Compiles without warnings
106 | run: mix compile
107 |
108 | # - name: Compiles without warnings
109 | # run: mix compile --warnings-as-errors
110 |
111 | # Step: Check that the checked in code has already been formatted.
112 | # This step fails if something was found unformatted.
113 | # Customize this step as desired.
114 | - name: Check Formatting
115 | run: mix format --check-formatted
116 |
117 | # Step: Execute the tests.
118 | - name: Run tests
119 | run: mix test
120 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/views/game_view.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.GameView do
2 | use DemonSpiritWeb, :view
3 | alias DemonSpiritWeb.GameUI
4 | import Phoenix.Component, only: [live_render: 3]
5 |
6 | @doc """
7 | staging?/1: Given the current gameui, is it in a staging state?
8 | The staging state is when we are waiting for players to sit down
9 | and ready up before the game begins.
10 | """
11 | def staging?(gameui) do
12 | GameUI.staging?(gameui)
13 | end
14 |
15 | @doc """
16 | needs_clarify?/1: Does the current player need to clarify which move they meant?
17 | """
18 | def needs_clarify?(gameui) do
19 | gameui.moves_need_clarify != nil and length(gameui.moves_need_clarify) > 0
20 | end
21 |
22 | @doc """
23 | clarify_moves/1: Get a list of moves a clarifying player can choose from, with index.
24 | In format: [ {0, %Card{}}, {1, %Card{}}, ... ]
25 | """
26 | def clarify_moves(gameui) do
27 | if needs_clarify?(gameui) do
28 | gameui.moves_need_clarify
29 | |> Enum.with_index()
30 | |> Enum.map(fn {move, i} -> {i, move.card} end)
31 | else
32 | []
33 | end
34 | end
35 |
36 | @doc """
37 | show_ready_button?/1: Given a gameui state and a player, should
38 | they see the ready button?
39 | """
40 | def show_ready_button?(gameui, guest) do
41 | cond do
42 | staging?(gameui) and gameui.black == guest and not gameui.black_ready ->
43 | true
44 |
45 | staging?(gameui) and gameui.white == guest and not gameui.white_ready ->
46 | true
47 |
48 | true ->
49 | false
50 | end
51 | end
52 |
53 | @doc """
54 | show_ready_button?/1: Given a gameui state and a player, should
55 | they see the "not ready" button?
56 | """
57 | def show_not_ready_button?(gameui, guest) do
58 | cond do
59 | staging?(gameui) and gameui.black == guest and gameui.black_ready ->
60 | true
61 |
62 | staging?(gameui) and gameui.white == guest and gameui.white_ready ->
63 | true
64 |
65 | true ->
66 | false
67 | end
68 | end
69 |
70 | def sort_gameinfos(gis) do
71 | gis |> Enum.sort_by(fn g -> {g.status, DateTime.to_iso8601(g.created_at)} end, &>=/2)
72 | end
73 |
74 | @doc """
75 | date_to_md/1: Turn a DateTime into a string representing the month and day in UTC.
76 | """
77 | def date_to_md(a) do
78 | "#{a.month}/#{a.day}"
79 | end
80 |
81 | @doc """
82 | date_to_hms/1: Turn a DateTime into a string representing the hour and minute in UTC.
83 | """
84 | def date_to_hms(a) do
85 | "#{a.hour}:#{zero_pad(a.minute)}"
86 | end
87 |
88 | # Zero_pad: Given an integer and an amount, pad left with 0s.
89 | # Returns string. zero_pad(1, 2) = "01". zero_pad(1, 3) = "001".
90 | @spec zero_pad(integer(), non_neg_integer()) :: String.t()
91 | def zero_pad(number, amount \\ 2) do
92 | number
93 | |> Integer.to_string()
94 | |> String.pad_leading(amount, "0")
95 | end
96 |
97 | def display_ms(ms) do
98 | seconds = ms / 1000
99 | seconds_int = trunc(seconds)
100 | # seconds_leftover = seconds - seconds_int
101 | {minutes_int, seconds_int_rem} = {div(seconds_int, 60), rem(seconds_int, 60)}
102 | "#{minutes_int}:#{zero_pad(seconds_int_rem, 2)}"
103 | end
104 |
105 | @doc """
106 | game_row_class/1: Returns the appropriate CSS class for a game row based on the game's status.
107 | """
108 | def game_row_class(game) do
109 | case game.status do
110 | :playing -> "bg-blue-200"
111 | :staging -> "bg-blue-100"
112 | :done -> "bg-blue-200"
113 | _ -> "bg-blue-300"
114 | end
115 | end
116 |
117 | def phx_click_value(x, y) do
118 | "click-square-#{x}-#{y}"
119 | end
120 |
121 | def piece_style(x, y, flip_per) do
122 | if flip_per do
123 | "transform: translate(#{(4 - x) * 100}%, #{y * 100}%);"
124 | else
125 | "transform: translate(#{x * 100}%, #{(4 - y) * 100}%);"
126 | end
127 | end
128 |
129 | def square_class(x, y, state, my_turn) do
130 | base_class = "cursor-pointer absolute square"
131 | selected_class = if my_turn and {x, y} == state.selected, do: " selected", else: ""
132 | move_dest_class = if my_turn and {x, y} in state.move_dest, do: " move_dest", else: ""
133 | also_piece_class = if Map.has_key?(state.game.board, {x, y}), do: " also_piece", else: ""
134 |
135 | last_move_class =
136 | if state.last_move != nil and
137 | (state.last_move.from == {x, y} or state.last_move.to == {x, y}),
138 | do: " last_move",
139 | else: ""
140 |
141 | base_class <> selected_class <> move_dest_class <> also_piece_class <> last_move_class
142 | end
143 | end
144 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/test/game_server_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GameServerTest do
2 | use ExUnit.Case, async: true
3 |
4 | doctest DemonSpiritGame.GameServer
5 | alias DemonSpiritGame.{GameServer, Game, Move, Card}
6 |
7 | describe "start_link/1" do
8 | test "spawns a process" do
9 | game_name = generate_game_name()
10 |
11 | assert {:ok, _pid} = GameServer.start_link(game_name)
12 | end
13 |
14 | test "each name can only have one process" do
15 | game_name = generate_game_name()
16 |
17 | assert {:ok, _pid} = GameServer.start_link(game_name)
18 | assert {:error, _reason} = GameServer.start_link(game_name)
19 | end
20 | end
21 |
22 | describe "start_link/2" do
23 | test "spawns a process" do
24 | game_name = generate_game_name()
25 |
26 | assert {:ok, _pid} = GameServer.start_link(game_name, :hardcoded_cards)
27 | end
28 |
29 | test "each name can only have one process" do
30 | game_name = generate_game_name()
31 |
32 | assert {:ok, _pid} = GameServer.start_link(game_name, :hardcoded_cards)
33 | assert {:error, _reason} = GameServer.start_link(game_name, :hardcoded_cards)
34 | end
35 | end
36 |
37 | describe "state/1" do
38 | test "get game state" do
39 | game_name = generate_game_name()
40 | assert {:ok, _pid} = GameServer.start_link(game_name)
41 | state = GameServer.state(game_name)
42 | assert %Game{} = state
43 | assert state.board |> Map.keys() |> length == 10
44 | end
45 |
46 | test "get game state (hardcoded cards)" do
47 | game_name = generate_game_name()
48 | assert {:ok, _pid} = GameServer.start_link(game_name, :hardcoded_cards)
49 | state = GameServer.state(game_name)
50 | assert %Game{} = state
51 | assert state.board |> Map.keys() |> length == 10
52 | assert state.cards.side.name == "Drake"
53 | end
54 | end
55 |
56 | describe "move/2" do
57 | setup do
58 | {:ok, wild_pig} = Card.by_name("Wild Pig")
59 | {:ok, cobra} = Card.by_name("Python")
60 | {:ok, drake} = Card.by_name("Drake")
61 | %{wild_pig: wild_pig, cobra: cobra, drake: drake}
62 | end
63 |
64 | test "Moving a piece via GameServer", %{cobra: cobra, wild_pig: wild_pig, drake: drake} do
65 | ## Need a way to generate an initial game state w/o RNG
66 | ## To know a move we can test
67 | game_name = generate_game_name()
68 | assert {:ok, _pid} = GameServer.start_link(game_name, :hardcoded_cards)
69 | game = GameServer.state(game_name)
70 |
71 | ## Simple move
72 | move = %Move{from: {0, 0}, to: {1, 1}, card: cobra}
73 |
74 | ## Do the move ourself (w/o server)
75 | {:ok, game_move_manual} = Game.move(game, move)
76 | {:ok, game_move_server} = GameServer.move(game_name, move)
77 |
78 | ## Are the moves the same?
79 | assert game_move_manual == game_move_server
80 | ## Are they diff from the initial state?
81 | refute game == game_move_server
82 |
83 | ## Did the move do what we expect?
84 | # Player flipped
85 | assert game_move_server.turn == :black
86 | # Card rotated
87 | assert game_move_server.cards.side == cobra
88 | assert wild_pig in game_move_server.cards.white
89 | assert drake in game_move_server.cards.white
90 | # Piece Moved
91 | refute game_move_server.board |> Map.has_key?({0, 0})
92 | assert game_move_server.board |> Map.has_key?({1, 1})
93 | end
94 | end
95 |
96 | describe "all_valid_moves/1" do
97 | test "a" do
98 | game_name = generate_game_name()
99 | assert {:ok, _pid} = GameServer.start_link(game_name, :hardcoded_cards)
100 | moves = GameServer.all_valid_moves(game_name)
101 | assert length(moves) == 9
102 | count = moves |> Enum.filter(fn m -> m.from == {4, 0} && m.to == {4, 1} end) |> Enum.count()
103 | assert count == 1
104 | end
105 | end
106 |
107 | describe "active_piece?/2" do
108 | test "a" do
109 | game_name = generate_game_name()
110 | assert {:ok, _pid} = GameServer.start_link(game_name, :hardcoded_cards)
111 | assert GameServer.active_piece?(game_name, {0, 0})
112 | refute GameServer.active_piece?(game_name, {2, 2})
113 | refute GameServer.active_piece?(game_name, {4, 4})
114 | end
115 | end
116 |
117 | describe "game_summary/1" do
118 | test "a" do
119 | game_name = generate_game_name()
120 | assert {:ok, _pid} = GameServer.start_link(game_name, :hardcoded_cards)
121 | summary = GameServer.game_summary(game_name)
122 | assert %{game: state, all_valid_moves: moves} = summary
123 | # Check moves
124 | assert length(moves) == 9
125 | count = moves |> Enum.filter(fn m -> m.from == {4, 0} && m.to == {4, 1} end) |> Enum.count()
126 | assert count == 1
127 | # Check game
128 | assert state.board |> Map.keys() |> length == 10
129 | assert state.cards.side.name == "Drake"
130 | end
131 | end
132 |
133 | defp generate_game_name do
134 | "game-#{:rand.uniform(1_000_000)}"
135 | end
136 | end
137 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/lib/demon_spirit_game/game_server.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritGame.GameServer do
2 | @moduledoc """
3 | Genserver to hold a %Game{}'s state within a process.
4 | """
5 | use GenServer
6 | require Logger
7 | @timeout :timer.hours(1)
8 |
9 | alias DemonSpiritGame.{Game, Move}
10 |
11 | #####################################
12 | ########### PUBLIC API ##############
13 | #####################################
14 |
15 | @doc """
16 | start_link/1: Generates a new game server under a provided name.
17 | """
18 | @spec start_link(String.t()) :: {:ok, pid} | {:error, any}
19 | def start_link(game_name) do
20 | GenServer.start_link(__MODULE__, {game_name}, name: via_tuple(game_name))
21 | end
22 |
23 | @doc """
24 | start_link/2: Generates a new game server under a provided name.
25 | Providing :hardcoded_cards removes the RNG of initial card selection
26 | and simply picks the first 5 cards in alphabetical order.
27 | This should only be used for testing.
28 | """
29 | @spec start_link(String.t(), :hardcoded_cards) :: {:ok, pid} | {:error, any}
30 | def start_link(game_name, :hardcoded_cards) do
31 | GenServer.start_link(__MODULE__, {game_name, :hardcoded_cards}, name: via_tuple(game_name))
32 | end
33 |
34 | @doc """
35 | via_tuple/1: Given a game name string, generate a via tuple for addressing the game.
36 | """
37 | def via_tuple(game_name),
38 | do: {:via, Registry, {DemonSpiritGame.GameRegistry, {__MODULE__, game_name}}}
39 |
40 | @doc """
41 | game_pid/1: Returns the `pid` of the game server process registered
42 | under the given `game_name`, or `nil` if no process is registered.
43 | """
44 | def game_pid(game_name) do
45 | game_name
46 | |> via_tuple()
47 | |> GenServer.whereis()
48 | end
49 |
50 | @doc """
51 | state/1: Retrieves the game state for the game under a provided name.
52 | """
53 | @spec state(String.t()) :: %Game{} | nil
54 | def state(game_name) do
55 | case game_pid(game_name) do
56 | nil -> nil
57 | _ -> GenServer.call(via_tuple(game_name), :state)
58 | end
59 | end
60 |
61 | @doc """
62 | move/2: Applies the given move to a game and returns the new game state.
63 | """
64 | @spec move(String.t(), %Move{}) :: {:ok, %Game{}} | {:error, :invalid_move}
65 | def move(game_name, move = %Move{}) do
66 | GenServer.call(via_tuple(game_name), {:move, move})
67 | end
68 |
69 | @doc """
70 | all_valid_moves/1: Return all valid moves for a game, given its name.
71 | """
72 | def all_valid_moves(game_name) do
73 | GenServer.call(via_tuple(game_name), :all_valid_moves)
74 | end
75 |
76 | @doc """
77 | active_piece?/2: Given a coordinate, does a piece exist there
78 | and belong to the currently playing player?
79 | """
80 | def active_piece?(game_name, coords = {x, y}) when is_integer(x) and is_integer(y) do
81 | GenServer.call(via_tuple(game_name), {:active_piece?, coords})
82 | end
83 |
84 | @doc """
85 | game_summary/1: Return both the game state and "all valid moves."
86 | %{
87 | game: %Game{},
88 | all_valid_moves: [ %Move{}, ... ]
89 | }
90 | """
91 | def game_summary(game_name) do
92 | GenServer.call(via_tuple(game_name), :game_summary)
93 | end
94 |
95 | #####################################
96 | ########### IMPLEMENTATION ##########
97 | #####################################
98 |
99 | def init({game_name, :hardcoded_cards}) do
100 | Logger.info("GameServer: Starting a server for game named [#{game_name}] (hardcoded cards).")
101 | _init(game_name, Game.new(game_name, :hardcoded_cards))
102 | end
103 |
104 | def init({game_name}) do
105 | Logger.info("GameServer: Starting a server for game named [#{game_name}].")
106 | _init(game_name, Game.new(game_name))
107 | end
108 |
109 | defp _init(game_name, new_game) do
110 | game =
111 | case :ets.lookup(:games, game_name) do
112 | [] ->
113 | game = new_game
114 | :ets.insert(:games, {game_name, game})
115 | game
116 |
117 | [{^game_name, game}] ->
118 | game
119 | end
120 |
121 | {:ok, game, @timeout}
122 | end
123 |
124 | def handle_call(:state, _from, game) do
125 | {:reply, game, game, @timeout}
126 | end
127 |
128 | def handle_call({:move, move = %Move{}}, _from, game) do
129 | case Game.move(game, move) do
130 | {:ok, new_game} ->
131 | :ets.insert(:games, {game.game_name, new_game})
132 | {:reply, {:ok, new_game}, new_game, @timeout}
133 |
134 | {:error, _} ->
135 | # ???
136 | {:reply, {:error, :invalid_move}, game, @timeout}
137 | end
138 | end
139 |
140 | def handle_call(:all_valid_moves, _from, game) do
141 | {:reply, Game.all_valid_moves(game), game, @timeout}
142 | end
143 |
144 | def handle_call({:active_piece?, coords = {x, y}}, _from, game)
145 | when is_integer(x) and is_integer(y) do
146 | response = Game.active_piece?(game, coords)
147 | {:reply, response, game, @timeout}
148 | end
149 |
150 | def handle_call(:game_summary, _from, game) do
151 | reply = %{
152 | all_valid_moves: Game.all_valid_moves(game),
153 | game: game
154 | }
155 |
156 | {:reply, reply, game, @timeout}
157 | end
158 |
159 | # When timing out, the order is handle_info(:timeout, _) -> terminate({:shutdown, :timeout}, _)
160 | def handle_info(:timeout, game) do
161 | {:stop, {:shutdown, :timeout}, game}
162 | end
163 |
164 | def terminate({:shutdown, :timeout}, game) do
165 | Logger.info("GameServer: Terminate (Timeout) running for #{game.game_name}")
166 | :ets.delete(:games, game.game_name)
167 | :ok
168 | end
169 |
170 | def terminate(_reason, game) do
171 | Logger.info("GameServer: Strange termination for [#{game.game_name}].")
172 | :ok
173 | end
174 | end
175 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/live_show.html.leex.bak:
--------------------------------------------------------------------------------
1 | <% game = @state.game %>
2 |
3 | <% top_cards = if @flip_per, do: game.cards.white, else: game.cards.black %>
4 | <% top_player = if @flip_per, do: @state.white, else: @state.black %>
5 | <% bottom_cards = if @flip_per, do: game.cards.black, else: game.cards.white %>
6 | <% bottom_player = if @flip_per, do: @state.black, else: @state.white %>
7 |
8 |
180 |
181 |
182 |
1
183 |
184 |
185 |
186 | <%= render "show/board.html", state: @state, flip_per: @flip_per %>
187 |
188 | 2
189 |
190 |
191 |
192 |
197 |
198 |
199 | <%= render "show/board.html", state: @state, flip_per: @flip_per %>
200 |
201 |
202 |
203 |
204 | <%= render "show/player.html", player: top_player %>
205 |
206 |
207 |
208 | <%= render "show/card.html", card: game.cards.side, flip: (game.turn == :black and not @flip_per) || (@flip_per and game.turn == :white) %>
209 | <%= if game.winner == nil do %>
210 |
211 | <%= game.turn %>'s turn
212 |
213 | <% end %>
214 |
215 |
216 |
217 | <%= render "show/player.html", player: bottom_player %>
218 |
219 |
220 |
225 |
226 | %>
227 |
228 |
229 | <%#= inspect(game, pretty: true) %>
230 |
231 |
232 | <%= inspect(@users, pretty: true) %>
233 |
234 |
235 | <%= inspect(@state, pretty: true) %>
236 |
237 |
238 | PHX Hook Example
239 | Type 10 numbers
240 |
241 |
242 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/templates/game/live_show.html.heex:
--------------------------------------------------------------------------------
1 | <% game = @state.game %>
2 |
3 | <% top_cards = if @flip_per, do: game.cards.white, else: game.cards.black %>
4 | <% top_player = if @flip_per, do: @state.white, else: @state.black %>
5 |
6 | <% bottom_cards = if @flip_per, do: game.cards.black, else: game.cards.white %>
7 | <% bottom_player = if @flip_per, do: @state.black, else: @state.white %>
8 |
9 | <% bottom_next = game.winner == nil and if @flip_per, do: game.turn == :black, else: game.turn == :white %>
10 | <% top_next = game.winner == nil and if @flip_per, do: game.turn == :white, else: game.turn == :black %>
11 |
12 | <% top_timer = if @flip_per, do: @state.timer.white_time_current, else: @state.timer.black_time_current %>
13 | <% bottom_timer = if @flip_per, do: @state.timer.black_time_current, else: @state.timer.white_time_current %>
14 |
15 |
90 |
91 | <%= if staging?(@state) do %>
92 | <%= render "show/ready.html", state: @state, socket: @socket, guest: @guest %>
93 | <% end %>
94 |
95 | @game_name}
97 | phx-hook="UpdateDing"
98 | class="hidden"
99 | data-moves={@state.game.moves}
100 | >
101 | <%= @state.game.moves %>
102 |
103 |
104 |
105 | <%# Main %>
106 |
107 |
108 |
109 | <%# Left - Chat %>
110 |
111 | <%= live_render(@socket, DemonSpiritWeb.LiveChatIndex,
112 | session: %{"chat_name" => @game_name, "guest" => @guest},
113 | id: @game_name,
114 | container: {:div, class: "h-full"}
115 | ) %>
116 |
117 |
118 | <%# Main - Board %>
119 |
120 |
121 | <%# Top cards, hide while staging %>
122 | <%= if staging?(@state) do %>
123 |
124 | <% else %>
125 |
126 | <%# Top Bar %>
127 | <%= for card <- top_cards do %>
128 | <%= render "show/card.html", card: card, flip: true, class: "mx-4" %>
129 | <% end %>
130 |
131 | <% end %>
132 |
133 |
134 | <%= render "show/board.html", state: @state, flip_per: @flip_per, guest: @guest, socket: @socket %>
135 |
136 |
137 | <%# Bottom cards, hide while staging %>
138 | <%= if staging?(@state) do %>
139 |
140 | <% else %>
141 |
142 | <%# Bottom Bar %>
143 | <%= for card <- bottom_cards do %>
144 | <%= render "show/card.html", card: card, flip: false, class: "mx-4" %>
145 | <% end %>
146 |
147 | <% end %>
148 |
149 |
150 |
151 | <%# Right - Side card and Players%>
152 | <%= if not staging?(@state) do %>
153 |
154 |
155 | <%= render "show/timer.html", timer: top_timer, active: top_next %>
156 | <%= if top_next do %>
157 |
158 | Next Move
159 |
160 | <% end %>
161 | <%= render "show/player.html", player: top_player %>
162 |
163 |
164 |
165 | <%= render "show/card.html",
166 | card: game.cards.side,
167 | flip: (game.turn == :black and not @flip_per) || (@flip_per and game.turn == :white),
168 | class: "mx-4"
169 | %>
170 | <%= if game.winner == nil do %>
171 |
172 | <%= game.turn %>'s turn
173 |
174 | <% end %>
175 |
176 |
177 |
178 | <%= render "show/player.html", player: bottom_player %>
179 | <%= if bottom_next do %>
180 |
181 | Next Move
182 |
183 | <% end %>
184 | <%= render "show/timer.html", timer: bottom_timer, active: bottom_next %>
185 |
186 |
187 |
188 | <% end %>
189 |
190 |
191 |
192 |
193 |
--------------------------------------------------------------------------------
/apps/demon_spirit_web/lib/demon_spirit_web/live/live_game_show.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritWeb.LiveGameShow do
2 | @moduledoc """
3 | LiveGameShow: This is the liveView of the "show" action of the game controller.
4 | If you are watching or playing a game, you're using this module.
5 | """
6 | use Phoenix.LiveView
7 | require Logger
8 | alias DemonSpiritWeb.{Endpoint, GameUIServer, GameView, Presence}
9 | alias DemonSpiritWeb.Router.Helpers, as: Routes
10 |
11 | def render(assigns) do
12 | GameView.render("live_show.html", assigns)
13 | end
14 |
15 | def mount(_params, %{"game_name" => game_name, "guest" => guest}, socket) do
16 | topic = topic_for(game_name)
17 |
18 | if connected?(socket), do: Endpoint.subscribe(topic)
19 | {:ok, _} = Presence.track(self(), topic, guest.id, guest)
20 |
21 | state = GameUIServer.sit_down_if_possible(game_name, guest)
22 | tick_ref = create_tick_interval(socket, state)
23 |
24 | notify(topic)
25 |
26 | socket =
27 | assign(socket,
28 | game_name: game_name,
29 | topic: topic,
30 | state: state,
31 | guest: guest,
32 | users: [],
33 | flip_per: guest == state.black,
34 | tick_ref: tick_ref
35 | )
36 |
37 | {:ok, socket}
38 | end
39 |
40 | # Update state every 1 second. This is so we can
41 | # see the chess timers counting down.
42 | defp create_tick_interval(socket, _state) do
43 | {:ok, tick_ref} =
44 | if connected?(socket) do
45 | :timer.send_interval(1000, self(), :tick)
46 | else
47 | {:ok, nil}
48 | end
49 |
50 | tick_ref
51 | end
52 |
53 | ## Event: "click-square-3-3" (Someone clicked on square (3,3))
54 | def handle_event(
55 | "click-square-" <> coords_str,
56 | _value,
57 | socket = %{assigns: %{game_name: game_name, guest: guest, topic: topic}}
58 | ) do
59 | {x, y} = extract_coords(coords_str)
60 |
61 | Logger.info("Game #{game_name}: Clicked on piece: #{x} #{y}")
62 | state = GameUIServer.click(game_name, {x, y}, guest)
63 | notify(topic)
64 |
65 | {:noreply, assign(socket, state: state, flip_per: guest == state.black)}
66 | end
67 |
68 | def handle_event(
69 | "click-ready",
70 | _value,
71 | socket = %{assigns: %{game_name: game_name, guest: guest, topic: topic}}
72 | ) do
73 | Logger.info("Game #{game_name}: Someone clicked ready")
74 | state = GameUIServer.ready(game_name, guest)
75 | notify(topic)
76 | {:noreply, assign(socket, state: state)}
77 | end
78 |
79 | def handle_event(
80 | "click-not-ready",
81 | _value,
82 | socket = %{assigns: %{game_name: game_name, guest: guest, topic: topic}}
83 | ) do
84 | Logger.info("Game #{game_name}: Someone clicked not ready")
85 | state = GameUIServer.not_ready(game_name, guest)
86 | notify(topic)
87 | {:noreply, assign(socket, state: state)}
88 | end
89 |
90 | def handle_event(
91 | "click-leave",
92 | _value,
93 | socket = %{assigns: %{game_name: game_name, guest: guest, topic: topic}}
94 | ) do
95 | Logger.info("Game #{game_name}: Someone clicked leave")
96 | state = GameUIServer.stand_up_if_possible(game_name, guest)
97 | notify(topic)
98 |
99 | socket =
100 | socket
101 | |> assign(state: state)
102 | |> redirect(to: Routes.game_path(socket, :index))
103 |
104 | {:stop, socket}
105 | end
106 |
107 | def handle_event(
108 | "drag-piece",
109 | %{"sx" => sx, "sy" => sy},
110 | socket = %{assigns: %{game_name: game_name, guest: guest, topic: topic}}
111 | ) do
112 | state = GameUIServer.drag_start(game_name, {sx, sy}, guest)
113 | notify(topic)
114 | {:noreply, assign(socket, state: state)}
115 | end
116 |
117 | def handle_event(
118 | "drag-end",
119 | _val,
120 | socket = %{assigns: %{game_name: game_name, guest: guest, topic: topic}}
121 | ) do
122 | state = GameUIServer.drag_end(game_name, guest)
123 | notify(topic)
124 | {:noreply, assign(socket, state: state)}
125 | end
126 |
127 | def handle_event(
128 | "drop-piece",
129 | %{"sx" => sx, "sy" => sy, "tx" => tx, "ty" => ty},
130 | socket = %{assigns: %{game_name: game_name, guest: guest, topic: topic}}
131 | ) do
132 | state = GameUIServer.drag_drop(game_name, {sx, sy}, {tx, ty}, guest)
133 | notify(topic)
134 | {:noreply, assign(socket, state: state)}
135 | end
136 |
137 | def handle_event(
138 | "clarify-move",
139 | %{"i" => i},
140 | socket = %{assigns: %{game_name: game_name, guest: guest, topic: topic}}
141 | ) do
142 | {i, ""} = Integer.parse(i)
143 | state = GameUIServer.clarify_move(game_name, i, guest)
144 | notify(topic)
145 | {:noreply, assign(socket, state: state)}
146 | end
147 |
148 | def handle_event(
149 | "cancel-clarify",
150 | _val,
151 | socket = %{assigns: %{game_name: game_name, guest: guest, topic: topic}}
152 | ) do
153 | state = GameUIServer.clarify_cancel(game_name, guest)
154 | notify(topic)
155 | {:noreply, assign(socket, state: state)}
156 | end
157 |
158 | defp extract_coords(coords_str) do
159 | [{x, ""}, {y, ""}] = coords_str |> String.split("-") |> Enum.map(&Integer.parse/1)
160 | {x, y}
161 | end
162 |
163 | def notify(topic) do
164 | Endpoint.broadcast_from(self(), topic, "state_update", %{})
165 | end
166 |
167 | def topic_for(game_name) do
168 | "game-topic:" <> game_name
169 | end
170 |
171 | # Handle incoming "state_updates": Game state has changed
172 | def handle_info(
173 | %{event: "state_update"},
174 | socket = %{assigns: %{game_name: game_name}}
175 | ) do
176 | state = GameUIServer.state(game_name)
177 | {:noreply, assign(socket, state: state)}
178 | end
179 |
180 | # Handle "presence_diff", someone joined or left
181 | def handle_info(%{event: "presence_diff"}, socket = %{assigns: %{topic: topic}}) do
182 | users =
183 | Presence.list(topic)
184 | |> Enum.map(fn {_user_id, data} ->
185 | data[:metas]
186 | |> List.first()
187 | end)
188 |
189 | {:noreply, assign(socket, users: users)}
190 | end
191 |
192 | # Handle ":tick", a request to update game state on a timer
193 | def handle_info(
194 | :tick,
195 | socket = %{assigns: %{game_name: game_name, tick_ref: tick_ref}}
196 | ) do
197 | state = GameUIServer.state(game_name)
198 |
199 | if tick_ref != nil and stop_ticking?(state) do
200 | :timer.cancel(tick_ref)
201 | end
202 |
203 | {:noreply, assign(socket, state: state)}
204 | end
205 |
206 | # There's a winner or game has been alive for a long time
207 | defp stop_ticking?(state) do
208 | state.game.winner != nil or game_alive_too_long?(state)
209 | end
210 |
211 | # Game alive more than 4 hours
212 | defp game_alive_too_long?(game_state) do
213 | DateTime.diff(DateTime.utc_now(), game_state.created_at) > 60 * 60 * 4
214 | end
215 | end
216 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/lib/demon_spirit_game/ai.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritGame.AI do
2 | @moduledoc """
3 | AI: This is a compute opponent to play against. You can feed it a game state, some
4 | parameters about how smart to be, and it will tell you its next move.
5 |
6 | Main function is alphabeta_skill/2.
7 | """
8 | alias DemonSpiritGame.Game
9 |
10 | # terminal?/1: Is this game in a terminal state? Boolean
11 | # (Is there a winner)
12 | defp terminal?(%Game{winner: nil}), do: false
13 | defp terminal?(%Game{}), do: true
14 |
15 | # max_player?/1: Is the current player trying to maximize score? Boolean
16 | # White is trying to maximize, black is trying to minimize.
17 | defp max_player?(%Game{turn: :white}), do: true
18 | defp max_player?(%Game{}), do: false
19 |
20 | # eval/1: Return score of current game. A won game is either + or -
21 | # 1 million points, positive if white won, negative if black won.
22 | # A non won-game is how many pieces up/down that player is.
23 | # If white has 5 pieces and black has 3, score is 2.
24 | # If black has 5 pieces and white has 1, score is -4.
25 | defp eval(%Game{winner: :white}), do: 1_000_000
26 | defp eval(%Game{winner: :black}), do: -1_000_000
27 |
28 | defp eval(game = %Game{}) do
29 | game.board
30 | |> Map.values()
31 | |> Enum.reduce(0, fn %{color: color}, acc -> acc + if color == :white, do: 1, else: -1 end)
32 | end
33 |
34 | # eval_skill/2: Return score of current game, but adjusted for skill level (1-100).
35 | # The lower the skill level of the AI, the more incorrect the score will be.
36 | # If skill level is 30, we use (real score * 0.30) + (random score * 0.70).
37 | defp eval_skill(game = %Game{}, 100), do: eval(game)
38 |
39 | defp eval_skill(game = %Game{}, skill) when is_integer(skill) and skill >= 0 and skill <= 100 do
40 | real_eval = eval(game)
41 |
42 | # Random between -6 and 6
43 | # fake_eval = :rand.uniform(13) - 7
44 | # Normal distribution, 2sd = -4.5 to 4.5, 3sd = -6.75 to 6.75, but rounded
45 | fake_eval = :rand.normal() * 2
46 |
47 | a = round(skill / 100 * real_eval + (100 - skill) / 100 * fake_eval)
48 | # "Eval_skill: skill[#{skill}] real eval[#{real_eval}] --> a[#{a}]" |> IO.inspect()
49 | a
50 | end
51 |
52 | @doc """
53 | alphabeta_skill/2: Run a mixmax a b prune search on game to find the next move, but
54 | give the AI a skill level (1-100). The lower skill, the poorer quality moves.
55 | Skill affects search depth, game state evaluation, and # of moves considered.
56 | """
57 | def alphabeta_skill(game, skill) when is_integer(skill) and skill >= 0 and skill <= 100 do
58 | depth = skill_to_depth(skill)
59 | alphabeta(game, depth, -1_000_000, 1_000_000, skill)
60 | end
61 |
62 | # skill_to_depth/1: Given a skill number 1-100, return a search depth to use.
63 | # We simply linerally rescale skill 1-100 to depth 2-8.
64 | defp skill_to_depth(skill) do
65 | # Depth 10 is just a little too slow, can take multiple minutes
66 | remap(skill, {0, 100}, {2, 8}) |> round()
67 | end
68 |
69 | # remap/3: Remap a number on one scale to another.
70 | # Example: remap(50, {0, 100}, {0, 1000}) = 500
71 | defp remap(x, {in_min, in_max}, {out_min, out_max}) do
72 | (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
73 | end
74 |
75 | # transform_moves_skill/2: Given a list of moves and a skill rating (1-100),
76 | # return which moves should be searched. At skill ratings below 70, we randomly
77 | # drop moves to be searched.
78 | defp transform_moves_skill(moves, 100), do: moves
79 | defp transform_moves_skill(moves, skill) when skill > 70, do: moves
80 |
81 | defp transform_moves_skill(moves, skill) do
82 | max_moves = length(moves)
83 |
84 | # min_moves = 1 # Lowest skill sees 1 random move
85 | # Lowest skill sees 45% of all moves, can't go below 1
86 | min_moves = (length(moves) * 0.45) |> round() |> max(1)
87 |
88 | # At skill >= skill_max, we see all moves
89 | skill_max = 70
90 | skill = min(skill_max, skill)
91 | num_moves = remap(skill, {0, skill_max}, {min_moves, max_moves}) |> round()
92 |
93 | # "Transform moves: skill[#{skill}] num moves[#{max_moves}}] moves considered[#{num_moves}]"
94 | # |> IO.inspect()
95 |
96 | if num_moves >= max_moves do
97 | moves
98 | else
99 | Enum.take_random(moves, num_moves)
100 | end
101 | end
102 |
103 | # alphabeta/2: Do minimax w/ alpha-beta pruning AI search for the best
104 | # move to play. Will consider `depth` number of moves.
105 | # For a brand new game, on my laptop
106 | # 9 - 4 seconds
107 | # 10 - 17 seconds
108 | # 11 - 1 minute 15 seconds
109 | # 12 - 5 minutes
110 | def alphabeta(game, depth) when is_integer(depth) do
111 | alphabeta(game, depth, -1_000_000, 1_000_000, 100)
112 | end
113 |
114 | def alphabeta(game, depth, a, b, skill)
115 | when is_integer(skill) and skill >= 0 and skill <= 100 do
116 | cond do
117 | depth == 0 or terminal?(game) -> %{val: eval_skill(game, skill), move: nil, a: a, b: b}
118 | max_player?(game) -> alphabeta_max(game, depth, a, b, skill)
119 | true -> alphabeta_min(game, depth, a, b, skill)
120 | end
121 | end
122 |
123 | defp alphabeta_max(game, depth, a, b, skill)
124 | when is_integer(skill) and skill >= 0 and skill <= 100 do
125 | initial_acc = %{
126 | val: -1_000_000,
127 | move: nil,
128 | a: a,
129 | b: b
130 | }
131 |
132 | game
133 | |> Game.all_valid_moves()
134 | |> transform_moves_skill(skill)
135 | |> Enum.reduce_while(initial_acc, fn move, acc ->
136 | {:ok, new_game} = Game.move(game, move)
137 | new_info = alphabeta(new_game, depth - 1, acc.a, acc.b, skill)
138 |
139 | # %{
140 | # who: "Maximizer",
141 | # move: move,
142 | # val: acc.val,
143 | # acc_move: acc.move,
144 | # new_val: new_info.val
145 | # }
146 | # |> IO.inspect()
147 |
148 | acc =
149 | if new_info.val > acc.val or acc.move == nil do
150 | %{acc | val: new_info.val, move: move}
151 | else
152 | acc
153 | end
154 |
155 | if acc.val >= acc.b do
156 | {:halt, acc}
157 | else
158 | acc = %{acc | a: Enum.max([acc.a, acc.val])}
159 | {:cont, acc}
160 | end
161 | end)
162 | end
163 |
164 | defp alphabeta_min(game, depth, a, b, skill)
165 | when is_integer(skill) and skill >= 0 and skill <= 100 do
166 | initial_acc = %{
167 | val: 1_000_000,
168 | move: nil,
169 | a: a,
170 | b: b
171 | }
172 |
173 | game
174 | |> Game.all_valid_moves()
175 | |> transform_moves_skill(skill)
176 | |> Enum.reduce_while(initial_acc, fn move, acc ->
177 | {:ok, new_game} = Game.move(game, move)
178 | new_info = alphabeta(new_game, depth - 1, acc.a, acc.b, skill)
179 |
180 | # %{
181 | # who: "Minimizer",
182 | # move: move,
183 | # val: acc.val,
184 | # new_val: new_info.val
185 | # }
186 | # |> IO.inspect()
187 |
188 | acc =
189 | if new_info.val < acc.val or acc.move == nil do
190 | %{acc | val: new_info.val, move: move}
191 | else
192 | acc
193 | end
194 |
195 | if acc.val <= acc.a do
196 | {:halt, acc}
197 | else
198 | acc = %{acc | b: Enum.min([acc.b, acc.val])}
199 | {:cont, acc}
200 | end
201 | end)
202 | end
203 | end
204 |
205 | # alias DemonSpiritGame.{AI, GameServer}
206 | # AI.example() |> AI.alphabeta(4)
207 |
208 | # GameServer.start_link("aaa")
209 | # GameServer.state("aaa") |> AI.alphabeta(4)
210 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/test/ai_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AiTest do
2 | use ExUnit.Case, async: true
3 |
4 | doctest DemonSpiritGame.AI, import: true
5 | alias DemonSpiritGame.{AI}
6 |
7 | test "example1" do
8 | ai_info = example1() |> AI.alphabeta(4)
9 | assert ai_info.val > 1000
10 | assert ai_info.move.from == {3, 3}
11 | assert ai_info.move.to == {2, 4}
12 | end
13 |
14 | test "example2" do
15 | ai_info = example2() |> AI.alphabeta(4)
16 | assert ai_info.move != nil
17 | end
18 |
19 | test "example3" do
20 | ai_info = example3() |> AI.alphabeta(4)
21 | assert ai_info.move != nil
22 | end
23 |
24 | test "example4" do
25 | ai_info = example4() |> AI.alphabeta(5)
26 | assert ai_info.val > 1000
27 | assert ai_info.move.from == {1, 2}
28 | assert ai_info.move.to == {1, 3}
29 | end
30 |
31 | # White to win in one move
32 | def example1 do
33 | %DemonSpiritGame.Game{
34 | board: %{
35 | {0, 0} => %{color: :white, type: :pawn},
36 | {0, 3} => %{color: :black, type: :pawn},
37 | {0, 4} => %{color: :black, type: :pawn},
38 | {1, 0} => %{color: :white, type: :pawn},
39 | {2, 4} => %{color: :black, type: :king},
40 | {3, 0} => %{color: :white, type: :pawn},
41 | {3, 3} => %{color: :white, type: :king},
42 | {4, 0} => %{color: :white, type: :pawn},
43 | {4, 4} => %{color: :black, type: :pawn}
44 | },
45 | cards: %{
46 | black: [
47 | %DemonSpiritGame.Card{
48 | color: :green,
49 | id: 1,
50 | moves: [{0, 2}, {0, -1}],
51 | name: "Tiger"
52 | },
53 | %DemonSpiritGame.Card{
54 | color: :blue,
55 | id: 12,
56 | moves: [{-1, 1}, {-1, -1}, {1, 0}],
57 | name: "Eel"
58 | }
59 | ],
60 | side: %DemonSpiritGame.Card{
61 | color: :red,
62 | id: 16,
63 | moves: [{1, 1}, {1, -1}, {-1, 0}],
64 | name: "Cobra"
65 | },
66 | white: [
67 | %DemonSpiritGame.Card{
68 | color: :blue,
69 | id: 28,
70 | moves: [{0, 1}, {-1, 1}, {1, -1}],
71 | name: "Bear"
72 | },
73 | %DemonSpiritGame.Card{
74 | color: :green,
75 | id: 5,
76 | moves: [{-2, 1}, {2, 1}, {-1, -1}, {1, -1}],
77 | name: "Dragon"
78 | }
79 | ]
80 | },
81 | game_name: "ephemeral-susquehanna-5655",
82 | turn: :white,
83 | winner: nil
84 | }
85 | end
86 |
87 | ## AI Move button ( + 153 ai_info = state.game |> AI.alphabeta(7)
88 | ## Was returning move = nil and crashing
89 | ## I guess black was about to lose no matter what, so it refused to move
90 | def example2 do
91 | %DemonSpiritGame.Game{
92 | board: %{
93 | {1, 1} => %{color: :white, type: :pawn},
94 | {1, 4} => %{color: :black, type: :pawn},
95 | {2, 0} => %{color: :white, type: :king},
96 | {2, 2} => %{color: :white, type: :pawn},
97 | {2, 3} => %{color: :black, type: :pawn},
98 | {2, 4} => %{color: :black, type: :king},
99 | {3, 0} => %{color: :white, type: :pawn},
100 | {3, 4} => %{color: :black, type: :pawn},
101 | {4, 0} => %{color: :white, type: :pawn},
102 | {4, 4} => %{color: :black, type: :pawn}
103 | },
104 | cards: %{
105 | black: [
106 | %DemonSpiritGame.Card{
107 | color: :red,
108 | id: 21,
109 | moves: [{1, 1}, {1, 0}, {1, -1}],
110 | name: "Fox"
111 | },
112 | %DemonSpiritGame.Card{
113 | color: :red,
114 | id: 13,
115 | moves: [{2, 0}, {1, 1}, {-1, -1}],
116 | name: "Rabbit"
117 | }
118 | ],
119 | side: %DemonSpiritGame.Card{
120 | color: :blue,
121 | id: 31,
122 | moves: [{0, 1}, {-2, 1}, {1, -1}],
123 | name: "Iguana"
124 | },
125 | white: [
126 | %DemonSpiritGame.Card{
127 | color: :green,
128 | id: 19,
129 | moves: [{-2, 0}, {2, 0}, {-1, 1}, {1, 1}],
130 | name: "Phoenix"
131 | },
132 | %DemonSpiritGame.Card{
133 | color: :green,
134 | id: 1,
135 | moves: [{0, 2}, {0, -1}],
136 | name: "Tiger"
137 | }
138 | ]
139 | },
140 | game_name: "incipient-imbroglio-4891",
141 | turn: :black,
142 | winner: nil
143 | }
144 | end
145 |
146 | ## Another one where black refuses to move
147 | def example3 do
148 | %DemonSpiritGame.Game{
149 | board: %{
150 | {0, 4} => %{color: :white, type: :king},
151 | {3, 2} => %{color: :black, type: :pawn},
152 | {4, 1} => %{color: :white, type: :pawn},
153 | {4, 3} => %{color: :black, type: :king}
154 | },
155 | cards: %{
156 | black: [
157 | %DemonSpiritGame.Card{
158 | color: :green,
159 | id: 3,
160 | moves: [{-1, 1}, {1, 1}, {-1, -1}, {1, -1}],
161 | name: "Monkey"
162 | },
163 | %DemonSpiritGame.Card{
164 | color: :green,
165 | id: 17,
166 | moves: [{0, -1}, {-2, 1}, {2, 1}],
167 | name: "Giraffe"
168 | }
169 | ],
170 | side: %DemonSpiritGame.Card{
171 | color: :blue,
172 | id: 10,
173 | moves: [{-1, 1}, {-1, 0}, {1, 0}, {1, -1}],
174 | name: "Goose"
175 | },
176 | white: [
177 | %DemonSpiritGame.Card{
178 | color: :green,
179 | id: 2,
180 | moves: [{0, 1}, {-2, 0}, {2, 0}],
181 | name: "Crab"
182 | },
183 | %DemonSpiritGame.Card{
184 | color: :blue,
185 | id: 28,
186 | moves: [{0, 1}, {-1, 1}, {1, -1}],
187 | name: "Bear"
188 | }
189 | ]
190 | },
191 | game_name: "summery-susquehanna-6049",
192 | turn: :black,
193 | winner: nil
194 | }
195 | end
196 |
197 | # White Advantage - Best move is {1, 2} to {1, 3}
198 | # Also forces mate in a few turns, |> AI.alphabeta(6)
199 | # should have val of 1000000
200 | def example4 do
201 | %DemonSpiritGame.Game{
202 | board: %{
203 | {1, 2} => %{color: :white, type: :pawn},
204 | {2, 0} => %{color: :white, type: :king},
205 | {2, 2} => %{color: :white, type: :pawn},
206 | {2, 4} => %{color: :black, type: :pawn},
207 | {3, 2} => %{color: :white, type: :pawn},
208 | {3, 4} => %{color: :black, type: :king},
209 | {4, 4} => %{color: :white, type: :pawn}
210 | },
211 | cards: %{
212 | black: [
213 | %DemonSpiritGame.Card{
214 | color: :red,
215 | id: 14,
216 | moves: [{1, 1}, {1, 0}, {-1, 0}, {-1, -1}],
217 | name: "Rooster"
218 | },
219 | %DemonSpiritGame.Card{
220 | color: :blue,
221 | id: 9,
222 | moves: [{-2, 0}, {-1, 1}, {1, -1}],
223 | name: "Frog"
224 | }
225 | ],
226 | side: %DemonSpiritGame.Card{
227 | color: :blue,
228 | id: 11,
229 | moves: [{-1, 0}, {0, 1}, {0, -1}],
230 | name: "Horse"
231 | },
232 | white: [
233 | %DemonSpiritGame.Card{
234 | color: :red,
235 | id: 22,
236 | moves: [{0, 1}, {1, 1}, {-1, -1}],
237 | name: "Panda"
238 | },
239 | %DemonSpiritGame.Card{
240 | color: :green,
241 | id: 17,
242 | moves: [{0, -1}, {-2, 1}, {2, 1}],
243 | name: "Giraffe"
244 | }
245 | ]
246 | },
247 | game_name: "lissome-susquehanna-1507",
248 | turn: :white,
249 | winner: nil
250 | }
251 | end
252 | end
253 |
--------------------------------------------------------------------------------
/apps/demon_spirit_game/lib/demon_spirit_game/card.ex:
--------------------------------------------------------------------------------
1 | defmodule DemonSpiritGame.Card do
2 | @moduledoc """
3 | Provides a structure to hold a card containing moves that
4 | a player may use. Also contains a hardcoded list of all cards.
5 |
6 | id: Hardcoded integer.
7 | name: String, name of card.
8 | moves: List of {int, int} tuples, representing moves.
9 | {1, 1} is the ability to move the piece up and right one.
10 | color: Atom, color of the card. Not used in gameplay.
11 | Blue is left-oriented, red is right-oriented, green is balanced.
12 | """
13 | alias DemonSpiritGame.{Card}
14 | defstruct id: nil, name: nil, moves: [], color: nil
15 |
16 | @doc """
17 | by_name/1: Retrieve a card by name.
18 |
19 | Input: A String of the name to search for.
20 | Output: Either {:ok, card} or {:error, nil}
21 | """
22 | @spec by_name(String.t()) :: {:ok, %Card{}} | {:error, nil}
23 | def by_name(name) do
24 | card = cards() |> Enum.filter(fn c -> c.name == name end)
25 |
26 | case length(card) do
27 | 1 -> {:ok, Enum.at(card, 0)}
28 | 0 -> {:error, nil}
29 | end
30 | end
31 |
32 | @doc """
33 | flip/1: Return a card with all of the moves flipped.
34 | That is, a {2, 1} will become a {-2, -1}.
35 |
36 | This is needed when black is playing, since by default, all
37 | moves specified are from white's perspective.
38 |
39 | Input: %Card
40 | Output: %Card with moves flipped.
41 | """
42 | @spec flip(%Card{}) :: %Card{}
43 | def flip(card) do
44 | flipped_moves = card.moves |> Enum.map(fn {x, y} -> {-x, -y} end)
45 | %{card | moves: flipped_moves}
46 | end
47 |
48 | @spec cards() :: nonempty_list(%Card{})
49 | def cards do
50 | base_cards() ++ exp1_cards() ++ exp2_cards()
51 | end
52 |
53 | @doc """
54 | cards/0: Provides all 16 cards that may be used in the game.
55 | A random set of 5 should be chosen when actually playing the game.
56 | """
57 | @spec base_cards() :: nonempty_list(%Card{})
58 | def base_cards do
59 | [
60 | %Card{
61 | id: 1,
62 | name: "Panther",
63 | moves: [{0, 2}, {0, -1}],
64 | color: :green
65 | },
66 | %Card{
67 | id: 2,
68 | name: "Crustacean",
69 | moves: [{0, 1}, {-2, 0}, {2, 0}],
70 | color: :green
71 | },
72 | %Card{
73 | id: 3,
74 | name: "Wukong",
75 | moves: [{-1, 1}, {1, 1}, {-1, -1}, {1, -1}],
76 | color: :green
77 | },
78 | %Card{
79 | id: 4,
80 | name: "Heron",
81 | moves: [{0, 1}, {-1, -1}, {1, -1}],
82 | color: :green
83 | },
84 | %Card{
85 | id: 5,
86 | name: "Drake",
87 | moves: [{-2, 1}, {2, 1}, {-1, -1}, {1, -1}],
88 | color: :green
89 | },
90 | %Card{
91 | id: 6,
92 | name: "Pachyderm",
93 | moves: [{1, 0}, {-1, 0}, {1, 1}, {-1, 1}],
94 | color: :green
95 | },
96 | %Card{
97 | id: 7,
98 | name: "Hierodula",
99 | moves: [{-1, 1}, {1, 1}, {0, -1}],
100 | color: :green
101 | },
102 | %Card{
103 | id: 8,
104 | name: "Wild Pig",
105 | moves: [{0, 1}, {-1, 0}, {1, 0}],
106 | color: :green
107 | },
108 | %Card{
109 | id: 9,
110 | name: "Toad",
111 | moves: [{-2, 0}, {-1, 1}, {1, -1}],
112 | color: :blue
113 | },
114 | %Card{
115 | id: 10,
116 | name: "Chen",
117 | moves: [{-1, 1}, {-1, 0}, {1, 0}, {1, -1}],
118 | color: :blue
119 | },
120 | %Card{
121 | id: 11,
122 | name: "Pony",
123 | moves: [{-1, 0}, {0, 1}, {0, -1}],
124 | color: :blue
125 | },
126 | %Card{
127 | id: 12,
128 | name: "Moray",
129 | moves: [{-1, 1}, {-1, -1}, {1, 0}],
130 | color: :blue
131 | },
132 | %Card{
133 | id: 13,
134 | name: "Hare",
135 | moves: [{2, 0}, {1, 1}, {-1, -1}],
136 | color: :red
137 | },
138 | %Card{
139 | id: 14,
140 | name: "Cockerel",
141 | moves: [{1, 1}, {1, 0}, {-1, 0}, {-1, -1}],
142 | color: :red
143 | },
144 | %Card{
145 | id: 15,
146 | name: "Steer",
147 | moves: [{1, 0}, {0, 1}, {0, -1}],
148 | color: :red
149 | },
150 | %Card{
151 | id: 16,
152 | name: "Python",
153 | moves: [{1, 1}, {1, -1}, {-1, 0}],
154 | color: :red
155 | }
156 | ]
157 | end
158 |
159 | @spec exp1_cards() :: nonempty_list(%Card{})
160 | def exp1_cards do
161 | [
162 | %Card{
163 | id: 17,
164 | name: "Camelopard",
165 | moves: [{0, -1}, {-2, 1}, {2, 1}],
166 | color: :green
167 | },
168 | %Card{
169 | id: 18,
170 | name: "Qilin",
171 | moves: [{1, 2}, {-1, 2}, {0, -2}],
172 | color: :green
173 | },
174 | %Card{
175 | id: 19,
176 | name: "Hawk",
177 | moves: [{-2, 0}, {2, 0}, {-1, 1}, {1, 1}],
178 | color: :green
179 | },
180 | # %Card{
181 | # id: 21,
182 | # name: "Vulpa",
183 | # moves: [{1, 1}, {1, 0}, {1, -1}],
184 | # color: :red
185 | # },
186 | %Card{
187 | id: 22,
188 | name: "Bao Bao",
189 | moves: [{0, 1}, {1, 1}, {-1, -1}],
190 | color: :red
191 | },
192 | %Card{
193 | id: 23,
194 | name: "Threadsnake",
195 | moves: [{0, 1}, {2, 0}, {-1, -1}],
196 | color: :red
197 | },
198 | %Card{
199 | id: 24,
200 | name: "Rodent",
201 | moves: [{1, 0}, {0, 1}, {-1, -1}],
202 | color: :red
203 | },
204 | %Card{
205 | id: 25,
206 | name: "Raccoon Dog",
207 | moves: [{0, 1}, {2, 1}, {-1, -1}],
208 | color: :red
209 | },
210 | %Card{
211 | id: 26,
212 | name: "Marten",
213 | moves: [{1, 1}, {-2, 0}, {-1, -1}],
214 | color: :red
215 | },
216 | # %Card{
217 | # id: 27,
218 | # name: "Canine",
219 | # moves: [{-1, 0}, {-1, 1}, {-1, -1}],
220 | # color: :blue
221 | # },
222 | %Card{
223 | id: 28,
224 | name: "Ursidae",
225 | moves: [{0, 1}, {-1, 1}, {1, -1}],
226 | color: :blue
227 | },
228 | %Card{
229 | id: 29,
230 | name: "Boa",
231 | moves: [{-2, 0}, {0, 1}, {1, -1}],
232 | color: :blue
233 | },
234 | %Card{
235 | id: 30,
236 | name: "Bandicoot",
237 | moves: [{-1, 0}, {0, 1}, {1, -1}],
238 | color: :blue
239 | },
240 | %Card{
241 | id: 31,
242 | name: "Lizard",
243 | moves: [{0, 1}, {-2, 1}, {1, -1}],
244 | color: :blue
245 | },
246 | %Card{
247 | id: 32,
248 | name: "Kawauso",
249 | moves: [{-1, 1}, {1, -1}, {2, 0}],
250 | color: :blue
251 | }
252 | ]
253 | end
254 |
255 | @spec exp2_cards() :: nonempty_list(%Card{})
256 | def exp2_cards do
257 | # Green - Default
258 | # Blue - Left
259 | # Red - Right
260 | [
261 | %Card{
262 | id: 33,
263 | name: "Wasp",
264 | moves: [{-1, 1}, {-1, -1}, {1, 2}, {1, -2}],
265 | color: :red
266 | },
267 | %Card{
268 | id: 34,
269 | name: "Bee",
270 | moves: [{1, 1}, {1, -1}, {-1, 2}, {-1, -2}],
271 | color: :blue
272 | },
273 | # %Card{
274 | # id: 35,
275 | # name: "Mole",
276 | # moves: [{2, 0}, {2, -1}, {2, 1}],
277 | # color: :red
278 | # },
279 | # %Card{
280 | # id: 36,
281 | # name: "Gopher",
282 | # moves: [{-2, 0}, {-2, -1}, {-2, 1}],
283 | # color: :blue
284 | # },
285 | %Card{
286 | id: 37,
287 | name: "Duck",
288 | moves: [{0, 1}, {1, 0}, {2, 2}],
289 | color: :red
290 | },
291 | %Card{
292 | id: 38,
293 | name: "Swan",
294 | moves: [{0, 1}, {-1, 0}, {-2, 2}],
295 | color: :blue
296 | },
297 | %Card{
298 | id: 39,
299 | name: "Raging Demon",
300 | moves: [{0, 2}, {1, 1}, {-1, 1}],
301 | color: :green
302 | },
303 | %Card{
304 | id: 40,
305 | name: "Dolphin",
306 | moves: [{0, 1}, {-1, 0}, {1, 2}],
307 | color: :red
308 | },
309 | %Card{
310 | id: 41,
311 | name: "Shark",
312 | moves: [{0, 1}, {1, 0}, {-1, 2}],
313 | color: :blue
314 | },
315 | %Card{
316 | id: 42,
317 | name: "Eagle",
318 | moves: [{2, 2}, {-2, 2}, {0, -1}],
319 | color: :green
320 | },
321 | %Card{
322 | id: 43,
323 | name: "Piglet",
324 | moves: [{0, 1}, {-1, 0}, {1, 0}],
325 | color: :green
326 | },
327 | %Card{
328 | id: 44,
329 | name: "Warthog",
330 | moves: [{0, 1}, {0, -1}, {-1, 0}, {1, 0}],
331 | color: :green
332 | }
333 | ]
334 | end
335 | end
336 |
--------------------------------------------------------------------------------