├── .credo.exs ├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── .tool-versions ├── README.md ├── assets ├── .babelrc ├── css │ └── app.css ├── js │ └── app.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── static │ ├── favicon.ico │ ├── images │ │ ├── logo.jpg │ │ ├── logo.png │ │ └── olog.png │ └── robots.txt ├── stylelint.config.js ├── tailwind.config.js └── webpack.config.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── prod.secret.exs └── test.exs ├── elixir_buildpack.config ├── lib ├── pixel_smash.ex ├── pixel_smash │ ├── accounts.ex │ ├── accounts │ │ ├── user.ex │ │ ├── user_notifier.ex │ │ └── user_token.ex │ ├── application.ex │ ├── attributes.ex │ ├── battles.ex │ ├── battles │ │ ├── action.ex │ │ ├── action_properties_deck.ex │ │ ├── battle.ex │ │ ├── battle_server.ex │ │ ├── battle_supervisor.ex │ │ ├── fighter.ex │ │ ├── log.ex │ │ ├── matchmaking.ex │ │ └── supervisor.ex │ ├── betting.ex │ ├── betting │ │ ├── bookie.ex │ │ └── supervisor.ex │ ├── gladiators.ex │ ├── gladiators │ │ ├── elo.ex │ │ ├── gladiator.ex │ │ ├── store.ex │ │ └── supervisor.ex │ ├── grids.ex │ ├── items.ex │ ├── items │ │ ├── item.ex │ │ └── pattern.ex │ ├── memory_repo.ex │ ├── repo.ex │ ├── sprites.ex │ ├── sprites │ │ ├── sprite.ex │ │ └── spritifier.ex │ ├── the_store.ex │ ├── wallets.ex │ └── wallets │ │ ├── supervisor.ex │ │ ├── vault.ex │ │ └── wallet.ex ├── pixel_smash_web.ex └── pixel_smash_web │ ├── channels │ └── user_socket.ex │ ├── components │ ├── finished_battle_component.ex │ ├── finished_battle_component.html.leex │ ├── gladiator_card_component.ex │ ├── gladiator_component.ex │ ├── in_progress_battle_component.ex │ ├── in_progress_battle_component.html.leex │ ├── item_component.ex │ ├── menu_component.ex │ ├── scheduled_battle_component.ex │ ├── scheduled_battle_component.html.leex │ ├── sprite_component.ex │ └── standings_component.ex │ ├── controllers │ ├── user_auth.ex │ ├── user_confirmation_controller.ex │ ├── user_registration_controller.ex │ ├── user_reset_password_controller.ex │ ├── user_session_controller.ex │ └── user_settings_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ ├── gladiator_live.ex │ ├── page_live.ex │ ├── page_live.html.leex │ ├── standings_live.ex │ └── store_live.ex │ ├── mount_helpers.ex │ ├── router.ex │ ├── telemetry.ex │ ├── templates │ ├── layout │ │ ├── _user_menu.html.eex │ │ ├── app.html.eex │ │ ├── live.html.leex │ │ └── root.html.leex │ ├── user_confirmation │ │ └── new.html.eex │ ├── user_registration │ │ └── new.html.eex │ ├── user_reset_password │ │ ├── edit.html.eex │ │ └── new.html.eex │ ├── user_session │ │ └── new.html.eex │ └── user_settings │ │ └── edit.html.eex │ └── views │ ├── error_helpers.ex │ ├── error_view.ex │ ├── layout_view.ex │ ├── user_confirmation_view.ex │ ├── user_registration_view.ex │ ├── user_reset_password_view.ex │ ├── user_session_view.ex │ └── user_settings_view.ex ├── mix.exs ├── mix.lock ├── package-lock.json ├── phoenix_static_buildpack.config ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── repo │ ├── migrations │ ├── .formatter.exs │ ├── 20200912002318_create_users_auth_tables.exs │ └── 20200912112204_add_gladiators_table.exs │ └── seeds.exs └── test ├── pixel_smash ├── accounts_test.exs └── doctests_test.exs ├── pixel_smash_web ├── controllers │ ├── user_auth_test.exs │ ├── user_confirmation_controller_test.exs │ ├── user_registration_controller_test.exs │ ├── user_reset_password_controller_test.exs │ ├── user_session_controller_test.exs │ └── user_settings_controller_test.exs ├── live │ └── page_live_test.exs └── views │ ├── error_view_test.exs │ └── layout_view_test.exs ├── support ├── channel_case.ex ├── conn_case.ex ├── data_case.ex └── fixtures │ └── accounts_fixtures.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: [ 68 | # 69 | ## Consistency Checks 70 | # 71 | {Credo.Check.Consistency.ExceptionNames, []}, 72 | {Credo.Check.Consistency.LineEndings, []}, 73 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 74 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 75 | {Credo.Check.Consistency.SpaceInParentheses, []}, 76 | {Credo.Check.Consistency.TabsOrSpaces, []}, 77 | 78 | # 79 | ## Design Checks 80 | # 81 | # You can customize the priority of any check 82 | # Priority values are: `low, normal, high, higher` 83 | # 84 | {Credo.Check.Design.AliasUsage, 85 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 86 | # You can also customize the exit_status of each check. 87 | # If you don't want TODO comments to cause `mix credo` to fail, just 88 | # set this value to 0 (zero). 89 | # 90 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 91 | {Credo.Check.Design.TagFIXME, []}, 92 | 93 | # 94 | ## Readability Checks 95 | # 96 | {Credo.Check.Readability.AliasOrder, []}, 97 | {Credo.Check.Readability.FunctionNames, []}, 98 | {Credo.Check.Readability.LargeNumbers, []}, 99 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 100 | {Credo.Check.Readability.ModuleAttributeNames, []}, 101 | {Credo.Check.Readability.ModuleDoc, false}, 102 | {Credo.Check.Readability.ModuleNames, []}, 103 | {Credo.Check.Readability.ParenthesesInCondition, []}, 104 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 105 | {Credo.Check.Readability.PredicateFunctionNames, []}, 106 | {Credo.Check.Readability.PreferImplicitTry, []}, 107 | {Credo.Check.Readability.RedundantBlankLines, []}, 108 | {Credo.Check.Readability.Semicolons, []}, 109 | {Credo.Check.Readability.SpaceAfterCommas, []}, 110 | {Credo.Check.Readability.StringSigils, []}, 111 | {Credo.Check.Readability.TrailingBlankLine, []}, 112 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 113 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 114 | {Credo.Check.Readability.VariableNames, []}, 115 | 116 | # 117 | ## Refactoring Opportunities 118 | # 119 | {Credo.Check.Refactor.CondStatements, []}, 120 | {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 10]}, 121 | {Credo.Check.Refactor.FunctionArity, []}, 122 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 123 | {Credo.Check.Refactor.MapInto, []}, 124 | {Credo.Check.Refactor.MatchInCondition, []}, 125 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 126 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 127 | {Credo.Check.Refactor.Nesting, []}, 128 | {Credo.Check.Refactor.UnlessWithElse, []}, 129 | {Credo.Check.Refactor.WithClauses, []}, 130 | 131 | # 132 | ## Warnings 133 | # 134 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 135 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 136 | {Credo.Check.Warning.IExPry, []}, 137 | {Credo.Check.Warning.IoInspect, []}, 138 | {Credo.Check.Warning.LazyLogging, []}, 139 | {Credo.Check.Warning.MixEnv, false}, 140 | {Credo.Check.Warning.OperationOnSameValues, []}, 141 | {Credo.Check.Warning.OperationWithConstantResult, []}, 142 | {Credo.Check.Warning.RaiseInsideRescue, []}, 143 | {Credo.Check.Warning.UnusedEnumOperation, []}, 144 | {Credo.Check.Warning.UnusedFileOperation, []}, 145 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 146 | {Credo.Check.Warning.UnusedListOperation, []}, 147 | {Credo.Check.Warning.UnusedPathOperation, []}, 148 | {Credo.Check.Warning.UnusedRegexOperation, []}, 149 | {Credo.Check.Warning.UnusedStringOperation, []}, 150 | {Credo.Check.Warning.UnusedTupleOperation, []}, 151 | {Credo.Check.Warning.UnsafeExec, []}, 152 | 153 | # 154 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 155 | 156 | # 157 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 158 | # 159 | {Credo.Check.Readability.StrictModuleLayout, false}, 160 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 161 | {Credo.Check.Consistency.UnusedVariableNames, false}, 162 | {Credo.Check.Design.DuplicatedCode, false}, 163 | {Credo.Check.Readability.AliasAs, false}, 164 | {Credo.Check.Readability.MultiAlias, false}, 165 | {Credo.Check.Readability.Specs, false}, 166 | {Credo.Check.Readability.SinglePipe, false}, 167 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 168 | {Credo.Check.Refactor.ABCSize, false}, 169 | {Credo.Check.Refactor.AppendSingleItem, false}, 170 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 171 | {Credo.Check.Refactor.ModuleDependencies, false}, 172 | {Credo.Check.Refactor.NegatedIsNil, false}, 173 | {Credo.Check.Refactor.PipeChainStart, false}, 174 | {Credo.Check.Refactor.VariableRebinding, false}, 175 | {Credo.Check.Warning.LeakyEnvironment, false}, 176 | {Credo.Check.Warning.MapGetUnsafePass, false}, 177 | {Credo.Check.Warning.UnsafeToAtom, false} 178 | 179 | # 180 | # Custom checks can be created using `mix credo.gen.check`. 181 | # 182 | ] 183 | } 184 | ] 185 | } 186 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | env: 4 | 5 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 6 | 7 | on: 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | jobs: 14 | build: 15 | 16 | name: Build and test 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Setup PostgreSQL 23 | uses: Harmon758/postgresql-action@v1.0.0 24 | with: 25 | # Version of PostgreSQL to use 26 | postgresql version: latest # optional, default is latest 27 | # POSTGRES_USER - create the specified user with superuser power 28 | postgresql user: postgres 29 | # POSTGRES_PASSWORD - superuser password 30 | postgresql password: postgres # optional, default is 31 | 32 | - name: Set up Elixir 33 | uses: actions/setup-elixir@v1 34 | with: 35 | elixir-version: '1.10.4' # Define the elixir version [required] 36 | otp-version: '23.0.4' # Define the OTP version [required] 37 | - name: Restore dependencies cache 38 | uses: actions/cache@v2 39 | with: 40 | path: deps 41 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 42 | restore-keys: ${{ runner.os }}-mix- 43 | - name: Install dependencies 44 | run: | 45 | mix local.rebar --force 46 | mix local.hex --force 47 | mix deps.get 48 | mix compile 49 | 50 | - name: Setup db 51 | run: mix ecto.create 52 | - name: Run tests 53 | run: MIX_ENV=test mix coveralls.github 54 | -------------------------------------------------------------------------------- /.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 | pixel_smash-*.tar 24 | 25 | # If NPM crashes, it generates a log, let's ignore it too. 26 | npm-debug.log 27 | 28 | # The directory NPM downloads your dependencies sources to. 29 | /assets/node_modules/ 30 | 31 | # Since we are building assets from assets/, 32 | # we ignore priv/static. You may want to comment 33 | # this depending on your deployment strategy. 34 | /priv/static/ 35 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.10.4-otp-23 2 | erlang 23.0.4 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](assets/static/images/logo.jpg) 2 | 3 | # PixelSmash Areeeenaaa!!! 4 | 5 | Spectators! Get your bets ready for the next season of PixelSmash Arena! This time we have new Gladiators coming from all corners of the pixel-verse, ready to give it their all. As the fights progress some will triumph, some will be pixelated, and new fighters will be born from freak cosmic fusion accidents! 6 | 7 | The latest version is running [here](https://ruddy-rosy-mallard.gigalixirapp.com/). 8 | 9 | # Start the Season! 10 | 11 | To start your Phoenix server: 12 | 13 | * Install dependencies with `mix deps.get` 14 | * Create and migrate your database with `mix ecto.setup` 15 | * Install Node.js dependencies with `npm install` inside the `assets` directory 16 | * Start Phoenix endpoint with `mix phx.server` 17 | 18 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 19 | 20 | # Design Goals 21 | 22 | The goal of our project was to create a multiplayer online game where you bet on simulated matches between procedurally generated gladiators, later spending your earnings to outfit the gladiators with equipment, and to upgrade your account's capabilities. 23 | 24 | When the server starts up, we generate 16 random gladiators. In order to do so, we generate a random 5x10 grid of pixels. We then mirror this this grid along the y-axis to make a randomly generated sprite for the gladiator. Each pixel color also corresponds to a specific stat (like strength, vitality, magic), and different combinations of pixels 25 | lead to the gladiator possessing different skills, spells, and combat capabilities. 26 | 27 | Gladiators also track stats (wins, losses, draws, and an ELO ranking), which are used to calculate betting odds and payouts for the simulated battles. 28 | 29 | While the server is running, we also constantly run simulated battles between the gladiators. This is coordinated by a matchmaking server, that ensures there's always two series of battles being managed: the upcoming battles, and the currently running battles. Once all of the currently running battles finish, or a time limit is hit, the scheduled battles begin. 30 | 31 | Betting is facilitated by a bookie server, that uses Phoenix PubSub to subscribe to battles being scheduled, started, and finished. It maintains an open book, and a closed book, for the scheduled and running battles respectively, and accepts bets from user accounts against any of the scheduled battles. The odds are calculated using the ELO ratings of the matchup, and the payouts are adjusted accordingly. 32 | 33 | We also randomly generate items, that are available for purchase in the shop. Items also take the form of an image, and represent an overlay that can be applied over a gladiator. For example, a helmet will combine with the pixels on the gladiator's head, in order to transform the gladiator in some way. We were able to procedurally generate and display the items in a shop, however we ran out of time to implement actually purchasing them and equipping them to a gladiator. 34 | 35 | The battles work like a deckbuilding card game. Based on the gladiator's stats, they are given a deck consisting of actions, attacks, skills, and spells. They then take turns drawing cards from their deck and applying them to the battle in some way. When the deck runs out of cards, it is reshuffled. Once one gladiator's health reaches below zero, the battle is over. 36 | 37 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | import "../css/app.css" 5 | 6 | // webpack automatically bundles all modules in your 7 | // entry points. Those entry points can be configured 8 | // in "webpack.config.js". 9 | // 10 | // Import deps with the dep name or local files with a relative path, for example: 11 | // 12 | // import {Socket} from "phoenix" 13 | // import socket from "./socket" 14 | // 15 | import "phoenix_html" 16 | import {Socket} from "phoenix" 17 | import NProgress from "nprogress" 18 | import {LiveSocket} from "phoenix_live_view" 19 | 20 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 21 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) 22 | 23 | // Show progress bar on live navigation and form submits 24 | window.addEventListener("phx:page-loading-start", info => NProgress.start()) 25 | window.addEventListener("phx:page-loading-stop", info => NProgress.done()) 26 | 27 | // connect if there are any LiveViews on the page 28 | liveSocket.connect() 29 | 30 | // expose liveSocket on window for web console debug logs and latency simulation: 31 | // >> liveSocket.enableDebug() 32 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 33 | // >> liveSocket.disableLatencySim() 34 | window.liveSocket = liveSocket 35 | 36 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "description": " ", 4 | "license": "MIT", 5 | "scripts": { 6 | "deploy": "NODE_ENV=production webpack --mode production", 7 | "watch": "webpack --mode development --watch" 8 | }, 9 | "dependencies": { 10 | "phoenix": "file:../deps/phoenix", 11 | "phoenix_html": "file:../deps/phoenix_html", 12 | "phoenix_live_view": "file:../deps/phoenix_live_view", 13 | "nprogress": "^0.2.0" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.0.0", 17 | "@babel/preset-env": "^7.0.0", 18 | "babel-loader": "^8.0.0", 19 | "copy-webpack-plugin": "^5.1.1", 20 | "css-loader": "^3.4.2", 21 | "hard-source-webpack-plugin": "^0.13.1", 22 | "mini-css-extract-plugin": "^0.9.0", 23 | "optimize-css-assets-webpack-plugin": "^5.0.1", 24 | "postcss-loader": "^4.0.1", 25 | "stylelint": "^13.7.1", 26 | "stylelint-config-standard": "^20.0.0", 27 | "tailwindcss": "^1.8.8", 28 | "terser-webpack-plugin": "^2.3.2", 29 | "webpack": "4.41.5", 30 | "webpack-cli": "^3.3.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require("tailwindcss"), 4 | require("autoprefixer"), 5 | ], 6 | }; -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spawnfest/pixel-smash/95960085b236bfd9f96fe85bc3ec057598be4c44/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/images/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spawnfest/pixel-smash/95960085b236bfd9f96fe85bc3ec057598be4c44/assets/static/images/logo.jpg -------------------------------------------------------------------------------- /assets/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spawnfest/pixel-smash/95960085b236bfd9f96fe85bc3ec057598be4c44/assets/static/images/logo.png -------------------------------------------------------------------------------- /assets/static/images/olog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spawnfest/pixel-smash/95960085b236bfd9f96fe85bc3ec057598be4c44/assets/static/images/olog.png -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /assets/stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["stylelint-config-standard"], 3 | rules: { 4 | "at-rule-no-unknown": [ 5 | true, 6 | { 7 | ignoreAtRules: [ 8 | "tailwind", 9 | "apply", 10 | "variants", 11 | "responsive", 12 | "screen", 13 | ], 14 | }, 15 | ], 16 | "declaration-block-trailing-semicolon": null, 17 | "no-descending-specificity": null, 18 | }, 19 | }; -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [ 3 | "../**/*.html.eex", 4 | "../**/*.html.leex", 5 | "../**/views/**/*.ex", 6 | "../**/live/**/*.ex", 7 | "./js/**/*.js", 8 | ], 9 | theme: { 10 | extend: {}, 11 | }, 12 | variants: {}, 13 | plugins: [], 14 | }; 15 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const HardSourceWebpackPlugin = require('hard-source-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const TerserPlugin = require('terser-webpack-plugin'); 6 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 7 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 8 | 9 | module.exports = (env, options) => { 10 | const devMode = options.mode !== 'production'; 11 | 12 | return { 13 | optimization: { 14 | minimizer: [ 15 | new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }), 16 | new OptimizeCSSAssetsPlugin({}) 17 | ] 18 | }, 19 | entry: { 20 | 'app': glob.sync('./vendor/**/*.js').concat(['./js/app.js']) 21 | }, 22 | output: { 23 | filename: '[name].js', 24 | path: path.resolve(__dirname, '../priv/static/js'), 25 | publicPath: '/js/' 26 | }, 27 | devtool: devMode ? 'eval-cheap-module-source-map' : undefined, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.js$/, 32 | exclude: /node_modules/, 33 | use: { 34 | loader: 'babel-loader' 35 | } 36 | }, 37 | { 38 | test: /\.[s]?css$/, 39 | use: [ 40 | MiniCssExtractPlugin.loader, 41 | 'css-loader', 42 | 'postcss-loader', 43 | ], 44 | } 45 | ] 46 | }, 47 | plugins: [ 48 | new MiniCssExtractPlugin({ filename: '../css/app.css' }), 49 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }]) 50 | ] 51 | .concat(devMode ? [new HardSourceWebpackPlugin()] : []) 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /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 :pixel_smash, 11 | ecto_repos: [PixelSmash.Repo] 12 | 13 | # Configures the endpoint 14 | config :pixel_smash, PixelSmashWeb.Endpoint, 15 | url: [host: "localhost"], 16 | secret_key_base: "HgNJVNLyZg8X0JFK0Xo41z+RHcmkoeLZFzkQ9crgSvj6q4CrB+ugu/OqDO6950t+", 17 | render_errors: [view: PixelSmashWeb.ErrorView, accepts: ~w(html json), layout: false], 18 | pubsub_server: PixelSmash.PubSub, 19 | live_view: [signing_salt: "gUVZ8VIP"] 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 | # for Postgres should be PixelSmash.Repo 30 | config :pixel_smash, :repo, PixelSmash.MemoryRepo 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 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :pixel_smash, PixelSmash.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "pixel_smash_dev", 8 | hostname: "localhost", 9 | show_sensitive_data_on_connection_error: true, 10 | pool_size: 10 11 | 12 | # For development, we disable any cache and enable 13 | # debugging and code reloading. 14 | # 15 | # The watchers configuration can be used to run external 16 | # watchers to your application. For example, we use it 17 | # with webpack to recompile .js and .css sources. 18 | config :pixel_smash, PixelSmashWeb.Endpoint, 19 | http: [port: 4000], 20 | debug_errors: true, 21 | code_reloader: true, 22 | check_origin: false, 23 | watchers: [ 24 | node: [ 25 | "node_modules/webpack/bin/webpack.js", 26 | "--mode", 27 | "development", 28 | "--watch-stdin", 29 | cd: Path.expand("../assets", __DIR__) 30 | ] 31 | ] 32 | 33 | # ## SSL Support 34 | # 35 | # In order to use HTTPS in development, a self-signed 36 | # certificate can be generated by running the following 37 | # Mix task: 38 | # 39 | # mix phx.gen.cert 40 | # 41 | # Note that this task requires Erlang/OTP 20 or later. 42 | # Run `mix help phx.gen.cert` for more information. 43 | # 44 | # The `http:` config above can be replaced with: 45 | # 46 | # https: [ 47 | # port: 4001, 48 | # cipher_suite: :strong, 49 | # keyfile: "priv/cert/selfsigned_key.pem", 50 | # certfile: "priv/cert/selfsigned.pem" 51 | # ], 52 | # 53 | # If desired, both `http:` and `https:` keys can be 54 | # configured to run both http and https servers on 55 | # different ports. 56 | 57 | # Watch static and templates for browser reloading. 58 | config :pixel_smash, PixelSmashWeb.Endpoint, 59 | live_reload: [ 60 | patterns: [ 61 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 62 | ~r"priv/gettext/.*(po)$", 63 | ~r"lib/pixel_smash_web/(live|views)/.*(ex)$", 64 | ~r"lib/pixel_smash_web/templates/.*(eex)$" 65 | ] 66 | ] 67 | 68 | # Do not include metadata nor timestamps in development logs 69 | config :logger, :console, format: "[$level] $message\n" 70 | 71 | # Set a higher stacktrace during development. Avoid configuring such 72 | # in production as building large stacktraces may be expensive. 73 | config :phoenix, :stacktrace_depth, 20 74 | 75 | # Initialize plugs at runtime for faster development compilation 76 | config :phoenix, :plug_init_mode, :runtime 77 | -------------------------------------------------------------------------------- /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 :pixel_smash, PixelSmashWeb.Endpoint, 13 | url: [host: "ruddy-rosy-mallard.gigalixirapp.com", port: 80], 14 | cache_static_manifest: "priv/static/cache_manifest.json" 15 | 16 | # Do not print debug messages in production 17 | config :logger, level: :info 18 | 19 | # ## SSL Support 20 | # 21 | # To get SSL working, you will need to add the `https` key 22 | # to the previous section and set your `:url` port to 443: 23 | # 24 | # config :pixel_smash, PixelSmashWeb.Endpoint, 25 | # ... 26 | # url: [host: "example.com", port: 443], 27 | # https: [ 28 | # port: 443, 29 | # cipher_suite: :strong, 30 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 31 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH"), 32 | # transport_options: [socket_opts: [:inet6]] 33 | # ] 34 | # 35 | # The `cipher_suite` is set to `:strong` to support only the 36 | # latest and more secure SSL ciphers. This means old browsers 37 | # and clients may not be supported. You can set it to 38 | # `:compatible` for wider support. 39 | # 40 | # `:keyfile` and `:certfile` expect an absolute path to the key 41 | # and cert in disk or a relative path inside priv, for example 42 | # "priv/ssl/server.key". For all supported SSL configuration 43 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 44 | # 45 | # We also recommend setting `force_ssl` in your endpoint, ensuring 46 | # no data is ever sent via http, always redirecting to https: 47 | # 48 | # config :pixel_smash, PixelSmashWeb.Endpoint, 49 | # force_ssl: [hsts: true] 50 | # 51 | # Check `Plug.SSL` for all available options in `force_ssl`. 52 | 53 | # Finally import the config/prod.secret.exs which loads secrets 54 | # and configuration from environment variables. 55 | import_config "prod.secret.exs" 56 | -------------------------------------------------------------------------------- /config/prod.secret.exs: -------------------------------------------------------------------------------- 1 | # In this file, we load production configuration and secrets 2 | # from environment variables. You can also hardcode secrets, 3 | # although such is generally not recommended and you have to 4 | # remember to add this file to your .gitignore. 5 | use Mix.Config 6 | 7 | database_url = 8 | System.get_env("DATABASE_URL") || 9 | raise """ 10 | environment variable DATABASE_URL is missing. 11 | For example: ecto://USER:PASS@HOST/DATABASE 12 | """ 13 | 14 | config :pixel_smash, PixelSmash.Repo, 15 | # ssl: true, 16 | url: database_url, 17 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") 18 | 19 | secret_key_base = 20 | System.get_env("SECRET_KEY_BASE") || 21 | raise """ 22 | environment variable SECRET_KEY_BASE is missing. 23 | You can generate one by calling: mix phx.gen.secret 24 | """ 25 | 26 | config :pixel_smash, PixelSmashWeb.Endpoint, 27 | http: [ 28 | port: String.to_integer(System.get_env("PORT") || "4000"), 29 | transport_options: [socket_opts: [:inet6]] 30 | ], 31 | secret_key_base: secret_key_base 32 | 33 | # ## Using releases (Elixir v1.9+) 34 | # 35 | # If you are doing OTP releases, you need to instruct Phoenix 36 | # to start each relevant endpoint: 37 | # 38 | # config :pixel_smash, PixelSmashWeb.Endpoint, server: true 39 | # 40 | # Then you can assemble a release by calling `mix release`. 41 | # See `mix help release` for more information. 42 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Only in tests, remove the complexity from the password hashing algorithm 4 | config :bcrypt_elixir, :log_rounds, 1 5 | 6 | # Configure your database 7 | # 8 | # The MIX_TEST_PARTITION environment variable can be used 9 | # to provide built-in test partitioning in CI environment. 10 | # Run `mix help test` for more information. 11 | config :pixel_smash, PixelSmash.Repo, 12 | username: "postgres", 13 | password: "postgres", 14 | database: "pixel_smash_test#{System.get_env("MIX_TEST_PARTITION")}", 15 | hostname: "localhost", 16 | pool: Ecto.Adapters.SQL.Sandbox 17 | 18 | # We don't run a server during test. If one is required, 19 | # you can enable the server option below. 20 | config :pixel_smash, PixelSmashWeb.Endpoint, 21 | http: [port: 4002], 22 | server: false 23 | 24 | # Print only warnings and errors during test 25 | config :logger, level: :warn 26 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | elixir_version=1.10.4 2 | erlang_version=22.3 3 | -------------------------------------------------------------------------------- /lib/pixel_smash.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash do 2 | @moduledoc """ 3 | PixelSmash keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/pixel_smash/accounts/user.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Accounts.User do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @derive {Inspect, except: [:password]} 6 | schema "users" do 7 | field :email, :string 8 | field :password, :string, virtual: true 9 | field :hashed_password, :string 10 | field :confirmed_at, :naive_datetime 11 | 12 | timestamps() 13 | end 14 | 15 | @doc """ 16 | A user changeset for registration. 17 | 18 | It is important to validate the length of both email and password. 19 | Otherwise databases may truncate the email without warnings, which 20 | could lead to unpredictable or insecure behaviour. Long passwords may 21 | also be very expensive to hash for certain algorithms. 22 | """ 23 | def registration_changeset(user, attrs) do 24 | user 25 | |> cast(attrs, [:email, :password]) 26 | |> validate_email() 27 | |> validate_password() 28 | end 29 | 30 | defp validate_email(changeset) do 31 | changeset 32 | |> validate_required([:email]) 33 | |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") 34 | |> validate_length(:email, max: 160) 35 | |> unsafe_validate_unique(:email, PixelSmash.Repo) 36 | |> unique_constraint(:email) 37 | end 38 | 39 | defp validate_password(changeset) do 40 | changeset 41 | |> validate_required([:password]) 42 | |> validate_length(:password, min: 12, max: 80) 43 | # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") 44 | # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") 45 | # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") 46 | |> prepare_changes(&hash_password/1) 47 | end 48 | 49 | defp hash_password(changeset) do 50 | password = get_change(changeset, :password) 51 | 52 | changeset 53 | |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) 54 | |> delete_change(:password) 55 | end 56 | 57 | @doc """ 58 | A user changeset for changing the email. 59 | 60 | It requires the email to change otherwise an error is added. 61 | """ 62 | def email_changeset(user, attrs) do 63 | user 64 | |> cast(attrs, [:email]) 65 | |> validate_email() 66 | |> case do 67 | %{changes: %{email: _}} = changeset -> changeset 68 | %{} = changeset -> add_error(changeset, :email, "did not change") 69 | end 70 | end 71 | 72 | @doc """ 73 | A user changeset for changing the password. 74 | """ 75 | def password_changeset(user, attrs) do 76 | user 77 | |> cast(attrs, [:password]) 78 | |> validate_confirmation(:password, message: "does not match password") 79 | |> validate_password() 80 | end 81 | 82 | @doc """ 83 | Confirms the account by setting `confirmed_at`. 84 | """ 85 | def confirm_changeset(user) do 86 | now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) 87 | change(user, confirmed_at: now) 88 | end 89 | 90 | @doc """ 91 | Verifies the password. 92 | 93 | If there is no user or the user doesn't have a password, we call 94 | `Bcrypt.no_user_verify/0` to avoid timing attacks. 95 | """ 96 | def valid_password?(%PixelSmash.Accounts.User{hashed_password: hashed_password}, password) 97 | when is_binary(hashed_password) and byte_size(password) > 0 do 98 | Bcrypt.verify_pass(password, hashed_password) 99 | end 100 | 101 | def valid_password?(_, _) do 102 | Bcrypt.no_user_verify() 103 | false 104 | end 105 | 106 | @doc """ 107 | Validates the current password otherwise adds an error to the changeset. 108 | """ 109 | def validate_current_password(changeset, password) do 110 | if valid_password?(changeset.data, password) do 111 | changeset 112 | else 113 | add_error(changeset, :current_password, "is not valid") 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/pixel_smash/accounts/user_notifier.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Accounts.UserNotifier do 2 | # For simplicity, this module simply logs messages to the terminal. 3 | # You should replace it by a proper email or notification tool, such as: 4 | # 5 | # * Swoosh - https://hexdocs.pm/swoosh 6 | # * Bamboo - https://hexdocs.pm/bamboo 7 | # 8 | defp deliver(to, body) do 9 | require Logger 10 | Logger.debug(body) 11 | {:ok, %{to: to, body: body}} 12 | end 13 | 14 | @doc """ 15 | Deliver instructions to confirm account. 16 | """ 17 | def deliver_confirmation_instructions(user, url) do 18 | deliver(user.email, """ 19 | 20 | ============================== 21 | 22 | Hi #{user.email}, 23 | 24 | You can confirm your account by visiting the URL below: 25 | 26 | #{url} 27 | 28 | If you didn't create an account with us, please ignore this. 29 | 30 | ============================== 31 | """) 32 | end 33 | 34 | @doc """ 35 | Deliver instructions to reset a user password. 36 | """ 37 | def deliver_reset_password_instructions(user, url) do 38 | deliver(user.email, """ 39 | 40 | ============================== 41 | 42 | Hi #{user.email}, 43 | 44 | You can reset your password by visiting the URL below: 45 | 46 | #{url} 47 | 48 | If you didn't request this change, please ignore this. 49 | 50 | ============================== 51 | """) 52 | end 53 | 54 | @doc """ 55 | Deliver instructions to update a user email. 56 | """ 57 | def deliver_update_email_instructions(user, url) do 58 | deliver(user.email, """ 59 | 60 | ============================== 61 | 62 | Hi #{user.email}, 63 | 64 | You can change your email by visiting the URL below: 65 | 66 | #{url} 67 | 68 | If you didn't request this change, please ignore this. 69 | 70 | ============================== 71 | """) 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/pixel_smash/accounts/user_token.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Accounts.UserToken do 2 | use Ecto.Schema 3 | import Ecto.Query 4 | 5 | @hash_algorithm :sha256 6 | @rand_size 32 7 | 8 | # It is very important to keep the reset password token expiry short, 9 | # since someone with access to the email may take over the account. 10 | @reset_password_validity_in_days 1 11 | @confirm_validity_in_days 7 12 | @change_email_validity_in_days 7 13 | @session_validity_in_days 60 14 | 15 | schema "users_tokens" do 16 | field :token, :binary 17 | field :context, :string 18 | field :sent_to, :string 19 | belongs_to :user, PixelSmash.Accounts.User 20 | 21 | timestamps(updated_at: false) 22 | end 23 | 24 | @doc """ 25 | Generates a token that will be stored in a signed place, 26 | such as session or cookie. As they are signed, those 27 | tokens do not need to be hashed. 28 | """ 29 | def build_session_token(user) do 30 | token = :crypto.strong_rand_bytes(@rand_size) 31 | {token, %PixelSmash.Accounts.UserToken{token: token, context: "session", user_id: user.id}} 32 | end 33 | 34 | @doc """ 35 | Checks if the token is valid and returns its underlying lookup query. 36 | 37 | The query returns the user found by the token. 38 | """ 39 | def verify_session_token_query(token) do 40 | query = 41 | from token in token_and_context_query(token, "session"), 42 | join: user in assoc(token, :user), 43 | where: token.inserted_at > ago(@session_validity_in_days, "day"), 44 | select: user 45 | 46 | {:ok, query} 47 | end 48 | 49 | @doc """ 50 | Builds a token with a hashed counter part. 51 | 52 | The non-hashed token is sent to the user email while the 53 | hashed part is stored in the database, to avoid reconstruction. 54 | The token is valid for a week as long as users don't change 55 | their email. 56 | """ 57 | def build_email_token(user, context) do 58 | build_hashed_token(user, context, user.email) 59 | end 60 | 61 | defp build_hashed_token(user, context, sent_to) do 62 | token = :crypto.strong_rand_bytes(@rand_size) 63 | hashed_token = :crypto.hash(@hash_algorithm, token) 64 | 65 | {Base.url_encode64(token, padding: false), 66 | %PixelSmash.Accounts.UserToken{ 67 | token: hashed_token, 68 | context: context, 69 | sent_to: sent_to, 70 | user_id: user.id 71 | }} 72 | end 73 | 74 | @doc """ 75 | Checks if the token is valid and returns its underlying lookup query. 76 | 77 | The query returns the user found by the token. 78 | """ 79 | def verify_email_token_query(token, context) do 80 | case Base.url_decode64(token, padding: false) do 81 | {:ok, decoded_token} -> 82 | hashed_token = :crypto.hash(@hash_algorithm, decoded_token) 83 | days = days_for_context(context) 84 | 85 | query = 86 | from token in token_and_context_query(hashed_token, context), 87 | join: user in assoc(token, :user), 88 | where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, 89 | select: user 90 | 91 | {:ok, query} 92 | 93 | :error -> 94 | :error 95 | end 96 | end 97 | 98 | defp days_for_context("confirm"), do: @confirm_validity_in_days 99 | defp days_for_context("reset_password"), do: @reset_password_validity_in_days 100 | 101 | @doc """ 102 | Checks if the token is valid and returns its underlying lookup query. 103 | 104 | The query returns the user token record. 105 | """ 106 | def verify_change_email_token_query(token, context) do 107 | case Base.url_decode64(token, padding: false) do 108 | {:ok, decoded_token} -> 109 | hashed_token = :crypto.hash(@hash_algorithm, decoded_token) 110 | 111 | query = 112 | from token in token_and_context_query(hashed_token, context), 113 | where: token.inserted_at > ago(@change_email_validity_in_days, "day") 114 | 115 | {:ok, query} 116 | 117 | :error -> 118 | :error 119 | end 120 | end 121 | 122 | @doc """ 123 | Returns the given token with the given context. 124 | """ 125 | def token_and_context_query(token, context) do 126 | from PixelSmash.Accounts.UserToken, where: [token: ^token, context: ^context] 127 | end 128 | 129 | @doc """ 130 | Gets all tokens for the given user for the given contexts. 131 | """ 132 | def user_and_contexts_query(user, :all) do 133 | from t in PixelSmash.Accounts.UserToken, where: t.user_id == ^user.id 134 | end 135 | 136 | def user_and_contexts_query(user, [_ | _] = contexts) do 137 | from t in PixelSmash.Accounts.UserToken, 138 | where: t.user_id == ^user.id and t.context in ^contexts 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/pixel_smash/application.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | children = [ 8 | # Start the Ecto repository 9 | PixelSmash.Repo, 10 | PixelSmash.MemoryRepo, 11 | # Start the Telemetry supervisor 12 | PixelSmashWeb.Telemetry, 13 | # Start the PubSub system 14 | {Phoenix.PubSub, name: PixelSmash.PubSub}, 15 | {PixelSmash.Wallets, restore_fn: &PixelSmash.Wallets.persisted_wallets/0}, 16 | PixelSmash.Gladiators, 17 | PixelSmash.Betting, 18 | PixelSmash.Battles, 19 | # Start the Endpoint (http/https) 20 | PixelSmashWeb.Endpoint 21 | ] 22 | 23 | opts = [strategy: :one_for_one, name: PixelSmash.Supervisor] 24 | Supervisor.start_link(children, opts) 25 | end 26 | 27 | def config_change(changed, _new, removed) do 28 | PixelSmashWeb.Endpoint.config_change(changed, removed) 29 | :ok 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/pixel_smash/attributes.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Attributes do 2 | @perseverance [:vitality, :defense] 3 | @combat [:strength, :casting] 4 | @special [:speed, :secret] 5 | 6 | def all(), do: Enum.concat([@perseverance, @combat, @special]) 7 | def perseverance(), do: Enum.random([:vitality, :defense]) 8 | def combat(), do: Enum.random([:strength, :casting]) 9 | def special(), do: Enum.random([:speed, :secret]) 10 | def noop(), do: :no_operation 11 | end 12 | -------------------------------------------------------------------------------- /lib/pixel_smash/battles.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Battles do 2 | alias PixelSmash.Battles.{ 3 | Battle, 4 | BattleServer, 5 | BattleSupervisor, 6 | Matchmaking, 7 | Supervisor 8 | } 9 | 10 | defdelegate child_spec(init_arg), to: Supervisor 11 | 12 | def list_upcoming_battles(matchmaking_server \\ Matchmaking) do 13 | Matchmaking.list_upcoming_battles(matchmaking_server) 14 | end 15 | 16 | def list_finished_battles(matchmaking_server \\ Matchmaking) do 17 | Matchmaking.list_finished_battles(matchmaking_server) 18 | end 19 | 20 | def list_current_battles(matchmaking_server \\ Matchmaking) do 21 | Matchmaking.list_current_battles(matchmaking_server) 22 | end 23 | 24 | def list_battles(battle_supervisor \\ BattleSupervisor) do 25 | BattleSupervisor.list_battle_servers(battle_supervisor) 26 | |> Enum.map(&BattleServer.get_battle/1) 27 | end 28 | 29 | def get_battle(battle_supervisor \\ BattleSupervisor, id) do 30 | {_pid, battle} = BattleSupervisor.get_battle_server(battle_supervisor, id) 31 | 32 | battle 33 | end 34 | 35 | def start_battle(battle_supervisor \\ BattleSupervisor, %Battle.Scheduled{} = battle) do 36 | {pid, _} = BattleSupervisor.get_battle_server(battle_supervisor, battle.id) 37 | 38 | BattleServer.start_battle(pid) 39 | end 40 | 41 | def narrate_battle(battle_supervisor \\ BattleSupervisor, battle) 42 | 43 | def narrate_battle(battle_supervisor, %Battle.InProgress{} = battle) do 44 | {pid, _} = BattleSupervisor.get_battle_server(battle_supervisor, battle.id) 45 | 46 | BattleServer.get_narration(pid) 47 | end 48 | 49 | def narrate_battle(battle_supervisor, %Battle.Finished{} = battle) do 50 | {pid, _} = BattleSupervisor.get_battle_server(battle_supervisor, battle.id) 51 | 52 | BattleServer.get_narration(pid) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/pixel_smash/battles/action.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Battles.Action do 2 | import Algae 3 | 4 | alias PixelSmash.Battles.{ 5 | Action, 6 | Fighter 7 | } 8 | 9 | # Just placeholder actions for now 10 | defsum do 11 | defdata Attack do 12 | fighter :: Fighter.t() 13 | target :: Fighter.t() 14 | damage :: integer() 15 | end 16 | 17 | defdata Cast do 18 | fighter :: Fighter.t() 19 | target :: Fighter.t() 20 | damage :: integer() 21 | spell_name :: String.t() 22 | end 23 | end 24 | 25 | def energy_cost(%Action.Attack{}), do: 7 26 | def energy_cost(%Action.Cast{}), do: 11 27 | 28 | def apply(%Action.Attack{} = action, %Fighter{} = fighter, %Fighter{} = opponent) do 29 | result = %Fighter{ 30 | action.target 31 | | health: max(0, action.target.health - action.damage) 32 | } 33 | 34 | case action.target do 35 | ^fighter -> {result, opponent} 36 | ^opponent -> {fighter, result} 37 | end 38 | end 39 | 40 | def apply(%Action.Cast{} = action, %Fighter{} = fighter, %Fighter{} = opponent) do 41 | result = %Fighter{ 42 | action.target 43 | | health: max(0, action.target.health - action.damage) 44 | } 45 | 46 | case action.target do 47 | ^fighter -> {result, opponent} 48 | ^opponent -> {fighter, result} 49 | end 50 | end 51 | 52 | def from_properties(properties, fighter, opponent) when is_map(properties) do 53 | case properties.kind do 54 | :cast -> 55 | %Action.Cast{ 56 | fighter: fighter, 57 | target: opponent, 58 | damage: properties.damage, 59 | spell_name: properties.spell_name 60 | } 61 | 62 | :attack -> 63 | %Action.Attack{ 64 | fighter: fighter, 65 | target: opponent, 66 | damage: properties.damage 67 | } 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/pixel_smash/battles/action_properties_deck.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Battles.ActionPropertiesDeck do 2 | alias PixelSmash.Sprites 3 | 4 | # counts of colors should be >= 0 5 | @colors_attack_multiplier [ 6 | {%{red: 12, pink: 5}, 2.0}, 7 | {%{dark_red: 7, dark_green: 3}, 1.5}, 8 | {%{red: 3}, 1.0} 9 | ] 10 | 11 | @colors_cast_multiplier [ 12 | {%{purple: 12, dark_pink: 5}, 3.0}, 13 | {%{dark_purple: 12, vitality: 5}, 1.5}, 14 | {%{purple: 12}, 1.0} 15 | ] 16 | 17 | @doc """ 18 | Generates attack and cast action properties from colors stats of given fighter's Sprite. 19 | Always adds one extra attack action properties set for case when there are no appropriate colors in the Sprite. 20 | """ 21 | def actions_properties(fighter) do 22 | stats = Sprites.stats(fighter.sprite) 23 | properties_from_stats(stats, fighter) 24 | end 25 | 26 | defp properties_from_stats(stats, fighter) do 27 | {_, attack_actions} = 28 | Enum.reduce(@colors_attack_multiplier, {stats, []}, fn {color_count, multiplier}, 29 | {stats, actions} -> 30 | {stats, reductions_count} = reduce_stats(stats, color_count) 31 | attacks = List.duplicate(attack_action_properties(fighter, multiplier), reductions_count) 32 | {stats, actions ++ attacks} 33 | end) 34 | 35 | {_, cast_actions} = 36 | Enum.reduce(@colors_cast_multiplier, {stats, []}, fn {color_count, multiplier}, 37 | {stats, actions} -> 38 | {stats, reductions_count} = reduce_stats(stats, color_count) 39 | attacks = List.duplicate(cast_action_properties(fighter, multiplier), reductions_count) 40 | {stats, actions ++ attacks} 41 | end) 42 | 43 | [attack_action_properties(fighter, 1)] ++ attack_actions ++ cast_actions 44 | end 45 | 46 | defp reduce_stats(stats, color_count, reductions_count \\ 0) do 47 | reduced_stats = 48 | Enum.reduce_while(color_count, stats, fn {color, count}, stats -> 49 | stats_count = Map.get(stats, color, -1) 50 | 51 | if stats_count >= count do 52 | {:cont, Map.put(stats, color, stats_count - count)} 53 | else 54 | {:halt, :not_enough_count} 55 | end 56 | end) 57 | 58 | if reduced_stats == :not_enough_count do 59 | {stats, reductions_count} 60 | else 61 | reduce_stats(reduced_stats, color_count, reductions_count + 1) 62 | end 63 | end 64 | 65 | defp attack_action_properties(fighter, multiplier) do 66 | %{ 67 | kind: :attack, 68 | damage: round(fighter.strength * multiplier) 69 | } 70 | end 71 | 72 | defp cast_action_properties(fighter, multiplier) do 73 | %{ 74 | kind: :cast, 75 | damage: round(fighter.magic * multiplier), 76 | spell_name: Enum.random(fighter.spells) 77 | } 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/pixel_smash/battles/battle.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Battles.Battle do 2 | import Algae 3 | 4 | alias PixelSmash.Battles.{ 5 | Action, 6 | ActionPropertiesDeck, 7 | Battle, 8 | Fighter, 9 | Log 10 | } 11 | 12 | @type id :: String.t() 13 | 14 | defsum do 15 | defdata Scheduled do 16 | id :: Battle.id() 17 | fighters :: {Fighter.t(), Fighter.t()} \\ nil 18 | end 19 | 20 | defdata InProgress do 21 | id :: Battle.id() 22 | fighters :: {Fighter.t(), Fighter.t()} \\ nil 23 | log :: Log.t() 24 | end 25 | 26 | defdata Finished do 27 | id :: Battle.id() 28 | fighters :: {Fighter.t(), Fighter.t()} \\ nil 29 | outcome :: :left | :draw | :right 30 | log :: Log.t() 31 | end 32 | end 33 | 34 | def schedule(%Fighter{} = left, %Fighter{} = right) do 35 | left = %{left | all_actions_properties: ActionPropertiesDeck.actions_properties(left)} 36 | right = %{right | all_actions_properties: ActionPropertiesDeck.actions_properties(right)} 37 | 38 | Battle.Scheduled.new(Ecto.UUID.generate(), {left, right}) 39 | end 40 | 41 | def start(%Battle.Scheduled{} = battle) do 42 | Battle.InProgress.new(battle.id, battle.fighters) 43 | end 44 | 45 | def narrate(%Battle.InProgress{} = battle) do 46 | Log.narrate(battle.log) 47 | end 48 | 49 | def narrate(%Battle.Finished{} = battle) do 50 | Log.narrate(battle.log) 51 | end 52 | 53 | def simulate(%Battle.InProgress{} = battle) do 54 | case simulate_tick(battle) do 55 | %Battle.InProgress{} = battle -> 56 | simulate(battle) 57 | 58 | %Battle.Finished{} = battle -> 59 | battle 60 | end 61 | end 62 | 63 | def simulate_tick(%Battle.InProgress{} = battle) do 64 | battle = 65 | case choose_fighter(battle) do 66 | :none -> 67 | battle 68 | 69 | :left -> 70 | {fighter, opponent} = battle.fighters 71 | {fighter, opponent, action} = take_turn(fighter, opponent) 72 | 73 | %Battle.InProgress{battle | fighters: {fighter, opponent}} 74 | |> log_action(action) 75 | 76 | :right -> 77 | {opponent, fighter} = battle.fighters 78 | {fighter, opponent, action} = take_turn(fighter, opponent) 79 | 80 | %Battle.InProgress{battle | fighters: {opponent, fighter}} 81 | |> log_action(action) 82 | end 83 | 84 | battle 85 | |> map_fighters(&Fighter.rest/1) 86 | |> check_if_finished() 87 | end 88 | 89 | def choose_fighter(%Battle.InProgress{} = battle) do 90 | {left, right} = battle.fighters 91 | 92 | case {left.exhaustion, right.exhaustion} do 93 | {n, _} when n <= 0 -> :left 94 | {_, n} when n <= 0 -> :right 95 | {_, _} -> :none 96 | end 97 | end 98 | 99 | def take_turn(%Fighter{} = fighter, %Fighter{} = opponent) do 100 | {action, fighter} = next_action(fighter, opponent) 101 | {fighter, opponent} = Action.apply(action, fighter, opponent) 102 | fighter = apply_exhaustion(fighter, action) 103 | 104 | {fighter, opponent, action} 105 | end 106 | 107 | defp next_action(%Fighter{} = fighter, %Fighter{} = opponent) do 108 | {properties, fighter} = Fighter.next_action_properties(fighter) 109 | {Action.from_properties(properties, fighter, opponent), fighter} 110 | end 111 | 112 | def apply_exhaustion(%Fighter{} = fighter, action) do 113 | cost = Action.energy_cost(action) 114 | 115 | %Fighter{fighter | exhaustion: fighter.exhaustion + cost} 116 | end 117 | 118 | def check_if_finished(%Battle.InProgress{fighters: {left, right}} = battle) do 119 | case {left.health, right.health} do 120 | {n, _} when n <= 0 -> 121 | %Battle.Finished{ 122 | id: battle.id, 123 | fighters: battle.fighters, 124 | outcome: :right, 125 | log: battle.log 126 | } 127 | 128 | {_, n} when n <= 0 -> 129 | %Battle.Finished{ 130 | id: battle.id, 131 | fighters: battle.fighters, 132 | outcome: :left, 133 | log: battle.log 134 | } 135 | 136 | _ -> 137 | battle 138 | end 139 | end 140 | 141 | defp map_fighters(%Battle.InProgress{fighters: {left, right}} = battle, fun) do 142 | %Battle.InProgress{battle | fighters: {fun.(left), fun.(right)}} 143 | end 144 | 145 | defp log_action(%Battle.InProgress{log: log} = battle, action) do 146 | %Battle.InProgress{battle | log: Log.append(log, action)} 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/pixel_smash/battles/battle_server.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Battles.BattleServer do 2 | use GenServer, restart: :temporary 3 | 4 | alias PixelSmash.Battles 5 | alias PixelSmash.Battles.Fighter 6 | alias PixelSmash.Battles.Battle 7 | 8 | @seconds_per_tick 2 9 | 10 | def start_link({%Fighter{}, %Fighter{}} = init_arg) do 11 | GenServer.start_link(__MODULE__, init_arg) 12 | end 13 | 14 | def start_battle(pid) do 15 | GenServer.call(pid, :start_battle) 16 | end 17 | 18 | def get_battle(pid) do 19 | GenServer.call(pid, :get_battle) 20 | end 21 | 22 | def get_narration(pid) do 23 | GenServer.call(pid, :get_narration) 24 | end 25 | 26 | @impl GenServer 27 | def init({left, right}) do 28 | {:ok, Battle.schedule(left, right)} 29 | end 30 | 31 | @impl GenServer 32 | def handle_call(:start_battle, _from, %Battle.Scheduled{} = battle) do 33 | schedule_next_tick() 34 | 35 | {:reply, :ok, Battle.start(battle)} 36 | end 37 | 38 | @impl GenServer 39 | def handle_call(:get_battle, _from, battle) do 40 | {:reply, battle, battle} 41 | end 42 | 43 | @impl GenServer 44 | def handle_call(:get_narration, _from, %Battle.InProgress{} = battle) do 45 | {:reply, Battle.narrate(battle), battle} 46 | end 47 | 48 | @impl GenServer 49 | def handle_call(:get_narration, _from, %Battle.Finished{} = battle) do 50 | {:reply, Battle.narrate(battle), battle} 51 | end 52 | 53 | @impl GenServer 54 | def handle_info(:tick, %Battles.Battle.InProgress{} = battle) do 55 | case Battle.simulate_tick(battle) do 56 | %Battles.Battle.InProgress{} = battle -> 57 | schedule_next_tick() 58 | 59 | {:noreply, battle} 60 | 61 | %Battles.Battle.Finished{} = battle -> 62 | {:stop, {:shutdown, battle}, battle} 63 | end 64 | end 65 | 66 | defp schedule_next_tick() do 67 | Process.send_after(self(), :tick, @seconds_per_tick * 1000) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/pixel_smash/battles/battle_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Battles.BattleSupervisor do 2 | use DynamicSupervisor 3 | 4 | alias PixelSmash.Battles.{ 5 | BattleServer, 6 | Fighter 7 | } 8 | 9 | def start_link(init_arg) do 10 | DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) 11 | end 12 | 13 | def schedule_battle(supervisor \\ __MODULE__, {%Fighter{} = left, %Fighter{} = right}) do 14 | DynamicSupervisor.start_child(supervisor, {BattleServer, {left, right}}) 15 | end 16 | 17 | def list_battle_servers(supervisor \\ __MODULE__) do 18 | DynamicSupervisor.which_children(supervisor) 19 | |> Enum.filter(fn 20 | {_, pid, :worker, _} when is_pid(pid) -> true 21 | _ -> false 22 | end) 23 | |> Enum.map(fn {_, pid, :worker, _} -> pid end) 24 | end 25 | 26 | def get_battle_server(supervisor \\ __MODULE__, id) do 27 | list_battle_servers(supervisor) 28 | |> Enum.map(&{&1, BattleServer.get_battle(&1)}) 29 | |> Enum.find(fn {_, battle} -> battle.id == id end) 30 | end 31 | 32 | def terminate_battle_server(supervisor \\ __MODULE__, pid) do 33 | DynamicSupervisor.terminate_child(supervisor, pid) 34 | end 35 | 36 | @impl true 37 | def init(_init_arg) do 38 | DynamicSupervisor.init(strategy: :one_for_one) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/pixel_smash/battles/fighter.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Battles.Fighter do 2 | import Algae 3 | 4 | alias PixelSmash.Battles.Fighter 5 | alias PixelSmash.Gladiators 6 | 7 | defdata do 8 | id :: String.t() 9 | name :: String.t() 10 | sprite :: map() 11 | exhaustion :: non_neg_integer() 12 | max_health :: non_neg_integer() 13 | health :: non_neg_integer() 14 | strength :: non_neg_integer() 15 | speed :: non_neg_integer() 16 | magic :: non_neg_integer() 17 | spells :: [String.t()] 18 | all_actions_properties :: [map()] 19 | remaining_actions_properties :: [map()] 20 | end 21 | 22 | def next_action_properties(%Fighter{} = fighter) do 23 | fighter = maybe_fill_remaining_actions(fighter) 24 | %Fighter{remaining_actions_properties: [action | actions_tail]} = fighter 25 | {action, %{fighter | remaining_actions_properties: actions_tail}} 26 | end 27 | 28 | defp maybe_fill_remaining_actions(%Fighter{remaining_actions_properties: []} = fighter) do 29 | %{fighter | remaining_actions_properties: Enum.shuffle(fighter.all_actions_properties)} 30 | end 31 | 32 | defp maybe_fill_remaining_actions(%Fighter{} = fighter), do: fighter 33 | 34 | def rest(%Fighter{} = fighter) do 35 | %Fighter{fighter | exhaustion: max(0, fighter.exhaustion - fighter.speed)} 36 | end 37 | 38 | def from_gladiator(%Gladiators.Gladiator{} = gladiator) do 39 | %Fighter{ 40 | id: gladiator.id, 41 | name: gladiator.name, 42 | sprite: gladiator.sprite, 43 | max_health: gladiator.max_health, 44 | health: gladiator.max_health, 45 | strength: gladiator.strength, 46 | speed: gladiator.speed, 47 | magic: gladiator.magic, 48 | spells: gladiator.spells, 49 | exhaustion: 0 50 | } 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/pixel_smash/battles/log.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Battles.Log do 2 | import Algae 3 | 4 | alias PixelSmash.Battles 5 | 6 | defdata do 7 | actions :: [Battles.Actions.t()] \\ [] 8 | narrations :: [String.t()] \\ [] 9 | end 10 | 11 | def narrate(%Battles.Log{narrations: narrations}) do 12 | narrations 13 | end 14 | 15 | def append(%Battles.Log{} = log, action) do 16 | %Battles.Log{ 17 | log 18 | | actions: log.actions ++ [action], 19 | narrations: log.narrations ++ [narrate_action(action)] 20 | } 21 | end 22 | 23 | defp narrate_action(%Battles.Action.Attack{} = action) do 24 | attack_narration(Enum.random(1..3), action) 25 | end 26 | 27 | defp narrate_action(%Battles.Action.Cast{} = action) do 28 | cast_narration(Enum.random(1..3), action) 29 | end 30 | 31 | defp attack_narration(1, action) do 32 | [ 33 | action.fighter.name, 34 | " leaps into action and hits ", 35 | action.target.name, 36 | " for ", 37 | Integer.to_string(action.damage), 38 | " damage!" 39 | ] 40 | end 41 | 42 | defp attack_narration(2, action) do 43 | [ 44 | action.fighter.name, 45 | " attacks and does ", 46 | Integer.to_string(action.damage), 47 | " damage to ", 48 | action.target.name, 49 | "." 50 | ] 51 | end 52 | 53 | defp attack_narration(3, action) do 54 | [ 55 | action.fighter.name, 56 | " tirelessly strikes ", 57 | action.target.name, 58 | " with ", 59 | Integer.to_string(action.damage), 60 | " damage." 61 | ] 62 | end 63 | 64 | defp cast_narration(1, action) do 65 | [ 66 | action.fighter.name, 67 | " conjures up ", 68 | action.spell_name, 69 | " and flings it at ", 70 | action.target.name, 71 | " for ", 72 | Integer.to_string(action.damage), 73 | " damage!" 74 | ] 75 | end 76 | 77 | defp cast_narration(2, action) do 78 | [ 79 | action.fighter.name, 80 | " calls the force of ", 81 | action.spell_name, 82 | " and does ", 83 | Integer.to_string(action.damage), 84 | " damage to ", 85 | action.target.name, 86 | "." 87 | ] 88 | end 89 | 90 | defp cast_narration(3, action) do 91 | [ 92 | action.fighter.name, 93 | " ventures a spell of the ", 94 | action.spell_name, 95 | " on ", 96 | action.target.name, 97 | " with ", 98 | Integer.to_string(action.damage), 99 | " damage." 100 | ] 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/pixel_smash/battles/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Battles.Supervisor do 2 | use Supervisor 3 | 4 | alias PixelSmash.Battles 5 | 6 | def start_link(state) do 7 | GenServer.start_link(__MODULE__, state, name: __MODULE__) 8 | end 9 | 10 | @impl true 11 | def init(_init_arg) do 12 | children = [ 13 | Battles.Matchmaking, 14 | Battles.BattleSupervisor 15 | ] 16 | 17 | Supervisor.start_link(children, strategy: :rest_for_one) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/pixel_smash/betting.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Betting do 2 | alias PixelSmash.{ 3 | Accounts, 4 | Battles, 5 | Betting 6 | } 7 | 8 | defdelegate child_spec(init_arg), to: Betting.Supervisor 9 | 10 | @type bet :: {Accounts.User.t(), :left | :right, integer()} 11 | 12 | def get_odds(bookie \\ Betting.Bookie, %Battles.Battle.Scheduled{} = battle) do 13 | Betting.Bookie.get_odds(bookie, battle) 14 | end 15 | 16 | def get_expected_winnings( 17 | bookie \\ Betting.Bookie, 18 | %Battles.Battle.Scheduled{} = battle, 19 | side, 20 | amount 21 | ) 22 | when side in [:left, :right] and is_integer(amount) do 23 | Betting.Bookie.get_expected_winnings(bookie, battle, side, amount) 24 | end 25 | 26 | def get_bet(bookie \\ Betting.Bookie, battle, user) 27 | def get_bet(_bookie, _battle, nil), do: nil 28 | 29 | def get_bet(bookie, battle, %Accounts.User{} = user) do 30 | Betting.Bookie.get_bet(bookie, battle, user) 31 | end 32 | 33 | def place_bet( 34 | bookie \\ Betting.Bookie, 35 | %Battles.Battle.Scheduled{} = battle, 36 | {%Accounts.User{}, side, amount} = bet 37 | ) 38 | when side in [:left, :right] and is_integer(amount) do 39 | Betting.Bookie.place_bet(bookie, battle, bet) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/pixel_smash/betting/bookie.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Betting.Bookie do 2 | use GenServer 3 | 4 | require Logger 5 | 6 | alias PixelSmash.{ 7 | Battles, 8 | Gladiators, 9 | Wallets 10 | } 11 | 12 | def start_link(opts) do 13 | name = Keyword.get(opts, :name, __MODULE__) 14 | 15 | GenServer.start_link(__MODULE__, opts, name: name) 16 | end 17 | 18 | def get_odds(pid \\ __MODULE__, battle) do 19 | GenServer.call(pid, {:get_odds, battle}) 20 | end 21 | 22 | def get_expected_winnings(pid \\ __MODULE__, battle, side, amount) do 23 | GenServer.call(pid, {:get_expected_winnings, battle, side, amount}) 24 | end 25 | 26 | def get_bet(pid \\ __MODULE__, battle, user) do 27 | GenServer.call(pid, {:get_bet, battle, user}) 28 | end 29 | 30 | def place_bet(pid \\ __MODULE__, battle, bet) do 31 | GenServer.call(pid, {:place_bet, battle, bet}) 32 | end 33 | 34 | def init(_opts) do 35 | Phoenix.PubSub.subscribe(PixelSmash.PubSub, "battles:*") 36 | 37 | state = %{ 38 | open_books: %{}, 39 | closed_books: %{} 40 | } 41 | 42 | {:ok, state} 43 | end 44 | 45 | def handle_call({:get_odds, battle}, _from, state) do 46 | case Map.get(state.open_books, battle.id) do 47 | nil -> 48 | {:reply, {:error, :battle_not_open_for_betting}, state} 49 | 50 | {odds, _bets} -> 51 | {:reply, {:ok, odds}, state} 52 | end 53 | end 54 | 55 | def handle_call({:get_expected_winnings, battle, side, amount}, _from, state) do 56 | case Map.get(state.open_books, battle.id) do 57 | nil -> 58 | {:error, :battle_not_open_for_betting} 59 | 60 | {odds, _bets} -> 61 | winnings = calculate_expected_winnings(odds, side, amount) 62 | 63 | {:reply, {:ok, winnings}, state} 64 | end 65 | end 66 | 67 | def handle_call({:get_bet, battle, user}, _from, state) do 68 | case battle do 69 | %Battles.Battle.Scheduled{} -> 70 | bet = do_get_bet(state.open_books, battle.id, user) 71 | 72 | {:reply, bet, state} 73 | 74 | %Battles.Battle.InProgress{} -> 75 | bet = do_get_bet(state.closed_books, battle.id, user) 76 | 77 | {:reply, bet, state} 78 | end 79 | end 80 | 81 | def handle_call({:place_bet, battle, {user, side, amount} = bet}, _from, state) do 82 | wallet_id = Wallets.get_wallet_id(user.id) 83 | 84 | with {:ok, _wallet} <- Wallets.take_stake(wallet_id, amount), 85 | {:ok, state} <- add_bet(state, battle.id, bet) do 86 | Logger.info(fn -> 87 | "Placed bet: {#{user.id}, #{side}, #{amount}}, on battle: #{battle.id}" 88 | end) 89 | 90 | {:reply, :ok, state} 91 | else 92 | {:error, error} -> 93 | {:reply, {:error, error}, state} 94 | end 95 | end 96 | 97 | def handle_info({:battle_scheduled, %Battles.Battle.Scheduled{} = battle}, state) do 98 | {left, right} = battle.fighters 99 | 100 | odds = 101 | Gladiators.expected_battle_result( 102 | {Gladiators.get_gladiator(left.id), Gladiators.get_gladiator(right.id)} 103 | ) 104 | 105 | state = open_book(state, battle.id, odds) 106 | 107 | Logger.info(fn -> 108 | "Opened betting for battle: #{battle.id}" 109 | end) 110 | 111 | {:noreply, state} 112 | end 113 | 114 | def handle_info({:battle_started, %Battles.Battle.InProgress{} = battle}, state) do 115 | state = close_book(state, battle.id) 116 | 117 | Logger.info(fn -> 118 | "Closed betting for battle: #{battle.id}" 119 | end) 120 | 121 | {:noreply, state} 122 | end 123 | 124 | def handle_info({:battle_finished, %Battles.Battle.Finished{} = battle}, state) do 125 | state = consume_bets(state, battle) 126 | 127 | {:noreply, state} 128 | end 129 | 130 | defp open_book(state, battle_id, odds) do 131 | put_in(state, [:open_books, battle_id], {odds, []}) 132 | end 133 | 134 | defp close_book(state, battle_id) do 135 | case Map.pop(state.open_books, battle_id) do 136 | {nil, _} -> 137 | state 138 | 139 | {closed_book, open_books} -> 140 | state = %{ 141 | state 142 | | open_books: open_books, 143 | closed_books: Map.put(state.closed_books, battle_id, closed_book) 144 | } 145 | 146 | state 147 | end 148 | end 149 | 150 | defp consume_bets(state, battle) do 151 | case Map.pop(state.closed_books, battle.id) do 152 | {nil, _} -> 153 | state 154 | 155 | {{odds, bets}, closed_books} -> 156 | Enum.each(bets, &handle_bet_outcome(&1, battle, odds)) 157 | 158 | %{ 159 | state 160 | | closed_books: closed_books 161 | } 162 | end 163 | end 164 | 165 | defp do_get_bet(books, battle_id, user) do 166 | case Map.get(books, battle_id) do 167 | nil -> 168 | nil 169 | 170 | {_, bets} -> 171 | Enum.find(bets, fn {better, _, _} -> user.id == better.id end) 172 | end 173 | end 174 | 175 | defp add_bet(state, battle_id, bet) do 176 | case Map.get(state.open_books, battle_id) do 177 | nil -> 178 | {:error, :battle_not_open_for_betting} 179 | 180 | {odds, bets} -> 181 | state = %{ 182 | state 183 | | open_books: Map.put(state.open_books, battle_id, {odds, [bet | bets]}) 184 | } 185 | 186 | {:ok, state} 187 | end 188 | end 189 | 190 | defp calculate_expected_winnings({left_odds, right_odds}, side, amount) do 191 | divisor = 192 | case side do 193 | :left -> 194 | Decimal.div(left_odds, right_odds) 195 | 196 | :right -> 197 | Decimal.div(right_odds, left_odds) 198 | end 199 | 200 | winnings = Decimal.div_int(amount, divisor) 201 | total = Decimal.add(amount, winnings) 202 | 203 | Decimal.to_integer(total) 204 | end 205 | 206 | defp handle_bet_outcome({user, side, amount}, battle, odds) do 207 | cond do 208 | :draw == battle.outcome -> 209 | wallet_id = Wallets.get_wallet_id(user.id) 210 | {:ok, _wallet} = Wallets.fund(wallet_id, amount) 211 | 212 | Logger.info(fn -> 213 | "Refunded a bet against a drawed battle: {#{user.id}, #{side}, #{amount}}, battle: #{ 214 | battle.id 215 | }" 216 | end) 217 | 218 | side == battle.outcome -> 219 | winnings = calculate_expected_winnings(odds, side, amount) 220 | wallet_id = Wallets.get_wallet_id(user.id) 221 | 222 | {:ok, _wallet} = Wallets.fund(wallet_id, winnings) 223 | 224 | Logger.info(fn -> 225 | "Paid out a winning bet: {#{user.id}, #{side}, #{amount}}, winnings: #{winnings}, battle: #{ 226 | battle.id 227 | }" 228 | end) 229 | 230 | :else -> 231 | Logger.info(fn -> 232 | "Closed out a losing bet: {#{user.id}, #{side}, #{amount}}, battle: #{battle.id}" 233 | end) 234 | 235 | :ok 236 | end 237 | end 238 | end 239 | -------------------------------------------------------------------------------- /lib/pixel_smash/betting/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Betting.Supervisor do 2 | use Supervisor 3 | 4 | alias PixelSmash.Betting 5 | 6 | def start_link(state) do 7 | GenServer.start_link(__MODULE__, state, name: __MODULE__) 8 | end 9 | 10 | @impl true 11 | def init(_init_arg) do 12 | children = [ 13 | Betting.Bookie 14 | ] 15 | 16 | Supervisor.start_link(children, strategy: :one_for_one) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/pixel_smash/gladiators.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Gladiators do 2 | @moduledoc """ 3 | Gladiators context. Allows listing gladiators, and fetching them by ID. 4 | """ 5 | 6 | alias PixelSmash.Gladiators.{ 7 | ELO, 8 | Gladiator, 9 | Store, 10 | Supervisor 11 | } 12 | 13 | @store Store 14 | 15 | defdelegate child_spec(init_arg), to: Supervisor 16 | 17 | @spec list_gladiators(GenServer.name()) :: [Gladiator] 18 | def list_gladiators(store \\ @store) do 19 | Store.list_gladiators(store) 20 | end 21 | 22 | @spec list_gladiators_by_elo(atom(), GenServer.name()) :: [Gladiator] 23 | def list_gladiators_by_elo(sort_order \\ :desc, store \\ @store) do 24 | Store.list_gladiators_by_elo(store, sort_order) 25 | end 26 | 27 | @spec get_gladiator(GenServer.name(), String.t()) :: Gladiator | nil 28 | def get_gladiator(store \\ @store, id) do 29 | Store.get_gladiator(store, id) 30 | end 31 | 32 | @spec expected_battle_result({Gladiator.t(), Gladiator.t()}) :: {float(), float()} 33 | def expected_battle_result({%Gladiator{} = left, %Gladiator{} = right}) do 34 | left_chances = 35 | ELO.expected_result(left, right) 36 | |> Decimal.from_float() 37 | 38 | right_chances = Decimal.sub(1, left_chances) 39 | 40 | {left_chances, right_chances} 41 | end 42 | 43 | @spec register_battle_result( 44 | GenServer.name(), 45 | {Gladiator.t(), Gladiator.t()}, 46 | :left | :draw | :right 47 | ) :: :ok 48 | def register_battle_result(store \\ @store, {%Gladiator{}, %Gladiator{}} = matchup, winner) do 49 | Store.register_battle_result(store, matchup, winner) 50 | end 51 | 52 | def generate_gladiator() do 53 | Gladiator.generate() 54 | end 55 | 56 | def equip(%Gladiator{} = gladiator, %PixelSmash.Items.Item{} = item, slot) do 57 | sprite = PixelSmash.Sprites.equip(gladiator, item) 58 | 59 | gladiator 60 | |> Map.put(:sprite, sprite) 61 | |> Map.put(slot, item) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/pixel_smash/gladiators/elo.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Gladiators.ELO do 2 | @k_factor 24 3 | 4 | alias PixelSmash.Gladiators.Gladiator 5 | 6 | def expected_result(%Gladiator{} = player, %Gladiator{} = opponent) do 7 | 1.0 / (1.0 + :math.pow(10.0, (opponent.elo - player.elo) / 400.0)) 8 | end 9 | 10 | def handle_battle_result({%Gladiator{} = left, %Gladiator{} = right}, winner) do 11 | left_new_score = 12 | case winner do 13 | :left -> new_score(left, right, :win) 14 | :draw -> new_score(left, right, :draw) 15 | :right -> new_score(left, right, :loss) 16 | end 17 | 18 | right_new_score = 19 | case winner do 20 | :left -> new_score(right, left, :loss) 21 | :draw -> new_score(right, left, :draw) 22 | :right -> new_score(right, left, :win) 23 | end 24 | 25 | left = %Gladiator{left | elo: left_new_score} 26 | right = %Gladiator{right | elo: right_new_score} 27 | 28 | {left, right} 29 | end 30 | 31 | def new_score(%Gladiator{} = player, %Gladiator{} = opponent, result) do 32 | factor = 33 | case result do 34 | :win -> 1.0 35 | :draw -> 0.5 36 | :loss -> 0.0 37 | end 38 | 39 | expected = expected_result(player, opponent) 40 | delta = @k_factor * (factor - expected) 41 | 42 | round(player.elo + delta) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/pixel_smash/gladiators/gladiator.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Gladiators.Gladiator do 2 | import Algae 3 | 4 | alias PixelSmash.Gladiators.Gladiator 5 | 6 | @type id :: String.t() 7 | 8 | defdata do 9 | id :: id() 10 | elo :: non_neg_integer() 11 | wins :: non_neg_integer() 12 | losses :: non_neg_integer() 13 | draws :: non_neg_integer() 14 | name :: String.t() 15 | data :: list() 16 | sprite :: map() 17 | max_health :: non_neg_integer() 18 | defense :: non_neg_integer() 19 | strength :: non_neg_integer() 20 | speed :: non_neg_integer() 21 | magic :: non_neg_integer() 22 | secret :: non_neg_integer() 23 | spells :: [String.t()] 24 | slot1 :: map() 25 | slot2 :: map() 26 | end 27 | 28 | def generate() do 29 | number_of_attrs = Enum.random(2..4) 30 | 31 | available_attrs = 32 | PixelSmash.Attributes.all() 33 | |> Enum.shuffle() 34 | |> Enum.take(number_of_attrs) 35 | 36 | data = 37 | PixelSmash.Grids.generate(5, 10, fn _, _ -> 38 | Enum.random(available_attrs) 39 | end) 40 | 41 | data = PixelSmash.Grids.mirror(data) 42 | flat_data = Enum.flat_map(data, fn x -> x end) 43 | 44 | gladiator = %Gladiator{ 45 | id: Ecto.UUID.generate(), 46 | elo: 1500, 47 | wins: 0, 48 | losses: 0, 49 | draws: 0, 50 | data: data, 51 | sprite: %PixelSmash.Sprites.Sprite{}, 52 | name: Faker.Person.En.name(), 53 | max_health: 50 + count_entries(flat_data, :vitality), 54 | defense: 10 + count_entries(flat_data, :defense), 55 | strength: 3 + count_entries(flat_data, :strength), 56 | magic: 8 + count_entries(flat_data, :casting), 57 | speed: 1 + count_entries(flat_data, :speed), 58 | secret: 0 + count_entries(flat_data, :secret), 59 | spells: Faker.Util.sample_uniq(3, &Faker.Superhero.En.power/0) 60 | } 61 | 62 | sprite = PixelSmash.Sprites.Spritifier.to_sprite(gladiator) 63 | Map.put(gladiator, :sprite, sprite) 64 | end 65 | 66 | defp count_entries(list, entry) do 67 | list 68 | |> Enum.filter(fn x -> x == entry end) 69 | |> Enum.count() 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/pixel_smash/gladiators/store.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Gladiators.Store do 2 | use GenServer 3 | 4 | require Logger 5 | 6 | alias PixelSmash.Gladiators.{ 7 | ELO, 8 | Gladiator 9 | } 10 | 11 | @max_gladiators 16 12 | 13 | def start_link(init_arg) do 14 | GenServer.start_link(__MODULE__, init_arg, name: __MODULE__) 15 | end 16 | 17 | def list_gladiators(pid \\ __MODULE__) do 18 | GenServer.call(pid, :list_gladiators) 19 | end 20 | 21 | def list_gladiators_by_elo(pid \\ __MODULE__, sort_order) do 22 | GenServer.call(pid, {:list_gladiators_by_elo, sort_order}) 23 | end 24 | 25 | def get_gladiator(pid \\ __MODULE__, id) do 26 | GenServer.call(pid, {:get_gladiator, id}) 27 | end 28 | 29 | def register_battle_result(pid \\ __MODULE__, matchup, winner) do 30 | GenServer.call(pid, {:register_battle_result, matchup, winner}) 31 | end 32 | 33 | def init(_init_arg) do 34 | {:ok, %{}, {:continue, :seed}} 35 | end 36 | 37 | def handle_continue(:seed, store) do 38 | gladiators = 39 | Enum.map(1..@max_gladiators, fn _ -> 40 | Gladiator.generate() 41 | end) 42 | 43 | store = 44 | Enum.reduce(gladiators, store, fn gladiator, acc -> 45 | Map.put(acc, gladiator.id, gladiator) 46 | end) 47 | 48 | {:noreply, store} 49 | end 50 | 51 | def handle_call(:list_gladiators, _from, store) do 52 | gladiators = Map.values(store) 53 | 54 | {:reply, gladiators, store} 55 | end 56 | 57 | def handle_call({:list_gladiators_by_elo, sort_order}, _from, store) do 58 | gladiators = 59 | store 60 | |> Map.values() 61 | |> Enum.sort_by(& &1.elo, sort_order) 62 | 63 | {:reply, gladiators, store} 64 | end 65 | 66 | def handle_call({:get_gladiator, id}, _from, store) do 67 | gladiator = Map.get(store, id) 68 | 69 | {:reply, gladiator, store} 70 | end 71 | 72 | def handle_call({:register_battle_result, {left, right}, winner}, _from, store) do 73 | {left, right} = ELO.handle_battle_result({left, right}, winner) 74 | 75 | left = 76 | case winner do 77 | :left -> %Gladiator{left | wins: left.wins + 1} 78 | :draw -> %Gladiator{left | draws: left.draws + 1} 79 | :right -> %Gladiator{left | losses: left.losses + 1} 80 | end 81 | 82 | right = 83 | case winner do 84 | :left -> %Gladiator{right | losses: right.losses + 1} 85 | :draw -> %Gladiator{right | draws: right.draws + 1} 86 | :right -> %Gladiator{right | wins: right.wins + 1} 87 | end 88 | 89 | store = 90 | store 91 | |> Map.put(left.id, left) 92 | |> Map.put(right.id, right) 93 | 94 | Logger.info(fn -> 95 | "Registering a battle result for combatants: {#{left.id}, #{right.id}}, winner: #{winner}" 96 | end) 97 | 98 | {:reply, :ok, store} 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/pixel_smash/gladiators/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Gladiators.Supervisor do 2 | use Supervisor 3 | 4 | alias PixelSmash.Gladiators 5 | 6 | def start_link(state) do 7 | GenServer.start_link(__MODULE__, state, name: __MODULE__) 8 | end 9 | 10 | @impl true 11 | def init(_init_arg) do 12 | children = [ 13 | Gladiators.Store 14 | ] 15 | 16 | Supervisor.start_link(children, strategy: :one_for_one) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/pixel_smash/grids.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Grids do 2 | @moduledoc """ 3 | Helpers to generate different kinds of matrix structures. 4 | """ 5 | 6 | @type grid_data() :: list(list()) 7 | @type grid_map() :: %{{integer(), integer()} => term()} 8 | @type size() :: pos_integer() 9 | 10 | @doc """ 11 | Generates a X by Y (list of lists) with all starting values being `:nil`. A generator function 12 | may be applied in order to fill the sprite with the desired data. 13 | """ 14 | @spec generate(size_x :: size(), size_y :: size(), generator_fn :: (size(), size() -> term())) :: 15 | grid_data() 16 | def generate(size_x, size_y, generator_fn \\ fn _x, _y -> nil end) do 17 | for y <- 1..size_y do 18 | for x <- 1..size_x do 19 | generator_fn.(x, y) 20 | end 21 | end 22 | end 23 | 24 | @doc """ 25 | Transforms a list of list into a map representation. 26 | """ 27 | @spec to_map(data :: grid_data(), size_x :: size(), size_y :: size()) :: grid_map() 28 | def to_map(data, size_x, size_y) do 29 | coordinates = 30 | for y <- 1..size_y, x <- 1..size_x do 31 | {x, y} 32 | end 33 | 34 | elements = Enum.flat_map(data, fn x -> x end) 35 | Enum.zip(coordinates, elements) |> Enum.into(%{}) 36 | end 37 | 38 | def to_sprite(data, size_x, size_y) do 39 | %PixelSmash.Sprites.Sprite{ 40 | x: size_x, 41 | y: size_y, 42 | map: to_map(data, size_x, size_y) 43 | } 44 | end 45 | 46 | def mirror(data) do 47 | Enum.map(data, fn row -> 48 | reverse = Enum.reverse(row) 49 | Enum.concat(row, reverse) 50 | end) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/pixel_smash/items.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Items do 2 | alias PixelSmash.Items.Item 3 | alias PixelSmash.Items.Pattern 4 | 5 | def generate_item() do 6 | [ 7 | &Pattern.helmet/0, 8 | &Pattern.crown/0, 9 | &Pattern.hat/0, 10 | &Pattern.scouter/0, 11 | # &Pattern.eyepatch/0, 12 | &Pattern.googles/0, 13 | &Pattern.unilens/0, 14 | &Pattern.stick/0, 15 | &Pattern.sword/0, 16 | &Pattern.glove/0 17 | ] 18 | |> Enum.random() 19 | |> (fn f -> f.() end).() 20 | |> bend_pattern_a_little() 21 | |> mirrored() 22 | |> add_attributes() 23 | end 24 | 25 | def to_sprite(item) do 26 | PixelSmash.Sprites.Spritifier.to_sprite(item) 27 | end 28 | 29 | defp add_attributes(%Item{} = item) do 30 | # Add attribute 31 | item = 32 | cond do 33 | item.type in [:helmet, :hat, :googles] -> 34 | Map.put(item, :attribute, PixelSmash.Attributes.perseverance()) 35 | 36 | item.type in [:crown, :eyepatch, :scouter] -> 37 | Map.put(item, :attribute, PixelSmash.Attributes.special()) 38 | 39 | item.type in [:unilens, :stick, :sword, :glove] -> 40 | Map.put(item, :attribute, PixelSmash.Attributes.combat()) 41 | 42 | true -> 43 | item 44 | end 45 | 46 | # Generate name 47 | item = name(item) 48 | 49 | # We create an array of coordinates 50 | coordinates = 51 | for y <- 1..item.y, x <- 1..item.x do 52 | {x, y} 53 | end 54 | 55 | # Flatten the item's list of lists 56 | elements = 57 | item.data 58 | |> Enum.flat_map(fn x -> x end) 59 | 60 | # Merge results into a map 61 | map = 62 | coordinates 63 | |> Enum.zip(elements) 64 | |> Enum.into(%{}) 65 | 66 | # Assign attributes 67 | map = 68 | Enum.map(map, fn {key, value} -> 69 | case value do 70 | "0" -> {key, :no_operation} 71 | "X" -> {key, item.attribute} 72 | " " -> {key, :empty} 73 | end 74 | end) 75 | 76 | item = Map.put(item, :map, map) 77 | 78 | # Add power 79 | power = 80 | Enum.reduce(item.map, 0, fn {_key, value}, acc -> 81 | case value do 82 | :empty -> 83 | acc 84 | 85 | :no_operation -> 86 | acc 87 | 88 | _ -> 89 | acc + 1 90 | end 91 | end) 92 | 93 | Map.put(item, :power, power) 94 | end 95 | 96 | defp bend_pattern_a_little(%Item{} = item) do 97 | # Randomizes all `"?"` characters from a template 98 | data = 99 | Enum.map(item.data, fn row -> 100 | Enum.map(row, &randomize_rule/1) 101 | end) 102 | 103 | Map.put(item, :data, data) 104 | end 105 | 106 | defp randomize_rule(rule) do 107 | case rule do 108 | "t" -> 109 | Enum.random([" ", "X"]) 110 | 111 | "z" -> 112 | Enum.random(["0", "X"]) 113 | 114 | _ -> 115 | rule 116 | end 117 | end 118 | 119 | defp mirrored(%Item{} = item) do 120 | # Mirror if appropiate 121 | cond do 122 | item.type in [:helmet, :crown, :hat, :googles, :unilens] -> 123 | mirror(item) 124 | 125 | item.type in [:stick, :sword, :glove] -> 126 | mirror?(item) 127 | 128 | true -> 129 | item 130 | end 131 | end 132 | 133 | defp mirror?(%Item{} = item) do 134 | if Enum.random([true, false]) do 135 | mirror(item) 136 | else 137 | item 138 | end 139 | end 140 | 141 | defp mirror(%Item{} = item) do 142 | # Mirror data and adjust sizes 143 | data = 144 | Enum.map(item.data, fn row -> 145 | reverse = Enum.reverse(row) 146 | Enum.concat(row, reverse) 147 | end) 148 | 149 | item 150 | |> Map.put(:data, data) 151 | |> Map.put(:x, item.x * 2) 152 | |> Map.put(:y, item.y * 2) 153 | end 154 | 155 | defp name(%Item{} = item) do 156 | item_name = to_string(item.type) 157 | 158 | epic_name = 159 | Enum.random([ 160 | Faker.StarWars.character(), 161 | Faker.StarWars.planet(), 162 | Faker.Color.name(), 163 | Faker.Color.fancy_name(), 164 | Faker.Vehicle.model(), 165 | Faker.Superhero.power(), 166 | Faker.Superhero.name() 167 | ]) 168 | 169 | name = 170 | Enum.shuffle([item_name, epic_name]) 171 | |> Enum.join(" ") 172 | |> String.capitalize() 173 | 174 | Map.put(item, :name, name) 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /lib/pixel_smash/items/item.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Items.Item do 2 | defstruct [:name, :map, :data, :x, :y, :type, :power, :attribute] 3 | end 4 | -------------------------------------------------------------------------------- /lib/pixel_smash/items/pattern.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Items.Pattern do 2 | alias PixelSmash.Items.Item 3 | 4 | def helmet() do 5 | %Item{ 6 | x: 5, 7 | y: 4, 8 | data: [ 9 | ["0", "X", "X", "X", "X"], 10 | ["X", "X", "X", "X", "t"], 11 | ["X", "t", " ", " ", " "] 12 | ], 13 | type: :helmet 14 | } 15 | end 16 | 17 | def crown() do 18 | %Item{ 19 | x: 5, 20 | y: 4, 21 | data: [ 22 | ["X", "0", "X", "0", "X"], 23 | ["X", "z", "X", "z", "X"], 24 | ["X", "X", "X", "X", "X"] 25 | ], 26 | type: :crown 27 | } 28 | end 29 | 30 | def hat() do 31 | %Item{ 32 | x: 5, 33 | y: 4, 34 | data: [ 35 | ["0", "0", "0", "z", "X"], 36 | ["z", "0", "0", "X", "X"], 37 | ["z", "X", "X", "X", "X"], 38 | ["X", "X", "X", "X", "X"] 39 | ], 40 | type: :hat 41 | } 42 | end 43 | 44 | def eyepatch() do 45 | %Item{ 46 | x: 5, 47 | y: 4, 48 | data: [ 49 | ["X", "X", "X", "X", " "], 50 | [" ", "X", "X", "X", " "], 51 | [" ", "X", "X", "X", " "], 52 | [" ", "X", "X", "X", "X"] 53 | ], 54 | type: :eyepatch 55 | } 56 | end 57 | 58 | def scouter() do 59 | %Item{ 60 | x: 5, 61 | y: 5, 62 | data: [ 63 | ["X", "X", "X", "X", "X"], 64 | ["X", "X", "0", "0", "X"], 65 | ["X", "X", "0", "0", "X"], 66 | [" ", "X", "0", "0", "X"], 67 | [" ", "X", "X", "X", "X"] 68 | ], 69 | type: :scouter 70 | } 71 | end 72 | 73 | def googles() do 74 | %Item{ 75 | x: 5, 76 | y: 4, 77 | data: [ 78 | [" ", "t", "X", "X", "t"], 79 | ["X", "X", "0", "0", "X"], 80 | ["X", "X", "0", "0", "X"], 81 | [" ", "t", "X", "X", "t"] 82 | ], 83 | type: :googles 84 | } 85 | end 86 | 87 | def unilens() do 88 | %Item{ 89 | x: 5, 90 | y: 4, 91 | data: [ 92 | ["0", "X", "X", "X", "X"], 93 | ["X", "0", "0", "z", "0"], 94 | ["X", "0", "0", "z", "0"], 95 | ["0", "X", "X", "X", "X"] 96 | ], 97 | type: :unilens 98 | } 99 | end 100 | 101 | def stick() do 102 | %Item{ 103 | x: 5, 104 | y: 6, 105 | data: [ 106 | [" ", " ", " ", " ", " "], 107 | [" ", "X", " ", " ", " "], 108 | [" ", "X", " ", " ", " "], 109 | [" ", "X", " ", " ", " "], 110 | [" ", "X", " ", " ", " "], 111 | [" ", "X", " ", " ", " "] 112 | ], 113 | type: :stick 114 | } 115 | end 116 | 117 | def sword() do 118 | %Item{ 119 | x: 5, 120 | y: 6, 121 | data: [ 122 | [" ", "t", " ", " ", " "], 123 | [" ", "X", " ", " ", " "], 124 | [" ", "X", " ", " ", " "], 125 | [" ", "X", " ", " ", " "], 126 | ["X", "X", "X", " ", " "], 127 | [" ", "X", " ", " ", " "] 128 | ], 129 | type: :sword 130 | } 131 | end 132 | 133 | def glove() do 134 | %Item{ 135 | x: 5, 136 | y: 4, 137 | data: [ 138 | ["t", "X", "X", " ", " "], 139 | ["X", "X", "X", "X", " "], 140 | ["X", "X", "X", "X", " "], 141 | ["t", "X", "X", " ", " "] 142 | ], 143 | type: :glove 144 | } 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /lib/pixel_smash/memory_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.MemoryRepo do 2 | @moduledoc "For fast experimenting with schemas" 3 | use GenServer 4 | 5 | def start_link(_init_arg) do 6 | GenServer.start_link(__MODULE__, %{}, name: __MODULE__) 7 | end 8 | 9 | def insert(entity) do 10 | GenServer.call(__MODULE__, {:insert, entity}) 11 | end 12 | 13 | def get!(schema_module, id) do 14 | GenServer.call(__MODULE__, {:get!, schema_module, id}) 15 | end 16 | 17 | def all(module) do 18 | GenServer.call(__MODULE__, {:all, module}) 19 | end 20 | 21 | @impl GenServer 22 | def init(map) do 23 | {:ok, map} 24 | end 25 | 26 | @impl GenServer 27 | def handle_call( 28 | {:insert, %Ecto.Changeset{data: %module{} = entity, changes: changes}}, 29 | _from, 30 | map 31 | ) do 32 | entity = Map.merge(entity, changes) 33 | 34 | list = Map.get(map, module, []) 35 | entity = Map.put(entity, :id, length(list) + 1) 36 | updated_map = Map.put(map, module, List.insert_at(list, -1, entity)) 37 | 38 | {:reply, {:ok, entity}, updated_map} 39 | end 40 | 41 | def handle_call({:get!, module, id}, _from, map) do 42 | entity = 43 | map 44 | |> Map.get(module, []) 45 | |> Enum.find(&(&1.id == id)) 46 | 47 | {:reply, entity, map} 48 | end 49 | 50 | def handle_call({:all, module}, _from, map) do 51 | list = Map.get(map, module, []) 52 | 53 | {:reply, list, map} 54 | end 55 | 56 | def sprite_from_map(map), do: map 57 | end 58 | -------------------------------------------------------------------------------- /lib/pixel_smash/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Repo do 2 | use Ecto.Repo, 3 | otp_app: :pixel_smash, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | alias PixelSmash.Sprites.Sprite 7 | 8 | def sprite_from_map(map) when is_map(map) do 9 | map 10 | |> Enum.reduce(%Sprite{}, fn {key, value}, sprite -> 11 | Map.put(sprite, String.to_existing_atom(key), value) 12 | end) 13 | |> Map.update!(:map, &map_to_coordinates_and_colors(&1)) 14 | end 15 | 16 | def map_to_coordinates_and_colors(map) do 17 | Enum.reduce(map, %{}, fn {key, value}, map -> 18 | Map.put(map, coordinates_string_to_tuple(key), color_list_to_tuple(value)) 19 | end) 20 | end 21 | 22 | defp coordinates_string_to_tuple(binary) do 23 | binary 24 | |> String.to_charlist() 25 | |> List.to_tuple() 26 | end 27 | 28 | defp color_list_to_tuple(list) do 29 | {name, tint} = List.to_tuple(list) 30 | {String.to_existing_atom(name), tint} 31 | end 32 | end 33 | 34 | defmodule TupleEncoder do 35 | alias Jason.Encoder 36 | 37 | defimpl Encoder, for: Tuple do 38 | def encode(data, options) when is_tuple(data) do 39 | data 40 | |> Tuple.to_list() 41 | |> Encoder.List.encode(options) 42 | end 43 | end 44 | 45 | defimpl String.Chars, for: Tuple do 46 | def to_string(data) when is_tuple(data) do 47 | data 48 | |> Tuple.to_list() 49 | |> List.to_string() 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/pixel_smash/sprites.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Sprites do 2 | alias PixelSmash.Sprites.{ 3 | # Pixel, 4 | Sprite, 5 | Spritifier 6 | } 7 | 8 | defdelegate stats(sprite), to: Sprite 9 | 10 | def equip(entity_a, entity_b) do 11 | entity_a = Spritifier.to_sprite(entity_a) 12 | entity_b = Spritifier.to_sprite(entity_b) 13 | 14 | Sprite.apply_mask(entity_a, entity_b, fn _key, v1, v2 -> 15 | case v2 do 16 | :transparent -> 17 | v1 18 | 19 | _ -> 20 | v2 21 | end 22 | end) 23 | end 24 | 25 | def position(entity, at \\ {0, 0}) do 26 | entity 27 | |> Spritifier.to_sprite() 28 | |> Sprite.position(at) 29 | end 30 | 31 | def fusion(entity_a, entity_b) do 32 | entity_a = Spritifier.to_sprite(entity_a) 33 | entity_b = Spritifier.to_sprite(entity_b) 34 | 35 | # An attempt at bad xoring 36 | Sprite.apply_mask(entity_a, entity_b, fn {x, y}, v1, v2 -> 37 | cond do 38 | rem(y, 2) == 0 and rem(x, 2) == 0 -> 39 | v1 40 | 41 | rem(y, 2) == 0 and rem(x, 2) == 1 -> 42 | v2 43 | 44 | rem(y, 2) == 1 and rem(x, 2) == 0 -> 45 | v1 46 | 47 | rem(y, 2) == 1 and rem(x, 2) == 1 -> 48 | v2 49 | 50 | true -> 51 | Enum.random([v1, v2]) 52 | end 53 | end) 54 | end 55 | 56 | def to_sprite(entity) do 57 | Spritifier.to_sprite(entity) 58 | end 59 | 60 | # TODO: 61 | # This specific method contains logic for gladiator not sprite 62 | # defdelegate generate_sprite(size_x, size_y, generator_fn), to: Sprite, as: :new 63 | 64 | # def new_sprite(pixels) do 65 | # count = Sprite.default_size() * Sprite.default_size() - length(pixels) 66 | # fitting_pixels = pixels ++ Pixel.background(count) 67 | # Sprite.new(fitting_pixels) 68 | # end 69 | 70 | # defdelegate new_pixel, to: Pixel, as: :new 71 | # defdelegate new_pixel(base_color, tint), to: Pixel, as: :new 72 | 73 | # def background_pixel, do: 1 |> Pixel.background() |> List.first() 74 | end 75 | -------------------------------------------------------------------------------- /lib/pixel_smash/sprites/sprite.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Sprites.Sprite do 2 | @moduledoc """ 3 | Helpers for working with sprite structures. 4 | """ 5 | 6 | defstruct [ 7 | :x, 8 | :y, 9 | :map 10 | ] 11 | 12 | @type t() :: %__MODULE__{} 13 | @type coor() :: {integer(), integer()} 14 | 15 | @doc """ 16 | Applies a "mask" over the first sprite argument. By default the original sprite will be overritten 17 | by the mask mask but it is possible to pass a merge function to decide between values. 18 | 19 | If the mask "overflows" the original sprite it will be cut from the end result. 20 | """ 21 | @spec apply_mask( 22 | sprite :: t(), 23 | mask :: t(), 24 | merge_fn :: (key :: any(), value1 :: any(), value2 :: any() -> any()) 25 | ) :: t() 26 | def apply_mask( 27 | %__MODULE__{} = sprite, 28 | %__MODULE__{} = mask, 29 | merge_fn \\ fn _k, _v1, v2 -> v2 end 30 | ) do 31 | mask_map = 32 | mask.map 33 | |> Enum.reject(fn {{x, y}, _value} -> x > sprite.x or y > sprite.y end) 34 | |> Enum.reject(fn {{x, y}, _value} -> x < 1 or y < 1 end) 35 | |> Enum.into(%{}) 36 | 37 | map = Map.merge(sprite.map, mask_map, merge_fn) 38 | Map.put(sprite, :map, map) 39 | end 40 | 41 | @doc """ 42 | Modifies the original position of any sprite by applying a sum to the original coordinates. 43 | The sum here is considered boundless positive or negative values. 44 | """ 45 | @spec position(sprite :: t(), at :: coor()) :: t() 46 | def position(%__MODULE__{} = sprite, at) do 47 | map = 48 | Enum.map(sprite.map, fn {key, value} -> 49 | key = add_coordinate(key, at) 50 | {key, value} 51 | end) 52 | |> Enum.into(%{}) 53 | 54 | Map.put(sprite, :map, map) 55 | end 56 | 57 | @spec add_coordinate(coor_a :: coor(), coor_b :: coor()) :: coor() 58 | defp add_coordinate(coor_a, coor_b) do 59 | # Adds a pair of coordinate tuples {x, y} + {x', y'} 60 | {xa, ya} = coor_a 61 | {xb, yb} = coor_b 62 | 63 | {xa + xb, ya + yb} 64 | end 65 | 66 | def stats(%__MODULE__{map: map}) do 67 | map 68 | |> Map.values() 69 | |> Enum.frequencies() 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/pixel_smash/sprites/spritifier.ex: -------------------------------------------------------------------------------- 1 | defprotocol PixelSmash.Sprites.Spritifier do 2 | alias PixelSmash.Sprites.Sprite 3 | 4 | @spec to_sprite(data :: term()) :: Sprite.t() 5 | def to_sprite(data) 6 | end 7 | 8 | defimpl PixelSmash.Sprites.Spritifier, for: PixelSmash.Sprites.Sprite do 9 | def to_sprite(%PixelSmash.Sprites.Sprite{} = sprite) do 10 | sprite 11 | end 12 | end 13 | 14 | defimpl PixelSmash.Sprites.Spritifier, for: PixelSmash.Items.Item do 15 | def to_sprite(%PixelSmash.Items.Item{} = item) do 16 | color_cell = fn {coordinate, attribute} -> 17 | attribute = 18 | case attribute do 19 | :empty -> :transparent 20 | :no_operation -> :gray 21 | :vitality -> :dark_green 22 | :defense -> :dark_blue 23 | :strength -> :dark_red 24 | :casting -> :dark_purple 25 | :speed -> :dark_yellow 26 | :secret -> :dark_pink 27 | end 28 | 29 | {coordinate, attribute} 30 | end 31 | 32 | map = 33 | item.map 34 | |> Enum.map(color_cell) 35 | |> Enum.into(%{}) 36 | 37 | # Map it into the Sprite struct 38 | sprite = %PixelSmash.Sprites.Sprite{ 39 | x: item.x, 40 | y: item.y, 41 | map: map 42 | } 43 | 44 | sprite = 45 | cond do 46 | item.type in [:helmet, :hat, :crown] -> 47 | PixelSmash.Sprites.Sprite.position(sprite, {0, 0}) 48 | 49 | item.type in [:googles, :eyepatch, :scouter, :unilens] -> 50 | PixelSmash.Sprites.Sprite.position(sprite, {0, 2}) 51 | 52 | item.type in [:stick, :sword] -> 53 | PixelSmash.Sprites.Sprite.position(sprite, {0, 4}) 54 | 55 | item.type in [:glove] -> 56 | PixelSmash.Sprites.Sprite.position(sprite, {0, 5}) 57 | 58 | true -> 59 | sprite 60 | end 61 | 62 | fill = 63 | PixelSmash.Grids.generate(10, 10, fn _, _ -> :transparent end) 64 | |> PixelSmash.Grids.to_sprite(10, 10) 65 | 66 | filled_item = Map.merge(fill.map, sprite.map) 67 | 68 | sprite 69 | |> Map.put(:map, filled_item) 70 | |> Map.put(:x, 10) 71 | |> Map.put(:y, 10) 72 | end 73 | end 74 | 75 | defimpl PixelSmash.Sprites.Spritifier, for: PixelSmash.Gladiators.Gladiator do 76 | def to_sprite(%PixelSmash.Gladiators.Gladiator{} = gladiator) do 77 | color_cell = fn {coordinate, attribute} -> 78 | attribute = 79 | case attribute do 80 | :empty -> :transparent 81 | :no_operation -> :gray 82 | :vitality -> :green 83 | :defense -> :blue 84 | :strength -> :red 85 | :casting -> :purple 86 | :speed -> :yellow 87 | :secret -> :pink 88 | end 89 | 90 | {coordinate, attribute} 91 | end 92 | 93 | map = 94 | gladiator.data 95 | |> PixelSmash.Grids.to_map(10, 10) 96 | |> Enum.map(color_cell) 97 | |> Enum.into(%{}) 98 | 99 | %PixelSmash.Sprites.Sprite{ 100 | x: 10, 101 | y: 10, 102 | map: map 103 | } 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/pixel_smash/the_store.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.TheStore do 2 | def on_sale() do 3 | Enum.map(1..6, fn _ -> 4 | item = PixelSmash.Items.generate_item() 5 | sprite = PixelSmash.Sprites.to_sprite(item) 6 | %{item: item, sprite: sprite} 7 | end) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/pixel_smash/wallets.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Wallets do 2 | @moduledoc """ 3 | Wallets context. Allows to fetch wallet by ID. Fund and withdraw money from it. 4 | 5 | ## Examples 6 | 7 | iex> alias PixelSmash.{Wallets, Wallets.Wallet} 8 | iex> 9 | ...> wallet_id = Wallets.get_wallet_id("user_0") 10 | ...> 1000.0 = wallet_id |> Wallets.get_balance() |> Decimal.to_float() 11 | iex> 12 | ...> {:error, :not_enough_balance} = Wallets.take_stake(wallet_id, "1100") 13 | iex> 14 | ...> {:ok, %Wallet{id: ^wallet_id}} = Wallets.take_stake(wallet_id, "299.9") 15 | ...> 700.1 = wallet_id |> Wallets.get_balance() |> Decimal.to_float() 16 | iex> 17 | ...> {:ok, %Wallet{id: ^wallet_id}} = Wallets.fund(wallet_id, "399.9") 18 | ...> 1100.0 = wallet_id |> Wallets.get_balance() |> Decimal.to_float() 19 | """ 20 | 21 | alias PixelSmash.Wallets.{ 22 | Vault, 23 | Supervisor, 24 | Wallet 25 | } 26 | 27 | defdelegate child_spec(init_arg), to: Supervisor 28 | 29 | def persisted_wallets do 30 | [] 31 | end 32 | 33 | def get_wallet_id(user_id) do 34 | %Wallet{id: id} = Vault.get_wallet_by_user(user_id) 35 | id 36 | end 37 | 38 | def get_balance(wallet_id) do 39 | %Wallet{deposit: deposit} = Vault.get_wallet(wallet_id) 40 | deposit 41 | end 42 | 43 | def take_stake(wallet_id, amount) do 44 | Vault.update_wallet(wallet_id, fn %Wallet{deposit: deposit} = wallet -> 45 | updated_deposit = Decimal.sub(deposit, amount) 46 | 47 | if Decimal.lt?(updated_deposit, 0) do 48 | {:error, :not_enough_balance} 49 | else 50 | %{wallet | deposit: updated_deposit} 51 | end 52 | end) 53 | end 54 | 55 | def fund(wallet_id, amount) do 56 | Vault.update_wallet(wallet_id, fn %Wallet{deposit: deposit} = wallet -> 57 | %{wallet | deposit: Decimal.add(deposit, amount)} 58 | end) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/pixel_smash/wallets/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Wallets.Supervisor do 2 | use Supervisor 3 | 4 | alias PixelSmash.Wallets 5 | 6 | def start_link(state) do 7 | GenServer.start_link(__MODULE__, state, name: __MODULE__) 8 | end 9 | 10 | @impl true 11 | def init(init_arg) do 12 | children = [ 13 | {Wallets.Vault, init_arg} 14 | ] 15 | 16 | Supervisor.start_link(children, strategy: :one_for_one) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/pixel_smash/wallets/vault.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Wallets.Vault do 2 | @moduledoc """ 3 | GenServer module to keep Wallets. 4 | 5 | ## Examples 6 | 7 | iex> alias PixelSmash.Wallets.{Vault, Wallet} 8 | iex> 9 | ...> pid = start_supervised!({Vault, [restore_fn: fn -> [Wallet.new("user_1", "250.00")] end, name: :test_vault]}) 10 | ...> [%Wallet{deposit: deposit, id: _, user_id: "user_1"}] = Vault.list_wallets(pid) 11 | ...> true = Decimal.eq?("250.0", deposit) 12 | iex> 13 | ...> Vault.put_wallet(pid, Wallet.new("+0", "user_0", 100)) 14 | ...> [%Wallet{id: "+0", user_id: "user_0"}, %Wallet{id: _, user_id: "user_1"}] = Vault.list_wallets(pid) 15 | iex> 16 | ...> %Wallet{user_id: "user_1", deposit: deposit} = Vault.get_wallet_by_user(pid, "user_1") 17 | ...> true = Decimal.eq?("1000.0", deposit) 18 | iex> 19 | ...> %Wallet{deposit: deposit} = Vault.get_wallet(pid, "+0") 20 | ...> true = Decimal.eq?(100, deposit) 21 | iex> 22 | ...> {:ok, %Wallet{id: "+0", deposit: deposit}} = Vault.update_wallet(pid, "+0", fn wallet -> %{wallet | deposit: Decimal.add(wallet.deposit, 50)} end) 23 | ...> %Wallet{deposit: ^deposit} = Vault.get_wallet(pid, "+0") 24 | ...> true = Decimal.eq?(150, deposit) 25 | iex> 26 | ...> {:error, :not_found} = Vault.update_wallet(pid, "nonexisting_wallet_id", fn _wallet -> nil end) 27 | iex> 28 | iex> # If anonymous function passed to update_wallet returns something other then 29 | ...> # a wallet then the original wallet stays the same and result of anonymous function 30 | ...> # is bypassed to caller. 31 | ...> 32 | ...> {:error, :reason} = Vault.update_wallet(pid, "+0", fn _wallet -> {:error, :reason} end) 33 | ...> %Wallet{deposit: deposit} = Vault.get_wallet(pid, "+0") 34 | ...> true = Decimal.eq?(150, deposit) 35 | """ 36 | 37 | use GenServer 38 | 39 | alias PixelSmash.Wallets.Wallet 40 | 41 | @initial_deposit 1000 42 | 43 | def start_link(opts) do 44 | restore_fn = Keyword.fetch!(opts, :restore_fn) 45 | name = Keyword.get(opts, :name, __MODULE__) 46 | 47 | GenServer.start_link(__MODULE__, restore_fn, name: name) 48 | end 49 | 50 | def list_wallets(pid \\ __MODULE__) do 51 | GenServer.call(pid, :list_wallets) 52 | end 53 | 54 | def put_wallet(pid \\ __MODULE__, %Wallet{} = wallet) do 55 | GenServer.call(pid, {:put_wallet, wallet}) 56 | end 57 | 58 | def get_wallet(pid \\ __MODULE__, id) do 59 | GenServer.call(pid, {:get_wallet, id}) 60 | end 61 | 62 | def get_wallet_by_user(pid \\ __MODULE__, user_id) do 63 | GenServer.call(pid, {:get_wallet_by_user, user_id}) 64 | end 65 | 66 | def update_wallet(pid \\ __MODULE__, id, update_fn) when is_function(update_fn) do 67 | GenServer.call(pid, {:update_wallet, id, update_fn}) 68 | end 69 | 70 | def init(restore_fn) do 71 | {:ok, %{}, {:continue, {:seed, restore_fn}}} 72 | end 73 | 74 | def handle_continue({:seed, restore_fn}, vault) do 75 | wallets = restore_fn.() ++ [] 76 | vault = Enum.reduce(wallets, vault, &do_put_wallet(&1, &2)) 77 | {:noreply, vault} 78 | end 79 | 80 | def handle_call(:list_wallets, _from, vault) do 81 | list = 82 | vault 83 | |> Enum.sort(fn {id1, _wallet1}, {id2, _wallet2} -> id1 <= id2 end) 84 | |> Enum.map(fn {_id, wallet} -> wallet end) 85 | 86 | {:reply, list, vault} 87 | end 88 | 89 | def handle_call({:put_wallet, wallet}, _from, vault) do 90 | {:reply, wallet, do_put_wallet(wallet, vault)} 91 | end 92 | 93 | def handle_call({:get_wallet, id}, _from, vault) do 94 | {:reply, vault[id], vault} 95 | end 96 | 97 | def handle_call({:get_wallet_by_user, id}, _from, vault) do 98 | vault = put_initial_wallet(vault, id) 99 | 100 | wallet = 101 | vault 102 | |> Map.values() 103 | |> Enum.find(&(&1.user_id == id)) 104 | 105 | {:reply, wallet, vault} 106 | end 107 | 108 | def handle_call({:update_wallet, id, update_fn}, _from, vault) do 109 | with %Wallet{id: old_id} = wallet when not is_nil(wallet) <- vault[id], 110 | %Wallet{id: ^old_id} = updated_wallet <- update_fn.(wallet) do 111 | {:reply, {:ok, updated_wallet}, do_put_wallet(updated_wallet, vault)} 112 | else 113 | nil -> {:reply, {:error, :not_found}, vault} 114 | reply -> {:reply, reply, vault} 115 | end 116 | end 117 | 118 | defp do_put_wallet(%Wallet{id: id} = wallet, vault) do 119 | Map.put(vault, id, wallet) 120 | end 121 | 122 | defp put_initial_wallet(vault, user_id) do 123 | wallet = Wallet.new(user_id, @initial_deposit) 124 | 125 | Map.put_new(vault, wallet.id, wallet) 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/pixel_smash/wallets/wallet.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Wallets.Wallet do 2 | defstruct [:id, :user_id, :deposit] 3 | 4 | def new(id, user_id, deposit) when byte_size(id) > 0 and user_id > 0 do 5 | struct!(__MODULE__, %{id: id, user_id: user_id, deposit: Decimal.new(deposit)}) 6 | end 7 | 8 | def new(user_id, initial_deposit) do 9 | __MODULE__.new(Ecto.UUID.generate(), user_id, initial_deposit) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/pixel_smash_web.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb 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 PixelSmashWeb, :controller 9 | use PixelSmashWeb, :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: PixelSmashWeb 23 | 24 | import Plug.Conn 25 | import PixelSmashWeb.Gettext 26 | alias PixelSmashWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/pixel_smash_web/templates", 34 | namespace: PixelSmashWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, 38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 39 | 40 | # Include shared imports and aliases for views 41 | unquote(view_helpers()) 42 | end 43 | end 44 | 45 | def live_view do 46 | quote do 47 | use Phoenix.LiveView, 48 | layout: {PixelSmashWeb.LayoutView, "live.html"} 49 | 50 | unquote(view_helpers()) 51 | end 52 | end 53 | 54 | def live_component do 55 | quote do 56 | use Phoenix.LiveComponent 57 | 58 | unquote(view_helpers()) 59 | end 60 | end 61 | 62 | def router do 63 | quote do 64 | use Phoenix.Router 65 | 66 | import Plug.Conn 67 | import Phoenix.Controller 68 | import Phoenix.LiveView.Router 69 | end 70 | end 71 | 72 | def channel do 73 | quote do 74 | use Phoenix.Channel 75 | import PixelSmashWeb.Gettext 76 | end 77 | end 78 | 79 | defp view_helpers do 80 | quote do 81 | # Use all HTML functionality (forms, tags, etc) 82 | use Phoenix.HTML 83 | 84 | # Import LiveView helpers (live_render, live_component, live_patch, etc) 85 | import Phoenix.LiveView.Helpers 86 | 87 | # Import basic rendering functionality (render, render_layout, etc) 88 | import Phoenix.View 89 | 90 | import PixelSmashWeb.ErrorHelpers 91 | import PixelSmashWeb.Gettext 92 | import PixelSmashWeb.MountHelpers 93 | alias PixelSmashWeb.Router.Helpers, as: Routes 94 | end 95 | end 96 | 97 | @doc """ 98 | When used, dispatch to the appropriate controller/view/etc. 99 | """ 100 | defmacro __using__(which) when is_atom(which) do 101 | apply(__MODULE__, which, []) 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", PixelSmashWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | @impl true 19 | def connect(_params, socket, _connect_info) do 20 | {:ok, socket} 21 | end 22 | 23 | # Socket id's are topics that allow you to identify all sockets for a given user: 24 | # 25 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 26 | # 27 | # Would allow you to broadcast a "disconnect" event and terminate 28 | # all active sockets and channels for a given user: 29 | # 30 | # PixelSmashWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 31 | # 32 | # Returning `nil` makes this socket anonymous. 33 | @impl true 34 | def id(_socket), do: nil 35 | end 36 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/finished_battle_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.FinishedBattleComponent do 2 | @moduledoc""" 3 | Handles the logic behind displaying the finished battles. 4 | Contains two fighters the loser (:right) and the winner (:left) 5 | """ 6 | use PixelSmashWeb, :live_component 7 | 8 | @impl true 9 | def mount(socket) do 10 | {:ok, socket} 11 | end 12 | 13 | @impl true 14 | def update(assigns, socket) do 15 | {left, right} = assigns.battle.fighters 16 | 17 | socket = 18 | socket 19 | |> assign(:battle, assigns.battle) 20 | |> assign(:left, left) 21 | |> assign(:right, right) 22 | 23 | {:ok, socket} 24 | end 25 | 26 | def outcome(:left, :left), do: "Winner" 27 | def outcome(:right, :right), do: "Winner" 28 | def outcome(_, :draw), do: "Draw" 29 | def outcome(_, _), do: "Loser" 30 | end 31 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/finished_battle_component.html.leex: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Finished 5 | 6 |
7 |
8 | <%= live_component @socket, PixelSmashWeb.GladiatorComponent, gladiator: @left, text: outcome(:left, @battle.outcome) %> 9 | <%= live_component @socket, PixelSmashWeb.GladiatorComponent, gladiator: @right, text: outcome(:right, @battle.outcome) %> 10 |
11 |
12 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/gladiator_card_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.GladiatorCardComponent do 2 | @moduledoc """ 3 | Live Component for displaying more details about each of the gladiators. 4 | """ 5 | use PixelSmashWeb, :live_component 6 | 7 | @impl true 8 | def render(assigns) do 9 | ~L""" 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 |
IconNameELOWinsLossesDrawsMax HealthDefenseStrengthSpeedMagicSpells
<%= live_component @socket, PixelSmashWeb.SpriteComponent, sprite: @gladiator.sprite %><%= @gladiator.name %><%= @gladiator.elo %><%= @gladiator.wins %><%= @gladiator.losses %><%= @gladiator.draws %><%= @gladiator.max_health %><%= @gladiator.defense %><%= @gladiator.strength %><%= @gladiator.speed %><%= @gladiator.magic %><%= Enum.join(@gladiator.spells, " ") %>
40 | """ 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/gladiator_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.GladiatorComponent do 2 | @moduledoc """ 3 | Responsible for the common html for the various battle components. 4 | Takes in the gladiator struct, and the text to display for the gladiator 5 | """ 6 | use PixelSmashWeb, :live_component 7 | 8 | def render(assigns) do 9 | ~L""" 10 |
11 | <%= live_component @socket, PixelSmashWeb.SpriteComponent, sprite: @gladiator.sprite %> 12 |
13 |

14 | <%= @gladiator.name %> 15 |

16 | 17 | <%= @text %> 18 | 19 |
20 |
21 | """ 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/in_progress_battle_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.InProgressBattleComponent do 2 | @moduledoc """ 3 | Handles all of the logic for displaying the battles that are currently in progress. 4 | """ 5 | use PixelSmashWeb, :live_component 6 | 7 | alias PixelSmash.Battles 8 | 9 | @impl true 10 | def mount(socket) do 11 | {:ok, socket} 12 | end 13 | 14 | @impl true 15 | def update(assigns, socket) do 16 | {left, right} = assigns.battle.fighters 17 | 18 | socket = 19 | socket 20 | |> assign(:battle, assigns.battle) 21 | |> assign(:left, left) 22 | |> assign(:right, right) 23 | 24 | {:ok, socket} 25 | end 26 | 27 | def narration(battle) do 28 | battle 29 | |> Battles.narrate_battle() 30 | |> Enum.reverse() 31 | |> Enum.take(3) 32 | |> Enum.with_index() 33 | |> Enum.map(fn 34 | {line, 0} -> {line, "text-opacity-100"} 35 | {line, 1} -> {line, "text-opacity-75"} 36 | {line, 2} -> {line, "text-opacity-50"} 37 | end) 38 | |> Enum.reverse() 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/in_progress_battle_component.html.leex: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | In Progress 5 | 6 |
7 | 8 |
9 |
10 | <%= live_component @socket, PixelSmashWeb.GladiatorComponent, gladiator: @left, text: "Health#{@left.health}" %> 11 | <%= live_component @socket, PixelSmashWeb.GladiatorComponent, gladiator: @right, text: "Health#{@right.health}" %> 12 |
13 | 14 |
15 | <%= for {line, opacity_class} <- narration(@battle) do %> 16 |

<%= line %>

17 | <% end %> 18 |
19 |
20 |
21 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/item_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.ItemComponent do 2 | @moduledoc """ 3 | Renders the display of the items available in the shop 4 | """ 5 | use PixelSmashWeb, :live_component 6 | 7 | @impl true 8 | def render(assigns) do 9 | ~L""" 10 |
11 |
12 | 13 | <%= @item.type %> 14 | 15 |
16 |
17 |
18 | <%= live_component @socket, PixelSmashWeb.SpriteComponent, sprite: @sprite %> 19 |
20 |

21 | <%= @item.name %> 22 |

23 | 24 | <%= @item.attribute %>: <%= @item.power %> 25 | 26 |
27 |
28 |
29 |
30 | """ 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/menu_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.MenuComponent do 2 | use PixelSmashWeb, :live_component 3 | 4 | def render(assigns) do 5 | ~L""" 6 |
7 |
8 |
9 |
10 |

11 | Balance: 12 |

13 | 14 | <%= Number.Currency.number_to_currency(@balance, precision: 0) %> 15 | 16 | <%= link "Standings", to: Routes.standings_path(@socket, :index), class: "text-lg font-semibold text-white -mt-1" %> 17 | <%= link "Gladiators", to: Routes.gladiator_path(@socket, :index), class: "text-lg font-semibold text-white -mt-1" %> 18 | <%= link "The Store", to: Routes.the_store_path(@socket, :index), class: "text-lg font-semibold text-white -mt-1" %> 19 |
20 |
21 |
22 |
23 | """ 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/scheduled_battle_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.ScheduledBattleComponent do 2 | @moduledoc """ 3 | Handles the display of the upcoming battles. 4 | Responsible for calculating the betting odds for each gladiator 5 | and displays the potential financial reward for placing a bet on the gladiator. 6 | """ 7 | use PixelSmashWeb, :live_component 8 | 9 | alias PixelSmash.{ 10 | Betting, 11 | Gladiators 12 | } 13 | 14 | @impl true 15 | def mount(socket) do 16 | {:ok, socket} 17 | end 18 | 19 | @impl true 20 | def update(assigns, socket) do 21 | {left, right} = assigns.battle.fighters 22 | 23 | current_bet = Betting.get_bet(assigns.battle, assigns.current_user) 24 | {:ok, {left_odds, right_odds}} = Betting.get_odds(assigns.battle) 25 | 26 | socket = 27 | socket 28 | |> assign(:balance, assigns.balance) 29 | |> assign(:current_bet, current_bet) 30 | |> assign(:battle, assigns.battle) 31 | |> assign(:left, Gladiators.get_gladiator(left.id)) 32 | |> assign(:right, Gladiators.get_gladiator(right.id)) 33 | |> assign(:left_odds, left_odds) 34 | |> assign(:right_odds, right_odds) 35 | 36 | {:ok, socket} 37 | end 38 | 39 | @doc """ 40 | Converts the odds to a percentage value to display to the user 41 | """ 42 | def as_percentage(%Decimal{} = n) do 43 | n 44 | |> Decimal.mult(100) 45 | |> Decimal.to_float() 46 | |> Number.Percentage.number_to_percentage(precision: 0) 47 | end 48 | 49 | @doc """ 50 | Calculates the potential winnings to be received if the gladiator being bet on is the winner 51 | """ 52 | def example_winnings(battle, side) do 53 | {:ok, winnings} = Betting.get_expected_winnings(battle, side, 100) 54 | 55 | winnings = Number.Currency.number_to_currency(winnings, precision: 0) 56 | bet = Number.Currency.number_to_currency(100, precision: 0) 57 | 58 | "#{bet} ⇒ #{winnings}" 59 | end 60 | 61 | @doc """ 62 | Handles the logic for if a player has placed a bet on a particular gladiator 63 | """ 64 | def bet?({_user, side, _amount}, side), do: true 65 | def bet?(_, _), do: false 66 | 67 | def can_bet?(0.0), do: false 68 | def can_bet?(_), do: true 69 | 70 | def bet_amount(balance) do 71 | min(100, round(balance)) 72 | end 73 | 74 | def formatted_bet_amount(balance) do 75 | Number.Currency.number_to_currency(bet_amount(balance), precision: 0) 76 | end 77 | 78 | def bet({_user, _side, amount}) do 79 | Number.Currency.number_to_currency(amount, precision: 0) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/scheduled_battle_component.html.leex: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Scheduled 5 | 6 |
7 |
8 |
9 | <%= live_component @socket, PixelSmashWeb.GladiatorComponent, gladiator: @left, text: "Wins: #{@left.wins} Draws: #{@left.draws} Losses: #{@left.losses} ELO: #{@left.elo}" %> 10 |
11 |

12 | <%= as_percentage(@left_odds) %> 13 |

14 | 15 | <%= example_winnings(@battle, :left) %> 16 | 17 |
18 |
19 | <%= cond do %> 20 | <% is_nil(@current_bet) -> %> 21 | <%= if can_bet?(@balance) do %> 22 | 25 | <% end %> 26 | <% not is_nil(@current_bet) and bet?(@current_bet, :left) -> %> 27 |

28 | <%= bet(@current_bet) %> 29 |

30 | <% true -> %> 31 | 32 | <% end %> 33 |
34 |
35 | 36 |
37 | <%= live_component @socket, PixelSmashWeb.GladiatorComponent, gladiator: @right, text: "Wins: #{@right.wins} Draws: #{@left.draws} Losses: #{@right.losses} ELO: #{@right.elo}" %> 38 |
39 |

40 | <%= as_percentage(@right_odds) %> 41 |

42 | 43 | <%= example_winnings(@battle, :right) %> 44 | 45 |
46 |
47 | <%= cond do %> 48 | <% is_nil(@current_bet) -> %> 49 | <%= if can_bet?(@balance) do %> 50 | 53 | <% end %> 54 | <% not is_nil(@current_bet) and bet?(@current_bet, :right) -> %> 55 |

56 | <%= bet(@current_bet) %> 57 |

58 | <% true -> %> 59 | 60 | <% end %> 61 |
62 |
63 |
64 |
65 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/sprite_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.SpriteComponent do 2 | @moduledoc """ 3 | Handles the display of the sprites representing the gladiators 4 | """ 5 | use PixelSmashWeb, :live_component 6 | 7 | def render(assigns) do 8 | ~L""" 9 | 15 | <%= for {{x, y}, base_color} <- @sprite.map do %> 16 | 23 | <% end %> 24 | 25 | """ 26 | end 27 | 28 | defp fill_color(base_color) do 29 | case hsl(base_color) do 30 | nil -> 31 | "none" 32 | 33 | {h, s, l} -> 34 | "hsla(#{h}, #{s}%, #{l}%)" 35 | end 36 | end 37 | 38 | defp hsl(base_color) do 39 | case base_color do 40 | :transparent -> nil 41 | :gray -> {0, 0, 20} 42 | :green -> {76, 100, 71} 43 | :blue -> {216, 100, 69} 44 | :yellow -> {46, 100, 68} 45 | :red -> {1, 100, 70} 46 | :purple -> {262, 100, 70} 47 | :pink -> {349, 100, 70} 48 | :dark_green -> {155, 90, 40} 49 | :dark_blue -> {216, 75, 49} 50 | :dark_yellow -> {46, 75, 48} 51 | :dark_red -> {1, 75, 50} 52 | :dark_purple -> {262, 75, 50} 53 | :dark_pink -> {349, 75, 50} 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/components/standings_component.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.StandingsComponent do 2 | @moduledoc """ 3 | Live Component for displaying the current standings 4 | Updates whenever a battle is completed with the current 5 | standing. 6 | """ 7 | use PixelSmashWeb, :live_component 8 | 9 | @impl true 10 | def render(assigns) do 11 | ~L""" 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | <%= for gladiator <- @gladiators do %> 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | <% end %> 29 |
NameELOWinsLossesDraws
<%= gladiator.name %><%= gladiator.elo %><%= gladiator.wins %><%= gladiator.losses %><%= gladiator.draws %>
30 | """ 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/controllers/user_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserAuth do 2 | import Plug.Conn 3 | import Phoenix.Controller 4 | 5 | alias PixelSmash.Accounts 6 | alias PixelSmashWeb.Router.Helpers, as: Routes 7 | 8 | # Make the remember me cookie valid for 60 days. 9 | # If you want bump or reduce this value, also change 10 | # the token expiry itself in UserToken. 11 | @max_age 60 * 60 * 24 * 60 12 | @remember_me_cookie "user_remember_me" 13 | @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] 14 | 15 | @doc """ 16 | Logs the user in. 17 | 18 | It renews the session ID and clears the whole session 19 | to avoid fixation attacks. See the renew_session 20 | function to customize this behaviour. 21 | 22 | It also sets a `:live_socket_id` key in the session, 23 | so LiveView sessions are identified and automatically 24 | disconnected on log out. The line can be safely removed 25 | if you are not using LiveView. 26 | """ 27 | def log_in_user(conn, user, params \\ %{}) do 28 | token = Accounts.generate_user_session_token(user) 29 | user_return_to = get_session(conn, :user_return_to) 30 | 31 | conn 32 | |> renew_session() 33 | |> put_session(:user_token, token) 34 | |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") 35 | |> maybe_write_remember_me_cookie(token, params) 36 | |> redirect(to: user_return_to || signed_in_path(conn)) 37 | end 38 | 39 | defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do 40 | put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) 41 | end 42 | 43 | defp maybe_write_remember_me_cookie(conn, _token, _params) do 44 | conn 45 | end 46 | 47 | # This function renews the session ID and erases the whole 48 | # session to avoid fixation attacks. If there is any data 49 | # in the session you may want to preserve after log in/log out, 50 | # you must explicitly fetch the session data before clearing 51 | # and then immediately set it after clearing, for example: 52 | # 53 | # defp renew_session(conn) do 54 | # preferred_locale = get_session(conn, :preferred_locale) 55 | # 56 | # conn 57 | # |> configure_session(renew: true) 58 | # |> clear_session() 59 | # |> put_session(:preferred_locale, preferred_locale) 60 | # end 61 | # 62 | defp renew_session(conn) do 63 | conn 64 | |> configure_session(renew: true) 65 | |> clear_session() 66 | end 67 | 68 | @doc """ 69 | Logs the user out. 70 | 71 | It clears all session data for safety. See renew_session. 72 | """ 73 | def log_out_user(conn) do 74 | user_token = get_session(conn, :user_token) 75 | user_token && Accounts.delete_session_token(user_token) 76 | 77 | if live_socket_id = get_session(conn, :live_socket_id) do 78 | PixelSmashWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) 79 | end 80 | 81 | conn 82 | |> renew_session() 83 | |> delete_resp_cookie(@remember_me_cookie) 84 | |> redirect(to: "/") 85 | end 86 | 87 | @doc """ 88 | Authenticates the user by looking into the session 89 | and remember me token. 90 | """ 91 | def fetch_current_user(conn, _opts) do 92 | {user_token, conn} = ensure_user_token(conn) 93 | user = user_token && Accounts.get_user_by_session_token(user_token) 94 | assign(conn, :current_user, user) 95 | end 96 | 97 | defp ensure_user_token(conn) do 98 | if user_token = get_session(conn, :user_token) do 99 | {user_token, conn} 100 | else 101 | conn = fetch_cookies(conn, signed: [@remember_me_cookie]) 102 | 103 | if user_token = conn.cookies[@remember_me_cookie] do 104 | {user_token, put_session(conn, :user_token, user_token)} 105 | else 106 | {nil, conn} 107 | end 108 | end 109 | end 110 | 111 | @doc """ 112 | Used for routes that require the user to not be authenticated. 113 | """ 114 | def redirect_if_user_is_authenticated(conn, _opts) do 115 | if conn.assigns[:current_user] do 116 | conn 117 | |> redirect(to: signed_in_path(conn)) 118 | |> halt() 119 | else 120 | conn 121 | end 122 | end 123 | 124 | @doc """ 125 | Used for routes that require the user to be authenticated. 126 | 127 | If you want to enforce the user email is confirmed before 128 | they use the application at all, here would be a good place. 129 | """ 130 | def require_authenticated_user(conn, _opts) do 131 | if conn.assigns[:current_user] do 132 | conn 133 | else 134 | conn 135 | |> put_flash(:error, "You must log in to access this page.") 136 | |> maybe_store_return_to() 137 | |> redirect(to: Routes.user_session_path(conn, :new)) 138 | |> halt() 139 | end 140 | end 141 | 142 | defp maybe_store_return_to(%{method: "GET"} = conn) do 143 | %{request_path: request_path, query_string: query_string} = conn 144 | return_to = if query_string == "", do: request_path, else: request_path <> "?" <> query_string 145 | put_session(conn, :user_return_to, return_to) 146 | end 147 | 148 | defp maybe_store_return_to(conn), do: conn 149 | 150 | defp signed_in_path(_conn), do: "/" 151 | end 152 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/controllers/user_confirmation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserConfirmationController do 2 | use PixelSmashWeb, :controller 3 | 4 | alias PixelSmash.Accounts 5 | 6 | def new(conn, _params) do 7 | render(conn, "new.html") 8 | end 9 | 10 | def create(conn, %{"user" => %{"email" => email}}) do 11 | if user = Accounts.get_user_by_email(email) do 12 | Accounts.deliver_user_confirmation_instructions( 13 | user, 14 | &Routes.user_confirmation_url(conn, :confirm, &1) 15 | ) 16 | end 17 | 18 | # Regardless of the outcome, show an impartial success/error message. 19 | conn 20 | |> put_flash( 21 | :info, 22 | "If your email is in our system and it has not been confirmed yet, " <> 23 | "you will receive an email with instructions shortly." 24 | ) 25 | |> redirect(to: "/") 26 | end 27 | 28 | # Do not log in the user after confirmation to avoid a 29 | # leaked token giving the user access to the account. 30 | def confirm(conn, %{"token" => token}) do 31 | case Accounts.confirm_user(token) do 32 | {:ok, _} -> 33 | conn 34 | |> put_flash(:info, "Account confirmed successfully.") 35 | |> redirect(to: "/") 36 | 37 | :error -> 38 | conn 39 | |> put_flash(:error, "Confirmation link is invalid or it has expired.") 40 | |> redirect(to: "/") 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/controllers/user_registration_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserRegistrationController do 2 | use PixelSmashWeb, :controller 3 | 4 | alias PixelSmash.Accounts 5 | alias PixelSmash.Accounts.User 6 | alias PixelSmashWeb.UserAuth 7 | 8 | def new(conn, _params) do 9 | changeset = Accounts.change_user_registration(%User{}) 10 | render(conn, "new.html", changeset: changeset) 11 | end 12 | 13 | def create(conn, %{"user" => user_params}) do 14 | case Accounts.register_user(user_params) do 15 | {:ok, user} -> 16 | {:ok, _} = 17 | Accounts.deliver_user_confirmation_instructions( 18 | user, 19 | &Routes.user_confirmation_url(conn, :confirm, &1) 20 | ) 21 | 22 | conn 23 | |> put_flash(:info, "User created successfully.") 24 | |> UserAuth.log_in_user(user) 25 | 26 | {:error, %Ecto.Changeset{} = changeset} -> 27 | render(conn, "new.html", changeset: changeset) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/controllers/user_reset_password_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserResetPasswordController do 2 | use PixelSmashWeb, :controller 3 | 4 | alias PixelSmash.Accounts 5 | 6 | plug :get_user_by_reset_password_token when action in [:edit, :update] 7 | 8 | def new(conn, _params) do 9 | render(conn, "new.html") 10 | end 11 | 12 | def create(conn, %{"user" => %{"email" => email}}) do 13 | if user = Accounts.get_user_by_email(email) do 14 | Accounts.deliver_user_reset_password_instructions( 15 | user, 16 | &Routes.user_reset_password_url(conn, :edit, &1) 17 | ) 18 | end 19 | 20 | # Regardless of the outcome, show an impartial success/error message. 21 | conn 22 | |> put_flash( 23 | :info, 24 | "If your email is in our system, you will receive instructions to reset your password shortly." 25 | ) 26 | |> redirect(to: "/") 27 | end 28 | 29 | def edit(conn, _params) do 30 | render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user)) 31 | end 32 | 33 | # Do not log in the user after reset password to avoid a 34 | # leaked token giving the user access to the account. 35 | def update(conn, %{"user" => user_params}) do 36 | case Accounts.reset_user_password(conn.assigns.user, user_params) do 37 | {:ok, _} -> 38 | conn 39 | |> put_flash(:info, "Password reset successfully.") 40 | |> redirect(to: Routes.user_session_path(conn, :new)) 41 | 42 | {:error, changeset} -> 43 | render(conn, "edit.html", changeset: changeset) 44 | end 45 | end 46 | 47 | defp get_user_by_reset_password_token(conn, _opts) do 48 | %{"token" => token} = conn.params 49 | 50 | if user = Accounts.get_user_by_reset_password_token(token) do 51 | conn |> assign(:user, user) |> assign(:token, token) 52 | else 53 | conn 54 | |> put_flash(:error, "Reset password link is invalid or it has expired.") 55 | |> redirect(to: "/") 56 | |> halt() 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/controllers/user_session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserSessionController do 2 | use PixelSmashWeb, :controller 3 | 4 | alias PixelSmash.Accounts 5 | alias PixelSmashWeb.UserAuth 6 | 7 | def new(conn, _params) do 8 | render(conn, "new.html", error_message: nil) 9 | end 10 | 11 | def create(conn, %{"user" => user_params}) do 12 | %{"email" => email, "password" => password} = user_params 13 | 14 | if user = Accounts.get_user_by_email_and_password(email, password) do 15 | UserAuth.log_in_user(conn, user, user_params) 16 | else 17 | render(conn, "new.html", error_message: "Invalid email or password") 18 | end 19 | end 20 | 21 | def delete(conn, _params) do 22 | conn 23 | |> put_flash(:info, "Logged out successfully.") 24 | |> UserAuth.log_out_user() 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/controllers/user_settings_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserSettingsController do 2 | use PixelSmashWeb, :controller 3 | 4 | alias PixelSmash.Accounts 5 | alias PixelSmashWeb.UserAuth 6 | 7 | plug :assign_email_and_password_changesets 8 | 9 | def edit(conn, _params) do 10 | render(conn, "edit.html") 11 | end 12 | 13 | def update_email(conn, %{"current_password" => password, "user" => user_params}) do 14 | user = conn.assigns.current_user 15 | 16 | case Accounts.apply_user_email(user, password, user_params) do 17 | {:ok, applied_user} -> 18 | Accounts.deliver_update_email_instructions( 19 | applied_user, 20 | user.email, 21 | &Routes.user_settings_url(conn, :confirm_email, &1) 22 | ) 23 | 24 | conn 25 | |> put_flash( 26 | :info, 27 | "A link to confirm your email change has been sent to the new address." 28 | ) 29 | |> redirect(to: Routes.user_settings_path(conn, :edit)) 30 | 31 | {:error, changeset} -> 32 | render(conn, "edit.html", email_changeset: changeset) 33 | end 34 | end 35 | 36 | def confirm_email(conn, %{"token" => token}) do 37 | case Accounts.update_user_email(conn.assigns.current_user, token) do 38 | :ok -> 39 | conn 40 | |> put_flash(:info, "Email changed successfully.") 41 | |> redirect(to: Routes.user_settings_path(conn, :edit)) 42 | 43 | :error -> 44 | conn 45 | |> put_flash(:error, "Email change link is invalid or it has expired.") 46 | |> redirect(to: Routes.user_settings_path(conn, :edit)) 47 | end 48 | end 49 | 50 | def update_password(conn, %{"current_password" => password, "user" => user_params}) do 51 | user = conn.assigns.current_user 52 | 53 | case Accounts.update_user_password(user, password, user_params) do 54 | {:ok, user} -> 55 | conn 56 | |> put_flash(:info, "Password updated successfully.") 57 | |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit)) 58 | |> UserAuth.log_in_user(user) 59 | 60 | {:error, changeset} -> 61 | render(conn, "edit.html", password_changeset: changeset) 62 | end 63 | end 64 | 65 | defp assign_email_and_password_changesets(conn, _opts) do 66 | user = conn.assigns.current_user 67 | 68 | conn 69 | |> assign(:email_changeset, Accounts.change_user_email(user)) 70 | |> assign(:password_changeset, Accounts.change_user_password(user)) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :pixel_smash 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_pixel_smash_key", 10 | signing_salt: "YuA9hdqo" 11 | ] 12 | 13 | socket "/socket", PixelSmashWeb.UserSocket, 14 | websocket: true, 15 | longpoll: false 16 | 17 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 18 | 19 | # Serve at "/" the static files from "priv/static" directory. 20 | # 21 | # You should set gzip to true if you are running phx.digest 22 | # when deploying your static files in production. 23 | plug Plug.Static, 24 | at: "/", 25 | from: :pixel_smash, 26 | gzip: false, 27 | only: ~w(css fonts images js favicon.ico robots.txt) 28 | 29 | # Code reloading can be explicitly enabled under the 30 | # :code_reloader configuration of your endpoint. 31 | if code_reloading? do 32 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 33 | plug Phoenix.LiveReloader 34 | plug Phoenix.CodeReloader 35 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :pixel_smash 36 | end 37 | 38 | plug Phoenix.LiveDashboard.RequestLogger, 39 | param_key: "request_logger", 40 | cookie_key: "request_logger" 41 | 42 | plug Plug.RequestId 43 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 44 | 45 | plug Plug.Parsers, 46 | parsers: [:urlencoded, :multipart, :json], 47 | pass: ["*/*"], 48 | json_decoder: Phoenix.json_library() 49 | 50 | plug Plug.MethodOverride 51 | plug Plug.Head 52 | plug Plug.Session, @session_options 53 | plug PixelSmashWeb.Router 54 | end 55 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.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 PixelSmashWeb.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: :pixel_smash 24 | end 25 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/live/gladiator_live.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.GladiatorLive do 2 | use PixelSmashWeb, :live_view 3 | 4 | alias PixelSmash.Gladiators 5 | alias PixelSmashWeb.MenuComponent 6 | 7 | def mount(%{"id" => id} = params, session, socket) do 8 | socket = 9 | socket 10 | |> assign_defaults(params, session) 11 | |> assign(gladiator: Gladiators.get_gladiator(id)) 12 | 13 | {:ok, socket} 14 | end 15 | 16 | def mount(params, session, socket) do 17 | socket = 18 | socket 19 | |> assign_defaults(params, session) 20 | |> assign(gladiators: Gladiators.list_gladiators()) 21 | 22 | {:ok, socket} 23 | 24 | end 25 | 26 | def render(%{gladiator: gladiator} = assigns) do 27 | ~L""" 28 | <%= live_component @socket, MenuComponent, balance: @balance %> 29 | 30 | <%= live_component @socket, PixelSmashWeb.GladiatorCardComponent, gladiator: gladiator %> 31 | """ 32 | end 33 | 34 | def render(%{gladiators: gladiators} = assigns) do 35 | ~L""" 36 | <%= live_component @socket, MenuComponent, balance: @balance %> 37 | 38 | <%= for gladiator <- gladiators do %> 39 | <%= live_component @socket, PixelSmashWeb.GladiatorCardComponent, gladiator: gladiator %> 40 | <% end %> 41 | """ 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/live/page_live.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.PageLive do 2 | @moduledoc """ 3 | This contains the bulk of logic for handling the front end portion of the 4 | PixelSmash Arena. Currently we are using a polling system with a timer to 5 | update the current battle events happening through the scheduler. 6 | """ 7 | use PixelSmashWeb, :live_view 8 | 9 | alias PixelSmash.{ 10 | Battles, 11 | Betting, 12 | Gladiators 13 | } 14 | 15 | alias PixelSmashWeb.{ 16 | FinishedBattleComponent, 17 | InProgressBattleComponent, 18 | MenuComponent, 19 | ScheduledBattleComponent 20 | } 21 | 22 | @tick_rate :timer.seconds(2) 23 | 24 | @impl true 25 | def mount(params, session, socket) do 26 | socket = 27 | socket 28 | |> assign_defaults(params, session) 29 | |> assign(:upcoming_battles, Battles.list_upcoming_battles()) 30 | |> assign(:finished_battles, Battles.list_finished_battles()) 31 | |> assign(:current_battles, Battles.list_current_battles()) 32 | |> assign(:gladiators, Gladiators.list_gladiators_by_elo()) 33 | |> assign(:sort_order, :desc) 34 | 35 | send(self(), :tick) 36 | 37 | {:ok, socket} 38 | end 39 | 40 | @impl true 41 | def handle_event("bet", %{"side" => side, "amount" => amount, "battle" => battle_id}, socket) do 42 | side = 43 | case side do 44 | "left" -> :left 45 | "right" -> :right 46 | end 47 | 48 | battle = Battles.get_battle(battle_id) 49 | 50 | :ok = 51 | Betting.place_bet(battle, {socket.assigns.current_user, side, String.to_integer(amount)}) 52 | 53 | socket = 54 | socket 55 | |> assign_balance() 56 | 57 | {:noreply, socket} 58 | end 59 | 60 | @impl true 61 | def handle_event("show_gladiator", %{"id" => id}, socket) do 62 | {:noreply, push_redirect(socket, to: "/gladiator/#{id}")} 63 | end 64 | 65 | @impl true 66 | def handle_info(:tick, %{assigns: %{sort_order: sort_order}} = socket) do 67 | schedule_next_tick() 68 | 69 | socket = 70 | socket 71 | |> assign_balance() 72 | |> assign(:upcoming_battles, Battles.list_upcoming_battles()) 73 | |> assign(:finished_battles, Battles.list_finished_battles()) 74 | |> assign(:current_battles, Battles.list_current_battles()) 75 | |> assign(:gladiators, Gladiators.list_gladiators_by_elo(sort_order)) 76 | 77 | {:noreply, socket} 78 | end 79 | 80 | defp schedule_next_tick() do 81 | Process.send_after(self(), :tick, @tick_rate) 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/live/page_live.html.leex: -------------------------------------------------------------------------------- 1 | <%= live_component @socket, MenuComponent, balance: @balance %> 2 | 3 | <%= if not Enum.empty?(@current_battles) do %> 4 |

In-Progress Battles

5 |
6 | <%= for battle <- @current_battles do %> 7 | <%= live_component @socket, InProgressBattleComponent, battle: battle %> 8 | <% end %> 9 |
10 | <% end %> 11 | 12 | <%= if not Enum.empty?(@finished_battles) do %> 13 |

Finished Battles

14 |
15 | <%= for battle <- @finished_battles do %> 16 | <%= live_component @socket, FinishedBattleComponent, battle: battle %> 17 | <% end %> 18 |
19 | <% end %> 20 | 21 | <%= if not Enum.empty?(@upcoming_battles) do %> 22 |

Upcoming Battles

23 |
24 | <%= for battle <- @upcoming_battles do %> 25 | <%= live_component @socket, ScheduledBattleComponent, battle: battle, current_user: @current_user, balance: @balance %> 26 | <% end %> 27 |
28 | <% end %> 29 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/live/standings_live.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.StandingsLive do 2 | @moduledoc """ 3 | Handles the display, and updating of the standings table. 4 | The standings are updated by polling the data every 5 seconds, 5 | to display the new standings. 6 | The standings are sorted by the highest ELO value by default, 7 | but can be sorted by the lowest value if the user clicks on the 8 | ELO header in the table 9 | """ 10 | use PixelSmashWeb, :live_view 11 | 12 | alias PixelSmash.Gladiators 13 | alias PixelSmashWeb.{ 14 | MenuComponent, 15 | StandingsComponent} 16 | 17 | @tick_rate :timer.seconds(5) 18 | 19 | @impl true 20 | def mount(params, session, socket) do 21 | socket = 22 | socket 23 | |> assign_defaults(params, session) 24 | |> assign(:gladiators, Gladiators.list_gladiators_by_elo()) 25 | |> assign(:sort_order, :desc) 26 | 27 | send(self(), :tick) 28 | 29 | {:ok, socket} 30 | end 31 | 32 | @impl true 33 | def render(assigns) do 34 | ~L""" 35 | <%= live_component @socket, MenuComponent, balance: @balance %> 36 | 37 | <%= live_component @socket, StandingsComponent, gladiators: @gladiators %> 38 | """ 39 | end 40 | 41 | @impl true 42 | def handle_event("resort_standings", _params, %{assigns: %{sort_order: :desc}} = socket) do 43 | socket = 44 | socket 45 | |> assign(:sort_order, :asc) 46 | |> assign(:gladiators, Gladiators.list_gladiators_by_elo(:asc)) 47 | 48 | {:noreply, socket} 49 | end 50 | 51 | def handle_event("resort_standings", _params, %{assigns: %{sort_order: :asc}} = socket) do 52 | socket = 53 | socket 54 | |> assign(:sort_order, :desc) 55 | |> assign(:gladiators, Gladiators.list_gladiators_by_elo(:desc)) 56 | 57 | {:noreply, socket} 58 | end 59 | 60 | @impl true 61 | def handle_info(:tick, %{assigns: %{sort_order: sort_order}} = socket) do 62 | schedule_next_tick() 63 | 64 | socket = 65 | socket 66 | |> assign(:gladiators, Gladiators.list_gladiators_by_elo(sort_order)) 67 | 68 | {:noreply, socket} 69 | end 70 | 71 | defp schedule_next_tick() do 72 | Process.send_after(self(), :tick, @tick_rate) 73 | end 74 | 75 | end 76 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/live/store_live.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.TheStoreLive do 2 | @moduledoc """ 3 | Handles the display, and updating of the store items. 4 | """ 5 | 6 | use PixelSmashWeb, :live_view 7 | 8 | alias PixelSmashWeb.MenuComponent 9 | 10 | @impl true 11 | def mount(params, session, socket) do 12 | socket = 13 | socket 14 | |> assign_defaults(params, session) 15 | |> assign(:the_store, PixelSmash.TheStore.on_sale()) 16 | 17 | {:ok, socket} 18 | end 19 | 20 | @impl true 21 | def render(assigns) do 22 | ~L""" 23 | <%= live_component @socket, MenuComponent, balance: @balance %> 24 | 25 | <%= if not Enum.empty?(@the_store) do %> 26 |

The Store

27 |
28 | <%= for item <- @the_store do %> 29 | <%= live_component @socket, PixelSmashWeb.ItemComponent, item: item.item, sprite: item.sprite %> 30 | <% end %> 31 |
32 | <% end %> 33 | """ 34 | end 35 | 36 | @impl true 37 | def handle_event(_event, _params, socket) do 38 | {:noreply, socket} 39 | end 40 | 41 | @impl true 42 | def handle_info(_message, socket) do 43 | {:noreply, socket} 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/mount_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.MountHelpers do 2 | import Phoenix.LiveView 3 | 4 | alias PixelSmash.Wallets 5 | 6 | def assign_defaults(socket, _params, session) do 7 | socket 8 | |> assign_current_user(session) 9 | |> assign_balance() 10 | end 11 | 12 | def assign_balance(socket) do 13 | balance = 14 | case socket.assigns.current_user do 15 | nil -> 16 | 0 17 | 18 | user -> 19 | user.id 20 | |> Wallets.get_wallet_id() 21 | |> Wallets.get_balance() 22 | |> Decimal.to_float() 23 | end 24 | 25 | assign(socket, :balance, balance) 26 | end 27 | 28 | defp assign_current_user(socket, session) do 29 | assign_new(socket, :current_user, fn -> 30 | PixelSmash.Accounts.get_user_by_session_token(session["user_token"]) 31 | end) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.Router do 2 | use PixelSmashWeb, :router 3 | 4 | import PixelSmashWeb.UserAuth 5 | 6 | pipeline :browser do 7 | plug :accepts, ["html"] 8 | plug :fetch_session 9 | plug :fetch_live_flash 10 | plug :put_root_layout, {PixelSmashWeb.LayoutView, :root} 11 | plug :protect_from_forgery 12 | plug :put_secure_browser_headers 13 | plug :fetch_current_user 14 | end 15 | 16 | pipeline :api do 17 | plug :accepts, ["json"] 18 | end 19 | 20 | scope "/", PixelSmashWeb do 21 | pipe_through :browser 22 | 23 | live "/", PageLive, :index 24 | live "/gladiator/", GladiatorLive, :index 25 | live "/gladiator/:id", GladiatorLive, :show 26 | live "/standings/", StandingsLive, :index 27 | live "/thestore/", TheStoreLive, :index 28 | end 29 | 30 | # Other scopes may use custom stacks. 31 | # scope "/api", PixelSmashWeb do 32 | # pipe_through :api 33 | # end 34 | 35 | # Enables LiveDashboard only for development 36 | # 37 | # If you want to use the LiveDashboard in production, you should put 38 | # it behind authentication and allow only admins to access it. 39 | # If your application does not have an admins-only section yet, 40 | # you can use Plug.BasicAuth to set up some basic authentication 41 | # as long as you are also using SSL (which you should anyway). 42 | if Mix.env() in [:dev, :test] do 43 | import Phoenix.LiveDashboard.Router 44 | 45 | scope "/" do 46 | pipe_through :browser 47 | live_dashboard "/dashboard", metrics: PixelSmashWeb.Telemetry 48 | end 49 | end 50 | 51 | ## Authentication routes 52 | 53 | scope "/", PixelSmashWeb do 54 | pipe_through [:browser, :redirect_if_user_is_authenticated] 55 | 56 | get "/users/register", UserRegistrationController, :new 57 | post "/users/register", UserRegistrationController, :create 58 | get "/users/log_in", UserSessionController, :new 59 | post "/users/log_in", UserSessionController, :create 60 | get "/users/reset_password", UserResetPasswordController, :new 61 | post "/users/reset_password", UserResetPasswordController, :create 62 | get "/users/reset_password/:token", UserResetPasswordController, :edit 63 | put "/users/reset_password/:token", UserResetPasswordController, :update 64 | end 65 | 66 | scope "/", PixelSmashWeb do 67 | pipe_through [:browser, :require_authenticated_user] 68 | 69 | get "/users/settings", UserSettingsController, :edit 70 | put "/users/settings/update_password", UserSettingsController, :update_password 71 | put "/users/settings/update_email", UserSettingsController, :update_email 72 | get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email 73 | end 74 | 75 | scope "/", PixelSmashWeb do 76 | pipe_through [:browser] 77 | 78 | delete "/users/log_out", UserSessionController, :delete 79 | get "/users/confirm", UserConfirmationController, :new 80 | post "/users/confirm", UserConfirmationController, :create 81 | get "/users/confirm/:token", UserConfirmationController, :confirm 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # Database Metrics 34 | summary("pixel_smash.repo.query.total_time", unit: {:native, :millisecond}), 35 | summary("pixel_smash.repo.query.decode_time", unit: {:native, :millisecond}), 36 | summary("pixel_smash.repo.query.query_time", unit: {:native, :millisecond}), 37 | summary("pixel_smash.repo.query.queue_time", unit: {:native, :millisecond}), 38 | summary("pixel_smash.repo.query.idle_time", unit: {:native, :millisecond}), 39 | 40 | # VM Metrics 41 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 42 | summary("vm.total_run_queue_lengths.total"), 43 | summary("vm.total_run_queue_lengths.cpu"), 44 | summary("vm.total_run_queue_lengths.io") 45 | ] 46 | end 47 | 48 | defp periodic_measurements do 49 | [ 50 | # A module, function and arguments to be invoked periodically. 51 | # This function must call :telemetry.execute/3 and a metric must be added above. 52 | # {PixelSmashWeb, :count_users, []} 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/templates/layout/_user_menu.html.eex: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%= @inner_content %> 5 |
6 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/templates/layout/live.html.leex: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 9 | 10 | <%= @inner_content %> 11 |
12 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/templates/layout/root.html.leex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <%= live_title_tag assigns[:page_title] || "PixelSmash", suffix: " · Spawnfest 2020" %> 9 | "/> 10 | 11 | 12 | 13 |
14 |
15 |

16 | 21 | 24 |

25 |
26 |
27 | 28 |
29 | <%= @inner_content %> 30 |
31 | 32 | <%= if function_exported?(Routes, :live_dashboard_path, 2) do %> 33 |

34 | <%= link "LiveDashboard", to: Routes.live_dashboard_path(@conn, :home) %> 35 |

36 | <% end %> 37 | 38 | 39 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/templates/user_confirmation/new.html.eex: -------------------------------------------------------------------------------- 1 |

Resend confirmation instructions

2 | 3 | <%= form_for :user, Routes.user_confirmation_path(@conn, :create), fn f -> %> 4 | <%= label f, :email %> 5 | <%= email_input f, :email, required: true %> 6 | 7 |
8 | <%= submit "Resend confirmation instructions" %> 9 |
10 | <% end %> 11 | 12 |

13 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | 14 | <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> 15 |

16 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/templates/user_registration/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 | <%= form_for @changeset, Routes.user_registration_path(@conn, :create), [class: "flex flex-col bg-gray-900 rounded-lg max-w-1/4 m-4"], fn f -> %> 3 |
4 | 5 | Register 6 | 7 |
8 | 9 |
10 | <%= if @changeset.action do %> 11 |

Oops, something went wrong! Please check the errors below.

12 | <% end %> 13 | 14 |
15 | <%= label f, :email, class: "block font-bold pr-4" %> 16 | <%= email_input f, :email, required: true, class: "bg-gray-100 appearance-none border-2 border-gray-200 rounded py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 mr-4" %> 17 | <%= error_tag f, :email %> 18 |
19 | 20 |
21 | <%= label f, :password, class: "block font-bold pr-4" %> 22 | <%= password_input f, :password, required: true, class: "bg-gray-100 appearance-none border-2 border-gray-200 rounded py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 mr-4" %> 23 | <%= error_tag f, :password %> 24 |
25 |
26 | 27 |
28 | <%= submit "Register", class: "shadow bg-purple-600 hover:bg-purple-500 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded" %> 29 |
30 | <% end %> 31 |
32 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/templates/user_reset_password/edit.html.eex: -------------------------------------------------------------------------------- 1 |

Reset password

2 | 3 | <%= form_for @changeset, Routes.user_reset_password_path(@conn, :update, @token), fn f -> %> 4 | <%= if @changeset.action do %> 5 |
6 |

Oops, something went wrong! Please check the errors below.

7 |
8 | <% end %> 9 | 10 | <%= label f, :password, "New password" %> 11 | <%= password_input f, :password, required: true %> 12 | <%= error_tag f, :password %> 13 | 14 | <%= label f, :password_confirmation, "Confirm new password" %> 15 | <%= password_input f, :password_confirmation, required: true %> 16 | <%= error_tag f, :password_confirmation %> 17 | 18 |
19 | <%= submit "Reset password" %> 20 |
21 | <% end %> 22 | 23 |

24 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | 25 | <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> 26 |

27 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/templates/user_reset_password/new.html.eex: -------------------------------------------------------------------------------- 1 |

Forgot your password?

2 | 3 | <%= form_for :user, Routes.user_reset_password_path(@conn, :create), fn f -> %> 4 | <%= label f, :email %> 5 | <%= email_input f, :email, required: true %> 6 | 7 |
8 | <%= submit "Send instructions to reset password" %> 9 |
10 | <% end %> 11 | 12 |

13 | <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> | 14 | <%= link "Log in", to: Routes.user_session_path(@conn, :new) %> 15 |

16 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/templates/user_session/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 | <%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user, class: "flex flex-col bg-gray-900 rounded-lg max-w-1/4 m-4"], fn f -> %> 3 |
4 | 5 | Log In 6 | 7 |
8 | 9 |
10 | <%= if @error_message do %> 11 |

