├── web ├── templates │ ├── page │ │ ├── error.html.eex │ │ ├── index.html.eex │ │ ├── not_found.html.eex │ │ ├── invitation.html.eex │ │ ├── account.html.eex │ │ ├── client.html.eex │ │ ├── friend.html.eex │ │ └── auth.html.eex │ └── layout │ │ └── app.html.eex ├── views │ ├── layout_view.ex │ ├── page_view.ex │ └── error_view.ex ├── models │ ├── invitation.ex │ ├── account.ex │ ├── friend.ex │ ├── character.ex │ └── queries.ex ├── controllers │ ├── page_controller.ex │ ├── docu_controller.ex │ ├── auth_controller.ex │ ├── char_controller.ex │ ├── account_controller.ex │ ├── token_controller.ex │ └── friends_controller.ex ├── channels │ ├── vitals_channel.ex │ ├── movement_channel.ex │ ├── social_channel.ex │ ├── socket.ex │ ├── group_channel.ex │ ├── skill_channel.ex │ └── entity_channel.ex ├── router.ex └── web.ex ├── .gitignore ├── elixir_buildpack.config ├── lib ├── entice │ └── web │ │ ├── repo.ex │ │ ├── token.ex │ │ ├── endpoint.ex │ │ ├── client_server.ex │ │ └── client.ex └── entice_web.ex ├── priv ├── static │ ├── images │ │ ├── entice.png │ │ └── favicon.png │ ├── robots.txt │ ├── css │ │ └── main.css │ └── js │ │ └── phoenix.js └── repo │ ├── migrations │ ├── 20151004132846_init_accounts.exs │ ├── 20151004133858_init_friends.exs │ ├── 20151004133318_init_invitiations.exs │ └── 20151004133026_init_characters.exs │ └── seeds.exs ├── Procfile ├── test ├── test_helper.exs ├── web │ ├── models │ │ └── character_test.exs │ ├── channels │ │ ├── vitals_channel_test.exs │ │ ├── movement_channel_test.exs │ │ ├── group_channel_test.exs │ │ ├── social_channel_test.exs │ │ ├── skill_channel_test.exs │ │ └── entity_channel_test.exs │ └── controllers │ │ ├── token_controller_test.exs │ │ ├── char_controller_test.exs │ │ ├── docu_controller_test.exs │ │ ├── auth_controller_test.exs │ │ ├── friends_controller_test.exs │ │ └── account_controller_test.exs ├── lib │ └── entice │ │ └── web │ │ ├── token_test.exs │ │ └── client_test.exs ├── support │ ├── channel_case.ex │ ├── model_case.ex │ └── conn_case.ex └── test_factories.exs ├── config ├── prod.exs ├── test.exs ├── dev.exs └── config.exs ├── LICENSE ├── Vagrantfile ├── README.md ├── .travis.yml ├── bootstrap.sh ├── mix.exs └── mix.lock /web/templates/page/error.html.eex: -------------------------------------------------------------------------------- 1 | Something went wrong 2 | -------------------------------------------------------------------------------- /web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 | ... welcome to entice ... 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | /.vagrant 4 | erl_crash.dump 5 | *.ez 6 | *.swp 7 | -------------------------------------------------------------------------------- /web/templates/page/not_found.html.eex: -------------------------------------------------------------------------------- 1 | The page you are looking for does not exist 2 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | erlang_version=18.2 2 | elixir_version=1.2.4 3 | always_rebuild=true 4 | -------------------------------------------------------------------------------- /web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.LayoutView do 2 | use Entice.Web.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.PageView do 2 | use Entice.Web.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/entice/web/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Repo do 2 | use Ecto.Repo, otp_app: :entice_web 3 | end 4 | -------------------------------------------------------------------------------- /priv/static/images/entice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/entice/web/HEAD/priv/static/images/entice.png -------------------------------------------------------------------------------- /priv/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/entice/web/HEAD/priv/static/images/favicon.png -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: yes | mix compile.protocols && mix compile && mix ecto.migrate Entice.Web.Repo && elixir -pa _build/prod/consolidated -S mix phoenix.server 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Code.require_file "test_factories.exs", __DIR__ 2 | 3 | Entice.Test.Factories.Counter.start_link 4 | 5 | ExUnit.start 6 | 7 | Ecto.Adapters.SQL.Sandbox.mode(Entice.Web.Repo, :manual) 8 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :entice_web, Entice.Web.Endpoint, 4 | http: [port: {:system, "PORT"}], 5 | url: [ 6 | host: (System.get_env("HOST_NAME") || "to.entice.so"), 7 | port: (System.get_env("HOST_PORT") || 80)], 8 | cache_static_manifest: "priv/static/manifest.json" 9 | 10 | # Do not print debug messages in production 11 | config :logger, 12 | level: :info 13 | -------------------------------------------------------------------------------- /web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.ErrorView do 2 | use Entice.Web.Web, :view 3 | 4 | def render("404.html", _assigns) do 5 | "Page not found - 404" 6 | end 7 | 8 | def render("500.html", _assigns) do 9 | "Server internal error - 500" 10 | end 11 | 12 | # Render all other templates as 500 13 | def template_not_found(_template, assigns) do 14 | render "500.html", assigns 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151004132846_init_accounts.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Repo.Migrations.InitAccounts do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:accounts, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :email, :string, size: 60, null: false 8 | add :password, :string, size: 50, null: false 9 | timestamps 10 | end 11 | 12 | create unique_index(:accounts, [:email]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /web/models/invitation.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Invitation do 2 | use Entice.Web.Web, :schema 3 | 4 | schema "invitations" do 5 | field :email, :string 6 | field :key, :string 7 | timestamps 8 | end 9 | 10 | 11 | def changeset(invitation, params \\ :invalid) do 12 | invitation 13 | |> cast(params, [:email, :key]) 14 | |> validate_required([:email, :key]) 15 | |> unique_constraint(:email) 16 | |> unique_constraint(:key) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/web/models/character_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.CharacterTest do 2 | use Entice.Web.ModelCase 3 | alias Entice.Web.{Account, Character} 4 | 5 | 6 | test "initial available skills" do 7 | acc = Account.changeset(%Account{}, %{email: "hansus@wurstus.com", password: "hansus_wurstus"}) |> Repo.insert! 8 | char = Character.changeset_char_create(%Character{}, %{account_id: acc.id, name: "Hansus Wurstus"}) |> Repo.insert! 9 | assert char.available_skills != "" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151004133858_init_friends.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Repo.Migrations.InitFriends do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:friends, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :base_name, :string, size: 60 8 | add :account_id, references(:accounts, type: :binary_id) 9 | add :friend_account_id, references(:accounts, type: :binary_id) 10 | timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151004133318_init_invitiations.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Repo.Migrations.InitInvitiations do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:invitations, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :email, :string, size: 60, null: false 8 | add :key, :string, size: 36, null: false 9 | timestamps 10 | end 11 | 12 | create unique_index(:invitations, [:email]) 13 | create unique_index(:invitations, [:key]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /web/models/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Account do 2 | use Entice.Web.Web, :schema 3 | 4 | schema "accounts" do 5 | field :email, :string 6 | field :password, :string 7 | has_many :characters, Entice.Web.Character 8 | has_many :friends, Entice.Web.Friend 9 | timestamps 10 | end 11 | 12 | 13 | def changeset(account, params \\ :invalid) do 14 | account 15 | |> cast(params, [:email, :password]) 16 | |> validate_required([:email, :password]) 17 | |> validate_format(:email, ~r/@/) 18 | |> unique_constraint(:email) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/web/channels/vitals_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.VitalsChannelTest do 2 | use Entice.Web.ChannelCase 3 | use Entice.Logic.Maps 4 | alias Entice.Logic.Vitals 5 | alias Entice.Test.Factories 6 | 7 | setup do 8 | player = Factories.create_player(HeroesAscent) 9 | {:ok, _, _socket} = subscribe_and_join(player[:socket], "vitals:heroes_ascent", %{}) 10 | {:ok, [entity: player[:entity_id]]} 11 | end 12 | 13 | 14 | test "entity death propagation", %{entity: eid} do 15 | Vitals.kill(eid) 16 | assert_broadcast "entity:dead", %{entity: ^eid} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /web/models/friend.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Friend do 2 | use Entice.Web.Web, :schema 3 | 4 | schema "friends" do 5 | field :base_name, :string 6 | belongs_to :account, Entice.Web.Account 7 | belongs_to :friend_account, Entice.Web.Account 8 | timestamps 9 | end 10 | 11 | 12 | def changeset(friend, params \\ :invalid) do 13 | friend 14 | |> cast(params, [:account_id, :friend_account_id, :base_name]) 15 | |> validate_required([:account_id, :friend_account_id, :base_name]) 16 | |> assoc_constraint(:account) 17 | |> assoc_constraint(:friend_account) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :entice_web, Entice.Web.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | config :entice_web, client_version: "TestVersion" 10 | 11 | # Print only warnings and errors during test 12 | config :logger, level: :warn 13 | 14 | # Set a higher stacktrace during test 15 | config :phoenix, :stacktrace_depth, 20 16 | 17 | # Configure your database 18 | config :entice_web, Entice.Web.Repo, 19 | adapter: Ecto.Adapters.Postgres, 20 | username: "postgres", 21 | password: "", 22 | database: "entice_test", 23 | hostname: "localhost", 24 | pool: Ecto.Adapters.SQL.Sandbox 25 | -------------------------------------------------------------------------------- /test/lib/entice/web/token_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.TokenTest do 2 | use ExUnit.Case 3 | use Entice.Logic.Maps 4 | alias Entice.Web.Token 5 | alias Entice.Test.Factories 6 | 7 | 8 | setup do 9 | player = Factories.create_player(HeroesAscent) 10 | {:ok, [client_id: player[:client_id], entity_id: player[:entity_id], character: player[:character]]} 11 | end 12 | 13 | 14 | test "mapchange token", %{client_id: cid, entity_id: eid, character: char} do 15 | {:ok, token} = Token.create_mapchange_token(cid, %{ 16 | entity_id: eid, 17 | map: RandomArenas, 18 | char: char}) 19 | 20 | assert {:ok, ^token, :mapchange, %{entity_id: ^eid, map: RandomArenas, char: ^char}} = Token.get_token(cid) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure(2) do |config| 5 | # check https://atlas.hashicorp.com/search for other boxes 6 | config.vm.box = "debian/jessie64" 7 | 8 | # standard PostgreSQL port 9 | config.vm.network "forwarded_port", guest: 5432, host: 5432 10 | 11 | # standard PhoenixFramework dev port 12 | config.vm.network "forwarded_port", guest: 9000, host: 9000 13 | 14 | config.vm.network "private_network", ip: "192.168.33.10" 15 | 16 | config.vm.synced_folder "./", "/vagrant/", type: "rsync", rsync__exclude: [".git/", "debs/", "_build/"] 17 | 18 | config.vm.provision "shell", path: "bootstrap.sh", privileged: false 19 | 20 | config.vm.post_up_message = "Entice test machine ready.\nTo run the server on port 9000 issue 'vagrant ssh' and run 'mix phoenix.server'.\nPostgreSQL has been automatically started (for dev and test environments) on port 5432." 21 | end 22 | -------------------------------------------------------------------------------- /test/web/controllers/token_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.TokenControllerTest do 2 | use Entice.Web.ConnCase 3 | 4 | setup do 5 | result = %{email: "root@entice.ps", password: "root" } 6 | result = Map.put(result, :params, %{client_version: "TestVersion", map: "heroes_ascent", char_name: "Root Root A"}) 7 | {:ok, result} 8 | end 9 | 10 | @tag id: 0 11 | test "entity_token success", context do 12 | {:ok, result} = fetch_route(:get, "/api/token/entity", context) 13 | 14 | assert result["status"] == "ok", "entity_token should have succeeded but didn't." 15 | assert result["message"] == "Transferring...", "entity_token returned unexpected value for key: message." 16 | assert result["map"] == "heroes_ascent", "entity_token returned unexpected value for key: map." 17 | assert result["is_outpost"] == true, "entity_token returned unexpected value for key: is_outpost." 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/entice/web.svg)](https://travis-ci.org/entice/web) 2 | 3 | # Entice.Web 4 | 5 | Serves a web frontend for entice. Use this to access the worlds. 6 | 7 | Needs: 8 | 9 | - Erlang version: 17.5 10 | - Elixir version: 1.0.4 11 | 12 | To config: 13 | 14 | - find the config files in `./config` 15 | - in local dev environment edit the `./config/prod.exs` config file 16 | - in production environment (see `MIX_ENV`) use DATABASE_URL to set the PostgreSQL url: 17 | - get your url, check postgres info on how to do that it should look somewhat like this: `postgres://username:password@example.com/database_name` 18 | - replace the `postgres` with `ecto` like this: `ecto://username:password@example.com/database_name` 19 | 20 | 21 | To start: 22 | 23 | 1. Install dependencies with `mix deps.get` 24 | 2. Create the database with `mix ecto.migrate` 25 | 3. Seed the database with `mix run priv/repo/seeds.exs` 26 | 4. Start server with `mix phoenix.server` 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: erlang 2 | sudo: false 3 | 4 | otp_release: 5 | - 18.2 6 | 7 | addons: 8 | postgresql: "9.4" 9 | 10 | before_install: 11 | - psql -c 'create database entice_test;' -U postgres 12 | - wget http://s3.hex.pm/builds/elixir/v1.3.2.zip 13 | - unzip -d elixir v1.3.2.zip 14 | 15 | before_script: 16 | - export PATH=`pwd`/elixir/bin:$PATH 17 | - mix local.hex --force 18 | - mix deps.get 19 | 20 | script: 21 | - MIX_ENV=test mix ecto.migrate 22 | - MIX_ENV=test mix run priv/repo/seeds.exs 23 | - MIX_ENV=test mix test 24 | 25 | notifications: 26 | irc: irc.rizon.net#gwlpr 27 | 28 | deploy: 29 | provider: heroku 30 | buildpack: https://github.com/HashNuke/heroku-buildpack-elixir.git 31 | strategy: git 32 | api_key: 33 | secure: HSDMAqUCQDuOdY9RxFYRb0fMYiEW9RPN63/GO/TJKc9GYjOV3uOD6JRCDhIx+tFIrh+UIbjO6P1cM7sGELYcbGtMrMAIvYP5mC+782qgMK7703q6pYoDRNvDFa161heVZqUehHw2PYHwJGucJqr6O8e9aQ8zKEYfhMPt1E0tBsM= 34 | app: 35 | master: entice-web 36 | develop: entice-web-staging 37 | 38 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151004133026_init_characters.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Repo.Migrations.InitCharacters do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:characters, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :name, :string, size: 30, null: false 8 | 9 | add :available_skills, :string, default: "0" 10 | add :skillbar, {:array, :integer}, default: [] 11 | 12 | # appearance values: 13 | add :profession, :integer 14 | add :campaign, :integer 15 | add :sex, :integer 16 | add :height, :integer 17 | add :skin_color, :integer 18 | add :hair_color, :integer 19 | add :hairstyle, :integer 20 | add :face, :integer 21 | 22 | add :account_id, references(:accounts, type: :binary_id) 23 | 24 | timestamps 25 | end 26 | 27 | create unique_index(:characters, [:name]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/web/channels/movement_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.MovementChannelTest do 2 | use Entice.Web.ChannelCase 3 | use Entice.Logic.Maps 4 | use Entice.Logic.Attributes 5 | alias Entice.Entity 6 | alias Entice.Test.Factories 7 | 8 | 9 | setup do 10 | player = Factories.create_player(HeroesAscent) 11 | {:ok, _, socket} = subscribe_and_join(player[:socket], "movement:heroes_ascent", %{}) 12 | {:ok, [socket: socket, entity_id: player[:entity_id]]} 13 | end 14 | 15 | 16 | test "update position etc.", %{socket: socket, entity_id: eid} do 17 | socket |> push("update", %{ 18 | "position" => %{"x" => 42, "y" => 1337, "plane" => 13}, 19 | "goal" => %{"x" => 1337, "y" => 42, "plane" => 7}, 20 | "move_type" => 9, 21 | "velocity" => 0.1337}) 22 | 23 | assert_broadcast "update", %{ 24 | entity: ^eid, 25 | position: _, 26 | goal: _, 27 | move_type: _, 28 | velocity: _} 29 | 30 | assert %Position{pos: %Coord{x: 42, y: 1337}} = Entity.get_attribute(eid, Position) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/lib/entice/web/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.ClientTest do 2 | use ExUnit.Case 3 | alias Entice.Logic.Skills 4 | alias Entice.Web.Character 5 | alias Entice.Web.Client 6 | 7 | setup do 8 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Entice.Web.Repo) 9 | end 10 | 11 | test "default accounts" do 12 | assert {:ok, _id} = Client.log_in("root@entice.ps", "root") 13 | end 14 | 15 | test "default character" do 16 | assert {:ok, id} = Client.log_in("root@entice.ps", "root") 17 | assert {:ok, char} = Client.get_char(id, "Root Root A") 18 | assert Skills.max_unlocked_skills == :erlang.list_to_integer(char.available_skills |> String.to_char_list, 16) 19 | end 20 | 21 | test "account updating while getting" do 22 | assert {:ok, id} = Client.log_in("root@entice.ps", "root") 23 | assert {:ok, acc} = Client.get_account(id) 24 | char = Entice.Web.Repo.insert!(%Character{name: "Blubb Test Blubb", account_id: acc.id}) 25 | assert {:ok, ^char} = Client.get_char(id, char.name) 26 | Entice.Web.Repo.delete!(char) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/entice_web.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web do 2 | use Application 3 | 4 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | 9 | children = [ 10 | # Start the endpoint when the application starts 11 | supervisor(Entice.Web.Endpoint, []), 12 | # Start the Ecto repository 13 | worker(Entice.Web.Repo, []), 14 | worker(Entice.Web.Client.Server, []), 15 | worker(Entice.Logic.MapRegistry, []) 16 | ] 17 | 18 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 19 | # for other strategies and supported options 20 | opts = [strategy: :one_for_one, name: Entice.Web.Supervisor] 21 | result = Supervisor.start_link(children, opts) 22 | 23 | result 24 | end 25 | 26 | # Tell Phoenix to update the endpoint configuration 27 | # whenever the application is updated. 28 | def config_change(changed, _new, removed) do 29 | Entice.Web.Endpoint.config_change(changed, removed) 30 | :ok 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.PageController do 2 | use Entice.Web.Web, :controller 3 | 4 | plug :ensure_login when action in [:client, :invitation] 5 | plug :put_view, Entice.Web.PageView 6 | 7 | 8 | def index(conn, _), do: conn |> render("index.html") 9 | def invitation(conn, _), do: conn |> render("invitation.html") 10 | def friend(conn, _), do: conn |> render("friend.html") 11 | def not_found(conn, _), do: conn |> render("not_found.html") 12 | def error(conn, _), do: conn |> render("error.html") 13 | 14 | 15 | def account(conn, _) do 16 | client_version = Application.get_env(:entice_web, :client_version) 17 | conn |> render("account.html", client_version: client_version) 18 | end 19 | 20 | 21 | def auth(conn, _) do 22 | client_version = Application.get_env(:entice_web, :client_version) 23 | conn |> render("auth.html", client_version: client_version) 24 | end 25 | 26 | 27 | def client(conn, %{"map" => map}), 28 | do: conn |> render("client.html", map: map) 29 | 30 | def client(conn, _params), 31 | do: conn |> send_resp(400, "The client needs a 'map' parameter to work") 32 | end 33 | -------------------------------------------------------------------------------- /test/web/channels/group_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.GroupChannelTest do 2 | use Entice.Web.ChannelCase 3 | use Entice.Logic.{Maps, Attributes} 4 | alias Entice.Web.Endpoint 5 | alias Entice.Entity 6 | alias Entice.Test.Factories 7 | 8 | 9 | setup do 10 | player = Factories.create_player(HeroesAscent) 11 | {:ok, _, socket} = subscribe_and_join(player[:socket], "group:heroes_ascent", %{}) 12 | {:ok, [socket: socket, entity_id: player[:entity_id]]} 13 | end 14 | 15 | 16 | # this should actually be tested together with the entity_channel reacting to a map change request 17 | test "mapchange", %{socket: socket, entity_id: entity_id} do 18 | new_map = TeamArenas.underscore_name 19 | # we fake a uuid and subscribe ourselfs to its topic 20 | eid = UUID.uuid4() 21 | Endpoint.subscribe(Entice.Web.Socket.id_by_entity(eid)) 22 | # set the faked entity as a member 23 | entity_id |> Entity.put_attribute(%Leader{members: [eid]}) 24 | # trigger the mapchange 25 | send socket.channel_pid, {:entity_mapchange, %{map: new_map}} 26 | # we expect to be notified 27 | assert_receive {:leader_mapchange, %{map: ^new_map}} 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.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 | imports other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | # Import conveniences for testing with channels 20 | use Phoenix.ChannelTest 21 | 22 | alias Entice.Web.Repo 23 | import Ecto.Model 24 | import Ecto.Query, only: [from: 2] 25 | 26 | 27 | # The default endpoint for testing 28 | @endpoint Entice.Web.Endpoint 29 | end 30 | end 31 | 32 | setup tags do 33 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Entice.Web.Repo) 34 | 35 | unless tags[:async] do 36 | Ecto.Adapters.SQL.Sandbox.mode(Entice.Web.Repo, {:shared, self()}) 37 | end 38 | 39 | :ok 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /web/controllers/docu_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.DocuController do 2 | use Entice.Web.Web, :controller 3 | alias Entice.Logic.Maps 4 | alias Entice.Skills 5 | 6 | plug :ensure_login 7 | 8 | 9 | def maps(conn, _params) do 10 | maps = Maps.get_maps 11 | |> Enum.filter(&(&1 != Lobby and &1 != Transfer)) 12 | |> Enum.map(&(&1.underscore_name)) 13 | 14 | conn |> json(ok(%{ 15 | message: "All maps...", 16 | maps: maps})) 17 | end 18 | 19 | 20 | def skills(conn, %{"id" => id}) do 21 | case id |> String.to_integer |> Skills.get_skill do 22 | {:error, m} -> conn |> json(error(%{message: m})) 23 | {:ok, s} -> 24 | conn |> json(ok(%{ 25 | message: "Requested skill...", 26 | skill: %{ 27 | id: s.id, 28 | name: s.underscore_name, 29 | description: s.description}})) 30 | end 31 | end 32 | 33 | 34 | def skills(conn, _params) do 35 | sk = Skills.get_skills 36 | |> Enum.map(&(%{ 37 | id: &1.id, 38 | name: &1.skill.underscore_name, 39 | description: &1.skill.description})) 40 | 41 | conn |> json(ok(%{ 42 | message: "All skills...", 43 | skills: sk})) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/entice/web/token.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Token do 2 | alias Entice.Entity 3 | alias Entice.Web.Token 4 | @moduledoc """ 5 | This adds an access token to a client entity. 6 | Access tokens can carry various kinds of data. 7 | This accesses the entity directly and is not a behaviour. 8 | """ 9 | 10 | 11 | # This attribute is only useable with a client. 12 | defstruct id: "", type: :simple, payload: %{} 13 | 14 | 15 | def create_token(id, type \\ :simple, payload \\ %{}) do 16 | tid = UUID.uuid4() 17 | Entity.put_attribute(id, %Token{id: tid, type: type, payload: payload}) 18 | {:ok, tid} 19 | end 20 | 21 | 22 | def create_entity_token(id, %{entity_id: _} = payload), 23 | do: create_token(id, :entity, payload) 24 | 25 | 26 | def create_mapchange_token(id, %{entity_id: _} = payload), 27 | do: create_token(id, :mapchange, payload) 28 | 29 | 30 | def get_token(id) when is_bitstring(id), do: get_token(Entity.fetch_attribute(id, Token)) 31 | def get_token({:ok, token}), do: {:ok, token.id, token.type, token.payload} 32 | def get_token(:error), do: {:error, :token_not_found} 33 | 34 | 35 | def delete_token(id), do: Entity.remove_attribute(id, Token) 36 | end 37 | -------------------------------------------------------------------------------- /web/channels/vitals_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.VitalsChannel do 2 | use Entice.Web.Web, :channel 3 | alias Entice.Entity.Coordination 4 | alias Entice.Logic.{Maps, Vitals} 5 | alias Phoenix.Socket 6 | 7 | 8 | def join("vitals:" <> map, _message, %Socket{assigns: %{map: map_mod}} = socket) do 9 | {:ok, ^map_mod} = Maps.get_map(camelize(map)) 10 | Process.flag(:trap_exit, true) 11 | send(self, :after_join) 12 | {:ok, socket} 13 | end 14 | 15 | 16 | def handle_info(:after_join, %Socket{assigns: %{entity_id: entity_id}} = socket) do 17 | Coordination.register_observer(self, socket |> map) 18 | Vitals.register(entity_id) 19 | {:noreply, socket} 20 | end 21 | 22 | 23 | def handle_info({:entity_dead, %{entity_id: entity_id}}, socket) do 24 | socket |> broadcast("entity:dead", %{entity: entity_id}) 25 | {:noreply, socket} 26 | end 27 | 28 | 29 | def handle_info({:entity_resurrected, %{entity_id: entity_id}}, socket) do 30 | socket |> broadcast("entity:resurrected", %{entity: entity_id}) 31 | {:noreply, socket} 32 | end 33 | 34 | 35 | def handle_info(_msg, socket), do: {:noreply, socket} 36 | 37 | 38 | def terminate(_msg, socket) do 39 | Vitals.unregister(socket |> entity_id) 40 | :ok 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with brunch.io to recompile .js and .css sources. 9 | config :entice_web, Entice.Web.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | cache_static_lookup: false, 14 | check_origin: false, 15 | watchers: [] 16 | 17 | # Watch static and templates for browser reloading. 18 | config :entice_web, Entice.Web.Endpoint, 19 | live_reload: [ 20 | patterns: [ 21 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 22 | ~r{web/views/.*(ex)$}, 23 | ~r{web/templates/.*(eex)$} 24 | ] 25 | ] 26 | 27 | # Do not include metadata nor timestamps in development logs 28 | config :logger, :console, 29 | format: "[$level] $message\n" 30 | 31 | # Set a higher stacktrace during development. 32 | # Do not configure such in production as keeping 33 | # and calculating stacktraces is usually expensive. 34 | config :phoenix, :stacktrace_depth, 20 35 | 36 | # Configure your database 37 | config :entice_web, Entice.Web.Repo, 38 | adapter: Ecto.Adapters.Postgres, 39 | username: "postgres", 40 | password: "", 41 | database: "entice", 42 | hostname: "localhost", 43 | pool_size: 10 44 | -------------------------------------------------------------------------------- /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 | use Mix.Config 7 | 8 | config :entice_web, 9 | app_namespace: Entice.Web, 10 | client_version: (System.get_env("CLIENT_VERSION") || "MS11"), 11 | ecto_repos: [Entice.Web.Repo] 12 | 13 | # Configures the endpoint 14 | config :entice_web, Entice.Web.Endpoint, 15 | url: [host: "localhost"], 16 | root: Path.dirname(__DIR__), 17 | secret_key_base: "2chowpvvTbXuS+loaCzcTU2RXQY1wQtCn22qrcE51+kcqSCenmMIRE7IrhC2Cwax", 18 | render_errors: [view: Entice.Web.ErrorView, accepts: ~w(html json)], 19 | transports: [websocket_timeout: 60000], 20 | pubsub: [name: Entice.Web.PubSub, 21 | adapter: Phoenix.PubSub.PG2] 22 | 23 | # Configure the database module 24 | config :entice_web, Entice.Web.Repo, 25 | adapter: Ecto.Adapters.Postgres, 26 | url: System.get_env("DATABASE_URL"), 27 | priv: "priv/repo" 28 | 29 | # Configures Elixir's Logger 30 | config :logger, :console, 31 | format: "$time $metadata[$level] $message\n", 32 | metadata: [:request_id] 33 | 34 | # Import environment specific config. This must remain at the bottom 35 | # of this file so it overrides the configuration defined above. 36 | import_config "#{Mix.env}.exs" 37 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | sudo apt-get update 4 | sudo apt-get upgrade 5 | 6 | 7 | # installing PostgreSQL & disabling user password (dont run this on prod ;P)... 8 | sudo apt-get install -y postgresql-9.4 postgresql-client-9.4 9 | sudo -u 'postgres' psql -c "ALTER ROLE postgres WITH PASSWORD ''" 1>/dev/null 10 | 11 | # init the postgres service 12 | cd /etc/postgresql/9.4/main 13 | sudo rm -f pg_hba.conf 14 | 15 | cat | sudo -u postgres tee pg_hba.conf <<- EOM 16 | local all all trust 17 | host all all all trust 18 | EOM 19 | 20 | sudo sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '*'/" "postgresql.conf" 21 | sudo service postgresql restart 22 | cd - 23 | 24 | 25 | # installing Git... 26 | sudo apt-get install -y git 27 | 28 | 29 | # installing Elixir... 30 | wget https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb 31 | sudo dpkg -i erlang-solutions_1.0_all.deb && rm erlang-solutions_1.0_all.deb 32 | sudo apt-get update 33 | sudo apt-get install -y esl-erlang elixir rebar 34 | 35 | 36 | # installing entice & seeding the db... 37 | cd /vagrant/ 38 | mix local.hex --force 39 | mix deps.get 40 | sudo -u 'postgres' psql -c "CREATE DATABASE entice;" 41 | sudo -u 'postgres' psql -c "CREATE DATABASE entice_test;" 42 | MIX_ENV=dev mix ecto.migrate 43 | MIX_ENV=dev mix run priv/repo/seeds.exs 44 | MIX_ENV=test mix ecto.migrate 45 | MIX_ENV=test mix run priv/repo/seeds.exs 46 | -------------------------------------------------------------------------------- /test/web/controllers/char_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.CharControllerTest do 2 | use Entice.Web.ConnCase 3 | 4 | setup context do 5 | result = %{email: "root@entice.ps", password: "root" } 6 | result = case context.id do 7 | 1 -> Map.put(result, :params, %{char_name: "Im sooooooo unique 1"}) 8 | 2 -> Map.put(result, :params, %{char_name: "Im sooooooo unique 2", skin_color: 13, hair_color: 13}) 9 | 3 -> Map.put(result, :params, %{char_name: "Im not so unique, meh"}) 10 | end 11 | {:ok, result} 12 | end 13 | 14 | 15 | @tag id: 1 16 | test "create character if it has a unique name w/o appearance", context do 17 | {:ok, result} = fetch_route(:post, "/api/char", context) 18 | 19 | assert result["status"] == "ok" 20 | assert %{"name" => "Im sooooooo unique 1"} = result["character"] 21 | end 22 | 23 | @tag id: 2 24 | test "create character if it has a unique name w/ some appearance", context do 25 | {:ok, result} = fetch_route(:post, "/api/char", context) 26 | 27 | assert result["status"] == "ok" 28 | assert %{"name" => "Im sooooooo unique 2", "skin_color" => 13, "hair_color" => 13} = result["character"] 29 | end 30 | 31 | @tag id: 3 32 | test "don't create character if it has a non-unique name", context do 33 | {:ok, result} = fetch_route(:post, "/api/char", context) 34 | 35 | assert result["status"] == "ok" 36 | 37 | {:ok, result} = fetch_route(:post, "/api/char", context) 38 | 39 | assert result["status"] == "error" 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /web/controllers/auth_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.AuthController do 2 | use Entice.Web.Web, :controller 3 | 4 | def login(conn, %{"email" => email, "password" => password, "client_version" => client_version}) do 5 | if client_version == Application.get_env(:entice_web, :client_version), 6 | do: login(conn, email, password, Client.logged_out?(conn)), 7 | else: conn |> json(error(%{message: "Invalid Client Version"})) 8 | end 9 | 10 | def login(conn, params), do: conn |> json(error(%{message: "Expected param 'email, password, client_version', got: #{inspect params}"})) 11 | 12 | defp login(conn, _email, _password, false), do: conn |> json(error(%{message: "Already logged in."})) 13 | defp login(conn, email, password, true), do: Client.log_in(email, password) |> maybe_log_in(conn, email) 14 | 15 | 16 | defp maybe_log_in(:error, conn, _email), do: conn |> json(error(%{message: "Authentication failed."})) 17 | defp maybe_log_in({:ok, id}, conn, email) do 18 | conn 19 | |> put_session(:email, email) 20 | |> put_session(:client_id, id) 21 | |> json(ok(%{message: "Logged in."})) 22 | end 23 | 24 | 25 | def logout(conn, params), do: logout(conn, params, Client.logged_in?(conn)) 26 | 27 | defp logout(conn, _params, false), do: conn |> json(error(%{message: "Already logged out."})) 28 | defp logout(conn, _params, true) do 29 | Client.log_out(get_session(conn, :client_id)) 30 | conn 31 | |> configure_session(renew: true) 32 | |> json(ok(%{message: "Logged out."})) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/web/channels/social_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.SocialChannelTest do 2 | use Entice.Web.ChannelCase 3 | use Entice.Logic.Maps 4 | alias Entice.Logic.Group 5 | alias Entice.Test.Factories 6 | 7 | 8 | setup do 9 | player = Factories.create_player(HeroesAscent) 10 | {:ok, %{socket: player[:socket], entity_id: player[:entity_id]}} 11 | end 12 | 13 | 14 | test "message all", %{socket: socket} do 15 | {:ok, _, socket} = subscribe_and_join(socket, "social:heroes_ascent", %{}) 16 | socket |> push("message", %{"text" => "Blubb"}) 17 | assert_broadcast "message", %{sender: _, text: "Blubb"} 18 | end 19 | 20 | test "group join", %{socket: socket, entity_id: eid} do 21 | %{entity_id: e1} = Factories.create_player(HeroesAscent) 22 | Group.register(e1) 23 | Group.register(eid) 24 | # prepare the group... 25 | eid |> Group.new_leader(e1) 26 | assert eid |> Group.is_my_leader?(e1) 27 | 28 | # join chat 29 | {:ok, _, socket} = subscribe_and_join(socket, "social:heroes_ascent:group:#{e1}", %{}) 30 | socket |> push("message", %{"text" => "Blubb"}) 31 | assert_broadcast "message", %{sender: _, text: "Blubb"} 32 | Process.monitor socket.channel_pid 33 | 34 | # get kicked when leader is not leader anymore 35 | eid |> Group.new_leader(eid) 36 | assert not (eid |> Group.is_my_leader?(e1)) 37 | # the channel should be dead 38 | # this should work, but doesn't: assert_push "phx_close", %{}, 5000 39 | assert_receive {:DOWN, _, _, _, _} 40 | end 41 | end 42 | 43 | -------------------------------------------------------------------------------- /lib/entice/web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :entice_web 3 | 4 | 5 | socket "/socket", Entice.Web.Socket 6 | 7 | 8 | plug Plug.Static, 9 | at: "/", from: :entice_web, gzip: false, 10 | only: ~w(css fonts images js favicon.ico robots.txt) 11 | 12 | # Code reloading can be explicitly enabled under the 13 | # :code_reloader configuration of your endpoint. 14 | if code_reloading? do 15 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 16 | plug Phoenix.LiveReloader 17 | plug Phoenix.CodeReloader 18 | end 19 | 20 | plug Plug.RequestId 21 | plug Plug.Logger 22 | 23 | plug Plug.Parsers, 24 | parsers: [:urlencoded, :multipart, :json], 25 | pass: ["*/*"], 26 | json_decoder: Poison 27 | 28 | plug Plug.MethodOverride 29 | plug Plug.Head 30 | 31 | plug Plug.Session, 32 | store: :cookie, 33 | key: "entice_session", 34 | signing_salt: "wZg9FGRp", 35 | encryption_salt: "KzX0FbQY" 36 | 37 | plug Entice.Web.Router 38 | 39 | 40 | # Helpers that are not offered by phoenix by default 41 | 42 | def subscribers(topic), 43 | do: Phoenix.PubSub.subscribers(@pubsub_server, topic) 44 | 45 | def plain_broadcast(topic, message), 46 | do: Phoenix.PubSub.broadcast(@pubsub_server, topic, message) 47 | 48 | def plain_broadcast_from(topic, message), 49 | do: plain_broadcast_from(self, topic, message) 50 | 51 | def plain_broadcast_from(pid, topic, message), 52 | do: Phoenix.PubSub.broadcast_from(@pubsub_server, pid, topic, message) 53 | end 54 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :entice_web, 6 | version: "0.0.1", 7 | elixir: "~> 1.3", 8 | elixirc_paths: elixirc_paths(Mix.env), 9 | compilers: [:phoenix, :gettext] ++ Mix.compilers, 10 | build_embedded: Mix.env == :prod, 11 | start_permanent: Mix.env == :prod, 12 | aliases: aliases, 13 | deps: deps] 14 | end 15 | 16 | def application do 17 | [mod: {Entice.Web, []}, 18 | applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext, 19 | :phoenix_ecto, :postgrex, :entice_entity]] 20 | end 21 | 22 | defp elixirc_paths(:test), do: ["lib", "web", "test/support"] 23 | defp elixirc_paths(_), do: ["lib", "web"] 24 | 25 | defp deps do 26 | [{:entice_logic, github: "entice/logic", ref: "e3a833c9197edbdb6c43ebffb02a2705ca13bad3"}, 27 | {:entice_entity, github: "entice/entity", ref: "c26f6f77ae650e25e6cd2ffea8aae46b7d83966a"}, 28 | {:entice_utils, github: "entice/utils", ref: "79ead4dca77324b4c24f584468edbaff2029eeab"}, 29 | {:cowboy, "~> 1.0"}, 30 | {:phoenix, "~> 1.2.0-rc"}, 31 | {:phoenix_pubsub, "~> 1.0.0-rc"}, 32 | {:phoenix_ecto, "~> 3.0.0-rc"}, 33 | {:postgrex, ">= 0.0.0"}, 34 | {:phoenix_html, "~> 2.3"}, 35 | {:phoenix_live_reload, "~> 1.0", only: :dev}, 36 | {:gettext, "~> 0.9"}, 37 | {:uuid, "~> 1.0"}] # https://github.com/zyro/elixir-uuid 38 | end 39 | 40 | defp aliases do 41 | ["ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 42 | "ecto.reset": ["ecto.drop", "ecto.setup"], 43 | "test": ["ecto.create --quiet", "ecto.migrate", "test"]] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /web/channels/movement_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.MovementChannel do 2 | use Entice.Web.Web, :channel 3 | use Entice.Logic.Attributes 4 | alias Entice.Logic.Maps 5 | alias Entice.Logic.Movement, as: Move 6 | alias Entice.Entity.Coordination 7 | alias Phoenix.Socket 8 | 9 | 10 | def join("movement:" <> map, _message, %Socket{assigns: %{map: map_mod}} = socket) do 11 | {:ok, ^map_mod} = Maps.get_map(camelize(map)) 12 | Process.flag(:trap_exit, true) 13 | send(self, :after_join) 14 | {:ok, socket} 15 | end 16 | 17 | 18 | def handle_info(:after_join, socket) do 19 | Coordination.register_observer(self, socket |> map) 20 | :ok = Move.register(socket |> entity_id) 21 | {:noreply, socket} 22 | end 23 | 24 | def handle_info(_msg, socket), do: {:noreply, socket} 25 | 26 | 27 | # Incoming 28 | 29 | 30 | def handle_in("update", %{ 31 | "position" => %{"x" => pos_x, "y" => pos_y, "plane" => pos_plane} = pos, 32 | "goal" => %{"x" => goal_x, "y" => goal_y, "plane" => goal_plane} = goal, 33 | "move_type" => mtype, 34 | "velocity" => velo}, socket) 35 | when mtype in 0..10 and (-1.0 < velo) and (velo < 2.0) do 36 | Move.update(socket |> entity_id, 37 | %Position{pos: %Coord{x: pos_x, y: pos_y}, plane: pos_plane}, 38 | %Movement{goal: %Coord{x: goal_x, y: goal_y}, plane: goal_plane, move_type: mtype, velocity: velo}) 39 | 40 | broadcast!(socket, "update", %{entity: socket |> entity_id, position: pos, goal: goal, move_type: mtype, velocity: velo}) 41 | 42 | {:noreply, socket} 43 | end 44 | 45 | 46 | # Leaving the socket (voluntarily or forcefully) 47 | 48 | 49 | def terminate(_msg, socket) do 50 | Move.unregister(socket |> entity_id) 51 | :ok 52 | end 53 | end 54 | 55 | -------------------------------------------------------------------------------- /web/controllers/char_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.CharController do 2 | use Entice.Web.Web, :controller 3 | alias Entice.Web.Character 4 | 5 | plug :ensure_login 6 | 7 | 8 | @field_whitelist [ 9 | :name, 10 | :available_skills, 11 | :skillbar, 12 | :profession, 13 | :campaign, 14 | :sex, 15 | :height, 16 | :skin_color, 17 | :hair_color, 18 | :hairstyle, 19 | :face] 20 | 21 | 22 | def list(conn, _params) do 23 | id = conn |> get_session(:client_id) 24 | {:ok, acc} = Client.get_account(id) 25 | 26 | chars = acc.characters 27 | |> Enum.map(fn char -> 28 | char 29 | |> Map.from_struct 30 | |> Map.take(@field_whitelist) 31 | end) 32 | 33 | conn |> json(ok(%{ 34 | message: "All chars...", 35 | characters: chars})) 36 | end 37 | 38 | 39 | def create(conn, %{"char_name" => name} = params) do 40 | id = conn |> get_session(:client_id) 41 | {:ok, acc} = Client.get_account(id) 42 | 43 | changeset = Character.changeset_char_create(%Character{account_id: acc.id}, Map.put(params, "name", name)) 44 | char = Entice.Web.Repo.insert(changeset) 45 | 46 | result = 47 | case char do 48 | {:error, %{errors: [name: "has already been taken"]}} -> error(%{message: "Could not create char. The name is already in use."}) 49 | {:error, %{errors: errors}} -> error(%{message: "Errors occured: #{inspect errors}"}) 50 | {:error, _reason} -> error(%{message: "Unknown error occured."}) 51 | {:ok, char} -> 52 | # make sure the account has the new char... 53 | {:ok, _char} = Client.get_char(id, char.name) 54 | ok(%{message: "Char created.", character: char |> Map.from_struct |> Map.take(@field_whitelist)}) 55 | end 56 | 57 | conn |> json(result) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /web/models/character.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Character do 2 | use Entice.Web.Web, :schema 3 | alias Entice.Logic.Skills 4 | 5 | schema "characters" do 6 | field :name, :string 7 | field :available_skills, :string, default: (:erlang.integer_to_list(Skills.max_unlocked_skills, 16) |> to_string) 8 | field :skillbar, {:array, :integer}, default: [0, 0, 0, 0, 0, 0, 0, 0] 9 | field :profession, :integer, default: 1 10 | field :campaign, :integer, default: 0 11 | field :sex, :integer, default: 1 12 | field :height, :integer, default: 0 13 | field :skin_color, :integer, default: 3 14 | field :hair_color, :integer, default: 0 15 | field :hairstyle, :integer, default: 7 16 | field :face, :integer, default: 30 17 | belongs_to :account, Entice.Web.Account 18 | timestamps 19 | end 20 | 21 | 22 | def changeset_skillbar(character, skillbar \\ [0, 0, 0, 0, 0, 0, 0, 0]) do 23 | character 24 | |> cast(%{skillbar: skillbar}, [:skillbar]) 25 | |> validate_required([:skillbar]) 26 | end 27 | 28 | 29 | def changeset_char_create(character, params \\ :invalid) do 30 | character 31 | |> cast(params, [:name, :account_id, :available_skills, :skillbar, :profession, :campaign, :sex, :height, :skin_color, :hair_color, :hairstyle, :face]) 32 | |> validate_required([:name, :account_id]) 33 | |> validate_inclusion(:profession, 0..20) 34 | |> validate_inclusion(:campaign, 0..5) 35 | |> validate_inclusion(:sex, 0..5) 36 | |> validate_inclusion(:height, 0..30) 37 | |> validate_inclusion(:skin_color, 0..30) 38 | |> validate_inclusion(:hair_color, 0..35) 39 | |> validate_inclusion(:hairstyle, 0..35) 40 | |> validate_inclusion(:face, 0..35) 41 | |> assoc_constraint(:account) 42 | |> unique_constraint(:name) 43 | end 44 | end 45 | 46 | -------------------------------------------------------------------------------- /test/support/model_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.ModelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | model tests. 5 | 6 | You may define functions here to be used as helpers in 7 | your model tests. See `errors_on/2`'s definition as reference. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias Entice.Web.Repo 20 | import Ecto.Model 21 | import Ecto.Query, only: [from: 2] 22 | import Entice.Web.ModelCase 23 | end 24 | end 25 | 26 | setup tags do 27 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Entice.Web.Repo) 28 | 29 | unless tags[:async] do 30 | Ecto.Adapters.SQL.Sandbox.mode(Entice.Web.Repo, {:shared, self()}) 31 | end 32 | 33 | 34 | :ok 35 | end 36 | 37 | @doc """ 38 | Helper for returning list of errors in model when passed certain data. 39 | 40 | ## Examples 41 | 42 | Given a User model that lists `:name` as a required field and validates 43 | `:password` to be safe, it would return: 44 | 45 | iex> errors_on(%User{}, password: "password") 46 | [password: "is unsafe", name: "is blank"] 47 | 48 | You could then write your assertion like: 49 | 50 | assert {:password, "is unsafe"} in errors_on(%User{}, password: "password") 51 | 52 | You can also create the changeset manually and retrieve the errors 53 | field directly: 54 | 55 | iex> changeset = User.changeset(%User{}, password: "password") 56 | iex> {:password, "is unsafe"} in changeset.errors 57 | true 58 | """ 59 | def errors_on(model, data) do 60 | model.__struct__.changeset(model, data).errors 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /web/templates/page/invitation.html.eex: -------------------------------------------------------------------------------- 1 | <%= if logged_in?(@conn) do %> 2 |
3 | Request Invitation 4 |

