├── .dockerignore ├── test ├── test_helper.exs ├── leafblower_web │ └── views │ │ ├── page_view_test.exs │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs ├── leafblower │ └── deck_test.exs └── support │ ├── channel_case.ex │ ├── conn_case.ex │ └── data_case.ex ├── .tools_version ├── docs ├── mobile_demo.gif └── logo-leafblower.png ├── priv ├── repo │ ├── migrations │ │ └── .formatter.exs │ └── seeds.exs ├── static │ ├── favicon.ico │ ├── robots.txt │ └── images │ │ └── logo.svg └── gettext │ ├── errors.pot │ └── en │ └── LC_MESSAGES │ └── errors.po ├── lib ├── leafblower │ ├── mailer.ex │ ├── process_registry.ex │ ├── game_supervisor.ex │ ├── application.ex │ ├── game_ticker.ex │ ├── deck.ex │ └── game_statem.ex ├── leafblower_web │ ├── templates │ │ └── layout │ │ │ ├── app.html.heex │ │ │ ├── live.html.heex │ │ │ ├── ingame.html.heex │ │ │ └── root.html.heex │ ├── views │ │ ├── layout_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── plugs │ │ └── current_user.ex │ ├── gettext.ex │ ├── telemetry.ex │ ├── endpoint.ex │ ├── router.ex │ ├── component │ │ └── game_chat.ex │ └── controllers │ │ ├── game_splash_live.ex │ │ └── game_live.ex ├── leafblower.ex └── leafblower_web.ex ├── assets ├── package.json ├── js │ ├── chatHooks.js │ └── app.js ├── vendor │ └── topbar.js └── css │ ├── app.css │ └── phoenix.css ├── .formatter.exs ├── rel ├── env.bat.eex ├── vm.args.eex ├── remote.vm.args.eex └── env.sh.eex ├── config ├── test.exs ├── config.exs ├── prod.exs ├── dev.exs └── runtime.exs ├── fly.toml ├── .gitignore ├── README.md ├── mix.exs ├── Dockerfile ├── mix.lock └── LICENSE /.dockerignore: -------------------------------------------------------------------------------- 1 | assets/node_modules/ 2 | deps/ -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.tools_version: -------------------------------------------------------------------------------- 1 | erlang 23.2.1 2 | elixir 1.12 3 | -------------------------------------------------------------------------------- /docs/mobile_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fmterrorf/leafblower/HEAD/docs/mobile_demo.gif -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fmterrorf/leafblower/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /docs/logo-leafblower.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fmterrorf/leafblower/HEAD/docs/logo-leafblower.png -------------------------------------------------------------------------------- /lib/leafblower/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Leafblower.Mailer do 2 | use Swoosh.Mailer, otp_app: :leafblower 3 | end 4 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "deploy": "cd .. && mix assets.deploy && rm -f _build/esbuild" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/leafblower_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.PageViewTest do 2 | use LeafblowerWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /priv/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 | -------------------------------------------------------------------------------- /lib/leafblower_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%= @inner_content %> 5 |
6 | -------------------------------------------------------------------------------- /lib/leafblower.ex: -------------------------------------------------------------------------------- 1 | defmodule Leafblower do 2 | @moduledoc """ 3 | Leafblower keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/leafblower_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.LayoutView do 2 | use LeafblowerWeb, :view 3 | 4 | # Phoenix LiveDashboard is available only in development by default, 5 | # so we instruct Elixir to not warn if the dashboard route is missing. 6 | @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} 7 | end 8 | -------------------------------------------------------------------------------- /rel/env.bat.eex: -------------------------------------------------------------------------------- 1 | @echo off 2 | rem Set the release to work across nodes. If using the long name format like 3 | rem the one below (my_app@127.0.0.1), you need to also uncomment the 4 | rem RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none". 5 | rem set RELEASE_DISTRIBUTION=name 6 | rem set RELEASE_NODE=<%= @release.name %>@127.0.0.1 7 | -------------------------------------------------------------------------------- /test/leafblower_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.LayoutViewTest do 2 | use LeafblowerWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /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 | # Leafblower.Repo.insert!(%Leafblower.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /rel/vm.args.eex: -------------------------------------------------------------------------------- 1 | ## Customize flags given to the VM: https://erlang.org/doc/man/erl.html 2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here 3 | 4 | ## Number of dirty schedulers doing IO work (file, sockets, and others) 5 | ##+SDio 5 6 | 7 | ## Increase number of concurrent ports/sockets 8 | ##+Q 65536 9 | 10 | ## Tweak GC to run more often 11 | ##-env ERL_FULLSWEEP_AFTER 10 12 | -------------------------------------------------------------------------------- /rel/remote.vm.args.eex: -------------------------------------------------------------------------------- 1 | ## Customize flags given to the VM: https://erlang.org/doc/man/erl.html 2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here 3 | 4 | ## Number of dirty schedulers doing IO work (file, sockets, and others) 5 | ##+SDio 5 6 | 7 | ## Increase number of concurrent ports/sockets 8 | ##+Q 65536 9 | 10 | ## Tweak GC to run more often 11 | ##-env ERL_FULLSWEEP_AFTER 10 12 | -------------------------------------------------------------------------------- /lib/leafblower_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 9 | 10 | <%= @inner_content %> 11 |
12 | -------------------------------------------------------------------------------- /lib/leafblower_web/templates/layout/ingame.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 | 6 | 7 | 10 |
11 | 12 | <%= @inner_content %> 13 |
14 | -------------------------------------------------------------------------------- /lib/leafblower/process_registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Leafblower.ProcessRegistry do 2 | use Horde.Registry 3 | 4 | def start_link(_) do 5 | Horde.Registry.start_link(__MODULE__, [keys: :unique], name: __MODULE__) 6 | end 7 | 8 | def via_tuple(key) do 9 | {:via, Horde.Registry, {__MODULE__, key}} 10 | end 11 | 12 | def lookup(key) do 13 | Horde.Registry.lookup(__MODULE__, key) 14 | end 15 | 16 | def init(options) do 17 | Horde.Registry.init(options) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/leafblower_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.ErrorViewTest do 2 | use LeafblowerWeb.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(LeafblowerWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(LeafblowerWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/leafblower_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.ErrorView do 2 | use LeafblowerWeb, :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 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :leafblower, LeafblowerWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "6mYC9CTdkKnBJ5hDAY+2QoTO6grgwBdf3r+KwkLTOlLAxRq5mVTZPVbH225y4kqc", 8 | server: false 9 | 10 | # In test we don't send emails. 11 | config :leafblower, Leafblower.Mailer, adapter: Swoosh.Adapters.Test 12 | 13 | # Print only warnings and errors during test 14 | config :logger, level: :warn 15 | 16 | # Initialize plugs at runtime for faster test compilation 17 | config :phoenix, :plug_init_mode, :runtime 18 | -------------------------------------------------------------------------------- /lib/leafblower_web/plugs/current_user.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.Plugs.Currentuser do 2 | @moduledoc """ 3 | Sets a current_user_id to the session to uniquely identify players 4 | """ 5 | 6 | import Plug.Conn 7 | 8 | def init(default), do: default 9 | 10 | def call(%Plug.Conn{} = conn, _default) do 11 | if current_user_id = get_session(conn, :current_user_id) do 12 | conn 13 | |> assign(:current_user_id, current_user_id) 14 | else 15 | current_user_id = Ecto.UUID.generate() 16 | 17 | conn 18 | |> put_session(:current_user_id, current_user_id) 19 | |> assign(:current_user_id, current_user_id) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/leafblower/deck_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Leafblower.DeckTest do 2 | use ExUnit.Case 3 | alias Leafblower.Deck 4 | 5 | test "deal_white_card/4 deals whitecard properly" do 6 | deck = 7 | Deck.new( 8 | MapSet.new(), 9 | MapSet.new(["a", "b", "c", "d", "e", "f"]) 10 | ) 11 | 12 | {player_cards, deck} = 13 | Deck.deal_white_card( 14 | deck, 15 | %{"player_id1" => MapSet.new(["z"]), "player_id2" => MapSet.new()}, 16 | 1 17 | ) 18 | 19 | assert MapSet.size(player_cards["player_id1"]) == 1 20 | assert MapSet.size(player_cards["player_id2"]) == 1 21 | assert MapSet.size(deck.white) == 5 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/leafblower_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.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 LeafblowerWeb.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: :leafblower 24 | end 25 | -------------------------------------------------------------------------------- /rel/env.sh.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Sets and enables heart (recommended only in daemon mode) 4 | # case $RELEASE_COMMAND in 5 | # daemon*) 6 | # HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND" 7 | # export HEART_COMMAND 8 | # export ELIXIR_ERL_OPTIONS="-heart" 9 | # ;; 10 | # *) 11 | # ;; 12 | # esac 13 | 14 | # Set the release to work across nodes. If using the long name format like 15 | # the one below (my_app@127.0.0.1), you need to also uncomment the 16 | # RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none". 17 | # export RELEASE_DISTRIBUTION=name 18 | # export RELEASE_NODE=<%= @release.name %>@127.0.0.1 19 | 20 | 21 | ip=$(grep fly-local-6pn /etc/hosts | cut -f 1) 22 | export RELEASE_DISTRIBUTION=name 23 | export RELEASE_NODE=$FLY_APP_NAME@$ip 24 | export ELIXIR_ERL_OPTIONS="-proto_dist inet6_tcp" 25 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for leafblower on 2021-11-24T23:20:33-06:00 2 | 3 | app = "leafblower" 4 | 5 | kill_signal = "SIGTERM" 6 | kill_timeout = 5 7 | processes = [] 8 | 9 | [env] 10 | 11 | [experimental] 12 | allowed_public_ports = [] 13 | auto_rollback = true 14 | 15 | [[services]] 16 | http_checks = [] 17 | internal_port = 4000 18 | processes = ["app"] 19 | protocol = "tcp" 20 | script_checks = [] 21 | 22 | [services.concurrency] 23 | hard_limit = 25 24 | soft_limit = 20 25 | type = "connections" 26 | 27 | [[services.ports]] 28 | handlers = ["http"] 29 | port = 80 30 | 31 | [[services.ports]] 32 | handlers = ["tls", "http"] 33 | port = 443 34 | 35 | [[services.tcp_checks]] 36 | grace_period = "30s" # allow some time for startup 37 | interval = "15s" 38 | restart_limit = 0 39 | timeout = "2s" 40 | 41 | -------------------------------------------------------------------------------- /lib/leafblower/game_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Leafblower.GameSupervisor do 2 | use Horde.DynamicSupervisor 3 | alias Leafblower.{GameStatem, GameTicker} 4 | 5 | def start_link(_) do 6 | Horde.DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) 7 | end 8 | 9 | def new_game(arg) do 10 | {:ok, _} = 11 | Horde.DynamicSupervisor.start_child( 12 | __MODULE__, 13 | {GameTicker, Keyword.take(arg, [:id])} 14 | ) 15 | 16 | Horde.DynamicSupervisor.start_child( 17 | __MODULE__, 18 | {GameStatem, arg} 19 | ) 20 | end 21 | 22 | def find_game(id) do 23 | case Leafblower.ProcessRegistry.lookup({GameStatem, id}) do 24 | [{pid, _}] -> pid 25 | _ -> nil 26 | end 27 | end 28 | 29 | @impl true 30 | def init(_) do 31 | Horde.DynamicSupervisor.init(strategy: :one_for_one, members: :auto) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /.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 | leafblower-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | /priv/static/assets/ 27 | 28 | # Ignore digested assets cache. 29 | /priv/static/cache_manifest.json 30 | 31 | # In case you use Node.js/npm, you want to ignore these. 32 | npm-debug.log 33 | /assets/node_modules/ 34 | 35 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use LeafblowerWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import LeafblowerWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint LeafblowerWeb.Endpoint 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.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 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use LeafblowerWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import LeafblowerWeb.ConnCase 26 | 27 | alias LeafblowerWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint LeafblowerWeb.Endpoint 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/leafblower_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <%= live_title_tag assigns[:page_title] || "Leafblower", suffix: " · Play Cards Against Humanity online!" %> 9 | 10 | 11 | 12 | 13 |
14 |
15 | 17 |
18 | <%= link to: "/" do %> 19 | Leafblower 20 | <% end %> 21 |
22 |
23 |
24 | <%= @inner_content %> 25 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /lib/leafblower/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Leafblower.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 | @impl true 9 | def start(_type, _args) do 10 | topologies = Application.get_env(:libcluster, :topologies) || [] 11 | 12 | children = [ 13 | # Start the Telemetry supervisor 14 | LeafblowerWeb.Telemetry, 15 | # {Horde.Registry, [name: Leafblower.GameRegistry, keys: :unique, members: :auto]}, 16 | {Cluster.Supervisor, [topologies, [name: Leafblower.ClusterSupervisor]]}, 17 | Leafblower.GameSupervisor, 18 | Leafblower.ProcessRegistry, 19 | # Start the PubSub system 20 | {Phoenix.PubSub, name: Leafblower.PubSub}, 21 | # Start the Endpoint (http/https) 22 | LeafblowerWeb.Endpoint 23 | ] 24 | 25 | # See https://hexdocs.pm/elixir/Supervisor.html 26 | # for other strategies and supported options 27 | opts = [strategy: :one_for_one, name: Leafblower.Supervisor] 28 | Supervisor.start_link(children, opts) 29 | end 30 | 31 | # Tell Phoenix to update the endpoint configuration 32 | # whenever the application is updated. 33 | @impl true 34 | def config_change(changed, _new, removed) do 35 | LeafblowerWeb.Endpoint.config_change(changed, removed) 36 | :ok 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Leafblower 2 | 3 |

4 | 5 |

6 | 7 | Play Cards Against Humanity online with friends! 8 | 9 | Head over to https://leafblower.fly.dev/ to test it out. 10 | 11 | **Features** 12 | - In-game chat. So you can talk 💩 all you want 13 | - Mobile friendly 14 | 15 | # Development 16 | 17 | This project requires the following to run 18 | 19 | erlang 23.2.1 20 | elixir 1.12 21 | 22 | If you have [asdf](https://github.com/asdf-vm/asdf) installed, simply run `asdf install` 23 | 24 | To run this project locally simply run 25 | 26 | mix deps.get 27 | mix phx.server 28 | 29 | ## Code organization 30 | 31 | | File | What it does | 32 | | --------- | --------------- | 33 | | [game_live](./lib/leafblower_web/controllers/game_live.ex) | This is what you see when you start playing the game | 34 | | [game_statem](./lib/leafblower/game_statem.ex) | Handles the game state and logic | 35 | | [game_supervisor](./lib/leafblower/game_supervisor.ex) | Spawns the `game_statem` and `game_ticker` process | 36 | | [deck](./lib/leafblower/deck.ex) | Handles all operations to the deck like drawing cards from it | 37 | | [cards_against.json](./priv/cards_against.json) | Stores all the cards used in the game. Taken from [JSON Against Humanity](https://github.com/crhallberg/json-against-humanity) | 38 | 39 | # FAQ 40 | 41 | - Where did you get the card packs? I got it from [JSON Against Humanity](https://github.com/crhallberg/json-against-humanity) 42 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Leafblower.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use Leafblower.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias Leafblower.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import Leafblower.DataCase 27 | end 28 | end 29 | 30 | @doc """ 31 | A helper that transforms changeset errors into a map of messages. 32 | 33 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 34 | assert "password is too short" in errors_on(changeset).password 35 | assert %{password: ["password is too short"]} = errors_on(changeset) 36 | 37 | """ 38 | def errors_on(changeset) do 39 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 40 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 41 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 42 | end) 43 | end) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/leafblower_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # VM Metrics 34 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 35 | summary("vm.total_run_queue_lengths.total"), 36 | summary("vm.total_run_queue_lengths.cpu"), 37 | summary("vm.total_run_queue_lengths.io") 38 | ] 39 | end 40 | 41 | defp periodic_measurements do 42 | [ 43 | # A module, function and arguments to be invoked periodically. 44 | # This function must call :telemetry.execute/3 and a metric must be added above. 45 | # {LeafblowerWeb, :count_users, []} 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /assets/js/chatHooks.js: -------------------------------------------------------------------------------- 1 | export const ChatInput = { 2 | mounted() { 3 | this.el.addEventListener("keydown", (e) => { 4 | if (e.key === "Enter" && !e.shiftKey) { 5 | e.preventDefault(); 6 | 7 | document.querySelector("form").dispatchEvent(new Event("submit", { 8 | bubbles: true, 9 | cancelable: true 10 | })); 11 | } 12 | }); 13 | } 14 | } 15 | 16 | /** 17 | * ChatList provides auto scrolling to the bottom of the list 18 | * Taken from https://github.com/elixirschool/live-view-chat/blob/master/assets/js/app.js#L22 19 | */ 20 | export const ChatList = { 21 | mounted() { 22 | // Select the node that will be observed for mutations 23 | const targetNode = this.el; 24 | 25 | document.addEventListener("DOMContentLoaded", function () { 26 | targetNode.scrollTop = targetNode.scrollHeight 27 | }); 28 | 29 | // Options for the observer (which mutations to observe) 30 | const config = { attributes: true, childList: true, subtree: true }; 31 | // Callback function to execute when mutations are observed 32 | const callback = function (mutationsList, observer) { 33 | for (const mutation of mutationsList) { 34 | if (mutation.type == 'childList') { 35 | targetNode.scrollTop = targetNode.scrollHeight 36 | } 37 | } 38 | }; 39 | // Create an observer instance linked to the callback function 40 | const observer = new MutationObserver(callback); 41 | // Start observing the target node for configured mutations 42 | observer.observe(targetNode, config); 43 | } 44 | } -------------------------------------------------------------------------------- /lib/leafblower_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :leafblower 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_leafblower_key", 10 | signing_salt: "a9omCiF9" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :leafblower, 22 | gzip: false, 23 | only: ~w(assets fonts images favicon.ico robots.txt) 24 | 25 | # Code reloading can be explicitly enabled under the 26 | # :code_reloader configuration of your endpoint. 27 | if code_reloading? do 28 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 29 | plug Phoenix.LiveReloader 30 | plug Phoenix.CodeReloader 31 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :leafblower 32 | end 33 | 34 | plug Phoenix.LiveDashboard.RequestLogger, 35 | param_key: "request_logger", 36 | cookie_key: "request_logger" 37 | 38 | plug Plug.RequestId 39 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 40 | 41 | plug Plug.Parsers, 42 | parsers: [:urlencoded, :multipart, :json], 43 | pass: ["*/*"], 44 | json_decoder: Phoenix.json_library() 45 | 46 | plug Plug.MethodOverride 47 | plug Plug.Head 48 | plug Plug.Session, @session_options 49 | plug LeafblowerWeb.Router 50 | end 51 | -------------------------------------------------------------------------------- /lib/leafblower_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.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), 14 | class: "invalid-feedback", 15 | phx_feedback_for: input_name(form, field) 16 | ) 17 | end) 18 | end 19 | 20 | @doc """ 21 | Translates an error message using gettext. 22 | """ 23 | def translate_error({msg, opts}) do 24 | # When using gettext, we typically pass the strings we want 25 | # to translate as a static argument: 26 | # 27 | # # Translate "is invalid" in the "errors" domain 28 | # dgettext("errors", "is invalid") 29 | # 30 | # # Translate the number of files with plural rules 31 | # dngettext("errors", "1 file", "%{count} files", count) 32 | # 33 | # Because the error messages we show in our forms and APIs 34 | # are defined inside Ecto, we need to translate them dynamically. 35 | # This requires us to call the Gettext module passing our gettext 36 | # backend as first argument. 37 | # 38 | # Note we use the "errors" domain, which means translations 39 | # should be written to the errors.po file. The :count option is 40 | # set by Ecto and indicates we should also apply plural rules. 41 | if count = opts[:count] do 42 | Gettext.dngettext(LeafblowerWeb.Gettext, "errors", msg, msg, count, opts) 43 | else 44 | Gettext.dgettext(LeafblowerWeb.Gettext, "errors", msg, opts) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :leafblower, 11 | game_inactivity_timeout: :timer.minutes(30) 12 | 13 | # Configures the endpoint 14 | config :leafblower, LeafblowerWeb.Endpoint, 15 | url: [host: "localhost"], 16 | render_errors: [view: LeafblowerWeb.ErrorView, accepts: ~w(html json), layout: false], 17 | pubsub_server: Leafblower.PubSub, 18 | live_view: [signing_salt: "6oygRDIO"] 19 | 20 | # Configures the mailer 21 | # 22 | # By default it uses the "Local" adapter which stores the emails 23 | # locally. You can see the emails in your browser, at "/dev/mailbox". 24 | # 25 | # For production it's recommended to configure a different adapter 26 | # at the `config/runtime.exs`. 27 | config :leafblower, Leafblower.Mailer, adapter: Swoosh.Adapters.Local 28 | 29 | # Swoosh API client is needed for adapters other than SMTP. 30 | config :swoosh, :api_client, false 31 | 32 | # Configure esbuild (the version is required) 33 | config :esbuild, 34 | version: "0.12.18", 35 | default: [ 36 | args: ~w(js/app.js --bundle --target=es2016 --outdir=../priv/static/assets), 37 | cd: Path.expand("../assets", __DIR__), 38 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 39 | ] 40 | 41 | # Configures Elixir's Logger 42 | config :logger, :console, 43 | format: "$time $metadata[$level] $message\n", 44 | metadata: [:request_id] 45 | 46 | # Use Jason for JSON parsing in Phoenix 47 | config :phoenix, :json_library, Jason 48 | 49 | # Import environment specific config. This must remain at the bottom 50 | # of this file so it overrides the configuration defined above. 51 | import_config "#{config_env()}.exs" 52 | -------------------------------------------------------------------------------- /lib/leafblower_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.Router do 2 | use LeafblowerWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, {LeafblowerWeb.LayoutView, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | plug LeafblowerWeb.Plugs.Currentuser 12 | end 13 | 14 | pipeline :ingame do 15 | plug :put_root_layout, {LeafblowerWeb.LayoutView, :ingame} 16 | end 17 | 18 | pipeline :api do 19 | plug :accepts, ["json"] 20 | end 21 | 22 | scope "/", LeafblowerWeb do 23 | pipe_through :browser 24 | 25 | live "/", GameSplashLive, :index 26 | live "/join", GameSplashLive, :join_by_code 27 | live "/start", GameSplashLive, :start_game 28 | live "/:id", GameLive 29 | end 30 | 31 | # Other scopes may use custom stacks. 32 | # scope "/api", LeafblowerWeb do 33 | # pipe_through :api 34 | # end 35 | 36 | # Enables LiveDashboard only for development 37 | # 38 | # If you want to use the LiveDashboard in production, you should put 39 | # it behind authentication and allow only admins to access it. 40 | # If your application does not have an admins-only section yet, 41 | # you can use Plug.BasicAuth to set up some basic authentication 42 | # as long as you are also using SSL (which you should anyway). 43 | if Mix.env() in [:dev, :test] do 44 | import Phoenix.LiveDashboard.Router 45 | 46 | scope "/" do 47 | pipe_through :browser 48 | live_dashboard "/dashboard", metrics: LeafblowerWeb.Telemetry 49 | end 50 | end 51 | 52 | # Enables the Swoosh mailbox preview in development. 53 | # 54 | # Note that preview only shows emails that were sent by the same 55 | # node running the Phoenix server. 56 | if Mix.env() == :dev do 57 | scope "/dev" do 58 | pipe_through :browser 59 | 60 | forward "/mailbox", Plug.Swoosh.MailboxPreview 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We import the CSS which is extracted to its own file by esbuild. 2 | // Remove this line if you add a your own CSS build pipeline (e.g postcss). 3 | import "../css/app.css" 4 | 5 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 6 | // to get started and then uncomment the line below. 7 | // import "./user_socket.js" 8 | 9 | // You can include dependencies in two ways. 10 | // 11 | // The simplest option is to put them in assets/vendor and 12 | // import them using relative paths: 13 | // 14 | // import "./vendor/some-package.js" 15 | // 16 | // Alternatively, you can `npm install some-package` and import 17 | // them using a path starting with the package name: 18 | // 19 | // import "some-package" 20 | // 21 | 22 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 23 | import "phoenix_html" 24 | // Establish Phoenix Socket and LiveView configuration. 25 | import { Socket } from "phoenix" 26 | import { LiveSocket } from "phoenix_live_view" 27 | import topbar from "../vendor/topbar" 28 | import { ChatInput, ChatList } from "./chatHooks" 29 | 30 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 31 | let liveSocket = new LiveSocket("/live", Socket, { 32 | params: { _csrf_token: csrfToken }, 33 | hooks: { ChatInput, ChatList } 34 | }) 35 | 36 | // Show progress bar on live navigation and form submits 37 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) 38 | window.addEventListener("phx:page-loading-start", info => topbar.show()) 39 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 40 | 41 | // connect if there are any LiveViews on the page 42 | liveSocket.connect() 43 | 44 | // expose liveSocket on window for web console debug logs and latency simulation: 45 | // >> liveSocket.enableDebug() 46 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 47 | // >> liveSocket.disableLatencySim() 48 | window.liveSocket = liveSocket -------------------------------------------------------------------------------- /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 :leafblower, LeafblowerWeb.Endpoint, 13 | cache_static_manifest: "priv/static/cache_manifest.json" 14 | 15 | # Do not print debug messages in production 16 | config :logger, level: :info 17 | 18 | # ## SSL Support 19 | # 20 | # To get SSL working, you will need to add the `https` key 21 | # to the previous section and set your `:url` port to 443: 22 | # 23 | # config :leafblower, LeafblowerWeb.Endpoint, 24 | # ..., 25 | # url: [host: "example.com", port: 443], 26 | # https: [ 27 | # ..., 28 | # port: 443, 29 | # cipher_suite: :strong, 30 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 31 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 32 | # ] 33 | # 34 | # The `cipher_suite` is set to `:strong` to support only the 35 | # latest and more secure SSL ciphers. This means old browsers 36 | # and clients may not be supported. You can set it to 37 | # `:compatible` for wider support. 38 | # 39 | # `:keyfile` and `:certfile` expect an absolute path to the key 40 | # and cert in disk or a relative path inside priv, for example 41 | # "priv/ssl/server.key". For all supported SSL configuration 42 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 43 | # 44 | # We also recommend setting `force_ssl` in your endpoint, ensuring 45 | # no data is ever sent via http, always redirecting to https: 46 | # 47 | # config :leafblower, LeafblowerWeb.Endpoint, 48 | # force_ssl: [hsts: true] 49 | # 50 | # Check `Plug.SSL` for all available options in `force_ssl`. 51 | -------------------------------------------------------------------------------- /lib/leafblower_web/component/game_chat.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.Component.GameChat do 2 | use LeafblowerWeb, :live_component 3 | 4 | @impl true 5 | def mount(socket) do 6 | {:ok, assign(socket, changeset: cast_message())} 7 | end 8 | 9 | @impl true 10 | def handle_event("submit", %{"message" => params}, socket) do 11 | chat_publish(socket.assigns.game_id, socket.assigns.user_id, params["message"]) 12 | {:noreply, assign(socket, changeset: cast_message())} 13 | end 14 | 15 | @impl true 16 | def handle_event("validate-message", %{"message" => params}, socket) do 17 | {:noreply, 18 | assign(socket, 19 | changeset: 20 | cast_message(params) 21 | |> Map.put(:action, :insert) 22 | )} 23 | end 24 | 25 | @impl true 26 | def render(assigns) do 27 | ~H""" 28 |
29 | 34 | <.form let={f} for={@changeset} phx-target={@myself} phx-change="validate-message" phx-submit="submit" as="message"> 35 | <%= error_tag f, :message %> 36 | <%= text_input f, :message %> 37 | <%= submit "Send", [disabled: length(@changeset.errors) > 0] %> 38 | 39 |
40 | """ 41 | end 42 | 43 | def chat_topic(id), do: "#{__MODULE__}/#{id}" 44 | def chat_subscribe(id), do: Phoenix.PubSub.subscribe(Leafblower.PubSub, chat_topic(id)) 45 | 46 | def chat_publish(game_id, user_id, message), 47 | do: 48 | Phoenix.PubSub.broadcast( 49 | Leafblower.PubSub, 50 | chat_topic(game_id), 51 | {:new_message, %{id: Ecto.UUID.generate(), from: user_id, content: message}} 52 | ) 53 | 54 | defp cast_message(params \\ %{}) do 55 | {%{}, %{message: :string}} 56 | |> Ecto.Changeset.cast(params, [:message]) 57 | |> Ecto.Changeset.validate_required([:message]) 58 | |> Ecto.Changeset.validate_length(:message, max: 50) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Leafblower.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :leafblower, 7 | version: "0.1.0", 8 | elixir: "~> 1.12", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:gettext] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {Leafblower.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.6.0"}, 37 | {:phoenix_ecto, "~> 4.4"}, 38 | {:ecto_sql, "~> 3.6"}, 39 | {:postgrex, ">= 0.0.0"}, 40 | {:phoenix_html, "~> 3.0"}, 41 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 42 | {:phoenix_live_view, "~> 0.17.5"}, 43 | {:floki, ">= 0.30.0", only: :test}, 44 | {:phoenix_live_dashboard, "~> 0.6.1"}, 45 | {:esbuild, "~> 0.2", runtime: Mix.env() == :dev}, 46 | {:swoosh, "~> 1.3"}, 47 | {:telemetry_metrics, "~> 0.6"}, 48 | {:telemetry_poller, "~> 1.0"}, 49 | {:gettext, "~> 0.18"}, 50 | {:jason, "~> 1.2"}, 51 | {:plug_cowboy, "~> 2.5"}, 52 | {:gen_state_machine, "~> 3.0"}, 53 | {:libcluster, "~> 3.3"}, 54 | {:horde, "~> 0.7"} 55 | ] 56 | end 57 | 58 | # Aliases are shortcuts or tasks specific to the current project. 59 | # For example, to install project dependencies and perform other setup tasks, run: 60 | # 61 | # $ mix setup 62 | # 63 | # See the documentation for `Mix` for more info on aliases. 64 | defp aliases do 65 | [ 66 | setup: ["deps.get", "ecto.setup"], 67 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 68 | "ecto.reset": ["ecto.drop", "ecto.setup"], 69 | # test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 70 | test: ["test"], 71 | "assets.deploy": ["esbuild default --minify", "phx.digest"] 72 | ] 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with esbuild to bundle .js and .css sources. 9 | config :leafblower, LeafblowerWeb.Endpoint, 10 | # Binding to loopback ipv4 address prevents access from other machines. 11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 12 | http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "WNzKgw6B5L0cx9f1Qvbdvj1xEdGHVI+KvsuevUhsbZC8JmSxCkYSK7x/bfj/GjWo", 17 | watchers: [ 18 | # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) 19 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} 20 | ] 21 | 22 | # ## SSL Support 23 | # 24 | # In order to use HTTPS in development, a self-signed 25 | # certificate can be generated by running the following 26 | # Mix task: 27 | # 28 | # mix phx.gen.cert 29 | # 30 | # Note that this task requires Erlang/OTP 20 or later. 31 | # Run `mix help phx.gen.cert` for more information. 32 | # 33 | # The `http:` config above can be replaced with: 34 | # 35 | # https: [ 36 | # port: 4001, 37 | # cipher_suite: :strong, 38 | # keyfile: "priv/cert/selfsigned_key.pem", 39 | # certfile: "priv/cert/selfsigned.pem" 40 | # ], 41 | # 42 | # If desired, both `http:` and `https:` keys can be 43 | # configured to run both http and https servers on 44 | # different ports. 45 | 46 | # Watch static and templates for browser reloading. 47 | config :leafblower, LeafblowerWeb.Endpoint, 48 | live_reload: [ 49 | patterns: [ 50 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 51 | ~r"priv/gettext/.*(po)$", 52 | ~r"lib/leafblower_web/(live|views)/.*(ex)$", 53 | ~r"lib/leafblower_web/templates/.*(eex)$" 54 | ] 55 | ] 56 | 57 | # Do not include metadata nor timestamps in development logs 58 | config :logger, :console, format: "[$level] $message\n" 59 | 60 | # Set a higher stacktrace during development. Avoid configuring such 61 | # in production as building large stacktraces may be expensive. 62 | config :phoenix, :stacktrace_depth, 20 63 | 64 | # Initialize plugs at runtime for faster development compilation 65 | config :phoenix, :plug_init_mode, :runtime 66 | 67 | config :libcluster, 68 | topologies: [ 69 | leafblower: [ 70 | strategy: Elixir.Cluster.Strategy.Gossip 71 | ] 72 | ] 73 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/leafblower_web.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb 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 LeafblowerWeb, :controller 9 | use LeafblowerWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: LeafblowerWeb 23 | 24 | import Plug.Conn 25 | import LeafblowerWeb.Gettext 26 | alias LeafblowerWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/leafblower_web/templates", 34 | namespace: LeafblowerWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, 38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 39 | 40 | # Include shared imports and aliases for views 41 | unquote(view_helpers()) 42 | end 43 | end 44 | 45 | def live_view do 46 | quote do 47 | use Phoenix.LiveView, 48 | layout: {LeafblowerWeb.LayoutView, "live.html"} 49 | 50 | unquote(view_helpers()) 51 | end 52 | end 53 | 54 | def ingame_live_view do 55 | quote do 56 | use Phoenix.LiveView, 57 | layout: {LeafblowerWeb.LayoutView, "ingame.html"} 58 | 59 | unquote(view_helpers()) 60 | end 61 | end 62 | 63 | def live_component do 64 | quote do 65 | use Phoenix.LiveComponent 66 | 67 | unquote(view_helpers()) 68 | end 69 | end 70 | 71 | def router do 72 | quote do 73 | use Phoenix.Router 74 | 75 | import Plug.Conn 76 | import Phoenix.Controller 77 | import Phoenix.LiveView.Router 78 | end 79 | end 80 | 81 | def channel do 82 | quote do 83 | use Phoenix.Channel 84 | import LeafblowerWeb.Gettext 85 | end 86 | end 87 | 88 | defp view_helpers do 89 | quote do 90 | # Use all HTML functionality (forms, tags, etc) 91 | use Phoenix.HTML 92 | 93 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) 94 | import Phoenix.LiveView.Helpers 95 | 96 | # Import basic rendering functionality (render, render_layout, etc) 97 | import Phoenix.View 98 | 99 | import LeafblowerWeb.ErrorHelpers 100 | import LeafblowerWeb.Gettext 101 | alias LeafblowerWeb.Router.Helpers, as: Routes 102 | end 103 | end 104 | 105 | @doc """ 106 | When used, dispatch to the appropriate controller/view/etc. 107 | """ 108 | defmacro __using__(which) when is_atom(which) do 109 | apply(__MODULE__, which, []) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/leafblower/game_ticker.ex: -------------------------------------------------------------------------------- 1 | defmodule Leafblower.GameTicker do 2 | use GenServer 3 | alias Leafblower.{GameTicker} 4 | 5 | defstruct [:id, :timer_ref, :countdown_left, :action_meta] 6 | 7 | def start_link(arg) do 8 | id = Keyword.fetch!(arg, :id) 9 | GenServer.start_link(__MODULE__, arg, name: via_tuple(id)) 10 | end 11 | 12 | def start_tick(ticker, action_meta, duration_in_seconds), 13 | do: GenServer.cast(ticker, {:start_tick, action_meta, duration_in_seconds}) 14 | 15 | def stop_tick(ticker, action_meta), do: GenServer.cast(ticker, {:stop_tick, action_meta}) 16 | def subscribe(id), do: Phoenix.PubSub.subscribe(Leafblower.PubSub, topic(id)) 17 | def via_tuple(id), do: Leafblower.ProcessRegistry.via_tuple({__MODULE__, id}) 18 | 19 | @impl true 20 | def init(init_arg) do 21 | id = Keyword.fetch!(init_arg, :id) 22 | 23 | {:ok, 24 | %GameTicker{ 25 | id: id, 26 | timer_ref: nil, 27 | action_meta: nil, 28 | countdown_left: 0 29 | }} 30 | end 31 | 32 | @impl true 33 | def handle_cast({:start_tick, action_meta, duration_in_seconds}, state) do 34 | if state.timer_ref != nil do 35 | Process.cancel_timer(state.timer_ref) 36 | end 37 | 38 | Phoenix.PubSub.broadcast( 39 | Leafblower.PubSub, 40 | topic(state.id), 41 | {:ticker_ticked, duration_in_seconds} 42 | ) 43 | 44 | timer_ref = Process.send_after(self(), :tick, :timer.seconds(1)) 45 | 46 | {:noreply, 47 | %GameTicker{ 48 | state 49 | | countdown_left: duration_in_seconds, 50 | action_meta: action_meta, 51 | timer_ref: timer_ref 52 | }} 53 | end 54 | 55 | @impl true 56 | def handle_cast( 57 | {:stop_tick, action_meta}, 58 | %GameTicker{action_meta: action_meta, id: id, timer_ref: timer_ref} = state 59 | ) do 60 | Phoenix.PubSub.broadcast( 61 | Leafblower.PubSub, 62 | topic(id), 63 | {:ticker_ticked, 0} 64 | ) 65 | 66 | if timer_ref != nil do 67 | Process.cancel_timer(timer_ref) 68 | end 69 | 70 | {:noreply, 71 | %GameTicker{ 72 | state 73 | | countdown_left: 0, 74 | action_meta: nil, 75 | timer_ref: nil 76 | }} 77 | end 78 | 79 | @impl true 80 | def handle_info(:tick, %GameTicker{id: id} = state) when state.countdown_left > 1 do 81 | timer_ref = Process.send_after(self(), :tick, :timer.seconds(1)) 82 | state = %GameTicker{state | countdown_left: state.countdown_left - 1, timer_ref: timer_ref} 83 | 84 | Phoenix.PubSub.broadcast( 85 | Leafblower.PubSub, 86 | topic(id), 87 | {:ticker_ticked, state.countdown_left} 88 | ) 89 | 90 | {:noreply, state} 91 | end 92 | 93 | def handle_info(:tick, %GameTicker{id: id} = state) do 94 | state = %GameTicker{state | timer_ref: nil} 95 | 96 | Leafblower.GameSupervisor.find_game(id) 97 | |> send({:timer_end, state.action_meta}) 98 | 99 | {:noreply, state} 100 | end 101 | 102 | defp topic(id), do: "#{__MODULE__}/#{id}" 103 | end 104 | -------------------------------------------------------------------------------- /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 have %{count} item(s)" 54 | msgid_plural "should have %{count} item(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should be %{count} character(s)" 59 | msgid_plural "should be %{count} character(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be %{count} byte(s)" 64 | msgid_plural "should be %{count} byte(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 least %{count} character(s)" 74 | msgid_plural "should be at least %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should be at least %{count} byte(s)" 79 | msgid_plural "should be at least %{count} byte(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | msgid "should have at most %{count} item(s)" 84 | msgid_plural "should have at most %{count} item(s)" 85 | msgstr[0] "" 86 | msgstr[1] "" 87 | 88 | msgid "should be at most %{count} character(s)" 89 | msgid_plural "should be at most %{count} character(s)" 90 | msgstr[0] "" 91 | msgstr[1] "" 92 | 93 | msgid "should be at most %{count} byte(s)" 94 | msgid_plural "should be at most %{count} byte(s)" 95 | msgstr[0] "" 96 | msgstr[1] "" 97 | 98 | ## From Ecto.Changeset.validate_number/3 99 | msgid "must be less than %{number}" 100 | msgstr "" 101 | 102 | msgid "must be greater than %{number}" 103 | msgstr "" 104 | 105 | msgid "must be less than or equal to %{number}" 106 | msgstr "" 107 | 108 | msgid "must be greater than or equal to %{number}" 109 | msgstr "" 110 | 111 | msgid "must be equal to %{number}" 112 | msgstr "" 113 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of 2 | # Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # 8 | # This file is based on these images: 9 | # 10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image 12 | # - https://pkgs.org/ - resource for finding needed packages 13 | # - Ex: hexpm/elixir:1.12.3-erlang-24.1.4-debian-bullseye-20210902-slim 14 | # 15 | ARG BUILDER_IMAGE="hexpm/elixir:1.12.3-erlang-24.1.4-debian-bullseye-20210902-slim" 16 | ARG RUNNER_IMAGE="debian:bullseye-20210902-slim" 17 | 18 | FROM ${BUILDER_IMAGE} as builder 19 | 20 | # install build dependencies 21 | RUN apt-get update -y && apt-get install -y build-essential git \ 22 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 23 | 24 | # prepare build dir 25 | WORKDIR /app 26 | 27 | # install hex + rebar 28 | RUN mix local.hex --force && \ 29 | mix local.rebar --force 30 | 31 | # set build ENV 32 | ENV MIX_ENV="prod" 33 | 34 | # install mix dependencies 35 | COPY mix.exs mix.lock ./ 36 | RUN mix deps.get --only $MIX_ENV 37 | RUN mkdir config 38 | 39 | # copy compile-time config files before we compile dependencies 40 | # to ensure any relevant config change will trigger the dependencies 41 | # to be re-compiled. 42 | COPY config/config.exs config/${MIX_ENV}.exs config/ 43 | RUN mix deps.compile 44 | 45 | COPY priv priv 46 | 47 | # note: if your project uses a tool like https://purgecss.com/, 48 | # which customizes asset compilation based on what it finds in 49 | # your Elixir templates, you will need to move the asset compilation 50 | # step down so that `lib` is available. 51 | COPY assets assets 52 | 53 | # For Phoenix 1.6 and later, compile assets using esbuild 54 | RUN mix assets.deploy 55 | 56 | # For Phoenix versions earlier than 1.6, compile assets npm 57 | # RUN cd assets && yarn install && yarn run webpack --mode production 58 | # RUN mix phx.digest 59 | 60 | # Compile the release 61 | COPY lib lib 62 | 63 | RUN mix compile 64 | 65 | # Changes to config/runtime.exs don't require recompiling the code 66 | COPY config/runtime.exs config/ 67 | 68 | COPY rel rel 69 | RUN mix release 70 | 71 | # start a new build stage so that the final image will only contain 72 | # the compiled release and other runtime necessities 73 | FROM ${RUNNER_IMAGE} 74 | 75 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \ 76 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 77 | 78 | # Set the locale 79 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 80 | 81 | ENV LANG en_US.UTF-8 82 | ENV LANGUAGE en_US:en 83 | ENV LC_ALL en_US.UTF-8 84 | 85 | WORKDIR "/app" 86 | RUN chown nobody /app 87 | 88 | # Only copy the final release from the build stage 89 | COPY --from=builder --chown=nobody:root /app/_build/prod/rel ./ 90 | 91 | USER nobody 92 | 93 | # Create a symlink to the command that starts your application. This is required 94 | # since the release directory and start up script are named after the 95 | # application, and we don't know that name. 96 | RUN set -eux; \ 97 | ln -nfs /app/$(basename *)/bin/$(basename *) /app/entry 98 | 99 | CMD /app/entry start -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | if config_env() == :prod do 10 | # The secret key base is used to sign/encrypt cookies and other secrets. 11 | # A default value is used in config/dev.exs and config/test.exs but you 12 | # want to use a different value for prod and you most likely don't want 13 | # to check this value into version control, so we use an environment 14 | # variable instead. 15 | secret_key_base = 16 | System.get_env("SECRET_KEY_BASE") || 17 | raise """ 18 | environment variable SECRET_KEY_BASE is missing. 19 | You can generate one by calling: mix phx.gen.secret 20 | """ 21 | 22 | # IMPORTANT: Get the app_name we're using 23 | app_name = 24 | System.get_env("FLY_APP_NAME") || 25 | raise "FLY_APP_NAME not available" 26 | 27 | config :leafblower, LeafblowerWeb.Endpoint, 28 | url: [host: "#{app_name}.fly.dev", port: 80], 29 | http: [ 30 | # Enable IPv6 and bind on all interfaces. 31 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 32 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 33 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 34 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 35 | port: String.to_integer(System.get_env("PORT") || "4000") 36 | ], 37 | secret_key_base: secret_key_base 38 | 39 | # config :libcluster, 40 | # topologies: [ 41 | # render: [ 42 | # strategy: Cluster.Strategy.Kubernetes.DNS, 43 | # config: [ 44 | # service: System.fetch_env!("RENDER_DISCOVERY_SERVICE"), 45 | # application_name: System.fetch_env!("RENDER_SERVICE_NAME") 46 | # ] 47 | # ] 48 | # ] 49 | 50 | config :libcluster, 51 | topologies: [ 52 | leafblower: [ 53 | strategy: Elixir.Cluster.Strategy.Gossip 54 | ] 55 | ] 56 | 57 | # ## Using releases 58 | # 59 | # If you are doing OTP releases, you need to instruct Phoenix 60 | # to start each relevant endpoint: 61 | # 62 | config :leafblower, LeafblowerWeb.Endpoint, server: true 63 | # 64 | # Then you can assemble a release by calling `mix release`. 65 | # See `mix help release` for more information. 66 | 67 | # ## Configuring the mailer 68 | # 69 | # In production you need to configure the mailer to use a different adapter. 70 | # Also, you may need to configure the Swoosh API client of your choice if you 71 | # are not using SMTP. Here is an example of the configuration: 72 | # 73 | # config :leafblower, Leafblower.Mailer, 74 | # adapter: Swoosh.Adapters.Mailgun, 75 | # api_key: System.get_env("MAILGUN_API_KEY"), 76 | # domain: System.get_env("MAILGUN_DOMAIN") 77 | # 78 | # For this example you need include a HTTP client required by Swoosh API client. 79 | # Swoosh supports Hackney and Finch out of the box: 80 | # 81 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 82 | # 83 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 84 | end 85 | -------------------------------------------------------------------------------- /lib/leafblower/deck.ex: -------------------------------------------------------------------------------- 1 | defmodule Leafblower.Deck.Helpers do 2 | @file_path Application.app_dir(:leafblower, "priv") |> Path.join("cards_against.json") 3 | @external_resource @file_path 4 | def load_cards() do 5 | for item <- 6 | @file_path 7 | |> File.read!() 8 | |> Jason.decode!(), 9 | into: %{} do 10 | {item["name"], 11 | %{white: key_with_text_hash(item["white"]), black: key_with_text_hash(item["black"])}} 12 | end 13 | end 14 | 15 | defp key_with_text_hash(items) do 16 | for %{"text" => text} = item <- items, into: %{} do 17 | id = :crypto.hash(:sha256, text) |> Base.encode16() |> String.downcase() 18 | {id, Map.put(item, "id", id)} 19 | end 20 | end 21 | 22 | def card_ids_by_card_pack(cards) do 23 | for {name, %{white: white, black: black}} <- cards, into: %{} do 24 | {name, %{white: Map.keys(white) |> MapSet.new(), black: Map.keys(black) |> MapSet.new()}} 25 | end 26 | end 27 | 28 | def flatten_cards(cards, :white) do 29 | Enum.flat_map(cards, fn {_key, %{white: white}} -> white end) 30 | |> Enum.into(%{}) 31 | end 32 | 33 | def flatten_cards(cards, :black) do 34 | Enum.flat_map(cards, fn {_key, %{black: black}} -> black end) 35 | |> Enum.into(%{}) 36 | end 37 | end 38 | 39 | defmodule Leafblower.Deck do 40 | @cards Leafblower.Deck.Helpers.load_cards() 41 | @all_black_cards Leafblower.Deck.Helpers.flatten_cards(@cards, :black) 42 | @all_white_cards Leafblower.Deck.Helpers.flatten_cards(@cards, :white) 43 | @card_ids_by_card_pack Leafblower.Deck.Helpers.card_ids_by_card_pack(@cards) 44 | @type card :: binary() 45 | @type card_set :: MapSet.t(binary()) 46 | @type t :: %{black: card_set(), white: card_set()} 47 | 48 | @spec new(card_set(), card_set()) :: t() 49 | def new(black, white) do 50 | %{black: black, white: white} 51 | end 52 | 53 | @doc """ 54 | Deals white card to players 55 | """ 56 | def deal_white_card(deck, player_cards, max_cards_per_player \\ 7) do 57 | player_by_needed_cards = 58 | for {player_id, cards} <- player_cards, 59 | needed_cards = max_cards_per_player - MapSet.size(cards) do 60 | {player_id, needed_cards} 61 | end 62 | 63 | {cards, white} = 64 | Enum.map_reduce(player_by_needed_cards, deck.white, fn {player_id, count}, white -> 65 | cards_in_hand = player_cards[player_id] 66 | 67 | if count == 0 do 68 | {{player_id, cards_in_hand}, white} 69 | else 70 | new_cards = Enum.take_random(white, count) |> MapSet.new() 71 | 72 | {{player_id, MapSet.union(new_cards, cards_in_hand)}, 73 | MapSet.difference(white, new_cards)} 74 | end 75 | end) 76 | 77 | { 78 | Enum.into(cards, %{}), 79 | %{deck | white: white} 80 | } 81 | end 82 | 83 | def take_black_card(deck) do 84 | [taken_card] = Enum.take_random(deck.black, 1) 85 | black = MapSet.delete(deck.black, taken_card) 86 | {taken_card, %{deck | black: black}} 87 | end 88 | 89 | def card(id, :black) do 90 | @all_black_cards[id] 91 | end 92 | 93 | def card(ids, :white) when is_list(ids) do 94 | Map.take(@all_white_cards, ids) 95 | end 96 | 97 | def card(id, :white) do 98 | @all_white_cards[id] 99 | end 100 | 101 | def card_packs do 102 | Map.keys(@card_ids_by_card_pack) 103 | end 104 | 105 | def get_cards(packs) when is_list(packs) do 106 | cards = 107 | Map.take(@card_ids_by_card_pack, packs) 108 | |> Map.values() 109 | 110 | black = Enum.flat_map(cards, & &1.black) |> MapSet.new() 111 | white = Enum.flat_map(cards, & &1.white) |> MapSet.new() 112 | %{black: black, white: white} 113 | end 114 | 115 | def get_cards(pack), do: get_cards([pack]) 116 | def get_cards, do: get_cards("CAH Base Set") 117 | end 118 | -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 1.0.0, 2021-01-06 4 | * http://buunguyen.github.io/topbar 5 | * Copyright (c) 2021 Buu Nguyen 6 | */ 7 | (function (window, document) { 8 | "use strict"; 9 | 10 | // https://gist.github.com/paulirish/1579671 11 | (function () { 12 | var lastTime = 0; 13 | var vendors = ["ms", "moz", "webkit", "o"]; 14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 15 | window.requestAnimationFrame = 16 | window[vendors[x] + "RequestAnimationFrame"]; 17 | window.cancelAnimationFrame = 18 | window[vendors[x] + "CancelAnimationFrame"] || 19 | window[vendors[x] + "CancelRequestAnimationFrame"]; 20 | } 21 | if (!window.requestAnimationFrame) 22 | window.requestAnimationFrame = function (callback, element) { 23 | var currTime = new Date().getTime(); 24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 25 | var id = window.setTimeout(function () { 26 | callback(currTime + timeToCall); 27 | }, timeToCall); 28 | lastTime = currTime + timeToCall; 29 | return id; 30 | }; 31 | if (!window.cancelAnimationFrame) 32 | window.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | })(); 36 | 37 | var canvas, 38 | progressTimerId, 39 | fadeTimerId, 40 | currentProgress, 41 | showing, 42 | addEvent = function (elem, type, handler) { 43 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 44 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 45 | else elem["on" + type] = handler; 46 | }, 47 | options = { 48 | autoRun: true, 49 | barThickness: 3, 50 | barColors: { 51 | 0: "rgba(26, 188, 156, .9)", 52 | ".25": "rgba(52, 152, 219, .9)", 53 | ".50": "rgba(241, 196, 15, .9)", 54 | ".75": "rgba(230, 126, 34, .9)", 55 | "1.0": "rgba(211, 84, 0, .9)", 56 | }, 57 | shadowBlur: 10, 58 | shadowColor: "rgba(0, 0, 0, .6)", 59 | className: null, 60 | }, 61 | repaint = function () { 62 | canvas.width = window.innerWidth; 63 | canvas.height = options.barThickness * 5; // need space for shadow 64 | 65 | var ctx = canvas.getContext("2d"); 66 | ctx.shadowBlur = options.shadowBlur; 67 | ctx.shadowColor = options.shadowColor; 68 | 69 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 70 | for (var stop in options.barColors) 71 | lineGradient.addColorStop(stop, options.barColors[stop]); 72 | ctx.lineWidth = options.barThickness; 73 | ctx.beginPath(); 74 | ctx.moveTo(0, options.barThickness / 2); 75 | ctx.lineTo( 76 | Math.ceil(currentProgress * canvas.width), 77 | options.barThickness / 2 78 | ); 79 | ctx.strokeStyle = lineGradient; 80 | ctx.stroke(); 81 | }, 82 | createCanvas = function () { 83 | canvas = document.createElement("canvas"); 84 | var style = canvas.style; 85 | style.position = "fixed"; 86 | style.top = style.left = style.right = style.margin = style.padding = 0; 87 | style.zIndex = 100001; 88 | style.display = "none"; 89 | if (options.className) canvas.classList.add(options.className); 90 | document.body.appendChild(canvas); 91 | addEvent(window, "resize", repaint); 92 | }, 93 | topbar = { 94 | config: function (opts) { 95 | for (var key in opts) 96 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 97 | }, 98 | show: function () { 99 | if (showing) return; 100 | showing = true; 101 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 102 | if (!canvas) createCanvas(); 103 | canvas.style.opacity = 1; 104 | canvas.style.display = "block"; 105 | topbar.progress(0); 106 | if (options.autoRun) { 107 | (function loop() { 108 | progressTimerId = window.requestAnimationFrame(loop); 109 | topbar.progress( 110 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 111 | ); 112 | })(); 113 | } 114 | }, 115 | progress: function (to) { 116 | if (typeof to === "undefined") return currentProgress; 117 | if (typeof to === "string") { 118 | to = 119 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 120 | ? currentProgress 121 | : 0) + parseFloat(to); 122 | } 123 | currentProgress = to > 1 ? 1 : to; 124 | repaint(); 125 | return currentProgress; 126 | }, 127 | hide: function () { 128 | if (!showing) return; 129 | showing = false; 130 | if (progressTimerId != null) { 131 | window.cancelAnimationFrame(progressTimerId); 132 | progressTimerId = null; 133 | } 134 | (function loop() { 135 | if (topbar.progress("+.1") >= 1) { 136 | canvas.style.opacity -= 0.05; 137 | if (canvas.style.opacity <= 0.05) { 138 | canvas.style.display = "none"; 139 | fadeTimerId = null; 140 | return; 141 | } 142 | } 143 | fadeTimerId = window.requestAnimationFrame(loop); 144 | })(); 145 | }, 146 | }; 147 | 148 | if (typeof module === "object" && typeof module.exports === "object") { 149 | module.exports = topbar; 150 | } else if (typeof define === "function" && define.amd) { 151 | define(function () { 152 | return topbar; 153 | }); 154 | } else { 155 | this.topbar = topbar; 156 | } 157 | }.call(this, window, document)); 158 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application CSS */ 2 | @import "./phoenix.css"; 3 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@500&display=swap'); 4 | 5 | 6 | html { 7 | --background-color: #f7fafc; 8 | background-color: var(--background-color); 9 | } 10 | 11 | /* Alerts and form errors used by phx.new */ 12 | .alert { 13 | padding: 15px; 14 | margin-bottom: 20px; 15 | border: 1px solid transparent; 16 | border-radius: 4px; 17 | } 18 | 19 | .alert-info { 20 | color: #31708f; 21 | background-color: #d9edf7; 22 | border-color: #bce8f1; 23 | } 24 | 25 | .alert-warning { 26 | color: #8a6d3b; 27 | background-color: #fcf8e3; 28 | border-color: #faebcc; 29 | } 30 | 31 | .alert-danger { 32 | color: #a94442; 33 | background-color: #f2dede; 34 | border-color: #ebccd1; 35 | } 36 | 37 | .alert p { 38 | margin-bottom: 0; 39 | } 40 | 41 | .alert:empty { 42 | display: none; 43 | } 44 | 45 | .invalid-feedback { 46 | color: #a94442; 47 | display: block; 48 | } 49 | 50 | /* LiveView specific classes for your customization */ 51 | .phx-no-feedback.invalid-feedback, 52 | .phx-no-feedback .invalid-feedback { 53 | display: none; 54 | } 55 | 56 | .phx-click-loading { 57 | opacity: 0.5; 58 | transition: opacity 1s ease-out; 59 | } 60 | 61 | .phx-disconnected { 62 | cursor: wait; 63 | } 64 | 65 | .phx-disconnected * { 66 | pointer-events: none; 67 | } 68 | 69 | .phx-modal { 70 | opacity: 1 !important; 71 | position: fixed; 72 | z-index: 1; 73 | left: 0; 74 | top: 0; 75 | width: 100%; 76 | height: 100%; 77 | overflow: auto; 78 | background-color: rgb(0, 0, 0); 79 | background-color: rgba(0, 0, 0, 0.4); 80 | } 81 | 82 | .phx-modal-content { 83 | background-color: #fefefe; 84 | margin: 15vh auto; 85 | padding: 20px; 86 | border: 1px solid #888; 87 | width: 80%; 88 | } 89 | 90 | .phx-modal-close { 91 | color: #aaa; 92 | float: right; 93 | font-size: 28px; 94 | font-weight: bold; 95 | } 96 | 97 | .phx-modal-close:hover, 98 | .phx-modal-close:focus { 99 | color: black; 100 | text-decoration: none; 101 | cursor: pointer; 102 | } 103 | 104 | 105 | /* Card */ 106 | .card { 107 | width: 2.5in; 108 | min-height: 2in; 109 | max-height: 2.5in; 110 | border-radius: .15in; 111 | margin: 5px; 112 | } 113 | 114 | .dark { 115 | border: 3px solid black; 116 | color: white; 117 | background: black; 118 | } 119 | 120 | .pointer { 121 | cursor: pointer; 122 | } 123 | 124 | .light { 125 | border: 3px solid black; 126 | color: black; 127 | background: white; 128 | } 129 | 130 | .text { 131 | font-family: 'Inter', sans-serif; 132 | font-weight: 900; 133 | text-align: start; 134 | font-size: 1.5rem; 135 | word-wrap: break-word; 136 | display: block; 137 | padding: 2rem; 138 | } 139 | 140 | .card-container { 141 | display: flex; 142 | flex-wrap: wrap; 143 | list-style: none; 144 | justify-content: center; 145 | } 146 | 147 | .multi-card-wrapper:not(:first-child) { 148 | border-left: black solid 3px; 149 | } 150 | 151 | @media (max-width: 576px) { 152 | 153 | /* Small devices (landscape phones, 576px and up) */ 154 | .card-container { 155 | scroll-snap-type: x mandatory; 156 | overflow-x: auto; 157 | flex-wrap: nowrap; 158 | justify-content: unset; 159 | } 160 | 161 | .card-container>* { 162 | flex-shrink: 0; 163 | scroll-snap-align: center; 164 | } 165 | } 166 | 167 | header { 168 | padding-top: 0.5em; 169 | padding-bottom: 0.5em; 170 | } 171 | 172 | footer { 173 | margin-top: 2em; 174 | font-weight: 600; 175 | color: #43484b; 176 | font-size: 0.7em; 177 | text-align: center; 178 | } 179 | 180 | .welcome-root>div { 181 | justify-content: center; 182 | } 183 | 184 | .welcome-root { 185 | padding-bottom: 1em; 186 | } 187 | 188 | .game-container { 189 | display: grid; 190 | gap: 0.5rem; 191 | grid-template-columns: [left] 0.25fr 2fr 1fr [right]; 192 | grid-template-areas: "panleft mainbody panright"; 193 | } 194 | 195 | .game-container>.panel.left { 196 | grid-area: panleft; 197 | } 198 | 199 | .game-container>.panel.right { 200 | grid-area: panright; 201 | } 202 | 203 | .game-container>.mainbody { 204 | grid-area: mainbody; 205 | } 206 | 207 | .show-chat { 208 | display: none; 209 | } 210 | 211 | #sidenav-open { 212 | --easeOutExpo: cubic-bezier(0.16, 1, 0.3, 1); 213 | --duration: .6s; 214 | background-color: var(--background-color); 215 | } 216 | 217 | #sidenav-close { 218 | visibility: hidden; 219 | } 220 | 221 | #chatlist { 222 | padding-top: 1rem; 223 | padding-left: 1rem; 224 | border: 3px solid black; 225 | border-radius: 1%; 226 | width: 100%; 227 | height: 50vh; 228 | overflow: scroll; 229 | list-style: none; 230 | } 231 | 232 | #chatlist>li { 233 | margin-bottom: 0.2rem; 234 | } 235 | 236 | 237 | @media (max-width: 50.0rem) { 238 | .show-chat { 239 | display: block; 240 | } 241 | 242 | #sidenav-close { 243 | visibility: visible; 244 | text-align: right; 245 | padding-top: 1rem; 246 | } 247 | 248 | .game-container>.panel.left { 249 | display: none; 250 | } 251 | 252 | .game-container>.panel.right { 253 | grid-area: mainbody; 254 | grid-column-start: left; 255 | grid-column-end: right; 256 | z-index: 1; 257 | } 258 | 259 | .game-container>.mainbody { 260 | grid-column-start: left; 261 | grid-column-end: right; 262 | } 263 | 264 | #sidenav-open { 265 | visibility: hidden; 266 | transform: translateX(-110vw); 267 | will-change: transform; 268 | transition: transform var(--duration) var(--easeOutExpo), visibility 0s linear var(--duration) 269 | } 270 | 271 | #sidenav-open:target { 272 | visibility: visible; 273 | transform: translateX(0); 274 | transition: transform var(--duration) var(--easeOutExpo) 275 | } 276 | } -------------------------------------------------------------------------------- /priv/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /lib/leafblower_web/controllers/game_splash_live.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.GameSplashLive do 2 | use LeafblowerWeb, :live_view 3 | 4 | @impl true 5 | def mount(_param, %{"current_user_id" => user_id}, socket) do 6 | {:ok, 7 | assign(socket, 8 | user_id: user_id, 9 | page: :index 10 | )} 11 | end 12 | 13 | @impl true 14 | def handle_params(params, _url, socket) do 15 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 16 | end 17 | 18 | defp apply_action(socket, :start_game = action, _params) do 19 | socket 20 | |> assign(:page, action) 21 | |> assign(:changeset, cast_user()) 22 | end 23 | 24 | defp apply_action(socket, :join_by_code = action, _params) do 25 | socket 26 | |> assign(:page, action) 27 | |> assign(:changeset, cast_game_code()) 28 | end 29 | 30 | defp apply_action(socket, _action, _params) do 31 | socket 32 | end 33 | 34 | @impl true 35 | def handle_event("validate_user", %{"user" => params}, socket) do 36 | changeset = 37 | cast_user(params) 38 | |> Map.put(:action, :insert) 39 | 40 | {:noreply, assign(socket, changeset: changeset)} 41 | end 42 | 43 | def handle_event("validate_code", %{"code" => params}, socket) do 44 | changeset = 45 | cast_game_code(params) 46 | |> Map.put(:action, :insert) 47 | 48 | {:noreply, assign(socket, changeset: changeset)} 49 | end 50 | 51 | def handle_event("new_game", %{"user" => params}, socket) do 52 | {:ok, code} = Leafblower.GameStatem.generate_game_code() 53 | 54 | data = 55 | cast_user(params) 56 | |> Ecto.Changeset.apply_changes() 57 | 58 | {:ok, game} = 59 | Leafblower.GameSupervisor.new_game(id: code, countdown_duration: 120, min_player_count: 2) 60 | 61 | Leafblower.GameStatem.join_player(game, socket.assigns.user_id, data.name) 62 | 63 | {:noreply, 64 | socket 65 | |> push_redirect(to: Routes.live_path(socket, LeafblowerWeb.GameLive, code), replace: true)} 66 | end 67 | 68 | def handle_event("join_by_code", %{"code" => params}, socket) do 69 | {:noreply, 70 | socket 71 | |> push_redirect( 72 | to: Routes.live_path(socket, LeafblowerWeb.GameLive, String.upcase(params["code"])), 73 | replace: true 74 | )} 75 | end 76 | 77 | @impl true 78 | def render(%{page: :start_game} = assigns) do 79 | ~H""" 80 | <.form let={f} for={@changeset} phx-change="validate_user" phx-submit="new_game" as="user"> 81 | <%= label f, :name %> 82 | <%= text_input f, :name, placeholder: "Enter your name!" %> 83 | <%= error_tag f, :name %> 84 | 85 | <%= submit "Start a new game", [disabled: length(@changeset.errors) > 0] %> 86 | 87 | """ 88 | end 89 | 90 | @impl true 91 | def render(%{page: :join_by_code} = assigns) do 92 | ~H""" 93 | <.form let={f} for={@changeset} phx-change="validate_code" phx-submit="join_by_code" as="code"> 94 | <%= label f, :code, "Enter game code" %> 95 | <%= text_input f, :code, style: "text-transform:uppercase" %> 96 | <%= error_tag f, :code %> 97 | 98 | <%= submit "Find game", [disabled: length(@changeset.errors) > 0] %> 99 | 100 | """ 101 | end 102 | 103 | @impl true 104 | def render(assigns) do 105 | ~H""" 106 |
107 |
108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 |
117 |
118 |