<%= @error_message %>%>

12 | <% end %> 13 | 14 |
15 | <%= label f, :email, class: "block font-bold pr-4" %> 16 | <%= email_input f, :email, required: true, class: "bg-gray-100 appearance-none border-2 border-gray-200 rounded py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 mr-4" %> 17 | <%= error_tag f, :email %> 18 |
19 | 20 |
21 | <%= label f, :password, class: "block font-bold pr-4" %> 22 | <%= password_input f, :password, required: true, class: "bg-gray-100 appearance-none border-2 border-gray-200 rounded py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 mr-4" %> 23 | <%= error_tag f, :password %> 24 |
25 | 26 |
27 | <%= label f, :remember_me, "Remember me", class: "inline font-bold pr-4" %> 28 | <%= checkbox f, :remember_me, class: "align-middle" %> 29 |
30 |
31 | 32 |
33 | <%= submit "Log in", class: "shadow bg-purple-600 hover:bg-purple-500 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded" %> 34 |
35 | <% end %> 36 |
37 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/templates/user_settings/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 | <%= form_for @email_changeset, Routes.user_settings_path(@conn, :update_email), [class: "flex flex-col bg-gray-900 rounded-lg max-w-1/4 m-4"], fn f -> %> 3 |
4 | 5 | Change Email 6 | 7 |
8 | 9 |
10 | <%= if @email_changeset.action do %> 11 |

