├── test
├── test_helper.exs
├── mine_sweeper_web
│ ├── views
│ │ ├── page_view_test.exs
│ │ ├── layout_view_test.exs
│ │ └── error_view_test.exs
│ └── controllers
│ │ └── page_controller_test.exs
└── support
│ ├── data_case.ex
│ ├── channel_case.ex
│ └── conn_case.ex
├── lib
├── mine_sweeper_web
│ ├── templates
│ │ ├── page
│ │ │ └── index.html.heex
│ │ └── layout
│ │ │ ├── app.html.heex
│ │ │ ├── live.html.heex
│ │ │ └── root.html.heex
│ ├── views
│ │ ├── page_view.ex
│ │ ├── layout_view.ex
│ │ ├── error_view.ex
│ │ └── error_helpers.ex
│ ├── controllers
│ │ └── page_controller.ex
│ ├── live
│ │ ├── session_live
│ │ │ ├── cell_component.html.heex
│ │ │ ├── form_component.ex
│ │ │ ├── index.ex
│ │ │ ├── index.html.heex
│ │ │ ├── show.html.heex
│ │ │ ├── show.ex
│ │ │ ├── form_component.html.heex
│ │ │ └── cell_component.ex
│ │ └── live_helpers.ex
│ ├── gettext.ex
│ ├── router.ex
│ ├── endpoint.ex
│ └── telemetry.ex
├── mine_sweeper.ex
├── mine_sweeper
│ ├── release.ex
│ ├── application.ex
│ ├── game.ex
│ ├── cell_server.ex
│ └── game_server.ex
└── mine_sweeper_web.ex
├── rel
└── overlays
│ └── bin
│ ├── server.bat
│ ├── migrate.bat
│ ├── server
│ └── migrate
├── priv
├── repo
│ ├── migrations
│ │ └── .formatter.exs
│ └── seeds.exs
├── static
│ └── robots.txt
└── gettext
│ ├── errors.pot
│ └── en
│ └── LC_MESSAGES
│ └── errors.po
├── .formatter.exs
├── .dockerignore
├── assets
├── tailwind.config.js
├── js
│ └── app.js
├── css
│ └── app.css
└── vendor
│ └── topbar.js
├── config
├── test.exs
├── config.exs
├── prod.exs
├── dev.exs
└── runtime.exs
├── .github
└── workflows
│ └── vuln.yml
├── README.md
├── .gitignore
├── fly.toml
├── mix.exs
├── Dockerfile
└── mix.lock
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/templates/page/index.html.heex:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/rel/overlays/bin/server.bat:
--------------------------------------------------------------------------------
1 | set PHX_SERVER=true
2 | call "%~dp0\mine_sweeper" start
--------------------------------------------------------------------------------
/rel/overlays/bin/migrate.bat:
--------------------------------------------------------------------------------
1 | call "%~dp0\mine_sweeper" eval MineSweeper.Release.migrate
--------------------------------------------------------------------------------
/priv/repo/migrations/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto_sql],
3 | inputs: ["*.exs"]
4 | ]
5 |
--------------------------------------------------------------------------------
/rel/overlays/bin/server:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd -P -- "$(dirname -- "$0")"
3 | PHX_SERVER=true exec ./mine_sweeper start
4 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/views/page_view.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.PageView do
2 | use MineSweeperWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/rel/overlays/bin/migrate:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd -P -- "$(dirname -- "$0")"
3 | exec ./mine_sweeper eval MineSweeper.Release.migrate
--------------------------------------------------------------------------------
/test/mine_sweeper_web/views/page_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.PageViewTest do
2 | use MineSweeperWeb.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 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.PageController do
2 | use MineSweeperWeb, :controller
3 |
4 | def index(conn, _params) do
5 | redirect(conn, to: "/sessions")
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/test/mine_sweeper_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.PageControllerTest do
2 | use MineSweeperWeb.ConnCase
3 |
4 | test "GET /", %{conn: conn} do
5 | conn = get(conn, "/")
6 | assert html_response(conn, 200) =~ "Welcome to Phoenix!"
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/templates/layout/app.html.heex:
--------------------------------------------------------------------------------
1 |
2 | <%= get_flash(@conn, :info) %>
3 | <%= get_flash(@conn, :error) %>
4 | <%= @inner_content %>
5 |
6 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/live/session_live/cell_component.html.heex:
--------------------------------------------------------------------------------
1 |
10 | <%= show(@cell) %>
11 |
12 |
--------------------------------------------------------------------------------
/lib/mine_sweeper.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeper do
2 | @moduledoc """
3 | MineSweeper 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 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .dockerignore
2 | # there are valid reasons to keep the .git, namely so that you can get the
3 | # current commit hash
4 | #.git
5 | .log
6 | tmp
7 |
8 | # Mix artifacts
9 | _build
10 | deps
11 | *.ez
12 | releases
13 |
14 | # Generate on crash by the VM
15 | erl_crash.dump
16 |
17 | # Static artifacts
18 | node_modules
19 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/views/layout_view.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.LayoutView do
2 | use MineSweeperWeb, :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 |
--------------------------------------------------------------------------------
/test/mine_sweeper_web/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.LayoutViewTest do
2 | use MineSweeperWeb.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 |
--------------------------------------------------------------------------------
/assets/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // See the Tailwind configuration guide for advanced usage
2 | // https://tailwindcss.com/docs/configuration
3 | module.exports = {
4 | content: [
5 | './js/**/*.js',
6 | '../lib/*_web.ex',
7 | '../lib/*_web/**/*.*ex'
8 | ],
9 | theme: {
10 | extend: {},
11 | },
12 | plugins: [
13 | require('@tailwindcss/forms')
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/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 | # MineSweeper.Repo.insert!(%MineSweeper.SomeSchema{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/templates/layout/live.html.heex:
--------------------------------------------------------------------------------
1 |
2 | <%= live_flash(@flash, :info) %>
5 |
6 | <%= live_flash(@flash, :error) %>
9 |
10 | <%= @inner_content %>
11 |
12 |
--------------------------------------------------------------------------------
/test/mine_sweeper_web/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.ErrorViewTest do
2 | use MineSweeperWeb.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(MineSweeperWeb.ErrorView, "404.html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(MineSweeperWeb.ErrorView, "500.html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.ErrorView do
2 | use MineSweeperWeb, :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 :mine_sweeper, MineSweeperWeb.Endpoint,
6 | http: [ip: {127, 0, 0, 1}, port: 4002],
7 | secret_key_base: "ZErmj0tg1ie/FSaMR/p8Yx7hOq1c2nCfvir/QO8l1KzkTpD/554FRBhLpkvjbswM",
8 | server: false
9 |
10 | # In test we don't send emails.
11 | config :mine_sweeper, MineSweeper.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 |
--------------------------------------------------------------------------------
/.github/workflows/vuln.yml:
--------------------------------------------------------------------------------
1 | name: Scan for vuln
2 | on: [workflow_dispatch]
3 | jobs:
4 | build:
5 | name: Scan
6 | runs-on: ubuntu-latest
7 | steps:
8 | - name: Checkout code
9 | uses: actions/checkout@v2
10 | - name: Build an image from Dockerfile
11 | run: |
12 | docker build -t docker.io/my-organization/my-app:${{ github.sha }} .
13 | - name: Run Trivy vulnerability scanner
14 | uses: aquasecurity/trivy-action@master
15 | with:
16 | image-ref: 'docker.io/my-organization/my-app:${{ github.sha }}'
17 | format: 'table'
18 | exit-code: '1'
19 | ignore-unfixed: true
20 | vuln-type: 'os,library'
21 | severity: 'CRITICAL,HIGH,MEDIUM,LOW'
--------------------------------------------------------------------------------
/lib/mine_sweeper/release.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeper.Release do
2 | @moduledoc """
3 | Used for executing DB release tasks when run in production without Mix
4 | installed.
5 | """
6 | # @app :mine_sweeper
7 |
8 | # def migrate do
9 | # load_app()
10 |
11 | # for repo <- repos() do
12 | # {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
13 | # end
14 | # end
15 |
16 | # def rollback(repo, version) do
17 | # load_app()
18 | # {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
19 | # end
20 |
21 | # defp repos do
22 | # Application.fetch_env!(@app, :ecto_repos)
23 | # end
24 |
25 | # defp load_app do
26 | # Application.load(@app)
27 | # end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/live/session_live/form_component.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.SessionLive.FormComponent do
2 | use MineSweeperWeb, :live_component
3 |
4 | alias MineSweeper.Game
5 |
6 | def handle_event("start", %{"session" => session_params}, socket) do
7 | save_session(socket, socket.assigns.action, session_params)
8 | end
9 |
10 | defp save_session(socket, :new, session_params) do
11 | case Game.create_session(session_params) do
12 | {:ok, slug} ->
13 | {:noreply,
14 | socket
15 | |> put_flash(:info, "Session created successfully")
16 | |> push_redirect(to: Routes.session_show_path(socket, :show, slug))}
17 |
18 | {:error, _} ->
19 | {:noreply, put_flash(socket, :warn, "Failed to create session")}
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/mine_sweeper/application.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeper.Application do
2 | @moduledoc false
3 |
4 | use Application
5 |
6 | @impl true
7 | def start(_type, _args) do
8 | children = [
9 | MineSweeperWeb.Telemetry,
10 | {Phoenix.PubSub, name: MineSweeper.PubSub},
11 | {Registry, name: RealmRegistry, keys: :duplicate},
12 | {Registry, name: GameRegistry, keys: :unique},
13 | {DynamicSupervisor, strategy: :one_for_one, name: GameSupervisor},
14 | MineSweeperWeb.Endpoint
15 | ]
16 |
17 | opts = [strategy: :one_for_one, name: MineSweeper.Supervisor]
18 | Supervisor.start_link(children, opts)
19 | end
20 |
21 | @impl true
22 | def config_change(changed, _new, removed) do
23 | MineSweeperWeb.Endpoint.config_change(changed, removed)
24 | :ok
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.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 MineSweeperWeb.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: :mine_sweeper
24 | end
25 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeper.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 MineSweeper.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 | import MineSweeper.DataCase
22 | end
23 | end
24 |
25 | setup tags do
26 | :ok
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/live/session_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.SessionLive.Index do
2 | use MineSweeperWeb, :live_view
3 |
4 | alias MineSweeper.Game
5 |
6 | @impl true
7 | def mount(_params, _session, socket) do
8 | {:ok, assign(socket, :sessions, list_sessions())}
9 | end
10 |
11 | @impl true
12 | def handle_params(params, _url, socket) do
13 | {:noreply, apply_action(socket, socket.assigns.live_action, params)}
14 | end
15 |
16 | defp apply_action(socket, :new, _params) do
17 | socket
18 | |> assign(:page_title, "New Session")
19 | |> assign(:session, %{id: nil})
20 | end
21 |
22 | defp apply_action(socket, :index, _params) do
23 | socket
24 | |> assign(:page_title, "Listing Sessions")
25 | |> assign(:session, nil)
26 | end
27 |
28 | defp list_sessions do
29 | Game.list_sessions()
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MineSweeper
2 |
3 | - Game logic: https://github.com/princemaple/mine_sweeper/tree/main/lib/mine_sweeper
4 | - Game interaction and rendering: https://github.com/princemaple/mine_sweeper/tree/main/lib/mine_sweeper_web/live/session_live
5 | - Forum post: https://elixirforum.com/t/minesweeper-built-with-liveview-and-other-recent-tools/45266
6 |
7 | ## Run locally
8 |
9 | To start your Phoenix server:
10 |
11 | * Install dependencies with `mix deps.get`
12 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
13 |
14 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
15 |
16 | ## Learn more
17 |
18 | * Official website: https://www.phoenixframework.org/
19 | * Guides: https://hexdocs.pm/phoenix/overview.html
20 | * Docs: https://hexdocs.pm/phoenix
21 | * Forum: https://elixirforum.com/c/phoenix-forum
22 | * Source: https://github.com/phoenixframework/phoenix
23 |
--------------------------------------------------------------------------------
/.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 | *.gz
23 |
24 | # Ignore package tarball (built via "mix hex.build").
25 | mine_sweeper-*.tar
26 |
27 | # Ignore assets that are produced by build tools.
28 | /priv/static/assets/
29 |
30 | # Ignore digested assets cache.
31 | /priv/static/cache_manifest.json
32 |
33 | # In case you use Node.js/npm, you want to ignore these.
34 | npm-debug.log
35 | /assets/node_modules/
36 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml file generated for minesweeper on 2022-01-12T13:20:59+11:00
2 |
3 | app = "minesweeper"
4 |
5 | kill_signal = "SIGTERM"
6 | kill_timeout = 5
7 | processes = []
8 |
9 | [deploy]
10 | # release_command = "/app/bin/migrate"
11 |
12 | [env]
13 | PHX_HOST = "minesweeper.fly.dev"
14 | PORT = "8080"
15 |
16 | [experimental]
17 | allowed_public_ports = []
18 | auto_rollback = true
19 |
20 | [[services]]
21 | http_checks = []
22 | internal_port = 8080
23 | processes = ["app"]
24 | protocol = "tcp"
25 | script_checks = []
26 |
27 | [services.concurrency]
28 | hard_limit = 25
29 | soft_limit = 20
30 | type = "connections"
31 |
32 | [[services.ports]]
33 | handlers = ["http"]
34 | port = 80
35 |
36 | [[services.ports]]
37 | handlers = ["tls", "http"]
38 | port = 443
39 |
40 | [[services.tcp_checks]]
41 | grace_period = "1s"
42 | interval = "15s"
43 | restart_limit = 0
44 | timeout = "2s"
45 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.Router do
2 | use MineSweeperWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :fetch_session
7 | plug :fetch_live_flash
8 | plug :put_root_layout, {MineSweeperWeb.LayoutView, :root}
9 | plug :protect_from_forgery
10 | plug :put_secure_browser_headers
11 | end
12 |
13 | pipeline :api do
14 | plug :accepts, ["json"]
15 | end
16 |
17 | scope "/", MineSweeperWeb do
18 | pipe_through :browser
19 |
20 | get "/", PageController, :index
21 |
22 | live "/sessions", SessionLive.Index, :index
23 | live "/sessions/new", SessionLive.Index, :new
24 | live "/sessions/:id", SessionLive.Show, :show
25 | end
26 |
27 | if Mix.env() in [:dev, :test] do
28 | import Phoenix.LiveDashboard.Router
29 |
30 | scope "/" do
31 | pipe_through :browser
32 |
33 | live_dashboard "/dashboard", metrics: MineSweeperWeb.Telemetry
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/live/session_live/index.html.heex:
--------------------------------------------------------------------------------
1 |
Listing 💣 Sessions
2 |
3 | <%= if @live_action in [:new, :edit] do %>
4 | <.modal return_to={Routes.session_index_path(@socket, :index)}>
5 | <.live_component
6 | module={MineSweeperWeb.SessionLive.FormComponent}
7 | id={@session.id || :new}
8 | title={@page_title}
9 | action={@live_action}
10 | session={@session}
11 | />
12 |
13 | <% end %>
14 |
15 |
16 |
17 |
18 | Game ID
19 |
20 |
21 |
22 | <%= for {session, slug} <- @sessions do %>
23 |
24 | <%= slug %>
25 |
26 |
27 | <%= live_redirect "Enter", to: "/sessions/#{slug}" %>
28 |
29 |
30 | <% end %>
31 |
32 |
33 |
34 | <%= live_patch "New Session", to: Routes.session_index_path(@socket, :new) %>
35 |
--------------------------------------------------------------------------------
/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.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 MineSweeperWeb.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 MineSweeperWeb.ChannelCase
25 |
26 | # The default endpoint for testing
27 | @endpoint MineSweeperWeb.Endpoint
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/live/session_live/show.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Game <%= @slug %>
4 | <%= live_redirect "Back to list", to: Routes.session_index_path(@socket, :index) %>
5 |
6 |
7 |
8 | <%= @time %> (Terminate at <%= @time_limit %>)
9 | <%= @count %>🚩/<%= @total %>💣
10 |
11 |
12 |
13 | <%= for row <- 1..@height do %>
14 |
15 | <%= for col <- 1..@width do %>
16 |
17 | <.live_component
18 | id={"#{@slug}-#{row}-#{col}"}
19 | module={MineSweeperWeb.SessionLive.CellComponent}
20 | slug={@slug}
21 | coords={{row, col}}
22 | version={@buster[{row, col}]}
23 | />
24 |
25 | <% end %>
26 |
27 | <% end %>
28 |
29 |
30 |
31 | <%= if @ending == :win do %>
32 | 🎉🎉🎉🥳🎊🥳🎊🥳🎉🎉🎉
33 | <% end %>
34 |
35 | <%= if @ending == :lose do %>
36 | 😢😢😭😭😿😿😿😭😭😢😢
37 | <% end %>
38 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.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 MineSweeperWeb.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 MineSweeperWeb.ConnCase
26 |
27 | alias MineSweeperWeb.Router.Helpers, as: Routes
28 |
29 | # The default endpoint for testing
30 | @endpoint MineSweeperWeb.Endpoint
31 | end
32 | end
33 |
34 | setup tags do
35 | {:ok, conn: Phoenix.ConnTest.build_conn()}
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/mine_sweeper/game.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeper.Game do
2 | @moduledoc """
3 | The Game context.
4 | """
5 |
6 | def list_sessions do
7 | Registry.lookup(RealmRegistry, :public)
8 | end
9 |
10 | def get_session!(slug) do
11 | Registry.whereis_name({GameRegistry, {:game, slug}})
12 | end
13 |
14 | @difficulty %{
15 | "easy" => {10, 8, 10, 3 * 60},
16 | "medium" => {18, 14, 40, 10 * 60},
17 | "hard" => {24, 20, 99, 30 * 60},
18 | "extreme" => {30, 22, 145, 45 * 60}
19 | }
20 |
21 | def create_session(%{
22 | "slug" => slug,
23 | "difficulty" => difficulty,
24 | "visibility" => visibility
25 | }) do
26 | {width, height, mine_count, time_limit} = Map.fetch!(@difficulty, difficulty)
27 |
28 | slug =
29 | if slug in [nil, ""] do
30 | Base.encode16(:crypto.strong_rand_bytes(4))
31 | else
32 | slug
33 | end
34 |
35 | visibility = String.to_existing_atom(visibility)
36 |
37 | game =
38 | DynamicSupervisor.start_child(
39 | GameSupervisor,
40 | {MineSweeper.GameServer,
41 | slug: slug,
42 | width: width,
43 | height: height,
44 | mine_count: mine_count,
45 | visibility: visibility,
46 | time_limit: time_limit}
47 | )
48 |
49 | with {:ok, _} <- game do
50 | {:ok, slug}
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/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 | # Configures the endpoint
11 | config :mine_sweeper, MineSweeperWeb.Endpoint,
12 | url: [host: "localhost"],
13 | render_errors: [view: MineSweeperWeb.ErrorView, accepts: ~w(html json), layout: false],
14 | pubsub_server: MineSweeper.PubSub,
15 | live_view: [signing_salt: "OxR5VgnU"]
16 |
17 | config :tailwind,
18 | version: "3.0.12",
19 | default: [
20 | args: ~w(
21 | --config=tailwind.config.js
22 | --input=css/app.css
23 | --output=../priv/static/assets/app.css
24 | ),
25 | cd: Path.expand("../assets", __DIR__)
26 | ]
27 |
28 | config :esbuild,
29 | version: "0.14.10",
30 | default: [
31 | args:
32 | ~w(js/app.js --bundle --target=es2020 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
33 | cd: Path.expand("../assets", __DIR__),
34 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
35 | ]
36 |
37 | # Configures Elixir's Logger
38 | config :logger, :console,
39 | format: "$time $metadata[$level] $message\n",
40 | metadata: [:request_id]
41 |
42 | # Use Jason for JSON parsing in Phoenix
43 | config :phoenix, :json_library, Jason
44 |
45 | # Import environment specific config. This must remain at the bottom
46 | # of this file so it overrides the configuration defined above.
47 | import_config "#{config_env()}.exs"
48 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :mine_sweeper
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: "_mine_sweeper_key",
10 | signing_salt: "g55evaOI"
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: :mine_sweeper,
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 | end
32 |
33 | plug Phoenix.LiveDashboard.RequestLogger,
34 | param_key: "request_logger",
35 | cookie_key: "request_logger"
36 |
37 | plug Plug.RequestId
38 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
39 |
40 | plug Plug.Parsers,
41 | parsers: [:urlencoded, :multipart, :json],
42 | pass: ["*/*"],
43 | json_decoder: Phoenix.json_library()
44 |
45 | plug Plug.MethodOverride
46 | plug Plug.Head
47 | plug Plug.Session, @session_options
48 | plug MineSweeperWeb.Router
49 | end
50 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.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(MineSweeperWeb.Gettext, "errors", msg, msg, count, opts)
43 | else
44 | Gettext.dgettext(MineSweeperWeb.Gettext, "errors", msg, opts)
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/live/session_live/show.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.SessionLive.Show do
2 | use MineSweeperWeb, :live_view
3 |
4 | alias MineSweeper.{Game, GameServer}
5 |
6 | @impl true
7 | def mount(_params, _session, socket) do
8 | {:ok, socket |> assign(:ending, nil)}
9 | end
10 |
11 | @impl true
12 | def handle_params(%{"id" => slug}, _, socket) do
13 | session = Game.get_session!(slug)
14 |
15 | Phoenix.PubSub.subscribe(MineSweeper.PubSub, slug)
16 |
17 | {{width, height}, time, {count, total}} = GameServer.info(session)
18 | time_limit = GameServer.time_limit(session)
19 |
20 | {:noreply,
21 | socket
22 | |> assign(:session, session)
23 | |> assign(:width, width)
24 | |> assign(:height, height)
25 | |> assign(:time, Time.from_seconds_after_midnight(time))
26 | |> assign(:count, count)
27 | |> assign(:total, total)
28 | |> assign(:slug, slug)
29 | |> assign(:page_title, slug)
30 | |> assign(:buster, %{})
31 | |> assign(:time_limit, Time.from_seconds_after_midnight(time_limit))}
32 | end
33 |
34 | @impl true
35 | def handle_info({:update, coords}, socket) do
36 | {:noreply, assign(socket, :buster, Map.update(socket.assigns.buster, coords, 1, &(&1 + 1)))}
37 | end
38 |
39 | @impl true
40 | def handle_info({:mark_count, count}, socket) do
41 | {:noreply, assign(socket, :count, count)}
42 | end
43 |
44 | @impl true
45 | def handle_info({:tick, time}, socket) do
46 | {:noreply, assign(socket, :time, Time.from_seconds_after_midnight(time))}
47 | end
48 |
49 | @impl true
50 | def handle_info(:win, socket) do
51 | {:noreply, assign(socket, :ending, :win)}
52 | end
53 |
54 | @impl true
55 | def handle_info(:lose, socket) do
56 | {:noreply, assign(socket, :ending, :lose)}
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule MineSweeper.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :mine_sweeper,
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 | def application do
18 | [
19 | mod: {MineSweeper.Application, []},
20 | extra_applications: [:logger, :runtime_tools]
21 | ]
22 | end
23 |
24 | defp elixirc_paths(:test), do: ["lib", "test/support"]
25 | defp elixirc_paths(_), do: ["lib"]
26 |
27 | defp deps do
28 | [
29 | {:phoenix, "~> 1.6.6"},
30 | {:phoenix_ecto, "~> 4.4"},
31 | {:ecto_sql, "~> 3.6"},
32 | {:phoenix_html, "~> 3.0"},
33 | {:phoenix_live_reload, "~> 1.2", only: :dev},
34 | {:phoenix_live_view, "~> 0.17.5"},
35 | {:floki, ">= 0.30.0", only: :test},
36 | {:phoenix_live_dashboard, "~> 0.6"},
37 | {:esbuild, "~> 0.3", runtime: Mix.env() == :dev},
38 | {:tailwind, "~> 0.1", runtime: Mix.env() == :dev},
39 | {:telemetry_metrics, "~> 0.6"},
40 | {:telemetry_poller, "~> 1.0"},
41 | {:gettext, "~> 0.18"},
42 | {:jason, "~> 1.2"},
43 | {:plug_cowboy, "~> 2.5"}
44 | ]
45 | end
46 |
47 | defp aliases do
48 | [
49 | setup: ["deps.get", "ecto.setup"],
50 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
51 | "ecto.reset": ["ecto.drop", "ecto.setup"],
52 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
53 | "assets.deploy": [
54 | "tailwind default --minify",
55 | "esbuild default --minify --target=es2020",
56 | "phx.digest"
57 | ]
58 | ]
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/live/session_live/form_component.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
<%= @title %>
3 |
4 |
55 |
56 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/live/live_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.LiveHelpers do
2 | import Phoenix.LiveView
3 | import Phoenix.LiveView.Helpers
4 |
5 | alias Phoenix.LiveView.JS
6 |
7 | @doc """
8 | Renders a live component inside a modal.
9 |
10 | The rendered modal receives a `:return_to` option to properly update
11 | the URL when the modal is closed.
12 |
13 | ## Examples
14 |
15 | <.modal return_to={Routes.session_index_path(@socket, :index)}>
16 | <.live_component
17 | module={MineSweeperWeb.SessionLive.FormComponent}
18 | id={@session.id || :new}
19 | title={@page_title}
20 | action={@live_action}
21 | return_to={Routes.session_index_path(@socket, :index)}
22 | session: @session
23 | />
24 |
25 | """
26 | def modal(assigns) do
27 | assigns = assign_new(assigns, :return_to, fn -> nil end)
28 |
29 | ~H"""
30 |
31 |
38 | <%= if @return_to do %>
39 | <%= live_patch "❌",
40 | to: @return_to,
41 | id: "close",
42 | class: "phx-modal-close",
43 | phx_click: hide_modal()
44 | %>
45 | <% else %>
46 |
❌
47 | <% end %>
48 |
49 | <%= render_slot(@inner_block) %>
50 |
51 |
52 | """
53 | end
54 |
55 | defp hide_modal(js \\ %JS{}) do
56 | js
57 | |> JS.hide(to: "#modal", transition: "fade-out")
58 | |> JS.hide(to: "#modal-content", transition: "fade-out-scale")
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/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 :mine_sweeper, MineSweeperWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
13 |
14 | # Do not print debug messages in production
15 | config :logger, level: :info
16 |
17 | # ## SSL Support
18 | #
19 | # To get SSL working, you will need to add the `https` key
20 | # to the previous section and set your `:url` port to 443:
21 | #
22 | # config :mine_sweeper, MineSweeperWeb.Endpoint,
23 | # ...,
24 | # url: [host: "example.com", port: 443],
25 | # https: [
26 | # ...,
27 | # port: 443,
28 | # cipher_suite: :strong,
29 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
30 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
31 | # ]
32 | #
33 | # The `cipher_suite` is set to `:strong` to support only the
34 | # latest and more secure SSL ciphers. This means old browsers
35 | # and clients may not be supported. You can set it to
36 | # `:compatible` for wider support.
37 | #
38 | # `:keyfile` and `:certfile` expect an absolute path to the key
39 | # and cert in disk or a relative path inside priv, for example
40 | # "priv/ssl/server.key". For all supported SSL configuration
41 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
42 | #
43 | # We also recommend setting `force_ssl` in your endpoint, ensuring
44 | # no data is ever sent via http, always redirecting to https:
45 | #
46 | # config :mine_sweeper, MineSweeperWeb.Endpoint,
47 | # force_ssl: [hsts: true]
48 | #
49 | # Check `Plug.SSL` for all available options in `force_ssl`.
50 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/templates/layout/root.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | <%= csrf_meta_tag() %>
12 | <%= live_title_tag "MineSweeper", suffix: " · Liveview" %>
13 |
14 |
15 |
16 |
17 |
18 |
19 | <%= @inner_content %>
20 |
21 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/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 :mine_sweeper, MineSweeperWeb.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: {127, 0, 0, 1}, port: 4000],
13 | check_origin: false,
14 | code_reloader: true,
15 | debug_errors: true,
16 | secret_key_base: "7no23vf09BSAtqhGXS9mXdvc8KQTVnVYyNOCFAqqgvY6+85MmggJyomzxKIaWnkg",
17 | watchers: [
18 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
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 :mine_sweeper, MineSweeperWeb.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/mine_sweeper_web/(live|views)/.*(ex)$",
53 | ~r"lib/mine_sweeper_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 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.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 | # Database Metrics
34 | summary("mine_sweeper.repo.query.total_time",
35 | unit: {:native, :millisecond},
36 | description: "The sum of the other measurements"
37 | ),
38 | summary("mine_sweeper.repo.query.decode_time",
39 | unit: {:native, :millisecond},
40 | description: "The time spent decoding the data received from the database"
41 | ),
42 | summary("mine_sweeper.repo.query.query_time",
43 | unit: {:native, :millisecond},
44 | description: "The time spent executing the query"
45 | ),
46 | summary("mine_sweeper.repo.query.queue_time",
47 | unit: {:native, :millisecond},
48 | description: "The time spent waiting for a database connection"
49 | ),
50 | summary("mine_sweeper.repo.query.idle_time",
51 | unit: {:native, :millisecond},
52 | description:
53 | "The time the connection spent waiting before being checked out for the query"
54 | ),
55 |
56 | # VM Metrics
57 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
58 | summary("vm.total_run_queue_lengths.total"),
59 | summary("vm.total_run_queue_lengths.cpu"),
60 | summary("vm.total_run_queue_lengths.io")
61 | ]
62 | end
63 |
64 | defp periodic_measurements do
65 | [
66 | # A module, function and arguments to be invoked periodically.
67 | # This function must call :telemetry.execute/3 and a metric must be added above.
68 | # {MineSweeperWeb, :count_users, []}
69 | ]
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/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.13.1-erlang-24.2-debian-bullseye-20210902-slim
14 | #
15 | ARG BUILDER_IMAGE="hexpm/elixir:1.13.1-erlang-24.2-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
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 do deps.compile, tailwind.install, esbuild.install
44 |
45 | COPY priv priv
46 | COPY assets assets
47 | COPY lib lib
48 |
49 | RUN mix do assets.deploy, compile
50 |
51 | COPY config/runtime.exs config/
52 | COPY rel rel
53 |
54 | RUN mix release
55 |
56 | # start a new build stage so that the final image will only contain
57 | # the compiled release and other runtime necessities
58 | FROM ${RUNNER_IMAGE}
59 |
60 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
61 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
62 |
63 | # Set the locale
64 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
65 |
66 | ENV LANG en_US.UTF-8
67 | ENV LANGUAGE en_US:en
68 | ENV LC_ALL en_US.UTF-8
69 |
70 | WORKDIR "/app"
71 | RUN chown nobody /app
72 |
73 | # Only copy the final release from the build stage
74 | COPY --from=builder --chown=nobody:root /app/_build/prod/rel/mine_sweeper ./
75 |
76 | USER nobody
77 |
78 | CMD ["/app/bin/server"]
79 |
80 | # Appended by flyctl
81 | ENV ECTO_IPV6 true
82 | ENV ERL_AFLAGS "-proto_dist inet6_tcp"
83 |
--------------------------------------------------------------------------------
/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/mine_sweeper_web/live/session_live/cell_component.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb.SessionLive.CellComponent do
2 | use MineSweeperWeb, :live_component
3 |
4 | alias MineSweeper.CellServer
5 |
6 | @impl true
7 | def update(%{version: version}, %{assigns: %{version: version}} = socket) do
8 | {:ok, socket}
9 | end
10 |
11 | @impl true
12 | def update(%{slug: slug, coords: coords, version: version}, socket) do
13 | cell = CellServer.get(CellServer.via(slug, coords))
14 |
15 | {:ok,
16 | socket
17 | |> assign(:slug, slug)
18 | |> assign(:coords, coords)
19 | |> assign(:row, elem(coords, 0))
20 | |> assign(:col, elem(coords, 1))
21 | |> assign(:version, version)
22 | |> assign(:cell, cell)}
23 | end
24 |
25 | @impl true
26 | def handle_event("reveal", _payload, %{assigns: %{slug: slug, coords: coords}} = socket) do
27 | cell = CellServer.reveal(CellServer.via(slug, coords))
28 | {:noreply, socket |> assign(:cell, cell)}
29 | end
30 |
31 | @impl true
32 | def handle_event("mark", _payload, %{assigns: %{slug: slug, coords: coords}} = socket) do
33 | cell = CellServer.mark(CellServer.via(slug, coords))
34 | {:noreply, socket |> assign(:cell, cell)}
35 | end
36 |
37 | @impl true
38 | def handle_event(
39 | "detect",
40 | _payload,
41 | %{assigns: %{cell: %{revealed?: true, value: value}, slug: slug, coords: coords}} = socket
42 | )
43 | when is_integer(value) and value > 0 do
44 | CellServer.detect(CellServer.via(slug, coords))
45 | {:noreply, push_event(socket, "detect", %{row: elem(coords, 0), col: elem(coords, 1)})}
46 | end
47 |
48 | @impl true
49 | def handle_event("detect", _payload, socket) do
50 | {:noreply, socket}
51 | end
52 |
53 | def show(%{revealed?: true, value: :mine}), do: "💣"
54 | def show(%{revealed?: true, value: 0}), do: " "
55 | def show(%{revealed?: true, value: v}), do: v
56 | def show(%{revealed?: false, marked?: true}), do: "🚩"
57 | def show(%{revealed?: false}), do: " "
58 |
59 | @color List.to_tuple(~w(
60 | text-current
61 | text-blue-600
62 | text-green-600
63 | text-red-600
64 | text-purple-600
65 | text-red-900
66 | text-teal-400
67 | text-black-600
68 | text-gray-600
69 | ))
70 | def class(%{revealed?: revealed?, marked?: marked?, opaque?: opaque?, value: value}) do
71 | [
72 | revealed? && "revealed cursor-default",
73 | cond do
74 | marked? -> "bg-yellow-200"
75 | !revealed? -> "bg-gray-200"
76 | revealed? && value == :mine -> "bg-red-200"
77 | revealed? && value == 0 -> "bg-gray-100"
78 | revealed? -> "bg-blue-200"
79 | end,
80 | opaque? && "blur-sm",
81 | is_integer(value) && elem(@color, value)
82 | ]
83 | |> Enum.filter(& &1)
84 | |> Enum.join(" ")
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/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 |
10 | # Start the phoenix server if environment is set and running in a release
11 | if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do
12 | config :mine_sweeper, MineSweeperWeb.Endpoint, server: true
13 | end
14 |
15 | if config_env() == :prod do
16 | # The secret key base is used to sign/encrypt cookies and other secrets.
17 | # A default value is used in config/dev.exs and config/test.exs but you
18 | # want to use a different value for prod and you most likely don't want
19 | # to check this value into version control, so we use an environment
20 | # variable instead.
21 | secret_key_base =
22 | System.get_env("SECRET_KEY_BASE") ||
23 | raise """
24 | environment variable SECRET_KEY_BASE is missing.
25 | You can generate one by calling: mix phx.gen.secret
26 | """
27 |
28 | host = System.get_env("PHX_HOST") || "example.com"
29 | port = String.to_integer(System.get_env("PORT") || "4000")
30 |
31 | config :mine_sweeper, MineSweeperWeb.Endpoint,
32 | url: [host: host, port: 443],
33 | http: [
34 | # Enable IPv6 and bind on all interfaces.
35 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
36 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
37 | # for details about using IPv6 vs IPv4 and loopback vs public addresses.
38 | ip: {0, 0, 0, 0, 0, 0, 0, 0},
39 | port: port
40 | ],
41 | secret_key_base: secret_key_base
42 |
43 | # ## Using releases
44 | #
45 | # If you are doing OTP releases, you need to instruct Phoenix
46 | # to start each relevant endpoint:
47 | #
48 | # config :mine_sweeper, MineSweeperWeb.Endpoint, server: true
49 | #
50 | # Then you can assemble a release by calling `mix release`.
51 | # See `mix help release` for more information.
52 |
53 | # ## Configuring the mailer
54 | #
55 | # In production you need to configure the mailer to use a different adapter.
56 | # Also, you may need to configure the Swoosh API client of your choice if you
57 | # are not using SMTP. Here is an example of the configuration:
58 | #
59 | # config :mine_sweeper, MineSweeper.Mailer,
60 | # adapter: Swoosh.Adapters.Mailgun,
61 | # api_key: System.get_env("MAILGUN_API_KEY"),
62 | # domain: System.get_env("MAILGUN_DOMAIN")
63 | #
64 | # For this example you need include a HTTP client required by Swoosh API client.
65 | # Swoosh supports Hackney and Finch out of the box:
66 | #
67 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney
68 | #
69 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
70 | end
71 |
--------------------------------------------------------------------------------
/lib/mine_sweeper_web.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeperWeb 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 MineSweeperWeb, :controller
9 | use MineSweeperWeb, :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: MineSweeperWeb
23 |
24 | import Plug.Conn
25 | import MineSweeperWeb.Gettext
26 | alias MineSweeperWeb.Router.Helpers, as: Routes
27 | end
28 | end
29 |
30 | def view do
31 | quote do
32 | use Phoenix.View,
33 | root: "lib/mine_sweeper_web/templates",
34 | namespace: MineSweeperWeb
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: {MineSweeperWeb.LayoutView, "live.html"}
49 |
50 | unquote(view_helpers())
51 | end
52 | end
53 |
54 | def live_component do
55 | quote do
56 | use Phoenix.LiveComponent
57 |
58 | unquote(view_helpers())
59 | end
60 | end
61 |
62 | def component do
63 | quote do
64 | use Phoenix.Component
65 |
66 | unquote(view_helpers())
67 | end
68 | end
69 |
70 | def router do
71 | quote do
72 | use Phoenix.Router
73 |
74 | import Plug.Conn
75 | import Phoenix.Controller
76 | import Phoenix.LiveView.Router
77 | end
78 | end
79 |
80 | def channel do
81 | quote do
82 | use Phoenix.Channel
83 | import MineSweeperWeb.Gettext
84 | end
85 | end
86 |
87 | defp view_helpers do
88 | quote do
89 | # Use all HTML functionality (forms, tags, etc)
90 | use Phoenix.HTML
91 |
92 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
93 | import Phoenix.LiveView.Helpers
94 | import MineSweeperWeb.LiveHelpers
95 |
96 | # Import basic rendering functionality (render, render_layout, etc)
97 | import Phoenix.View
98 |
99 | import MineSweeperWeb.ErrorHelpers
100 | import MineSweeperWeb.Gettext
101 | alias MineSweeperWeb.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel`
2 | // to get started and then uncomment the line below.
3 | // import "./user_socket.js"
4 |
5 | // You can include dependencies in two ways.
6 | //
7 | // The simplest option is to put them in assets/vendor and
8 | // import them using relative paths:
9 | //
10 | // import "../vendor/some-package.js"
11 | //
12 | // Alternatively, you can `npm install some-package --prefix assets` and import
13 | // them using a path starting with the package name:
14 | //
15 | // import "some-package"
16 | //
17 |
18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
19 | import 'phoenix_html';
20 | // Establish Phoenix Socket and LiveView configuration.
21 | import {Socket} from 'phoenix';
22 | import {LiveSocket} from 'phoenix_live_view';
23 | import topbar from '../vendor/topbar';
24 |
25 | const NoContextMenu = el => {
26 | el.addEventListener('contextmenu', e => {
27 | e.preventDefault();
28 | e.stopImmediatePropagation();
29 | e.cancelBubble = true;
30 | return false;
31 | });
32 | };
33 |
34 | const hooks = {};
35 |
36 | hooks.CellButton = {
37 | mounted() {
38 | NoContextMenu(this.el);
39 |
40 | this.el.addEventListener('mouseup', e => {
41 | if (e.button == 0) {
42 | this.pushEventTo(e.target.attributes.getNamedItem('phx-target').value, 'reveal');
43 | }
44 | });
45 | this.el.addEventListener('mousedown', e => {
46 | if (e.buttons == 2) {
47 | this.pushEventTo(e.target.attributes.getNamedItem('phx-target').value, 'mark');
48 | }
49 | if (e.buttons == 3) {
50 | this.pushEventTo(e.target.attributes.getNamedItem('phx-target').value, 'detect');
51 | }
52 | });
53 |
54 | this.row = parseInt(this.el.dataset['row']);
55 | this.col = parseInt(this.el.dataset['col']);
56 |
57 | const isNeighbour = e =>
58 | [-1, 0, 1].map(d => d + this.row).includes(e.row) &&
59 | [-1, 0, 1].map(d => d + this.col).includes(e.col) &&
60 | (e.row != this.row || e.col != this.col);
61 |
62 | this.handleEvent('detect', e => {
63 | if (isNeighbour(e)) {
64 | this.el.classList.add('shake');
65 | setTimeout(() => {
66 | this.el.classList.remove('shake');
67 | }, 200);
68 | }
69 | });
70 | },
71 | };
72 |
73 | hooks.MineField = {
74 | mounted() {
75 | NoContextMenu(this.el);
76 | },
77 | };
78 |
79 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content');
80 | let liveSocket = new LiveSocket('/live', Socket, {hooks, params: {_csrf_token: csrfToken}});
81 |
82 | // Show progress bar on live navigation and form submits
83 | topbar.config({barColors: {0: '#29d'}, shadowColor: 'rgba(0, 0, 0, .3)'});
84 | window.addEventListener('phx:page-loading-start', () => topbar.show());
85 | window.addEventListener('phx:page-loading-stop', () => topbar.hide());
86 |
87 | // connect if there are any LiveViews on the page
88 | liveSocket.connect();
89 |
90 | // expose liveSocket on window for web console debug logs and latency simulation:
91 | // >> liveSocket.enableDebug()
92 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
93 | // >> liveSocket.disableLatencySim()
94 | window.liveSocket = liveSocket;
95 |
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind utilities;
3 |
4 | /* Alerts and form errors used by phx.new */
5 | .alert {
6 | padding: 15px;
7 | margin-top: 1em;
8 | border: 1px solid transparent;
9 | border-radius: 4px;
10 | }
11 | .alert-info {
12 | color: #31708f;
13 | background-color: #d9edf7;
14 | border-color: #bce8f1;
15 | }
16 | .alert-warning {
17 | color: #8a6d3b;
18 | background-color: #fcf8e3;
19 | border-color: #faebcc;
20 | }
21 | .alert-danger {
22 | color: #a94442;
23 | background-color: #f2dede;
24 | border-color: #ebccd1;
25 | }
26 | .alert p {
27 | margin-bottom: 0;
28 | }
29 | .alert:empty {
30 | display: none;
31 | }
32 | .invalid-feedback {
33 | color: #a94442;
34 | display: block;
35 | margin: -1rem 0 2rem;
36 | }
37 |
38 | /* LiveView specific classes for your customization */
39 | .phx-no-feedback.invalid-feedback,
40 | .phx-no-feedback .invalid-feedback {
41 | display: none;
42 | }
43 |
44 | .phx-click-loading {
45 | opacity: 0.5;
46 | transition: opacity 1s ease-out;
47 | }
48 |
49 | .phx-loading {
50 | cursor: wait;
51 | }
52 |
53 | .phx-modal {
54 | opacity: 1 !important;
55 | position: fixed;
56 | z-index: 1;
57 | left: 0;
58 | top: 0;
59 | width: 100%;
60 | height: 100%;
61 | overflow: auto;
62 | background-color: rgba(0, 0, 0, 0.4);
63 | }
64 |
65 | .phx-modal-content {
66 | background-color: #fefefe;
67 | margin: 15vh auto;
68 | padding: 20px;
69 | border: 1px solid #888;
70 | width: min(90%, 600px);
71 | border-radius: 4px;
72 | }
73 |
74 | .phx-modal-close {
75 | color: #aaa;
76 | float: right;
77 | font-size: 28px;
78 | font-weight: bold;
79 | }
80 |
81 | .phx-modal-close:hover,
82 | .phx-modal-close:focus {
83 | color: black;
84 | text-decoration: none;
85 | cursor: pointer;
86 | }
87 |
88 | .fade-in-scale {
89 | animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys;
90 | }
91 |
92 | .fade-out-scale {
93 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys;
94 | }
95 |
96 | .fade-in {
97 | animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
98 | }
99 | .fade-out {
100 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys;
101 | }
102 |
103 | @keyframes fade-in-scale-keys {
104 | 0% {
105 | scale: 0.95;
106 | opacity: 0;
107 | }
108 | 100% {
109 | scale: 1;
110 | opacity: 1;
111 | }
112 | }
113 |
114 | @keyframes fade-out-scale-keys {
115 | 0% {
116 | scale: 1;
117 | opacity: 1;
118 | }
119 | 100% {
120 | scale: 0.95;
121 | opacity: 0;
122 | }
123 | }
124 |
125 | @keyframes fade-in-keys {
126 | 0% {
127 | opacity: 0;
128 | }
129 | 100% {
130 | opacity: 1;
131 | }
132 | }
133 |
134 | @keyframes fade-out-keys {
135 | 0% {
136 | opacity: 1;
137 | }
138 | 100% {
139 | opacity: 0;
140 | }
141 | }
142 |
143 | h2 {
144 | font-size: 1.5em;
145 | font-weight: bold;
146 | color: #533566;
147 | margin: 1em 0;
148 | }
149 |
150 | .end button {
151 | pointer-events: none;
152 | }
153 |
154 | @layer base {
155 | a {
156 | @apply text-red-400;
157 | }
158 |
159 | .phx-modal button {
160 | @apply bg-[#533566] text-white p-0 px-2 pb-1 rounded;
161 | }
162 |
163 | .phx-modal label {
164 | @apply flex items-center gap-2;
165 | }
166 | }
167 |
168 | .shake:not(.revealed) {
169 | animation: 0.2s ease-in-out 0s normal forwards 1 shake;
170 | }
171 |
172 | @keyframes shake {
173 | 0% {
174 | transform: rotate(0);
175 | }
176 |
177 | 25% {
178 | transform: rotate(15deg);
179 | }
180 |
181 | 50% {
182 | transform: rotate(0);
183 | }
184 |
185 | 75% {
186 | transform: rotate(-15deg);
187 | }
188 |
189 | 100% {
190 | transform: rotate(0);
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/lib/mine_sweeper/cell_server.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeper.CellServer do
2 | use GenServer, restart: :temporary
3 |
4 | alias MineSweeper.GameServer
5 | require Logger
6 |
7 | def via(slug, coords) do
8 | {:via, Registry, {GameRegistry, {:cell, slug, coords}}}
9 | end
10 |
11 | def start_link({slug, {coords, _}} = data) do
12 | GenServer.start_link(__MODULE__, data, name: via(slug, coords))
13 | end
14 |
15 | def get(cell) do
16 | GenServer.call(cell, :get)
17 | end
18 |
19 | def reveal(cell) do
20 | GenServer.call(cell, :reveal)
21 | end
22 |
23 | defp reveal(cell, :chain) do
24 | GenServer.cast(cell, :chain)
25 | end
26 |
27 | defp reveal(cell, :death) do
28 | GenServer.cast(cell, :death)
29 | end
30 |
31 | def mark(cell) do
32 | GenServer.call(cell, :mark)
33 | end
34 |
35 | def detect(cell) do
36 | GenServer.call(cell, :detect)
37 | end
38 |
39 | @impl true
40 | def init({slug, {coords, data}}) do
41 | {:ok, %{slug: slug, coords: coords, data: Map.put(data, :opaque?, false)}}
42 | end
43 |
44 | @impl true
45 | def handle_call(:get, _from, state) do
46 | {:reply, state.data, state}
47 | end
48 |
49 | @impl true
50 | def handle_call(:mark, _from, %{data: %{revealed?: true}} = state) do
51 | {:reply, state.data, state}
52 | end
53 |
54 | @impl true
55 | def handle_call(:mark, _from, state) do
56 | broadcast_update(state)
57 |
58 | if state.data.marked? do
59 | MineSweeper.GameServer.mark(GameServer.via(state.slug), -1)
60 | else
61 | MineSweeper.GameServer.mark(GameServer.via(state.slug), 1)
62 | end
63 |
64 | data = %{state.data | marked?: !state.data.marked?}
65 | {:reply, data, %{state | data: data}}
66 | end
67 |
68 | @impl true
69 | def handle_call(:reveal, _from, %{data: %{revealed?: true}} = state) do
70 | {:reply, state.data, state}
71 | end
72 |
73 | @impl true
74 | def handle_call(:reveal, _from, state) do
75 | data = do_reveal(state, :chain)
76 |
77 | if state.data.marked? do
78 | MineSweeper.GameServer.mark(GameServer.via(state.slug), -1)
79 | end
80 |
81 | {:reply, data, %{state | data: data}}
82 | end
83 |
84 | @impl true
85 | def handle_call(:detect, _from, %{data: %{value: v}} = state) when is_integer(v) and v > 0 do
86 | {row, col} = state.coords
87 |
88 | for dr <- -1..1//1, dc <- -1..1//1, {dr, dc} != {0, 0} do
89 | {row + dr, col + dc}
90 | end
91 | |> Enum.map(
92 | &Task.async(fn ->
93 | GenServer.cast(via(state.slug, &1), {:marked?, self()})
94 |
95 | receive do
96 | {:marked?, marked?} ->
97 | marked?
98 | after
99 | 100 -> false
100 | end
101 | end)
102 | )
103 | |> Task.yield_many(20)
104 | |> Enum.map(fn {task, res} ->
105 | res || Task.shutdown(task, :brutal_kill)
106 | end)
107 | |> Enum.count_until(
108 | fn
109 | {:ok, true} -> true
110 | _ -> false
111 | end,
112 | state.data.value
113 | )
114 | |> then(fn count ->
115 | if count >= state.data.value do
116 | for dr <- -1..1//1, dc <- -1..1//1, {dr, dc} != {0, 0} do
117 | GenServer.cast(via(state.slug, {row + dr, col + dc}), :chain)
118 | end
119 | end
120 | end)
121 |
122 | {:reply, :ok, state}
123 | end
124 |
125 | @impl true
126 | def handle_call(:detect, _from, state) do
127 | {:reply, :ok, state}
128 | end
129 |
130 | @impl true
131 | def handle_cast({:marked?, reply_to}, %{data: %{marked?: marked?}} = state) do
132 | send(reply_to, {:marked?, marked?})
133 | {:noreply, state}
134 | end
135 |
136 | @impl true
137 | def handle_cast(:chain, %{data: %{marked?: true}} = state) do
138 | {:noreply, state}
139 | end
140 |
141 | @impl true
142 | def handle_cast(type, %{data: %{revealed?: true}} = state) when type in [:chain, :death] do
143 | {:noreply, state}
144 | end
145 |
146 | @impl true
147 | def handle_cast(type, state) when type in [:chain, :death] do
148 | data = do_reveal(state, type)
149 | {:noreply, %{state | data: data}}
150 | end
151 |
152 | defp do_reveal(state, type) do
153 | broadcast_update(state)
154 | MineSweeper.GameServer.reveal(GameServer.via(state.slug))
155 |
156 | case {type, state.data.value} do
157 | {:chain, :mine} ->
158 | Task.start(fn ->
159 | MineSweeper.GameServer.explode(GameServer.via(state.slug))
160 | end)
161 |
162 | {:chain, 0} ->
163 | Task.start(fn ->
164 | Process.sleep(30)
165 | chain(state, :chain)
166 | end)
167 |
168 | {:chain, _n} ->
169 | :ok
170 | end
171 |
172 | %{state.data | revealed?: true, marked?: false}
173 | end
174 |
175 | defp chain(state, type) do
176 | {row, col} = state.coords
177 |
178 | for dr <- -1..1//1, dc <- -1..1//1, {dr, dc} != {0, 0} do
179 | reveal(via(state.slug, {row + dr, col + dc}), type)
180 | end
181 | end
182 |
183 | defp broadcast_update(state) do
184 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, {:update, state.coords})
185 | end
186 | end
187 |
--------------------------------------------------------------------------------
/lib/mine_sweeper/game_server.ex:
--------------------------------------------------------------------------------
1 | defmodule MineSweeper.GameServer do
2 | use GenServer, restart: :temporary
3 |
4 | require Logger
5 |
6 | def via(slug) do
7 | {:via, Registry, {GameRegistry, {:game, slug}}}
8 | end
9 |
10 | def start_link(opts) do
11 | slug = Keyword.fetch!(opts, :slug)
12 | GenServer.start_link(__MODULE__, opts, name: via(slug))
13 | end
14 |
15 | def info(server) do
16 | GenServer.call(server, :info)
17 | end
18 |
19 | def time_limit(server) do
20 | GenServer.call(server, :time_limit)
21 | end
22 |
23 | def mark(server, diff) do
24 | GenServer.cast(server, {:mark, diff})
25 | end
26 |
27 | def reveal(server) do
28 | GenServer.cast(server, :reveal)
29 | end
30 |
31 | def explode(server) do
32 | GenServer.cast(server, :explode)
33 | end
34 |
35 | @impl true
36 | def init(opts) do
37 | {slug, opts} = Keyword.pop(opts, :slug)
38 | {visibility, opts} = Keyword.pop(opts, :visibility)
39 | {time_limit, opts} = Keyword.pop(opts, :time_limit)
40 |
41 | Registry.register(RealmRegistry, visibility, slug)
42 |
43 | {:ok,
44 | %{
45 | opts: opts,
46 | slug: slug,
47 | time: 0,
48 | mark_count: 0,
49 | reveal_count: 0,
50 | time_limit: time_limit,
51 | timer_ref: nil,
52 | cells_sup: nil,
53 | mines: nil
54 | }, {:continue, :init_game}}
55 | end
56 |
57 | @impl true
58 | def handle_continue(:init_game, state) do
59 | Logger.debug(init: state.slug, opts: state.opts)
60 |
61 | {:noreply,
62 | state
63 | |> setup_field()
64 | |> setup_timer()}
65 | end
66 |
67 | defp setup_field(state) do
68 | slug = state.slug
69 | width = Keyword.fetch!(state.opts, :width)
70 | height = Keyword.fetch!(state.opts, :height)
71 | mine_count = Keyword.fetch!(state.opts, :mine_count)
72 |
73 | field =
74 | for row <- 1..height, col <- 1..width do
75 | {{row, col}, %{revealed?: false, marked?: false, value: 0}}
76 | end
77 |
78 | {mines, cells} =
79 | field
80 | |> Enum.shuffle()
81 | |> Enum.split(mine_count)
82 |
83 | field =
84 | mines
85 | |> Enum.map(fn {coords, cell} -> {coords, %{cell | value: :mine, marked?: false}} end)
86 | |> Kernel.++(cells)
87 | |> Enum.into(%{})
88 |
89 | field =
90 | Enum.reduce(mines, field, fn {{row, col}, _}, field ->
91 | for dr <- -1..1//1,
92 | dc <- -1..1//1,
93 | coords = {row + dr, col + dc},
94 | is_map_key(field, coords),
95 | reduce: field do
96 | field ->
97 | Map.replace(
98 | field,
99 | coords,
100 | Map.update!(field[coords], :value, fn
101 | :mine -> :mine
102 | n -> n + 1
103 | end)
104 | )
105 | end
106 | end)
107 |
108 | {:ok, cells_sup} =
109 | DynamicSupervisor.start_child(GameSupervisor, {DynamicSupervisor, strategy: :one_for_one})
110 |
111 | for {coords, opts} <- field do
112 | DynamicSupervisor.start_child(cells_sup, {MineSweeper.CellServer, {slug, {coords, opts}}})
113 | end
114 |
115 | %{state | cells_sup: cells_sup, mines: Enum.map(mines, &elem(&1, 0))}
116 | end
117 |
118 | defp setup_timer(state) do
119 | {:ok, ref} = :timer.send_interval(1000, self(), :tick)
120 | %{state | timer_ref: ref}
121 | end
122 |
123 | @impl true
124 | def handle_call(:info, _from, %{opts: opts} = state) do
125 | {:reply,
126 | {
127 | {opts[:width], opts[:height]},
128 | state.time,
129 | {state.mark_count, opts[:mine_count]}
130 | }, state}
131 | end
132 |
133 | @impl true
134 | def handle_call(:time_limit, _from, state) do
135 | {:reply, state.time_limit, state}
136 | end
137 |
138 | @impl true
139 | def handle_cast({:mark, diff}, state) do
140 | mark_count = state.mark_count + diff
141 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, {:mark_count, mark_count})
142 | {:noreply, %{state | mark_count: mark_count}}
143 | end
144 |
145 | @impl true
146 | def handle_cast(:reveal, state) do
147 | reveal_count = state.reveal_count + 1
148 |
149 | if state.opts[:width] * state.opts[:height] - state.opts[:mine_count] == reveal_count do
150 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, :win)
151 | {:stop, :shutdown, %{state | reveal_count: reveal_count}}
152 | else
153 | {:noreply, %{state | reveal_count: reveal_count}}
154 | end
155 | end
156 |
157 | @impl true
158 | def handle_cast(:explode, state) do
159 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, :lose)
160 |
161 | state.mines
162 | |> Enum.with_index()
163 | |> Enum.each(fn {coords, i} ->
164 | Task.start(fn ->
165 | Process.sleep(100 * i)
166 | MineSweeper.CellServer.reveal(MineSweeper.CellServer.via(state.slug, coords))
167 | end)
168 | end)
169 |
170 | {:stop, :shutdown, state}
171 | end
172 |
173 | @impl true
174 | def handle_info(:tick, state) do
175 | time = state.time + 1
176 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, {:tick, time})
177 |
178 | if time < state.time_limit do
179 | {:noreply, %{state | time: time}}
180 | else
181 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, :lose)
182 | {:stop, :shutdown, %{state | time: time}}
183 | end
184 | end
185 |
186 | @impl true
187 | def terminate(:shutdown, state) do
188 | :timer.cancel(state.timer_ref)
189 | :timer.kill_after(15_000, state.cells_sup)
190 | end
191 | end
192 |
--------------------------------------------------------------------------------
/assets/vendor/topbar.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license MIT
3 | * topbar 1.0.0, 2021-01-06
4 | * https://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 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "castore": {:hex, :castore, "0.1.14", "3f6d7c7c1574c402fef29559d3f1a7389ba3524bc6a090a5e9e6abc3af65dcca", [:mix], [], "hexpm", "b34af542eadb727e6c8b37fdf73e18b2e02eb483a4ea0b52fd500bc23f052b7b"},
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 | "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"},
10 | "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"},
11 | "esbuild": {:hex, :esbuild, "0.4.0", "9f17db148aead4cf1e6e6a584214357287a93407b5fb51a031f122b61385d4c2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "b61e4e6b92ffe45e4ee4755a22de6211a67c67987dc02afb35a425a0add1d447"},
12 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
13 | "floki": {:hex, :floki, "0.32.0", "f915dc15258bc997d49be1f5ef7d3992f8834d6f5695270acad17b41f5bcc8e2", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "1c5a91cae1fd8931c26a4826b5e2372c284813904c8bacb468b5de39c7ececbd"},
14 | "gettext": {:hex, :gettext, "0.19.0", "6909d61b38bb33339558f128f8af5913d5d5fe304a770217bf352b1620fb7ec4", [:mix], [], "hexpm", "3f7a274f52ebda9bb6655dfeda3d6b0dc4537ae51ce41dcccc7f73ca7379ad5e"},
15 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
16 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
17 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"},
18 | "phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [: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", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"},
19 | "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"},
20 | "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
21 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.2", "0769470265eb13af01b5001b29cb935f4710d6adaa1ffc18417a570a337a2f0f", [: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 or ~> 2.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", "5bc6c6b38a2ca8b5020b442322fcee6afd5e641637a0b1fb059d4bd89bc58e7b"},
22 | "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"},
23 | "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"},
24 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
25 | "phoenix_view": {:hex, :phoenix_view, "1.1.0", "149f053830ec3c19a2a8a67c208885a26e4c2b92cc4a9d54e03b633d68ef9add", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "dd219f768b3d97a224ed11e8a83f4fd0f3bd490434d3950d7c51a2e597a762f1"},
26 | "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"},
27 | "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"},
28 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
29 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
30 | "tailwind": {:hex, :tailwind, "0.1.4", "be3c48382a47e9d43262eb169ac22f7d3b035d7e30a7b3ebd46de57eb8527dbf", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "cf648462dcbf006da3bdf95dc7dd87d2fb44d8bcc3bb59ab61a2410d0edb09bb"},
31 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"},
32 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
33 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
34 | }
35 |
--------------------------------------------------------------------------------