5 | 6 |

7 |

8 | 9 | 10 |

11 | 12 |
13 | 43 | <% else %> 44 |

You need to be Logged in...

45 | <% end %> 46 | 47 |
48 |
-------------------------------------------------------------------------------- /test/web/channels/skill_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.SkillChannelTest do 2 | use Entice.Web.ChannelCase 3 | use Entice.Logic.Maps 4 | alias Entice.Web.Socket.Helpers 5 | alias Entice.Logic.{Skills, Vitals} 6 | alias Entice.Test.Factories 7 | 8 | setup do 9 | player = Factories.create_player(HeroesAscent) 10 | player[:entity_id] |> Vitals.register 11 | 12 | locked_skill_id = 4 13 | skills = :erlang.integer_to_list(Entice.Utils.BitOps.unset_bit(Skills.max_unlocked_skills, locked_skill_id), 16) |> to_string 14 | 15 | {:ok, new_character} = Entice.Web.Repo.insert(%{player.character | available_skills: skills}) 16 | 17 | {:ok, _, socket} = subscribe_and_join(player[:socket] |> Helpers.set_character(new_character), "skill:heroes_ascent", %{}) 18 | 19 | {:ok, [socket: socket, locked_skill_id: locked_skill_id, skills: skills]} 20 | end 21 | 22 | 23 | test "join", %{skills: skills} do 24 | assert skills != "" 25 | assert_push "initial", %{unlocked_skills: ^skills, skillbar: _} 26 | end 27 | 28 | test "skillbar:set skill not unlocked", %{socket: socket, locked_skill_id: locked_skill_id} do 29 | ref = push socket, "skillbar:set", %{"slot" => 0, "id" => locked_skill_id} 30 | assert_reply ref, :error, _reason 31 | end 32 | 33 | test "simple casting", %{socket: socket} do 34 | skill_id = Skills.HealingSignet.id 35 | ref = push socket, "skillbar:set", %{"slot" => 0, "id" => Skills.HealingSignet.id} 36 | assert_reply ref, :ok 37 | ref = push socket, "cast", %{"slot" => 0} 38 | assert_reply ref, :ok 39 | assert_broadcast "cast:start", %{entity: id, target: id, slot: 0, skill: ^skill_id, cast_time: cast_time} 40 | assert_broadcast "cast:end", %{entity: id, target: id, slot: 0, skill: ^skill_id, recharge_time: recharge_time}, (cast_time + 100) 41 | assert_broadcast "after_cast:end", %{entity: _}, (Entice.Logic.Casting.after_cast_delay + 100) 42 | assert_broadcast "recharge:end", %{entity: _, slot: 0, skill: ^skill_id}, (recharge_time - Entice.Logic.Casting.after_cast_delay + 100) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Router do 2 | use Entice.Web.Web, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_flash 8 | plug :protect_from_forgery 9 | plug :put_secure_browser_headers 10 | # Manually inject the layout here due to non standard namespaces... 11 | plug :put_layout, {Entice.Web.LayoutView, "app.html"} 12 | end 13 | 14 | # Web routes 15 | scope "/", Entice.Web do 16 | pipe_through :browser # Use the default browser stack 17 | 18 | get "/", PageController, :index 19 | get "/auth", PageController, :auth 20 | get "/client/:map", PageController, :client 21 | get "/register", PageController, :account 22 | get "/invitation", PageController, :invitation 23 | get "/friend", PageController, :friend 24 | end 25 | 26 | 27 | pipeline :api do 28 | plug :accepts, ["json"] 29 | plug :fetch_session 30 | plug :fetch_flash 31 | end 32 | 33 | # API routes 34 | scope "/api", Entice.Web do 35 | pipe_through :api 36 | 37 | post "/login", AuthController, :login 38 | post "/logout", AuthController, :logout 39 | 40 | get "/char", CharController, :list 41 | post "/char", CharController, :create 42 | 43 | get "/maps", DocuController, :maps 44 | get "/skills", DocuController, :skills 45 | get "/skills/:id", DocuController, :skills 46 | 47 | get "/token/entity", TokenController, :entity_token 48 | 49 | get "/account/by_char_name", AccountController, :by_char_name 50 | post "/account/register", AccountController, :register 51 | post "/account/request", AccountController, :request_invite 52 | 53 | get "/friend", FriendsController, :index 54 | post "/friend", FriendsController, :create 55 | delete "/friend", FriendsController, :delete 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /web/channels/social_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.SocialChannel do 2 | use Entice.Web.Web, :channel 3 | alias Entice.Logic.{Group, Maps} 4 | alias Entice.Entity.Coordination 5 | alias Phoenix.Socket 6 | 7 | 8 | def join("social:" <> map_rooms, _message, %Socket{assigns: %{map: map_mod}} = socket) do 9 | [map | rooms] = Regex.split(~r/:/, map_rooms) 10 | {:ok, ^map_mod} = Maps.get_map(camelize(map)) 11 | join_internal(rooms, socket) 12 | end 13 | 14 | 15 | # free for all mapwide channel 16 | defp join_internal([], socket), do: {:ok, socket} 17 | 18 | # group only channel, restricted to group usage 19 | defp join_internal(["group", leader_id], socket) do 20 | case Group.is_my_leader?(socket |> entity_id, leader_id) do 21 | false -> {:error, %{reason: "Access to this group chat denied"}} 22 | true -> 23 | Coordination.register_observer(self, socket |> map) 24 | {:ok, socket |> set_leader(leader_id)} 25 | end 26 | end 27 | 28 | 29 | # Internal events 30 | 31 | 32 | @doc """ 33 | Very simple check, might be triggering unecessarily... so if it gets too much 34 | we need to replace this with a more restrictive match, that checks if Leader or Member has 35 | been added/changed/updated 36 | """ 37 | def handle_info({:entity_change, %{entity_id: leader_id}}, %Socket{assigns: %{leader: leader_id}} = socket) do 38 | case Group.is_my_leader?(socket |> entity_id, leader_id) do 39 | false -> {:stop, :normal, socket} 40 | true -> {:noreply, socket} 41 | end 42 | end 43 | 44 | def handle_info({:entity_leave, %{entity_id: leader_id}}, %Socket{assigns: %{leader: leader_id}} = socket), 45 | do: {:stop, :normal, socket} 46 | 47 | def handle_info(_msg, socket), do: {:noreply, socket} 48 | 49 | 50 | # Incoming messages 51 | 52 | 53 | def handle_in("message", %{"text" => t}, socket) do 54 | broadcast(socket, "message", %{text: t, sender: socket |> name}) 55 | {:noreply, socket} 56 | end 57 | 58 | 59 | def handle_in("emote", %{"action" => a}, socket) do 60 | broadcast(socket, "emote", %{action: a, sender: socket |> name}) 61 | {:noreply, socket} 62 | end 63 | 64 | 65 | # internal 66 | 67 | 68 | def set_leader(socket, leader), do: socket |> assign(:leader, leader) 69 | def leader(socket), do: socket.assigns[:leader] 70 | end 71 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.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 | imports other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | # Import conveniences for testing with connections 20 | use Phoenix.ConnTest 21 | 22 | alias Entice.Web.Repo 23 | import Ecto.Model 24 | import Ecto.Query, only: [from: 2] 25 | 26 | import Entice.Web.Router.Helpers 27 | 28 | # The default endpoint for testing 29 | @endpoint Entice.Web.Endpoint 30 | 31 | @opts Entice.Web.Router.init([]) 32 | 33 | def with_session(conn) do 34 | session_opts = Plug.Session.init(store: :cookie, 35 | key: "_app", 36 | encryption_salt: "abc", 37 | signing_salt: "abc") 38 | 39 | conn 40 | |> Map.put(:secret_key_base, String.duplicate("abcdefgh", 8)) 41 | |> Plug.Session.call(session_opts) 42 | |> Plug.Conn.fetch_session() 43 | end 44 | 45 | def log_in(conn, context) do 46 | {:ok, id} = Entice.Web.Client.log_in(context.email, context.password) 47 | conn 48 | |> put_session(:email, context.email) 49 | |> put_session(:client_id, id) 50 | end 51 | 52 | def fetch_route(req, route, context), do: fetch_route(req, route, context, true) 53 | 54 | def fetch_route(req, route, context, must_login) do 55 | conn = build_conn(req, route, context.params) 56 | |> with_session() 57 | if must_login == true, do: conn = log_in(conn, context) 58 | 59 | conn = Entice.Web.Router.call(conn, @opts) 60 | Poison.decode(conn.resp_body) 61 | end 62 | end 63 | end 64 | 65 | setup tags do 66 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Entice.Web.Repo) 67 | 68 | unless tags[:async] do 69 | Ecto.Adapters.SQL.Sandbox.mode(Entice.Web.Repo, {:shared, self()}) 70 | end 71 | 72 | :ok 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /priv/static/css/main.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | position: relative; 7 | margin: 0; 8 | background-color:#241C1C; 9 | min-height: 100%; 10 | font-family: 'Trebuchet MS'; 11 | color: #9e0400; 12 | text-decoration: none; 13 | word-spacing: normal; 14 | letter-spacing: 0; 15 | } 16 | 17 | a { 18 | color: #CC554D; 19 | text-decoration: none; 20 | } 21 | 22 | a:hover { 23 | color: #FF6A60; 24 | text-decoration: underline; 25 | } 26 | 27 | 28 | fieldset { 29 | padding: 5px 10px 5px 10px; 30 | border-style: solid; 31 | border-color: #9E0400; 32 | } 33 | 34 | .header { 35 | text-align: center; 36 | width: 600px; 37 | position: absolute; 38 | left: 50%; 39 | padding: 10px 0 10px 0; 40 | margin: 0 0 0 -300px; 41 | z-index: 10; 42 | } 43 | 44 | .footer { 45 | text-align: center; 46 | width: 600px; 47 | position: absolute; 48 | bottom: 0; 49 | left: 50%; 50 | padding: 10px 0 10px 0; 51 | margin: 0 0 0 -300px; 52 | z-index: 10; 53 | font-size: small; 54 | } 55 | 56 | .center { 57 | text-align: center; 58 | width: 400px; 59 | position: relative; 60 | left: 50%; 61 | padding: 60px 0 100px 0; 62 | margin: 0 0 0 -200px; 63 | } 64 | 65 | .titleMessage { 66 | font-weight: bold; 67 | text-align: center; 68 | width: 100%; 69 | } 70 | 71 | .statusMessage { 72 | text-align: center; 73 | width: 100%; 74 | } 75 | 76 | .inputLabel { 77 | width: 10em; 78 | display: block; 79 | float: left; 80 | } 81 | 82 | .inputField { 83 | background-color: #241C1C; 84 | border-style: solid; 85 | border-color: #9E0400; 86 | font-family: 'Trebuchet MS'; 87 | color: #CC554D; 88 | } 89 | 90 | .inputButton { 91 | background-color: #241C1C; 92 | border-style: solid; 93 | border-color: #9E0400; 94 | font-family: 'Trebuchet MS'; 95 | color: #CC554D; 96 | width: 100%; 97 | } 98 | 99 | .inputButton:hover { 100 | color: #FF6A60; 101 | } 102 | 103 | .lobbyChars { 104 | border-style: solid; 105 | border-width: 1px; 106 | border-color: #9E0400; 107 | border-collapse: collapse; 108 | width: 100%; 109 | } 110 | 111 | .outputTextbox { 112 | border-style: solid; 113 | border-width: 1px; 114 | border-color: #9E0400; 115 | width: 100%; 116 | padding: 0 10px 0 10px; 117 | text-align: left; 118 | } 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /web/web.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Web do 2 | @moduledoc """ 3 | A module that keeps using definitions for controllers, 4 | views and so on. 5 | 6 | This can be used as: 7 | 8 | use Entice.Web.Web, :controller 9 | use Entice.Web.Web, :view 10 | """ 11 | 12 | 13 | defmacro __using__(which) when is_atom(which) do 14 | apply(__MODULE__, which, []) 15 | end 16 | 17 | 18 | def router do 19 | quote do 20 | use Phoenix.Router 21 | end 22 | end 23 | 24 | 25 | def schema do 26 | quote do 27 | use Ecto.Schema 28 | import Ecto.Changeset 29 | @primary_key {:id, :binary_id, autogenerate: true} 30 | @foreign_key_type :binary_id 31 | end 32 | end 33 | 34 | 35 | def view do 36 | quote do 37 | use Phoenix.View, namespace: Entice.Web, root: "web/templates" 38 | 39 | # Import all HTML functions (forms, tags, etc) 40 | use Phoenix.HTML 41 | import Phoenix.Controller, only: [get_flash: 2] 42 | 43 | # Import helpers 44 | import Entice.Web.Router.Helpers 45 | import Entice.Web.Client 46 | 47 | # Functions defined here are available to all other views/templates 48 | def title, do: "... entice server ..." 49 | def email(conn), do: Plug.Conn.get_session(conn, :email) 50 | end 51 | end 52 | 53 | 54 | def controller do 55 | quote do 56 | use Phoenix.Controller 57 | alias Entice.Web.Client 58 | alias Entice.Web.Repo 59 | import Entice.Web.Router.Helpers 60 | 61 | @doc "Use as plug to filter for logged in clients" 62 | def ensure_login(conn, _opts) do 63 | case Client.logged_in?(get_session(conn, :client_id)) do 64 | true -> conn 65 | false -> conn 66 | |> put_flash(:message, "You need to login.") 67 | |> redirect(to: "/") 68 | |> halt 69 | end 70 | end 71 | 72 | @doc "Simple API message helper, returns JSON with OK status" 73 | def ok(msg), do: Map.merge(%{status: :ok}, msg) 74 | 75 | @doc "Simple API message helper, returns JSON with ERROR status" 76 | def error(msg), do: Map.merge(%{status: :error}, msg) 77 | end 78 | end 79 | 80 | 81 | def channel do 82 | quote do 83 | use Phoenix.Channel 84 | import Phoenix.Socket 85 | import Phoenix.Naming 86 | import Entice.Web.Socket.Helpers 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /web/controllers/account_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.AccountController do 2 | use Entice.Web.Web, :controller 3 | alias Entice.Web.{Account, Invitation, Queries} 4 | 5 | 6 | plug :ensure_login when action in [:request_invite] 7 | 8 | def register(conn, %{"email" => email, "password" => password, "invite_key" => invite_key}) do 9 | result = case Queries.get_invite(email) do 10 | {:ok, %Invitation{key: ^invite_key} = invite} -> 11 | %Account{email: email, password: password} |> Entice.Web.Repo.insert 12 | # Delete the used invite (no need to store them) 13 | Repo.delete(invite) 14 | ok(%{message: "Account created!"}) 15 | {:ok, _} -> error(%{message: "Invalid Key!"}) 16 | {:error, :no_matching_invite} -> error(%{message: "No Invitation found for this Email"}) 17 | _ -> error(%{message: "Unknown Error occured"}) 18 | end 19 | conn |> json(result) 20 | end 21 | 22 | def register(conn, params), do: conn |> json(error(%{message: "Expected param 'email, password, invite_key', got: #{inspect params}"})) 23 | 24 | 25 | def request_invite(conn, %{"email" => email}) do 26 | result = case {Queries.get_account(email), Queries.get_invite(email)} do 27 | {{:ok, _account}, _} -> error(%{message: "This Email address is already in use"}) 28 | {_, {:ok, invite}} -> ok(%{message: "Invite exists already", email: invite.email, key: invite.key}) 29 | {_, {:error, :no_matching_invite}} -> 30 | {:ok, invite} = %Invitation{email: email, key: UUID.uuid4()} |> Entice.Web.Repo.insert 31 | ok(%{message: "Invite Created", email: invite.email, key: invite.key}) 32 | end 33 | conn |> json(result) 34 | end 35 | 36 | def request_invite(conn, params), do: conn |> json(error(%{message: "Expected param 'email', got: #{inspect params}"})) 37 | 38 | 39 | @doc "Gets the account id of a character by name (passed through conn) ." 40 | def by_char_name(conn, %{"char_name" => char_name}) do 41 | result = case Queries.get_account_id(char_name) do 42 | {:error, :no_matching_character} -> error(%{message: "Couldn't find character."}) 43 | {:ok, account_id} -> ok(%{account_id: account_id}) 44 | end 45 | 46 | conn |> json(result) 47 | end 48 | 49 | def create(conn, params), do: conn |> json(error(%{message: "Expected param 'char_name', got: #{inspect params}"})) 50 | end 51 | -------------------------------------------------------------------------------- /web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%= title %> 12 | "> 13 | "> 14 | 15 | 16 | 17 | 18 |
19 | Home... 20 | 21 | <%= if logged_in?(@conn) do %> 22 | | Friends... 23 | | ">Client... 24 | | Invite Someone... 25 | | Logout... 26 | <% else %> 27 | | Login... 28 | | Register... 29 | 30 | <% end %> 31 |
32 |
33 | " alt="entice-logo" width="400" height="400"> 34 |
<%= title %>
35 | 36 | <%= if message = get_flash(@conn, :message) do %> 37 |
38 |