Oops, something went wrong! Please check the errors below.

12 | <% end %> 13 | 14 |
15 | <%= label f, :email, class: "block font-bold pr-4" %> 16 | <%= email_input f, :email, required: true, class: "bg-gray-100 appearance-none border-2 border-gray-200 rounded py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 mr-4" %> 17 | <%= error_tag f, :email %> 18 |
19 | 20 |
21 | <%= label f, :current_password, for: "current_password_for_email", class: "block font-bold pr-4" %> 22 | <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email", class: "bg-gray-100 appearance-none border-2 border-gray-200 rounded py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 mr-4" %> 23 | <%= error_tag f, :current_password %> 24 |
25 |
26 | 27 |
28 | <%= submit "Change email", class: "shadow bg-purple-600 hover:bg-purple-500 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded" %> 29 |
30 | <% end %> 31 | 32 | <%= form_for @password_changeset, Routes.user_settings_path(@conn, :update_password), [class: "flex flex-col bg-gray-900 rounded-lg max-w-1/4 m-4"], fn f -> %> 33 |
34 | 35 | Change Password 36 | 37 |
38 | 39 |
40 | <%= if @password_changeset.action do %> 41 |

Oops, something went wrong! Please check the errors below.

42 | <% end %> 43 | 44 |
45 | <%= label f, :password, "New password", class: "block font-bold pr-4" %> 46 | <%= password_input f, :password, required: true, class: "bg-gray-100 appearance-none border-2 border-gray-200 rounded py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 mr-4" %> 47 | <%= error_tag f, :password %> 48 |
49 | 50 |
51 | <%= label f, :password_confirmation, "Confirm new password", class: "block font-bold pr-4" %> 52 | <%= password_input f, :password_confirmation, required: true, class: "bg-gray-100 appearance-none border-2 border-gray-200 rounded py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 mr-4" %> 53 | <%= error_tag f, :password_confirmation %> 54 |
55 | 56 |
57 | <%= label f, :current_password, for: "current_password_for_password", class: "block font-bold pr-4" %> 58 | <%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password", class: "bg-gray-100 appearance-none border-2 border-gray-200 rounded py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500 mr-4" %> 59 | <%= error_tag f, :current_password %> 60 |
61 |
62 | 63 |
64 | <%= submit "Change password", class: "shadow bg-purple-600 hover:bg-purple-500 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded" %> 65 |
66 | <% end %> 67 |
68 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), 14 | class: "invalid-feedback block", 15 | phx_feedback_for: input_id(form, field) 16 | ) 17 | end) 18 | end 19 | 20 | @doc """ 21 | Translates an error message using gettext. 22 | """ 23 | def translate_error({msg, opts}) do 24 | # When using gettext, we typically pass the strings we want 25 | # to translate as a static argument: 26 | # 27 | # # Translate "is invalid" in the "errors" domain 28 | # dgettext("errors", "is invalid") 29 | # 30 | # # Translate the number of files with plural rules 31 | # dngettext("errors", "1 file", "%{count} files", count) 32 | # 33 | # Because the error messages we show in our forms and APIs 34 | # are defined inside Ecto, we need to translate them dynamically. 35 | # This requires us to call the Gettext module passing our gettext 36 | # backend as first argument. 37 | # 38 | # Note we use the "errors" domain, which means translations 39 | # should be written to the errors.po file. The :count option is 40 | # set by Ecto and indicates we should also apply plural rules. 41 | if count = opts[:count] do 42 | Gettext.dngettext(PixelSmashWeb.Gettext, "errors", msg, msg, count, opts) 43 | else 44 | Gettext.dgettext(PixelSmashWeb.Gettext, "errors", msg, opts) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.ErrorView do 2 | use PixelSmashWeb, :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 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.LayoutView do 2 | use PixelSmashWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/views/user_confirmation_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserConfirmationView do 2 | use PixelSmashWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/views/user_registration_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserRegistrationView do 2 | use PixelSmashWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/views/user_reset_password_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserResetPasswordView do 2 | use PixelSmashWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/views/user_session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserSessionView do 2 | use PixelSmashWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/pixel_smash_web/views/user_settings_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserSettingsView do 2 | use PixelSmashWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :pixel_smash, 7 | version: "0.5.0", 8 | elixir: "~> 1.7", 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 | test_coverage: [tool: ExCoveralls], 15 | preferred_cli_env: [ 16 | coveralls: :test, 17 | "coveralls.detail": :test, 18 | "coveralls.post": :test, 19 | "coveralls.html": :test 20 | ] 21 | ] 22 | end 23 | 24 | # Configuration for the OTP application. 25 | # 26 | # Type `mix help compile.app` for more information. 27 | def application do 28 | [ 29 | mod: {PixelSmash.Application, []}, 30 | extra_applications: [:logger, :runtime_tools, :os_mon] 31 | ] 32 | end 33 | 34 | # Specifies which paths to compile per environment. 35 | defp elixirc_paths(:test), do: ["lib", "test/support"] 36 | defp elixirc_paths(_), do: ["lib"] 37 | 38 | # Specifies your project dependencies. 39 | # 40 | # Type `mix help deps` for examples and options. 41 | defp deps do 42 | [ 43 | {:algae, "~> 1.2"}, 44 | {:bcrypt_elixir, "~> 2.0"}, 45 | {:credo, "~> 1.4", only: [:dev, :test], runtime: false}, 46 | {:excoveralls, "~> 0.10", only: :test}, 47 | {:phoenix, "~> 1.5.4"}, 48 | {:phoenix_ecto, "~> 4.1"}, 49 | {:phx_gen_auth, "~> 0.5", only: [:dev], runtime: false}, 50 | {:ecto_sql, "~> 3.4"}, 51 | {:postgrex, ">= 0.0.0"}, 52 | {:phoenix_live_view, "~> 0.13.0"}, 53 | {:floki, ">= 0.0.0", only: :test}, 54 | {:phoenix_html, "~> 2.11"}, 55 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 56 | {:phoenix_live_dashboard, "~> 0.2"}, 57 | {:telemetry_metrics, "~> 0.4"}, 58 | {:telemetry_poller, "~> 0.4"}, 59 | {:gettext, "~> 0.11"}, 60 | {:jason, "~> 1.0"}, 61 | {:plug_cowboy, "~> 2.0"}, 62 | {:faker, "~> 0.15"}, 63 | {:norm, "~> 0.12.0"}, 64 | {:decimal, "~> 1.9"}, 65 | {:number, "~> 1.0.1"} 66 | ] 67 | end 68 | 69 | # Aliases are shortcuts or tasks specific to the current project. 70 | # For example, to install project dependencies and perform other setup tasks, run: 71 | # 72 | # $ mix setup 73 | # 74 | # See the documentation for `Mix` for more info on aliases. 75 | defp aliases do 76 | [ 77 | setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"], 78 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 79 | "ecto.reset": ["ecto.drop", "ecto.setup"], 80 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] 81 | ] 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /phoenix_static_buildpack.config: -------------------------------------------------------------------------------- 1 | node_version=12.16.3 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200912002318_create_users_auth_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Repo.Migrations.CreateUsersAuthTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute "CREATE EXTENSION IF NOT EXISTS citext", "" 6 | 7 | create table(:users) do 8 | add :email, :citext, null: false 9 | add :hashed_password, :string, null: false 10 | add :confirmed_at, :naive_datetime 11 | timestamps() 12 | end 13 | 14 | create unique_index(:users, [:email]) 15 | 16 | create table(:users_tokens) do 17 | add :user_id, references(:users, on_delete: :delete_all), null: false 18 | add :token, :binary, null: false 19 | add :context, :string, null: false 20 | add :sent_to, :string 21 | timestamps(updated_at: false) 22 | end 23 | 24 | create index(:users_tokens, [:user_id]) 25 | create unique_index(:users_tokens, [:context, :token]) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200912112204_add_gladiators_table.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.Repo.Migrations.AddGladiatorsTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:gladiators) do 6 | add :name, :string, null: false 7 | add :sprite, :map, null: false 8 | timestamps() 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # PixelSmash.Repo.insert!(%PixelSmash.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /test/pixel_smash/doctests_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.DoctestsTest do 2 | use PixelSmash.DataCase 3 | 4 | doctest PixelSmash.Wallets 5 | doctest PixelSmash.Wallets.Vault 6 | end 7 | -------------------------------------------------------------------------------- /test/pixel_smash_web/controllers/user_auth_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserAuthTest do 2 | use PixelSmashWeb.ConnCase, async: true 3 | 4 | alias PixelSmash.Accounts 5 | alias PixelSmashWeb.UserAuth 6 | import PixelSmash.AccountsFixtures 7 | 8 | setup %{conn: conn} do 9 | conn = 10 | conn 11 | |> Map.replace!(:secret_key_base, PixelSmashWeb.Endpoint.config(:secret_key_base)) 12 | |> init_test_session(%{}) 13 | 14 | %{user: user_fixture(), conn: conn} 15 | end 16 | 17 | describe "log_in_user/3" do 18 | test "stores the user token in the session", %{conn: conn, user: user} do 19 | conn = UserAuth.log_in_user(conn, user) 20 | assert token = get_session(conn, :user_token) 21 | assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" 22 | assert redirected_to(conn) == "/" 23 | assert Accounts.get_user_by_session_token(token) 24 | end 25 | 26 | test "clears everything previously stored in the session", %{conn: conn, user: user} do 27 | conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) 28 | refute get_session(conn, :to_be_removed) 29 | end 30 | 31 | test "redirects to the configured path", %{conn: conn, user: user} do 32 | conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) 33 | assert redirected_to(conn) == "/hello" 34 | end 35 | 36 | test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do 37 | conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) 38 | assert get_session(conn, :user_token) == conn.cookies["user_remember_me"] 39 | 40 | assert %{value: signed_token, max_age: max_age} = conn.resp_cookies["user_remember_me"] 41 | assert signed_token != get_session(conn, :user_token) 42 | assert max_age == 5_184_000 43 | end 44 | end 45 | 46 | describe "logout_user/1" do 47 | test "erases session and cookies", %{conn: conn, user: user} do 48 | user_token = Accounts.generate_user_session_token(user) 49 | 50 | conn = 51 | conn 52 | |> put_session(:user_token, user_token) 53 | |> put_req_cookie("user_remember_me", user_token) 54 | |> fetch_cookies() 55 | |> UserAuth.log_out_user() 56 | 57 | refute get_session(conn, :user_token) 58 | refute conn.cookies["user_remember_me"] 59 | assert %{max_age: 0} = conn.resp_cookies["user_remember_me"] 60 | assert redirected_to(conn) == "/" 61 | refute Accounts.get_user_by_session_token(user_token) 62 | end 63 | 64 | test "broadcasts to the given live_socket_id", %{conn: conn} do 65 | live_socket_id = "users_sessions:abcdef-token" 66 | PixelSmashWeb.Endpoint.subscribe(live_socket_id) 67 | 68 | conn 69 | |> put_session(:live_socket_id, live_socket_id) 70 | |> UserAuth.log_out_user() 71 | 72 | assert_receive %Phoenix.Socket.Broadcast{ 73 | event: "disconnect", 74 | topic: "users_sessions:abcdef-token" 75 | } 76 | end 77 | 78 | test "works even if user is already logged out", %{conn: conn} do 79 | conn = conn |> fetch_cookies() |> UserAuth.log_out_user() 80 | refute get_session(conn, :user_token) 81 | assert %{max_age: 0} = conn.resp_cookies["user_remember_me"] 82 | assert redirected_to(conn) == "/" 83 | end 84 | end 85 | 86 | describe "fetch_current_user/2" do 87 | test "authenticates user from session", %{conn: conn, user: user} do 88 | user_token = Accounts.generate_user_session_token(user) 89 | conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) 90 | assert conn.assigns.current_user.id == user.id 91 | end 92 | 93 | test "authenticates user from cookies", %{conn: conn, user: user} do 94 | logged_in_conn = 95 | conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) 96 | 97 | user_token = logged_in_conn.cookies["user_remember_me"] 98 | %{value: signed_token} = logged_in_conn.resp_cookies["user_remember_me"] 99 | 100 | conn = 101 | conn 102 | |> put_req_cookie("user_remember_me", signed_token) 103 | |> UserAuth.fetch_current_user([]) 104 | 105 | assert get_session(conn, :user_token) == user_token 106 | assert conn.assigns.current_user.id == user.id 107 | end 108 | 109 | test "does not authenticate if data is missing", %{conn: conn, user: user} do 110 | _ = Accounts.generate_user_session_token(user) 111 | conn = UserAuth.fetch_current_user(conn, []) 112 | refute get_session(conn, :user_token) 113 | refute conn.assigns.current_user 114 | end 115 | end 116 | 117 | describe "redirect_if_user_is_authenticated/2" do 118 | test "redirects if user is authenticated", %{conn: conn, user: user} do 119 | conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) 120 | assert conn.halted 121 | assert redirected_to(conn) == "/" 122 | end 123 | 124 | test "does not redirect if user is not authenticated", %{conn: conn} do 125 | conn = UserAuth.redirect_if_user_is_authenticated(conn, []) 126 | refute conn.halted 127 | refute conn.status 128 | end 129 | end 130 | 131 | describe "require_authenticated_user/2" do 132 | test "redirects if user is not authenticated", %{conn: conn} do 133 | conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) 134 | assert conn.halted 135 | assert redirected_to(conn) == Routes.user_session_path(conn, :new) 136 | assert get_flash(conn, :error) == "You must log in to access this page." 137 | end 138 | 139 | test "stores the path to redirect to on GET", %{conn: conn} do 140 | halted_conn = 141 | %{conn | request_path: "/foo", query_string: ""} 142 | |> fetch_flash() 143 | |> UserAuth.require_authenticated_user([]) 144 | 145 | assert halted_conn.halted 146 | assert get_session(halted_conn, :user_return_to) == "/foo" 147 | 148 | halted_conn = 149 | %{conn | request_path: "/foo", query_string: "bar=baz"} 150 | |> fetch_flash() 151 | |> UserAuth.require_authenticated_user([]) 152 | 153 | assert halted_conn.halted 154 | assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" 155 | 156 | halted_conn = 157 | %{conn | request_path: "/foo?bar", method: "POST"} 158 | |> fetch_flash() 159 | |> UserAuth.require_authenticated_user([]) 160 | 161 | assert halted_conn.halted 162 | refute get_session(halted_conn, :user_return_to) 163 | end 164 | 165 | test "does not redirect if user is authenticated", %{conn: conn, user: user} do 166 | conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) 167 | refute conn.halted 168 | refute conn.status 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /test/pixel_smash_web/controllers/user_confirmation_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserConfirmationControllerTest do 2 | use PixelSmashWeb.ConnCase, async: true 3 | 4 | alias PixelSmash.Accounts 5 | alias PixelSmash.Repo 6 | import PixelSmash.AccountsFixtures 7 | 8 | setup do 9 | %{user: user_fixture()} 10 | end 11 | 12 | describe "GET /users/confirm" do 13 | test "renders the confirmation page", %{conn: conn} do 14 | conn = get(conn, Routes.user_confirmation_path(conn, :new)) 15 | response = html_response(conn, 200) 16 | assert response =~ "