Play Cards Against Humanity online

119 |
120 |
121 |
122 | <%= live_patch to: Routes.game_splash_path(@socket, :start_game) do%> 123 | 124 | <% end %> 125 |
126 |
127 | 128 |
129 | Or 130 |
131 | 132 |
133 |
134 | <%= live_patch to: Routes.game_splash_path(@socket, :join_by_code) do%> 135 | 136 | <% end %> 137 |
138 |
139 |
140 | """ 141 | end 142 | 143 | defp cast_user(params \\ %{}) do 144 | {%{}, %{name: :string}} 145 | |> Ecto.Changeset.cast(params, [:name]) 146 | |> Ecto.Changeset.validate_required([:name]) 147 | |> Ecto.Changeset.validate_length(:name, max: 15) 148 | end 149 | 150 | defp cast_game_code(params \\ %{}) do 151 | {%{}, %{code: :string}} 152 | |> Ecto.Changeset.cast(params, [:code]) 153 | |> Ecto.Changeset.validate_required([:code]) 154 | |> Ecto.Changeset.validate_length(:code, max: 5) 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "0.1.13", "ccf3ab251ffaebc4319f41d788ce59a6ab3f42b6c27e598ad838ffecee0b04f9", [:mix], [], "hexpm", "a14a7eecfec7e20385493dbb92b0d12c5d77ecfd6307de10102d58c94e8c49c0"}, 3 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 4 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 5 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 6 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 7 | "db_connection": {:hex, :db_connection, "2.4.1", "6411f6e23f1a8b68a82fa3a36366d4881f21f47fc79a9efb8c615e62050219da", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ea36d226ec5999781a9a8ad64e5d8c4454ecedc7a4d643e4832bf08efca01f00"}, 8 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 9 | "delta_crdt": {:hex, :delta_crdt, "0.6.4", "79d235eef82a58bb0cb668bc5b9558d2e65325ccb46b74045f20b36fd41671da", [:mix], [{:merkle_map, "~> 0.2.0", [hex: :merkle_map, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4a81f579c06aeeb625db54c6c109859a38aa00d837e3e7f8ac27b40cea34885a"}, 10 | "ecto": {:hex, :ecto, "3.7.1", "a20598862351b29f80f285b21ec5297da1181c0442687f9b8329f0445d228892", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d36e5b39fc479e654cffd4dbe1865d9716e4a9b6311faff799b6f90ab81b8638"}, 11 | "ecto_sql": {:hex, :ecto_sql, "3.7.1", "8de624ef50b2a8540252d8c60506379fbbc2707be1606853df371cf53df5d053", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b42a32e2ce92f64aba5c88617891ab3b0ba34f3f3a503fa20009eae1a401c81"}, 12 | "esbuild": {:hex, :esbuild, "0.4.0", "9f17db148aead4cf1e6e6a584214357287a93407b5fb51a031f122b61385d4c2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "b61e4e6b92ffe45e4ee4755a22de6211a67c67987dc02afb35a425a0add1d447"}, 13 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 14 | "floki": {:hex, :floki, "0.32.0", "f915dc15258bc997d49be1f5ef7d3992f8834d6f5695270acad17b41f5bcc8e2", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "1c5a91cae1fd8931c26a4826b5e2372c284813904c8bacb468b5de39c7ececbd"}, 15 | "gen_state_machine": {:hex, :gen_state_machine, "3.0.0", "1e57f86a494e5c6b14137ebef26a7eb342b3b0070c7135f2d6768ed3f6b6cdff", [:mix], [], "hexpm", "0a59652574bebceb7309f6b749d2a41b45fdeda8dbb4da0791e355dd19f0ed15"}, 16 | "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, 17 | "horde": {:hex, :horde, "0.8.5", "ee70fcecf81d7a64a6e7f8df644e82b60ac64fcdf1a3fe66fa5808bee4bdb536", [:mix], [{:delta_crdt, "~> 0.6.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:libring, "~> 1.4", [hex: :libring, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0.0 or ~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0.0 or ~> 0.5.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "acf4d356c6980960aff39c3329f7401b3244129e8709ebda7fc226bd80a1bf7a"}, 18 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 19 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 20 | "libcluster": {:hex, :libcluster, "3.3.0", "f7d45ff56d88e9fb4c30aee662480cbab69ebc0e7f7da4ad8d01b1e4f7492da8", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "ecdcdc88334ec8eb18b10a13a1d5f22a3319a970b5b1e66cfe71c7719a4ab6cc"}, 21 | "libring": {:hex, :libring, "1.5.0", "44313eb6862f5c9168594a061e9d5f556a9819da7c6444706a9e2da533396d70", [:mix], [], "hexpm", "04e843d4fdcff49a62d8e03778d17c6cb2a03fe2d14020d3825a1761b55bd6cc"}, 22 | "merkle_map": {:hex, :merkle_map, "0.2.1", "01a88c87a6b9fb594c67c17ebaf047ee55ffa34e74297aa583ed87148006c4c8", [:mix], [], "hexpm", "fed4d143a5c8166eee4fa2b49564f3c4eace9cb252f0a82c1613bba905b2d04d"}, 23 | "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, 24 | "phoenix": {:hex, :phoenix, "1.6.2", "6cbd5c8ed7a797f25a919a37fafbc2fb1634c9cdb12a4448d7a5d0b26926f005", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7bbee475acae0c3abc229b7f189e210ea788e63bd168e585f60c299a4b2f9133"}, 25 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, 26 | "phoenix_html": {:hex, :phoenix_html, "3.1.0", "0b499df05aad27160d697a9362f0e89fa0e24d3c7a9065c2bd9d38b4d1416c09", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0c0a98a2cefa63433657983a2a594c7dee5927e4391e0f1bfd3a151d1def33fc"}, 27 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.1", "fb94a33c077141f9ac7930b322a7a3b99f9b144bf3a08dd667b9f9aaf0319889", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "6faf1373e5846c8ab68c2cf55cfa5c196c1fbbe0c72d12a4cdfaaac6ef189948"}, 28 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, 29 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.5", "63f52a6f9f6983f04e424586ff897c016ecc5e4f8d1e2c22c2887af1c57215d8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.9 or ~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c5586e6a3d4df71b8214c769d4f5eb8ece2b4001711a7ca0f97323c36958b0e3"}, 30 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 31 | "phoenix_view": {:hex, :phoenix_view, "1.0.0", "fea71ecaaed71178b26dd65c401607de5ec22e2e9ef141389c721b3f3d4d8011", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "82be3e2516f5633220246e2e58181282c71640dab7afc04f70ad94253025db0c"}, 32 | "plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"}, 33 | "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, 34 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 35 | "postgrex": {:hex, :postgrex, "0.15.13", "7794e697481799aee8982688c261901de493eb64451feee6ea58207d7266d54a", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3ffb76e1a97cfefe5c6a95632a27ffb67f28871c9741fb585f9d1c3cd2af70f1"}, 36 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 37 | "swoosh": {:hex, :swoosh, "1.5.2", "c246e0038367bf9ac3b66715151930a7215eb7427c242cc5206fc59fa344a7dc", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc34b2c14afaa6e2cd92c829492536887a00ae625e404e40469926949b029605"}, 38 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 39 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, 40 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 41 | } 42 | -------------------------------------------------------------------------------- /assets/css/phoenix.css: -------------------------------------------------------------------------------- 1 | /* Includes some default style for the starter application. 2 | * This can be safely deleted to start fresh. 3 | */ 4 | 5 | /* Milligram v1.4.1 https://milligram.github.io 6 | * Copyright (c) 2020 CJ Patoilo Licensed under the MIT license 7 | */ 8 | 9 | *, *:after, *:before { 10 | box-sizing: inherit 11 | } 12 | 13 | html { 14 | box-sizing: border-box; 15 | font-size: 62.5% 16 | } 17 | 18 | body { 19 | color: #000000; 20 | font-family: 'Inter', sans-serif; 21 | font-size: 1.6em; 22 | font-weight: 300; 23 | letter-spacing: .01em; 24 | line-height: 1.6 25 | } 26 | 27 | blockquote { 28 | border-left: 0.3rem solid #d1d1d1; 29 | margin-left: 0; 30 | margin-right: 0; 31 | padding: 1rem 1.5rem 32 | } 33 | 34 | blockquote *:last-child { 35 | margin-bottom: 0 36 | } 37 | 38 | .button, button, input[type='button'], input[type='reset'], input[type='submit'] { 39 | background-color: #000000; 40 | border: 0.1rem solid #000000; 41 | border-radius: .4rem; 42 | color: #fff; 43 | cursor: pointer; 44 | display: inline-block; 45 | font-size: 1.1rem; 46 | font-weight: 700; 47 | /* height: 3.8rem; */ 48 | letter-spacing: .1rem; 49 | line-height: 3.8rem; 50 | padding: 0 1.0rem; 51 | text-align: center; 52 | text-decoration: none; 53 | text-transform: uppercase; 54 | white-space: nowrap 55 | } 56 | 57 | .button:focus, .button:hover, button:focus, button:hover, input[type='button']:focus, input[type='button']:hover, input[type='reset']:focus, input[type='reset']:hover, input[type='submit']:focus, input[type='submit']:hover { 58 | background-color: #606c76; 59 | border-color: #606c76; 60 | color: #fff; 61 | outline: 0 62 | } 63 | 64 | .button[disabled], button[disabled], input[type='button'][disabled], input[type='reset'][disabled], input[type='submit'][disabled] { 65 | cursor: default; 66 | opacity: .5 67 | } 68 | 69 | .button[disabled]:focus, .button[disabled]:hover, button[disabled]:focus, button[disabled]:hover, input[type='button'][disabled]:focus, input[type='button'][disabled]:hover, input[type='reset'][disabled]:focus, input[type='reset'][disabled]:hover, input[type='submit'][disabled]:focus, input[type='submit'][disabled]:hover { 70 | background-color: #000000; 71 | border-color: #000000 72 | } 73 | 74 | .button.button-outline, button.button-outline, input[type='button'].button-outline, input[type='reset'].button-outline, input[type='submit'].button-outline { 75 | background-color: transparent; 76 | color: #000000 77 | } 78 | 79 | .button.button-outline:focus, .button.button-outline:hover, button.button-outline:focus, button.button-outline:hover, input[type='button'].button-outline:focus, input[type='button'].button-outline:hover, input[type='reset'].button-outline:focus, input[type='reset'].button-outline:hover, input[type='submit'].button-outline:focus, input[type='submit'].button-outline:hover { 80 | background-color: transparent; 81 | border-color: #606c76; 82 | color: #606c76 83 | } 84 | 85 | .button.button-outline[disabled]:focus, .button.button-outline[disabled]:hover, button.button-outline[disabled]:focus, button.button-outline[disabled]:hover, input[type='button'].button-outline[disabled]:focus, input[type='button'].button-outline[disabled]:hover, input[type='reset'].button-outline[disabled]:focus, input[type='reset'].button-outline[disabled]:hover, input[type='submit'].button-outline[disabled]:focus, input[type='submit'].button-outline[disabled]:hover { 86 | border-color: inherit; 87 | color: #000000 88 | } 89 | 90 | .button.button-clear, button.button-clear, input[type='button'].button-clear, input[type='reset'].button-clear, input[type='submit'].button-clear { 91 | background-color: transparent; 92 | border-color: transparent; 93 | color: #000000 94 | } 95 | 96 | .button.button-clear:focus, .button.button-clear:hover, button.button-clear:focus, button.button-clear:hover, input[type='button'].button-clear:focus, input[type='button'].button-clear:hover, input[type='reset'].button-clear:focus, input[type='reset'].button-clear:hover, input[type='submit'].button-clear:focus, input[type='submit'].button-clear:hover { 97 | background-color: transparent; 98 | border-color: transparent; 99 | color: #606c76 100 | } 101 | 102 | .button.button-clear[disabled]:focus, .button.button-clear[disabled]:hover, button.button-clear[disabled]:focus, button.button-clear[disabled]:hover, input[type='button'].button-clear[disabled]:focus, input[type='button'].button-clear[disabled]:hover, input[type='reset'].button-clear[disabled]:focus, input[type='reset'].button-clear[disabled]:hover, input[type='submit'].button-clear[disabled]:focus, input[type='submit'].button-clear[disabled]:hover { 103 | color: #000000 104 | } 105 | 106 | code { 107 | background: #f4f5f6; 108 | border-radius: .4rem; 109 | font-size: 86%; 110 | margin: 0 .2rem; 111 | padding: .2rem .5rem; 112 | white-space: nowrap 113 | } 114 | 115 | pre { 116 | background: #f4f5f6; 117 | border-left: 0.3rem solid #000000; 118 | overflow-y: hidden 119 | } 120 | 121 | pre>code { 122 | border-radius: 0; 123 | display: block; 124 | padding: 1rem 1.5rem; 125 | white-space: pre 126 | } 127 | 128 | hr { 129 | border: 0; 130 | border-top: 0.1rem solid #f4f5f6; 131 | margin: 3.0rem 0 132 | } 133 | 134 | input[type='color'], input[type='date'], input[type='datetime'], input[type='datetime-local'], input[type='email'], input[type='month'], input[type='number'], input[type='password'], input[type='search'], input[type='tel'], input[type='text'], input[type='url'], input[type='week'], input:not([type]), textarea, select { 135 | -webkit-appearance: none; 136 | background-color: transparent; 137 | border: 0.1rem solid #d1d1d1; 138 | border-radius: .4rem; 139 | box-shadow: none; 140 | box-sizing: inherit; 141 | height: 3.8rem; 142 | padding: .6rem 1.0rem .7rem; 143 | width: 100% 144 | } 145 | 146 | input[type='color']:focus, input[type='date']:focus, input[type='datetime']:focus, input[type='datetime-local']:focus, input[type='email']:focus, input[type='month']:focus, input[type='number']:focus, input[type='password']:focus, input[type='search']:focus, input[type='tel']:focus, input[type='text']:focus, input[type='url']:focus, input[type='week']:focus, input:not([type]):focus, textarea:focus, select:focus { 147 | border-color: #000000; 148 | outline: 0 149 | } 150 | 151 | select { 152 | background: url('data:image/svg+xml;utf8,') center right no-repeat; 153 | padding-right: 3.0rem 154 | } 155 | 156 | select:focus { 157 | background-image: url('data:image/svg+xml;utf8,') 158 | } 159 | 160 | select[multiple] { 161 | background: none; 162 | height: auto 163 | } 164 | 165 | textarea { 166 | min-height: 6.5rem 167 | } 168 | 169 | label, legend { 170 | display: block; 171 | font-size: 1.6rem; 172 | font-weight: 700; 173 | margin-bottom: .5rem 174 | } 175 | 176 | fieldset { 177 | border-width: 0; 178 | padding: 0 179 | } 180 | 181 | input[type='checkbox'], input[type='radio'] { 182 | display: inline 183 | } 184 | 185 | .label-inline { 186 | display: inline-block; 187 | font-weight: normal; 188 | margin-left: .5rem 189 | } 190 | 191 | .container { 192 | margin: 0 auto; 193 | max-width: 112.0rem; 194 | padding: 0 2.0rem; 195 | position: relative; 196 | width: 100% 197 | } 198 | 199 | .row { 200 | display: flex; 201 | flex-direction: column; 202 | padding: 0; 203 | width: 100% 204 | } 205 | 206 | .row.row-no-padding { 207 | padding: 0 208 | } 209 | 210 | .row.row-no-padding>.column { 211 | padding: 0 212 | } 213 | 214 | .row.row-wrap { 215 | flex-wrap: wrap 216 | } 217 | 218 | .row.row-top { 219 | align-items: flex-start 220 | } 221 | 222 | .row.row-bottom { 223 | align-items: flex-end 224 | } 225 | 226 | .row.row-center { 227 | align-items: center 228 | } 229 | 230 | .row.row-stretch { 231 | align-items: stretch 232 | } 233 | 234 | .row.row-baseline { 235 | align-items: baseline 236 | } 237 | 238 | .row .column { 239 | display: block; 240 | flex: 1 1 auto; 241 | margin-left: 0; 242 | max-width: 100%; 243 | width: 100% 244 | } 245 | 246 | .row .column.column-offset-10 { 247 | margin-left: 10% 248 | } 249 | 250 | .row .column.column-offset-20 { 251 | margin-left: 20% 252 | } 253 | 254 | .row .column.column-offset-25 { 255 | margin-left: 25% 256 | } 257 | 258 | .row .column.column-offset-33, .row .column.column-offset-34 { 259 | margin-left: 33.3333% 260 | } 261 | 262 | .row .column.column-offset-40 { 263 | margin-left: 40% 264 | } 265 | 266 | .row .column.column-offset-50 { 267 | margin-left: 50% 268 | } 269 | 270 | .row .column.column-offset-60 { 271 | margin-left: 60% 272 | } 273 | 274 | .row .column.column-offset-66, .row .column.column-offset-67 { 275 | margin-left: 66.6666% 276 | } 277 | 278 | .row .column.column-offset-75 { 279 | margin-left: 75% 280 | } 281 | 282 | .row .column.column-offset-80 { 283 | margin-left: 80% 284 | } 285 | 286 | .row .column.column-offset-90 { 287 | margin-left: 90% 288 | } 289 | 290 | .row .column.column-10 { 291 | flex: 0 0 10%; 292 | max-width: 10% 293 | } 294 | 295 | .row .column.column-20 { 296 | flex: 0 0 20%; 297 | max-width: 20% 298 | } 299 | 300 | .row .column.column-25 { 301 | flex: 0 0 25%; 302 | max-width: 25% 303 | } 304 | 305 | .row .column.column-33, .row .column.column-34 { 306 | flex: 0 0 33.3333%; 307 | max-width: 33.3333% 308 | } 309 | 310 | .row .column.column-40 { 311 | flex: 0 0 40%; 312 | max-width: 40% 313 | } 314 | 315 | .row .column.column-50 { 316 | flex: 0 0 50%; 317 | max-width: 50% 318 | } 319 | 320 | .row .column.column-60 { 321 | flex: 0 0 60%; 322 | max-width: 60% 323 | } 324 | 325 | .row .column.column-66, .row .column.column-67 { 326 | flex: 0 0 66.6666%; 327 | max-width: 66.6666% 328 | } 329 | 330 | .row .column.column-75 { 331 | flex: 0 0 75%; 332 | max-width: 75% 333 | } 334 | 335 | .row .column.column-80 { 336 | flex: 0 0 80%; 337 | max-width: 80% 338 | } 339 | 340 | .row .column.column-90 { 341 | flex: 0 0 90%; 342 | max-width: 90% 343 | } 344 | 345 | .row .column .column-top { 346 | align-self: flex-start 347 | } 348 | 349 | .row .column .column-bottom { 350 | align-self: flex-end 351 | } 352 | 353 | .row .column .column-center { 354 | align-self: center 355 | } 356 | 357 | @media (min-width: 40rem) { 358 | .row { 359 | flex-direction: row; 360 | } 361 | 362 | .row .column { 363 | margin-bottom: inherit; 364 | padding: 0 1.0rem 365 | } 366 | } 367 | 368 | a { 369 | color: #0069d9; 370 | text-decoration: none 371 | } 372 | 373 | a:focus, a:hover { 374 | color: #606c76 375 | } 376 | 377 | dl, ol, ul { 378 | list-style: none; 379 | margin-top: 0; 380 | padding-left: 0 381 | } 382 | 383 | dl dl, dl ol, dl ul, ol dl, ol ol, ol ul, ul dl, ul ol, ul ul { 384 | font-size: 90%; 385 | margin: 1.5rem 0 1.5rem 3.0rem 386 | } 387 | 388 | ol { 389 | list-style: decimal inside 390 | } 391 | 392 | ul { 393 | list-style: inside 394 | } 395 | 396 | .button, button, dd, dt, li { 397 | margin-bottom: 1.0rem 398 | } 399 | 400 | fieldset, input, select, textarea { 401 | margin-bottom: 1.5rem 402 | } 403 | 404 | blockquote, dl, figure, form, ol, p, pre, table, ul { 405 | margin-bottom: 1em 406 | } 407 | 408 | table { 409 | border-spacing: 0; 410 | display: block; 411 | overflow-x: auto; 412 | text-align: left; 413 | width: 100% 414 | } 415 | 416 | td, th { 417 | border-bottom: 0.1rem solid #e1e1e1; 418 | padding: 1.2rem 1.5rem 419 | } 420 | 421 | td:first-child, th:first-child { 422 | padding-left: 0 423 | } 424 | 425 | td:last-child, th:last-child { 426 | padding-right: 0 427 | } 428 | 429 | @media (min-width: 40rem) { 430 | table { 431 | display: table; 432 | overflow-x: initial 433 | } 434 | } 435 | 436 | b, strong { 437 | font-weight: bold 438 | } 439 | 440 | p { 441 | margin-top: 0 442 | } 443 | 444 | h1, h2, h3, h4, h5, h6 { 445 | font-weight: 300; 446 | letter-spacing: -.1rem; 447 | margin-bottom: 2.0rem; 448 | margin-top: 0 449 | } 450 | 451 | h1 { 452 | font-size: 4.6rem; 453 | line-height: 1.2 454 | } 455 | 456 | h2 { 457 | font-size: 3.6rem; 458 | line-height: 1.25 459 | } 460 | 461 | h3 { 462 | font-size: 2.8rem; 463 | line-height: 1.3 464 | } 465 | 466 | h4 { 467 | font-size: 2.2rem; 468 | letter-spacing: -.08rem; 469 | line-height: 1.35 470 | } 471 | 472 | h5 { 473 | font-size: 1.8rem; 474 | letter-spacing: -.05rem; 475 | line-height: 1.5 476 | } 477 | 478 | h6 { 479 | font-size: 1.6rem; 480 | letter-spacing: 0; 481 | line-height: 1.4 482 | } 483 | 484 | img { 485 | max-width: 100% 486 | } 487 | 488 | .clearfix:after { 489 | clear: both; 490 | content: ' '; 491 | display: table 492 | } 493 | 494 | .float-left { 495 | float: left 496 | } 497 | 498 | .float-right { 499 | float: right 500 | } 501 | 502 | /* General style */ 503 | h1 { 504 | font-size: 3.6rem; 505 | line-height: 1.25 506 | } 507 | 508 | h2 { 509 | font-size: 2.8rem; 510 | line-height: 1.3 511 | } 512 | 513 | h3 { 514 | font-size: 2.2rem; 515 | letter-spacing: -.08rem; 516 | line-height: 1.35 517 | } 518 | 519 | h4 { 520 | font-size: 1.8rem; 521 | letter-spacing: -.05rem; 522 | line-height: 1.5 523 | } 524 | 525 | h5 { 526 | font-size: 1.6rem; 527 | letter-spacing: 0; 528 | line-height: 1.4 529 | } 530 | 531 | h6 { 532 | font-size: 1.4rem; 533 | letter-spacing: 0; 534 | line-height: 1.2 535 | } 536 | 537 | pre { 538 | padding: 0.5em; 539 | } 540 | 541 | .container { 542 | margin: 0 auto; 543 | max-width: 80.0rem; 544 | padding: 0 2.0rem; 545 | position: relative; 546 | width: 100% 547 | } 548 | 549 | select { 550 | width: auto; 551 | } 552 | 553 | /* Headers */ 554 | header { 555 | width: 100%; 556 | background: #fdfdfd; 557 | border-bottom: 1px solid #eaeaea; 558 | } 559 | 560 | header section { 561 | align-items: center; 562 | display: flex; 563 | flex-direction: column; 564 | justify-content: space-between; 565 | } 566 | 567 | header section :first-child { 568 | order: 2; 569 | } 570 | 571 | header section :last-child { 572 | order: 1; 573 | } 574 | 575 | header nav ul, 576 | header nav li { 577 | margin: 0; 578 | padding: 0; 579 | display: block; 580 | text-align: right; 581 | white-space: nowrap; 582 | } 583 | 584 | header nav ul { 585 | margin: 1rem; 586 | margin-top: 0; 587 | } 588 | 589 | header nav a { 590 | display: block; 591 | } 592 | 593 | .phx-connected { 594 | height: 100%; 595 | } -------------------------------------------------------------------------------- /lib/leafblower/game_statem.ex: -------------------------------------------------------------------------------- 1 | defmodule Leafblower.GameStatem do 2 | use GenStateMachine, callback_mode: [:handle_event_function, :state_enter] 3 | alias Leafblower.{GameTicker, ProcessRegistry, Deck} 4 | 5 | @inactivity_timeout_action {:state_timeout, 6 | Application.get_env(:leafblower, :game_inactivity_timeout), 7 | :terminate} 8 | @type player_id :: binary() 9 | @type player_info :: %{player_id() => %{name: binary()}} 10 | @type status :: :waiting_for_players | :round_started_waiting_for_response | :round_ended 11 | @type data :: %{ 12 | id: binary(), 13 | active_players: MapSet.t(player_id()), 14 | player_info: player_info(), 15 | round_number: non_neg_integer(), 16 | round_player_answers: %{player_id() => list(binary())}, 17 | required_white_cards_count: non_neg_integer | nil, 18 | leader_player_id: player_id() | nil, 19 | winner_player_id: player_id() | nil, 20 | min_player_count: non_neg_integer(), 21 | countdown_duration: non_neg_integer(), 22 | player_score: %{player_id() => non_neg_integer()}, 23 | deck: Deck.t(), 24 | player_cards: %{player_id() => Deck.card_set()}, 25 | black_card: Deck.card(), 26 | discard_pile: Deck.card_set() 27 | } 28 | 29 | def child_spec(init_arg) do 30 | id = Keyword.fetch!(init_arg, :id) 31 | 32 | %{ 33 | id: "#{__MODULE__}-#{id}", 34 | start: {__MODULE__, :start_link, [init_arg]}, 35 | restart: :transient, 36 | shutdown: 10_000 37 | } 38 | end 39 | 40 | def start_link(arg) do 41 | id = Keyword.fetch!(arg, :id) 42 | round_number = Keyword.get(arg, :round_number, 0) 43 | round_player_answers = Keyword.get(arg, :round_player_answers, %{}) 44 | active_players = Keyword.get(arg, :active_players, MapSet.new()) 45 | min_player_count = Keyword.get(arg, :min_player_count, 3) 46 | leader_player_id = Keyword.get(arg, :leader_player_id) 47 | countdown_duration = Keyword.get(arg, :countdown_duration, 0) 48 | player_info = Keyword.get(arg, :player_info, %{}) 49 | deck = Keyword.get_lazy(arg, :deck, &Leafblower.Deck.get_cards/0) 50 | player_cards = Keyword.get(arg, :player_cards, %{}) 51 | required_white_cards_count = Keyword.get(arg, :required_white_cards_count) 52 | 53 | GenStateMachine.start_link( 54 | __MODULE__, 55 | %{ 56 | id: id, 57 | active_players: active_players, 58 | round_number: round_number, 59 | round_player_answers: round_player_answers, 60 | leader_player_id: leader_player_id, 61 | min_player_count: min_player_count, 62 | countdown_duration: countdown_duration, 63 | player_info: player_info, 64 | player_score: %{}, 65 | player_cards: player_cards, 66 | deck: deck, 67 | winner_player_id: nil, 68 | black_card: nil, 69 | required_white_cards_count: required_white_cards_count 70 | }, 71 | name: via_tuple(id) 72 | ) 73 | end 74 | 75 | def join_player(game, player_id, player_name), 76 | do: GenStateMachine.call(game, {:join_player, player_id, player_name}) 77 | 78 | def start_round(game, player_id), do: GenStateMachine.call(game, {:start_round, player_id}) 79 | 80 | def submit_answer(game, player_id, answer), 81 | do: GenStateMachine.cast(game, {:submit_answer, player_id, answer}) 82 | 83 | def pick_winner(game, player_id), 84 | do: GenStateMachine.cast(game, {:pick_winner, player_id}) 85 | 86 | def subscribe(id), do: Phoenix.PubSub.subscribe(Leafblower.PubSub, topic(id)) 87 | def via_tuple(id), do: ProcessRegistry.via_tuple({__MODULE__, id}) 88 | 89 | @spec get_state(any()) :: {status(), data()} 90 | def get_state(game), do: GenStateMachine.call(game, :get_state) 91 | 92 | @impl true 93 | @spec init(keyword) :: {:ok, status(), data()} 94 | def init(init_arg) do 95 | {:ok, :waiting_for_players, init_arg} 96 | end 97 | 98 | @spec handle_event(term, term, status(), data()) :: 99 | GenStateMachine.event_handler_result(status()) 100 | def handle_event(atom, old_state, state, data) 101 | 102 | @impl true 103 | def handle_event({:call, from}, :get_state, status, data) do 104 | {:keep_state_and_data, [{:reply, from, {status, data}}]} 105 | end 106 | 107 | @impl true 108 | def handle_event( 109 | {:call, from}, 110 | {:join_player, player_id, player_name}, 111 | :waiting_for_players, 112 | %{ 113 | active_players: active_players, 114 | player_score: player_score, 115 | player_info: player_info, 116 | player_cards: player_cards 117 | } = data 118 | ) do 119 | data = 120 | %{ 121 | data 122 | | active_players: MapSet.put(active_players, player_id), 123 | player_score: Map.put(player_score, player_id, 0), 124 | player_info: Map.put(player_info, player_id, %{name: player_name, id: player_id}), 125 | player_cards: Map.put(player_cards, player_id, MapSet.new()) 126 | } 127 | |> maybe_assign_leader(:start_of_game) 128 | 129 | {:keep_state, data, 130 | [{:reply, from, :ok}, {:next_event, :internal, :broadcast}, @inactivity_timeout_action]} 131 | end 132 | 133 | @impl true 134 | def handle_event( 135 | {:call, from}, 136 | {:start_round, player_id}, 137 | status, 138 | %{ 139 | leader_player_id: player_id 140 | } = data 141 | ) 142 | when status in [:waiting_for_players, :round_ended, :show_winner] do 143 | start_timer(data, :no_response_countdown) 144 | 145 | data = %{ 146 | data 147 | | round_number: data.round_number + 1, 148 | round_player_answers: %{}, 149 | winner_player_id: nil 150 | } 151 | 152 | data = 153 | if status == :show_winner do 154 | maybe_assign_leader(data, :end_of_round) 155 | else 156 | data 157 | end 158 | 159 | {:next_state, :round_started_waiting_for_response, data, 160 | [ 161 | {:reply, from, :ok}, 162 | {:next_event, :internal, :deal_cards}, 163 | {:next_event, :internal, :broadcast}, 164 | @inactivity_timeout_action 165 | ]} 166 | end 167 | 168 | @impl true 169 | def handle_event( 170 | :cast, 171 | {:submit_answer, player_id, answer}, 172 | :round_started_waiting_for_response, 173 | data 174 | ) do 175 | data = %{ 176 | data 177 | | round_player_answers: 178 | Map.update( 179 | data.round_player_answers, 180 | player_id, 181 | [answer], 182 | fn old_value -> old_value ++ [answer] end 183 | ), 184 | player_cards: 185 | Map.update!( 186 | data.player_cards, 187 | player_id, 188 | &MapSet.delete(&1, answer) 189 | ) 190 | } 191 | 192 | if all_players_answered?(data) do 193 | stop_timer(data, :no_response_countdown) 194 | start_timer(data, :nonexistent_winner_countdown) 195 | 196 | {:next_state, :round_ended, data, 197 | [{:next_event, :internal, :broadcast}, @inactivity_timeout_action]} 198 | else 199 | {:keep_state, data, [{:next_event, :internal, :broadcast}]} 200 | end 201 | end 202 | 203 | @impl true 204 | def handle_event( 205 | :cast, 206 | {:pick_winner, player_id}, 207 | :round_ended, 208 | %{player_score: player_score} = data 209 | ) do 210 | data = %{ 211 | data 212 | | player_score: Map.update(player_score, player_id, 0, &(&1 + 1)), 213 | winner_player_id: player_id 214 | } 215 | 216 | stop_timer(data, :nonexistent_winner_countdown) 217 | 218 | {:next_state, :show_winner, data, 219 | [{:next_event, :internal, :broadcast}, @inactivity_timeout_action]} 220 | end 221 | 222 | # info 223 | 224 | @impl true 225 | def handle_event( 226 | :info, 227 | {:timer_end, :no_response_countdown}, 228 | :round_started_waiting_for_response, 229 | data 230 | ) do 231 | start_timer(data, :nonexistent_winner_countdown) 232 | 233 | {:next_state, :round_ended, data, 234 | [{:next_event, :internal, :broadcast}, @inactivity_timeout_action]} 235 | end 236 | 237 | @impl true 238 | def handle_event( 239 | :info, 240 | {:timer_end, :nonexistent_winner_countdown}, 241 | _status, 242 | %{leader_player_id: leader_player_id, active_players: active_players} 243 | ) do 244 | winnder_player_id = MapSet.delete(active_players, leader_player_id) |> Enum.random() 245 | :ok = GenServer.cast(self(), {:pick_winner, winnder_player_id}) 246 | :keep_state_and_data 247 | end 248 | 249 | # enters 250 | 251 | def handle_event(:enter, :round_started_waiting_for_response, :round_ended, data) do 252 | if all_players_answered?(data) do 253 | :keep_state_and_data 254 | else 255 | # We want to make sure that all players have drawn the right amount of white cards based on required_white_cards_count 256 | # If for some reason they didn't draw enough cards, we automatically draw it for them 257 | player_id_card_taken_and_cards = 258 | for player_id <- MapSet.delete(data.active_players, data.leader_player_id), 259 | cards = data.player_cards[player_id], 260 | cards_needed = 261 | data.required_white_cards_count - 262 | length(Map.get(data.round_player_answers, player_id, [])), 263 | cards_needed > 0 do 264 | cards_taken = Enum.take_random(cards, cards_needed) 265 | {player_id, cards_taken, MapSet.difference(cards, MapSet.new(cards_taken))} 266 | end 267 | 268 | round_player_answers = 269 | Enum.into(player_id_card_taken_and_cards, data.round_player_answers, fn {player_id, 270 | cards_taken, 271 | _} -> 272 | {player_id, Map.get(data.round_player_answers, player_id, []) ++ cards_taken} 273 | end) 274 | 275 | player_cards = 276 | Enum.into(player_id_card_taken_and_cards, data.player_cards, fn {player_id, _, cards} -> 277 | {player_id, cards} 278 | end) 279 | 280 | {:keep_state, 281 | %{data | round_player_answers: round_player_answers, player_cards: player_cards}} 282 | end 283 | end 284 | 285 | def handle_event(:enter, _event, _state, _data) do 286 | :keep_state_and_data 287 | end 288 | 289 | # internal 290 | def handle_event(:internal, :deal_cards, _state, data) do 291 | {black_card, deck} = Leafblower.Deck.take_black_card(data.deck) 292 | %{"pick" => required_white_cards_count} = Leafblower.Deck.card(black_card, :black) 293 | 294 | {player_cards, deck} = Leafblower.Deck.deal_white_card(deck, data.player_cards) 295 | 296 | {:keep_state, 297 | %{ 298 | data 299 | | black_card: black_card, 300 | deck: deck, 301 | player_cards: player_cards, 302 | required_white_cards_count: required_white_cards_count 303 | }} 304 | end 305 | 306 | def handle_event(:internal, :broadcast, status, data) do 307 | Phoenix.PubSub.broadcast( 308 | Leafblower.PubSub, 309 | topic(data.id), 310 | {:game_state_changed, status, Map.drop(data, [:deck])} 311 | ) 312 | 313 | :keep_state_and_data 314 | end 315 | 316 | # timeout 317 | def handle_event(:state_timeout, :terminate, state, data) do 318 | Phoenix.PubSub.broadcast( 319 | Leafblower.PubSub, 320 | topic(data.id), 321 | {:terminated_for_inactivity, state} 322 | ) 323 | 324 | {:stop, :normal} 325 | end 326 | 327 | def generate_game_code() do 328 | # Generate 3 server codes to try. Take the first that is unused. 329 | # If no unused ones found, add an error 330 | codes = Enum.map(1..3, fn _ -> do_generate_code() end) 331 | 332 | case Enum.find(codes, &(!server_found?(&1))) do 333 | nil -> 334 | # no unused game code found. Report server busy, try again later. 335 | {:error, "Didn't find unused code, try again later"} 336 | 337 | code -> 338 | {:ok, code} 339 | end 340 | end 341 | 342 | defp do_generate_code() do 343 | # Generate a single 4 character random code 344 | range = ?A..?Z 345 | 346 | 1..5 347 | |> Enum.map(fn _ -> [Enum.random(range)] |> List.to_string() end) 348 | |> Enum.join("") 349 | end 350 | 351 | defp server_found?(game_code) do 352 | Leafblower.GameSupervisor.find_game(game_code) != nil 353 | end 354 | 355 | defp start_timer(data, action_meta) do 356 | data.id 357 | |> GameTicker.via_tuple() 358 | |> GameTicker.start_tick(action_meta, data.countdown_duration) 359 | end 360 | 361 | defp stop_timer(data, action_meta) do 362 | data.id 363 | |> GameTicker.via_tuple() 364 | |> GameTicker.stop_tick(action_meta) 365 | end 366 | 367 | # When first player joins the game (leader_player_id != nil), pick that player to be the leader 368 | defp maybe_assign_leader( 369 | %{ 370 | leader_player_id: leader_player_id, 371 | active_players: active_players 372 | } = data, 373 | :start_of_game 374 | ) do 375 | active_players = MapSet.to_list(active_players) 376 | 377 | if leader_player_id == nil do 378 | %{data | leader_player_id: active_players |> hd} 379 | else 380 | data 381 | end 382 | end 383 | 384 | # After a round ends, we want to pick the next player in the list to be the leader 385 | defp maybe_assign_leader( 386 | %{ 387 | leader_player_id: leader_player_id, 388 | active_players: active_players 389 | } = data, 390 | :end_of_round 391 | ) do 392 | # From what i saw, converting to list seems to always yield a sorted result. So we'll always have the same 393 | # order of players getting picked a leader 394 | active_players = MapSet.to_list(active_players) 395 | 396 | new_idx = 397 | rem(Enum.find_index(active_players, &(&1 == leader_player_id)) + 1, length(active_players)) 398 | 399 | %{data | leader_player_id: Enum.at(active_players, new_idx)} 400 | end 401 | 402 | defp all_players_answered?(data) do 403 | current_player_count_minus_leader = 404 | (MapSet.size(data.active_players) - 1) * data.required_white_cards_count 405 | 406 | all_cards = 407 | Map.values(data.round_player_answers) 408 | |> Enum.map(&length/1) 409 | |> Enum.sum() 410 | 411 | current_player_count_minus_leader == all_cards 412 | end 413 | 414 | defp topic(id), do: "#{__MODULE__}/#{id}" 415 | end 416 | -------------------------------------------------------------------------------- /lib/leafblower_web/controllers/game_live.ex: -------------------------------------------------------------------------------- 1 | defmodule LeafblowerWeb.GameLive do 2 | use LeafblowerWeb, :ingame_live_view 3 | alias Leafblower.{GameStatem, GameSupervisor, GameTicker, Deck} 4 | 5 | @type assigns :: %{ 6 | game: pid(), 7 | game_status: GameStatem.state() | nil, 8 | game_data: GameStatem.data() | nil, 9 | user_id: binary(), 10 | joined_in_game?: boolean(), 11 | countdown_left: non_neg_integer() | nil, 12 | is_leader?: boolean(), 13 | show_chat: boolean() 14 | } 15 | 16 | @impl true 17 | def mount(params, session, socket) do 18 | GameSupervisor.find_game(params["id"]) 19 | |> do_mount(params, session, socket) 20 | end 21 | 22 | def do_mount(game, _params, %{"current_user_id" => user_id}, socket) when game != nil do 23 | {status, data} = GameStatem.get_state(game) 24 | 25 | if connected?(socket) do 26 | GameStatem.subscribe(data.id) 27 | GameTicker.subscribe(data.id) 28 | LeafblowerWeb.Component.GameChat.chat_subscribe(data.id) 29 | end 30 | 31 | {:ok, 32 | assign(socket, 33 | game: game, 34 | game_status: status, 35 | game_data: data, 36 | user_id: user_id, 37 | countdown_left: nil, 38 | joined_in_game?: MapSet.member?(data.active_players, user_id), 39 | is_leader?: data.leader_player_id == user_id, 40 | show_chat: false, 41 | message: nil 42 | ) 43 | |> clear_flash() 44 | |> maybe_assign_changeset()} 45 | end 46 | 47 | def do_mount(nil, _params, _session, socket) do 48 | {:ok, 49 | socket 50 | |> put_flash(:error, "Game not found") 51 | |> redirect(to: Routes.game_splash_path(socket, :index))} 52 | end 53 | 54 | @impl true 55 | def handle_info({:terminated_for_inactivity, _state}, socket) do 56 | {:noreply, 57 | socket 58 | |> put_flash(:error, "Game is terminated for inactivity") 59 | |> redirect(to: Routes.game_splash_path(socket, :index))} 60 | end 61 | 62 | def handle_info({:game_state_changed, state, data}, socket) do 63 | socket = 64 | case state do 65 | :round_ended -> 66 | assign(socket, countdown_left: nil) 67 | 68 | _ -> 69 | socket 70 | end 71 | 72 | {:noreply, 73 | assign(socket, 74 | game_status: state, 75 | game_data: data, 76 | is_leader?: data.leader_player_id == socket.assigns.user_id 77 | )} 78 | end 79 | 80 | def handle_info({:ticker_ticked, countdown_left}, socket) when countdown_left > 1 do 81 | {:noreply, assign(socket, countdown_left: countdown_left)} 82 | end 83 | 84 | def handle_info({:ticker_ticked, _countdown_left}, socket) do 85 | {:noreply, assign(socket, countdown_left: nil)} 86 | end 87 | 88 | def handle_info({:new_message, message}, socket) do 89 | {:noreply, assign(socket, message: message)} 90 | end 91 | 92 | defp maybe_assign_changeset(%{assigns: %{joined_in_game?: false}} = socket) do 93 | assign(socket, changeset: cast_user()) 94 | end 95 | 96 | defp maybe_assign_changeset(socket), do: assign(socket, changeset: nil) 97 | 98 | defp cast_user(params \\ %{}) do 99 | {%{}, %{name: :string}} 100 | |> Ecto.Changeset.cast(params, [:name]) 101 | |> Ecto.Changeset.validate_required([:name]) 102 | |> Ecto.Changeset.validate_length(:name, max: 15) 103 | end 104 | 105 | @impl true 106 | def handle_event("join_game", %{"user" => user}, socket) do 107 | %{game: game, user_id: user_id} = socket.assigns 108 | :ok = GameStatem.join_player(game, user_id, user["name"]) 109 | 110 | {:noreply, 111 | socket 112 | |> clear_flash() 113 | |> assign(joined_in_game?: true)} 114 | end 115 | 116 | def handle_event("validate_join_game", %{"user" => params}, socket) do 117 | {:noreply, 118 | assign(socket, 119 | changeset: 120 | params 121 | |> cast_user 122 | |> Map.put(:action, :insert) 123 | )} 124 | end 125 | 126 | def handle_event("start_round", _value, socket) do 127 | %{game: game, user_id: user_id} = socket.assigns 128 | :ok = GameStatem.start_round(game, user_id) 129 | {:noreply, socket} 130 | end 131 | 132 | 133 | def handle_event("toggle_chat", _value, socket) do 134 | {:noreply, assign(socket, show_chat: !socket.assigns.show_chat)} 135 | end 136 | 137 | def handle_event("submit_answer", %{"id" => id}, socket) do 138 | %{game: game, user_id: user_id} = socket.assigns 139 | GameStatem.submit_answer(game, user_id, id) 140 | {:noreply, socket} 141 | end 142 | 143 | def handle_event("pick_winner", %{"id" => id}, socket) do 144 | %{game: game} = socket.assigns 145 | :ok = GameStatem.pick_winner(game, id) 146 | {:noreply, socket} 147 | end 148 | 149 | @impl true 150 | @spec render(assigns()) :: Phoenix.LiveView.Rendered.t() 151 | 152 | def render(%{joined_in_game?: false} = assigns) do 153 | ~H""" 154 | <%= if @game_status == :waiting_for_players do %> 155 | <.form let={f} for={@changeset} phx-change="validate_join_game" phx-submit="join_game" as="user"> 156 |
157 | <%= label f, :name %> 158 | <%= text_input f, :name, placeholder: "Enter your name! " %> 159 | <%= error_tag f, :name %> 160 |
161 | <%= submit "Join game", [disabled: length(@changeset.errors) > 0] %> 162 | 163 | <% else %> 164 |