<%= message %>

39 |
40 | <% end %> 41 | 42 |
43 | <%= render @view_module, @view_template, assigns %> 44 |
45 | 46 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /web/templates/page/account.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | Account 5 |

6 | 7 |

8 |

9 | 10 | 11 |

12 |

13 | 14 | 15 |

16 |

17 | 18 | 19 |

20 | 21 |
22 | 61 |
62 | 63 |
64 |
65 | -------------------------------------------------------------------------------- /web/channels/socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Socket.Helpers do 2 | import Phoenix.Socket 3 | 4 | def set_map(socket, map), do: socket |> assign(:map, map) 5 | def map(socket), do: socket.assigns[:map] 6 | 7 | def set_entity_id(socket, entity_id), do: socket |> assign(:entity_id, entity_id) 8 | def entity_id(socket), do: socket.assigns[:entity_id] 9 | 10 | def set_client_id(socket, client_id), do: socket |> assign(:client_id, client_id) 11 | def client_id(socket), do: socket.assigns[:client_id] 12 | 13 | def set_character(socket, character), do: socket |> assign(:character, character) 14 | def character(socket), do: socket.assigns[:character] 15 | 16 | def set_name(socket, name), do: socket |> assign(:name, name) 17 | def name(socket), do: socket.assigns[:name] 18 | end 19 | 20 | 21 | defmodule Entice.Web.Socket do 22 | use Phoenix.Socket 23 | alias Entice.Logic.Maps 24 | alias Entice.Web.Token 25 | import Entice.Web.Socket.Helpers 26 | import Phoenix.Naming 27 | 28 | 29 | ## Channels 30 | channel "entity:*", Entice.Web.EntityChannel 31 | channel "group:*", Entice.Web.GroupChannel 32 | channel "movement:*", Entice.Web.MovementChannel 33 | channel "skill:*", Entice.Web.SkillChannel 34 | channel "social:*", Entice.Web.SocialChannel 35 | channel "vitals:*", Entice.Web.VitalsChannel 36 | 37 | 38 | ## Transports 39 | transport :websocket, Phoenix.Transports.WebSocket, timeout: 60_000 40 | # transport :longpoll, Phoenix.Transports.LongPoll 41 | 42 | 43 | def connect(%{"client_id" => client_id, "entity_token" => token, "map" => map}, socket) do 44 | try_connect( 45 | client_id, token, socket, 46 | Token.get_token(client_id), 47 | Maps.get_map(camelize(map))) 48 | end 49 | 50 | defp try_connect( 51 | client_id, token, socket, 52 | {:ok, token, :entity, %{entity_id: entity_id, map: map_mod, char: char}}, 53 | {:ok, map_mod}) do 54 | socket = socket 55 | |> set_map(map_mod) 56 | |> set_entity_id(entity_id) 57 | |> set_client_id(client_id) 58 | |> set_character(char) 59 | |> set_name(char.name) 60 | {:ok, socket} 61 | end 62 | defp try_connect(_client_id, _token, _socket, _token_return, _map_return), 63 | do: :ignore 64 | 65 | 66 | def id(socket), do: id_by_entity(socket |> entity_id) 67 | def id_by_entity(entity_id), do: "socket:entity:#{entity_id}" 68 | end 69 | -------------------------------------------------------------------------------- /web/templates/page/client.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 66 | -------------------------------------------------------------------------------- /web/controllers/token_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.TokenController do 2 | use Entice.Web.Web, :controller 3 | alias Entice.Entity 4 | alias Entice.Entity.Coordination 5 | alias Entice.Logic.{Maps, Player, MapInstance, MapRegistry} 6 | alias Entice.Logic.Player.Appearance 7 | alias Entice.Web.{Character, Token} 8 | import Entice.Utils.StructOps 9 | import Phoenix.Naming 10 | 11 | plug :ensure_login 12 | 13 | def entity_token(conn, %{"map" => map, "char_name" => char_name}), do: entity_token_internal(conn, map, char_name) 14 | 15 | def entity_token(conn, params), do: conn |> json(error(%{message: "Expected param 'map, char_name', got: #{inspect params}"})) 16 | 17 | defp entity_token_internal(conn, map, char_name) do 18 | id = get_session(conn, :client_id) 19 | 20 | # make sure any old entities are killed before being able to play 21 | case Client.get_entity(id) do 22 | old when is_bitstring(old) -> Entity.stop(old) 23 | _ -> nil 24 | end 25 | 26 | {:ok, map_mod} = Maps.get_map(camelize(map)) 27 | {:ok, char} = Client.get_char(id, char_name) 28 | {:ok, eid, _pid} = Entity.start() 29 | name = char.name 30 | 31 | # check if the player already has a mapchange token set 32 | :ok = case Token.get_token(id) do 33 | {:ok, _token, :mapchange, %{entity_id: _old_entity_id, map: ^map_mod, char: %Character{name: ^name}}} -> :ok 34 | {:ok, _token, :entity, _data} -> :ok 35 | {:error, :token_not_found} -> :ok 36 | token -> 37 | raise "Token did not match expectations. Map: #{inspect map_mod}, Char: #{inspect char}, Actual: #{inspect token}" 38 | end 39 | 40 | # create the token (or use the mapchange token) 41 | {:ok, token} = Token.create_entity_token(id, %{entity_id: eid, map: map_mod, char: char}) 42 | 43 | # init the entity and update the client 44 | with :ok <- Client.set_entity(id, eid), 45 | :ok <- Coordination.register(eid, map_mod), 46 | instance_id = MapRegistry.get_or_create_instance(map_mod), 47 | :ok <- MapInstance.add_player(instance_id, eid), 48 | %{Player.Appearance => _, 49 | Player.Level => _, 50 | Player.Name =>_, 51 | Player.Position => _ } <- Player.register(eid, map_mod, char.name, copy_into(%Appearance{}, char)) do 52 | conn |> json(ok(%{ 53 | message: "Transferring...", 54 | client_id: id, 55 | entity_id: eid, 56 | entity_token: token, 57 | map: map_mod.underscore_name, 58 | is_outpost: map_mod.is_outpost?})) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /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 | # Entice.Web.Repo.insert!(%SomeModel{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | 13 | defmodule Seeds do 14 | alias Entice.Web.Repo 15 | alias Entice.Web.Account 16 | alias Entice.Web.Character 17 | alias Entice.Web.Friend 18 | 19 | 20 | def import do 21 | accs = import_accounts 22 | import_characters(accs) 23 | import_friends(accs) 24 | end 25 | 26 | 27 | def import_accounts do 28 | inserts = [ 29 | Account.changeset(%Account{}, %{email: "root@entice.ps", password: "root"}) |> Repo.insert!, 30 | Account.changeset(%Account{}, %{email: "test@entice.ps", password: "test"}) |> Repo.insert!, 31 | Account.changeset(%Account{}, %{email: "testA@entice.ps", password: "testA"}) |> Repo.insert!, 32 | Account.changeset(%Account{}, %{email: "testB@entice.ps", password: "testB"}) |> Repo.insert!, 33 | Account.changeset(%Account{}, %{email: "testC@entice.ps", password: "testC"}) |> Repo.insert!, 34 | Account.changeset(%Account{}, %{email: "testD@entice.ps", password: "testD"}) |> Repo.insert!, 35 | Account.changeset(%Account{}, %{email: "testE@entice.ps", password: "testE"}) |> Repo.insert!] 36 | 37 | for acc <- inserts, into: %{}, do: {acc.id, acc} 38 | end 39 | 40 | 41 | def import_characters(%{} = accounts) do 42 | for {acc_id, acc} <- accounts do 43 | name = acc.email |> String.split("@") |> hd() |> String.capitalize() 44 | Character.changeset_char_create(%Character{}, %{account_id: acc_id, name: "#{name} #{name} A"}) |> Repo.insert! 45 | Character.changeset_char_create(%Character{}, %{account_id: acc_id, name: "#{name} #{name} B"}) |> Repo.insert! 46 | Character.changeset_char_create(%Character{}, %{account_id: acc_id, name: "#{name} #{name} C"}) |> Repo.insert! 47 | end 48 | end 49 | 50 | 51 | def import_friends(%{} = accounts) do 52 | #The characters are named after their account's email + an index,we set base_name to the first char 53 | for {acc_id1, _acc1} <- accounts, {acc_id2, acc2} <- accounts, acc_id1 != acc_id2 do 54 | name = acc2.email |> String.split("@") |> hd() |> String.capitalize() 55 | Friend.changeset(%Friend{}, %{account_id: acc_id1, friend_account_id: acc_id2, base_name: "#{name} #{name} A"}) |> Repo.insert! 56 | end 57 | end 58 | end 59 | 60 | 61 | Seeds.import 62 | -------------------------------------------------------------------------------- /test/test_factories.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Test.Factories do 2 | @moduledoc """ 3 | Stuff à la factory_girl, but with a bit more concrete flavour. 4 | """ 5 | use Entice.Logic.Attributes 6 | use Phoenix.ChannelTest 7 | alias Entice.Entity 8 | alias Entice.Entity.Coordination 9 | alias Entice.Logic.Player 10 | alias Entice.Web.{Account, Character, Client, Token} 11 | alias Entice.Test.Factories.Counter 12 | import Entice.Utils.StructOps 13 | 14 | 15 | @endpoint Entice.Web.Endpoint 16 | 17 | 18 | def create_character(name \\ "Some Char #{Counter.get_num(:character_name)}"), 19 | do: %Character{name: name} 20 | 21 | 22 | def create_account, do: create_account([create_character]) 23 | def create_account(%Character{} = char), do: create_account([char]) 24 | def create_account(characters), 25 | do: %Account{ 26 | email: "somemail#{Counter.get_num(:account_email)}@example.com", 27 | characters: characters, 28 | id: Counter.get_num(:account_id)} 29 | 30 | 31 | def create_client do 32 | {:ok, id} = Client.add(create_account) 33 | id 34 | end 35 | 36 | 37 | def create_client(%Account{} = acc) do 38 | {:ok, id} = Client.add(acc) 39 | id 40 | end 41 | 42 | 43 | def create_entity do 44 | {:ok, id, pid} = Entity.start 45 | {id, pid} 46 | end 47 | 48 | 49 | def create_entity(id) do 50 | {:ok, id, pid} = Entity.start(id) 51 | {id, pid} 52 | end 53 | 54 | def create_player(map) when is_atom(map) do 55 | char = create_character 56 | acc = create_account(char) 57 | cid = create_client(acc) 58 | {eid, pid} = create_entity 59 | {:ok, tid} = Token.create_entity_token(cid, %{entity_id: eid, map: map, char: char}) 60 | 61 | Coordination.register(eid, map) 62 | Player.register(eid, map, char.name, copy_into(%Appearance{}, char)) 63 | 64 | {:ok, socket} = connect(Entice.Web.Socket, %{"client_id" => cid, "entity_token" => tid, "map" => map.underscore_name}) 65 | 66 | %{character: char, account: acc, client_id: cid, entity_id: eid, entity: pid, token: tid, socket: socket} 67 | end 68 | end 69 | 70 | 71 | defmodule Entice.Test.Factories.Counter do 72 | use GenServer 73 | 74 | def start_link, 75 | do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__) 76 | 77 | def get_num(key) when is_atom(key), 78 | do: GenServer.call(__MODULE__, {:num, key}) 79 | 80 | # internal... 81 | 82 | def handle_call({:num, key}, _sender, state) do 83 | new_state = Map.update(state, key, 1, fn count -> count + 1 end) 84 | {:reply, Map.get(new_state, key), new_state} 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/web/controllers/docu_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.DocuControllerTest do 2 | use Entice.Web.ConnCase 3 | 4 | setup context do 5 | result = %{email: "root@entice.ps", password: "root" } 6 | result = case context.id do 7 | 0 -> Map.put(result, :params, %{}) 8 | 1 -> Map.put(result, :params, %{"id" => "1"}) 9 | 2 -> Map.put(result, :params, %{}) 10 | 3 -> Map.put(result, :params, %{}) 11 | _ -> Map.put(result, :params, %{}) 12 | end 13 | {:ok, result} 14 | end 15 | 16 | @tag id: 0 17 | test "maps success", context do 18 | {:ok, result} = fetch_route(:get, "/api/maps", context) 19 | 20 | assert result["status"] == "ok", "maps should have succeeded but didn't." 21 | assert result["message"] == "All maps...", "maps returned unexpected value for key: message." 22 | assert result["maps"] != [], "maps returned unexpected value for key: maps." 23 | end 24 | 25 | 26 | 27 | #Finish tests below once latest skill branch is merged 28 | #No points in fixing the route until then 29 | 30 | 31 | #Currently fails as it's supposed to 32 | #get_skill returns a skill but 33 | #/api/skills calls it expecting {:error, :message} or {:ok, skill} 34 | # @tag id: 1 35 | # test "skills wrong id", context do 36 | # {:ok, result} = fetch_route(:get, "/api/skills", context) 37 | 38 | # assert result["status"] == "error", "skills should have failed but didn't." 39 | # assert result["message"] == "", "skills returned unexpected value for key: message." 40 | # end 41 | 42 | # @tag id: 2 43 | # test "skills write case here", context do 44 | # {:ok, result} = fetch_route(:get, "/api/skills/:id", context) 45 | 46 | # assert result["status"] == "ok", "skills should have succeeded but didn't." 47 | # assert result["message"] == "Requested skill...", "skills returned unexpected value for key: message." 48 | # assert result["skill"] == %{ id, "skills returned unexpected value for key: skill." 49 | # assert result["name"] == s.underscore_name, "skills returned unexpected value for key: name." 50 | # assert result["description"] == s.description}, "skills returned unexpected value for key: description." 51 | # end 52 | 53 | # @tag id: 3 54 | # test "skills write case here", context do 55 | # {:ok, result} = fetch_route(:get, "/api/skills", context) 56 | 57 | # assert result["status"] == "ok", "skills should have succeeded but didn't." 58 | # assert result["message"] == "All skills...", "skills returned unexpected value for key: message." 59 | # assert result["skill"] == sk, "skills returned unexpected value for key: skill." 60 | # end 61 | end 62 | -------------------------------------------------------------------------------- /web/templates/page/friend.html.eex: -------------------------------------------------------------------------------- 1 | <%= if logged_in?(@conn) do %> 2 | 3 |
4 | Friends list 5 |

