├── .env
├── README.md
├── app
├── .dockerignore
├── .formatter.exs
├── .gitignore
├── Dockerfile
├── README.md
├── config
│ ├── config.exs
│ ├── dev.exs
│ ├── prod.exs
│ ├── prod.secret.exs
│ └── test.exs
├── entrypoint.sh
├── lib
│ ├── match.ex
│ ├── match
│ │ ├── application.ex
│ │ ├── card.ex
│ │ ├── find_user.ex
│ │ ├── game.ex
│ │ ├── game_supervisor.ex
│ │ ├── generator.ex
│ │ ├── hash.ex
│ │ ├── logon.ex
│ │ ├── password.ex
│ │ ├── process.ex
│ │ ├── random.ex
│ │ ├── record.ex
│ │ ├── registry.ex
│ │ ├── repo.ex
│ │ ├── session.ex
│ │ ├── statistic.ex
│ │ ├── statistics.ex
│ │ ├── user.ex
│ │ └── users.ex
│ ├── match_web.ex
│ └── match_web
│ │ ├── authenticator.ex
│ │ ├── channels
│ │ ├── available_channel.ex
│ │ ├── presence.ex
│ │ └── user_socket.ex
│ │ ├── controllers
│ │ ├── logout_controller.ex
│ │ ├── page_controller.ex
│ │ ├── registration_controller.ex
│ │ └── session_controller.ex
│ │ ├── endpoint.ex
│ │ ├── gettext.ex
│ │ ├── live
│ │ └── game_live.ex
│ │ ├── router.ex
│ │ ├── templates
│ │ ├── layout
│ │ │ └── app.html.eex
│ │ ├── page
│ │ │ ├── new.html.eex
│ │ │ ├── private.html.eex
│ │ │ └── show.html.eex
│ │ ├── registration
│ │ │ └── new.html.eex
│ │ └── session
│ │ │ ├── index.html.eex
│ │ │ └── new.html.eex
│ │ └── views
│ │ ├── error_helpers.ex
│ │ ├── error_view.ex
│ │ ├── layout_view.ex
│ │ ├── page_view.ex
│ │ ├── registration_view.ex
│ │ └── session_view.ex
├── mix.exs
├── mix.lock
├── priv
│ ├── gettext
│ │ ├── en
│ │ │ └── LC_MESSAGES
│ │ │ │ └── errors.po
│ │ └── errors.pot
│ ├── repo
│ │ ├── migrations
│ │ │ ├── .formatter.exs
│ │ │ ├── 20190222211932_create_users.exs
│ │ │ └── 20190222214752_create_statistics.exs
│ │ └── seeds.exs
│ └── static
│ │ ├── css
│ │ └── app.css
│ │ ├── favicon.ico
│ │ ├── images
│ │ ├── card.jpg
│ │ ├── cards
│ │ │ ├── eight.png
│ │ │ ├── eleven.png
│ │ │ ├── fifteen.png
│ │ │ ├── five.png
│ │ │ ├── four.png
│ │ │ ├── fourteen.png
│ │ │ ├── nine.png
│ │ │ ├── one.png
│ │ │ ├── seven.png
│ │ │ ├── six.png
│ │ │ ├── ten.png
│ │ │ ├── thirteen.png
│ │ │ ├── three.png
│ │ │ ├── twelve.png
│ │ │ └── two.png
│ │ ├── elixir.png
│ │ ├── phoenix.png
│ │ ├── play.svg
│ │ └── profile.png
│ │ ├── js
│ │ ├── app.js
│ │ ├── live.js
│ │ └── phoenix.js
│ │ └── robots.txt
└── test
│ ├── match
│ ├── find_user_test.exs
│ ├── game_test.exs
│ ├── hash_test.exs
│ ├── logon_test.exs
│ ├── statistics_test.exs
│ └── users_test.exs
│ ├── match_web
│ ├── authenticator_test.exs
│ ├── controllers
│ │ ├── page_controller_test.exs
│ │ └── session_controller_test.exs
│ ├── live
│ │ └── game_live_test.exs
│ └── views
│ │ ├── error_view_test.exs
│ │ ├── layout_view_test.exs
│ │ └── page_view_test.exs
│ ├── support
│ ├── channel_case.ex
│ ├── conn_case.ex
│ └── data_case.ex
│ └── test_helper.exs
├── docker-compose.yml
└── nginx
├── Dockerfile
└── nginx.conf
/.env:
--------------------------------------------------------------------------------
1 | MIX_ENV=prod
2 | POSTGRES_USER=postgres
3 | POSTGRES_PASSWORD=postgres
4 | POSTGRES_HOST=postgres
5 | POSTGRES_DB=match_prod
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Phoenix powered "match game" to show off what I've learned writing Elixir the last few months
2 |
3 | ```bash
4 | git clone https://github.com/toranb/elixir-match.git example
5 | ```
6 |
7 | To run the Phoenix app with mix
8 |
9 | 1) install elixir
10 |
11 | ```bash
12 | brew install elixir
13 | ```
14 |
15 | 2) install postgres
16 |
17 | ```bash
18 | brew install postgres
19 | ```
20 |
21 | 3) install dependencies
22 |
23 | ```bash
24 | cd app
25 | mix deps.get
26 | ```
27 |
28 | 4) run ecto create/migrate
29 |
30 | ```bash
31 | cd app
32 | mix ecto.create
33 | mix ecto.migrate
34 | ```
35 |
36 | 5) start phoenix
37 |
38 | ```bash
39 | cd app
40 | iex -S mix phx.server
41 | ```
42 |
43 | 6) Use `elixir2019` as invite code in login screen.
44 |
45 | To run the app with docker
46 |
47 | 1) install docker
48 |
49 | https://docs.docker.com/docker-for-mac/
50 |
51 | 2) build and run the app with docker
52 |
53 | ```bash
54 | docker-compose build
55 | docker-compose up
56 | ```
57 |
--------------------------------------------------------------------------------
/app/.dockerignore:
--------------------------------------------------------------------------------
1 | /_build*
2 | /deps*
3 |
--------------------------------------------------------------------------------
/app/.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 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | match-*.tar
24 |
25 | # Since we are building assets from assets/,
26 | # we ignore priv/static. You may want to comment
27 | # this depending on your deployment strategy.
28 | # /priv/static/
29 |
30 | # Files matching config/*.secret.exs pattern contain sensitive
31 | # data and you should not commit them into version control.
32 | #
33 | # Alternatively, you may comment the line below and commit the
34 | # secrets files as long as you replace their contents by environment
35 | # variables.
36 | # /config/*.secret.exs
37 |
--------------------------------------------------------------------------------
/app/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM elixir:1.8.0
2 |
3 | RUN mix local.hex --force
4 | RUN mix local.rebar --force
5 |
6 | COPY . /example
7 | WORKDIR /example
8 |
9 | RUN apt-get update
10 | RUN apt-get install make gcc libc-dev
11 |
12 | RUN mix deps.get && mix deps.compile && mix compile
13 |
14 | RUN chmod +x entrypoint.sh
15 |
16 | EXPOSE 4000
17 |
--------------------------------------------------------------------------------
/app/README.md:
--------------------------------------------------------------------------------
1 | # Match
2 |
3 | To start your Phoenix server:
4 |
5 | * Install dependencies with `mix deps.get`
6 | * Create and migrate your database with `mix ecto.setup`
7 | * Start Phoenix endpoint with `mix phx.server`
8 |
9 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
10 |
11 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
12 |
13 | ## Learn more
14 |
15 | * Official website: http://www.phoenixframework.org/
16 | * Guides: https://hexdocs.pm/phoenix/overview.html
17 | * Docs: https://hexdocs.pm/phoenix
18 | * Mailing list: http://groups.google.com/group/phoenix-talk
19 | * Source: https://github.com/phoenixframework/phoenix
20 |
--------------------------------------------------------------------------------
/app/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | use Mix.Config
9 |
10 | config :match,
11 | ecto_repos: [Match.Repo]
12 |
13 | # Configures the endpoint
14 | config :match, MatchWeb.Endpoint,
15 | url: [host: "localhost"],
16 | secret_key_base: "8pvi2WEzD2m1NBsUNjT2zRwH8lizv3DNEnMU4/yfhZ3OMYW1n/u5K4Oj0soopgoG",
17 | render_errors: [view: MatchWeb.ErrorView, accepts: ~w(html json)],
18 | pubsub: [name: Match.PubSub, adapter: Phoenix.PubSub.PG2],
19 | live_view: [signing_salt: "w0g8oe6B8diY4As+2BqhDsykh8GHVzFr"]
20 |
21 | # Configures Elixir's Logger
22 | config :logger, :console,
23 | format: "$time $metadata[$level] $message\n",
24 | metadata: [:request_id]
25 |
26 | # Use Jason for JSON parsing in Phoenix
27 | config :phoenix, :json_library, Jason
28 |
29 | # LiveView
30 | config :phoenix, :template_engines, leex: Phoenix.LiveView.Engine
31 |
32 | # Import environment specific config. This must remain at the bottom
33 | # of this file so it overrides the configuration defined above.
34 | import_config "#{Mix.env()}.exs"
35 |
--------------------------------------------------------------------------------
/app/config/dev.exs:
--------------------------------------------------------------------------------
1 | use Mix.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 webpack to recompile .js and .css sources.
9 | config :match, MatchWeb.Endpoint,
10 | http: [port: 4000],
11 | debug_errors: true,
12 | code_reloader: true,
13 | check_origin: false,
14 | watchers: []
15 |
16 | config :match, MatchWeb.Endpoint,
17 | live_reload: [
18 | patterns: [
19 | ~r{lib/match_web/live/.*(ex)$}
20 | ]
21 | ]
22 |
23 | # ## SSL Support
24 | #
25 | # In order to use HTTPS in development, a self-signed
26 | # certificate can be generated by running the following
27 | # Mix task:
28 | #
29 | # mix phx.gen.cert
30 | #
31 | # Note that this task requires Erlang/OTP 20 or later.
32 | # Run `mix help phx.gen.cert` for more information.
33 | #
34 | # The `http:` config above can be replaced with:
35 | #
36 | # https: [
37 | # port: 4001,
38 | # cipher_suite: :strong,
39 | # keyfile: "priv/cert/selfsigned_key.pem",
40 | # certfile: "priv/cert/selfsigned.pem"
41 | # ],
42 | #
43 | # If desired, both `http:` and `https:` keys can be
44 | # configured to run both http and https servers on
45 | # different ports.
46 |
47 | # Watch static and templates for browser reloading.
48 | config :match, MatchWeb.Endpoint,
49 | live_reload: [
50 | patterns: [
51 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
52 | ~r{priv/gettext/.*(po)$},
53 | ~r{lib/match_web/views/.*(ex)$},
54 | ~r{lib/match_web/templates/.*(eex)$}
55 | ]
56 | ]
57 |
58 | # Do not include metadata nor timestamps in development logs
59 | config :logger, :console, format: "[$level] $message\n"
60 |
61 | # Set a higher stacktrace during development. Avoid configuring such
62 | # in production as building large stacktraces may be expensive.
63 | config :phoenix, :stacktrace_depth, 20
64 |
65 | # Initialize plugs at runtime for faster development compilation
66 | config :phoenix, :plug_init_mode, :runtime
67 |
68 | # Configure your database
69 | config :match, Match.Repo,
70 | username: "postgres",
71 | password: "postgres",
72 | database: "match_dev",
73 | hostname: "localhost",
74 | pool_size: 10
75 |
--------------------------------------------------------------------------------
/app/config/prod.exs:
--------------------------------------------------------------------------------
1 | use Mix.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 :match, MatchWeb.Endpoint,
13 | http: [:inet6, port: System.get_env("PORT") || 4000],
14 | url: [host: "localhost", port: 80],
15 | cache_static_manifest: "priv/static/cache_manifest.json"
16 |
17 | # Do not print debug messages in production
18 | config :logger, level: :info
19 |
20 | # ## SSL Support
21 | #
22 | # To get SSL working, you will need to add the `https` key
23 | # to the previous section and set your `:url` port to 443:
24 | #
25 | # config :match, MatchWeb.Endpoint,
26 | # ...
27 | # url: [host: "example.com", port: 443],
28 | # https: [
29 | # :inet6,
30 | # port: 443,
31 | # cipher_suite: :strong,
32 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
33 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
34 | # ]
35 | #
36 | # The `cipher_suite` is set to `:strong` to support only the
37 | # latest and more secure SSL ciphers. This means old browsers
38 | # and clients may not be supported. You can set it to
39 | # `:compatible` for wider support.
40 | #
41 | # `:keyfile` and `:certfile` expect an absolute path to the key
42 | # and cert in disk or a relative path inside priv, for example
43 | # "priv/ssl/server.key". For all supported SSL configuration
44 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
45 | #
46 | # We also recommend setting `force_ssl` in your endpoint, ensuring
47 | # no data is ever sent via http, always redirecting to https:
48 | #
49 | # config :match, MatchWeb.Endpoint,
50 | # force_ssl: [hsts: true]
51 | #
52 | # Check `Plug.SSL` for all available options in `force_ssl`.
53 |
54 | # ## Using releases (distillery)
55 | #
56 | # If you are doing OTP releases, you need to instruct Phoenix
57 | # to start the server for all endpoints:
58 | #
59 | # config :phoenix, :serve_endpoints, true
60 | #
61 | # Alternatively, you can configure exactly which server to
62 | # start per endpoint:
63 | #
64 | # config :match, MatchWeb.Endpoint, server: true
65 | #
66 | # Note you can't rely on `System.get_env/1` when using releases.
67 | # See the releases documentation accordingly.
68 |
69 | # Finally import the config/prod.secret.exs which should be versioned
70 | # separately.
71 | import_config "prod.secret.exs"
72 |
--------------------------------------------------------------------------------
/app/config/prod.secret.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # In this file, we keep production configuration that
4 | # you'll likely want to automate and keep away from
5 | # your version control system.
6 | #
7 | # You should document the content of this
8 | # file or create a script for recreating it, since it's
9 | # kept out of version control and might be hard to recover
10 | # or recreate for your teammates (or yourself later on).
11 | config :match, MatchWeb.Endpoint,
12 | secret_key_base: "TY34345435ADFADfadfasd6S8Aa2dsfadddd5324kiaSdfwe98de54Aidalhlhzl"
13 |
14 | # Configure your database
15 | config :match, Match.Repo,
16 | username: System.get_env("POSTGRES_USER"),
17 | password: System.get_env("POSTGRES_PASSWORD"),
18 | database: System.get_env("POSTGRES_DB"),
19 | hostname: System.get_env("POSTGRES_HOST"),
20 | pool_size: 15
21 |
--------------------------------------------------------------------------------
/app/config/test.exs:
--------------------------------------------------------------------------------
1 | use Mix.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 :match, MatchWeb.Endpoint,
6 | http: [port: 4002],
7 | server: false
8 |
9 | # Print only warnings and errors during test
10 | config :logger, level: :warn
11 |
12 | # Configure your database
13 | config :match, Match.Repo,
14 | username: "postgres",
15 | password: "postgres",
16 | database: "match_test",
17 | hostname: "localhost",
18 | pool: Ecto.Adapters.SQL.Sandbox
19 |
--------------------------------------------------------------------------------
/app/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | mix ecto.create
4 | mix ecto.migrate
5 | mix phx.server
6 |
--------------------------------------------------------------------------------
/app/lib/match.ex:
--------------------------------------------------------------------------------
1 | defmodule Match do
2 | @moduledoc """
3 | Match 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 |
--------------------------------------------------------------------------------
/app/lib/match/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | def start(_type, _args) do
9 | children = [
10 | Match.Repo,
11 | MatchWeb.Endpoint,
12 | MatchWeb.Presence,
13 | {Registry, keys: :unique, name: Match.Registry},
14 | Match.GameSupervisor,
15 | Match.Record,
16 | Match.Logon
17 | ]
18 |
19 | :ets.new(:games_table, [:public, :named_table])
20 |
21 | opts = [strategy: :one_for_one, name: Match.Supervisor]
22 | Supervisor.start_link(children, opts)
23 | end
24 |
25 | def config_change(changed, _new, removed) do
26 | MatchWeb.Endpoint.config_change(changed, removed)
27 | :ok
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/app/lib/match/card.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Card do
2 | defstruct id: nil, name: nil, image: nil, flipped: false, paired: false, score: false
3 | end
4 |
--------------------------------------------------------------------------------
/app/lib/match/find_user.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.FindUser do
2 | alias Match.Password
3 |
4 | def with_username_and_password(users, username, password) do
5 | case Enum.filter(users, fn {_, {k, _, _}} -> k == username end) do
6 | [{id, {_username, _icon, hash}}] ->
7 | if Password.verify(password, hash) do
8 | id
9 | end
10 | [] ->
11 | Password.dummy_verify()
12 | nil
13 | end
14 | end
15 |
16 | end
17 |
--------------------------------------------------------------------------------
/app/lib/match/game.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Game do
2 | defstruct cards: [], winner: nil, animating: false, active_player_id: nil, players: [], scores: %{}
3 |
4 | import Match.Random, only: [take_random: 2]
5 | import Match.Hash, only: [hmac: 3]
6 |
7 | def new(size) do
8 | cards = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen"]
9 | |> take_random(size)
10 | |> generate_cards()
11 | %Match.Game{cards: cards}
12 | end
13 |
14 | def prepare_restart(old_game) do
15 | %Match.Game{winner: winner, cards: cards} = old_game
16 | case winner != nil do
17 | true ->
18 | unpaired =
19 | cards
20 | |> Enum.map(fn(card) -> %Match.Card{card | paired: false} end)
21 | %{ old_game | cards: unpaired}
22 | false -> old_game
23 | end
24 | end
25 |
26 | def restart(old_game) do
27 | %Match.Game{players: players, active_player_id: active_player_id, cards: cards} = old_game
28 | size = div(Enum.count(cards), 2)
29 | game = new(size)
30 | %{ game | active_player_id: active_player_id, players: players}
31 | end
32 |
33 | def unflip(game) do
34 | %Match.Game{cards: cards, players: players, active_player_id: active_player_id} = game
35 | exclude_active_player = Enum.filter(players, fn(p) -> p !== active_player_id end)
36 | next_player =
37 | case Enum.count(exclude_active_player) == 0 do
38 | true ->
39 | active_player_id
40 | false ->
41 | [ next_player ] = Enum.take(exclude_active_player, 1)
42 | next_player
43 | end
44 | new_players = exclude_active_player ++ [ active_player_id ]
45 | new_cards =
46 | cards
47 | |> Enum.map(fn(card) -> %Match.Card{card | flipped: false} end)
48 | %{ game | cards: new_cards, animating: false, active_player_id: next_player, players: new_players}
49 | end
50 |
51 | def join(game, player_id) do
52 | %Match.Game{players: players} = game
53 | case Enum.count(players) == 2 do
54 | true ->
55 | {:error, "join failed: game was full"}
56 | false ->
57 | new_game = join_game(game, player_id)
58 | {:ok, new_game}
59 | end
60 | end
61 |
62 | def leave(game, player_id) do
63 | %Match.Game{players: players, active_player_id: active_player_id} = game
64 | new_players = Enum.filter(players, fn(p) -> p !== player_id end)
65 | active =
66 | case active_player_id == player_id do
67 | true ->
68 | case Enum.take(new_players, 1) do
69 | [ next_player ] -> next_player
70 | [] -> nil
71 | end
72 | false ->
73 | active_player_id
74 | end
75 | %{ game | players: new_players, active_player_id: active}
76 | end
77 |
78 | def flip(game, id, player_id) do
79 | %Match.Game{cards: cards, winner: winner, animating: animating, active_player_id: active_player_id} = game
80 | cond do
81 | animating == true or winner == true ->
82 | game
83 | player_id != active_player_id ->
84 | game
85 | true ->
86 | cards
87 | |> Enum.map(&flip_card(&1, id))
88 | |> attempt_match(game)
89 | |> calculate_scores()
90 | |> declare_winner()
91 | end
92 | end
93 |
94 | defp attempt_match(cards, game) do
95 | flipped_cards = Enum.filter(cards, fn (card) -> card.flipped end)
96 | case Enum.count(flipped_cards) == 2 do
97 | true ->
98 | [
99 | %Match.Card{:name => first},
100 | %Match.Card{:name => last},
101 | ] = flipped_cards
102 | case first === last do
103 | true -> %{game | cards: Enum.map(cards, &pair_card(&1))}
104 | false -> %{game | cards: cards, animating: true}
105 | end
106 | false -> %{game | cards: cards}
107 | end
108 | end
109 |
110 | defp calculate_scores(game) do
111 | %Match.Game{cards: cards, scores: scores, active_player_id: active_player_id} = game
112 | new_scores =
113 | case Enum.count(cards, fn(card) -> card.score == true end) > 0 do
114 | true ->
115 | score = Map.get(scores, active_player_id, 0) + 1
116 | Map.put(scores, active_player_id, score)
117 | false -> scores
118 | end
119 | %{game | scores: new_scores, cards: Enum.map(cards, fn(card) -> %Match.Card{card | score: false} end)}
120 | end
121 |
122 | defp declare_winner(game) do
123 | %Match.Game{cards: cards, scores: scores} = game
124 | total = Enum.count(cards)
125 | paired = Enum.count(cards, fn(card) -> card.paired == true end)
126 | case total == paired do
127 | true ->
128 | {player_id, _} = Enum.max_by(scores, fn({_,v}) -> v end)
129 | %{game | winner: player_id}
130 | false ->
131 | %{game | winner: nil}
132 | end
133 | end
134 |
135 | defp pair_card(card) do
136 | case card.flipped == true do
137 | true -> %Match.Card{card | paired: true, flipped: false, score: true}
138 | false -> card
139 | end
140 | end
141 |
142 | defp flip_card(card, id) do
143 | case card.id == id do
144 | true -> %Match.Card{card | flipped: true}
145 | false -> card
146 | end
147 | end
148 |
149 | defp join_game(game, player_id) do
150 | %Match.Game{players: players, active_player_id: active_player_id} = game
151 | active =
152 | case active_player_id == nil do
153 | true ->
154 | player_id
155 | false ->
156 | active_player_id
157 | end
158 | new_players =
159 | case player_id in players do
160 | true -> players
161 | false -> [player_id | players]
162 | end
163 | %{ game | players: new_players, active_player_id: active}
164 |
165 | end
166 |
167 | defp generate_cards(cards) do
168 | length = 6
169 | total = Enum.count(cards)
170 | Enum.map(cards, fn (name) ->
171 | hash = hmac("type:card", name, length)
172 | one = %Match.Card{id: "#{hash}1", name: name, image: "/images/cards/#{name}.png" }
173 | two = %Match.Card{id: "#{hash}2", name: name, image: "/images/cards/#{name}.png" }
174 | [one, two]
175 | end)
176 | |> List.flatten()
177 | |> take_random(total * 2)
178 | end
179 | end
180 |
--------------------------------------------------------------------------------
/app/lib/match/game_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.GameSupervisor do
2 | use DynamicSupervisor
3 |
4 | def start_link(_args) do
5 | DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
6 | end
7 |
8 | def init(:ok) do
9 | DynamicSupervisor.init(strategy: :one_for_one)
10 | end
11 |
12 | def start_game(name, user, scope, size \\ 9) do
13 | child_spec = %{
14 | id: Match.Session,
15 | start: {Match.Session, :start_link, [name, user, scope, size]},
16 | restart: :transient
17 | }
18 |
19 | DynamicSupervisor.start_child(__MODULE__, child_spec)
20 | end
21 |
22 | def stop_game(name) do
23 | :ets.delete(:games_table, name)
24 | child_pid = Match.Session.session_pid(name)
25 | DynamicSupervisor.terminate_child(__MODULE__, child_pid)
26 | broadcast_public_games()
27 | end
28 |
29 | def broadcast_public_games do
30 | public = :ets.match(:games_table, {:"$1", :"$2", :public})
31 | games = Enum.map(public, fn([_, map]) -> map end)
32 | MatchWeb.Endpoint.broadcast_from!(self(), "available", "public_games", %{games: games})
33 | end
34 |
35 | end
36 |
--------------------------------------------------------------------------------
/app/lib/match/generator.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Generator do
2 |
3 | def haiku do
4 | [
5 | Enum.random(foods()),
6 | :rand.uniform(9999)
7 | ]
8 | |> Enum.join("-")
9 | end
10 |
11 | defp foods do
12 | ~w(
13 | apple banana orange
14 | grape kiwi mango
15 | pear pineapple strawberry
16 | tomato watermelon cantaloupe
17 | )
18 | end
19 |
20 | def icon do
21 | Enum.random(numbers())
22 | end
23 |
24 | defp numbers do
25 | ~w(
26 | one two three
27 | four five six
28 | seven eight nine
29 | ten eleven twelve
30 | thirteen fourteen
31 | )
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/app/lib/match/hash.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Hash do
2 | def hmac(key, value, length \\ 25) do
3 | :crypto.hmac(:sha256, key, value)
4 | |> Base.encode16
5 | |> String.slice(0, length)
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/app/lib/match/logon.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Logon do
2 | use GenServer
3 |
4 | alias Match.Hash
5 | alias Match.User
6 | alias Match.Users
7 | alias Match.FindUser
8 | alias Match.Password
9 | alias Match.Generator
10 |
11 | def start_link(_args) do
12 | GenServer.start_link(__MODULE__, :ok, name: via(:logon))
13 | end
14 |
15 | defp via(name), do: Match.Registry.via(name)
16 |
17 | @impl GenServer
18 | def init(:ok) do
19 | state = for %User{id: id, username: username, icon: icon, hash: hash} <- Users.all(), into: %{}, do: {id, {username, icon, hash}}
20 | {:ok, state, {:continue, :init}}
21 | end
22 |
23 | @impl GenServer
24 | def handle_continue(:init, state) do
25 | {:noreply, state}
26 | end
27 |
28 | def get(name, id) do
29 | GenServer.call(via(name), {:get, id})
30 | end
31 |
32 | def get_by_username_and_password(name, username, password) do
33 | GenServer.call(via(name), {:get, username, password})
34 | end
35 |
36 | def put(name, username, password, invite) do
37 | GenServer.call(via(name), {:put, username, password, invite})
38 | end
39 |
40 | @impl GenServer
41 | def handle_call({:get, id}, _timeout, state) do
42 | {username, icon, _} = Map.get(state, id)
43 | {:reply, {username, icon}, state}
44 | end
45 |
46 | @impl GenServer
47 | def handle_call({:get, username, password}, _timeout, state) do
48 | id = FindUser.with_username_and_password(state, username, password)
49 | {:reply, id, state}
50 | end
51 |
52 | @impl GenServer
53 | def handle_call({:put, username, password, invite}, _timeout, state) do
54 | icon = Generator.icon()
55 | hash = Password.hash(password)
56 | id = Hash.hmac("type:user", username)
57 | changeset = User.changeset(%User{}, %{id: id, username: username, password: password, icon: icon, invite: invite, hash: hash})
58 | case Users.insert(changeset) do
59 | {:ok, _result} ->
60 | new_state = Map.put(state, id, {username, icon, hash})
61 | {:reply, {:ok, {id, username, icon}}, new_state}
62 | {:error, changeset} ->
63 | {_key, {message, _}} = Enum.find(changeset.errors, fn(i) -> i end)
64 | {:reply, {:error, {id, message}}, state}
65 | end
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/app/lib/match/password.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Password do
2 | import Bcrypt, only: [hash_pwd_salt: 1, verify_pass: 2, no_user_verify: 0]
3 |
4 | def hash(password) do
5 | hash_pwd_salt(password)
6 | end
7 |
8 | def verify(password, hash) do
9 | verify_pass(password, hash)
10 | end
11 |
12 | def dummy_verify, do: no_user_verify()
13 | end
14 |
--------------------------------------------------------------------------------
/app/lib/match/process.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Process do
2 |
3 | def sleep(t) do
4 | Process.sleep(t * 100)
5 | end
6 |
7 | end
8 |
--------------------------------------------------------------------------------
/app/lib/match/random.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Random do
2 |
3 | def take_random(items, number) do
4 | Enum.take_random(items, number)
5 | end
6 |
7 | end
8 |
--------------------------------------------------------------------------------
/app/lib/match/record.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Record do
2 | use GenServer
3 |
4 | alias Match.Statistic
5 | alias Match.Statistics
6 |
7 | def start_link(_args) do
8 | GenServer.start_link(__MODULE__, :ok, name: via(:record))
9 | end
10 |
11 | defp via(name), do: Match.Registry.via(name)
12 |
13 | @impl GenServer
14 | def init(:ok) do
15 | statistics = Statistics.all()
16 | state = Statistic.transform_previous(statistics)
17 | {:ok, state, {:continue, :init}}
18 | end
19 |
20 | @impl GenServer
21 | def handle_continue(:init, state) do
22 | {:noreply, state}
23 | end
24 |
25 | def statistics(name, scores, winner) do
26 | GenServer.cast(via(name), {:statistics, scores, winner})
27 | end
28 |
29 | @impl GenServer
30 | def handle_cast({:statistics, scores, winner}, state) do
31 | new_state = Statistic.insert_statistics(scores, winner, state)
32 | {:noreply, new_state}
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/app/lib/match/registry.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Registry do
2 | def via(name) do
3 | {:via, Registry, {__MODULE__, name}}
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/app/lib/match/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Repo do
2 | use Ecto.Repo,
3 | otp_app: :match,
4 | adapter: Ecto.Adapters.Postgres
5 | end
6 |
--------------------------------------------------------------------------------
/app/lib/match/session.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Session do
2 | use GenServer
3 |
4 | @timeout :timer.minutes(60)
5 |
6 | import Match.Process, only: [sleep: 1]
7 |
8 | def start_link(name, user, scope, size) do
9 | GenServer.start_link(__MODULE__, {:ok, name, user, scope, size}, name: via(name))
10 | end
11 |
12 | defp via(name), do: Match.Registry.via(name)
13 |
14 | @impl GenServer
15 | def init({:ok, name, user, scope, size}) do
16 | state = Match.Game.new(size)
17 | send(self(), {:available, name, user, scope})
18 | {:ok, state, @timeout}
19 | end
20 |
21 | def session_pid(name) do
22 | name
23 | |> via()
24 | |> GenServer.whereis()
25 | end
26 |
27 | def game_state(name) do
28 | GenServer.call(via(name), {:game_state})
29 | end
30 |
31 | def join(name, player_id) do
32 | GenServer.call(via(name), {:join, player_id})
33 | end
34 |
35 | def leave(name, player_id) do
36 | GenServer.call(via(name), {:leave, player_id})
37 | end
38 |
39 | def flip(name, id, player_id) do
40 | GenServer.call(via(name), {:flip, id, player_id})
41 | end
42 |
43 | def unflip(name) do
44 | sleep(10)
45 | GenServer.call(via(name), {:unflip})
46 | end
47 |
48 | def prepare_restart(name) do
49 | GenServer.call(via(name), {:prepare_restart})
50 | end
51 |
52 | def restart(name) do
53 | sleep(1)
54 | GenServer.call(via(name), {:restart})
55 | end
56 |
57 | @impl GenServer
58 | def handle_call({:game_state}, _from, state) do
59 | {:reply, state, state, @timeout}
60 | end
61 |
62 | @impl GenServer
63 | def handle_call({:join, player_id}, _from, state) do
64 | case Match.Game.join(state, player_id) do
65 | {:ok, new_state} ->
66 | %Match.Game{players: players} = new_state
67 | if Enum.count(players) == 2 do
68 | remove_listing()
69 | end
70 | {:reply, {:ok, new_state}, new_state, @timeout}
71 | {:error, reason} ->
72 | {:reply, {:error, reason}, state, @timeout}
73 | end
74 | end
75 |
76 | @impl GenServer
77 | def handle_call({:leave, player_id}, _from, state) do
78 | new_state = Match.Game.leave(state, player_id)
79 | {:reply, new_state, new_state, @timeout}
80 | end
81 |
82 | @impl GenServer
83 | def handle_call({:flip, id, player_id}, _from, state) do
84 | new_state = Match.Game.flip(state, id, player_id)
85 | {:reply, new_state, new_state, @timeout}
86 | end
87 |
88 | @impl GenServer
89 | def handle_call({:unflip}, _from, state) do
90 | new_state = Match.Game.unflip(state)
91 | {:reply, new_state, new_state, @timeout}
92 | end
93 |
94 | @impl GenServer
95 | def handle_call({:prepare_restart}, _from, state) do
96 | new_state = Match.Game.prepare_restart(state)
97 | {:reply, new_state, new_state, @timeout}
98 | end
99 |
100 | @impl GenServer
101 | def handle_call({:restart}, _from, state) do
102 | new_state = Match.Game.restart(state)
103 | {:reply, new_state, new_state, @timeout}
104 | end
105 |
106 | @impl GenServer
107 | def handle_info({:available, name, user, scope}, state) do
108 | if scope == :public do
109 | created = DateTime.utc_now
110 | :ets.insert(:games_table, {name, %{"name" => name, "username" => user.username, "icon" => user.icon, "created" => created}, scope})
111 | Match.GameSupervisor.broadcast_public_games()
112 | end
113 | {:noreply, state}
114 | end
115 |
116 | @impl GenServer
117 | def handle_info(:timeout, session) do
118 | {:stop, {:shutdown, :timeout}, session}
119 | end
120 |
121 | @impl GenServer
122 | def terminate({:shutdown, :timeout}, _session) do
123 | remove_listing()
124 | :ok
125 | end
126 |
127 | @impl GenServer
128 | def terminate(_reason, _session) do
129 | :ok
130 | end
131 |
132 | def session_name do
133 | Registry.keys(Match.Registry, self()) |> List.first
134 | end
135 |
136 | defp remove_listing do
137 | :ets.delete(:games_table, session_name())
138 | Match.GameSupervisor.broadcast_public_games()
139 | end
140 |
141 | end
142 |
--------------------------------------------------------------------------------
/app/lib/match/statistic.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Statistic do
2 | use Ecto.Schema
3 |
4 | alias Match.Statistics
5 |
6 | import Ecto.Changeset
7 |
8 | @primary_key {:user, :string, []}
9 | schema "statistics" do
10 | field :wins, :integer
11 | field :flips, :integer
12 |
13 | timestamps()
14 | end
15 |
16 | def changeset(stat, params \\ %{}) do
17 | stat
18 | |> cast(params, [:user, :wins, :flips])
19 | end
20 |
21 | def transform_previous(statistics) do
22 | Enum.map(statistics, fn(stat) ->
23 | %Match.Statistic{user: user, wins: wins, flips: flips} = stat
24 | %{user => %{wins: wins, flips: flips}}
25 | end)
26 | end
27 |
28 | def insert_statistics(scores, winner, statistics) do
29 | stats = parse(scores, winner)
30 | previous = get_persisted_stats(scores, statistics)
31 | results = merge_stats(previous, stats)
32 | map = insert(results)
33 | net_new = pull_out_any_net_new_stats(map, statistics)
34 | new_state = merge_any_existing_stats(map, statistics)
35 | new_state ++ net_new
36 | end
37 |
38 | def insert(results) do
39 | for {user, map} <- results do
40 | attrs = map |> Map.put(:user, user)
41 | Statistics.upsert_by(user, attrs)
42 | %{user => map}
43 | end
44 | end
45 |
46 | def parse(scores, winner) do
47 | Enum.map(scores, &transform_score(&1, winner))
48 | end
49 |
50 | def get_persisted_stats(scores, statistics) do
51 | Enum.map(scores, &pull_scores_from_previous(&1, statistics))
52 | |> Enum.filter(fn(s) -> !Enum.empty?(s) end)
53 | end
54 |
55 | def merge_stats(previous, stats) do
56 | statistics = previous ++ stats
57 | Enum.reduce(statistics, &sum_statistics(&1, &2))
58 | end
59 |
60 | def pull_out_any_net_new_stats(map, statistics) do
61 | Enum.filter(map, fn(s) ->
62 | [{k, _}] = Map.to_list(s)
63 | !Enum.find(statistics, nil, fn(m) ->
64 | [{user, _}] = Map.to_list(m)
65 | k == user
66 | end)
67 | end)
68 | end
69 |
70 | def merge_any_existing_stats(map, statistics) do
71 | Enum.map(statistics, fn(stat) ->
72 | [{k, _}] = Map.to_list(stat)
73 | case Enum.find(map, nil, fn(m) ->
74 | [{user, _}] = Map.to_list(m)
75 | k == user
76 | end) do
77 | nil -> stat
78 | s ->
79 | Map.put(stat, k, s[k])
80 | end
81 | end)
82 | end
83 |
84 | defp sum_statistics(stat, next) do
85 | Map.merge(stat, next, fn _key, map1, map2 ->
86 | for {k, v1} <- map1, into: %{}, do: {k, v1 + map2[k]}
87 | end)
88 | end
89 |
90 | defp pull_scores_from_previous(score, statistics) do
91 | {user, _flips} = score
92 | case Enum.find(statistics, nil, fn(map) ->
93 | [{k, _}] = Map.to_list(map)
94 | k == user
95 | end) do
96 | nil -> %{}
97 | stat -> stat
98 | end
99 | end
100 |
101 | defp transform_score(score, winner) do
102 | {user, flips} = score
103 | case user == winner do
104 | true ->
105 | %{user => %{wins: 1, flips: flips}}
106 | false ->
107 | %{user => %{wins: 0, flips: flips}}
108 | end
109 | end
110 | end
111 |
--------------------------------------------------------------------------------
/app/lib/match/statistics.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Statistics do
2 | import Ecto.Query, warn: false
3 |
4 | alias Match.Repo
5 | alias Match.Statistic
6 |
7 | def all do
8 | Repo.all(Statistic)
9 | end
10 |
11 | def get_by(attrs \\ %{}) do
12 | Repo.get_by(Statistic, attrs, log: false)
13 | end
14 |
15 | def upsert_by(user_id, stats) do
16 | case Repo.get_by(Statistic, %{:user => user_id}) do
17 | nil -> %Statistic{}
18 | stat -> stat
19 | end
20 | |> Statistic.changeset(stats)
21 | |> Repo.insert_or_update
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/app/lib/match/user.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.User do
2 | use Ecto.Schema
3 |
4 | import Ecto.Changeset
5 |
6 | @primary_key {:id, :string, []}
7 | schema "users" do
8 | field :username, :string
9 | field :hash, :string
10 | field :icon, :string
11 | field :invite, :string, virtual: true
12 | field :password, :string, virtual: true
13 |
14 | timestamps()
15 | end
16 |
17 | def changeset(user, params \\ %{}) do
18 | user
19 | |> cast(params, [:id, :username, :password, :icon, :invite, :hash])
20 | |> validate_required([:username, :password])
21 | |> unique_constraint(:id, name: :users_pkey, message: "username already exists")
22 | |> validate_length(:username, min: 4, max: 12, message: "username must be 4-12 characters")
23 | |> validate_length(:password, min: 8, max: 20, message: "password must be 8-20 characters")
24 | |> validate_invite
25 | end
26 |
27 | defp validate_invite(%Ecto.Changeset{} = changeset) do
28 | value = Map.get(changeset.changes, :invite)
29 | case value == "elixir2019" do
30 | true -> changeset
31 | false -> add_error(changeset, :invite, "invalid invite code")
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/app/lib/match/users.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.Users do
2 | import Ecto.Query, warn: false
3 |
4 | alias Match.Repo
5 | alias Match.User
6 |
7 | def all do
8 | Repo.all(User)
9 | end
10 |
11 | def insert(changeset) do
12 | Repo.insert(changeset)
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/app/lib/match_web.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb 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 MatchWeb, :controller
9 | use MatchWeb, :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: MatchWeb
23 |
24 | import Plug.Conn
25 | import MatchWeb.Gettext
26 | alias MatchWeb.Router.Helpers, as: Routes
27 | end
28 | end
29 |
30 | def view do
31 | quote do
32 | use Phoenix.View,
33 | root: "lib/match_web/templates",
34 | namespace: MatchWeb
35 |
36 | # Import convenience functions from controllers
37 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]
38 |
39 | # Import LiveView
40 | import Phoenix.LiveView, only: [live_render: 2, live_render: 3]
41 |
42 | # Use all HTML functionality (forms, tags, etc)
43 | use Phoenix.HTML
44 |
45 | import MatchWeb.ErrorHelpers
46 | import MatchWeb.Gettext
47 | alias MatchWeb.Router.Helpers, as: Routes
48 | end
49 | end
50 |
51 | def router do
52 | quote do
53 | use Phoenix.Router
54 | import Plug.Conn
55 | import Phoenix.Controller
56 | import Phoenix.LiveView.Router
57 | end
58 | end
59 |
60 | def channel do
61 | quote do
62 | use Phoenix.Channel
63 | import MatchWeb.Gettext
64 | end
65 | end
66 |
67 | @doc """
68 | When used, dispatch to the appropriate controller/view/etc.
69 | """
70 | defmacro __using__(which) when is_atom(which) do
71 | apply(__MODULE__, which, [])
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/app/lib/match_web/authenticator.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.Authenticator do
2 | import Plug.Conn, only: [get_session: 2, assign: 3]
3 |
4 | def init(opts), do: opts
5 |
6 | def call(conn, _opts) do
7 | case get_session(conn, :user_id) do
8 | nil ->
9 | conn
10 | id ->
11 | {username, icon} = Match.Logon.get(:logon, "#{id}")
12 | assign(conn, :current_user, %{id: id, username: username, icon: icon})
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/lib/match_web/channels/available_channel.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.AvailableChannel do
2 | use MatchWeb, :channel
3 |
4 | def join("available", _params, socket) do
5 | send(self(), {:after_join})
6 | {:ok, socket}
7 | end
8 |
9 | def handle_info({:after_join}, socket) do
10 | public = :ets.match(:games_table, {:"$1", :"$2", :public})
11 | games = Enum.map(public, fn([_, map]) -> map end)
12 | push(socket, "public_games", %{games: games})
13 | {:noreply, socket}
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/lib/match_web/channels/presence.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.Presence do
2 | use Phoenix.Presence,
3 | otp_app: :match,
4 | pubsub_server: Match.PubSub
5 | end
6 |
--------------------------------------------------------------------------------
/app/lib/match_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | channel "available", MatchWeb.AvailableChannel
6 |
7 | # Socket params are passed from the client and can
8 | # be used to verify and authenticate a user. After
9 | # verification, you can put default assigns into
10 | # the socket that will be set for all channels, ie
11 | #
12 | # {:ok, assign(socket, :user_id, verified_user_id)}
13 | #
14 | # To deny connection, return `:error`.
15 | #
16 | # See `Phoenix.Token` documentation for examples in
17 | # performing token verification on connect.
18 |
19 | def connect(%{"token" => token}, socket, _connect_info) do
20 | case Phoenix.Token.verify(socket, "player_auth", token, max_age: 86400) do
21 | {:ok, current_user} ->
22 | {:ok, assign(socket, :current_user, current_user)}
23 | {:error, _reason} ->
24 | :error
25 | end
26 | end
27 |
28 | def connect(_params, socket, _connect_info) do
29 | {:ok, socket}
30 | end
31 |
32 | # Socket id's are topics that allow you to identify all sockets for a given user:
33 | #
34 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}"
35 | #
36 | # Would allow you to broadcast a "disconnect" event and terminate
37 | # all active sockets and channels for a given user:
38 | #
39 | # MatchWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
40 | #
41 | # Returning `nil` makes this socket anonymous.
42 | def id(_socket), do: nil
43 | end
44 |
--------------------------------------------------------------------------------
/app/lib/match_web/controllers/logout_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.LogoutController do
2 | use MatchWeb, :controller
3 |
4 | import Plug.Conn, only: [clear_session: 1, configure_session: 2]
5 |
6 | def index(conn, _params) do
7 | conn
8 | |> clear_session()
9 | |> configure_session(drop: true)
10 | |> redirect(to: Routes.session_path(conn, :index))
11 | end
12 |
13 | end
14 |
--------------------------------------------------------------------------------
/app/lib/match_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.PageController do
2 | use MatchWeb, :controller
3 |
4 | def new(conn, %{"visibility" => visibility}) do
5 | scope = String.to_atom(visibility)
6 | game_name = Match.Generator.haiku()
7 | current_user = Map.get(conn.assigns, :current_user)
8 | case Match.GameSupervisor.start_game(game_name, current_user, scope) do
9 | {:ok, _pid} ->
10 | redirect(conn, to: Routes.page_path(conn, :show, game_name))
11 | {:error, {:already_started, _pid}} ->
12 | redirect(conn, to: Routes.page_path(conn, :show, game_name))
13 | {:error, _error} ->
14 | render(conn, "index.html")
15 | end
16 | end
17 |
18 | def show(conn, %{"id" => game_name}) do
19 | case Match.Session.session_pid(game_name) do
20 | pid when is_pid(pid) ->
21 | current_user = Map.get(conn.assigns, :current_user)
22 | case Match.Session.join(game_name, current_user.id) do
23 | {:ok, _} -> render_live_view(conn, game_name, current_user)
24 | {:error, _} -> redirect_user(conn)
25 | end
26 | nil -> redirect_user(conn)
27 | end
28 | end
29 |
30 | defp redirect_user(conn) do
31 | conn
32 | |> put_flash(:error, "game not found")
33 | |> redirect(to: Routes.session_path(conn, :index))
34 | end
35 |
36 | defp render_live_view(conn, game_name, current_user) do
37 | Phoenix.LiveView.Controller.live_render(conn, MatchWeb.GameLive, session: %{
38 | game_name: game_name,
39 | player_id: current_user.id,
40 | username: current_user.username,
41 | joined_id: nil,
42 | error: nil
43 | })
44 | end
45 |
46 | end
47 |
--------------------------------------------------------------------------------
/app/lib/match_web/controllers/registration_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.RegistrationController do
2 | use MatchWeb, :controller
3 |
4 | def new(conn, _) do
5 | render(conn, "new.html")
6 | end
7 |
8 | def create(conn, %{"username" => username, "invite" => invite, "password" => password}) do
9 | case Match.Logon.put(:logon, username, password, invite) do
10 | {:ok, {_id, _username, _icon}} ->
11 | path = Routes.session_path(conn, :new)
12 | conn
13 | |> put_flash(:info, "Account Created!")
14 | |> redirect(to: path)
15 | {:error, {_id, message}} ->
16 | path = Routes.registration_path(conn, :new)
17 | conn
18 | |> put_flash(:error, message)
19 | |> redirect(to: path)
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/app/lib/match_web/controllers/session_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.SessionController do
2 | use MatchWeb, :controller
3 |
4 | import Plug.Conn, only: [put_session: 3, get_session: 2]
5 |
6 | def new(conn, _) do
7 | render(conn, "new.html")
8 | end
9 |
10 | def index(conn, _params) do
11 | auth_token = get_auth_token(conn)
12 | render(conn, "index.html", %{auth_token: auth_token})
13 | end
14 |
15 | def redirected(conn, %{"reason" => reason}) do
16 | auth_token = get_auth_token(conn)
17 | conn
18 | |> put_flash(:error, reason)
19 | |> render("index.html", %{auth_token: auth_token})
20 | end
21 |
22 | def create(conn, %{"username" => username, "password" => password}) do
23 | case Match.Logon.get_by_username_and_password(:logon, username, password) do
24 | nil ->
25 | conn
26 | |> put_flash(:error, "incorrect username or password")
27 | |> render("new.html")
28 | id ->
29 | conn
30 | |> put_session(:user_id, id)
31 | |> redirect_back_to_game
32 | end
33 | end
34 |
35 | defp redirect_back_to_game(conn) do
36 | path = get_session(conn, :return_to) || Routes.session_path(conn, :index)
37 |
38 | conn
39 | |> put_session(:return_to, nil)
40 | |> redirect(to: path)
41 | end
42 |
43 | defp get_auth_token(conn) do
44 | case Map.get(conn.assigns, :current_user) do
45 | nil -> nil
46 | user -> Phoenix.Token.sign(conn, "player_auth", user)
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/app/lib/match_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :match
3 |
4 | socket "/live", Phoenix.LiveView.Socket
5 |
6 | socket "/socket", MatchWeb.UserSocket,
7 | websocket: true,
8 | longpoll: false
9 |
10 | # Serve at "/" the static files from "priv/static" directory.
11 | #
12 | # You should set gzip to true if you are running phx.digest
13 | # when deploying your static files in production.
14 | plug Plug.Static,
15 | at: "/",
16 | from: :match,
17 | gzip: false,
18 | only: ~w(css fonts images js favicon.ico robots.txt)
19 |
20 | # Code reloading can be explicitly enabled under the
21 | # :code_reloader configuration of your endpoint.
22 | if code_reloading? do
23 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
24 | plug Phoenix.LiveReloader
25 | plug Phoenix.CodeReloader
26 | end
27 |
28 | plug Plug.RequestId
29 | plug Plug.Logger
30 |
31 | plug Plug.Parsers,
32 | parsers: [:urlencoded, :multipart, :json],
33 | pass: ["*/*"],
34 | json_decoder: Phoenix.json_library()
35 |
36 | plug Plug.MethodOverride
37 | plug Plug.Head
38 |
39 | # The session will be stored in the cookie and signed,
40 | # this means its contents can be read but not tampered with.
41 | # Set :encryption_salt if you would also like to encrypt it.
42 | plug Plug.Session,
43 | store: :cookie,
44 | key: "_match_key",
45 | signing_salt: "6dO/GLTD"
46 |
47 | plug MatchWeb.Router
48 | end
49 |
--------------------------------------------------------------------------------
/app/lib/match_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.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 MatchWeb.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: :match
24 | end
25 |
--------------------------------------------------------------------------------
/app/lib/match_web/live/game_live.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.GameLive do
2 | use Phoenix.LiveView
3 |
4 | alias MatchWeb.Presence
5 | alias Phoenix.Socket.Broadcast
6 |
7 | def render(assigns) do
8 | ~L"""
9 |
14 |
15 |
16 |
17 |
21 |
22 |
23 |
29 |
30 |
31 |
32 |
33 | <%= for card <- rows(assigns) do %>
34 |
38 | <% end %>
39 |
40 | <%= if splash(assigns) do %>
41 |
42 |
43 |
44 | <%= cond do %>
45 | <% winner(assigns) -> %>
46 |
<%= winner(assigns) %> won!
47 |
Want more action?
48 |
Play Again
49 |
Home
50 | <% joined(assigns) -> %>
51 |
52 |
<%= joined(assigns) %> joined!
53 |
54 |
Continue
55 | <% error(assigns) -> %>
56 |
57 |
<%= error(assigns) %>
58 |
59 |
Try Again
60 | <% true -> %>
61 |
62 | <% end %>
63 |
64 |
65 | <% end %>
66 |
67 |
68 |
69 | """
70 | end
71 |
72 | def mount(session, socket) do
73 | if connected?(socket), do: subscribe(session)
74 |
75 | game_state(session, socket)
76 | end
77 |
78 | def handle_event("continue", _value, socket) do
79 | {:noreply, set_joined(socket, nil)}
80 | end
81 |
82 | def handle_event("prepare_restart", _value, socket) do
83 | %{:game_name => game_name} = socket.assigns
84 | case Match.Session.session_pid(game_name) do
85 | pid when is_pid(pid) ->
86 | game = Match.Session.prepare_restart(game_name)
87 | MatchWeb.Endpoint.broadcast_from!(self(), "game:#{game_name}", "summary", game)
88 | send(self(), {:restart, game_name})
89 | {:noreply, set_state(socket, game, socket.assigns)}
90 | nil ->
91 | {:noreply, set_error(socket)}
92 | end
93 | end
94 |
95 | def handle_event("flip", id, socket) do
96 | %{:game_name => game_name, :player_id => player_id} = socket.assigns
97 | case Match.Session.session_pid(game_name) do
98 | pid when is_pid(pid) ->
99 | game = Match.Session.flip(game_name, id, player_id)
100 | MatchWeb.Endpoint.broadcast_from!(self(), "game:#{game_name}", "summary", game)
101 |
102 | %Match.Game{animating: animating, winner: winner, scores: scores} = game
103 | if animating == true do
104 | send(self(), {:unflip, game_name})
105 | end
106 | if winner != nil do
107 | Match.Record.statistics(:record, scores, winner)
108 | end
109 |
110 | {:noreply, set_state(socket, game, socket.assigns)}
111 | nil ->
112 | {:noreply, set_error(socket)}
113 | end
114 | end
115 |
116 | def handle_info({:unflip, game_name}, socket) do
117 | case Match.Session.session_pid(game_name) do
118 | pid when is_pid(pid) ->
119 | game = Match.Session.unflip(game_name)
120 |
121 | MatchWeb.Endpoint.broadcast_from!(self(), "game:#{game_name}", "summary", game)
122 |
123 | {:noreply, set_state(socket, game, socket.assigns)}
124 | nil ->
125 | {:noreply, set_error(socket)}
126 | end
127 | end
128 |
129 | def handle_info({:restart, game_name}, socket) do
130 | case Match.Session.session_pid(game_name) do
131 | pid when is_pid(pid) ->
132 | game = Match.Session.restart(game_name)
133 |
134 | MatchWeb.Endpoint.broadcast_from!(self(), "game:#{game_name}", "summary", game)
135 |
136 | {:noreply, set_state(socket, game, socket.assigns)}
137 | nil ->
138 | {:noreply, set_error(socket)}
139 | end
140 | end
141 |
142 | def handle_info(%Broadcast{event: "summary", payload: game}, socket) do
143 | {:noreply, set_state(socket, game, socket.assigns)}
144 | end
145 |
146 | def handle_info(%Broadcast{event: "presence_diff"}, socket) do
147 | {:noreply, set_users(socket)}
148 | end
149 |
150 | def handle_info(%Broadcast{event: "join", payload: %{:player_id => player_id}}, socket) do
151 | %{active_player_id: active_player_id} = socket.assigns
152 | joined_id =
153 | case player_id != active_player_id do
154 | true -> player_id
155 | false -> nil
156 | end
157 | {:noreply, set_joined(socket, joined_id)}
158 | end
159 |
160 | def terminate({:shutdown, :closed}, socket) do
161 | %{:game_name => game_name, :player_id => player_id} = socket.assigns
162 | if Match.Session.session_pid(game_name) do
163 | game = Match.Session.leave(game_name, player_id)
164 | MatchWeb.Endpoint.broadcast_from!(self(), "game:#{game_name}", "summary", game)
165 | end
166 |
167 | :ok
168 | end
169 |
170 | def terminate(:shutdown, _socket) do
171 | :ok
172 | end
173 |
174 | defp subscribe(session) do
175 | %{:game_name => game_name, :player_id => player_id, :username => username} = session
176 | Phoenix.PubSub.subscribe(Match.PubSub, "game:#{game_name}")
177 | Phoenix.PubSub.subscribe(Match.PubSub, "users:#{game_name}")
178 | Presence.track(self(), "users:#{game_name}", player_id, %{username: username})
179 | MatchWeb.Endpoint.broadcast_from!(self(), "game:#{game_name}", "join", %{player_id: player_id})
180 | end
181 |
182 | defp game_state(session, socket) do
183 | %{game_name: game_name} = session
184 | game = Match.Session.game_state(game_name)
185 | {:ok, set_state(socket, game, session)}
186 | end
187 |
188 | defp set_state(socket, game, session) do
189 | %Match.Game{cards: cards, winner: winner, active_player_id: active_player_id, scores: scores} = game
190 | %{game_name: game_name, player_id: player_id, username: username, joined_id: joined_id, error: error} = session
191 | assign(socket,
192 | game_name: game_name,
193 | player_id: player_id,
194 | username: username,
195 | joined_id: joined_id,
196 | error: error,
197 | cards: cards,
198 | winner: winner,
199 | active_player_id: active_player_id,
200 | scores: scores,
201 | users: Presence.list("users:#{game_name}")
202 | )
203 | end
204 |
205 | defp set_users(socket) do
206 | %{game_name: game_name} = socket.assigns
207 | assign(socket,
208 | users: Presence.list("users:#{game_name}")
209 | )
210 | end
211 |
212 | defp set_joined(socket, player_id) do
213 | assign(socket,
214 | joined_id: player_id
215 | )
216 | end
217 |
218 | defp set_error(socket) do
219 | assign(socket,
220 | error: "an error occurred"
221 | )
222 | end
223 |
224 | defp rows(%{cards: cards}) do
225 | Enum.map(cards, &Map.from_struct(&1))
226 | end
227 |
228 | defp score(%{active_player_id: active_player_id, scores: scores}) do
229 | Map.get(scores, active_player_id, 0)
230 | end
231 |
232 | defp player(%{active_player_id: active_player_id, users: users}) do
233 | player(users, active_player_id, %{metas: [%{username: active_player_id}]})
234 | end
235 |
236 | defp player(users, id, fallback) do
237 | %{:metas => metas} = Map.get(users, id, fallback)
238 | [%{:username => username}] = metas
239 | username
240 | end
241 |
242 | defp joined(%{users: users, joined_id: joined_id}) do
243 | case joined_id == nil do
244 | true -> nil
245 | false ->
246 | case Enum.count(users) == 2 do
247 | true -> player(users, joined_id, %{metas: [%{username: joined_id}]})
248 | false -> nil
249 | end
250 | end
251 | end
252 |
253 | defp winner(%{winner: winner, users: users}) do
254 | name = player(users, winner, %{metas: [%{username: winner}]})
255 | cond do
256 | name == winner and winner != nil -> String.slice(name, 0..6)
257 | name == nil -> nil
258 | true -> name
259 | end
260 | end
261 |
262 | defp error(%{error: error}) do
263 | error
264 | end
265 |
266 | defp splash(assigns) do
267 | cond do
268 | winner(assigns) ->
269 | true
270 | joined(assigns) ->
271 | true
272 | error(assigns) ->
273 | true
274 | true ->
275 | false
276 | end
277 | end
278 |
279 | defp clazz(%{flipped: flipped, paired: paired}) do
280 | case paired == true do
281 | true -> "found"
282 | false ->
283 | case flipped == true do
284 | true -> "flipped"
285 | false -> ""
286 | end
287 | end
288 | end
289 |
290 | end
291 |
--------------------------------------------------------------------------------
/app/lib/match_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.Router do
2 | use MatchWeb, :router
3 |
4 | import Plug.Conn, only: [put_session: 3, fetch_session: 2, halt: 1]
5 |
6 | def redirect_unauthorized(conn, _opts) do
7 | current_user = Map.get(conn.assigns, :current_user)
8 | if current_user != nil and current_user.username != nil do
9 | conn
10 | else
11 | conn
12 | |> put_session(:return_to, conn.request_path)
13 | |> redirect(to: MatchWeb.Router.Helpers.session_path(conn, :new))
14 | |> halt()
15 | end
16 | end
17 |
18 | pipeline :browser do
19 | plug :accepts, ["html"]
20 | plug :fetch_session
21 | plug :fetch_flash
22 | plug Phoenix.LiveView.Flash
23 | plug :protect_from_forgery
24 | plug :put_secure_browser_headers
25 | plug MatchWeb.Authenticator
26 | end
27 |
28 | pipeline :restricted do
29 | plug :browser
30 | plug :redirect_unauthorized
31 | end
32 |
33 | scope "/", MatchWeb do
34 | pipe_through :browser
35 |
36 | get "/", SessionController, :index
37 | get "/login", SessionController, :new
38 | post "/login", SessionController, :create
39 | get "/redirected", SessionController, :redirected
40 | end
41 |
42 | scope "/signup", MatchWeb do
43 | pipe_through :browser
44 |
45 | get "/", RegistrationController, :new
46 | post "/", RegistrationController, :create
47 | end
48 |
49 | scope "/logout", MatchWeb do
50 | pipe_through :browser
51 |
52 | get "/", LogoutController, :index
53 | end
54 |
55 | scope "/game", MatchWeb do
56 | pipe_through :restricted
57 |
58 | get "/", PageController, :new
59 | get "/:id", PageController, :show
60 | end
61 |
62 | end
63 |
--------------------------------------------------------------------------------
/app/lib/match_web/templates/layout/app.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Elixir Match
8 | "/>
9 |
10 |
11 |
12 |
13 | <%= if get_flash(@conn, :error) do %>
14 |
19 | <% end %>
20 | <%= if get_flash(@conn, :info) do %>
21 |
26 | <% end %>
27 | <%= render @view_module, @view_template, assigns %>
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/app/lib/match_web/templates/page/new.html.eex:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/lib/match_web/templates/page/new.html.eex
--------------------------------------------------------------------------------
/app/lib/match_web/templates/page/private.html.eex:
--------------------------------------------------------------------------------
1 | private
2 |
--------------------------------------------------------------------------------
/app/lib/match_web/templates/page/show.html.eex:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/lib/match_web/templates/page/show.html.eex
--------------------------------------------------------------------------------
/app/lib/match_web/templates/registration/new.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
"/>
5 |
6 |
30 |
31 |
32 |
33 |
38 |
--------------------------------------------------------------------------------
/app/lib/match_web/templates/session/index.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
"/>
5 |
6 |
7 | <%= if @auth_token != nil do %>
8 |
9 |
10 |
11 |
12 |
Start new game
13 |
14 |
15 |
16 |
17 | <%= link "Public", class: 'public icon-padding action-button animate margin-right green', to: Routes.page_path(@conn, :new, [visibility: :public]) %>
18 | <%= link "Private", class: 'private icon-padding action-button animate purple', to: Routes.page_path(@conn, :new, [visibility: :private]) %>
19 |
20 |
21 |
22 |
23 |
Join public game
24 |
25 |
26 |
27 |
28 |
29 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | {{game.username}}
40 | {{game.createdDate}}
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | No Available Games
50 |
51 |
52 |
53 |
54 |
55 |
<%= @auth_token %>
56 |
126 | <% else %>
127 |
128 |
131 |
132 |
When my son was younger, we would play the memory card game for hours. I always thought that creating a digital version of the game would make for a great hobby project. As I journeyed into Elixir last year, I was able to bring this idea to life when I decided a multiplayer game for the iPad would be the perfect opportunity to learn about concurrency.
133 |
134 |
135 |
136 | <%= link "Login", class: 'action-button animate margin-right green', to: Routes.session_path(@conn, :new) %>
137 | <%= link "Sign up", class: 'action-button animate purple', to: Routes.registration_path(@conn, :new) %>
138 |
139 |
140 |
141 | <% end %>
142 |
143 |
144 |
145 |
--------------------------------------------------------------------------------
/app/lib/match_web/templates/session/new.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
"/>
5 |
6 |
30 |
31 |
32 |
33 |
38 |
--------------------------------------------------------------------------------
/app/lib/match_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.ErrorHelpers do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | use Phoenix.HTML
7 |
8 | @doc """
9 | Generates tag for inlined form input errors.
10 | """
11 | def error_tag(form, field) do
12 | Enum.map(Keyword.get_values(form.errors, field), fn error ->
13 | content_tag(:span, translate_error(error), class: "help-block")
14 | end)
15 | end
16 |
17 | @doc """
18 | Translates an error message using gettext.
19 | """
20 | def translate_error({msg, opts}) do
21 | # When using gettext, we typically pass the strings we want
22 | # to translate as a static argument:
23 | #
24 | # # Translate "is invalid" in the "errors" domain
25 | # dgettext("errors", "is invalid")
26 | #
27 | # # Translate the number of files with plural rules
28 | # dngettext("errors", "1 file", "%{count} files", count)
29 | #
30 | # Because the error messages we show in our forms and APIs
31 | # are defined inside Ecto, we need to translate them dynamically.
32 | # This requires us to call the Gettext module passing our gettext
33 | # backend as first argument.
34 | #
35 | # Note we use the "errors" domain, which means translations
36 | # should be written to the errors.po file. The :count option is
37 | # set by Ecto and indicates we should also apply plural rules.
38 | if count = opts[:count] do
39 | Gettext.dngettext(MatchWeb.Gettext, "errors", msg, msg, count, opts)
40 | else
41 | Gettext.dgettext(MatchWeb.Gettext, "errors", msg, opts)
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/app/lib/match_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.ErrorView do
2 | use MatchWeb, :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 |
--------------------------------------------------------------------------------
/app/lib/match_web/views/layout_view.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.LayoutView do
2 | use MatchWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/app/lib/match_web/views/page_view.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.PageView do
2 | use MatchWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/app/lib/match_web/views/registration_view.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.RegistrationView do
2 | use MatchWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/app/lib/match_web/views/session_view.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.SessionView do
2 | use MatchWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/app/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Match.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :match,
7 | version: "0.1.0",
8 | elixir: "~> 1.5",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | compilers: [:phoenix, :gettext] ++ Mix.compilers(),
11 | start_permanent: Mix.env() == :prod,
12 | aliases: aliases(),
13 | deps: deps()
14 | ]
15 | end
16 |
17 | # Configuration for the OTP application.
18 | #
19 | # Type `mix help compile.app` for more information.
20 | def application do
21 | [
22 | mod: {Match.Application, []},
23 | extra_applications: [:logger, :runtime_tools]
24 | ]
25 | end
26 |
27 | # Specifies which paths to compile per environment.
28 | defp elixirc_paths(:test), do: ["lib", "test/support"]
29 | defp elixirc_paths(_), do: ["lib"]
30 |
31 | # Specifies your project dependencies.
32 | #
33 | # Type `mix help deps` for examples and options.
34 | defp deps do
35 | [
36 | {:phoenix, "~> 1.4.2", override: true},
37 | {:phoenix_pubsub, "~> 1.1"},
38 | {:phoenix_ecto, "~> 4.0"},
39 | {:ecto_sql, "~> 3.0"},
40 | {:postgrex, ">= 0.0.0"},
41 | {:phoenix_html, "~> 2.11"},
42 | {:phoenix_live_reload, "~> 1.2", only: :dev},
43 | {:gettext, "~> 0.11"},
44 | {:jason, "~> 1.0"},
45 | {:plug_cowboy, "~> 2.0"},
46 | {:bcrypt_elixir, "~> 1.1"},
47 | {:floki, "~> 0.20.0"},
48 | {:phoenix_live_view, github: "phoenixframework/phoenix_live_view"},
49 | {:mix_test_watch, "~> 0.8", only: :dev, runtime: false}
50 | ]
51 | end
52 |
53 | # Aliases are shortcuts or tasks specific to the current project.
54 | # For example, to create, migrate and run the seeds file at once:
55 | #
56 | # $ mix ecto.setup
57 | #
58 | # See the documentation for `Mix` for more info on aliases.
59 | defp aliases do
60 | [
61 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
62 | "ecto.reset": ["ecto.drop", "ecto.setup"],
63 | test: ["ecto.create --quiet", "ecto.migrate", "test"]
64 | ]
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/app/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "bcrypt_elixir": {:hex, :bcrypt_elixir, "1.1.1", "6b5560e47a02196ce5f0ab3f1d8265db79a23868c137e973b27afef928ed8006", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"},
3 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
4 | "cowboy": {:hex, :cowboy, "2.6.1", "f2e06f757c337b3b311f9437e6e072b678fcd71545a7b2865bdaa154d078593f", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
5 | "cowlib": {:hex, :cowlib, "2.7.0", "3ef16e77562f9855a2605900cedb15c1462d76fb1be6a32fc3ae91973ee543d2", [:rebar3], [], "hexpm"},
6 | "db_connection": {:hex, :db_connection, "2.0.6", "bde2f85d047969c5b5800cb8f4b3ed6316c8cb11487afedac4aa5f93fd39abfa", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
7 | "decimal": {:hex, :decimal, "1.7.0", "30d6b52c88541f9a66637359ddf85016df9eb266170d53105f02e4a67e00c5aa", [:mix], [], "hexpm"},
8 | "ecto": {:hex, :ecto, "3.1.0", "42e669e172df63a0db37ece40be2dc2fc8698854436d2a9ce84375ddf221c1cb", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
9 | "ecto_sql": {:hex, :ecto_sql, "3.1.0", "20d773799db0ed112dc4109305df679f1c8b0a59cf75e6d15ee05656af5bd708", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
10 | "elixir_make": {:hex, :elixir_make, "0.5.2", "96a28c79f5b8d34879cd95ebc04d2a0d678cfbbd3e74c43cb63a76adf0ee8054", [:mix], [], "hexpm"},
11 | "file_system": {:hex, :file_system, "0.2.6", "fd4dc3af89b9ab1dc8ccbcc214a0e60c41f34be251d9307920748a14bf41f1d3", [:mix], [], "hexpm"},
12 | "floki": {:hex, :floki, "0.20.4", "be42ac911fece24b4c72f3b5846774b6e61b83fe685c2fc9d62093277fb3bc86", [:mix], [{:html_entities, "~> 0.4.0", [hex: :html_entities, repo: "hexpm", optional: false]}, {:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"},
13 | "gettext": {:hex, :gettext, "0.16.1", "e2130b25eebcbe02bb343b119a07ae2c7e28bd4b146c4a154da2ffb2b3507af2", [:mix], [], "hexpm"},
14 | "html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm"},
15 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
16 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
17 | "mix_test_watch": {:hex, :mix_test_watch, "0.9.0", "c72132a6071261893518fa08e121e911c9358713f62794a90c95db59042af375", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"},
18 | "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"},
19 | "phoenix": {:hex, :phoenix, "1.4.3", "8eed4a64ff1e12372cd634724bddd69185938f52c18e1396ebac76375d85677d", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"},
20 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
21 | "phoenix_html": {:hex, :phoenix_html, "2.13.2", "f5d27c9b10ce881a60177d2b5227314fc60881e6b66b41dfe3349db6ed06cf57", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
22 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.0", "3bb31a9fbd40ffe8652e60c8660dffd72dd231efcdf49b744fb75b9ef7db5dd2", [: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"},
23 | "phoenix_live_view": {:git, "https://github.com/phoenixframework/phoenix_live_view.git", "f6d63c518a189fb789fd871a3f5b82e3dbe7ecb3", []},
24 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"},
25 | "plug": {:hex, :plug, "1.8.0", "9d2685cb007fe5e28ed9ac27af2815bc262b7817a00929ac10f56f169f43b977", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},
26 | "plug_cowboy": {:hex, :plug_cowboy, "2.0.2", "6055f16868cc4882b24b6e1d63d2bada94fb4978413377a3b32ac16c18dffba2", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
27 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"},
28 | "postgrex": {:hex, :postgrex, "0.14.1", "63247d4a5ad6b9de57a0bac5d807e1c32d41e39c04b8a4156a26c63bcd8a2e49", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
29 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
30 | "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"},
31 | }
32 |
--------------------------------------------------------------------------------
/app/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | ## From Ecto.Changeset.validate_length/3
53 | msgid "should be %{count} character(s)"
54 | msgid_plural "should be %{count} character(s)"
55 | msgstr[0] ""
56 | msgstr[1] ""
57 |
58 | msgid "should have %{count} item(s)"
59 | msgid_plural "should have %{count} item(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be at least %{count} character(s)"
64 | msgid_plural "should be at least %{count} character(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at most %{count} character(s)"
74 | msgid_plural "should be at most %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should have at most %{count} item(s)"
79 | msgid_plural "should have at most %{count} item(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | ## From Ecto.Changeset.validate_number/3
84 | msgid "must be less than %{number}"
85 | msgstr ""
86 |
87 | msgid "must be greater than %{number}"
88 | msgstr ""
89 |
90 | msgid "must be less than or equal to %{number}"
91 | msgstr ""
92 |
93 | msgid "must be greater than or equal to %{number}"
94 | msgstr ""
95 |
96 | msgid "must be equal to %{number}"
97 | msgstr ""
98 |
--------------------------------------------------------------------------------
/app/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 |
--------------------------------------------------------------------------------
/app/priv/repo/migrations/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto_sql],
3 | inputs: ["*.exs"]
4 | ]
5 |
--------------------------------------------------------------------------------
/app/priv/repo/migrations/20190222211932_create_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Match.Repo.Migrations.CreateUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:users, primary_key: false) do
6 | add :id, :string, primary_key: true
7 | add :username, :string
8 | add :hash, :string
9 | add :icon, :string
10 |
11 | timestamps()
12 | end
13 |
14 | create unique_index(:users, [:username])
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/app/priv/repo/migrations/20190222214752_create_statistics.exs:
--------------------------------------------------------------------------------
1 | defmodule Match.Repo.Migrations.CreateStatistics do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:statistics, primary_key: false) do
6 | add :user, :string, primary_key: true
7 | add :wins, :integer
8 | add :flips, :integer
9 |
10 | timestamps()
11 | end
12 |
13 | create unique_index(:statistics, [:user])
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/app/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 | # Match.Repo.insert!(%Match.SomeSchema{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
--------------------------------------------------------------------------------
/app/priv/static/css/app.css:
--------------------------------------------------------------------------------
1 | html {
2 | color: White;
3 | font-size: 16px;
4 | font-family: 'Roboto', sans-serif;
5 | font-weight: 400;
6 | box-sizing: border-box;
7 | font-smoothing: antialiased;
8 | -webkit-font-smoothing: antialiased;
9 | -moz-osx-font-smoothing: grayscale;
10 | }
11 |
12 | *, *:before, *:after {
13 | box-sizing: inherit;
14 | }
15 |
16 | body {
17 | margin: 0;
18 | overflow-y: hidden;
19 | }
20 |
21 | .animate {
22 | transition: all 0.1s;
23 | -webkit-transition: all 0.1s;
24 | }
25 |
26 | .margin-right {
27 | margin-right: 15px;
28 | }
29 |
30 | .icon-padding {
31 | padding-left: 40px;
32 | padding-right: 30px;
33 | }
34 |
35 | .action-button {
36 | padding: 10px 40px;
37 | border-radius: 5px;
38 | font-size: 24px;
39 | color: #FFF;
40 | text-decoration: none;
41 | }
42 |
43 | .public {
44 | background-size: 35px;
45 | background-position: left;
46 | background-repeat: no-repeat;
47 | }
48 |
49 | .private {
50 | background-size: 35px;
51 | background-position: left;
52 | background-repeat: no-repeat;
53 | }
54 |
55 | .play {
56 | background-image: url("/images/play.svg");
57 | background-size: 30px;
58 | background-position: center;
59 | background-repeat: no-repeat;
60 | padding: 20px;
61 | padding-bottom: 5px;
62 | padding-top: 5px;
63 | }
64 |
65 | .green {
66 | background-color: rgba(76, 175, 80, 0.8);
67 | }
68 |
69 | .purple {
70 | background-color: rgba(195, 109, 216, 1);
71 | }
72 |
73 | .index-container {
74 | display: flex;
75 | justify-content: center;
76 | }
77 |
78 | .index {
79 | display: flex;
80 | max-width: 1025px;
81 | }
82 |
83 | .logo {
84 | flex: 2 0 0;
85 | height: 786px;
86 | padding-top: 10px;
87 | }
88 |
89 | .logo img {
90 | max-width: 100%;
91 | }
92 |
93 | .flex {
94 | display: flex;
95 | flex: 1 0 0;
96 | }
97 |
98 | .options {
99 | height: 786px;
100 | background: rgba(147,209,245, 0.9);
101 | background: -webkit-linear-gradient(right, rgba(147,209,245, 0.9), #bde0ff);
102 | background: linear-gradient(right, rgba(147,209,245, 0.9), #bde0ff);
103 | }
104 |
105 | .intro-container {
106 | width: 400px;
107 | display: flex;
108 | flex-direction: column;
109 | justify-content: center;
110 | color: #505b5e;
111 | }
112 |
113 | .intro-header {
114 | padding-left: 40px;
115 | padding-right: 40px;
116 | }
117 |
118 | .intro-header h1 {
119 | font-family: 'Aclonica', sans-serif;
120 | word-break: break-word;
121 | font-size: 5em;
122 | }
123 |
124 | .intro-text {
125 | margin-top: -58px;
126 | padding-left: 40px;
127 | padding-right: 40px;
128 | }
129 |
130 | .intro-text p {
131 | line-height: 1.4em;
132 | font-size: 1.4em;
133 | }
134 |
135 | .intro-header-login {
136 | color: #505b5e;
137 | padding-left: 20px;
138 | padding-right: 20px;
139 | margin-top: -100px;
140 | }
141 |
142 | .intro-header-login h1 {
143 | font-family: 'Aclonica', sans-serif;
144 | word-break: break-word;
145 | font-size: 5em;
146 | }
147 |
148 | .intro {
149 | width: 350px;
150 | margin: 16px auto;
151 | font-size: 16px;
152 | }
153 |
154 | .card-game {
155 | width: 100%;
156 | height: 100%;
157 | background: rgb(147,209,245);
158 | }
159 |
160 | .game-container {
161 | display: flex;
162 | justify-content: center;
163 | text-align: center;
164 | }
165 |
166 | .sticky-header {
167 | position:-webkit-sticky;
168 | position:sticky;
169 | top:0;
170 | display: flex;
171 | justify-content: center;
172 | z-index: 3;
173 | }
174 |
175 | .phoenix-header {
176 | margin: auto;
177 | position: fixed;
178 | right: 0;
179 | left: 0;
180 | z-index: 3;
181 | }
182 |
183 | .phoenix-header .header {
184 | font-weight: bold;
185 | padding-left: 25px;
186 | padding-right: 25px;
187 | }
188 |
189 | .sticky-header .header {
190 | font-weight: bold;
191 | padding-left: 25px;
192 | padding-right: 25px;
193 | }
194 |
195 | .header {
196 | text-align: center;
197 | color: #fff;
198 | border-bottom-right-radius: 5px;
199 | border-bottom-left-radius: 5px;
200 | border: 3px solid rgba(70,86,88, 0.9);
201 | box-shadow: rgba(0,0,0,0.8) 0 0 4px;
202 | background: rgba(97,111,114, 0.8);
203 | border-top: 0;
204 | font-size: 1.5em;
205 | letter-spacing: 1px;
206 | line-height: 1em;
207 | padding-top: 0.6em;
208 | padding-bottom: 0.5em;
209 | font-weight: 200;
210 | }
211 |
212 | .header-game {
213 | min-width: 500px;
214 | font-family: 'Aclonica', sans-serif;
215 | }
216 |
217 | .label {
218 | color: #00ff0a;
219 | }
220 |
221 | .alert-danger {
222 | background: rgba(240, 91, 116, 0.9);
223 | text-transform: capitalize;
224 | }
225 |
226 | .alert-success {
227 | background: rgba(76, 175, 80, 0.9);
228 | text-transform: capitalize;
229 | }
230 |
231 | .alert {
232 | text-align: center;
233 | color: #fff;
234 | font-family: 'Roboto', sans-serif;
235 | padding-top: 0.8em;
236 | padding-bottom: 0.8em;
237 | }
238 |
239 | .login-container {
240 | width: 400px;
241 | display: flex;
242 | justify-content: center;
243 | align-items: center;
244 | }
245 |
246 | .login-page {
247 | width: 350px;
248 | }
249 | .form {
250 | background: #FFFFFF;
251 | padding: 45px;
252 | text-align: center;
253 | box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
254 | }
255 | .form input {
256 | outline: 0;
257 | background: #f2f2f2;
258 | width: 100%;
259 | border: 0;
260 | margin: 0 0 15px;
261 | padding: 15px;
262 | box-sizing: border-box;
263 | font-size: 14px;
264 | }
265 | .form button {
266 | text-transform: uppercase;
267 | outline: 0;
268 | background: #7eb6d6;
269 | width: 100%;
270 | border: 0;
271 | padding: 15px;
272 | color: #FFFFFF;
273 | font-size: 14px;
274 | -webkit-transition: all 0.3 ease;
275 | transition: all 0.3 ease;
276 | cursor: pointer;
277 | }
278 | .form button:hover,.form button:active,.form button:focus {
279 | background: #86bfd4;
280 | }
281 | .form .message {
282 | margin: 15px 0 0;
283 | color: #b3b3b3;
284 | font-size: 12px;
285 | }
286 | .form .message a {
287 | color: #4CAF50;
288 | text-decoration: none;
289 | }
290 | .form .optional-form {
291 | display: none;
292 | }
293 |
294 | .ten-more {
295 | padding-top: 10px;
296 | }
297 |
298 | .twenty-more {
299 | padding-top: 20px;
300 | }
301 |
302 | .start-join-container {
303 | font-family: 'Aclonica', sans-serif;
304 | color: #505b5e;
305 | width: 400px;
306 | display: flex;
307 | justify-content: space-evenly;
308 | align-items: center;
309 | }
310 |
311 | .links-container {
312 | padding-top: 5px;
313 | width: 400px;
314 | display: flex;
315 | justify-content: space-evenly;
316 | align-items: center;
317 | }
318 |
319 | .links-intro-padding {
320 | padding-top: 20px;
321 | padding-bottom: 25px;
322 | }
323 |
324 | .links {
325 | padding: 0;
326 | margin: 0;
327 | }
328 |
329 | .games-container {
330 | width: 400px;
331 | display: flex;
332 | justify-content: center;
333 | align-items: center;
334 | }
335 |
336 | .game {
337 | max-width: 1025px;
338 | }
339 |
340 | .games {
341 | width: 350px;
342 | }
343 |
344 | .filter-container {
345 | background: #FFFFFF;
346 | box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
347 | }
348 |
349 | .filter-form input::-webkit-input-placeholder {
350 | font-weight: 300;
351 | color: #7c8b8f;
352 | }
353 |
354 | .filter-form input::-moz-placeholder {
355 | font-weight: 300;
356 | color: #7c8b8f;
357 | }
358 |
359 | .filter-form input::input-placeholder {
360 | font-weight: 300;
361 | color: #7c8b8f;
362 | }
363 |
364 | .filter-games {
365 | box-sizing: border-box;
366 | line-height: 1.4em;
367 | font-size: 24px;
368 | width: 100%;
369 | padding: 16px 16px 16px 20px;
370 | border: none;
371 | background: rgba(0, 0, 0, 0.003);
372 | color: #505b5e;
373 | }
374 |
375 | .available {
376 | max-height: 478px;
377 | overflow: hidden;
378 | overflow-x: hidden;
379 | background: #FFFFFF;
380 | box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
381 | }
382 |
383 | .games-list {
384 | margin: 0;
385 | padding: 0;
386 | list-style: none;
387 | }
388 |
389 | .games-list li {
390 | width: 100%;
391 | font-size: 24px;
392 | border-bottom: 1px solid #ededed;
393 | padding-bottom: 15px;
394 | display: inline-block;
395 | background: url("/images/profile.png") no-repeat;
396 | overflow: hidden;
397 | }
398 |
399 | .games-list li:first-child {
400 | margin-top: 4px;
401 | }
402 |
403 | .games-list li:last-child {
404 | border-bottom: none;
405 | }
406 |
407 | .games h1 {
408 | width: 100%;
409 | color: rgba(80,91,94, .8);
410 | text-rendering: optimizeLegibility;
411 | font-size: 45px;
412 | font-weight: 100;
413 | text-align: center;
414 | }
415 |
416 | .available-container {
417 | display: flex;
418 | }
419 |
420 | .games-list li .title {
421 | color: #505b5e;
422 | word-break: break-all;
423 | padding-top: 8px;
424 | padding-bottom: 7px;
425 | padding-left: 85px;
426 | text-transform: uppercase;
427 | font-size: .8em;
428 | }
429 |
430 | .games-list li .username {
431 | font-family: 'Aclonica', sans-serif;
432 | display: block;
433 | }
434 |
435 | .games-list li .created {
436 | font-size: .7em;
437 | display: inline-block;
438 | color: #7c8b8f;
439 | }
440 |
441 | .games-list li .play-icon {
442 | align-self: center;
443 | margin-left: auto;
444 | margin-right: 10px;
445 | }
446 |
447 | .games-list li .nada {
448 | word-break: break-all;
449 | padding: 20px;
450 | display: block;
451 | line-height: 1.2;
452 | transition: color 0.4s;
453 | text-align: center;
454 | }
455 |
456 | .games-list li.no-games {
457 | text-align: center;
458 | font-weight: 300;
459 | color: #7c8b8f;
460 | background: none;
461 | padding-bottom: 0;
462 | }
463 |
464 | .games-list li.one {
465 | background-position: 0 1px;
466 | }
467 |
468 | .games-list li.two {
469 | background-position: 0 -75px;
470 | }
471 |
472 | .games-list li.three {
473 | background-position: 0 -150px;
474 | }
475 |
476 | .games-list li.four {
477 | background-position: 0 -226px;
478 | }
479 |
480 | .games-list li.five {
481 | background-position: 0 -300px;
482 | }
483 |
484 | .games-list li.six {
485 | background-position: 0 -374px;
486 | }
487 |
488 | .games-list li.seven {
489 | background-position: 0 -448px;
490 | }
491 |
492 | .games-list li.eight {
493 | background-position: 0 -525px;
494 | }
495 |
496 | .games-list li.nine {
497 | background-position: 0 -600px;
498 | }
499 |
500 | .games-list li.ten {
501 | background-position: 0 -674px;
502 | }
503 |
504 | .games-list li.eleven {
505 | background-position: 0 -749px;
506 | }
507 |
508 | .games-list li.twelve {
509 | background-position: 0 -823px;
510 | }
511 |
512 | .games-list li.thirteen {
513 | background-position: 0 -897px;
514 | }
515 |
516 | .games-list li.fourteen {
517 | background-position: 0 -973px;
518 | }
519 |
520 | .splash .overlay {
521 | position: absolute;
522 | left: 0;
523 | right: 0;
524 | top: 0;
525 | bottom: 0;
526 | background-color: rgba(0, 0, 0, 0.6);
527 | }
528 |
529 | .splash .content h1 {
530 | color: #00ff0a;
531 | font-size: 1.7em;
532 | padding: 30px;
533 | padding-top: 10px;
534 | padding-bottom: 25px;
535 | text-transform: uppercase;
536 | font-family: 'Aclonica', sans-serif;
537 | }
538 |
539 | .splash .content h2 {
540 | color: #ffffff;
541 | padding-bottom: 20px;
542 | font-family: 'Aclonica', sans-serif;
543 | }
544 |
545 | .splash .content {
546 | position: absolute;
547 | left: 0;
548 | right: 0;
549 | top: 0;
550 | bottom: 0;
551 | width: 425px;
552 | height: 250px;
553 | margin: auto;
554 | text-align: center;
555 | background-color: rgba(51, 51, 51, 0.9);
556 | -moz-border-radius: 5px;
557 | -webkit-border-radius: 5px;
558 | border-radius: 5px;
559 | -moz-box-shadow: 5px 5px 20px rgba(0, 0, 0, 0.8);
560 | -webkit-box-shadow: 5px 5px 20px rgba(0, 0, 0, 0.8);
561 | box-shadow: 5px 5px 20px rgba(0, 0, 0, 0.8);
562 | }
563 |
564 | .joined {
565 | padding-top: 20px;
566 | padding-bottom: 20px;
567 | }
568 |
569 | .score {
570 | padding-left: 10px;
571 | }
572 |
573 | @media only screen and (min-width : 414px) {
574 | .games-container {
575 | width: 414px;
576 | }
577 | .intro-container {
578 | width: 414px;
579 | }
580 | .login-container {
581 | width: 414px;
582 | }
583 | .start-join-container {
584 | width: 414px;
585 | }
586 | .links-container {
587 | width: 414px;
588 | }
589 | }
590 |
591 | @media only screen and (min-width : 415px) {
592 | .phoenix-header {
593 | max-width: 470px;
594 | }
595 | }
596 |
597 | .cards .card {
598 | position: relative;
599 | display: inline-block;
600 | width: 100px;
601 | height: 150px;
602 | margin: 2em;
603 | -moz-transition: opacity 0.5s;
604 | -o-transition: opacity 0.5s;
605 | -webkit-transition: opacity 0.5s;
606 | transition: opacity 0.5s;
607 | }
608 |
609 | .cards .card .front, .cards .card .back {
610 | border-radius: 5px;
611 | position: absolute;
612 | left: 0;
613 | right: 0;
614 | top: 0;
615 | bottom: 0;
616 | width: 100%;
617 | height: 100%;
618 | background-color: White;
619 | -moz-backface-visibility: hidden;
620 | -webkit-backface-visibility: hidden;
621 | backface-visibility: hidden;
622 | -moz-transform: translateZ(0);
623 | -ms-transform: translateZ(0);
624 | -webkit-transform: translateZ(0);
625 | transform: translateZ(0);
626 | -moz-transition: -moz-transform 0.6s;
627 | -o-transition: -o-transform 0.6s;
628 | -webkit-transition: -webkit-transform 0.6s;
629 | transition: transform 0.6s;
630 | -moz-transform-style: preserve-3d;
631 | -webkit-transform-style: preserve-3d;
632 | transform-style: preserve-3d;
633 | }
634 |
635 | .cards .card .back {
636 | background-image: url("/images/card.jpg");
637 | background-size: 90%;
638 | background-position: center;
639 | background-repeat: no-repeat;
640 | font-size: 12px;
641 | }
642 |
643 | .cards .card .front {
644 | -moz-transform: rotateY(-180deg);
645 | -ms-transform: rotateY(-180deg);
646 | -webkit-transform: rotateY(-180deg);
647 | transform: rotateY(-180deg);
648 | background-size: 90%;
649 | background-repeat: no-repeat;
650 | background-position: center;
651 | }
652 |
653 | .cards .card.flipped .back, .cards .card.found .back {
654 | -moz-transform: rotateY(180deg);
655 | -ms-transform: rotateY(180deg);
656 | -webkit-transform: rotateY(180deg);
657 | transform: rotateY(180deg);
658 | }
659 |
660 | .cards .card.flipped .front, .cards .card.found .front {
661 | -moz-transform: rotateY(0deg);
662 | -ms-transform: rotateY(0deg);
663 | -webkit-transform: rotateY(0deg);
664 | transform: rotateY(0deg);
665 | }
666 |
667 | .cards .card.found {
668 | opacity: 0.3;
669 | }
670 |
671 | .inactive {
672 | visibility: hidden;
673 | };
674 |
--------------------------------------------------------------------------------
/app/priv/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/favicon.ico
--------------------------------------------------------------------------------
/app/priv/static/images/card.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/card.jpg
--------------------------------------------------------------------------------
/app/priv/static/images/cards/eight.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/eight.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/eleven.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/eleven.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/fifteen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/fifteen.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/five.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/five.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/four.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/four.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/fourteen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/fourteen.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/nine.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/nine.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/one.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/one.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/seven.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/seven.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/six.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/six.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/ten.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/ten.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/thirteen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/thirteen.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/three.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/three.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/twelve.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/twelve.png
--------------------------------------------------------------------------------
/app/priv/static/images/cards/two.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/cards/two.png
--------------------------------------------------------------------------------
/app/priv/static/images/elixir.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/elixir.png
--------------------------------------------------------------------------------
/app/priv/static/images/phoenix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/phoenix.png
--------------------------------------------------------------------------------
/app/priv/static/images/play.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/app/priv/static/images/profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toranb/elixir-match/c63c94055688efa55ce62f3070fa01e08de7f6e3/app/priv/static/images/profile.png
--------------------------------------------------------------------------------
/app/priv/static/js/app.js:
--------------------------------------------------------------------------------
1 | // for phoenix_html support, including form and button helpers
2 | // copy the following scripts into your javascript bundle:
3 | // * https://raw.githubusercontent.com/phoenixframework/phoenix_html/v2.10.0/priv/static/phoenix_html.js
4 |
--------------------------------------------------------------------------------
/app/priv/static/js/phoenix.js:
--------------------------------------------------------------------------------
1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Phoenix=t():e.Phoenix=t()}(window,function(){return function(e){var t={};function n(i){if(t[i])return t[i].exports;var o=t[i]={i:i,l:!1,exports:{}};return e[i].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,i){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:i})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(i,o,function(t){return e[t]}.bind(null,o));return i},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=0)}([function(e,t,n){(function(t){e.exports=t.Phoenix=n(2)}).call(this,n(1))},function(e,t){var n;n=function(){return this}();try{n=n||Function("return this")()||(0,eval)("this")}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){"use strict";function i(e){return function(e){if(Array.isArray(e)){for(var t=0,n=new Array(e.length);t
0&&void 0!==arguments[0]?arguments[0]:this.timeout;if(this.joinedOnce)throw"tried to join multiple times. 'join' can only be called a single time per channel instance";return this.joinedOnce=!0,this.rejoin(e),this.joinPush}},{key:"onClose",value:function(e){this.on(p.close,e)}},{key:"onError",value:function(e){return this.on(p.error,function(t){return e(t)})}},{key:"on",value:function(e,t){var n=this.bindingRef++;return this.bindings.push({event:e,ref:n,callback:t}),n}},{key:"off",value:function(e,t){this.bindings=this.bindings.filter(function(n){return!(n.event===e&&(void 0===t||t===n.ref))})}},{key:"canPush",value:function(){return this.socket.isConnected()&&this.isJoined()}},{key:"push",value:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.timeout;if(!this.joinedOnce)throw"tried to push '".concat(e,"' to '").concat(this.topic,"' before joining. Use channel.join() before pushing events");var i=new m(this,e,function(){return t},n);return this.canPush()?i.send():(i.startTimeout(),this.pushBuffer.push(i)),i}},{key:"leave",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;this.state=f.leaving;var n=function(){e.socket.hasLogger()&&e.socket.log("channel","leave ".concat(e.topic)),e.trigger(p.close,"leave")},i=new m(this,p.leave,y({}),t);return i.receive("ok",function(){return n()}).receive("timeout",function(){return n()}),i.send(),this.canPush()||i.trigger("ok",{}),i}},{key:"onMessage",value:function(e,t,n){return t}},{key:"isLifecycleEvent",value:function(e){return d.indexOf(e)>=0}},{key:"isMember",value:function(e,t,n,i){return this.topic===e&&(!i||i===this.joinRef()||!this.isLifecycleEvent(t)||(this.socket.hasLogger()&&this.socket.log("channel","dropping outdated message",{topic:e,event:t,payload:n,joinRef:i}),!1))}},{key:"joinRef",value:function(){return this.joinPush.ref}},{key:"sendJoin",value:function(e){this.state=f.joining,this.joinPush.resend(e)}},{key:"rejoin",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.timeout;this.isLeaving()||this.sendJoin(e)}},{key:"trigger",value:function(e,t,n,i){var o=this.onMessage(e,t,n,i);if(t&&!o)throw"channel onMessage callbacks must return the payload, modified or unmodified";for(var r=0;r1&&void 0!==arguments[1]?arguments[1]:{};s(this,e),this.stateChangeCallbacks={open:[],close:[],error:[],message:[]},this.channels=[],this.sendBuffer=[],this.ref=0,this.timeout=i.timeout||l,this.transport=i.transport||u.WebSocket||j,this.defaultEncoder=k.encode,this.defaultDecoder=k.decode,this.transport!==j?(this.encode=i.encode||this.defaultEncoder,this.decode=i.decode||this.defaultDecoder):(this.encode=this.defaultEncoder,this.decode=this.defaultDecoder),this.heartbeatIntervalMs=i.heartbeatIntervalMs||3e4,this.reconnectAfterMs=i.reconnectAfterMs||function(e){return[1e3,2e3,5e3,1e4][e-1]||1e4},this.logger=i.logger||null,this.longpollerTimeout=i.longpollerTimeout||2e4,this.params=y(i.params||{}),this.endPoint="".concat(t,"/").concat(v.websocket),this.heartbeatTimer=null,this.pendingHeartbeatRef=null,this.reconnectTimer=new C(function(){n.teardown(function(){return n.connect()})},this.reconnectAfterMs)}return c(e,[{key:"protocol",value:function(){return location.protocol.match(/^https/)?"wss":"ws"}},{key:"endPointURL",value:function(){var e=R.appendParams(R.appendParams(this.endPoint,this.params()),{vsn:"2.0.0"});return"/"!==e.charAt(0)?e:"/"===e.charAt(1)?"".concat(this.protocol(),":").concat(e):"".concat(this.protocol(),"://").concat(location.host).concat(e)}},{key:"disconnect",value:function(e,t,n){this.reconnectTimer.reset(),this.teardown(e,t,n)}},{key:"connect",value:function(e){var t=this;e&&(console&&console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor"),this.params=y(e)),this.conn||(this.conn=new this.transport(this.endPointURL()),this.conn.timeout=this.longpollerTimeout,this.conn.onopen=function(){return t.onConnOpen()},this.conn.onerror=function(e){return t.onConnError(e)},this.conn.onmessage=function(e){return t.onConnMessage(e)},this.conn.onclose=function(e){return t.onConnClose(e)})}},{key:"log",value:function(e,t,n){this.logger(e,t,n)}},{key:"hasLogger",value:function(){return null!==this.logger}},{key:"onOpen",value:function(e){this.stateChangeCallbacks.open.push(e)}},{key:"onClose",value:function(e){this.stateChangeCallbacks.close.push(e)}},{key:"onError",value:function(e){this.stateChangeCallbacks.error.push(e)}},{key:"onMessage",value:function(e){this.stateChangeCallbacks.message.push(e)}},{key:"onConnOpen",value:function(){this.hasLogger()&&this.log("transport","connected to ".concat(this.endPointURL())),this.flushSendBuffer(),this.reconnectTimer.reset(),this.resetHeartbeat(),this.resetChannelTimers(),this.stateChangeCallbacks.open.forEach(function(e){return e()})}},{key:"resetHeartbeat",value:function(){var e=this;this.conn.skipHeartbeat||(this.pendingHeartbeatRef=null,clearInterval(this.heartbeatTimer),this.heartbeatTimer=setInterval(function(){return e.sendHeartbeat()},this.heartbeatIntervalMs))}},{key:"teardown",value:function(e,t,n){this.conn&&(this.conn.onclose=function(){},t?this.conn.close(t,n||""):this.conn.close(),this.conn=null),e&&e()}},{key:"onConnClose",value:function(e){this.hasLogger()&&this.log("transport","close",e),this.triggerChanError(),clearInterval(this.heartbeatTimer),e&&1e3!==e.code&&this.reconnectTimer.scheduleTimeout(),this.stateChangeCallbacks.close.forEach(function(t){return t(e)})}},{key:"onConnError",value:function(e){this.hasLogger()&&this.log("transport",e),this.triggerChanError(),this.stateChangeCallbacks.error.forEach(function(t){return t(e)})}},{key:"triggerChanError",value:function(){this.channels.forEach(function(e){return e.trigger(p.error)})}},{key:"connectionState",value:function(){switch(this.conn&&this.conn.readyState){case h.connecting:return"connecting";case h.open:return"open";case h.closing:return"closing";default:return"closed"}}},{key:"isConnected",value:function(){return"open"===this.connectionState()}},{key:"remove",value:function(e){this.channels=this.channels.filter(function(t){return t.joinRef()!==e.joinRef()})}},{key:"channel",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=new g(e,t,this);return this.channels.push(n),n}},{key:"push",value:function(e){var t=this;if(this.hasLogger()){var n=e.topic,i=e.event,o=e.payload,r=e.ref,s=e.join_ref;this.log("push","".concat(n," ").concat(i," (").concat(s,", ").concat(r,")"),o)}this.isConnected()?this.encode(e,function(e){return t.conn.send(e)}):this.sendBuffer.push(function(){return t.encode(e,function(e){return t.conn.send(e)})})}},{key:"makeRef",value:function(){var e=this.ref+1;return e===this.ref?this.ref=0:this.ref=e,this.ref.toString()}},{key:"sendHeartbeat",value:function(){if(this.isConnected()){if(this.pendingHeartbeatRef)return this.pendingHeartbeatRef=null,this.hasLogger()&&this.log("transport","heartbeat timeout. Attempting to re-establish connection"),void this.conn.close(1e3,"hearbeat timeout");this.pendingHeartbeatRef=this.makeRef(),this.push({topic:"phoenix",event:"heartbeat",payload:{},ref:this.pendingHeartbeatRef})}}},{key:"flushSendBuffer",value:function(){this.isConnected()&&this.sendBuffer.length>0&&(this.sendBuffer.forEach(function(e){return e()}),this.sendBuffer=[])}},{key:"onConnMessage",value:function(e){var t=this;this.decode(e.data,function(e){var n=e.topic,i=e.event,o=e.payload,r=e.ref,s=e.join_ref;r&&r===t.pendingHeartbeatRef&&(t.pendingHeartbeatRef=null),t.hasLogger()&&t.log("receive","".concat(o.status||""," ").concat(n," ").concat(i," ").concat(r&&"("+r+")"||""),o);for(var a=0;a1&&void 0!==arguments[1]?arguments[1]:{};s(this,e);var o=i.events||{state:"presence_state",diff:"presence_diff"};this.state={},this.pendingDiffs=[],this.channel=t,this.joinRef=null,this.caller={onJoin:function(){},onLeave:function(){},onSync:function(){}},this.channel.on(o.state,function(t){var i=n.caller,o=i.onJoin,r=i.onLeave,s=i.onSync;n.joinRef=n.channel.joinRef(),n.state=e.syncState(n.state,t,o,r),n.pendingDiffs.forEach(function(t){n.state=e.syncDiff(n.state,t,o,r)}),n.pendingDiffs=[],s()}),this.channel.on(o.diff,function(t){var i=n.caller,o=i.onJoin,r=i.onLeave,s=i.onSync;n.inPendingSyncState()?n.pendingDiffs.push(t):(n.state=e.syncDiff(n.state,t,o,r),s())})}return c(e,[{key:"onJoin",value:function(e){this.caller.onJoin=e}},{key:"onLeave",value:function(e){this.caller.onLeave=e}},{key:"onSync",value:function(e){this.caller.onSync=e}},{key:"list",value:function(t){return e.list(this.state,t)}},{key:"inPendingSyncState",value:function(){return!this.joinRef||this.joinRef!==this.channel.joinRef()}}],[{key:"syncState",value:function(e,t,n,i){var o=this,r=this.clone(e),s={},a={};return this.map(r,function(e,n){t[e]||(a[e]=n)}),this.map(t,function(e,t){var n=r[e];if(n){var i=t.metas.map(function(e){return e.phx_ref}),c=n.metas.map(function(e){return e.phx_ref}),u=t.metas.filter(function(e){return c.indexOf(e.phx_ref)<0}),h=n.metas.filter(function(e){return i.indexOf(e.phx_ref)<0});u.length>0&&(s[e]=t,s[e].metas=u),h.length>0&&(a[e]=o.clone(n),a[e].metas=h)}else s[e]=t}),this.syncDiff(r,{joins:s,leaves:a},n,i)}},{key:"syncDiff",value:function(e,t,n,o){var r=t.joins,s=t.leaves,a=this.clone(e);return n||(n=function(){}),o||(o=function(){}),this.map(r,function(e,t){var o=a[e];if(a[e]=t,o){var r,s=a[e].metas.map(function(e){return e.phx_ref}),c=o.metas.filter(function(e){return s.indexOf(e.phx_ref)<0});(r=a[e].metas).unshift.apply(r,i(c))}n(e,o,t)}),this.map(s,function(e,t){var n=a[e];if(n){var i=t.metas.map(function(e){return e.phx_ref});n.metas=n.metas.filter(function(e){return i.indexOf(e.phx_ref)<0}),o(e,n,t),0===n.metas.length&&delete a[e]}}),a}},{key:"list",value:function(e,t){return t||(t=function(e,t){return t}),this.map(e,function(e,n){return t(e,n)})}},{key:"map",value:function(e,t){return Object.getOwnPropertyNames(e).map(function(n){return t(n,e[n])})}},{key:"clone",value:function(e){return JSON.parse(JSON.stringify(e))}}]),e}(),C=function(){function e(t,n){s(this,e),this.callback=t,this.timerCalc=n,this.timer=null,this.tries=0}return c(e,[{key:"reset",value:function(){this.tries=0,this.clearTimer()}},{key:"restart",value:function(){var e=null!==this.timer;this.reset(),e&&this.scheduleTimeout()}},{key:"scheduleTimeout",value:function(){var e=this;this.clearTimer(),this.timer=setTimeout(function(){e.tries=e.tries+1,e.callback()},this.timerCalc(this.tries+1))}},{key:"clearTimer",value:function(){clearTimeout(this.timer),this.timer=null}}]),e}()}])});
--------------------------------------------------------------------------------
/app/priv/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/app/test/match/find_user_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Match.FindUserTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Match.Password
5 | alias Match.FindUser
6 |
7 | @user_one "A4E3400CF711E76BBD86C57CA"
8 | @user_two "EBDA4E3FDECD8F2759D96250A"
9 |
10 | test "find with username and password" do
11 | state = %{}
12 | icon = "one"
13 | username = "toranb"
14 | password = "abc123"
15 | password_hash = Password.hash(password)
16 |
17 | assert FindUser.with_username_and_password(state, username, password) === nil
18 |
19 | new_state = Map.put(state, @user_one, {username, icon, password_hash})
20 | assert FindUser.with_username_and_password(new_state, username, password) === @user_one
21 | assert FindUser.with_username_and_password(new_state, username, "abc12") === nil
22 |
23 | icon_two = "two"
24 | username_two = "jarrod"
25 | password_two = "def456"
26 | password_hash_two = Password.hash(password_two)
27 | last_state = Map.put(new_state, @user_two, {username_two, icon_two, password_hash_two})
28 | assert FindUser.with_username_and_password(last_state, username, password) === @user_one
29 | assert FindUser.with_username_and_password(last_state, username_two, password_two) === @user_two
30 | assert FindUser.with_username_and_password(last_state, username_two, "") === nil
31 | assert FindUser.with_username_and_password(last_state, username, password_two) === nil
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/app/test/match/game_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Match.GameTest do
2 | use ExUnit.Case, async: true
3 |
4 | test "new returns game struct with list of cards in random order" do
5 | state = Match.Game.new(2)
6 |
7 | %Match.Game{cards: cards, winner: winner, active_player_id: active_player_id, players: players} = state
8 |
9 | possible = [
10 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false},
11 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false},
12 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => false},
13 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false},
14 | %Match.Card{:id => "3F9A231", :name => "three", :image => "/images/cards/three.png", :flipped => false},
15 | %Match.Card{:id => "3F9A232", :name => "three", :image => "/images/cards/three.png", :flipped => false},
16 | %Match.Card{:id => "3F9AC51", :name => "four", :image => "/images/cards/four.png", :flipped => false},
17 | %Match.Card{:id => "3F9AC52", :name => "four", :image => "/images/cards/four.png", :flipped => false},
18 | %Match.Card{:id => "DA320E1", :name => "five", :image => "/images/cards/five.png", :flipped => false},
19 | %Match.Card{:id => "DA320E2", :name => "five", :image => "/images/cards/five.png", :flipped => false},
20 | %Match.Card{:id => "145AEF1", :name => "six", :image => "/images/cards/six.png", :flipped => false},
21 | %Match.Card{:id => "145AEF2", :name => "six", :image => "/images/cards/six.png", :flipped => false},
22 | %Match.Card{:id => "8808BC1", :name => "seven", :image => "/images/cards/seven.png", :flipped => false},
23 | %Match.Card{:id => "8808BC2", :name => "seven", :image => "/images/cards/seven.png", :flipped => false},
24 | %Match.Card{:id => "7720B41", :name => "eight", :image => "/images/cards/eight.png", :flipped => false},
25 | %Match.Card{:id => "7720B42", :name => "eight", :image => "/images/cards/eight.png", :flipped => false},
26 | %Match.Card{:id => "0073F91", :name => "nine", :image => "/images/cards/nine.png", :flipped => false},
27 | %Match.Card{:id => "0073F92", :name => "nine", :image => "/images/cards/nine.png", :flipped => false},
28 | %Match.Card{:id => "E6AAE31", :name => "ten", :image => "/images/cards/ten.png", :flipped => false},
29 | %Match.Card{:id => "E6AAE32", :name => "ten", :image => "/images/cards/ten.png", :flipped => false},
30 | %Match.Card{:id => "85C9871", :name => "eleven", :image => "/images/cards/eleven.png", :flipped => false},
31 | %Match.Card{:id => "85C9872", :name => "eleven", :image => "/images/cards/eleven.png", :flipped => false},
32 | %Match.Card{:id => "32252B1", :name => "twelve", :image => "/images/cards/twelve.png", :flipped => false},
33 | %Match.Card{:id => "32252B2", :name => "twelve", :image => "/images/cards/twelve.png", :flipped => false},
34 | %Match.Card{:id => "B9B1C01", :name => "thirteen", :image => "/images/cards/thirteen.png", :flipped => false},
35 | %Match.Card{:id => "B9B1C02", :name => "thirteen", :image => "/images/cards/thirteen.png", :flipped => false},
36 | %Match.Card{:id => "C4D0AD1", :name => "fourteen", :image => "/images/cards/fourteen.png", :flipped => false},
37 | %Match.Card{:id => "C4D0AD2", :name => "fourteen", :image => "/images/cards/fourteen.png", :flipped => false},
38 | %Match.Card{:id => "8F7E371", :name => "fifteen", :image => "/images/cards/fifteen.png", :flipped => false},
39 | %Match.Card{:id => "8F7E372", :name => "fifteen", :image => "/images/cards/fifteen.png", :flipped => false},
40 | ]
41 |
42 | [
43 | %Match.Card{id: id_one, name: name_one, image: image_one, flipped: flipped_one},
44 | %Match.Card{id: id_two, name: name_two, image: image_two, flipped: flipped_two},
45 | %Match.Card{id: id_three, name: name_three, image: image_three, flipped: flipped_three},
46 | %Match.Card{id: id_four, name: name_four, image: image_four, flipped: flipped_four}
47 | ] = cards
48 |
49 | assert Enum.count(cards) == 4
50 |
51 | %Match.Card{:name => name, :image => image, :flipped => flipped} = Enum.find(possible, fn(card) -> card.id === id_one end)
52 | assert name == name_one
53 | assert image == image_one
54 | assert flipped == flipped_one
55 |
56 | %Match.Card{:name => name, :image => image, :flipped => flipped} = Enum.find(possible, fn(card) -> card.id === id_two end)
57 | assert name == name_two
58 | assert image == image_two
59 | assert flipped == flipped_two
60 |
61 | %Match.Card{:name => name, :image => image, :flipped => flipped} = Enum.find(possible, fn(card) -> card.id === id_three end)
62 | assert name == name_three
63 | assert image == image_three
64 | assert flipped == flipped_three
65 |
66 | %Match.Card{:name => name, :image => image, :flipped => flipped} = Enum.find(possible, fn(card) -> card.id === id_four end)
67 | assert name == name_four
68 | assert image == image_four
69 | assert flipped == flipped_four
70 |
71 | assert winner == nil
72 | assert active_player_id == nil
73 | assert players == []
74 |
75 | another_state = Match.Game.new(2)
76 | %Match.Game{cards: another_cards} = another_state
77 | assert cards != another_cards
78 | end
79 |
80 | test "restart will keep active player along with players and unpair all cards" do
81 | state = %Match.Game{
82 | cards: [
83 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false, :paired => true},
84 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => true, :paired => false},
85 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false, :paired => true},
86 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false, :paired => false},
87 | %Match.Card{:id => "3F9A231", :name => "three", :image => "/images/cards/three.png", :flipped => false, :paired => true},
88 | %Match.Card{:id => "3F9A232", :name => "three", :image => "/images/cards/three.png", :flipped => false, :paired => true},
89 | ],
90 | winner: nil,
91 | players: [
92 | "EBDA4E",
93 | "01D3CC"
94 | ],
95 | active_player_id: "01D3CC",
96 | scores: %{"EBDA4E" => 1, "01D3CC" => 1}
97 | }
98 |
99 | state = Match.Game.flip(state, "3079822", "01D3CC")
100 |
101 | %Match.Game{cards: cards, winner: winner, active_player_id: active_player_id, players: players, scores: scores} = state
102 |
103 | assert Enum.count(cards) == 6
104 | assert winner == "01D3CC"
105 | assert active_player_id == "01D3CC"
106 | assert players == ["EBDA4E", "01D3CC"]
107 | assert scores == %{"EBDA4E" => 1, "01D3CC" => 2}
108 | Enum.each(cards, fn (card) -> assert card.paired == true end)
109 |
110 | restarted = Match.Game.restart(state)
111 |
112 | %Match.Game{cards: cards, winner: winner, active_player_id: active_player_id, players: players, scores: scores} = restarted
113 |
114 | assert Enum.count(cards) == 6
115 | assert winner == nil
116 | assert active_player_id == "01D3CC"
117 | assert players == ["EBDA4E", "01D3CC"]
118 | assert scores == %{}
119 |
120 | Enum.each(cards, fn (card) -> assert card.paired == false end)
121 | end
122 |
123 | test "flip will mark a given card with flipped attribute" do
124 | state = %Match.Game{
125 | cards: [
126 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false},
127 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => false},
128 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false},
129 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false},
130 | ],
131 | winner: nil,
132 | players: [
133 | "EBDA4E",
134 | "01D3CC"
135 | ],
136 | active_player_id: "01D3CC"
137 | }
138 |
139 | new_state = Match.Game.flip(state, "3079821", "01D3CC")
140 |
141 | %Match.Game{cards: cards, winner: winner, scores: scores} = new_state
142 | [
143 | %Match.Card{flipped: flip_one},
144 | %Match.Card{flipped: flip_two},
145 | %Match.Card{flipped: flip_three},
146 | %Match.Card{flipped: flip_four}
147 | ] = cards
148 |
149 | assert flip_one == false
150 | assert flip_two == true
151 | assert flip_three == false
152 | assert flip_four == false
153 |
154 | assert winner == nil
155 | assert scores == %{}
156 | end
157 |
158 | test "flipping the 2nd card in a match will mark the cards as paired and revert flipped to false" do
159 | state = %Match.Game{
160 | cards: [
161 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false},
162 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => true},
163 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false},
164 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false},
165 | ],
166 | winner: nil,
167 | players: [
168 | "EBDA4E",
169 | "01D3CC"
170 | ],
171 | active_player_id: "01D3CC"
172 | }
173 |
174 | new_state = Match.Game.flip(state, "3079822", "01D3CC")
175 |
176 | %Match.Game{cards: cards, winner: winner, scores: scores} = new_state
177 | [
178 | %Match.Card{flipped: flip_one, paired: paired_one},
179 | %Match.Card{flipped: flip_two, paired: paired_two},
180 | %Match.Card{flipped: flip_three, paired: paired_three},
181 | %Match.Card{flipped: flip_four, paired: paired_four},
182 | ] = cards
183 |
184 | assert flip_one == false
185 | assert flip_two == false
186 | assert flip_three == false
187 | assert flip_four == false
188 |
189 | assert paired_one == false
190 | assert paired_two == true
191 | assert paired_three == false
192 | assert paired_four == true
193 |
194 | assert winner == nil
195 | assert scores == %{"01D3CC" => 1}
196 | end
197 |
198 | test "flipping the 2nd card that is NOT a match will mark the cards as flipped but not paired" do
199 | state = %Match.Game{
200 | cards: [
201 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false},
202 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => true},
203 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false},
204 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false},
205 | ],
206 | winner: nil,
207 | animating: false,
208 | players: [
209 | "EBDA4E",
210 | "01D3CC"
211 | ],
212 | active_player_id: "01D3CC"
213 | }
214 |
215 | new_state = Match.Game.flip(state, "24CEDF1", "01D3CC")
216 |
217 | %Match.Game{cards: cards, winner: winner, animating: animating} = new_state
218 | [
219 | %Match.Card{flipped: flip_one, paired: paired_one},
220 | %Match.Card{flipped: flip_two, paired: paired_two},
221 | %Match.Card{flipped: flip_three, paired: paired_three},
222 | %Match.Card{flipped: flip_four, paired: paired_four},
223 | ] = cards
224 |
225 | assert flip_one == true
226 | assert flip_two == true
227 | assert flip_three == false
228 | assert flip_four == false
229 |
230 | assert paired_one == false
231 | assert paired_two == false
232 | assert paired_three == false
233 | assert paired_four == false
234 |
235 | assert winner == nil
236 | assert animating == true
237 | end
238 |
239 | test "unflip will reset animating to false and revert any flipped cards" do
240 | state = %Match.Game{
241 | cards: [
242 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => true},
243 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => true},
244 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false},
245 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false},
246 | ],
247 | winner: nil,
248 | animating: true,
249 | players: [
250 | "EBDA4E",
251 | "01D3CC"
252 | ],
253 | active_player_id: "01D3CC"
254 | }
255 |
256 | new_state = Match.Game.unflip(state)
257 |
258 | %Match.Game{cards: cards, winner: winner, animating: animating, active_player_id: active_player_id, players: players} = new_state
259 | [
260 | %Match.Card{flipped: flip_one, paired: paired_one},
261 | %Match.Card{flipped: flip_two, paired: paired_two},
262 | %Match.Card{flipped: flip_three, paired: paired_three},
263 | %Match.Card{flipped: flip_four, paired: paired_four},
264 | ] = cards
265 |
266 | [player_one, player_two] = players
267 |
268 | assert flip_one == false
269 | assert flip_two == false
270 | assert flip_three == false
271 | assert flip_four == false
272 |
273 | assert paired_one == false
274 | assert paired_two == false
275 | assert paired_three == false
276 | assert paired_four == false
277 |
278 | assert winner == nil
279 | assert animating == false
280 | assert active_player_id == "EBDA4E"
281 | assert player_one == "EBDA4E"
282 | assert player_two == "01D3CC"
283 | end
284 |
285 | test "flipping the last match will mark the winner as truthy and prepare restart will unflip/unpair each card" do
286 | state = %Match.Game{
287 | cards: [
288 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false, :paired => true},
289 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => true, :paired => false},
290 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false, :paired => true},
291 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false, :paired => false},
292 | ],
293 | winner: nil,
294 | players: [
295 | "EBDA4E",
296 | "01D3CC"
297 | ],
298 | active_player_id: "01D3CC",
299 | scores: %{"EBDA4E" => 1, "01D3CC" => 1}
300 | }
301 |
302 | new_state = Match.Game.flip(state, "3079822", "01D3CC")
303 |
304 | %Match.Game{cards: cards, winner: winner, scores: scores} = new_state
305 | [
306 | %Match.Card{flipped: flip_one, paired: paired_one},
307 | %Match.Card{flipped: flip_two, paired: paired_two},
308 | %Match.Card{flipped: flip_three, paired: paired_three},
309 | %Match.Card{flipped: flip_four, paired: paired_four},
310 | ] = cards
311 |
312 | assert flip_one == false
313 | assert flip_two == false
314 | assert flip_three == false
315 | assert flip_four == false
316 |
317 | assert paired_one == true
318 | assert paired_two == true
319 | assert paired_three == true
320 | assert paired_four == true
321 |
322 | assert winner == "01D3CC"
323 | assert scores == %{"EBDA4E" => 1, "01D3CC" => 2}
324 |
325 | prepare_state = Match.Game.prepare_restart(new_state)
326 |
327 | %Match.Game{cards: cards, winner: winner, scores: scores} = prepare_state
328 | [
329 | %Match.Card{flipped: flip_one, paired: paired_one},
330 | %Match.Card{flipped: flip_two, paired: paired_two},
331 | %Match.Card{flipped: flip_three, paired: paired_three},
332 | %Match.Card{flipped: flip_four, paired: paired_four},
333 | ] = cards
334 |
335 | assert flip_one == false
336 | assert flip_two == false
337 | assert flip_three == false
338 | assert flip_four == false
339 |
340 | assert paired_one == false
341 | assert paired_two == false
342 | assert paired_three == false
343 | assert paired_four == false
344 |
345 | assert winner == "01D3CC"
346 | assert scores == %{"EBDA4E" => 1, "01D3CC" => 2}
347 | end
348 |
349 | test "prepare restart does nothing if winner is nil" do
350 | state = %Match.Game{
351 | cards: [
352 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false, :paired => true},
353 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => true, :paired => false},
354 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false, :paired => true},
355 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false, :paired => false},
356 | ],
357 | winner: nil,
358 | players: [
359 | "EBDA4E",
360 | "01D3CC"
361 | ],
362 | active_player_id: "01D3CC",
363 | scores: %{"EBDA4E" => 1, "01D3CC" => 1}
364 | }
365 |
366 | new_state = Match.Game.prepare_restart(state)
367 |
368 | %Match.Game{cards: cards, winner: winner, scores: scores} = new_state
369 | [
370 | %Match.Card{flipped: flip_one, paired: paired_one},
371 | %Match.Card{flipped: flip_two, paired: paired_two},
372 | %Match.Card{flipped: flip_three, paired: paired_three},
373 | %Match.Card{flipped: flip_four, paired: paired_four},
374 | ] = cards
375 |
376 | assert flip_one == false
377 | assert flip_two == true
378 | assert flip_three == false
379 | assert flip_four == false
380 |
381 | assert paired_one == true
382 | assert paired_two == false
383 | assert paired_three == true
384 | assert paired_four == false
385 |
386 | assert winner == nil
387 | assert scores == %{"EBDA4E" => 1, "01D3CC" => 1}
388 | end
389 |
390 | test "flipping will append score when user id already in the scores map" do
391 | state = %Match.Game{
392 | cards: [
393 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false, :paired => true},
394 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => true, :paired => false},
395 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false, :paired => true},
396 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false, :paired => false},
397 | ],
398 | winner: nil,
399 | players: [
400 | "EBDA4E",
401 | "01D3CC"
402 | ],
403 | active_player_id: "01D3CC",
404 | scores: %{"01D3CC" => 1}
405 | }
406 |
407 | new_state = Match.Game.flip(state, "3079822", "01D3CC")
408 |
409 | %Match.Game{winner: winner, scores: scores} = new_state
410 |
411 | assert winner == "01D3CC"
412 | assert scores == %{"01D3CC" => 2}
413 | end
414 |
415 | test "flipping when animating is marked as true flip does nothing" do
416 | state = %Match.Game{
417 | cards: [
418 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false, :paired => false},
419 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => true, :paired => false},
420 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => true, :paired => false},
421 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false, :paired => false},
422 | ],
423 | winner: nil,
424 | animating: true
425 | }
426 |
427 | new_state = Match.Game.flip(state, "3079822", "01D3CC")
428 |
429 | %Match.Game{cards: cards, winner: winner, animating: animating} = new_state
430 | [
431 | %Match.Card{flipped: flip_one, paired: paired_one},
432 | %Match.Card{flipped: flip_two, paired: paired_two},
433 | %Match.Card{flipped: flip_three, paired: paired_three},
434 | %Match.Card{flipped: flip_four, paired: paired_four},
435 | ] = cards
436 |
437 | assert flip_one == false
438 | assert flip_two == true
439 | assert flip_three == true
440 | assert flip_four == false
441 |
442 | assert paired_one == false
443 | assert paired_two == false
444 | assert paired_three == false
445 | assert paired_four == false
446 |
447 | assert winner == nil
448 | assert animating == true
449 | end
450 |
451 | test "flipping when winner is marked as true flip does nothing" do
452 | state = %Match.Game{
453 | cards: [
454 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false, :paired => true},
455 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => false, :paired => true},
456 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false, :paired => true},
457 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false, :paired => true},
458 | ],
459 | winner: true,
460 | animating: false
461 | }
462 |
463 | new_state = Match.Game.flip(state, "3079822", "01D3CC")
464 |
465 | %Match.Game{cards: cards, winner: winner, animating: animating} = new_state
466 | [
467 | %Match.Card{flipped: flip_one, paired: paired_one},
468 | %Match.Card{flipped: flip_two, paired: paired_two},
469 | %Match.Card{flipped: flip_three, paired: paired_three},
470 | %Match.Card{flipped: flip_four, paired: paired_four},
471 | ] = cards
472 |
473 | assert flip_one == false
474 | assert flip_two == false
475 | assert flip_three == false
476 | assert flip_four == false
477 |
478 | assert paired_one == true
479 | assert paired_two == true
480 | assert paired_three == true
481 | assert paired_four == true
482 |
483 | assert winner == true
484 | assert animating == false
485 | end
486 |
487 | test "join will add player_id to players list and leave will remove player_id from players list" do
488 | state = %Match.Game{
489 | cards: [
490 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false},
491 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => false},
492 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false},
493 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false},
494 | ]
495 | }
496 |
497 | {:ok, new_state} = Match.Game.join(state, "01D3CC")
498 |
499 | %Match.Game{active_player_id: active_player_id, players: players} = new_state
500 | [ player ] = players
501 |
502 | assert Enum.count(players) == 1
503 | assert active_player_id == "01D3CC"
504 | assert player == "01D3CC"
505 |
506 | leave_state = Match.Game.leave(new_state, "01D3CC")
507 |
508 | %Match.Game{active_player_id: active_player_id, players: players} = leave_state
509 |
510 | assert Enum.count(players) == 0
511 | assert active_player_id == nil
512 |
513 | {:ok, joined_again} = Match.Game.join(leave_state, "01D3CC")
514 |
515 | %Match.Game{active_player_id: active_player_id, players: players} = joined_again
516 | [ player ] = players
517 |
518 | assert Enum.count(players) == 1
519 | assert active_player_id == "01D3CC"
520 | assert player == "01D3CC"
521 |
522 | {:ok, another_join} = Match.Game.join(joined_again, "EBDA4E")
523 |
524 | %Match.Game{active_player_id: active_player_id, players: players} = another_join
525 | [ player_one, player_two ] = players
526 |
527 | assert Enum.count(players) == 2
528 | assert active_player_id == "01D3CC"
529 | assert player_one == "EBDA4E"
530 | assert player_two == "01D3CC"
531 |
532 | {:error, message} = Match.Game.join(another_join, "01D3CC")
533 | assert message == "join failed: game was full"
534 |
535 | {:error, message} = Match.Game.join(another_join, "EBDA4E")
536 | assert message == "join failed: game was full"
537 |
538 | leave_again_state = Match.Game.leave(another_join, "01D3CC")
539 |
540 | %Match.Game{active_player_id: active_player_id, players: players} = leave_again_state
541 | [ player ] = players
542 |
543 | assert Enum.count(players) == 1
544 | assert active_player_id == "EBDA4E"
545 | assert player == "EBDA4E"
546 |
547 | leave_duplicate = Match.Game.leave(leave_again_state, "01D3CC")
548 |
549 | %Match.Game{active_player_id: active_player_id, players: players} = leave_duplicate
550 | [ player ] = players
551 |
552 | assert Enum.count(players) == 1
553 | assert active_player_id == "EBDA4E"
554 | assert player == "EBDA4E"
555 | end
556 |
557 | test "flipping the 2nd card in a match will NOT mark the cards as flipped when player_id is not active" do
558 | state = %Match.Game{
559 | cards: [
560 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => false},
561 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => true},
562 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false},
563 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false},
564 | ],
565 | winner: nil,
566 | players: [
567 | "EBDA4E",
568 | "01D3CC"
569 | ],
570 | active_player_id: "EBDA4E"
571 | }
572 |
573 | new_state = Match.Game.flip(state, "3079822", "01D3CC")
574 |
575 | %Match.Game{cards: cards, winner: winner} = new_state
576 | [
577 | %Match.Card{flipped: flip_one},
578 | %Match.Card{flipped: flip_two},
579 | %Match.Card{flipped: flip_three},
580 | %Match.Card{flipped: flip_four},
581 | ] = cards
582 |
583 | assert flip_one == false
584 | assert flip_two == true
585 | assert flip_three == false
586 | assert flip_four == false
587 |
588 | assert winner == nil
589 | end
590 |
591 | test "unflip will not explode with only 1 active player" do
592 | state = %Match.Game{
593 | cards: [
594 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => true},
595 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => true},
596 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false},
597 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false},
598 | ],
599 | winner: nil,
600 | animating: true,
601 | players: [
602 | "01D3CC"
603 | ],
604 | active_player_id: "01D3CC"
605 | }
606 |
607 | new_state = Match.Game.unflip(state)
608 | %Match.Game{players: players, active_player_id: active_player_id} = new_state
609 |
610 | [player] = players
611 |
612 | assert player == "01D3CC"
613 | assert active_player_id == "01D3CC"
614 | end
615 |
616 | test "flip and unflip will rotate between players correctly" do
617 | state = %Match.Game{
618 | cards: [
619 | %Match.Card{:id => "24CEDF1", :name => "one", :image => "/images/cards/one.png", :flipped => true},
620 | %Match.Card{:id => "3079821", :name => "two", :image => "/images/cards/two.png", :flipped => true},
621 | %Match.Card{:id => "24CEDF2", :name => "one", :image => "/images/cards/one.png", :flipped => false},
622 | %Match.Card{:id => "3079822", :name => "two", :image => "/images/cards/two.png", :flipped => false},
623 | ],
624 | winner: nil,
625 | animating: true,
626 | players: [
627 | "01D3CC",
628 | "EBDA4E",
629 | "ADDE47"
630 | ],
631 | active_player_id: "01D3CC"
632 | }
633 |
634 | state_two = Match.Game.unflip(state)
635 | %Match.Game{active_player_id: active_player_id} = state_two
636 |
637 | assert active_player_id == "EBDA4E"
638 |
639 | state_three = Match.Game.flip(state_two, "3079821", "EBDA4E")
640 | state_four = Match.Game.flip(state_three, "24CEDF1", "EBDA4E")
641 |
642 | state_five = Match.Game.unflip(state_four)
643 | %Match.Game{active_player_id: active_player_id} = state_five
644 |
645 | assert active_player_id == "ADDE47"
646 | end
647 | end
648 |
--------------------------------------------------------------------------------
/app/test/match/hash_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Match.HashTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Match.Hash
5 |
6 | @user_one "A4E3400CF711E76BBD86C57CA"
7 | @user_two "EBDA4E3FDECD8F2759D96250A"
8 |
9 | test "hmac will generate the same value for key consistently" do
10 | one = Hash.hmac("type:user", "toran")
11 | assert one == @user_one
12 |
13 | two = Hash.hmac("type:user", "toran")
14 | assert two == @user_one
15 |
16 | unknown = Hash.hmac("type:unknown", "toran")
17 | assert unknown != @user_one
18 |
19 | joel = Hash.hmac("type:user", "joel")
20 | assert joel == @user_two
21 | end
22 |
23 | test "hmac will generate hash with specified length" do
24 | bird = Hash.hmac("type:card", "bird", 6)
25 | assert bird == "6F0108"
26 | bird_again = Hash.hmac("type:card", "bird", 6)
27 | assert bird_again == "6F0108"
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/app/test/match/logon_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Match.LogonTest do
2 | use Match.DataCase, async: false
3 |
4 | alias Match.Logon
5 |
6 | @invite "elixir2019"
7 | @user_one "A4E3400CF711E76BBD86C57CA"
8 | @user_two "EBDA4E3FDECD8F2759D96250A"
9 |
10 | test "get and put work" do
11 | {:ok, _} = GenServer.start_link(Logon, :ok)
12 |
13 | {:ok, result} = Logon.put(:logon, "toran", "abcd1234", @invite)
14 | {id, username, _icon} = result
15 | assert id == @user_one
16 | assert username == "toran"
17 |
18 | {username, _icon} = Logon.get(:logon, @user_one)
19 | assert username === "toran"
20 | assert Logon.get_by_username_and_password(:logon, "toran", "abcd1234") === @user_one
21 | assert Logon.get_by_username_and_password(:logon, "joel", "defg4567") === nil
22 |
23 | {:ok, result} = Logon.put(:logon, "joel", "defg4567", @invite)
24 | {id, username, _icon} = result
25 | assert id == @user_two
26 | assert username == "joel"
27 |
28 | {username, _icon} = Logon.get(:logon, @user_one)
29 | assert username === "toran"
30 | {username, _icon} = Logon.get(:logon, @user_two)
31 | assert username === "joel"
32 |
33 | assert Logon.get_by_username_and_password(:logon, "toran", "abcd1234") === @user_one
34 | assert Logon.get_by_username_and_password(:logon, "joel", "defg4567") === @user_two
35 | assert Logon.get_by_username_and_password(:logon, "joel", "def4577") === nil
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/app/test/match/statistics_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Match.StatisticsTest do
2 | use Match.DataCase, async: true
3 |
4 | alias Match.Repo
5 | alias Match.Statistic
6 | alias Match.Statistics
7 |
8 | @user_one "A4E3400CF711E76BBD86C57CA"
9 | @user_two "EBDA4E3FDECD8F2759D96250A"
10 | @user_three "D3DFFDC4AFE0172529ED3CFA5"
11 |
12 | @valid_attrs %{user: @user_one, wins: 1, flips: 9}
13 |
14 | test "get_by user will return Statistic or nil" do
15 | result = Statistics.get_by(%{user: @user_one})
16 | assert result == nil
17 |
18 | Statistic
19 | |> struct(@valid_attrs)
20 | |> Repo.insert()
21 |
22 | result = Statistics.get_by(%{user: @user_one})
23 | %Match.Statistic{user: user, wins: wins, flips: flips} = result
24 | assert user == @user_one
25 | assert wins == 1
26 | assert flips == 9
27 | end
28 |
29 | test "all will return Statistics" do
30 | results = Statistics.all()
31 | assert results == []
32 |
33 | Statistic
34 | |> struct(@valid_attrs)
35 | |> Repo.insert()
36 |
37 | Statistic
38 | |> struct(%{user: @user_two, wins: 3, flips: 1})
39 | |> Repo.insert()
40 |
41 | results = Statistics.all()
42 | [ one, two ] = results
43 |
44 | %Match.Statistic{user: user, wins: wins, flips: flips} = one
45 | assert user == @user_one
46 | assert wins == 1
47 | assert flips == 9
48 |
49 | %Match.Statistic{user: user, wins: wins, flips: flips} = two
50 | assert user == @user_two
51 | assert wins == 3
52 | assert flips == 1
53 | end
54 |
55 | test "upsert_by will insert when no data present" do
56 | {:ok, result} = Statistics.upsert_by(@user_one, @valid_attrs)
57 | %Match.Statistic{user: user, wins: wins, flips: flips} = result
58 | assert user == @user_one
59 | assert wins == 1
60 | assert flips == 9
61 | end
62 |
63 | test "upsert_by will update when data present" do
64 | Statistic
65 | |> struct(@valid_attrs)
66 | |> Repo.insert()
67 |
68 | {:ok, result} = Statistics.upsert_by(@user_one, %{user: @user_one, wins: 2, flips: 18})
69 | %Match.Statistic{user: user, wins: wins, flips: flips} = result
70 | assert user == @user_one
71 | assert wins == 2
72 | assert flips == 18
73 | end
74 |
75 | test "parse will return 2 insert friendly maps in a list" do
76 | winner = @user_two
77 | scores = %{@user_one => 1, @user_two => 2}
78 |
79 | results = Statistic.parse(scores, winner)
80 | assert Enum.count(results) == 2
81 | [one, two] = results
82 |
83 | [{user, value}] = Map.to_list(one)
84 | %{wins: wins, flips: flips} = value
85 | assert user == @user_one
86 | assert wins == 0
87 | assert flips == 1
88 |
89 | [{user, value}] = Map.to_list(two)
90 | %{wins: wins, flips: flips} = value
91 | assert user == @user_two
92 | assert wins == 1
93 | assert flips == 2
94 | end
95 |
96 | test "transform_previous will return map with user as key" do
97 | Statistic
98 | |> struct(@valid_attrs)
99 | |> Repo.insert()
100 |
101 | Statistic
102 | |> struct(%{user: @user_two, wins: 3, flips: 1})
103 | |> Repo.insert()
104 |
105 | results = Statistics.all()
106 | [ one, two ] = Statistic.transform_previous(results)
107 |
108 | [{user, value}] = Map.to_list(one)
109 | %{wins: wins, flips: flips} = value
110 | assert user == @user_one
111 | assert wins == 1
112 | assert flips == 9
113 |
114 | [{user, value}] = Map.to_list(two)
115 | %{wins: wins, flips: flips} = value
116 | assert user == @user_two
117 | assert wins == 3
118 | assert flips == 1
119 | end
120 |
121 | test "get_persisted_stats will return Statistic for each in the database" do
122 | statistics = [%{@user_one => %{wins: 1, flips: 9}}, %{@user_three => %{wins: 2, flips: 25}}]
123 |
124 | scores = %{@user_one => 1, @user_two => 2}
125 | results = Statistic.get_persisted_stats(scores, statistics)
126 | assert Enum.count(results) == 1
127 | [ one ] = results
128 |
129 | [{user, value}] = Map.to_list(one)
130 | %{wins: wins, flips: flips} = value
131 | assert user == @user_one
132 | assert wins == 1
133 | assert flips == 9
134 | end
135 |
136 | test "merge_stats will correctly sum previous and new stats" do
137 | statistics = [%{@user_one => %{wins: 1, flips: 9}}, %{@user_two => %{wins: 0, flips: 2}}]
138 |
139 | stat_one = %{@user_two => %{wins: 1, flips: 0}}
140 | stat_two = %{@user_one => %{wins: 0, flips: 3}}
141 | stats = [ stat_one, stat_two ]
142 |
143 | results = Statistic.merge_stats(statistics, stats)
144 |
145 | assert Enum.count(results) == 2
146 |
147 | one = Map.get(results, @user_one)
148 | %{wins: wins, flips: flips} = one
149 | assert wins == 1
150 | assert flips == 12
151 |
152 | two = Map.get(results, @user_two)
153 | %{wins: wins, flips: flips} = two
154 | assert wins == 1
155 | assert flips == 2
156 | end
157 |
158 | test "insert_statistics will merge database with new stats data and insert" do
159 | Statistic
160 | |> struct(@valid_attrs)
161 | |> Repo.insert()
162 |
163 | data = Statistics.all()
164 | statistics = Statistic.transform_previous(data)
165 | winner = @user_two
166 | scores = %{@user_one => 1, @user_two => 2}
167 | results = Statistic.insert_statistics(scores, winner, statistics)
168 |
169 | assert Enum.count(results) == 2
170 | assert results == [
171 | %{@user_one => %{flips: 10, wins: 1}},
172 | %{@user_two => %{flips: 2, wins: 1}}
173 | ]
174 |
175 | inserted_one = Statistics.get_by(%{user: @user_one})
176 | %Match.Statistic{user: user, wins: wins, flips: flips} = inserted_one
177 | assert user == @user_one
178 | assert wins == 1
179 | assert flips == 10
180 |
181 | inserted_two = Statistics.get_by(%{user: @user_two})
182 | %Match.Statistic{user: user, wins: wins, flips: flips} = inserted_two
183 | assert user == @user_two
184 | assert wins == 1
185 | assert flips == 2
186 | end
187 |
188 | test "insert_statistics will properly calc wins and flips for both users" do
189 | Statistic
190 | |> struct(%{user: @user_one, wins: 1, flips: 10})
191 | |> Repo.insert()
192 | Statistic
193 | |> struct(%{user: @user_two, wins: 1, flips: 2})
194 | |> Repo.insert()
195 |
196 | data = Statistics.all()
197 | statistics = Statistic.transform_previous(data)
198 | winner = @user_one
199 | scores = %{@user_one => 2, @user_two => 1}
200 | results = Statistic.insert_statistics(scores, winner, statistics)
201 |
202 | assert Enum.count(results) == 2
203 | assert results == [
204 | %{@user_one => %{flips: 12, wins: 2}},
205 | %{@user_two => %{flips: 3, wins: 1}}
206 | ]
207 |
208 | inserted_one = Statistics.get_by(%{user: @user_one})
209 | %Match.Statistic{user: user, wins: wins, flips: flips} = inserted_one
210 | assert user == @user_one
211 | assert wins == 2
212 | assert flips == 12
213 |
214 | inserted_two = Statistics.get_by(%{user: @user_two})
215 | %Match.Statistic{user: user, wins: wins, flips: flips} = inserted_two
216 | assert user == @user_two
217 | assert wins == 1
218 | assert flips == 3
219 | end
220 |
221 | test "insert_statistics will continue to include previous stats even if user has 0 new flips" do
222 | Statistic
223 | |> struct(%{user: @user_one, wins: 2, flips: 12})
224 | |> Repo.insert()
225 | Statistic
226 | |> struct(%{user: @user_two, wins: 1, flips: 3})
227 | |> Repo.insert()
228 |
229 | data = Statistics.all()
230 | statistics = Statistic.transform_previous(data)
231 | winner = @user_one
232 | scores = %{@user_one => 3}
233 | results = Statistic.insert_statistics(scores, winner, statistics)
234 |
235 | assert Enum.count(results) == 2
236 | assert results == [
237 | %{@user_one => %{flips: 15, wins: 3}},
238 | %{@user_two => %{flips: 3, wins: 1}}
239 | ]
240 |
241 | inserted_one = Statistics.get_by(%{user: @user_one})
242 | %Match.Statistic{user: user, wins: wins, flips: flips} = inserted_one
243 | assert user == @user_one
244 | assert wins == 3
245 | assert flips == 15
246 |
247 | inserted_two = Statistics.get_by(%{user: @user_two})
248 | %Match.Statistic{user: user, wins: wins, flips: flips} = inserted_two
249 | assert user == @user_two
250 | assert wins == 1
251 | assert flips == 3
252 | end
253 |
254 | test "insert_statistics will not calc wins 0 during merge for existing stats that do not win" do
255 | Statistic
256 | |> struct(%{user: @user_one, wins: 3, flips: 15})
257 | |> Repo.insert()
258 | Statistic
259 | |> struct(%{user: @user_two, wins: 1, flips: 3})
260 | |> Repo.insert()
261 |
262 | data = Statistics.all()
263 | statistics = Statistic.transform_previous(data)
264 | winner = @user_one
265 | scores = %{@user_one => 2, @user_two => 1}
266 | results = Statistic.insert_statistics(scores, winner, statistics)
267 |
268 | assert Enum.count(results) == 2
269 | assert results == [
270 | %{@user_one => %{flips: 17, wins: 4}},
271 | %{@user_two => %{flips: 4, wins: 1}}
272 | ]
273 |
274 | inserted_one = Statistics.get_by(%{user: @user_one})
275 | %Match.Statistic{user: user, wins: wins, flips: flips} = inserted_one
276 | assert user == @user_one
277 | assert wins == 4
278 | assert flips == 17
279 |
280 | inserted_two = Statistics.get_by(%{user: @user_two})
281 | %Match.Statistic{user: user, wins: wins, flips: flips} = inserted_two
282 | assert user == @user_two
283 | assert wins == 1
284 | assert flips == 4
285 | end
286 |
287 | end
288 |
--------------------------------------------------------------------------------
/app/test/match/users_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Match.UsersTest do
2 | use Match.DataCase, async: true
3 |
4 | alias Match.Repo
5 | alias Match.User
6 |
7 | @id "A4E3400CF711E76BBD86C57CA"
8 | @valid_attrs %{id: @id, username: "toran", password: "abcd1234", invite: "elixir2019", icon: "one"}
9 |
10 | test "changeset is invalid if invite is wrong" do
11 | attrs = @valid_attrs |> Map.put(:invite, "foo")
12 | changeset = User.changeset(%User{}, attrs)
13 | refute changeset.valid?
14 | assert Map.get(changeset, :errors) == [invite: {"invalid invite code", []}]
15 | end
16 |
17 | test "changeset is fine with correct invite code" do
18 | attrs = @valid_attrs |> Map.put(:invite, "elixir2019")
19 | changeset = User.changeset(%User{}, attrs)
20 | assert changeset.valid?
21 | assert Map.get(changeset, :errors) == []
22 | end
23 |
24 | test "changeset is invalid if username is too short" do
25 | attrs = @valid_attrs |> Map.put(:username, "abc")
26 | changeset = User.changeset(%User{}, attrs)
27 | refute changeset.valid?
28 | assert Map.get(changeset, :errors) == [username: {"username must be 4-12 characters", [count: 4, validation: :length, kind: :min, type: :string]}]
29 | end
30 |
31 | test "changeset is invalid if username is too long" do
32 | attrs = @valid_attrs |> Map.put(:username, "abcdefghijklm")
33 | changeset = User.changeset(%User{}, attrs)
34 | refute changeset.valid?
35 | assert Map.get(changeset, :errors) == [username: {"username must be 4-12 characters", [count: 12, validation: :length, kind: :max, type: :string]}]
36 | end
37 |
38 | test "changeset is fine with 4 char username" do
39 | attrs = @valid_attrs |> Map.put(:username, "abcd")
40 | changeset = User.changeset(%User{}, attrs)
41 | assert changeset.valid?
42 | assert Map.get(changeset, :errors) == []
43 | end
44 |
45 | test "changeset is fine with 12 char username" do
46 | attrs = @valid_attrs |> Map.put(:username, "abcdefghijkl")
47 | changeset = User.changeset(%User{}, attrs)
48 | assert changeset.valid?
49 | assert Map.get(changeset, :errors) == []
50 | end
51 |
52 | test "changeset is invalid if password is too short" do
53 | attrs = @valid_attrs |> Map.put(:password, "abcdefg")
54 | changeset = User.changeset(%User{}, attrs)
55 | refute changeset.valid?
56 | assert Map.get(changeset, :errors) == [password: {"password must be 8-20 characters", [count: 8, validation: :length, kind: :min, type: :string]}]
57 | end
58 |
59 | test "changeset is invalid if password is too long" do
60 | attrs = @valid_attrs |> Map.put(:password, "abcdefghijklmnopqrstu")
61 | changeset = User.changeset(%User{}, attrs)
62 | refute changeset.valid?
63 | assert Map.get(changeset, :errors) == [password: {"password must be 8-20 characters", [count: 20, validation: :length, kind: :max, type: :string]}]
64 | end
65 |
66 | test "changeset is fine with 20 char password" do
67 | attrs = @valid_attrs |> Map.put(:password, "abcdefghijklmnopqrst")
68 | changeset = User.changeset(%User{}, attrs)
69 | assert changeset.valid?
70 | assert Map.get(changeset, :errors) == []
71 | end
72 |
73 | test "changeset is fine with 8 char password" do
74 | attrs = @valid_attrs |> Map.put(:password, "abcdefgh")
75 | changeset = User.changeset(%User{}, attrs)
76 | assert changeset.valid?
77 | assert Map.get(changeset, :errors) == []
78 | end
79 |
80 | test "changeset is invalid if username is used already" do
81 | %User{}
82 | |> User.changeset(@valid_attrs)
83 | |> Repo.insert
84 |
85 | duplicate =
86 | %User{}
87 | |> User.changeset(@valid_attrs)
88 | assert {:error, changeset} = Repo.insert(duplicate)
89 | assert Map.get(changeset, :errors) == [id: {"username already exists", [constraint: :unique, constraint_name: "users_pkey"]}]
90 | end
91 | end
92 |
--------------------------------------------------------------------------------
/app/test/match_web/authenticator_test.exs:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.AuthenticatorTest do
2 | use MatchWeb.ConnCase, async: false
3 |
4 | test "login will authenticate the user and redirect unauthenticated requests", %{conn: conn} do
5 | name = "toran"
6 | password = "abcd1234"
7 | login = %{username: name, password: password}
8 | data = %{username: name, invite: "elixir2019", password: password}
9 |
10 | request = get(conn, Routes.page_path(conn, :new, %{visibility: "public"}))
11 | assert String.match?(html_response(request, 302), ~r/.*You are being \
30 | Process.exit(pid, :kill)
31 | purge(Match.Repo)
32 | purge(Match.Game)
33 | purge(Match.Process)
34 | end
35 |
36 | %{game_name: game_name}
37 | end
38 |
39 | test "each card will be rendered with correct click handler, value and background image", %{game_name: game_name} do
40 | {:ok, _} = Match.Session.join(game_name, @user_one.id)
41 | {:ok, _view, html_one} = LiveViewTest.mount(Endpoint, GameLive, session: session(@user_one, game_name))
42 |
43 | cards = Floki.find(html_one, ".card")
44 | assert Enum.count(cards) == 4
45 |
46 | assert ["card", "card", "card", "card"] == card_classes(cards)
47 | assert [@id_one_a, @id_one_b, @id_two_a, @id_two_b] == click_values(cards)
48 | assert ["flip", "flip", "flip", "flip"] == click_handlers(cards)
49 | assert ["background-image: url(#{@one})", "background-image: url(#{@one})", "background-image: url(#{@two})", "background-image: url(#{@two})"] == child_styles(cards)
50 | end
51 |
52 | test "flipping 2 incorrect matches will unflip after a brief pause in both clients", %{game_name: game_name} do
53 | {:ok, _} = Match.Session.join(game_name, @user_one.id)
54 | {:ok, view_one, html_one} = LiveViewTest.mount(Endpoint, GameLive, session: session(@user_one, game_name))
55 |
56 | {:ok, _} = Match.Session.join(game_name, @user_two.id)
57 | {:ok, view_two, html_two} = LiveViewTest.mount(Endpoint, GameLive, session: session(@user_two, game_name))
58 |
59 | click_thru_joined_modal(view_one, view_two)
60 |
61 | cards = Floki.find(html_one, ".card")
62 | assert ["card", "card", "card", "card"] == card_classes(cards)
63 |
64 | cards = Floki.find(html_two, ".card")
65 | assert ["card", "card", "card", "card"] == card_classes(cards)
66 |
67 | html_one = LiveViewTest.render_click(view_one, :flip, @id_two_a)
68 | cards = Floki.find(html_one, ".card")
69 | assert ["card", "card", "card flipped", "card"] == card_classes(cards)
70 |
71 | html_two = LiveViewTest.render(view_two)
72 | cards = Floki.find(html_two, ".card")
73 | assert ["card", "card", "card flipped", "card"] == card_classes(cards)
74 |
75 | html_one = LiveViewTest.render_click(view_one, :flip, @id_one_b)
76 | cards = Floki.find(html_one, ".card")
77 | assert ["card", "card flipped", "card flipped", "card"] == card_classes(cards)
78 |
79 | html_two = LiveViewTest.render(view_two)
80 | cards = Floki.find(html_two, ".card")
81 | assert ["card", "card flipped", "card flipped", "card"] == card_classes(cards)
82 |
83 | Process.sleep(20)
84 |
85 | html_one = LiveViewTest.render(view_one)
86 | cards = Floki.find(html_one, ".card")
87 | assert ["card", "card", "card", "card"] == card_classes(cards)
88 |
89 | html_two = LiveViewTest.render(view_two)
90 | cards = Floki.find(html_two, ".card")
91 | assert ["card", "card", "card", "card"] == card_classes(cards)
92 | end
93 |
94 | test "flipping 2 correct matches will mark a pair in both clients", %{game_name: game_name} do
95 | {:ok, _} = Match.Session.join(game_name, @user_one.id)
96 | {:ok, view_one, html_one} = LiveViewTest.mount(Endpoint, GameLive, session: session(@user_one, game_name))
97 |
98 | {:ok, _} = Match.Session.join(game_name, @user_two.id)
99 | {:ok, view_two, html_two} = LiveViewTest.mount(Endpoint, GameLive, session: session(@user_two, game_name))
100 |
101 | click_thru_joined_modal(view_one, view_two)
102 |
103 | cards = Floki.find(html_one, ".card")
104 | assert ["card", "card", "card", "card"] == card_classes(cards)
105 |
106 | cards = Floki.find(html_two, ".card")
107 | assert ["card", "card", "card", "card"] == card_classes(cards)
108 |
109 | html_one = LiveViewTest.render_click(view_one, :flip, @id_two_a)
110 | cards = Floki.find(html_one, ".card")
111 | assert ["card", "card", "card flipped", "card"] == card_classes(cards)
112 |
113 | html_two = LiveViewTest.render(view_two)
114 | cards = Floki.find(html_two, ".card")
115 | assert ["card", "card", "card flipped", "card"] == card_classes(cards)
116 |
117 | html_one = LiveViewTest.render_click(view_one, :flip, @id_two_b)
118 | cards = Floki.find(html_one, ".card")
119 | assert ["card", "card", "card found", "card found"] == card_classes(cards)
120 |
121 | html_two = LiveViewTest.render(view_two)
122 | cards = Floki.find(html_two, ".card")
123 | assert ["card", "card", "card found", "card found"] == card_classes(cards)
124 | end
125 |
126 | test "players rotate between turns in both clients and winner shown in modal", %{game_name: game_name} do
127 | {:ok, _} = Match.Session.join(game_name, @user_one.id)
128 | {:ok, view_one, html_one} = LiveViewTest.mount(Endpoint, GameLive, session: session(@user_one, game_name))
129 |
130 | {:ok, _} = Match.Session.join(game_name, @user_two.id)
131 | {:ok, view_two, html_two} = LiveViewTest.mount(Endpoint, GameLive, session: session(@user_two, game_name))
132 |
133 | click_thru_joined_modal(view_one, view_two)
134 |
135 | {player, score} = active_player_score(html_one)
136 | assert player == @toran
137 | assert score == "0"
138 |
139 | {player, score} = active_player_score(html_two)
140 | assert player == @toran
141 | assert score == "0"
142 |
143 | LiveViewTest.render_click(view_one, :flip, @id_two_a)
144 | LiveViewTest.render_click(view_one, :flip, @id_one_b)
145 |
146 | Process.sleep(20)
147 |
148 | html_one = LiveViewTest.render(view_one)
149 | {player, score} = active_player_score(html_one)
150 | assert player == @brandon
151 | assert score == "0"
152 |
153 | html_two = LiveViewTest.render(view_two)
154 | {player, score} = active_player_score(html_two)
155 | assert player == @brandon
156 | assert score == "0"
157 |
158 | LiveViewTest.render_click(view_two, :flip, @id_one_a)
159 | LiveViewTest.render_click(view_two, :flip, @id_one_b)
160 |
161 | html_two = LiveViewTest.render(view_two)
162 | {player, score} = active_player_score(html_two)
163 | assert player == @brandon
164 | assert score == "1"
165 |
166 | html_one = LiveViewTest.render(view_one)
167 | {player, score} = active_player_score(html_one)
168 | assert player == @brandon
169 | assert score == "1"
170 |
171 | LiveViewTest.render_click(view_two, :flip, @id_two_a)
172 | LiveViewTest.render_click(view_two, :flip, @id_two_b)
173 |
174 | html_two = LiveViewTest.render(view_two)
175 | {player, score} = active_player_score(html_two)
176 | assert player == @brandon
177 | assert score == "2"
178 |
179 | html_one = LiveViewTest.render(view_one)
180 | {player, score} = active_player_score(html_one)
181 | assert player == @brandon
182 | assert score == "2"
183 |
184 | assert Enum.count(modal(html_one)) == 1
185 | assert Enum.count(modal(html_two)) == 1
186 | assert winner(html_one) == "#{@brandon} won!"
187 | assert winner(html_two) == "#{@brandon} won!"
188 |
189 | LiveViewTest.render_click(view_two, :prepare_restart, "")
190 | Process.sleep(2)
191 |
192 | html_one = LiveViewTest.render(view_one)
193 | assert Enum.count(modal(html_one)) == 0
194 | html_two = LiveViewTest.render(view_two)
195 | assert Enum.count(modal(html_two)) == 0
196 |
197 | cards = Floki.find(html_one, ".card")
198 | assert ["card", "card", "card", "card"] == card_classes(cards)
199 | cards = Floki.find(html_two, ".card")
200 | assert ["card", "card", "card", "card"] == card_classes(cards)
201 |
202 | {player, score} = active_player_score(html_one)
203 | assert player == @brandon
204 | assert score == "0"
205 |
206 | {player, score} = active_player_score(html_two)
207 | assert player == @brandon
208 | assert score == "0"
209 | end
210 |
211 | test "error modal shows up when backing session killed", %{game_name: game_name} do
212 | {:ok, _} = Match.Session.join(game_name, @user_one.id)
213 | {:ok, view_one, _html_one} = LiveViewTest.mount(Endpoint, GameLive, session: session(@user_one, game_name))
214 |
215 | {:ok, _} = Match.Session.join(game_name, @user_two.id)
216 | {:ok, view_two, _html_two} = LiveViewTest.mount(Endpoint, GameLive, session: session(@user_two, game_name))
217 |
218 | click_thru_joined_modal(view_one, view_two)
219 |
220 | pid = Match.Session.session_pid(game_name)
221 | Process.exit(pid, :shutdown)
222 |
223 | html_one = LiveViewTest.render(view_one)
224 | assert Enum.count(modal(html_one)) == 0
225 | html_two = LiveViewTest.render(view_two)
226 | assert Enum.count(modal(html_two)) == 0
227 |
228 | LiveViewTest.render_click(view_one, :flip, @id_two_a)
229 |
230 | html_one = LiveViewTest.render(view_one)
231 | assert Enum.count(modal(html_one)) == 1
232 | assert error(html_one) == "an error occurred"
233 |
234 | html_two = LiveViewTest.render(view_two)
235 | assert Enum.count(modal(html_two)) == 0
236 | end
237 |
238 | defp modal(html) do
239 | Floki.find(html, ".splash")
240 | end
241 |
242 | defp winner(html) do
243 | Floki.find(html, ".content h1") |> Floki.text
244 | end
245 |
246 | defp joined(html) do
247 | Floki.find(html, ".content h1") |> Floki.text
248 | end
249 |
250 | defp error(html) do
251 | Floki.find(html, ".content h1") |> Floki.text
252 | end
253 |
254 | defp click_thru_joined_modal(view_one, view_two) do
255 | html_one = LiveViewTest.render(view_one)
256 | html_two = LiveViewTest.render(view_two)
257 |
258 | assert Enum.count(modal(html_one)) == 1
259 | assert Enum.count(modal(html_two)) == 0
260 |
261 | assert joined(html_one) == "brandon joined!"
262 |
263 | LiveViewTest.render_click(view_one, :continue, "")
264 |
265 | html_one = LiveViewTest.render(view_one)
266 | html_two = LiveViewTest.render(view_two)
267 |
268 | assert Enum.count(modal(html_one)) == 0
269 | assert Enum.count(modal(html_two)) == 0
270 | end
271 |
272 | defp card_classes(cards) do
273 | cards
274 | |> Floki.attribute("class")
275 | |> Enum.map(&(String.trim(&1)))
276 | end
277 |
278 | defp click_handlers(cards) do
279 | cards
280 | |> Floki.attribute("phx-click")
281 | end
282 |
283 | defp click_values(cards) do
284 | cards
285 | |> Floki.attribute("phx-value")
286 | end
287 |
288 | defp child_styles(cards) do
289 | cards
290 | |> Enum.map(fn({_tag, _attr, child}) ->
291 | [_, front] = child
292 | [attribute] = Floki.attribute(front, "style")
293 | attribute
294 | end)
295 | end
296 |
297 | defp active_player_score(html) do
298 | player = Floki.find(html, ".active-player") |> Floki.text
299 | score = Floki.find(html, ".active-player-score") |> Floki.text
300 | {player, score}
301 | end
302 |
303 | defp session(current_user, game_name) do
304 | %{:id => id, :username => username} = current_user
305 | %{
306 | game_name: game_name,
307 | player_id: id,
308 | username: username,
309 | joined_id: nil,
310 | error: nil
311 | }
312 | end
313 |
314 | defp patch_random do
315 | Code.eval_string """
316 | defmodule Match.Random do
317 | def take_random(cards, number) do
318 | Enum.take(cards, number)
319 | end
320 | end
321 | """
322 | end
323 |
324 | defp patch_process do
325 | Code.eval_string """
326 | defmodule Match.Process do
327 | def sleep(t) do
328 | Process.sleep(t)
329 | end
330 | end
331 | """
332 | end
333 |
334 | defp patch_repo do
335 | Code.eval_string """
336 | defmodule Match.Repo do
337 | def all(_), do: nil
338 | def get_by(_, _), do: nil
339 | def get_by(_, _, _), do: nil
340 | def insert_or_update(_), do: nil
341 | end
342 | """
343 | end
344 |
345 | defp purge(module) do
346 | :code.purge(module)
347 | :code.delete(module)
348 | end
349 | end
350 |
--------------------------------------------------------------------------------
/app/test/match_web/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.ErrorViewTest do
2 | use MatchWeb.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(MatchWeb.ErrorView, "404.html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(MatchWeb.ErrorView, "500.html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/app/test/match_web/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.LayoutViewTest do
2 | use MatchWeb.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------
/app/test/match_web/views/page_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.PageViewTest do
2 | use MatchWeb.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------
/app/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | it cannot be async. For this reason, every test runs
12 | inside a transaction which is reset at the beginning
13 | of the test unless the test case is marked as async.
14 | """
15 |
16 | use ExUnit.CaseTemplate
17 |
18 | using do
19 | quote do
20 | # Import conveniences for testing with channels
21 | use Phoenix.ChannelTest
22 |
23 | # The default endpoint for testing
24 | @endpoint MatchWeb.Endpoint
25 | end
26 | end
27 |
28 | setup tags do
29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Match.Repo)
30 |
31 | unless tags[:async] do
32 | Ecto.Adapters.SQL.Sandbox.mode(Match.Repo, {:shared, self()})
33 | end
34 |
35 | :ok
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/app/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule MatchWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | it cannot be async. For this reason, every test runs
12 | inside a transaction which is reset at the beginning
13 | of the test unless the test case is marked as async.
14 | """
15 |
16 | use ExUnit.CaseTemplate
17 |
18 | using do
19 | quote do
20 | # Import conveniences for testing with connections
21 | use Phoenix.ConnTest
22 | alias MatchWeb.Router.Helpers, as: Routes
23 |
24 | # The default endpoint for testing
25 | @endpoint MatchWeb.Endpoint
26 | end
27 | end
28 |
29 | setup tags do
30 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Match.Repo)
31 |
32 | unless tags[:async] do
33 | Ecto.Adapters.SQL.Sandbox.mode(Match.Repo, {:shared, self()})
34 | end
35 |
36 | {:ok, conn: Phoenix.ConnTest.build_conn()}
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/app/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Match.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | it cannot be async. For this reason, every test runs
11 | inside a transaction which is reset at the beginning
12 | of the test unless the test case is marked as async.
13 | """
14 |
15 | use ExUnit.CaseTemplate
16 |
17 | using do
18 | quote do
19 | alias Match.Repo
20 |
21 | import Ecto
22 | import Ecto.Changeset
23 | import Ecto.Query
24 | import Match.DataCase
25 | end
26 | end
27 |
28 | setup tags do
29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Match.Repo)
30 |
31 | unless tags[:async] do
32 | Ecto.Adapters.SQL.Sandbox.mode(Match.Repo, {:shared, self()})
33 | end
34 |
35 | :ok
36 | end
37 |
38 | @doc """
39 | A helper that transforms changeset errors into a map of messages.
40 |
41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
42 | assert "password is too short" in errors_on(changeset).password
43 | assert %{password: ["password is too short"]} = errors_on(changeset)
44 |
45 | """
46 | def errors_on(changeset) do
47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
48 | Enum.reduce(opts, message, fn {key, value}, acc ->
49 | String.replace(acc, "%{#{key}}", to_string(value))
50 | end)
51 | end)
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/app/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 | Ecto.Adapters.SQL.Sandbox.mode(Match.Repo, :manual)
3 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '2.2'
2 |
3 | services:
4 | app:
5 | build:
6 | context: ./app
7 | dockerfile: Dockerfile
8 | depends_on:
9 | postgres:
10 | condition: service_healthy
11 | env_file:
12 | - .env
13 | ports:
14 | - 4000
15 | entrypoint: ./entrypoint.sh
16 |
17 | postgres:
18 | image: postgres:11.1-alpine
19 | volumes:
20 | - "./volumes/postgres:/var/lib/postgresql/data"
21 | ports:
22 | - 5432:5432
23 | healthcheck:
24 | test: ["CMD-SHELL", "pg_isready -U postgres"]
25 | interval: 10s
26 | timeout: 5s
27 | retries: 5
28 |
29 | proxy:
30 | build:
31 | context: ./nginx
32 | dockerfile: Dockerfile
33 | ports:
34 | - 80:80
35 | links:
36 | - app
37 |
--------------------------------------------------------------------------------
/nginx/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginx
2 |
3 | COPY nginx.conf /etc/nginx/nginx.conf
4 |
--------------------------------------------------------------------------------
/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | worker_processes 4;
2 |
3 | events { worker_connections 1024; }
4 |
5 | http {
6 | sendfile on;
7 |
8 | upstream app_servers {
9 | server example_app_1:4000;
10 | }
11 |
12 | server {
13 | listen 80;
14 |
15 | location / {
16 | proxy_pass http://app_servers;
17 | proxy_redirect off;
18 | proxy_set_header Host $host;
19 | proxy_set_header X-Real-IP $remote_addr;
20 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
21 | proxy_set_header X-Forwarded-Host $server_name;
22 | proxy_http_version 1.1;
23 | proxy_set_header Upgrade $http_upgrade;
24 | proxy_set_header Connection "Upgrade";
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------