Gamend.Leaderboards (gamend_core v1.0.1296)

Copy Markdown View Source

The Leaderboards context.

Provides server-authoritative leaderboard management. Scores can only be submitted via server-side code — there is no public API for score submission.

Usage

# Create a leaderboard
{:ok, lb} = Leaderboards.create_leaderboard(%{
  slug: "weekly_kills",
  title: "Weekly Kills",
  sort_order: :desc,
  operator: :incr
})

# Submit score (server-only): resolve the active leaderboard first and submit by ID
leaderboard = Leaderboards.get_active_leaderboard_by_slug("weekly_kills")
{:ok, record} = Leaderboards.submit_score(leaderboard.id, user_id, 10)

# List records with rank (use leaderboard id)
records = Leaderboards.list_records(leaderboard.id, page: 1, limit: 25)

Keys

A board can hold several rankings: a record is unique per user (or label) and key, and every read ranks within one key. "" is the default, a board with one ranking. A host keeping, say, a best per game and language on one board submits with key: "match|60|es_es" and metadata naming the parts; list_records/2 with key: :all and meta: reads across keys (best_per_user: true keeps each player's best of them).

Hidden boards

hidden: true keeps a board out of list_leaderboards/1, list_leaderboard_groups/1 and their counts unless include_hidden: true is passed. Everything else reads it like any board.

# Get user's record (use leaderboard id)
{:ok, record} = Leaderboards.get_user_record(leaderboard.id, user_id)

Summary

Functions

Returns a changeset for a leaderboard (used in forms).

Returns a changeset for a record (used in admin forms).

Count all leaderboard records across all leaderboards.

Counts unique leaderboard slugs.

Counts leaderboards matching the given filters.

Counts records for a leaderboard: the same :key, :meta, :best_per_user and :search as list_records/2.

Creates a new leaderboard.

Deletes a leaderboard and all its records.

Deletes a record.

Deletes a user's record from a leaderboard (in key:, default ""). Accepts either leaderboard ID or slug (both strings).

Ends a leaderboard by setting ends_at to the current time.

Gets the currently active leaderboard with the given slug. Returns nil if no active leaderboard exists.

Gets a single record by leaderboard ID, label and key (default "").

Gets a leaderboard by its UUID, or the active leaderboard by slug.

Gets a leaderboard by its ID. Raises if not found.

Gets a record by its ID, or nil when there is none (or id is not a UUID).

Gets a single record by leaderboard ID, user ID and key (default "").

Like get_record/1, but raises Ecto.NoResultsError when there is none.

Gets a user's record with their rank (within its key: key:, default ""). Returns {:ok, record_with_rank} or {:error, :not_found}.

Forget the cached leaderboard lookups.

Lists unique leaderboard slugs with summary info.

Lists leaderboards with optional filters.

Lists all leaderboards with the given slug (all seasons), ordered by end date.

Lists records for a leaderboard, ordered by rank.

Lists records around a specific user (centered on their position).

Resolves multiple slugs to their currently active leaderboards in a single query.

Submit a score for a label-based (non-user) record.

Updates an existing leaderboard.

Updates an existing record.

Functions

change_leaderboard(leaderboard, attrs \\ %{})

@spec change_leaderboard(Gamend.Leaderboards.Leaderboard.t(), map()) ::
  Ecto.Changeset.t()

Returns a changeset for a leaderboard (used in forms).

change_record(record, attrs \\ %{})

@spec change_record(Gamend.Leaderboards.Record.t(), map()) :: Ecto.Changeset.t()

Returns a changeset for a record (used in admin forms).

count_all_records()

@spec count_all_records() :: non_neg_integer()

Count all leaderboard records across all leaderboards.

count_leaderboard_groups(opts \\ [])

@spec count_leaderboard_groups(keyword()) :: non_neg_integer()

Counts unique leaderboard slugs.

count_leaderboards(opts \\ [])

@spec count_leaderboards(keyword()) :: non_neg_integer()

Counts leaderboards matching the given filters.

Accepts the same filter options as list_leaderboards/1.

count_records(leaderboard_id, opts \\ [])

@spec count_records(Ecto.UUID.t(), keyword()) :: non_neg_integer()

Counts records for a leaderboard: the same :key, :meta, :best_per_user and :search as list_records/2.

create_leaderboard(attrs)

@spec create_leaderboard(Gamend.Types.leaderboard_create_attrs()) ::
  {:ok, Gamend.Leaderboards.Leaderboard.t()} | {:error, Ecto.Changeset.t()}

Creates a new leaderboard.

Attributes

See Gamend.Types.leaderboard_create_attrs/0 for available fields.

Examples

iex> create_leaderboard(%{slug: "my_lb", title: "My Leaderboard"})
{:ok, %Leaderboard{}}

iex> create_leaderboard(%{slug: "", title: ""})
{:error, %Ecto.Changeset{}}

delete_leaderboard(leaderboard)

@spec delete_leaderboard(Gamend.Leaderboards.Leaderboard.t()) ::
  {:ok, Gamend.Leaderboards.Leaderboard.t()} | {:error, Ecto.Changeset.t()}

Deletes a leaderboard and all its records.

delete_record(record)

@spec delete_record(Gamend.Leaderboards.Record.t()) ::
  {:ok, Gamend.Leaderboards.Record.t()} | {:error, Ecto.Changeset.t()}

Deletes a record.

delete_user_record(id_or_slug, user_id, opts \\ [])

@spec delete_user_record(String.t(), Ecto.UUID.t(), keyword()) ::
  {:ok, Gamend.Leaderboards.Record.t()} | {:error, :not_found}

Deletes a user's record from a leaderboard (in key:, default ""). Accepts either leaderboard ID or slug (both strings).

end_leaderboard(leaderboard)

@spec end_leaderboard(Gamend.Leaderboards.Leaderboard.t() | String.t()) ::
  {:ok, Gamend.Leaderboards.Leaderboard.t()}
  | {:error, Ecto.Changeset.t() | :not_found}

Ends a leaderboard by setting ends_at to the current time.

get_active_leaderboard_by_slug(slug)

@spec get_active_leaderboard_by_slug(String.t()) ::
  Gamend.Leaderboards.Leaderboard.t() | nil

Gets the currently active leaderboard with the given slug. Returns nil if no active leaderboard exists.

An active leaderboard is one that:

  • Has not ended (ends_at is nil or in the future)
  • Has started (starts_at is nil or in the past)

If multiple active leaderboards exist with the same slug, returns the most recently created one.

get_label_record(leaderboard_id, label, key \\ "")

@spec get_label_record(Ecto.UUID.t(), String.t(), String.t()) ::
  Gamend.Leaderboards.Record.t() | nil

Gets a single record by leaderboard ID, label and key (default "").

get_leaderboard(id_or_slug)

@spec get_leaderboard(String.t()) :: Gamend.Leaderboards.Leaderboard.t() | nil

Gets a leaderboard by its UUID, or the active leaderboard by slug.

Examples

iex> get_leaderboard("0198c0de-...")
%Leaderboard{}

iex> get_leaderboard(Ecto.UUID.generate())
nil

get_leaderboard!(id)

@spec get_leaderboard!(String.t()) :: Gamend.Leaderboards.Leaderboard.t()

Gets a leaderboard by its ID. Raises if not found.

get_record(id)

@spec get_record(Ecto.UUID.t()) :: Gamend.Leaderboards.Record.t() | nil

Gets a record by its ID, or nil when there is none (or id is not a UUID).

Intended for internal/admin usage.

get_record(leaderboard_id, user_id, key \\ "")

@spec get_record(Ecto.UUID.t(), Ecto.UUID.t(), String.t()) ::
  Gamend.Leaderboards.Record.t() | nil

Gets a single record by leaderboard ID, user ID and key (default "").

get_record!(id)

@spec get_record!(Ecto.UUID.t()) :: Gamend.Leaderboards.Record.t()

Like get_record/1, but raises Ecto.NoResultsError when there is none.

get_user_record(leaderboard_id, user_id, opts \\ [])

@spec get_user_record(Ecto.UUID.t(), Ecto.UUID.t(), keyword()) ::
  {:ok, Gamend.Leaderboards.Record.t()} | {:error, :not_found}

Gets a user's record with their rank (within its key: key:, default ""). Returns {:ok, record_with_rank} or {:error, :not_found}.

invalidate_cache()

@spec invalidate_cache() :: :ok

Forget the cached leaderboard lookups.

Every write in here does this already. It is public because a CALLER can also learn the cache is stale: get_active_leaderboard_by_slug/1 is cached for a minute, so a board deleted (or rolled back, under a test sandbox) hands out an id whose row is gone, and the next submit_score/4 fails on the foreign key. A caller that sees that can drop the lookup and try again rather than wait out the TTL.

list_leaderboard_groups(opts \\ [])

@spec list_leaderboard_groups(keyword()) :: [map()]

Lists unique leaderboard slugs with summary info.

Returns a list of maps with:

  • :slug - the leaderboard slug
  • :title - title from the latest leaderboard
  • :description - description from the latest leaderboard
  • :active_id - ID of the currently active leaderboard (or nil)
  • :latest_id - ID of the most recent leaderboard
  • :season_count - total number of leaderboards with this slug

list_leaderboards(opts \\ [])

@spec list_leaderboards(keyword()) :: [Gamend.Leaderboards.Leaderboard.t()]

Lists leaderboards with optional filters.

Options

  • :slug - Filter by slug (returns all seasons of that leaderboard)
  • :active - If true, only active leaderboards. If false, only ended.
  • :order_by - Order by field: :ends_at or :inserted_at (default)
  • :starts_after - Only leaderboards that started after this DateTime
  • :starts_before - Only leaderboards that started before this DateTime
  • :ends_after - Only leaderboards that end after this DateTime
  • :ends_before - Only leaderboards that end before this DateTime
  • :include_hidden - Also hidden boards (default false)
  • :page - Page number (default 1)
  • :page_size - Page size (default 25)

Examples

iex> list_leaderboards(active: true)
[%Leaderboard{}, ...]

iex> list_leaderboards(slug: "weekly_kills")
[%Leaderboard{}, ...]

iex> list_leaderboards(starts_after: ~U[2025-01-01 00:00:00Z])
[%Leaderboard{}, ...]

list_leaderboards_by_slug(slug, opts \\ [])

@spec list_leaderboards_by_slug(String.t(), keyword()) :: [
  Gamend.Leaderboards.Leaderboard.t()
]

Lists all leaderboards with the given slug (all seasons), ordered by end date.

list_records(leaderboard_id, opts \\ [])

@spec list_records(String.t(), keyword()) :: [Gamend.Leaderboards.Record.t()]

Lists records for a leaderboard, ordered by rank.

Options

See Gamend.Types.pagination_opts/0 for available options, plus:

  • :key — the ranking to read (default ""), or :all for every key's rows ranked together.
  • :meta — a map, keeping only records whose metadata[field] equals each value (%{"game" => "match", "lang" => "es_es"}). Ranks are computed within the filtered set, because "the Spanish board" means first among Spanish, not 57th overall. That is the opposite of :search, which ranks over the whole key so a found player's real position is what shows.
  • :best_per_user — with key: :all, each player's (or label's) best row only: one board read across keys, a player once.