Friends:

6 |

7 |

8 | 9 | 10 |

11 | 12 | 13 |

14 |
15 | 16 | 85 | <% else %> 86 |

You need to be Logged in...

87 | <% end %> 88 | -------------------------------------------------------------------------------- /lib/entice/web/client_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Client.Server do 2 | use GenServer 3 | require Logger 4 | 5 | @tables [:emails, :client_ids, :account_ids] 6 | 7 | def start_link, do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 8 | 9 | def get_client_by_account_id(account_id), do: GenServer.call(__MODULE__, {:get, :account_id, account_id}) 10 | def get_client_by_email(email), do: GenServer.call(__MODULE__, {:get, :email, email}) 11 | 12 | def set_client_by_account_id(account_id, client), do: GenServer.cast(__MODULE__, {:set, :account_id, account_id, client}) 13 | def set_client_by_email(email, client), do: GenServer.cast(__MODULE__, {:set, :email, email, client}) 14 | 15 | def remove_client_by_account_id(account_id), do: GenServer.cast(__MODULE__, {:remove, :account_id, account_id}) 16 | def remove_client_by_email(email), do: GenServer.cast(__MODULE__, {:remove, :email, email}) 17 | 18 | 19 | # GenServer API 20 | def init(:ok) do 21 | Enum.each(@tables, &init_table(&1)) 22 | {:ok, :ok} 23 | end 24 | 25 | def handle_call({:get, :account_id, account_id}, _from, state) do 26 | id = get_client(:account_ids, account_id) 27 | {:reply, id, state} 28 | end 29 | def handle_call({:get, :email, email}, _from, state) do 30 | id = get_client(:emails, email) 31 | {:reply, id, state} 32 | end 33 | 34 | def handle_cast({:set, :account_id, account_id, client}, state) do 35 | set_client(:account_ids, account_id, client) 36 | {:noreply, state} 37 | end 38 | def handle_cast({:set, :email, email, client}, state) do 39 | set_client(:emails, email, client) 40 | {:noreply, state} 41 | end 42 | 43 | def handle_cast({:remove, :account_id, account_id}, state) do 44 | remove_client(:account_ids, account_id) 45 | {:noreply, state} 46 | end 47 | def handle_cast({:remove, :email, email}, state) do 48 | remove_client(:emails, email) 49 | {:noreply, state} 50 | end 51 | 52 | 53 | # Backend API 54 | @spec set_client(:atom, String.t, String.t) :: true 55 | defp set_client(table, key, client_id) do 56 | :ets.insert(table, {key, client_id}) 57 | end 58 | 59 | @spec get_client(:atom, String.t) :: String.t | nil 60 | def get_client(table, key) do 61 | case :ets.lookup(table, key) do 62 | [{_key, id}] -> id 63 | [] -> nil 64 | end 65 | end 66 | 67 | @spec remove_client(:atom, String.t) :: true 68 | def remove_client(table, key) do 69 | :ets.delete(table, key) 70 | end 71 | 72 | @spec init_table(:atom) :: true 73 | defp init_table(table) do 74 | Logger.info("Creating #{Atom.to_string(table)} database") 75 | :ets.new(table, [:ordered_set, :public, :named_table]) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /web/templates/page/auth.html.eex: -------------------------------------------------------------------------------- 1 | <%= if logged_in?(@conn) do %> 2 |
3 | 4 | 5 | 6 | 27 | 28 |
29 | <% else %> 30 |
31 | 32 |
Login 33 |

34 | 35 | 36 |

37 |

38 | 39 | 40 |