Game has started

165 | <% end %> 166 | """ 167 | end 168 | 169 | def render(assigns) do 170 | ~H""" 171 |
172 |
173 |
174 | <%= if @countdown_left != nil do %> 175 | 176 | <% end %> 177 |
178 |
<%= Atom.to_string(@game_status) %>
179 |
180 |
181 | Open Chat 182 |
183 | 184 | <%= case @game_status do 185 | :waiting_for_players -> render_waiting_for_players( 186 | @game_data.id, 187 | @game_data.active_players, 188 | @game_data.min_player_count, 189 | @game_data.player_info, 190 | @user_id, 191 | @is_leader?) 192 | :round_started_waiting_for_response -> render_round_started_waiting_for_response( 193 | @user_id, 194 | @game_data.active_players, 195 | @game_data.round_player_answers, 196 | @game_data.leader_player_id, 197 | @game_data.player_cards[@user_id], 198 | @game_data.black_card, 199 | @game_data.player_info, 200 | @is_leader?) 201 | :round_ended -> render_round_ended( 202 | @game_data.active_players, 203 | @game_data.round_player_answers, 204 | @game_data.player_info, 205 | @game_data.black_card, 206 | @game_data.leader_player_id, 207 | @is_leader?) 208 | :show_winner -> render_winner( 209 | @game_data.round_player_answers[@game_data.winner_player_id], 210 | @game_data.player_info[@game_data.winner_player_id], 211 | @game_data.player_score, 212 | @game_data.player_info, 213 | @user_id, 214 | @game_data.black_card, 215 | @is_leader? 216 | ) 217 | _ -> "" 218 | end %> 219 |
220 |
221 |
222 | Close Chat 223 |
224 | <.live_component 225 | module={LeafblowerWeb.Component.GameChat} id="game_chat" 226 | message={@message} 227 | player_info={@game_data.player_info} 228 | game_id={@game_data.id} 229 | user_id={@user_id} /> 230 |
231 |
232 | """ 233 | end 234 | 235 | defp render_waiting_for_players( 236 | game_id, 237 | active_players, 238 | min_player_count, 239 | player_info, 240 | current_user_id, 241 | is_leader? 242 | ) do 243 | active_players_size = MapSet.size(active_players) 244 | 245 | assigns = %{ 246 | game_id: game_id, 247 | disabled: active_players_size < min_player_count, 248 | is_leader?: is_leader?, 249 | active_players: active_players, 250 | player_info: player_info, 251 | current_user_id: current_user_id 252 | } 253 | 254 | ~H""" 255 |
Game code: <%= @game_id %>
Share it with your friends to play!
256 | <%= if @is_leader? do%> 257 | 258 | <% end %> 259 | 260 |
261 |
262 |