Resend confirmation instructions

" 17 | end 18 | end 19 | 20 | describe "POST /users/confirm" do 21 | @tag :capture_log 22 | test "sends a new confirmation token", %{conn: conn, user: user} do 23 | conn = 24 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 25 | "user" => %{"email" => user.email} 26 | }) 27 | 28 | assert redirected_to(conn) == "/" 29 | assert get_flash(conn, :info) =~ "If your email is in our system" 30 | assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" 31 | end 32 | 33 | test "does not send confirmation token if account is confirmed", %{conn: conn, user: user} do 34 | Repo.update!(Accounts.User.confirm_changeset(user)) 35 | 36 | conn = 37 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 38 | "user" => %{"email" => user.email} 39 | }) 40 | 41 | assert redirected_to(conn) == "/" 42 | assert get_flash(conn, :info) =~ "If your email is in our system" 43 | refute Repo.get_by(Accounts.UserToken, user_id: user.id) 44 | end 45 | 46 | test "does not send confirmation token if email is invalid", %{conn: conn} do 47 | conn = 48 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 49 | "user" => %{"email" => "unknown@example.com"} 50 | }) 51 | 52 | assert redirected_to(conn) == "/" 53 | assert get_flash(conn, :info) =~ "If your email is in our system" 54 | assert Repo.all(Accounts.UserToken) == [] 55 | end 56 | end 57 | 58 | describe "GET /users/confirm/:token" do 59 | test "confirms the given token once", %{conn: conn, user: user} do 60 | token = 61 | extract_user_token(fn url -> 62 | Accounts.deliver_user_confirmation_instructions(user, url) 63 | end) 64 | 65 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token)) 66 | assert redirected_to(conn) == "/" 67 | assert get_flash(conn, :info) =~ "Account confirmed successfully" 68 | assert Accounts.get_user!(user.id).confirmed_at 69 | refute get_session(conn, :user_token) 70 | assert Repo.all(Accounts.UserToken) == [] 71 | 72 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token)) 73 | assert redirected_to(conn) == "/" 74 | assert get_flash(conn, :error) =~ "Confirmation link is invalid or it has expired" 75 | end 76 | 77 | test "does not confirm email with invalid token", %{conn: conn, user: user} do 78 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, "oops")) 79 | assert redirected_to(conn) == "/" 80 | assert get_flash(conn, :error) =~ "Confirmation link is invalid or it has expired" 81 | refute Accounts.get_user!(user.id).confirmed_at 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /test/pixel_smash_web/controllers/user_registration_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserRegistrationControllerTest do 2 | use PixelSmashWeb.ConnCase, async: true 3 | 4 | import PixelSmash.AccountsFixtures 5 | 6 | describe "GET /users/register" do 7 | test "renders registration page", %{conn: conn} do 8 | conn = get(conn, Routes.user_registration_path(conn, :new)) 9 | response = html_response(conn, 200) 10 | assert response =~ "Register" 11 | end 12 | 13 | test "redirects if already logged in", %{conn: conn} do 14 | conn = conn |> log_in_user(user_fixture()) |> get(Routes.user_registration_path(conn, :new)) 15 | assert redirected_to(conn) == "/" 16 | end 17 | end 18 | 19 | describe "POST /users/register" do 20 | @tag :capture_log 21 | test "creates account and logs the user in", %{conn: conn} do 22 | email = unique_user_email() 23 | 24 | conn = 25 | post(conn, Routes.user_registration_path(conn, :create), %{ 26 | "user" => %{"email" => email, "password" => valid_user_password()} 27 | }) 28 | 29 | assert get_session(conn, :user_token) 30 | assert redirected_to(conn) =~ "/" 31 | 32 | # Now do a logged in request and assert on the menu 33 | conn = get(conn, "/") 34 | response = html_response(conn, 200) 35 | assert response =~ email 36 | end 37 | 38 | test "render errors for invalid data", %{conn: conn} do 39 | conn = 40 | post(conn, Routes.user_registration_path(conn, :create), %{ 41 | "user" => %{"email" => "with spaces", "password" => "too short"} 42 | }) 43 | 44 | response = html_response(conn, 200) 45 | assert response =~ "Register" 46 | assert response =~ "must have the @ sign and no spaces" 47 | assert response =~ "should be at least 12 character" 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/pixel_smash_web/controllers/user_reset_password_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserResetPasswordControllerTest do 2 | use PixelSmashWeb.ConnCase, async: true 3 | 4 | alias PixelSmash.Accounts 5 | alias PixelSmash.Repo 6 | import PixelSmash.AccountsFixtures 7 | 8 | setup do 9 | %{user: user_fixture()} 10 | end 11 | 12 | describe "GET /users/reset_password" do 13 | test "renders the reset password page", %{conn: conn} do 14 | conn = get(conn, Routes.user_reset_password_path(conn, :new)) 15 | response = html_response(conn, 200) 16 | assert response =~ "