list_records_around_user(leaderboard_id, user_id, opts \\ [])

@spec list_records_around_user(String.t(), Ecto.UUID.t(), keyword()) :: [
  Gamend.Leaderboards.Record.t()
]

Lists records around a specific user (centered on their position).

Returns records above and below the user's rank.

Options

  • :limit - Total number of records to return (default 11, centered on user)
  • :key - The ranking (default "")

resolve_slugs(slugs)

@spec resolve_slugs([String.t()]) :: %{
  required(String.t()) => Gamend.Leaderboards.Leaderboard.t()
}

Resolves multiple slugs to their currently active leaderboards in a single query.

Returns a map of slug => %Leaderboard{} for each slug that has an active leaderboard. Slugs with no active leaderboard are omitted from the result.

Examples

iex> resolve_slugs(["weekly_kills", "monthly_score", "nonexistent"])
%{
  "weekly_kills" => %Leaderboard{id: 1, slug: "weekly_kills", ...},
  "monthly_score" => %Leaderboard{id: 5, slug: "monthly_score", ...}
}

submit_label_score(leaderboard_id, label, score, metadata \\ %{}, opts \\ [])

@spec submit_label_score(String.t(), String.t(), integer(), map(), keyword()) ::
  {:ok, Gamend.Leaderboards.Record.t()} | {:error, term()}