Players

263 | 268 |
269 | """ 270 | end 271 | 272 | defp render_round_started_waiting_for_response( 273 | player_id, 274 | active_players, 275 | round_player_answers, 276 | leader_player_id, 277 | cards, 278 | black_card_id, 279 | player_info, 280 | is_leader? 281 | ) do 282 | black_card = Deck.card(black_card_id, :black) 283 | has_answered? = Map.has_key?(round_player_answers, player_id) 284 | needs_more_answer? = length(round_player_answers[player_id] || []) != black_card["pick"] 285 | 286 | assigns = %{ 287 | active_players: active_players, 288 | leader_player_id: leader_player_id, 289 | round_player_answers: round_player_answers, 290 | cards: cards, 291 | black_card: black_card, 292 | player_info: player_info, 293 | is_leader?: is_leader?, 294 | player_id: player_id, 295 | has_answered?: has_answered?, 296 | needs_more_answer?: needs_more_answer? 297 | } 298 | 299 | ~H""" 300 | 301 |
302 |
303 |
304 | <%= @black_card["text"] %> 305 |
306 |
307 |
308 | 309 | <%= if !@is_leader? do%> 310 | <%= if @has_answered? do %> 311 | You picked 312 |
313 | <%= render_cards(get_white_cards(@round_player_answers[@player_id]), "light") %> 314 |
315 | <%= if @needs_more_answer? do %> 316 | Pick more cards 317 | <% end %> 318 | <% end %> 319 | <%= if @needs_more_answer? do %> 320 | 327 | <% end %> 328 | <% else %> 329 |
330 |
331 |