Forgot your password?

" 17 | end 18 | end 19 | 20 | describe "POST /users/reset_password" do 21 | @tag :capture_log 22 | test "sends a new reset password token", %{conn: conn, user: user} do 23 | conn = 24 | post(conn, Routes.user_reset_password_path(conn, :create), %{ 25 | "user" => %{"email" => user.email} 26 | }) 27 | 28 | assert redirected_to(conn) == "/" 29 | assert get_flash(conn, :info) =~ "If your email is in our system" 30 | assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "reset_password" 31 | end 32 | 33 | test "does not send reset password token if email is invalid", %{conn: conn} do 34 | conn = 35 | post(conn, Routes.user_reset_password_path(conn, :create), %{ 36 | "user" => %{"email" => "unknown@example.com"} 37 | }) 38 | 39 | assert redirected_to(conn) == "/" 40 | assert get_flash(conn, :info) =~ "If your email is in our system" 41 | assert Repo.all(Accounts.UserToken) == [] 42 | end 43 | end 44 | 45 | describe "GET /users/reset_password/:token" do 46 | setup %{user: user} do 47 | token = 48 | extract_user_token(fn url -> 49 | Accounts.deliver_user_reset_password_instructions(user, url) 50 | end) 51 | 52 | %{token: token} 53 | end 54 | 55 | test "renders reset password", %{conn: conn, token: token} do 56 | conn = get(conn, Routes.user_reset_password_path(conn, :edit, token)) 57 | assert html_response(conn, 200) =~ "