Submit a score for a label-based (non-user) record.

Works just like submit_score/4 but uses a string label instead of a user ID. This is useful for statistics, rankings by category, etc.

Examples

iex> submit_label_score(leaderboard_id, "English", 42)
{:ok, %Record{label: "English", score: 42}}

submit_score(leaderboard_id, user_id, score, metadata \\ %{}, opts \\ [])

@spec submit_score(String.t(), Ecto.UUID.t(), integer(), map(), keyword()) ::
  {:ok, Gamend.Leaderboards.Record.t()} | {:error, term()}

Submits a score for a user on a leaderboard.

This is a server-only function — there is no public API for score submission. The score is processed according to the leaderboard's operator:

  • :set — Always replace with new score
  • :best — Only update if new score is better (respects sort_order)
  • :incr — Add to existing score
  • :decr — Subtract from existing score

To submit to a leaderboard by slug, first get the active leaderboard ID:

leaderboard = Leaderboards.get_active_leaderboard_by_slug("weekly_kills")
Leaderboards.submit_score(leaderboard.id, user_id, 10)

Examples

iex> submit_score(123, user_id, 10)
{:ok, %Record{score: 10}}

iex> submit_score(123, user_id, 5, %{weapon: "sword"})
{:ok, %Record{score: 15, metadata: %{weapon: "sword"}}}

key: (default "") is the ranking within the board the score goes to; the operator applies per key.

iex> submit_score(123, user_id, 23, %{"game" => "match"}, key: "match|60")
{:ok, %Record{key: "match|60", score: 23}}

update_leaderboard(leaderboard, attrs)

Updates an existing leaderboard.

Note: slug, sort_order, and operator cannot be changed after creation.

Attributes

See Gamend.Types.leaderboard_update_attrs/0 for available fields.

update_record(record, attrs)

@spec update_record(Gamend.Leaderboards.Record.t(), map()) ::
  {:ok, Gamend.Leaderboards.Record.t()} | {:error, Ecto.Changeset.t()}

Updates an existing record.

Intended for internal/admin usage.