Players are picking their answers. Please wait

332 |
333 | <% end %> 334 | 335 |
336 |
337 | Players 338 |
    339 | <%= for player <- Enum.map(@active_players, fn id -> @player_info[id] end) do %> 340 | <%= if player.id == @leader_player_id do %> 341 |
  • <%= player.name %> - 👑
  • 342 | <% else %> 343 |
  • <%= player.name %> - <%= if Map.has_key?(@round_player_answers, player.id), do: "✅", else: "⌛"%>
  • 344 | <% end %> 345 | <% end %> 346 |
347 |
348 |
349 | 350 | """ 351 | end 352 | 353 | defp render_round_ended( 354 | active_players, 355 | round_player_answers, 356 | player_info, 357 | black_card_id, 358 | leader_player_id, 359 | is_leader? 360 | ) do 361 | assigns = %{ 362 | active_players: active_players, 363 | round_player_answers: round_player_answers, 364 | has_answers?: Map.values(round_player_answers) |> Enum.any?(), 365 | player_info: player_info, 366 | black_card: Leafblower.Deck.card(black_card_id, :black), 367 | leader: player_info[leader_player_id], 368 | is_leader?: is_leader? 369 | } 370 | 371 | ~H""" 372 |
373 | <%= if @is_leader? do%> 374 |
375 |
376 |
377 | <%= @black_card["text"] %> 378 |
379 |
380 |
381 | <%= if @has_answers? do %> 382 |