Reset password

" 58 | end 59 | 60 | test "does not render reset password with invalid token", %{conn: conn} do 61 | conn = get(conn, Routes.user_reset_password_path(conn, :edit, "oops")) 62 | assert redirected_to(conn) == "/" 63 | assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" 64 | end 65 | end 66 | 67 | describe "PUT /users/reset_password/:token" do 68 | setup %{user: user} do 69 | token = 70 | extract_user_token(fn url -> 71 | Accounts.deliver_user_reset_password_instructions(user, url) 72 | end) 73 | 74 | %{token: token} 75 | end 76 | 77 | test "resets password once", %{conn: conn, user: user, token: token} do 78 | conn = 79 | put(conn, Routes.user_reset_password_path(conn, :update, token), %{ 80 | "user" => %{ 81 | "password" => "new valid password", 82 | "password_confirmation" => "new valid password" 83 | } 84 | }) 85 | 86 | assert redirected_to(conn) == Routes.user_session_path(conn, :new) 87 | refute get_session(conn, :user_token) 88 | assert get_flash(conn, :info) =~ "Password reset successfully" 89 | assert Accounts.get_user_by_email_and_password(user.email, "new valid password") 90 | end 91 | 92 | test "does not reset password on invalid data", %{conn: conn, token: token} do 93 | conn = 94 | put(conn, Routes.user_reset_password_path(conn, :update, token), %{ 95 | "user" => %{ 96 | "password" => "too short", 97 | "password_confirmation" => "does not match" 98 | } 99 | }) 100 | 101 | response = html_response(conn, 200) 102 | assert response =~ "

