diff --git a/lib/grapevine/application.ex b/lib/grapevine/application.ex index a08f8d5a..58fb5ffd 100644 --- a/lib/grapevine/application.ex +++ b/lib/grapevine/application.ex @@ -19,7 +19,8 @@ defmodule Grapevine.Application do {Metrics.Server, []}, {Telemetry.Poller, telemetry_opts()}, {Grapevine.Telnet.Worker, [name: Grapevine.Telnet.Worker]}, - {Grapevine.CNAMEs, [name: Grapevine.CNAMEs]} + {Grapevine.CNAMEs, [name: Grapevine.CNAMEs]}, + {Grapevine.Featured, [name: Grapevine.Featured]} ] Metrics.Setup.setup() diff --git a/lib/grapevine/featured.ex b/lib/grapevine/featured.ex new file mode 100644 index 00000000..c9239c6e --- /dev/null +++ b/lib/grapevine/featured.ex @@ -0,0 +1,33 @@ +defmodule Grapevine.Featured do + @moduledoc """ + Updates the home page with featured games nightly + + Selection includes: + - Games sorted by average player count, top 12 taken + - Games that use the web client or are connected to the chat network, random 4 + - Any game not included above, a random 4 + """ + + use GenServer + + alias Grapevine.Featured.Implementation + + def start_link(opts) do + GenServer.start_link(__MODULE__, [], opts) + end + + def init(_) do + {:ok, %{}, {:continue, :schedule_next_run}} + end + + def handle_continue(:schedule_next_run, state) do + next_run_delay = Implementation.calculate_next_cycle_delay(Timex.now()) + Process.send_after(self(), :select_featured, next_run_delay) + {:noreply, state} + end + + def handle_info(:select_featured, state) do + Implementation.select_featured() + {:noreply, state, {:continue, :schedule_next_run}} + end +end diff --git a/lib/grapevine/featured/implementation.ex b/lib/grapevine/featured/implementation.ex new file mode 100644 index 00000000..9141a872 --- /dev/null +++ b/lib/grapevine/featured/implementation.ex @@ -0,0 +1,121 @@ +defmodule Grapevine.Featured.Implementation do + @moduledoc """ + Implementation details for the Featured GenServer + """ + + import Ecto.Query + + alias Grapevine.Games + alias Grapevine.Repo + + @doc """ + Calculate the delay to the next cycle check which runs at 6 AM UTC + """ + def calculate_next_cycle_delay(now) do + now + |> Timex.set([hour: 6, minute: 0, second: 0]) + |> maybe_shift_a_day(now) + |> Timex.diff(now, :milliseconds) + end + + defp maybe_shift_a_day(next_run, now) do + case Timex.before?(now, next_run) do + true -> + next_run + + false -> + Timex.shift(next_run, days: 1) + end + end + + @doc """ + Select the featured games for that day + """ + def select_featured() do + Ecto.Multi.new() + |> reset_all() + |> update_selected() + |> Repo.transaction() + end + + defp reset_all(multi) do + Ecto.Multi.update_all(multi, :update_all, Grapevine.Games.Game, set: [featured_order: nil]) + end + + defp update_selected(multi) do + featured_games() + |> Enum.with_index() + |> Enum.reduce(multi, fn {game, order}, multi -> + changeset = + game + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_change(:featured_order, order) + + Ecto.Multi.update(multi, {:game, game.id}, changeset) + end) + end + + def featured_games() do + top_games = top_games_player_count([]) + selected_ids = Enum.map(top_games, & &1.id) + + random_games_using = random_games_using_grapevine(already_picked: selected_ids) + selected_ids = selected_ids ++ Enum.map(random_games_using, & &1.id) + + random_games = random_games(already_picked: selected_ids) + + Enum.shuffle(top_games ++ random_games_using ++ random_games) + end + + def top_games_player_count(opts) do + last_few_days = + Timex.now() + |> Timex.shift(days: -2) + |> Timex.set(minute: 0, second: 0) + |> DateTime.truncate(:second) + + limit = Keyword.get(opts, :select, 12) + + Grapevine.Statistics.PlayerStatistic + |> select([ps], ps.game_id) + |> join(:left, [ps], g in assoc(ps, :game)) + |> where([ps], ps.recorded_at >= ^last_few_days) + |> where([ps, g], g.display == true and not is_nil(g.cover_key)) + |> group_by([ps], [ps.game_id]) + |> order_by([ps], desc: avg(ps.player_count)) + |> limit(^limit) + |> Repo.all() + |> Enum.map(fn game_id -> + {:ok, game} = Games.get(game_id) + game + end) + end + + def random_games_using_grapevine(opts) do + active_cutoff = Timex.now() |> Timex.shift(minutes: -1) + mssp_cutoff = Timex.now() |> Timex.shift(minutes: -90) + + limit = Keyword.get(opts, :select, 4) + already_picked_games = Keyword.get(opts, :already_picked, []) + + Grapevine.Games.Game + |> where([g], g.display == true and not is_nil(g.cover_key)) + |> where([g], g.last_seen_at > ^active_cutoff or g.mssp_last_seen_at > ^mssp_cutoff) + |> where([g], g.id not in ^already_picked_games) + |> Repo.all() + |> Enum.shuffle() + |> Enum.take(limit) + end + + def random_games(opts) do + limit = Keyword.get(opts, :select, 4) + already_picked_games = Keyword.get(opts, :already_picked, []) + + Grapevine.Games.Game + |> where([g], g.display == true and not is_nil(g.cover_key)) + |> where([g], g.id not in ^already_picked_games) + |> Repo.all() + |> Enum.shuffle() + |> Enum.take(limit) + end +end diff --git a/lib/grapevine/games.ex b/lib/grapevine/games.ex index f0c8e9ae..96eb6525 100644 --- a/lib/grapevine/games.ex +++ b/lib/grapevine/games.ex @@ -56,6 +56,14 @@ defmodule Grapevine.Games do |> Repo.all() end + def featured() do + Game + |> where([g], g.display == true) + |> where([g], not is_nil(g.featured_order)) + |> order_by([g], asc: g.featured_order) + |> Repo.all() + end + def filter_on_attribute({"name", value}, query) do where(query, [g], ilike(g.name, ^"%#{value}%")) end diff --git a/lib/grapevine/games/game.ex b/lib/grapevine/games/game.ex index 2c601100..a219235d 100644 --- a/lib/grapevine/games/game.ex +++ b/lib/grapevine/games/game.ex @@ -27,6 +27,7 @@ defmodule Grapevine.Games.Game do field(:allow_character_registration, :boolean, default: true) field(:enable_web_client, :boolean, default: false) field(:allow_anonymous_client, :boolean, default: false) + field(:featured_order, :integer) field(:last_seen_at, :utc_datetime) field(:mssp_last_seen_at, :utc_datetime) diff --git a/lib/web/controllers/page_controller.ex b/lib/web/controllers/page_controller.ex index 5f2d041e..c103c12a 100644 --- a/lib/web/controllers/page_controller.ex +++ b/lib/web/controllers/page_controller.ex @@ -6,10 +6,8 @@ defmodule Web.PageController do action_fallback(Web.FallbackController) def index(conn, _params) do - games = Games.public(filter: %{"online" => "yes", "cover" => "yes"}) - conn - |> assign(:games, games) + |> assign(:games, Games.featured()) |> render("index.html") end diff --git a/priv/repo/migrations/20190401164850_add_featured_order_to_games.exs b/priv/repo/migrations/20190401164850_add_featured_order_to_games.exs new file mode 100644 index 00000000..f6ad2d0d --- /dev/null +++ b/priv/repo/migrations/20190401164850_add_featured_order_to_games.exs @@ -0,0 +1,9 @@ +defmodule Grapevine.Repo.Migrations.AddFeaturedOrderToGames do + use Ecto.Migration + + def change do + alter table(:games) do + add(:featured_order, :integer) + end + end +end diff --git a/test/grapevine/featured/implementation_test.exs b/test/grapevine/featured/implementation_test.exs new file mode 100644 index 00000000..90d45bb2 --- /dev/null +++ b/test/grapevine/featured/implementation_test.exs @@ -0,0 +1,139 @@ +defmodule Grapevine.Featured.ImplementationTest do + use Grapevine.DataCase + + alias Grapevine.Featured.Implementation + alias Grapevine.Games + alias Grapevine.Statistics + + describe "determining the amount of milliseconds to delay" do + test "for the next cycle" do + now = + Timex.now() + |> Timex.set([hour: 20, minute: 0, second: 0]) + |> DateTime.truncate(:second) + + delay = Implementation.calculate_next_cycle_delay(now) + + assert delay == 36000000 + end + + test "process is rebooted same day but before cycle runs" do + now = + Timex.now() + |> Timex.set([hour: 4, minute: 0, second: 0]) + |> DateTime.truncate(:second) + + delay = Implementation.calculate_next_cycle_delay(now) + + assert delay == 3600 * 2 * 1000 + end + end + + describe "selecting games to feature" do + test "updates the sort order for all games" do + user = create_user() + game1 = create_game(user, %{name: "Game 1", short_name: "Game1"}) + game2 = create_game(user, %{name: "Game 2", short_name: "Game2"}) + game3 = create_game(user, %{name: "Game 3", short_name: "Game3"}) + + Implementation.select_featured() + + Enum.each([game1, game2, game3], fn game -> + {:ok, game} = Games.get(game.id) + assert game.featured_order + end) + end + + test "selects from all three" do + user = create_user() + game1 = create_game(user, %{name: "Game 1", short_name: "Game1"}) + game2 = create_game(user, %{name: "Game 2", short_name: "Game2"}) + game3 = create_game(user, %{name: "Game 3", short_name: "Game3"}) + + {:ok, _stats} = Statistics.record_mssp_players(game1, 2, Timex.now()) + Games.seen_on_mssp(game2) + + games = Implementation.featured_games() + + game_ids = + games + |> Enum.map(& &1.id) + |> Enum.sort() + + assert game_ids == [game1.id, game2.id, game3.id] + end + + test "top games based on player count" do + user = create_user() + game1 = create_game(user, %{name: "Game 1", short_name: "Game1"}) + game2 = create_game(user, %{name: "Game 2", short_name: "Game2"}) + _game3 = create_game(user, %{name: "Game 3", short_name: "Game3"}) + + {:ok, _stats} = Statistics.record_mssp_players(game1, 2, Timex.now()) + {:ok, _stats} = Statistics.record_mssp_players(game2, 3, Timex.now()) + + games = Implementation.top_games_player_count(select: 2) + + game_ids = + games + |> Enum.map(& &1.id) + |> Enum.sort() + + assert game_ids == [game1.id, game2.id] + end + + test "random games using the web client or chat network" do + user = create_user() + game1 = create_game(user, %{name: "Game 1", short_name: "Game1"}) + game2 = create_game(user, %{name: "Game 2", short_name: "Game2"}) + _game3 = create_game(user, %{name: "Game 3", short_name: "Game3"}) + + Games.seen_on_mssp(game1) + Games.seen_on_socket(game2) + + games = Implementation.random_games_using_grapevine(select: 2) + + game_ids = + games + |> Enum.map(& &1.id) + |> Enum.sort() + + assert game_ids == [game1.id, game2.id] + end + + test "random games not already picked using client or chat" do + user = create_user() + game1 = create_game(user, %{name: "Game 1", short_name: "Game1"}) + game2 = create_game(user, %{name: "Game 2", short_name: "Game2"}) + _game3 = create_game(user, %{name: "Game 3", short_name: "Game3"}) + + Games.seen_on_mssp(game1) + Games.seen_on_socket(game2) + + games = Implementation.random_games_using_grapevine(select: 2, already_picked: [game1.id]) + + game_ids = + games + |> Enum.map(& &1.id) + |> Enum.sort() + + assert game_ids == [game2.id] + end + + test "random selection of games that have not been picked" do + user = create_user() + game1 = create_game(user, %{name: "Game 1", short_name: "Game1"}) + game2 = create_game(user, %{name: "Game 2", short_name: "Game2"}) + game3 = create_game(user, %{name: "Game 3", short_name: "Game3"}) + + games = Implementation.random_games(select: 2, already_picked: [game1.id]) + + game_ids = + games + |> Enum.map(& &1.id) + |> Enum.sort() + + assert game_ids == [game2.id, game3.id] + end + end +end diff --git a/test/support/test_helpers.ex b/test/support/test_helpers.ex index 3ea18157..e45b4fd3 100644 --- a/test/support/test_helpers.ex +++ b/test/support/test_helpers.ex @@ -6,6 +6,7 @@ defmodule Grapevine.TestHelpers do alias Grapevine.Channels alias Grapevine.Games alias Grapevine.Gauges + alias Grapevine.Repo def create_channel(attributes \\ %{}) do attributes = @@ -46,6 +47,13 @@ defmodule Grapevine.TestHelpers do def create_game(user, attributes \\ %{}) do {:ok, game} = Games.register(user, game_attributes(attributes)) + {:ok, game} = + game + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_change(:cover_key, UUID.uuid4()) + |> Ecto.Changeset.put_change(:cover_extension, ".png") + |> Repo.update() + game end