Pick a winner

383 | 393 | <% else %> 394 | 395 | <% end %> 396 | <% else %> 397 |
Waiting for <%= @leader.name %> 👑 to a pick a winner
398 | 404 | <% end %> 405 |
406 | """ 407 | end 408 | 409 | defp render_winner( 410 | winner_cards, 411 | winner_player, 412 | player_score, 413 | player_info, 414 | current_user_id, 415 | black_card_id, 416 | is_leader? 417 | ) do 418 | assigns = %{ 419 | is_leader?: is_leader?, 420 | winner_player: winner_player, 421 | player_score: player_score, 422 | player_info: player_info, 423 | winner_cards: winner_cards, 424 | black_card: Leafblower.Deck.card(black_card_id, :black) 425 | } 426 | 427 | ~H""" 428 |
429 |
430 |
431 | <%= @black_card["text"] %> 432 |
433 |
434 |
435 | 436 |

And the winner for this round is <%= @winner_player.name %> 🎉🎉🎉

437 |
438 | <%= render_cards(get_white_cards(@winner_cards), "light") %> 439 |
440 | <%= if @is_leader? do%> 441 | 442 | <% end %> 443 | 444 | <%= render_leader_board(player_info, player_score, current_user_id) %> 445 | """ 446 | end 447 | 448 | defp render_leader_board(player_info, player_score, current_user_id) do 449 | assigns = %{ 450 | sorted_score: Enum.sort(player_score, fn {_, left}, {_, right} -> left > right end), 451 | player_info: player_info, 452 | current_user_id: current_user_id 453 | } 454 | 455 | ~H""" 456 |
457 | 462 |
463 | """ 464 | end 465 | 466 | defp render_cards(cards, color, opts \\ []) when color in ["light", "dark"] do 467 | assigns = %{ 468 | cards: cards, 469 | color: color, 470 | phx_click: Keyword.get(opts, :phx_click), 471 | phx_value_id: Keyword.get(opts, :phx_value_id), 472 | id: Keyword.get(opts, :id), 473 | class: Keyword.get(opts, :class) 474 | } 475 | 476 | ~H""" 477 |
478 | <%= for {id, card} <- @cards do %> 479 |
480 | <%= card["text"] %> 481 |
482 | <% end %> 483 |
484 | """ 485 | end 486 | 487 | defp get_white_cards(cards) do 488 | Deck.card(cards, :white) 489 | end 490 | end 491 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Attribution-NonCommercial-ShareAlike 4.0 International 2 | 3 | ======================================================================= 4 | 5 | Creative Commons Corporation ("Creative Commons") is not a law firm and 6 | does not provide legal services or legal advice. Distribution of 7 | Creative Commons public licenses does not create a lawyer-client or 8 | other relationship. Creative Commons makes its licenses and related 9 | information available on an "as-is" basis. Creative Commons gives no 10 | warranties regarding its licenses, any material licensed under their 11 | terms and conditions, or any related information. Creative Commons 12 | disclaims all liability for damages resulting from their use to the 13 | fullest extent possible. 14 | 15 | Using Creative Commons Public Licenses 16 | 17 | Creative Commons public licenses provide a standard set of terms and 18 | conditions that creators and other rights holders may use to share 19 | original works of authorship and other material subject to copyright 20 | and certain other rights specified in the public license below. The 21 | following considerations are for informational purposes only, are not 22 | exhaustive, and do not form part of our licenses. 23 | 24 | Considerations for licensors: Our public licenses are 25 | intended for use by those authorized to give the public 26 | permission to use material in ways otherwise restricted by 27 | copyright and certain other rights. Our licenses are 28 | irrevocable. Licensors should read and understand the terms 29 | and conditions of the license they choose before applying it. 30 | Licensors should also secure all rights necessary before 31 | applying our licenses so that the public can reuse the 32 | material as expected. Licensors should clearly mark any 33 | material not subject to the license. This includes other CC- 34 | licensed material, or material used under an exception or 35 | limitation to copyright. More considerations for licensors: 36 | wiki.creativecommons.org/Considerations_for_licensors 37 | 38 | Considerations for the public: By using one of our public 39 | licenses, a licensor grants the public permission to use the 40 | licensed material under specified terms and conditions. If 41 | the licensor's permission is not necessary for any reason--for 42 | example, because of any applicable exception or limitation to 43 | copyright--then that use is not regulated by the license. Our 44 | licenses grant only permissions under copyright and certain 45 | other rights that a licensor has authority to grant. Use of 46 | the licensed material may still be restricted for other 47 | reasons, including because others have copyright or other 48 | rights in the material. A licensor may make special requests, 49 | such as asking that all changes be marked or described. 50 | Although not required by our licenses, you are encouraged to 51 | respect those requests where reasonable. More considerations 52 | for the public: 53 | wiki.creativecommons.org/Considerations_for_licensees 54 | 55 | ======================================================================= 56 | 57 | Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International 58 | Public License 59 | 60 | By exercising the Licensed Rights (defined below), You accept and agree 61 | to be bound by the terms and conditions of this Creative Commons 62 | Attribution-NonCommercial-ShareAlike 4.0 International Public License 63 | ("Public License"). To the extent this Public License may be 64 | interpreted as a contract, You are granted the Licensed Rights in 65 | consideration of Your acceptance of these terms and conditions, and the 66 | Licensor grants You such rights in consideration of benefits the 67 | Licensor receives from making the Licensed Material available under 68 | these terms and conditions. 69 | 70 | 71 | Section 1 -- Definitions. 72 | 73 | a. Adapted Material means material subject to Copyright and Similar 74 | Rights that is derived from or based upon the Licensed Material 75 | and in which the Licensed Material is translated, altered, 76 | arranged, transformed, or otherwise modified in a manner requiring 77 | permission under the Copyright and Similar Rights held by the 78 | Licensor. For purposes of this Public License, where the Licensed 79 | Material is a musical work, performance, or sound recording, 80 | Adapted Material is always produced where the Licensed Material is 81 | synched in timed relation with a moving image. 82 | 83 | b. Adapter's License means the license You apply to Your Copyright 84 | and Similar Rights in Your contributions to Adapted Material in 85 | accordance with the terms and conditions of this Public License. 86 | 87 | c. BY-NC-SA Compatible License means a license listed at 88 | creativecommons.org/compatiblelicenses, approved by Creative 89 | Commons as essentially the equivalent of this Public License. 90 | 91 | d. Copyright and Similar Rights means copyright and/or similar rights 92 | closely related to copyright including, without limitation, 93 | performance, broadcast, sound recording, and Sui Generis Database 94 | Rights, without regard to how the rights are labeled or 95 | categorized. For purposes of this Public License, the rights 96 | specified in Section 2(b)(1)-(2) are not Copyright and Similar 97 | Rights. 98 | 99 | e. Effective Technological Measures means those measures that, in the 100 | absence of proper authority, may not be circumvented under laws 101 | fulfilling obligations under Article 11 of the WIPO Copyright 102 | Treaty adopted on December 20, 1996, and/or similar international 103 | agreements. 104 | 105 | f. Exceptions and Limitations means fair use, fair dealing, and/or 106 | any other exception or limitation to Copyright and Similar Rights 107 | that applies to Your use of the Licensed Material. 108 | 109 | g. License Elements means the license attributes listed in the name 110 | of a Creative Commons Public License. The License Elements of this 111 | Public License are Attribution, NonCommercial, and ShareAlike. 112 | 113 | h. Licensed Material means the artistic or literary work, database, 114 | or other material to which the Licensor applied this Public 115 | License. 116 | 117 | i. Licensed Rights means the rights granted to You subject to the 118 | terms and conditions of this Public License, which are limited to 119 | all Copyright and Similar Rights that apply to Your use of the 120 | Licensed Material and that the Licensor has authority to license. 121 | 122 | j. Licensor means the individual(s) or entity(ies) granting rights 123 | under this Public License. 124 | 125 | k. NonCommercial means not primarily intended for or directed towards 126 | commercial advantage or monetary compensation. For purposes of 127 | this Public License, the exchange of the Licensed Material for 128 | other material subject to Copyright and Similar Rights by digital 129 | file-sharing or similar means is NonCommercial provided there is 130 | no payment of monetary compensation in connection with the 131 | exchange. 132 | 133 | l. Share means to provide material to the public by any means or 134 | process that requires permission under the Licensed Rights, such 135 | as reproduction, public display, public performance, distribution, 136 | dissemination, communication, or importation, and to make material 137 | available to the public including in ways that members of the 138 | public may access the material from a place and at a time 139 | individually chosen by them. 140 | 141 | m. Sui Generis Database Rights means rights other than copyright 142 | resulting from Directive 96/9/EC of the European Parliament and of 143 | the Council of 11 March 1996 on the legal protection of databases, 144 | as amended and/or succeeded, as well as other essentially 145 | equivalent rights anywhere in the world. 146 | 147 | n. You means the individual or entity exercising the Licensed Rights 148 | under this Public License. Your has a corresponding meaning. 149 | 150 | 151 | Section 2 -- Scope. 152 | 153 | a. License grant. 154 | 155 | 1. Subject to the terms and conditions of this Public License, 156 | the Licensor hereby grants You a worldwide, royalty-free, 157 | non-sublicensable, non-exclusive, irrevocable license to 158 | exercise the Licensed Rights in the Licensed Material to: 159 | 160 | a. reproduce and Share the Licensed Material, in whole or 161 | in part, for NonCommercial purposes only; and 162 | 163 | b. produce, reproduce, and Share Adapted Material for 164 | NonCommercial purposes only. 165 | 166 | 2. Exceptions and Limitations. For the avoidance of doubt, where 167 | Exceptions and Limitations apply to Your use, this Public 168 | License does not apply, and You do not need to comply with 169 | its terms and conditions. 170 | 171 | 3. Term. The term of this Public License is specified in Section 172 | 6(a). 173 | 174 | 4. Media and formats; technical modifications allowed. The 175 | Licensor authorizes You to exercise the Licensed Rights in 176 | all media and formats whether now known or hereafter created, 177 | and to make technical modifications necessary to do so. The 178 | Licensor waives and/or agrees not to assert any right or 179 | authority to forbid You from making technical modifications 180 | necessary to exercise the Licensed Rights, including 181 | technical modifications necessary to circumvent Effective 182 | Technological Measures. For purposes of this Public License, 183 | simply making modifications authorized by this Section 2(a) 184 | (4) never produces Adapted Material. 185 | 186 | 5. Downstream recipients. 187 | 188 | a. Offer from the Licensor -- Licensed Material. Every 189 | recipient of the Licensed Material automatically 190 | receives an offer from the Licensor to exercise the 191 | Licensed Rights under the terms and conditions of this 192 | Public License. 193 | 194 | b. Additional offer from the Licensor -- Adapted Material. 195 | Every recipient of Adapted Material from You 196 | automatically receives an offer from the Licensor to 197 | exercise the Licensed Rights in the Adapted Material 198 | under the conditions of the Adapter's License You apply. 199 | 200 | c. No downstream restrictions. You may not offer or impose 201 | any additional or different terms or conditions on, or 202 | apply any Effective Technological Measures to, the 203 | Licensed Material if doing so restricts exercise of the 204 | Licensed Rights by any recipient of the Licensed 205 | Material. 206 | 207 | 6. No endorsement. Nothing in this Public License constitutes or 208 | may be construed as permission to assert or imply that You 209 | are, or that Your use of the Licensed Material is, connected 210 | with, or sponsored, endorsed, or granted official status by, 211 | the Licensor or others designated to receive attribution as 212 | provided in Section 3(a)(1)(A)(i). 213 | 214 | b. Other rights. 215 | 216 | 1. Moral rights, such as the right of integrity, are not 217 | licensed under this Public License, nor are publicity, 218 | privacy, and/or other similar personality rights; however, to 219 | the extent possible, the Licensor waives and/or agrees not to 220 | assert any such rights held by the Licensor to the limited 221 | extent necessary to allow You to exercise the Licensed 222 | Rights, but not otherwise. 223 | 224 | 2. Patent and trademark rights are not licensed under this 225 | Public License. 226 | 227 | 3. To the extent possible, the Licensor waives any right to 228 | collect royalties from You for the exercise of the Licensed 229 | Rights, whether directly or through a collecting society 230 | under any voluntary or waivable statutory or compulsory 231 | licensing scheme. In all other cases the Licensor expressly 232 | reserves any right to collect such royalties, including when 233 | the Licensed Material is used other than for NonCommercial 234 | purposes. 235 | 236 | 237 | Section 3 -- License Conditions. 238 | 239 | Your exercise of the Licensed Rights is expressly made subject to the 240 | following conditions. 241 | 242 | a. Attribution. 243 | 244 | 1. If You Share the Licensed Material (including in modified 245 | form), You must: 246 | 247 | a. retain the following if it is supplied by the Licensor 248 | with the Licensed Material: 249 | 250 | i. identification of the creator(s) of the Licensed 251 | Material and any others designated to receive 252 | attribution, in any reasonable manner requested by 253 | the Licensor (including by pseudonym if 254 | designated); 255 | 256 | ii. a copyright notice; 257 | 258 | iii. a notice that refers to this Public License; 259 | 260 | iv. a notice that refers to the disclaimer of 261 | warranties; 262 | 263 | v. a URI or hyperlink to the Licensed Material to the 264 | extent reasonably practicable; 265 | 266 | b. indicate if You modified the Licensed Material and 267 | retain an indication of any previous modifications; and 268 | 269 | c. indicate the Licensed Material is licensed under this 270 | Public License, and include the text of, or the URI or 271 | hyperlink to, this Public License. 272 | 273 | 2. You may satisfy the conditions in Section 3(a)(1) in any 274 | reasonable manner based on the medium, means, and context in 275 | which You Share the Licensed Material. For example, it may be 276 | reasonable to satisfy the conditions by providing a URI or 277 | hyperlink to a resource that includes the required 278 | information. 279 | 3. If requested by the Licensor, You must remove any of the 280 | information required by Section 3(a)(1)(A) to the extent 281 | reasonably practicable. 282 | 283 | b. ShareAlike. 284 | 285 | In addition to the conditions in Section 3(a), if You Share 286 | Adapted Material You produce, the following conditions also apply. 287 | 288 | 1. The Adapter's License You apply must be a Creative Commons 289 | license with the same License Elements, this version or 290 | later, or a BY-NC-SA Compatible License. 291 | 292 | 2. You must include the text of, or the URI or hyperlink to, the 293 | Adapter's License You apply. You may satisfy this condition 294 | in any reasonable manner based on the medium, means, and 295 | context in which You Share Adapted Material. 296 | 297 | 3. You may not offer or impose any additional or different terms 298 | or conditions on, or apply any Effective Technological 299 | Measures to, Adapted Material that restrict exercise of the 300 | rights granted under the Adapter's License You apply. 301 | 302 | 303 | Section 4 -- Sui Generis Database Rights. 304 | 305 | Where the Licensed Rights include Sui Generis Database Rights that 306 | apply to Your use of the Licensed Material: 307 | 308 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right 309 | to extract, reuse, reproduce, and Share all or a substantial 310 | portion of the contents of the database for NonCommercial purposes 311 | only; 312 | 313 | b. if You include all or a substantial portion of the database 314 | contents in a database in which You have Sui Generis Database 315 | Rights, then the database in which You have Sui Generis Database 316 | Rights (but not its individual contents) is Adapted Material, 317 | including for purposes of Section 3(b); and 318 | 319 | c. You must comply with the conditions in Section 3(a) if You Share 320 | all or a substantial portion of the contents of the database. 321 | 322 | For the avoidance of doubt, this Section 4 supplements and does not 323 | replace Your obligations under this Public License where the Licensed 324 | Rights include other Copyright and Similar Rights. 325 | 326 | 327 | Section 5 -- Disclaimer of Warranties and Limitation of Liability. 328 | 329 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE 330 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS 331 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF 332 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, 333 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, 334 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR 335 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, 336 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT 337 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT 338 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. 339 | 340 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE 341 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, 342 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, 343 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, 344 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR 345 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN 346 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR 347 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR 348 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. 349 | 350 | c. The disclaimer of warranties and limitation of liability provided 351 | above shall be interpreted in a manner that, to the extent 352 | possible, most closely approximates an absolute disclaimer and 353 | waiver of all liability. 354 | 355 | 356 | Section 6 -- Term and Termination. 357 | 358 | a. This Public License applies for the term of the Copyright and 359 | Similar Rights licensed here. However, if You fail to comply with 360 | this Public License, then Your rights under this Public License 361 | terminate automatically. 362 | 363 | b. Where Your right to use the Licensed Material has terminated under 364 | Section 6(a), it reinstates: 365 | 366 | 1. automatically as of the date the violation is cured, provided 367 | it is cured within 30 days of Your discovery of the 368 | violation; or 369 | 370 | 2. upon express reinstatement by the Licensor. 371 | 372 | For the avoidance of doubt, this Section 6(b) does not affect any 373 | right the Licensor may have to seek remedies for Your violations 374 | of this Public License. 375 | 376 | c. For the avoidance of doubt, the Licensor may also offer the 377 | Licensed Material under separate terms or conditions or stop 378 | distributing the Licensed Material at any time; however, doing so 379 | will not terminate this Public License. 380 | 381 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public 382 | License. 383 | 384 | 385 | Section 7 -- Other Terms and Conditions. 386 | 387 | a. The Licensor shall not be bound by any additional or different 388 | terms or conditions communicated by You unless expressly agreed. 389 | 390 | b. Any arrangements, understandings, or agreements regarding the 391 | Licensed Material not stated herein are separate from and 392 | independent of the terms and conditions of this Public License. 393 | 394 | 395 | Section 8 -- Interpretation. 396 | 397 | a. For the avoidance of doubt, this Public License does not, and 398 | shall not be interpreted to, reduce, limit, restrict, or impose 399 | conditions on any use of the Licensed Material that could lawfully 400 | be made without permission under this Public License. 401 | 402 | b. To the extent possible, if any provision of this Public License is 403 | deemed unenforceable, it shall be automatically reformed to the 404 | minimum extent necessary to make it enforceable. If the provision 405 | cannot be reformed, it shall be severed from this Public License 406 | without affecting the enforceability of the remaining terms and 407 | conditions. 408 | 409 | c. No term or condition of this Public License will be waived and no 410 | failure to comply consented to unless expressly agreed to by the 411 | Licensor. 412 | 413 | d. Nothing in this Public License constitutes or may be interpreted 414 | as a limitation upon, or waiver of, any privileges and immunities 415 | that apply to the Licensor or You, including from the legal 416 | processes of any jurisdiction or authority. 417 | 418 | ======================================================================= 419 | 420 | Creative Commons is not a party to its public 421 | licenses. Notwithstanding, Creative Commons may elect to apply one of 422 | its public licenses to material it publishes and in those instances 423 | will be considered the “Licensor.” The text of the Creative Commons 424 | public licenses is dedicated to the public domain under the CC0 Public 425 | Domain Dedication. Except for the limited purpose of indicating that 426 | material is shared under a Creative Commons public license or as 427 | otherwise permitted by the Creative Commons policies published at 428 | creativecommons.org/policies, Creative Commons does not authorize the 429 | use of the trademark "Creative Commons" or any other trademark or logo 430 | of Creative Commons without its prior written consent including, 431 | without limitation, in connection with any unauthorized modifications 432 | to any of its public licenses or any other arrangements, 433 | understandings, or agreements concerning use of licensed material. For 434 | the avoidance of doubt, this paragraph does not form part of the 435 | public licenses. 436 | 437 | Creative Commons may be contacted at creativecommons.org. 438 | --------------------------------------------------------------------------------