Reset password

" 103 | assert response =~ "should be at least 12 character(s)" 104 | assert response =~ "does not match password" 105 | end 106 | 107 | test "does not reset password with invalid token", %{conn: conn} do 108 | conn = put(conn, Routes.user_reset_password_path(conn, :update, "oops")) 109 | assert redirected_to(conn) == "/" 110 | assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/pixel_smash_web/controllers/user_session_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserSessionControllerTest do 2 | use PixelSmashWeb.ConnCase, async: true 3 | 4 | import PixelSmash.AccountsFixtures 5 | 6 | setup do 7 | %{user: user_fixture()} 8 | end 9 | 10 | describe "GET /users/log_in" do 11 | test "renders log in page", %{conn: conn} do 12 | conn = get(conn, Routes.user_session_path(conn, :new)) 13 | response = html_response(conn, 200) 14 | assert response =~ "Log In" 15 | end 16 | 17 | test "redirects if already logged in", %{conn: conn, user: user} do 18 | conn = conn |> log_in_user(user) |> get(Routes.user_session_path(conn, :new)) 19 | assert redirected_to(conn) == "/" 20 | end 21 | end 22 | 23 | describe "POST /users/log_in" do 24 | test "logs the user in", %{conn: conn, user: user} do 25 | conn = 26 | post(conn, Routes.user_session_path(conn, :create), %{ 27 | "user" => %{"email" => user.email, "password" => valid_user_password()} 28 | }) 29 | 30 | assert get_session(conn, :user_token) 31 | assert redirected_to(conn) =~ "/" 32 | 33 | # Now do a logged in request and assert on the menu 34 | conn = get(conn, "/") 35 | response = html_response(conn, 200) 36 | assert response =~ user.email 37 | end 38 | 39 | test "logs the user in with remember me", %{conn: conn, user: user} do 40 | conn = 41 | post(conn, Routes.user_session_path(conn, :create), %{ 42 | "user" => %{ 43 | "email" => user.email, 44 | "password" => valid_user_password(), 45 | "remember_me" => "true" 46 | } 47 | }) 48 | 49 | assert conn.resp_cookies["user_remember_me"] 50 | assert redirected_to(conn) =~ "/" 51 | end 52 | 53 | test "emits error message with invalid credentials", %{conn: conn, user: user} do 54 | conn = 55 | post(conn, Routes.user_session_path(conn, :create), %{ 56 | "user" => %{"email" => user.email, "password" => "invalid_password"} 57 | }) 58 | 59 | response = html_response(conn, 200) 60 | assert response =~ "Log In" 61 | assert response =~ "Invalid email or password" 62 | end 63 | end 64 | 65 | describe "DELETE /users/log_out" do 66 | test "logs the user out", %{conn: conn, user: user} do 67 | conn = conn |> log_in_user(user) |> delete(Routes.user_session_path(conn, :delete)) 68 | assert redirected_to(conn) == "/" 69 | refute get_session(conn, :user_token) 70 | assert get_flash(conn, :info) =~ "Logged out successfully" 71 | end 72 | 73 | test "succeeds even if the user is not logged in", %{conn: conn} do 74 | conn = delete(conn, Routes.user_session_path(conn, :delete)) 75 | assert redirected_to(conn) == "/" 76 | refute get_session(conn, :user_token) 77 | assert get_flash(conn, :info) =~ "Logged out successfully" 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/pixel_smash_web/controllers/user_settings_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.UserSettingsControllerTest do 2 | use PixelSmashWeb.ConnCase, async: true 3 | 4 | alias PixelSmash.Accounts 5 | import PixelSmash.AccountsFixtures 6 | 7 | setup :register_and_log_in_user 8 | 9 | describe "GET /users/settings" do 10 | test "renders settings page", %{conn: conn} do 11 | conn = get(conn, Routes.user_settings_path(conn, :edit)) 12 | response = html_response(conn, 200) 13 | assert response =~ "Change Password" 14 | end 15 | 16 | test "redirects if user is not logged in" do 17 | conn = build_conn() 18 | conn = get(conn, Routes.user_settings_path(conn, :edit)) 19 | assert redirected_to(conn) == Routes.user_session_path(conn, :new) 20 | end 21 | end 22 | 23 | describe "PUT /users/settings/update_password" do 24 | test "updates the user password and resets tokens", %{conn: conn, user: user} do 25 | new_password_conn = 26 | put(conn, Routes.user_settings_path(conn, :update_password), %{ 27 | "current_password" => valid_user_password(), 28 | "user" => %{ 29 | "password" => "new valid password", 30 | "password_confirmation" => "new valid password" 31 | } 32 | }) 33 | 34 | assert redirected_to(new_password_conn) == Routes.user_settings_path(conn, :edit) 35 | assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) 36 | assert get_flash(new_password_conn, :info) =~ "Password updated successfully" 37 | assert Accounts.get_user_by_email_and_password(user.email, "new valid password") 38 | end 39 | 40 | test "does not update password on invalid data", %{conn: conn} do 41 | old_password_conn = 42 | put(conn, Routes.user_settings_path(conn, :update_password), %{ 43 | "current_password" => "invalid", 44 | "user" => %{ 45 | "password" => "too short", 46 | "password_confirmation" => "does not match" 47 | } 48 | }) 49 | 50 | response = html_response(old_password_conn, 200) 51 | assert response =~ "Change Password" 52 | assert response =~ "should be at least 12 character(s)" 53 | assert response =~ "does not match password" 54 | assert response =~ "is not valid" 55 | 56 | assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token) 57 | end 58 | end 59 | 60 | describe "PUT /users/settings/update_email" do 61 | @tag :capture_log 62 | test "updates the user email", %{conn: conn, user: user} do 63 | conn = 64 | put(conn, Routes.user_settings_path(conn, :update_email), %{ 65 | "current_password" => valid_user_password(), 66 | "user" => %{"email" => unique_user_email()} 67 | }) 68 | 69 | assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) 70 | assert get_flash(conn, :info) =~ "A link to confirm your email" 71 | assert Accounts.get_user_by_email(user.email) 72 | end 73 | 74 | test "does not update email on invalid data", %{conn: conn} do 75 | conn = 76 | put(conn, Routes.user_settings_path(conn, :update_email), %{ 77 | "current_password" => "invalid", 78 | "user" => %{"email" => "with spaces"} 79 | }) 80 | 81 | response = html_response(conn, 200) 82 | assert response =~ "Change Password" 83 | assert response =~ "must have the @ sign and no spaces" 84 | assert response =~ "is not valid" 85 | end 86 | end 87 | 88 | describe "GET /users/settings/confirm_email/:token" do 89 | setup %{user: user} do 90 | email = unique_user_email() 91 | 92 | token = 93 | extract_user_token(fn url -> 94 | Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url) 95 | end) 96 | 97 | %{token: token, email: email} 98 | end 99 | 100 | test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do 101 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 102 | assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) 103 | assert get_flash(conn, :info) =~ "Email changed successfully" 104 | refute Accounts.get_user_by_email(user.email) 105 | assert Accounts.get_user_by_email(email) 106 | 107 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 108 | assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) 109 | assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" 110 | end 111 | 112 | test "does not update email with invalid token", %{conn: conn, user: user} do 113 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops")) 114 | assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) 115 | assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" 116 | assert Accounts.get_user_by_email(user.email) 117 | end 118 | 119 | test "redirects if user is not logged in", %{token: token} do 120 | conn = build_conn() 121 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 122 | assert redirected_to(conn) == Routes.user_session_path(conn, :new) 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /test/pixel_smash_web/live/page_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.PageLiveTest do 2 | use PixelSmashWeb.ConnCase 3 | 4 | import Phoenix.LiveViewTest 5 | 6 | test "disconnected and connected render", %{conn: conn} do 7 | {:ok, _page_live, disconnected_html} = live(conn, "/") 8 | assert disconnected_html =~ "Upcoming Battles" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/pixel_smash_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.ErrorViewTest do 2 | use PixelSmashWeb.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(PixelSmashWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(PixelSmashWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/pixel_smash_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.LayoutViewTest do 2 | use PixelSmashWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use PixelSmashWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import PixelSmashWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint PixelSmashWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PixelSmash.Repo) 33 | 34 | unless tags[:async] do 35 | Ecto.Adapters.SQL.Sandbox.mode(PixelSmash.Repo, {:shared, self()}) 36 | end 37 | 38 | :ok 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmashWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use PixelSmashWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import PixelSmashWeb.ConnCase 26 | 27 | alias PixelSmashWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint PixelSmashWeb.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PixelSmash.Repo) 36 | 37 | unless tags[:async] do 38 | Ecto.Adapters.SQL.Sandbox.mode(PixelSmash.Repo, {:shared, self()}) 39 | end 40 | 41 | {:ok, conn: Phoenix.ConnTest.build_conn()} 42 | end 43 | 44 | @doc """ 45 | Setup helper that registers and logs in users. 46 | 47 | setup :register_and_log_in_user 48 | 49 | It stores an updated connection and a registered user in the 50 | test context. 51 | """ 52 | def register_and_log_in_user(%{conn: conn}) do 53 | user = PixelSmash.AccountsFixtures.user_fixture() 54 | %{conn: log_in_user(conn, user), user: user} 55 | end 56 | 57 | @doc """ 58 | Logs the given `user` into the `conn`. 59 | 60 | It returns an updated `conn`. 61 | """ 62 | def log_in_user(conn, user) do 63 | token = PixelSmash.Accounts.generate_user_session_token(user) 64 | 65 | conn 66 | |> Phoenix.ConnTest.init_test_session(%{}) 67 | |> Plug.Conn.put_session(:user_token, token) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use PixelSmash.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias PixelSmash.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import PixelSmash.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PixelSmash.Repo) 32 | 33 | unless tags[:async] do 34 | Ecto.Adapters.SQL.Sandbox.mode(PixelSmash.Repo, {:shared, self()}) 35 | end 36 | 37 | :ok 38 | end 39 | 40 | @doc """ 41 | A helper that transforms changeset errors into a map of messages. 42 | 43 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 44 | assert "password is too short" in errors_on(changeset).password 45 | assert %{password: ["password is too short"]} = errors_on(changeset) 46 | 47 | """ 48 | def errors_on(changeset) do 49 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 50 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 51 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 52 | end) 53 | end) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/support/fixtures/accounts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule PixelSmash.AccountsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `PixelSmash.Accounts` context. 5 | """ 6 | 7 | def unique_user_email, do: "user#{System.unique_integer()}@example.com" 8 | def valid_user_password, do: "hello world!" 9 | 10 | def user_fixture(attrs \\ %{}) do 11 | {:ok, user} = 12 | attrs 13 | |> Enum.into(%{ 14 | email: unique_user_email(), 15 | password: valid_user_password() 16 | }) 17 | |> PixelSmash.Accounts.register_user() 18 | 19 | user 20 | end 21 | 22 | def extract_user_token(fun) do 23 | {:ok, captured} = fun.(&"[TOKEN]#{&1}[TOKEN]") 24 | [_, token, _] = String.split(captured.body, "[TOKEN]") 25 | token 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(PixelSmash.Repo, :manual) 3 | --------------------------------------------------------------------------------