41 |
42 | 43 | 44 | 45 | 72 | 73 |
74 | <% end %> 75 | 76 |
77 |
78 | -------------------------------------------------------------------------------- /test/web/channels/entity_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.EntityChannelTest do 2 | use Entice.Web.ChannelCase 3 | use Entice.Logic.{Maps, Attributes} 4 | alias Entice.Entity 5 | alias Entice.Entity.Coordination 6 | alias Entice.Test.Factories 7 | 8 | 9 | setup do 10 | player = Factories.create_player(HeroesAscent) 11 | 12 | {eid, _pid} = Factories.create_entity() 13 | Coordination.register(eid, HeroesAscent) 14 | 15 | {:ok, _, socket} = subscribe_and_join(player[:socket], "entity:heroes_ascent", %{}) 16 | assert_push "initial", %{attributes: _} 17 | 18 | {:ok, [socket: socket, entity_id: player[:entity_id], other_entity_id: eid]} 19 | end 20 | 21 | 22 | test "entity spawn" do 23 | {:ok, eid, _pid} = Entity.start(UUID.uuid4(), [%Position{}, %Name{}]) 24 | Coordination.register(eid, HeroesAscent) 25 | assert_push "add", %{ 26 | entity: ^eid, 27 | attributes: %{ 28 | "position" => %{x: _, y: _, plane: _}, 29 | "name" => _}} 30 | end 31 | 32 | 33 | test "entity attr add", %{other_entity_id: eid} do 34 | Entity.attribute_transaction(eid, 35 | fn attrs -> attrs |> Map.merge(%{ 36 | Position => %Position{}, 37 | Name => %Name{}, 38 | Health => %Health{}}) end) 39 | empty_map = %{} 40 | assert_push "change", %{ 41 | entity: ^eid, 42 | added: %{ 43 | "position" => %{x: _, y: _, plane: _}, 44 | "name" => _, 45 | "health" => %{health: _, max_health: _}}, 46 | changed: ^empty_map, 47 | removed: ^empty_map} 48 | end 49 | 50 | 51 | test "entity attr change", %{other_entity_id: eid} do 52 | # first add some stuff 53 | Entity.attribute_transaction(eid, 54 | fn attrs -> attrs |> Map.merge(%{Position => %Position{}, Name => %Name{}}) end) 55 | # then change it, position should not be in the update 56 | Entity.attribute_transaction(eid, 57 | fn attrs -> attrs |> Map.merge(%{Position => %Position{plane: 42}, Name => %Name{name: "Rababerabar"}}) end) 58 | empty_map = %{} 59 | only_name = %{"name" => "Rababerabar"} 60 | assert_push "change", %{ 61 | entity: ^eid, 62 | added: ^empty_map, 63 | changed: ^only_name, 64 | removed: ^empty_map} 65 | end 66 | 67 | 68 | test "entity leave", %{other_entity_id: eid} do 69 | Entity.stop(eid) 70 | assert_push "remove", %{entity: ^eid} 71 | end 72 | 73 | 74 | test "getting kicked", %{socket: socket, entity_id: eid} do 75 | Process.monitor socket.channel_pid 76 | Entity.stop(eid) 77 | assert_receive {:DOWN, _, _, _, _} 78 | end 79 | 80 | 81 | test "mapchange", %{socket: socket} do 82 | new_map = TeamArenas.underscore_name 83 | ref = push socket, "map:change", %{"map" => new_map} 84 | assert_reply ref, :ok, %{map: ^new_map} 85 | end 86 | 87 | 88 | test "error in mapchange", %{socket: socket} do 89 | new_map = "non_existing_map" 90 | ref = push socket, "map:change", %{"map" => new_map} 91 | assert_reply ref, :error, %{reason: :unknown_map} 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/web/controllers/auth_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.AuthControllerTest do 2 | use Entice.Web.ConnCase 3 | 4 | setup context do 5 | result = %{email: "root@entice.ps", password: "root" } 6 | result = case context.id do 7 | 0 -> Map.put(result, :params, %{email: "root@entice.ps", password: "root", client_version: Application.get_env(:entice_web, :client_version)}) 8 | 1 -> Map.put(result, :params, %{email: "root@entice.ps", password: "root", client_version: Application.get_env(:entice_web, :client_version)}) 9 | 2 -> Map.put(result, :params, %{email: "root@entice.ps", password: "wrong pass", client_version: Application.get_env(:entice_web, :client_version)}) 10 | 3 -> Map.put(result, :params, %{email: "root@entice.ps", password: "root", client_version: Application.get_env(:entice_web, :client_version)}) 11 | 4 -> Map.put(result, :params, %{email: "root@entice.ps", password: "root", client_version: "Invalid"}) 12 | _ -> Map.put(result, :params, %{email: "root@entice.ps", password: "root", client_version: Application.get_env(:entice_web, :client_version)}) 13 | end 14 | {:ok, result} 15 | end 16 | 17 | @tag id: 0 18 | test "login already logged in", context do 19 | {:ok, result} = fetch_route(:post, "/api/login", context) 20 | 21 | assert result["status"] == "error", "login should have failed but didn't." 22 | assert result["message"] == "Already logged in.", "login returned unexpected value for key: message." 23 | end 24 | 25 | @tag id: 1 26 | test "login correct parameters", context do 27 | {:ok, result} = fetch_route(:post, "/api/login", context, false) 28 | 29 | assert result["status"] == "ok", "login should have succeeded but didn't." 30 | assert result["message"] == "Logged in.", "login returned unexpected value for key: message." 31 | end 32 | 33 | @tag id: 2 34 | test "login wrong pass", context do 35 | {:ok, result} = fetch_route(:post, "/api/login", context, false) 36 | 37 | assert result["status"] == "error", "login should have failed but didn't." 38 | assert result["message"] == "Authentication failed.", "login returned unexpected value for key: message." 39 | end 40 | 41 | @tag id: 3 42 | test "logout correct parameters", context do 43 | {:ok, result} = fetch_route(:post, "/api/logout", context) 44 | 45 | assert result["status"] == "ok", "logout should have succeeded but didn't." 46 | assert result["message"] == "Logged out.", "logout returned unexpected value for key: message." 47 | end 48 | 49 | @tag id: 4 50 | test "Invalid Client Version", context do 51 | {:ok, result} = fetch_route(:post, "/api/login", context) 52 | 53 | assert result["status"] == "error" 54 | assert result["message"] == "Invalid Client Version" 55 | end 56 | 57 | @tag id: 5 58 | test "logout already logged out", context do 59 | {:ok, result} = fetch_route(:post, "/api/logout", context, false) 60 | 61 | assert result["status"] == "error", "logout should have failed but didn't." 62 | assert result["message"] == "Already logged out.", "logout returned unexpected value for key: message." 63 | end 64 | 65 | end 66 | -------------------------------------------------------------------------------- /web/controllers/friends_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.FriendsController do 2 | use Entice.Web.Web, :controller 3 | alias Entice.Web.{Friend, Queries} 4 | 5 | plug :ensure_login 6 | 7 | #Case to think about: 8 | #Player A adds friend Player B under name N 9 | #Player B deletes account (and so all chars are deleted) 10 | #Player C creates acccount and char under name N 11 | #Player A adds Player C under name N 12 | #Now in friends DB: (N, PlayerA.id, PlayerB.id) and (N, PlayerA.id, PlayerC.id) 13 | #Since all queries are done by name it'll be an issue. 14 | #Solutions: 15 | #Delete all friends with PlayerB.id in them 16 | 17 | 18 | #TODO: add map once it's server sided, order by creation date 19 | @doc "Returns all friends of connected account." 20 | def index(conn, _params) do 21 | id = get_session(conn, :client_id) 22 | {:ok, friends} = Entice.Web.Client.get_friends(id) 23 | 24 | 25 | results = for friend <- friends do 26 | {:ok, status, name} = get_status(friend.base_name) 27 | _map = %{base_name: friend.base_name, current_name: name, status: status} 28 | end 29 | 30 | conn |> json(ok(%{ 31 | message: "All friends", 32 | friends: results})) 33 | end 34 | 35 | defp get_status(friend_name) do 36 | {:ok, _status, _name} = Client.get_status(friend_name) 37 | end 38 | 39 | @doc "Adds friend :id to friends list of connected account." 40 | def create(conn, %{"char_name" => friend_name}) do 41 | session_id = get_session(conn, :client_id) 42 | {:ok, acc} = Client.get_account(session_id) 43 | account_id = acc.id 44 | 45 | success = with {:ok, friend_account} <- Queries.get_account_by_name(friend_name), 46 | false <- friend_account.id == account_id, 47 | nil <- Queries.get_friend_by_friend_account_id(account_id, friend_account.id), 48 | {:ok, _friend} <- Queries.add_friend(acc, friend_account, friend_name), 49 | do: :ok 50 | 51 | result = case success do 52 | {:error, :no_matching_character} -> error(%{message: "There is no character with that name"}) 53 | true -> error(%{message: "Can't add yourself."}) 54 | %Friend{} -> error(%{message: "Already in friends list."}) 55 | _ -> ok(%{message: "Friend added."}) 56 | end 57 | 58 | conn |> json(result) 59 | end 60 | 61 | def create(conn, params), do: conn |> json(error(%{message: "Expected param 'char_name', got: #{inspect params}"})) 62 | 63 | 64 | @doc "Deletes friend :id from friends list of connected account." 65 | def delete(conn, %{"char_name" => friend_name}) do 66 | session_id = get_session(conn, :client_id) 67 | {:ok, acc} = Client.get_account(session_id) 68 | 69 | #friend_name will always be base_name of friend model since query controller by client, so no need to get friend by id 70 | result = case Queries.get_friend_by_base_name(acc.id, friend_name) do 71 | nil -> error(%{message: "This friend does not exist."}) 72 | friend -> 73 | Entice.Web.Repo.delete(friend) 74 | ok(%{message: "Friend deleted."}) 75 | end 76 | conn |> json(result) 77 | end 78 | 79 | def delete(conn, params), do: conn |> json(error(%{message: "Expected param 'char_name', got: #{inspect params}"})) 80 | end 81 | -------------------------------------------------------------------------------- /web/models/queries.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Queries do 2 | alias Entice.Web.{Account, Character, Invitation, Friend, Repo} 3 | import Ecto.Query 4 | import ExUnit.Assertions 5 | 6 | def all_accounts do 7 | query = from a in Account, 8 | select: a 9 | Repo.all(query) 10 | end 11 | 12 | def get_account_id(char_name) do 13 | query = from char in Character, 14 | where: char.name == ^char_name, 15 | select: char.account_id 16 | 17 | case Repo.all(query) do 18 | [account_id] -> {:ok, account_id} 19 | _ -> {:error, :no_matching_character} 20 | end 21 | end 22 | 23 | def get_account_by_name(char_name) do 24 | case get_account_id(char_name) do 25 | {:ok, account_id} -> 26 | account = Entice.Web.Repo.get(Entice.Web.Account, account_id) 27 | assert account != nil, "There should never be a character without an account." 28 | {:ok, account} 29 | _ -> {:error, :no_matching_character} 30 | end 31 | end 32 | 33 | def get_account(email, password) do 34 | query = from a in Account, 35 | where: a.email == ^email and a.password == ^password, 36 | preload: :characters, 37 | select: a 38 | 39 | case Repo.all(query) do 40 | [acc] -> 41 | friends = get_friends(acc.id) 42 | {:ok, %Account{acc | friends: friends}} 43 | _ -> {:error, :no_matching_account} 44 | end 45 | end 46 | 47 | def get_account(email) do 48 | query = from a in Account, 49 | where: a.email == ^email, 50 | select: a 51 | 52 | case Repo.all(query) do 53 | [acc] -> {:ok, acc} 54 | _ -> {:error, :account_not_found} 55 | end 56 | end 57 | 58 | def update_account(%Account{id: id}) do 59 | query = from a in Account, 60 | where: a.id == ^id, 61 | preload: :characters, 62 | select: a 63 | 64 | case Repo.all(query) do 65 | [acc] -> 66 | friends = get_friends(acc.id) 67 | {:ok, %Account{acc | friends: friends}} 68 | _ -> {:error, :no_matching_account} 69 | end 70 | end 71 | 72 | def get_invite(email) do 73 | query = from a in Invitation, 74 | limit: 1, 75 | where: a.email == ^email, 76 | select: a 77 | 78 | case Repo.all(query) do 79 | [invite] -> {:ok, invite} 80 | [] -> {:error, :no_matching_invite} 81 | _ -> {:error, :database_inconsistent} 82 | end 83 | end 84 | 85 | def add_friend(account, friend_account, base_name) do 86 | %Friend{base_name: base_name, friend_account: friend_account, account: account, friend_account_id: friend_account.id, account_id: account.id} 87 | |> Repo.insert 88 | end 89 | 90 | def get_friend_by_base_name(account_id, base_name), 91 | do: Entice.Web.Repo.get_by(Friend, account_id: account_id, base_name: base_name) 92 | 93 | def get_friend_by_friend_account_id(account_id, friend_account_id), 94 | do: Entice.Web.Repo.get_by(Friend, account_id: account_id, friend_account_id: friend_account_id) 95 | 96 | def get_friends(account_id) do 97 | query = from f in Friend, 98 | where: f.account_id == ^account_id, 99 | preload: [:friend_account], 100 | select: f 101 | 102 | Repo.all(query) 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /web/channels/group_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.GroupChannel do 2 | use Entice.Web.Web, :channel 3 | use Entice.Logic.Attributes 4 | alias Entice.Logic.{Group, Maps} 5 | alias Entice.Entity 6 | alias Entice.Entity.Coordination 7 | alias Entice.Web.{Endpoint, Token} 8 | alias Phoenix.Socket 9 | 10 | 11 | @reported_attributes [ 12 | Leader, 13 | Member] 14 | 15 | 16 | def join("group:" <> map, _message, %Socket{assigns: %{map: map_mod}} = socket) do 17 | {:ok, ^map_mod} = Maps.get_map(camelize(map)) 18 | Process.flag(:trap_exit, true) 19 | send(self, :after_join) 20 | {:ok, socket} 21 | end 22 | 23 | 24 | def handle_info(:after_join, socket) do 25 | Coordination.register_observer(self, socket |> map) 26 | :ok = Group.register(socket |> entity_id) 27 | {:noreply, socket} 28 | end 29 | 30 | 31 | # Internal events 32 | 33 | 34 | def handle_info({:entity_join, %{entity_id: entity_id, attributes: %{Leader => leader}}}, socket) do 35 | socket |> push_update(entity_id, leader) 36 | {:noreply, socket} 37 | end 38 | 39 | def handle_info({:entity_change, %{entity_id: entity_id, added: %{Leader => leader}}}, socket) do 40 | socket |> push_update(entity_id, leader) 41 | {:noreply, socket} 42 | end 43 | 44 | def handle_info({:entity_change, %{entity_id: entity_id, changed: %{Leader => leader}}}, socket) do 45 | socket |> push_update(entity_id, leader) 46 | {:noreply, socket} 47 | end 48 | 49 | def handle_info({:entity_leave, %{entity_id: entity_id, attributes: %{Leader => _}}}, socket) do 50 | socket |> push("remove", %{entity: entity_id}) 51 | {:noreply, socket} 52 | end 53 | 54 | 55 | def handle_info({:entity_mapchange, %{map: map_mod}}, socket) do 56 | mems = 57 | case Entity.fetch_attribute(socket |> entity_id, Leader) do 58 | {:ok, %Leader{members: mems}} -> mems 59 | _ -> [] 60 | end 61 | 62 | mems |> Enum.map( 63 | fn member -> 64 | Endpoint.plain_broadcast( 65 | Entice.Web.Socket.id_by_entity(member), 66 | {:leader_mapchange, %{map: map_mod}}) 67 | end) 68 | 69 | {:noreply, socket} 70 | end 71 | 72 | 73 | def handle_info({:leader_mapchange, %{map: map_mod}}, socket) do 74 | {:ok, _token} = Token.create_mapchange_token(socket |> client_id, %{ 75 | entity_id: socket |> entity_id, 76 | map: map_mod, 77 | char: socket |> character}) 78 | 79 | # TODO pls check if some this needs to be replaced? 80 | #Observer.notify_mapchange(socket |> entity_id, map_mod) 81 | 82 | socket |> push("map:change", %{map: map_mod.underscore_name}) 83 | 84 | {:noreply, socket} 85 | end 86 | 87 | def handle_info(_msg, socket), do: {:noreply, socket} 88 | 89 | 90 | # Incoming events 91 | 92 | 93 | def handle_in("merge", %{"target" => id}, socket) do 94 | Group.invite(socket |> entity_id, id) 95 | {:noreply, socket} 96 | end 97 | 98 | 99 | def handle_in("kick", %{"target" => id}, socket) do 100 | Group.kick(socket |> entity_id, id) 101 | {:noreply, socket} 102 | end 103 | 104 | 105 | # Leaving the socket (voluntarily or forcefully) 106 | 107 | 108 | def terminate(_msg, socket) do 109 | Group.unregister(socket |> entity_id) 110 | :ok 111 | end 112 | 113 | 114 | # Internal 115 | 116 | 117 | defp push_update(socket, entity_id, leader) do 118 | socket |> push("update", %{ 119 | leader: entity_id, 120 | members: leader.members, 121 | invited: leader.invited}) 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/entice/web/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.Client do 2 | alias Entice.Web.{Account, Client, Queries} 3 | alias Entice.Entity 4 | alias Entice.Logic.Player 5 | import Plug.Conn 6 | 7 | @doc """ 8 | Stores client related plain data 9 | """ 10 | defstruct entity_id: nil, online_status: :offline 11 | 12 | 13 | def exists?(id), do: Entity.exists?(id) 14 | 15 | 16 | def logged_in?(%Plug.Conn{} = conn), do: exists?(get_session(conn, :client_id)) 17 | def logged_in?(id) when is_bitstring(id), do: exists?(id) 18 | 19 | def logged_out?(conn), do: not logged_in?(conn) 20 | 21 | 22 | @doc "Internal api, for tests and so on." 23 | def add(%Account{} = acc), do: log_in({:ok, acc}) 24 | 25 | 26 | def log_in(email, password), do: log_in(Queries.get_account(email, password)) 27 | defp log_in({:error, _msg}), do: :error 28 | defp log_in({:ok, acc}) do 29 | existing = Client.Server.get_client_by_email(acc.email) 30 | 31 | {:ok, id, _pid} = case existing do 32 | id when is_bitstring(id) -> {:ok, id, :no_pid} 33 | nil -> Entity.start(UUID.uuid4(), %{Account => acc}) 34 | end 35 | 36 | Client.Server.set_client_by_email(acc.email, id) 37 | Client.Server.set_client_by_account_id(acc.id, id) 38 | 39 | {:ok, id} 40 | end 41 | 42 | 43 | def log_out(id) do 44 | {:ok, %Account{email: email, id: account_id}} = get_account(id) 45 | 46 | case get_entity(id) do 47 | eid when is_bitstring(eid) -> Entity.stop(eid) 48 | _ -> nil 49 | end 50 | 51 | Client.Server.remove_client_by_email(email) 52 | Client.Server.remove_client_by_account_id(account_id) 53 | 54 | Entity.stop(id) 55 | end 56 | 57 | 58 | # Account api 59 | 60 | 61 | @doc "Will always update the account data we have stored, in case the data in the db changed" 62 | def get_account(id) do 63 | acc = Entity.fetch_attribute!(id, Account) 64 | {:ok, acc} = Queries.update_account(acc) 65 | set_account(id, acc) 66 | {:ok, acc} 67 | end 68 | 69 | 70 | def set_account(id, %Account{} = acc), do: Entity.put_attribute(id, acc) 71 | 72 | 73 | # Chars api 74 | 75 | 76 | def get_char(id, name) do 77 | {:ok, %Account{characters: chars}} = get_account(id) 78 | case chars |> Enum.find(fn c -> c.name == name end) do 79 | nil -> {:error, :character_not_found, name} 80 | char -> {:ok, char} 81 | end 82 | end 83 | 84 | 85 | # Friends api 86 | 87 | 88 | def get_friends(id) do 89 | {:ok, %Account{friends: friends}} = get_account(id) 90 | {:ok, friends} 91 | end 92 | 93 | #TODO: Add current map to status when map is server side 94 | @doc "Returns a friend's online status and character name from their account id." 95 | def get_status(friend_name) do 96 | with {:ok, account_id} <- Queries.get_account_id(friend_name), 97 | client_id when is_binary(client_id) <- Client.Server.get_client_by_account_id(account_id), 98 | {:ok, client} <- Entity.fetch_attribute(client_id, Client), 99 | %{} = player <- Player.attributes(client.entity_id) do 100 | {:ok, client.online_status, player[Player.Name].name} 101 | else 102 | _ -> {:ok, :offline, friend_name} 103 | end 104 | 105 | end 106 | 107 | # Entity api 108 | 109 | def set_entity(id, entity_id), 110 | do: Entity.put_attribute(id, %Client{entity_id: entity_id, online_status: :online}) 111 | 112 | 113 | def get_entity(id) do 114 | case Entity.fetch_attribute(id, Client) do 115 | {:ok, %Client{entity_id: entity_id}} -> entity_id 116 | _ -> nil 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/web/controllers/friends_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.FriendsControllerTest do 2 | use Entice.Web.ConnCase 3 | alias Entice.Web.Queries 4 | 5 | setup context do 6 | result = %{email: "root@entice.ps", password: "root"} 7 | result = case context.id do 8 | 0 -> Map.put(result, :params, %{}) 9 | 1 -> 10 | #Both accounts already each other's friends, we need to delete one to be able to add it again 11 | #All other tests working with root account so we don't want to touch it 12 | result = %{email: "test@entice.ps", password: "test"} 13 | {:ok, acc} = Queries.get_account(result.email, result.password) 14 | friend = hd(acc.friends) #test2@entice.ps 15 | Entice.Web.Repo.delete!(friend) 16 | Map.put(result, :params, %{char_name: friend.base_name}) 17 | 2 -> Map.put(result, :params, %{char_name: "Char does not exist"}) 18 | 3 -> Map.put(result, :params, %{char_name: "Root Root A"}) 19 | 4 -> Map.put(result, :params, %{char_name: "Testc Testc A"}) 20 | 5 -> Map.put(result, :params, %{char_name: "Test Test A"}) 21 | 6 -> Map.put(result, :params, %{char_name: "Not a friend char"}) 22 | _ -> Map.put(result, :params, %{}) 23 | end 24 | {:ok, result} 25 | end 26 | 27 | @tag id: 0 28 | test "index success", context do 29 | {:ok, result} = fetch_route(:get, "/api/friend", context) 30 | 31 | assert result["status"] == "ok", "index should have succeeded but didn't." 32 | assert result["message"] == "All friends", "index returned unexpected value for key: message." 33 | assert result["friends"] != [], "index returned unexpected value for key: friends." 34 | end 35 | 36 | @tag id: 1 37 | test "create success", context do 38 | {:ok, result} = fetch_route(:post, "/api/friend", context) 39 | 40 | assert result["status"] == "ok", "create should have succeeded but didn't." 41 | assert result["message"] == "Friend added.", "create returned unexpected value for key: message." 42 | end 43 | 44 | @tag id: 2 45 | test "create character does not exist", context do 46 | {:ok, result} = fetch_route(:post, "/api/friend", context) 47 | 48 | assert result["status"] == "error", "create should have failed but didn't." 49 | assert result["message"] == "There is no character with that name", "create returned unexpected value for key: message." 50 | end 51 | 52 | @tag id: 3 53 | test "create adding own character", context do 54 | {:ok, result} = fetch_route(:post, "/api/friend", context) 55 | 56 | assert result["status"] == "error", "create should have failed but didn't." 57 | assert result["message"] == "Can't add yourself.", "create returned unexpected value for key: message." 58 | end 59 | 60 | @tag id: 4 61 | test "create character already friend", context do 62 | {:ok, result} = fetch_route(:post, "/api/friend", context) 63 | 64 | assert result["status"] == "error", "create should have failed but didn't." 65 | assert result["message"] == "Already in friends list.", "create returned unexpected value for key: message." 66 | end 67 | 68 | @tag id: 5 69 | test "delete success", context do 70 | {:ok, result} = fetch_route(:delete, "/api/friend", context) 71 | 72 | assert result["status"] == "ok", "delete should have succeeded but didn't." 73 | assert result["message"] == "Friend deleted.", "delete returned unexpected value for key: message." 74 | end 75 | 76 | @tag id: 6 77 | test "delete friend doesn't exist", context do 78 | {:ok, result} = fetch_route(:delete, "/api/friend", context) 79 | 80 | assert result["status"] == "error", "delete should have failed but didn't." 81 | assert result["message"] == "This friend does not exist.", "delete returned unexpected value for key: message." 82 | end 83 | 84 | end 85 | -------------------------------------------------------------------------------- /web/channels/skill_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.SkillChannel do 2 | use Entice.Web.Web, :channel 3 | alias Entice.Entity.Coordination 4 | alias Entice.Logic.{Skills, Maps, SkillBar, Casting} 5 | alias Entice.Web.Character 6 | alias Phoenix.Socket 7 | 8 | 9 | def join("skill:" <> map, _message, %Socket{assigns: %{map: map_mod}} = socket) do 10 | {:ok, ^map_mod} = Maps.get_map(camelize(map)) 11 | Process.flag(:trap_exit, true) 12 | send(self, :after_join) 13 | {:ok, socket} 14 | end 15 | 16 | 17 | def handle_info(:after_join, %Socket{assigns: %{entity_id: entity_id, character: char}} = socket) do 18 | Coordination.register_observer(self, socket |> map) 19 | SkillBar.register(entity_id, char.skillbar) 20 | Casting.register(entity_id) 21 | socket |> push("initial", %{unlocked_skills: char.available_skills, skillbar: entity_id |> SkillBar.get_skills}) 22 | {:noreply, socket} 23 | end 24 | 25 | def handle_info( 26 | {:skill_casted, %{ 27 | entity_id: entity_id, 28 | target_entity_id: target_id, 29 | slot: slot, 30 | skill: skill, 31 | recharge_time: recharge_time}}, socket) do 32 | socket |> broadcast("cast:end", %{ 33 | entity: entity_id, 34 | target: target_id, 35 | slot: slot, 36 | skill: skill.id, 37 | recharge_time: recharge_time}) 38 | {:noreply, socket} 39 | end 40 | 41 | def handle_info( 42 | {:skill_cast_interrupted, %{ 43 | entity_id: entity_id, 44 | target_entity_id: target_id, 45 | slot: slot, 46 | skill: skill, 47 | recharge_time: recharge_time, 48 | reason: reason}}, socket) do 49 | socket |> broadcast("cast:interrupted", %{ 50 | entity: entity_id, 51 | target: target_id, 52 | slot: slot, 53 | skill: skill.id, 54 | recharge_time: recharge_time, 55 | reason: reason}) 56 | {:noreply, socket} 57 | end 58 | 59 | def handle_info( 60 | {:skill_recharged, %{ 61 | entity_id: entity_id, 62 | slot: slot, 63 | skill: skill}}, socket) do 64 | socket |> broadcast("recharge:end", %{ 65 | entity: entity_id, 66 | slot: slot, 67 | skill: skill.id}) 68 | {:noreply, socket} 69 | end 70 | 71 | def handle_info({:after_cast_delay_ended, %{entity_id: entity_id}}, socket) do 72 | socket |> broadcast("after_cast:end", %{entity: entity_id}) 73 | {:noreply, socket} 74 | end 75 | 76 | def handle_info(_msg, socket), do: {:noreply, socket} 77 | 78 | 79 | # Incoming 80 | 81 | 82 | def handle_in("skillbar:set", %{"slot" => slot, "id" => id}, socket) when slot in 0..10 and id > -1 do 83 | skill_bits = :erlang.list_to_integer((socket |> character).available_skills |> String.to_char_list, 16) 84 | unlocked = Entice.Utils.BitOps.get_bit(skill_bits, id) 85 | 86 | case {unlocked, Skills.get_skill(id), (socket |> map).is_outpost?} do 87 | {_, nil, _} -> {:reply, {:error, %{reason: :undefined_skill}}, socket} 88 | {0, _, _} -> {:reply, {:error, %{reason: :unavailable_skill}}, socket} 89 | {_, _, false} -> {:reply, {:error, %{reason: :cannot_change_skill_in_explorable}}, socket} 90 | _ -> 91 | new_slots = socket |> entity_id |> SkillBar.change_skill(slot, id) 92 | Entice.Web.Repo.update(Character.changeset_skillbar((socket |> character), new_slots)) 93 | {:reply, {:ok, %{skillbar: new_slots}}, socket} 94 | end 95 | end 96 | 97 | def handle_in("cast", %{"slot" => slot} = msg, socket) when slot in 0..10 do 98 | skill = SkillBar.get_skill(socket |> entity_id, slot) 99 | target = Map.get(msg, "target", socket |> entity_id) 100 | 101 | case socket |> entity_id |> Casting.cast_skill(skill, slot, target, self) do 102 | {:error, reason} -> {:reply, {:error, %{slot: slot, reason: reason}}, socket} 103 | {:ok, skill, cast_time} -> 104 | socket |> broadcast("cast:start", %{ 105 | entity: socket |> entity_id, 106 | target: target, 107 | slot: slot, 108 | skill: skill.id, 109 | cast_time: cast_time}) 110 | {:reply, :ok, socket} 111 | end 112 | end 113 | 114 | 115 | # Leaving the socket (voluntarily or forcefully) 116 | 117 | 118 | def terminate(_msg, socket) do 119 | SkillBar.unregister(socket |> entity_id) 120 | :ok 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"connection": {:hex, :connection, "1.0.3", "3145f7416be3df248a4935f24e3221dc467c1e3a158d62015b35bd54da365786", [:mix], []}, 2 | "cowboy": {:hex, :cowboy, "1.0.4", "a324a8df9f2316c833a470d918aaf73ae894278b8aa6226ce7a9bf699388f878", [:rebar, :make], [{:cowlib, "~> 1.0.0", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.0", [hex: :ranch, optional: false]}]}, 3 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []}, 4 | "db_connection": {:hex, :db_connection, "1.0.0-rc.3", "d9ceb670fe300271140af46d357b669983cd16bc0d01206d7d3222dde56cf038", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: true]}, {:sbroker, "~> 1.0.0-beta.3", [hex: :sbroker, optional: true]}]}, 5 | "decimal": {:hex, :decimal, "1.1.2", "79a769d4657b2d537b51ef3c02d29ab7141d2b486b516c109642d453ee08e00c", [:mix], []}, 6 | "ecto": {:hex, :ecto, "2.0.2", "b02331c1f20bbe944dbd33c8ecd8f1ccffecc02e344c4471a891baf3a25f5406", [:mix], [{:db_connection, "~> 1.0-rc.2", [hex: :db_connection, optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}, {:mariaex, "~> 0.7.7", [hex: :mariaex, optional: true]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: false]}, {:postgrex, "~> 0.11.2", [hex: :postgrex, optional: true]}, {:sbroker, "~> 1.0-beta", [hex: :sbroker, optional: true]}]}, 7 | "entice_entity": {:git, "https://github.com/entice/entity.git", "c26f6f77ae650e25e6cd2ffea8aae46b7d83966a", [ref: "c26f6f77ae650e25e6cd2ffea8aae46b7d83966a"]}, 8 | "entice_logic": {:git, "https://github.com/entice/logic.git", "e3a833c9197edbdb6c43ebffb02a2705ca13bad3", [ref: "e3a833c9197edbdb6c43ebffb02a2705ca13bad3"]}, 9 | "entice_utils": {:git, "https://github.com/entice/utils.git", "79ead4dca77324b4c24f584468edbaff2029eeab", [ref: "79ead4dca77324b4c24f584468edbaff2029eeab"]}, 10 | "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], []}, 11 | "gettext": {:hex, :gettext, "0.11.0", "80c1dd42d270482418fa158ec5ba073d2980e3718bacad86f3d4ad71d5667679", [:mix], []}, 12 | "inflex": {:hex, :inflex, "1.7.0", "4466a34b7d8e871d8164619ba0f3b8410ec782e900f0ae1d3d27a5875a29532e", [:mix], []}, 13 | "phoenix": {:hex, :phoenix, "1.2.0", "1bdeb99c254f4c534cdf98fd201dede682297ccc62fcac5d57a2627c3b6681fb", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, optional: false]}, {:plug, "~> 1.1", [hex: :plug, optional: false]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: false]}]}, 14 | "phoenix_ecto": {:hex, :phoenix_ecto, "3.0.0", "b947aaf03d076f5b1448f87828f22fb7710478ee38455c67cc3fe8e9a4dfd015", [:mix], [{:ecto, "~> 2.0.0-rc", [hex: :ecto, optional: false]}, {:phoenix_html, "~> 2.6", [hex: :phoenix_html, optional: true]}]}, 15 | "phoenix_html": {:hex, :phoenix_html, "2.6.2", "944a5e581b0d899e4f4c838a69503ebd05300fe35ba228a74439e6253e10e0c0", [:mix], [{:plug, "~> 1.0", [hex: :plug, optional: false]}]}, 16 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.0.5", "829218c4152ba1e9848e2bf8e161fcde6b4ec679a516259442561d21fde68d0b", [:mix], [{:fs, "~> 0.9.1", [hex: :fs, optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2-rc", [hex: :phoenix, optional: false]}]}, 17 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.0", "c31af4be22afeeebfaf246592778c8c840e5a1ddc7ca87610c41ccfb160c2c57", [:mix], []}, 18 | "pipe": {:hex, :pipe, "0.0.2", "eff98a868b426745acef103081581093ff5c1b88100f8ff5949b4a30e81d0d9f", [:mix], []}, 19 | "plug": {:hex, :plug, "1.1.6", "8927e4028433fcb859e000b9389ee9c37c80eb28378eeeea31b0273350bf668b", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}]}, 20 | "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], []}, 21 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, 22 | "postgrex": {:hex, :postgrex, "0.11.2", "139755c1359d3c5c6d6e8b1ea72556d39e2746f61c6ddfb442813c91f53487e8", [:mix], [{:connection, "~> 1.0", [hex: :connection, optional: false]}, {:db_connection, "~> 1.0-rc", [hex: :db_connection, optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}]}, 23 | "ranch": {:hex, :ranch, "1.2.1", "a6fb992c10f2187b46ffd17ce398ddf8a54f691b81768f9ef5f461ea7e28c762", [:make], []}, 24 | "uuid": {:hex, :uuid, "1.1.4", "36c7734e4c8e357f2f67ba57fb61799d60c20a7f817b104896cca64b857e3686", [:mix], []}} 25 | -------------------------------------------------------------------------------- /test/web/controllers/account_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.AccountControllerTest do 2 | use Entice.Web.ConnCase 3 | alias Entice.Web.Invitation 4 | 5 | setup context do 6 | result = %{email: "root@entice.ps", password: "root" } 7 | result = case context.id do 8 | 1 -> Map.put(result, :params, %{char_name: "name does not exist"}) 9 | 2 -> Map.put(result, :params, %{char_name: "Root Root A"}) 10 | 3 -> Map.put(result, :params, %{email: "root@entice.ps"}) 11 | 4 -> 12 | email = "email@email.com" 13 | key = UUID.uuid4() 14 | %Invitation{email: email, key: key} |> Entice.Web.Repo.insert 15 | Map.put(result, :params, %{email: email, key: key}) 16 | 5 -> Map.put(result, :params, %{email: "new_email@email.com"}) 17 | 6 -> Map.put(result, :params, %{email: "was not invited", password: "p", invite_key: "k"}) 18 | 7 -> 19 | email = "was invited" 20 | password = "p" 21 | invite_key = "wrong key" 22 | key = UUID.uuid4() 23 | %Invitation{email: email, key: key} |> Entice.Web.Repo.insert 24 | Map.put(result, :params, %{email: email, password: password, invite_key: invite_key}) 25 | 8 -> 26 | email = "was invited too" 27 | password = "p" 28 | key = UUID.uuid4() 29 | %Invitation{email: email, key: key} |> Entice.Web.Repo.insert 30 | Map.put(result, :params, %{email: email, password: password, invite_key: key}) 31 | _ -> Map.put(result, :params, %{}) 32 | end 33 | {:ok, result} 34 | end 35 | 36 | @tag id: 1 37 | test "by_char_name wrong char name", context do 38 | {:ok, result} = fetch_route(:get, "/api/account/by_char_name", context) 39 | 40 | assert result["status"] == "error", "by_char_name should have failed but didn't." 41 | assert result["message"] == "Couldn't find character.", "by_char_name did not fail in the expected way." 42 | end 43 | 44 | @tag id: 2 45 | test "by_char_name existing char name", context do 46 | {:ok, result} = fetch_route(:get, "/api/account/by_char_name", context) 47 | 48 | assert result["status"] == "ok", "by_char_name should have succeeded but didn't." 49 | end 50 | 51 | @tag id: 3 52 | test "request_invite email already in use", context do 53 | {:ok, result} = fetch_route(:post, "/api/account/request", context) 54 | 55 | assert result["status"] == "error", "request_invite should have failed but didn't." 56 | assert result["message"] == "This Email address is already in use", "request_invite did not fail in the expected way." 57 | end 58 | 59 | @tag id: 4 60 | test "request_invite already invited", context do 61 | {:ok, result} = fetch_route(:post, "/api/account/request", context) 62 | 63 | assert result["status"] == "ok", "request_invite should have succeeded but didn't." 64 | assert result["message"] == "Invite exists already", "request_invite did not succeed in the expected way." 65 | assert result["key"] == context.params.key, "request_invite did not return the right key." 66 | assert result["email"] == context.params.email, "request_invite did not return the right email." 67 | end 68 | 69 | @tag id: 5 70 | test "request_invite correct parameters", context do 71 | {:ok, result} = fetch_route(:post, "/api/account/request", context) 72 | 73 | assert result["status"] == "ok", "request_invite should have succeeded but didn't." 74 | assert result["message"] == "Invite Created", "request_invite did not succeed in the expected way." 75 | assert result["email"] == context.params.email, "request_invite did not return the right email." 76 | end 77 | 78 | @tag id: 6 79 | test "register no invite", context do 80 | {:ok, result} = fetch_route(:post, "/api/account/register", context) 81 | 82 | assert result["status"] == "error", "register should have failed but didn't." 83 | assert result["message"] == "No Invitation found for this Email", "register did not fail in the expected way." 84 | end 85 | 86 | @tag id: 7 87 | test "register wrong key", context do 88 | {:ok, result} = fetch_route(:post, "/api/account/register", context) 89 | 90 | assert result["status"] == "error", "register should have failed but didn't." 91 | assert result["message"] == "Invalid Key!", "register did not fail in the expected way." 92 | end 93 | 94 | @tag id: 8 95 | test "register correct parameters", context do 96 | {:ok, result} = fetch_route(:post, "/api/account/register", context) 97 | 98 | assert result["status"] == "ok", "register should have succeeded but didn't." 99 | assert result["message"] == "Account created!", "register did not succeed in the expected way." 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /web/channels/entity_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Entice.Web.EntityChannel do 2 | use Entice.Web.Web, :channel 3 | use Entice.Logic.Attributes 4 | alias Entice.Utils.StructOps 5 | alias Entice.Entity 6 | alias Entice.Entity.Coordination 7 | alias Entice.Logic.{Maps, Npc} 8 | alias Entice.Web.{Endpoint, Token} 9 | alias Phoenix.Socket 10 | 11 | @all_reported_attributes [ 12 | Position, 13 | Name, 14 | Appearance, 15 | Health, 16 | Energy, 17 | Morale, 18 | Level, 19 | Npc] 20 | 21 | @initally_reported_attributes [ 22 | Position] 23 | 24 | @continually_reported_attributes @all_reported_attributes -- @initally_reported_attributes 25 | 26 | 27 | def join("entity:" <> map, _message, %Socket{assigns: %{map: map_mod}} = socket) do 28 | {:ok, ^map_mod} = Maps.get_map(camelize(map)) 29 | Process.flag(:trap_exit, true) 30 | send(self, :after_join) 31 | {:ok, socket} 32 | end 33 | 34 | 35 | def handle_info(:after_join, socket) do 36 | Coordination.register_observer(self, socket |> map) 37 | attrs = socket |> entity_id |> Entity.take_attributes(@all_reported_attributes) 38 | socket |> push("initial", %{attributes: process_attributes(attrs, @all_reported_attributes)}) 39 | {:noreply, socket} 40 | end 41 | 42 | 43 | # Internal events 44 | 45 | 46 | @doc "If this entity leaves, disconnect all sockets, and shut this down as well" 47 | def handle_info({:entity_leave, %{entity_id: eid}}, %Socket{assigns: %{entity_id: eid}} = socket) do 48 | Endpoint.broadcast(Entice.Web.Socket.id(socket), "disconnect", %{}) 49 | {:stop, :normal, socket} 50 | end 51 | 52 | 53 | @doc "Filter out 'join' messages of this entity, which we'd get after registering as an observer" 54 | def handle_info({:entity_join, %{entity_id: eid}}, %Socket{assigns: %{entity_id: eid}} = socket), do: {:noreply, socket} 55 | 56 | 57 | def handle_info({:entity_join, %{entity_id: entity_id, attributes: attrs}}, socket) do 58 | res = process_attributes(attrs, @all_reported_attributes) 59 | if not Enum.empty?(res) do 60 | socket |> push("add", %{ 61 | entity: entity_id, 62 | attributes: res}) 63 | end 64 | {:noreply, socket} 65 | end 66 | 67 | 68 | def handle_info({:entity_leave, %{entity_id: entity_id, attributes: _attrs}}, socket) do 69 | socket |> push("remove", %{entity: entity_id}) 70 | {:noreply, socket} 71 | end 72 | 73 | 74 | def handle_info({:entity_change, %{ 75 | entity_id: id, 76 | added: added, 77 | changed: changed, 78 | removed: removed}}, socket) do 79 | res = [ 80 | process_attributes(added, @all_reported_attributes), 81 | process_attributes(changed, @continually_reported_attributes), 82 | process_attributes(removed, @all_reported_attributes)] 83 | if res |> Enum.any?(&(not Enum.empty?(&1))) do 84 | socket |> push("change", %{ 85 | entity: id, 86 | added: res |> Enum.at(0), 87 | changed: res |> Enum.at(1), 88 | removed: res |> Enum.at(2)}) 89 | end 90 | {:noreply, socket} 91 | end 92 | 93 | 94 | def handle_info(_msg, socket), do: {:noreply, socket} 95 | 96 | 97 | # Incoming from the net 98 | 99 | 100 | def handle_in("map:change", %{"map" => map}, socket) do 101 | reply = case Maps.get_map(camelize(map)) do 102 | {:ok, map_mod} -> 103 | {:ok, _token} = Token.create_mapchange_token(socket |> client_id, %{ 104 | entity_id: socket |> entity_id, 105 | map: map_mod, 106 | char: socket |> character}) 107 | 108 | Endpoint.plain_broadcast(Entice.Web.Socket.id(socket), {:entity_mapchange, %{map: map_mod}}) 109 | {:ok, %{map: map}} 110 | 111 | _ -> {:error, %{reason: :unknown_map}} 112 | end 113 | 114 | {:reply, reply, socket} 115 | end 116 | 117 | 118 | # Leaving the socket (voluntarily or forcefully) 119 | 120 | 121 | def terminate(_msg, socket) do 122 | Entity.stop(socket |> entity_id) 123 | :ok 124 | end 125 | 126 | 127 | # Internal 128 | 129 | 130 | # Transform attributes to network transferable maps 131 | defp process_attributes(attributes, filter) when is_map(attributes) do 132 | attributes 133 | |> Map.keys 134 | |> Enum.filter_map( 135 | fn (attr) -> attr in filter end, 136 | fn (attr) -> attributes[attr] |> attribute_to_tuple end) 137 | |> Enum.into(%{}) 138 | end 139 | 140 | defp process_attributes(attributes, filter) when is_list(attributes) do 141 | attributes 142 | |> Enum.filter_map( 143 | fn (attr) -> attr in filter end, 144 | &StructOps.to_underscore_name/1) 145 | end 146 | 147 | 148 | # Maps an attribute to a network-transferable tuple 149 | defp attribute_to_tuple(%Position{pos: pos, plane: plane} = attr), 150 | do: {attr |> StructOps.to_underscore_name, Map.from_struct(pos) |> Map.put(:plane, plane)} 151 | 152 | defp attribute_to_tuple(%Name{name: name} = attr), 153 | do: {attr |> StructOps.to_underscore_name, name} 154 | 155 | defp attribute_to_tuple(%Appearance{} = attr), 156 | do: {attr |> StructOps.to_underscore_name, Map.from_struct(attr)} 157 | 158 | defp attribute_to_tuple(%Health{} = attr), 159 | do: {attr |> StructOps.to_underscore_name, Map.from_struct(attr)} 160 | 161 | defp attribute_to_tuple(%Energy{} = attr), 162 | do: {attr |> StructOps.to_underscore_name, Map.from_struct(attr)} 163 | 164 | defp attribute_to_tuple(%Morale{morale: morale} = attr), 165 | do: {attr |> StructOps.to_underscore_name, morale} 166 | 167 | defp attribute_to_tuple(%Level{level: lvl} = attr), 168 | do: {attr |> StructOps.to_underscore_name, lvl} 169 | 170 | defp attribute_to_tuple(%Npc{npc_model_id: npc_model_id} = attr), 171 | do: {attr |> StructOps.to_underscore_name, npc_model_id} 172 | end 173 | -------------------------------------------------------------------------------- /priv/static/js/phoenix.js: -------------------------------------------------------------------------------- 1 | (function(exports){ 2 | "use strict"; 3 | 4 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; }; 5 | 6 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 7 | 8 | Object.defineProperty(exports, "__esModule", { 9 | value: true 10 | }); 11 | 12 | function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } 13 | 14 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 15 | 16 | // Phoenix Channels JavaScript client 17 | // 18 | // ## Socket Connection 19 | // 20 | // A single connection is established to the server and 21 | // channels are multiplexed over the connection. 22 | // Connect to the server using the `Socket` class: 23 | // 24 | // let socket = new Socket("/ws", {params: {userToken: "123"}}) 25 | // socket.connect() 26 | // 27 | // The `Socket` constructor takes the mount point of the socket, 28 | // the authentication params, as well as options that can be found in 29 | // the Socket docs, such as configuring the `LongPoll` transport, and 30 | // heartbeat. 31 | // 32 | // ## Channels 33 | // 34 | // Channels are isolated, concurrent processes on the server that 35 | // subscribe to topics and broker events between the client and server. 36 | // To join a channel, you must provide the topic, and channel params for 37 | // authorization. Here's an example chat room example where `"new_msg"` 38 | // events are listened for, messages are pushed to the server, and 39 | // the channel is joined with ok/error/timeout matches: 40 | // 41 | // let channel = socket.channel("rooms:123", {token: roomToken}) 42 | // channel.on("new_msg", msg => console.log("Got message", msg) ) 43 | // $input.onEnter( e => { 44 | // channel.push("new_msg", {body: e.target.val}, 10000) 45 | // .receive("ok", (msg) => console.log("created message", msg) ) 46 | // .receive("error", (reasons) => console.log("create failed", reasons) ) 47 | // .receive("timeout", () => console.log("Networking issue...") ) 48 | // }) 49 | // channel.join() 50 | // .receive("ok", ({messages}) => console.log("catching up", messages) ) 51 | // .receive("error", ({reason}) => console.log("failed join", reason) ) 52 | // .receive("timeout", () => console.log("Networking issue. Still waiting...") ) 53 | // 54 | // 55 | // ## Joining 56 | // 57 | // Creating a channel with `socket.channel(topic, params)`, binds the params to 58 | // `channel.params`, which are sent up on `channel.join()`. 59 | // Subsequent rejoins will send up the modified params for 60 | // updating authorization params, or passing up last_message_id information. 61 | // Successful joins receive an "ok" status, while unsuccessful joins 62 | // receive "error". 63 | // 64 | // ## Duplicate Join Subscriptions 65 | // 66 | // While the client may join any number of topics on any number of channels, 67 | // the client may only hold a single subscription for each unique topic at any 68 | // given time. When attempting to create a duplicate subscription, 69 | // the server will close the existing channel, log a warning, and 70 | // spawn a new channel for the topic. The client will have their 71 | // `channel.onClose` callbacks fired for the existing channel, and the new 72 | // channel join will have its receive hooks processed as normal. 73 | // 74 | // ## Pushing Messages 75 | // 76 | // From the previous example, we can see that pushing messages to the server 77 | // can be done with `channel.push(eventName, payload)` and we can optionally 78 | // receive responses from the push. Additionally, we can use 79 | // `receive("timeout", callback)` to abort waiting for our other `receive` hooks 80 | // and take action after some period of waiting. The default timeout is 5000ms. 81 | // 82 | // 83 | // ## Socket Hooks 84 | // 85 | // Lifecycle events of the multiplexed connection can be hooked into via 86 | // `socket.onError()` and `socket.onClose()` events, ie: 87 | // 88 | // socket.onError( () => console.log("there was an error with the connection!") ) 89 | // socket.onClose( () => console.log("the connection dropped") ) 90 | // 91 | // 92 | // ## Channel Hooks 93 | // 94 | // For each joined channel, you can bind to `onError` and `onClose` events 95 | // to monitor the channel lifecycle, ie: 96 | // 97 | // channel.onError( () => console.log("there was an error!") ) 98 | // channel.onClose( () => console.log("the channel has gone away gracefully") ) 99 | // 100 | // ### onError hooks 101 | // 102 | // `onError` hooks are invoked if the socket connection drops, or the channel 103 | // crashes on the server. In either case, a channel rejoin is attempted 104 | // automatically in an exponential backoff manner. 105 | // 106 | // ### onClose hooks 107 | // 108 | // `onClose` hooks are invoked only in two cases. 1) the channel explicitly 109 | // closed on the server, or 2). The client explicitly closed, by calling 110 | // `channel.leave()` 111 | // 112 | // 113 | // ## Presence 114 | // 115 | // The `Presence` object provides features for syncing presence information 116 | // from the server with the client and handling presences joining and leaving. 117 | // 118 | // ### Syncing initial state from the server 119 | // 120 | // `Presence.syncState` is used to sync the list of presences on the server 121 | // with the client's state. An optional `onJoin` and `onLeave` callback can 122 | // be provided to react to changes in the client's local presences across 123 | // disconnects and reconnects with the server. 124 | // 125 | // `Presence.syncDiff` is used to sync a diff of presence join and leave 126 | // events from the server, as they happen. Like `syncState`, `syncDiff` 127 | // accepts optional `onJoin` and `onLeave` callbacks to react to a user 128 | // joining or leaving from a device. 129 | // 130 | // ### Listing Presences 131 | // 132 | // `Presence.list` is used to return a list of presence information 133 | // based on the local state of metadata. By default, all presence 134 | // metadata is returned, but a `listBy` function can be supplied to 135 | // allow the client to select which metadata to use for a given presence. 136 | // For example, you may have a user online from different devices with a 137 | // a metadata status of "online", but they have set themselves to "away" 138 | // on another device. In this case, they app may choose to use the "away" 139 | // status for what appears on the UI. The example below defines a `listBy` 140 | // function which prioritizes the first metadata which was registered for 141 | // each user. This could be the first tab they opened, or the first device 142 | // they came online from: 143 | // 144 | // let state = {} 145 | // Presence.syncState(state, stateFromServer) 146 | // let listBy = (id, {metas: [first, ...rest]}) => { 147 | // first.count = rest.length + 1 // count of this user's presences 148 | // first.id = id 149 | // return first 150 | // } 151 | // let onlineUsers = Presence.list(state, listBy) 152 | // 153 | // 154 | // ### Example Usage 155 | // 156 | // // detect if user has joined for the 1st time or from another tab/device 157 | // let onJoin = (id, current, newPres) => { 158 | // if(!current){ 159 | // console.log("user has entered for the first time", newPres) 160 | // } else { 161 | // console.log("user additional presence", newPres) 162 | // } 163 | // } 164 | // // detect if user has left from all tabs/devices, or is still present 165 | // let onLeave = (id, current, leftPres) => { 166 | // if(current.metas.length === 0){ 167 | // console.log("user has left from all devices", leftPres) 168 | // } else { 169 | // console.log("user left from a device", leftPres) 170 | // } 171 | // } 172 | // let presences = {} // client's initial empty presence state 173 | // // receive initial presence data from server, sent after join 174 | // myChannel.on("presences", state => { 175 | // Presence.syncState(presences, state, onJoin, onLeave) 176 | // displayUsers(Presence.list(presences)) 177 | // }) 178 | // // receive "presence_diff" from server, containing join/leave events 179 | // myChannel.on("presence_diff", diff => { 180 | // Presence.syncDiff(presences, diff, onJoin, onLeave) 181 | // this.setState({users: Presence.list(room.presences, listBy)}) 182 | // }) 183 | // 184 | var VSN = "1.0.0"; 185 | var SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 }; 186 | var DEFAULT_TIMEOUT = 10000; 187 | var CHANNEL_STATES = { 188 | closed: "closed", 189 | errored: "errored", 190 | joined: "joined", 191 | joining: "joining", 192 | leaving: "leaving" 193 | }; 194 | var CHANNEL_EVENTS = { 195 | close: "phx_close", 196 | error: "phx_error", 197 | join: "phx_join", 198 | reply: "phx_reply", 199 | leave: "phx_leave" 200 | }; 201 | var TRANSPORTS = { 202 | longpoll: "longpoll", 203 | websocket: "websocket" 204 | }; 205 | 206 | var Push = function () { 207 | 208 | // Initializes the Push 209 | // 210 | // channel - The Channel 211 | // event - The event, for example `"phx_join"` 212 | // payload - The payload, for example `{user_id: 123}` 213 | // timeout - The push timeout in milliseconds 214 | // 215 | 216 | function Push(channel, event, payload, timeout) { 217 | _classCallCheck(this, Push); 218 | 219 | this.channel = channel; 220 | this.event = event; 221 | this.payload = payload || {}; 222 | this.receivedResp = null; 223 | this.timeout = timeout; 224 | this.timeoutTimer = null; 225 | this.recHooks = []; 226 | this.sent = false; 227 | } 228 | 229 | _createClass(Push, [{ 230 | key: "resend", 231 | value: function resend(timeout) { 232 | this.timeout = timeout; 233 | this.cancelRefEvent(); 234 | this.ref = null; 235 | this.refEvent = null; 236 | this.receivedResp = null; 237 | this.sent = false; 238 | this.send(); 239 | } 240 | }, { 241 | key: "send", 242 | value: function send() { 243 | if (this.hasReceived("timeout")) { 244 | return; 245 | } 246 | this.startTimeout(); 247 | this.sent = true; 248 | this.channel.socket.push({ 249 | topic: this.channel.topic, 250 | event: this.event, 251 | payload: this.payload, 252 | ref: this.ref 253 | }); 254 | } 255 | }, { 256 | key: "receive", 257 | value: function receive(status, callback) { 258 | if (this.hasReceived(status)) { 259 | callback(this.receivedResp.response); 260 | } 261 | 262 | this.recHooks.push({ status: status, callback: callback }); 263 | return this; 264 | } 265 | 266 | // private 267 | 268 | }, { 269 | key: "matchReceive", 270 | value: function matchReceive(_ref) { 271 | var status = _ref.status; 272 | var response = _ref.response; 273 | var ref = _ref.ref; 274 | 275 | this.recHooks.filter(function (h) { 276 | return h.status === status; 277 | }).forEach(function (h) { 278 | return h.callback(response); 279 | }); 280 | } 281 | }, { 282 | key: "cancelRefEvent", 283 | value: function cancelRefEvent() { 284 | if (!this.refEvent) { 285 | return; 286 | } 287 | this.channel.off(this.refEvent); 288 | } 289 | }, { 290 | key: "cancelTimeout", 291 | value: function cancelTimeout() { 292 | clearTimeout(this.timeoutTimer); 293 | this.timeoutTimer = null; 294 | } 295 | }, { 296 | key: "startTimeout", 297 | value: function startTimeout() { 298 | var _this = this; 299 | 300 | if (this.timeoutTimer) { 301 | return; 302 | } 303 | this.ref = this.channel.socket.makeRef(); 304 | this.refEvent = this.channel.replyEventName(this.ref); 305 | 306 | this.channel.on(this.refEvent, function (payload) { 307 | _this.cancelRefEvent(); 308 | _this.cancelTimeout(); 309 | _this.receivedResp = payload; 310 | _this.matchReceive(payload); 311 | }); 312 | 313 | this.timeoutTimer = setTimeout(function () { 314 | _this.trigger("timeout", {}); 315 | }, this.timeout); 316 | } 317 | }, { 318 | key: "hasReceived", 319 | value: function hasReceived(status) { 320 | return this.receivedResp && this.receivedResp.status === status; 321 | } 322 | }, { 323 | key: "trigger", 324 | value: function trigger(status, response) { 325 | this.channel.trigger(this.refEvent, { status: status, response: response }); 326 | } 327 | }]); 328 | 329 | return Push; 330 | }(); 331 | 332 | var Channel = exports.Channel = function () { 333 | function Channel(topic, params, socket) { 334 | var _this2 = this; 335 | 336 | _classCallCheck(this, Channel); 337 | 338 | this.state = CHANNEL_STATES.closed; 339 | this.topic = topic; 340 | this.params = params || {}; 341 | this.socket = socket; 342 | this.bindings = []; 343 | this.timeout = this.socket.timeout; 344 | this.joinedOnce = false; 345 | this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout); 346 | this.pushBuffer = []; 347 | this.rejoinTimer = new Timer(function () { 348 | return _this2.rejoinUntilConnected(); 349 | }, this.socket.reconnectAfterMs); 350 | this.joinPush.receive("ok", function () { 351 | _this2.state = CHANNEL_STATES.joined; 352 | _this2.rejoinTimer.reset(); 353 | _this2.pushBuffer.forEach(function (pushEvent) { 354 | return pushEvent.send(); 355 | }); 356 | _this2.pushBuffer = []; 357 | }); 358 | this.onClose(function () { 359 | _this2.socket.log("channel", "close " + _this2.topic + " " + _this2.joinRef()); 360 | _this2.state = CHANNEL_STATES.closed; 361 | _this2.socket.remove(_this2); 362 | }); 363 | this.onError(function (reason) { 364 | _this2.socket.log("channel", "error " + _this2.topic, reason); 365 | _this2.state = CHANNEL_STATES.errored; 366 | _this2.rejoinTimer.scheduleTimeout(); 367 | }); 368 | this.joinPush.receive("timeout", function () { 369 | if (_this2.state !== CHANNEL_STATES.joining) { 370 | return; 371 | } 372 | 373 | _this2.socket.log("channel", "timeout " + _this2.topic, _this2.joinPush.timeout); 374 | _this2.state = CHANNEL_STATES.errored; 375 | _this2.rejoinTimer.scheduleTimeout(); 376 | }); 377 | this.on(CHANNEL_EVENTS.reply, function (payload, ref) { 378 | _this2.trigger(_this2.replyEventName(ref), payload); 379 | }); 380 | } 381 | 382 | _createClass(Channel, [{ 383 | key: "rejoinUntilConnected", 384 | value: function rejoinUntilConnected() { 385 | this.rejoinTimer.scheduleTimeout(); 386 | if (this.socket.isConnected()) { 387 | this.rejoin(); 388 | } 389 | } 390 | }, { 391 | key: "join", 392 | value: function join() { 393 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0]; 394 | 395 | if (this.joinedOnce) { 396 | throw "tried to join multiple times. 'join' can only be called a single time per channel instance"; 397 | } else { 398 | this.joinedOnce = true; 399 | this.rejoin(timeout); 400 | return this.joinPush; 401 | } 402 | } 403 | }, { 404 | key: "onClose", 405 | value: function onClose(callback) { 406 | this.on(CHANNEL_EVENTS.close, callback); 407 | } 408 | }, { 409 | key: "onError", 410 | value: function onError(callback) { 411 | this.on(CHANNEL_EVENTS.error, function (reason) { 412 | return callback(reason); 413 | }); 414 | } 415 | }, { 416 | key: "on", 417 | value: function on(event, callback) { 418 | this.bindings.push({ event: event, callback: callback }); 419 | } 420 | }, { 421 | key: "off", 422 | value: function off(event) { 423 | this.bindings = this.bindings.filter(function (bind) { 424 | return bind.event !== event; 425 | }); 426 | } 427 | }, { 428 | key: "canPush", 429 | value: function canPush() { 430 | return this.socket.isConnected() && this.state === CHANNEL_STATES.joined; 431 | } 432 | }, { 433 | key: "push", 434 | value: function push(event, payload) { 435 | var timeout = arguments.length <= 2 || arguments[2] === undefined ? this.timeout : arguments[2]; 436 | 437 | if (!this.joinedOnce) { 438 | throw "tried to push '" + event + "' to '" + this.topic + "' before joining. Use channel.join() before pushing events"; 439 | } 440 | var pushEvent = new Push(this, event, payload, timeout); 441 | if (this.canPush()) { 442 | pushEvent.send(); 443 | } else { 444 | pushEvent.startTimeout(); 445 | this.pushBuffer.push(pushEvent); 446 | } 447 | 448 | return pushEvent; 449 | } 450 | 451 | // Leaves the channel 452 | // 453 | // Unsubscribes from server events, and 454 | // instructs channel to terminate on server 455 | // 456 | // Triggers onClose() hooks 457 | // 458 | // To receive leave acknowledgements, use the a `receive` 459 | // hook to bind to the server ack, ie: 460 | // 461 | // channel.leave().receive("ok", () => alert("left!") ) 462 | // 463 | 464 | }, { 465 | key: "leave", 466 | value: function leave() { 467 | var _this3 = this; 468 | 469 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0]; 470 | 471 | this.state = CHANNEL_STATES.leaving; 472 | var onClose = function onClose() { 473 | _this3.socket.log("channel", "leave " + _this3.topic); 474 | _this3.trigger(CHANNEL_EVENTS.close, "leave", _this3.joinRef()); 475 | }; 476 | var leavePush = new Push(this, CHANNEL_EVENTS.leave, {}, timeout); 477 | leavePush.receive("ok", function () { 478 | return onClose(); 479 | }).receive("timeout", function () { 480 | return onClose(); 481 | }); 482 | leavePush.send(); 483 | if (!this.canPush()) { 484 | leavePush.trigger("ok", {}); 485 | } 486 | 487 | return leavePush; 488 | } 489 | 490 | // Overridable message hook 491 | // 492 | // Receives all events for specialized message handling 493 | 494 | }, { 495 | key: "onMessage", 496 | value: function onMessage(event, payload, ref) {} 497 | 498 | // private 499 | 500 | }, { 501 | key: "isMember", 502 | value: function isMember(topic) { 503 | return this.topic === topic; 504 | } 505 | }, { 506 | key: "joinRef", 507 | value: function joinRef() { 508 | return this.joinPush.ref; 509 | } 510 | }, { 511 | key: "sendJoin", 512 | value: function sendJoin(timeout) { 513 | this.state = CHANNEL_STATES.joining; 514 | this.joinPush.resend(timeout); 515 | } 516 | }, { 517 | key: "rejoin", 518 | value: function rejoin() { 519 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0]; 520 | if (this.state === CHANNEL_STATES.leaving) { 521 | return; 522 | } 523 | this.sendJoin(timeout); 524 | } 525 | }, { 526 | key: "trigger", 527 | value: function trigger(event, payload, ref) { 528 | var close = CHANNEL_EVENTS.close; 529 | var error = CHANNEL_EVENTS.error; 530 | var leave = CHANNEL_EVENTS.leave; 531 | var join = CHANNEL_EVENTS.join; 532 | 533 | if (ref && [close, error, leave, join].indexOf(event) >= 0 && ref !== this.joinRef()) { 534 | return; 535 | } 536 | this.onMessage(event, payload, ref); 537 | this.bindings.filter(function (bind) { 538 | return bind.event === event; 539 | }).map(function (bind) { 540 | return bind.callback(payload, ref); 541 | }); 542 | } 543 | }, { 544 | key: "replyEventName", 545 | value: function replyEventName(ref) { 546 | return "chan_reply_" + ref; 547 | } 548 | }]); 549 | 550 | return Channel; 551 | }(); 552 | 553 | var Socket = exports.Socket = function () { 554 | 555 | // Initializes the Socket 556 | // 557 | // endPoint - The string WebSocket endpoint, ie, "ws://example.com/ws", 558 | // "wss://example.com" 559 | // "/ws" (inherited host & protocol) 560 | // opts - Optional configuration 561 | // transport - The Websocket Transport, for example WebSocket or Phoenix.LongPoll. 562 | // Defaults to WebSocket with automatic LongPoll fallback. 563 | // timeout - The default timeout in milliseconds to trigger push timeouts. 564 | // Defaults `DEFAULT_TIMEOUT` 565 | // heartbeatIntervalMs - The millisec interval to send a heartbeat message 566 | // reconnectAfterMs - The optional function that returns the millsec 567 | // reconnect interval. Defaults to stepped backoff of: 568 | // 569 | // function(tries){ 570 | // return [1000, 5000, 10000][tries - 1] || 10000 571 | // } 572 | // 573 | // logger - The optional function for specialized logging, ie: 574 | // `logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) } 575 | // 576 | // longpollerTimeout - The maximum timeout of a long poll AJAX request. 577 | // Defaults to 20s (double the server long poll timer). 578 | // 579 | // params - The optional params to pass when connecting 580 | // 581 | // For IE8 support use an ES5-shim (https://github.com/es-shims/es5-shim) 582 | // 583 | 584 | function Socket(endPoint) { 585 | var _this4 = this; 586 | 587 | var opts = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; 588 | 589 | _classCallCheck(this, Socket); 590 | 591 | this.stateChangeCallbacks = { open: [], close: [], error: [], message: [] }; 592 | this.channels = []; 593 | this.sendBuffer = []; 594 | this.ref = 0; 595 | this.timeout = opts.timeout || DEFAULT_TIMEOUT; 596 | this.transport = opts.transport || window.WebSocket || LongPoll; 597 | this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000; 598 | this.reconnectAfterMs = opts.reconnectAfterMs || function (tries) { 599 | return [1000, 2000, 5000, 10000][tries - 1] || 10000; 600 | }; 601 | this.logger = opts.logger || function () {}; // noop 602 | this.longpollerTimeout = opts.longpollerTimeout || 20000; 603 | this.params = opts.params || {}; 604 | this.endPoint = endPoint + "/" + TRANSPORTS.websocket; 605 | this.reconnectTimer = new Timer(function () { 606 | _this4.disconnect(function () { 607 | return _this4.connect(); 608 | }); 609 | }, this.reconnectAfterMs); 610 | } 611 | 612 | _createClass(Socket, [{ 613 | key: "protocol", 614 | value: function protocol() { 615 | return location.protocol.match(/^https/) ? "wss" : "ws"; 616 | } 617 | }, { 618 | key: "endPointURL", 619 | value: function endPointURL() { 620 | var uri = Ajax.appendParams(Ajax.appendParams(this.endPoint, this.params), { vsn: VSN }); 621 | if (uri.charAt(0) !== "/") { 622 | return uri; 623 | } 624 | if (uri.charAt(1) === "/") { 625 | return this.protocol() + ":" + uri; 626 | } 627 | 628 | return this.protocol() + "://" + location.host + uri; 629 | } 630 | }, { 631 | key: "disconnect", 632 | value: function disconnect(callback, code, reason) { 633 | if (this.conn) { 634 | this.conn.onclose = function () {}; // noop 635 | if (code) { 636 | this.conn.close(code, reason || ""); 637 | } else { 638 | this.conn.close(); 639 | } 640 | this.conn = null; 641 | } 642 | callback && callback(); 643 | } 644 | 645 | // params - The params to send when connecting, for example `{user_id: userToken}` 646 | 647 | }, { 648 | key: "connect", 649 | value: function connect(params) { 650 | var _this5 = this; 651 | 652 | if (params) { 653 | console && console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor"); 654 | this.params = params; 655 | } 656 | if (this.conn) { 657 | return; 658 | } 659 | 660 | this.conn = new this.transport(this.endPointURL()); 661 | this.conn.timeout = this.longpollerTimeout; 662 | this.conn.onopen = function () { 663 | return _this5.onConnOpen(); 664 | }; 665 | this.conn.onerror = function (error) { 666 | return _this5.onConnError(error); 667 | }; 668 | this.conn.onmessage = function (event) { 669 | return _this5.onConnMessage(event); 670 | }; 671 | this.conn.onclose = function (event) { 672 | return _this5.onConnClose(event); 673 | }; 674 | } 675 | 676 | // Logs the message. Override `this.logger` for specialized logging. noops by default 677 | 678 | }, { 679 | key: "log", 680 | value: function log(kind, msg, data) { 681 | this.logger(kind, msg, data); 682 | } 683 | 684 | // Registers callbacks for connection state change events 685 | // 686 | // Examples 687 | // 688 | // socket.onError(function(error){ alert("An error occurred") }) 689 | // 690 | 691 | }, { 692 | key: "onOpen", 693 | value: function onOpen(callback) { 694 | this.stateChangeCallbacks.open.push(callback); 695 | } 696 | }, { 697 | key: "onClose", 698 | value: function onClose(callback) { 699 | this.stateChangeCallbacks.close.push(callback); 700 | } 701 | }, { 702 | key: "onError", 703 | value: function onError(callback) { 704 | this.stateChangeCallbacks.error.push(callback); 705 | } 706 | }, { 707 | key: "onMessage", 708 | value: function onMessage(callback) { 709 | this.stateChangeCallbacks.message.push(callback); 710 | } 711 | }, { 712 | key: "onConnOpen", 713 | value: function onConnOpen() { 714 | var _this6 = this; 715 | 716 | this.log("transport", "connected to " + this.endPointURL(), this.transport.prototype); 717 | this.flushSendBuffer(); 718 | this.reconnectTimer.reset(); 719 | if (!this.conn.skipHeartbeat) { 720 | clearInterval(this.heartbeatTimer); 721 | this.heartbeatTimer = setInterval(function () { 722 | return _this6.sendHeartbeat(); 723 | }, this.heartbeatIntervalMs); 724 | } 725 | this.stateChangeCallbacks.open.forEach(function (callback) { 726 | return callback(); 727 | }); 728 | } 729 | }, { 730 | key: "onConnClose", 731 | value: function onConnClose(event) { 732 | this.log("transport", "close", event); 733 | this.triggerChanError(); 734 | clearInterval(this.heartbeatTimer); 735 | this.reconnectTimer.scheduleTimeout(); 736 | this.stateChangeCallbacks.close.forEach(function (callback) { 737 | return callback(event); 738 | }); 739 | } 740 | }, { 741 | key: "onConnError", 742 | value: function onConnError(error) { 743 | this.log("transport", error); 744 | this.triggerChanError(); 745 | this.stateChangeCallbacks.error.forEach(function (callback) { 746 | return callback(error); 747 | }); 748 | } 749 | }, { 750 | key: "triggerChanError", 751 | value: function triggerChanError() { 752 | this.channels.forEach(function (channel) { 753 | return channel.trigger(CHANNEL_EVENTS.error); 754 | }); 755 | } 756 | }, { 757 | key: "connectionState", 758 | value: function connectionState() { 759 | switch (this.conn && this.conn.readyState) { 760 | case SOCKET_STATES.connecting: 761 | return "connecting"; 762 | case SOCKET_STATES.open: 763 | return "open"; 764 | case SOCKET_STATES.closing: 765 | return "closing"; 766 | default: 767 | return "closed"; 768 | } 769 | } 770 | }, { 771 | key: "isConnected", 772 | value: function isConnected() { 773 | return this.connectionState() === "open"; 774 | } 775 | }, { 776 | key: "remove", 777 | value: function remove(channel) { 778 | this.channels = this.channels.filter(function (c) { 779 | return c.joinRef() !== channel.joinRef(); 780 | }); 781 | } 782 | }, { 783 | key: "channel", 784 | value: function channel(topic) { 785 | var chanParams = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; 786 | 787 | var chan = new Channel(topic, chanParams, this); 788 | this.channels.push(chan); 789 | return chan; 790 | } 791 | }, { 792 | key: "push", 793 | value: function push(data) { 794 | var _this7 = this; 795 | 796 | var topic = data.topic; 797 | var event = data.event; 798 | var payload = data.payload; 799 | var ref = data.ref; 800 | 801 | var callback = function callback() { 802 | return _this7.conn.send(JSON.stringify(data)); 803 | }; 804 | this.log("push", topic + " " + event + " (" + ref + ")", payload); 805 | if (this.isConnected()) { 806 | callback(); 807 | } else { 808 | this.sendBuffer.push(callback); 809 | } 810 | } 811 | 812 | // Return the next message ref, accounting for overflows 813 | 814 | }, { 815 | key: "makeRef", 816 | value: function makeRef() { 817 | var newRef = this.ref + 1; 818 | if (newRef === this.ref) { 819 | this.ref = 0; 820 | } else { 821 | this.ref = newRef; 822 | } 823 | 824 | return this.ref.toString(); 825 | } 826 | }, { 827 | key: "sendHeartbeat", 828 | value: function sendHeartbeat() { 829 | if (!this.isConnected()) { 830 | return; 831 | } 832 | this.push({ topic: "phoenix", event: "heartbeat", payload: {}, ref: this.makeRef() }); 833 | } 834 | }, { 835 | key: "flushSendBuffer", 836 | value: function flushSendBuffer() { 837 | if (this.isConnected() && this.sendBuffer.length > 0) { 838 | this.sendBuffer.forEach(function (callback) { 839 | return callback(); 840 | }); 841 | this.sendBuffer = []; 842 | } 843 | } 844 | }, { 845 | key: "onConnMessage", 846 | value: function onConnMessage(rawMessage) { 847 | var msg = JSON.parse(rawMessage.data); 848 | var topic = msg.topic; 849 | var event = msg.event; 850 | var payload = msg.payload; 851 | var ref = msg.ref; 852 | 853 | this.log("receive", (payload.status || "") + " " + topic + " " + event + " " + (ref && "(" + ref + ")" || ""), payload); 854 | this.channels.filter(function (channel) { 855 | return channel.isMember(topic); 856 | }).forEach(function (channel) { 857 | return channel.trigger(event, payload, ref); 858 | }); 859 | this.stateChangeCallbacks.message.forEach(function (callback) { 860 | return callback(msg); 861 | }); 862 | } 863 | }]); 864 | 865 | return Socket; 866 | }(); 867 | 868 | var LongPoll = exports.LongPoll = function () { 869 | function LongPoll(endPoint) { 870 | _classCallCheck(this, LongPoll); 871 | 872 | this.endPoint = null; 873 | this.token = null; 874 | this.skipHeartbeat = true; 875 | this.onopen = function () {}; // noop 876 | this.onerror = function () {}; // noop 877 | this.onmessage = function () {}; // noop 878 | this.onclose = function () {}; // noop 879 | this.pollEndpoint = this.normalizeEndpoint(endPoint); 880 | this.readyState = SOCKET_STATES.connecting; 881 | 882 | this.poll(); 883 | } 884 | 885 | _createClass(LongPoll, [{ 886 | key: "normalizeEndpoint", 887 | value: function normalizeEndpoint(endPoint) { 888 | return endPoint.replace("ws://", "http://").replace("wss://", "https://").replace(new RegExp("(.*)\/" + TRANSPORTS.websocket), "$1/" + TRANSPORTS.longpoll); 889 | } 890 | }, { 891 | key: "endpointURL", 892 | value: function endpointURL() { 893 | return Ajax.appendParams(this.pollEndpoint, { token: this.token }); 894 | } 895 | }, { 896 | key: "closeAndRetry", 897 | value: function closeAndRetry() { 898 | this.close(); 899 | this.readyState = SOCKET_STATES.connecting; 900 | } 901 | }, { 902 | key: "ontimeout", 903 | value: function ontimeout() { 904 | this.onerror("timeout"); 905 | this.closeAndRetry(); 906 | } 907 | }, { 908 | key: "poll", 909 | value: function poll() { 910 | var _this8 = this; 911 | 912 | if (!(this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting)) { 913 | return; 914 | } 915 | 916 | Ajax.request("GET", this.endpointURL(), "application/json", null, this.timeout, this.ontimeout.bind(this), function (resp) { 917 | if (resp) { 918 | var status = resp.status; 919 | var token = resp.token; 920 | var messages = resp.messages; 921 | 922 | _this8.token = token; 923 | } else { 924 | var status = 0; 925 | } 926 | 927 | switch (status) { 928 | case 200: 929 | messages.forEach(function (msg) { 930 | return _this8.onmessage({ data: JSON.stringify(msg) }); 931 | }); 932 | _this8.poll(); 933 | break; 934 | case 204: 935 | _this8.poll(); 936 | break; 937 | case 410: 938 | _this8.readyState = SOCKET_STATES.open; 939 | _this8.onopen(); 940 | _this8.poll(); 941 | break; 942 | case 0: 943 | case 500: 944 | _this8.onerror(); 945 | _this8.closeAndRetry(); 946 | break; 947 | default: 948 | throw "unhandled poll status " + status; 949 | } 950 | }); 951 | } 952 | }, { 953 | key: "send", 954 | value: function send(body) { 955 | var _this9 = this; 956 | 957 | Ajax.request("POST", this.endpointURL(), "application/json", body, this.timeout, this.onerror.bind(this, "timeout"), function (resp) { 958 | if (!resp || resp.status !== 200) { 959 | _this9.onerror(status); 960 | _this9.closeAndRetry(); 961 | } 962 | }); 963 | } 964 | }, { 965 | key: "close", 966 | value: function close(code, reason) { 967 | this.readyState = SOCKET_STATES.closed; 968 | this.onclose(); 969 | } 970 | }]); 971 | 972 | return LongPoll; 973 | }(); 974 | 975 | var Ajax = exports.Ajax = function () { 976 | function Ajax() { 977 | _classCallCheck(this, Ajax); 978 | } 979 | 980 | _createClass(Ajax, null, [{ 981 | key: "request", 982 | value: function request(method, endPoint, accept, body, timeout, ontimeout, callback) { 983 | if (window.XDomainRequest) { 984 | var req = new XDomainRequest(); // IE8, IE9 985 | this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback); 986 | } else { 987 | var req = window.XMLHttpRequest ? new XMLHttpRequest() : // IE7+, Firefox, Chrome, Opera, Safari 988 | new ActiveXObject("Microsoft.XMLHTTP"); // IE6, IE5 989 | this.xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback); 990 | } 991 | } 992 | }, { 993 | key: "xdomainRequest", 994 | value: function xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback) { 995 | var _this10 = this; 996 | 997 | req.timeout = timeout; 998 | req.open(method, endPoint); 999 | req.onload = function () { 1000 | var response = _this10.parseJSON(req.responseText); 1001 | callback && callback(response); 1002 | }; 1003 | if (ontimeout) { 1004 | req.ontimeout = ontimeout; 1005 | } 1006 | 1007 | // Work around bug in IE9 that requires an attached onprogress handler 1008 | req.onprogress = function () {}; 1009 | 1010 | req.send(body); 1011 | } 1012 | }, { 1013 | key: "xhrRequest", 1014 | value: function xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback) { 1015 | var _this11 = this; 1016 | 1017 | req.timeout = timeout; 1018 | req.open(method, endPoint, true); 1019 | req.setRequestHeader("Content-Type", accept); 1020 | req.onerror = function () { 1021 | callback && callback(null); 1022 | }; 1023 | req.onreadystatechange = function () { 1024 | if (req.readyState === _this11.states.complete && callback) { 1025 | var response = _this11.parseJSON(req.responseText); 1026 | callback(response); 1027 | } 1028 | }; 1029 | if (ontimeout) { 1030 | req.ontimeout = ontimeout; 1031 | } 1032 | 1033 | req.send(body); 1034 | } 1035 | }, { 1036 | key: "parseJSON", 1037 | value: function parseJSON(resp) { 1038 | return resp && resp !== "" ? JSON.parse(resp) : null; 1039 | } 1040 | }, { 1041 | key: "serialize", 1042 | value: function serialize(obj, parentKey) { 1043 | var queryStr = []; 1044 | for (var key in obj) { 1045 | if (!obj.hasOwnProperty(key)) { 1046 | continue; 1047 | } 1048 | var paramKey = parentKey ? parentKey + "[" + key + "]" : key; 1049 | var paramVal = obj[key]; 1050 | if ((typeof paramVal === "undefined" ? "undefined" : _typeof(paramVal)) === "object") { 1051 | queryStr.push(this.serialize(paramVal, paramKey)); 1052 | } else { 1053 | queryStr.push(encodeURIComponent(paramKey) + "=" + encodeURIComponent(paramVal)); 1054 | } 1055 | } 1056 | return queryStr.join("&"); 1057 | } 1058 | }, { 1059 | key: "appendParams", 1060 | value: function appendParams(url, params) { 1061 | if (Object.keys(params).length === 0) { 1062 | return url; 1063 | } 1064 | 1065 | var prefix = url.match(/\?/) ? "&" : "?"; 1066 | return "" + url + prefix + this.serialize(params); 1067 | } 1068 | }]); 1069 | 1070 | return Ajax; 1071 | }(); 1072 | 1073 | Ajax.states = { complete: 4 }; 1074 | 1075 | var Presence = exports.Presence = { 1076 | syncState: function syncState(state, newState, onJoin, onLeave) { 1077 | var _this12 = this; 1078 | 1079 | var joins = {}; 1080 | var leaves = {}; 1081 | 1082 | this.map(state, function (key, presence) { 1083 | if (!newState[key]) { 1084 | leaves[key] = _this12.clone(presence); 1085 | } 1086 | }); 1087 | this.map(newState, function (key, newPresence) { 1088 | var currentPresence = state[key]; 1089 | if (currentPresence) { 1090 | (function () { 1091 | var newRefs = newPresence.metas.map(function (m) { 1092 | return m.phx_ref; 1093 | }); 1094 | var curRefs = currentPresence.metas.map(function (m) { 1095 | return m.phx_ref; 1096 | }); 1097 | var joinedMetas = newPresence.metas.filter(function (m) { 1098 | return curRefs.indexOf(m.phx_ref) < 0; 1099 | }); 1100 | var leftMetas = currentPresence.metas.filter(function (m) { 1101 | return newRefs.indexOf(m.phx_ref) < 0; 1102 | }); 1103 | if (joinedMetas.length > 0) { 1104 | joins[key] = newPresence; 1105 | joins[key].metas = joinedMetas; 1106 | } 1107 | if (leftMetas.length > 0) { 1108 | leaves[key] = _this12.clone(currentPresence); 1109 | leaves[key].metas = leftMetas; 1110 | } 1111 | })(); 1112 | } else { 1113 | joins[key] = newPresence; 1114 | } 1115 | }); 1116 | this.syncDiff(state, { joins: joins, leaves: leaves }, onJoin, onLeave); 1117 | }, 1118 | syncDiff: function syncDiff(state, _ref2, onJoin, onLeave) { 1119 | var joins = _ref2.joins; 1120 | var leaves = _ref2.leaves; 1121 | 1122 | if (!onJoin) { 1123 | onJoin = function onJoin() {}; 1124 | } 1125 | if (!onLeave) { 1126 | onLeave = function onLeave() {}; 1127 | } 1128 | 1129 | this.map(joins, function (key, newPresence) { 1130 | var currentPresence = state[key]; 1131 | state[key] = newPresence; 1132 | if (currentPresence) { 1133 | var _state$key$metas; 1134 | 1135 | (_state$key$metas = state[key].metas).unshift.apply(_state$key$metas, _toConsumableArray(currentPresence.metas)); 1136 | } 1137 | onJoin(key, currentPresence, newPresence); 1138 | }); 1139 | this.map(leaves, function (key, leftPresence) { 1140 | var currentPresence = state[key]; 1141 | if (!currentPresence) { 1142 | return; 1143 | } 1144 | var refsToRemove = leftPresence.metas.map(function (m) { 1145 | return m.phx_ref; 1146 | }); 1147 | currentPresence.metas = currentPresence.metas.filter(function (p) { 1148 | return refsToRemove.indexOf(p.phx_ref) < 0; 1149 | }); 1150 | onLeave(key, currentPresence, leftPresence); 1151 | if (currentPresence.metas.length === 0) { 1152 | delete state[key]; 1153 | } 1154 | }); 1155 | }, 1156 | list: function list(presences, chooser) { 1157 | if (!chooser) { 1158 | chooser = function chooser(key, pres) { 1159 | return pres; 1160 | }; 1161 | } 1162 | 1163 | return this.map(presences, function (key, presence) { 1164 | return chooser(key, presence); 1165 | }); 1166 | }, 1167 | 1168 | // private 1169 | 1170 | map: function map(obj, func) { 1171 | return Object.getOwnPropertyNames(obj).map(function (key) { 1172 | return func(key, obj[key]); 1173 | }); 1174 | }, 1175 | clone: function clone(obj) { 1176 | return JSON.parse(JSON.stringify(obj)); 1177 | } 1178 | }; 1179 | 1180 | // Creates a timer that accepts a `timerCalc` function to perform 1181 | // calculated timeout retries, such as exponential backoff. 1182 | // 1183 | // ## Examples 1184 | // 1185 | // let reconnectTimer = new Timer(() => this.connect(), function(tries){ 1186 | // return [1000, 5000, 10000][tries - 1] || 10000 1187 | // }) 1188 | // reconnectTimer.scheduleTimeout() // fires after 1000 1189 | // reconnectTimer.scheduleTimeout() // fires after 5000 1190 | // reconnectTimer.reset() 1191 | // reconnectTimer.scheduleTimeout() // fires after 1000 1192 | // 1193 | 1194 | var Timer = function () { 1195 | function Timer(callback, timerCalc) { 1196 | _classCallCheck(this, Timer); 1197 | 1198 | this.callback = callback; 1199 | this.timerCalc = timerCalc; 1200 | this.timer = null; 1201 | this.tries = 0; 1202 | } 1203 | 1204 | _createClass(Timer, [{ 1205 | key: "reset", 1206 | value: function reset() { 1207 | this.tries = 0; 1208 | clearTimeout(this.timer); 1209 | } 1210 | 1211 | // Cancels any previous scheduleTimeout and schedules callback 1212 | 1213 | }, { 1214 | key: "scheduleTimeout", 1215 | value: function scheduleTimeout() { 1216 | var _this13 = this; 1217 | 1218 | clearTimeout(this.timer); 1219 | 1220 | this.timer = setTimeout(function () { 1221 | _this13.tries = _this13.tries + 1; 1222 | _this13.callback(); 1223 | }, this.timerCalc(this.tries + 1)); 1224 | } 1225 | }]); 1226 | 1227 | return Timer; 1228 | }(); 1229 | 1230 | })(typeof(exports) === "undefined" ? window.Phoenix = window.Phoenix || {} : exports); 1231 | 1232 | --------------------------------------------------------------------------------