# Copyright (C) AlphaKeks # SPDX-License-Identifier: GPL-2.0-only defmodule SteamID do @moduledoc """ Valve's [SteamID]. [SteamID]: https://developer.valvesoftware.com/wiki/SteamID """ @typedoc "A SteamID." @type t() :: %SteamID{ universe: universe(), account_type: account_type(), account_instance: account_instance(), account_number: account_number(), authentication_server: authentication_server() } @typedoc "The various Steam universes." @type universe() :: (:unspecified | :individual) | :public | :beta | :internal | :dev | :rc @typedoc "The various Steam account types." @type account_type() :: :invalid | :individual | :multiseat | :game_server | :anon_game_server | :pending | :content_server | :clan | :chat | :p2p_super_seeder | :anon_user @type account_instance() :: non_neg_integer() @type account_number() :: non_neg_integer() @type authentication_server() :: 0 | 1 @doc """ A struct carrying all components of a SteamID. You may **read** all fields, but only change them by calling functions in the `SteamID` module. """ defstruct universe: :public, account_type: :individual, account_instance: 1, account_number: 0, authentication_server: 0 @fields_schema NimbleOptions.new!( universe: [ type: {:in, [:unspecified, :individual, :public, :beta, :internal, :dev, :rc]}, type_doc: "`t:universe/0`", type_spec: quote(do: universe()) ], account_type: [ type: {:in, [ :invalid, :individual, :multiseat, :game_server, :anon_game_server, :pending, :content_server, :clan, :chat, :p2p_super_seeder, :anon_user ]}, type_doc: "`t:account_type/0`", type_spec: quote(do: account_type()) ], account_instance: [ type: {:in, 0..0b11111111111111111111}, type_doc: "`t:account_instance/0`", type_spec: quote(do: account_instance()) ], account_number: [ type: {:in, 0..0b1111111111111111111111111111111}, type_doc: "`t:account_number/0`", type_spec: quote(do: account_number()) ], authentication_server: [ type: {:in, 0..1}, type_doc: "`t:authentication_server/0`", type_spec: quote(do: authentication_server()) ] ) @doc """ Creates a new `%SteamID{}`. ## Example iex> SteamID.new() %SteamID{ universe: :public, account_type: :individual, account_instance: 1, account_number: 0, authentication_server: 0 } iex> SteamID.new(account_number: 161178172, authentication_server: 1) %SteamID{ universe: :public, account_type: :individual, account_instance: 1, account_number: 161178172, authentication_server: 1 } ## Fields #{NimbleOptions.docs(@fields_schema, nest_level: 1)} """ @spec new([field]) :: t() when field: unquote(NimbleOptions.option_typespec(@fields_schema)) def new(fields \\ []) do {:ok, fields} = with {:error, error} <- NimbleOptions.validate(fields, @fields_schema) do raise ArgumentError, Exception.message(error) end struct!(%SteamID{}, fields) end @doc """ Converts an integer to a `%SteamID{}`. ## Example iex> SteamID.from_integer(0) {:ok, %SteamID{ universe: :public, account_type: :individual, account_instance: 1, account_number: 0, authentication_server: 0 }} iex> SteamID.from_integer(76561198282622073) {:ok, %SteamID{ universe: :public, account_type: :individual, account_instance: 1, account_number: 161178172, authentication_server: 1 }} iex> SteamID.from_integer(322356345) {:ok, %SteamID{ universe: :public, account_type: :individual, account_instance: 1, account_number: 161178172, authentication_server: 1 }} iex> SteamID.from_integer(-42) :error """ @spec from_integer(integer()) :: {:ok, t()} | :error def from_integer(value) def from_integer(value) when value in 0..0b1111111111111111111111111111111_1 do <> = <> {:ok, new(account_number: account_number, authentication_server: authentication_server)} end def from_integer(value) when value in 0..0b11111111_1111_11111111111111111111_1111111111111111111111111111111_1 do << universe::8, account_type::4, account_instance::20, account_number::31, authentication_server::1 >> = <> with {:ok, universe} <- integer_to_universe(universe), {:ok, account_type} <- integer_to_account_type(account_type) do {:ok, new( universe: universe, account_type: account_type, account_instance: account_instance, account_number: account_number, authentication_server: authentication_server )} end end def from_integer(value) when is_integer(value) do :error end @doc """ Converts a string to a `%SteamID{}`. ## Example iex> SteamID.parse("0") {:ok, %SteamID{ universe: :public, account_type: :individual, account_instance: 1, account_number: 0, authentication_server: 0 }} iex> SteamID.parse("U:1:322356345") {:ok, %SteamID{ universe: :public, account_type: :individual, account_instance: 1, account_number: 161178172, authentication_server: 1 }} iex> SteamID.parse("STEAM_1:1:161178172") {:ok, %SteamID{ universe: :public, account_type: :individual, account_instance: 1, account_number: 161178172, authentication_server: 1 }} iex> SteamID.parse("STEAM_0:1:161178172") {:ok, %SteamID{ universe: :unspecified, account_type: :individual, account_instance: 1, account_number: 161178172, authentication_server: 1 }} iex> SteamID.parse("bingus") :error """ @spec parse(binary()) :: {:ok, t()} | :error def parse(value) do cond do value =~ ~r/^[0-9]+$/ -> value |> String.to_integer() |> from_integer() matches = Regex.run(~r/^STEAM_([012345]):([01]):([0-9]+)$/, value, capture: :all_but_first) -> [universe, authentication_server, account_number] = Enum.map(matches, &String.to_integer/1) with {:ok, universe} <- integer_to_universe(universe), :ok <- validate_account_number(account_number) do {:ok, new( universe: universe, account_number: account_number, authentication_server: authentication_server )} end matches = Regex.run(~r/^([IiUMGAPCgTLc a]):1:([0-9]+)$/, value, capture: :all_but_first) -> [account_type, account_id] = matches account_id = String.to_integer(account_id) with {:ok, account_type} <- letter_to_account_type(account_type), :ok <- validate_account_id(account_id) do <> = <> {:ok, new( account_type: account_type, account_number: account_number, authentication_server: authentication_server )} end matches = Regex.run(~r/^\[([IiUMGAPCgTLc a]:1:[0-9]+)\]$/, value, capture: :all_but_first) -> [match] = matches parse(match) true -> :error end end @type size() :: 32 | 64 @opts_schema NimbleOptions.new!( size: [ default: 64, type: {:in, [32, 64]}, type_doc: "`t:size/0`", type_spec: quote(do: size()) ] ) @doc """ Converts a `%SteamID{}` to an integer. ## Example iex> steam_id = SteamID.new(account_number: 161178172, authentication_server: 1) iex> SteamID.to_integer(steam_id, size: 32) 322356345 iex> SteamID.to_integer(steam_id, size: 64) 76561198282622073 ## Options #{NimbleOptions.docs(@opts_schema, nest_level: 1)} """ @spec to_integer(t(), [option]) :: String.t() when option: unquote(NimbleOptions.option_typespec(@opts_schema)) def to_integer(%SteamID{} = steam_id, opts \\ []) do {:ok, opts} = with {:error, error} <- NimbleOptions.validate(opts, @opts_schema) do raise ArgumentError, Exception.message(error) end case Keyword.fetch!(opts, :size) do 32 -> %{account_number: account_number, authentication_server: authentication_server} = steam_id <> = <> steamid32 64 -> <> = << universe_to_integer(steam_id.universe)::8, account_type_to_integer(steam_id.account_type)::4, steam_id.account_instance::20, steam_id.account_number::31, steam_id.authentication_server::1 >> steamid64 end end @type format() :: :id2 | :id3 | :id32 | :id64 @opts_schema NimbleOptions.new!( format: [ default: :id64, type: {:in, ~w[id2 id3 id32 id64]a}, type_doc: "`t:format/0`", type_spec: quote(do: format()) ] ) @doc """ Converts a `%SteamID{}` to a string. ## Example iex> steam_id = SteamID.new(account_number: 161178172, authentication_server: 1) iex> SteamID.to_string(steam_id, format: :id2) "STEAM_1:1:161178172" iex> SteamID.to_string(steam_id, format: :id3) "U:1:322356345" iex> SteamID.to_string(steam_id, format: :id32) "322356345" iex> SteamID.to_string(steam_id, format: :id64) "76561198282622073" ## Options #{NimbleOptions.docs(@opts_schema, nest_level: 1)} When called with the default options, this function is equivalent to `Kernel.to_string/1`. """ @spec to_string(t(), [option]) :: String.t() when option: unquote(NimbleOptions.option_typespec(@opts_schema)) def to_string(%SteamID{} = steam_id, opts \\ []) do {:ok, opts} = with {:error, error} <- NimbleOptions.validate(opts, @opts_schema) do raise ArgumentError, Exception.message(error) end case Keyword.fetch!(opts, :format) do :id2 -> %{universe: universe, account_number: z, authentication_server: y} = steam_id x = universe_to_integer(universe) "STEAM_#{x}:#{y}:#{z}" :id3 -> %{account_type: account_type} = steam_id account_type = account_type_to_letter(account_type) account_id = to_integer(steam_id, size: 32) "#{account_type}:1:#{account_id}" :id32 -> steam_id |> to_integer(size: 32) |> Kernel.to_string() :id64 -> steam_id |> to_integer(size: 64) |> Kernel.to_string() end end @doc """ Normalizes the given `%SteamID{}` by setting `universe`, `account_type`, and `account_instance` to their default values. """ @spec normalize(t()) :: t() def normalize(%SteamID{} = steam_id) do %{steam_id | universe: :public, account_type: :individual, account_instance: 1} end @compile {:inline, universe_to_integer: 1} defp universe_to_integer(:unspecified), do: 0 defp universe_to_integer(:individual), do: 0 defp universe_to_integer(:public), do: 1 defp universe_to_integer(:beta), do: 2 defp universe_to_integer(:internal), do: 3 defp universe_to_integer(:dev), do: 4 defp universe_to_integer(:rc), do: 5 @compile {:inline, integer_to_universe: 1} defp integer_to_universe(0), do: {:ok, :unspecified} defp integer_to_universe(1), do: {:ok, :public} defp integer_to_universe(2), do: {:ok, :beta} defp integer_to_universe(3), do: {:ok, :internal} defp integer_to_universe(4), do: {:ok, :dev} defp integer_to_universe(5), do: {:ok, :rc} defp integer_to_universe(term) when is_integer(term), do: :error @compile {:inline, account_type_to_integer: 1} defp account_type_to_integer(:invalid), do: 0 defp account_type_to_integer(:individual), do: 1 defp account_type_to_integer(:multiseat), do: 2 defp account_type_to_integer(:game_server), do: 3 defp account_type_to_integer(:anon_game_server), do: 4 defp account_type_to_integer(:pending), do: 5 defp account_type_to_integer(:content_server), do: 6 defp account_type_to_integer(:clan), do: 7 defp account_type_to_integer(:chat), do: 8 defp account_type_to_integer(:p2p_super_seeder), do: 9 defp account_type_to_integer(:anon_user), do: 10 @compile {:inline, integer_to_account_type: 1} defp integer_to_account_type(0), do: {:ok, :invalid} defp integer_to_account_type(1), do: {:ok, :individual} defp integer_to_account_type(2), do: {:ok, :multiseat} defp integer_to_account_type(3), do: {:ok, :game_server} defp integer_to_account_type(4), do: {:ok, :anon_game_server} defp integer_to_account_type(5), do: {:ok, :pending} defp integer_to_account_type(6), do: {:ok, :content_server} defp integer_to_account_type(7), do: {:ok, :clan} defp integer_to_account_type(8), do: {:ok, :chat} defp integer_to_account_type(9), do: {:ok, :p2p_super_seeder} defp integer_to_account_type(10), do: {:ok, :anon_user} defp integer_to_account_type(term) when is_integer(term), do: :error @compile {:inline, account_type_to_letter: 1} defp account_type_to_letter(:invalid), do: "I" defp account_type_to_letter(:individual), do: "U" defp account_type_to_letter(:multiseat), do: "M" defp account_type_to_letter(:game_server), do: "G" defp account_type_to_letter(:anon_game_server), do: "A" defp account_type_to_letter(:pending), do: "P" defp account_type_to_letter(:content_server), do: "C" defp account_type_to_letter(:clan), do: "g" defp account_type_to_letter(:chat), do: "T" defp account_type_to_letter(:p2p_super_seeder), do: " " defp account_type_to_letter(:anon_user), do: "a" @compile {:inline, letter_to_account_type: 1} defp letter_to_account_type("I"), do: {:ok, :invalid} defp letter_to_account_type("U"), do: {:ok, :individual} defp letter_to_account_type("M"), do: {:ok, :multiseat} defp letter_to_account_type("G"), do: {:ok, :game_server} defp letter_to_account_type("A"), do: {:ok, :anon_game_server} defp letter_to_account_type("P"), do: {:ok, :pending} defp letter_to_account_type("C"), do: {:ok, :content_server} defp letter_to_account_type("g"), do: {:ok, :clan} defp letter_to_account_type("T"), do: {:ok, :chat} defp letter_to_account_type(" "), do: {:ok, :p2p_super_seeder} defp letter_to_account_type("a"), do: {:ok, :anon_user} defp letter_to_account_type(term) when is_binary(term), do: :error @compile {:inline, validate_account_number: 1} defp validate_account_number(value) when value in 0..0b1111111111111111111111111111111, do: :ok defp validate_account_number(value) when is_integer(value), do: :error @compile {:inline, validate_account_id: 1} defp validate_account_id(value) when value in 0..0b11111111111111111111111111111111, do: :ok defp validate_account_id(value) when is_integer(value), do: :error ## Protocols defimpl Inspect do import Inspect.Algebra def inspect(%SteamID{} = steam_id, opts) do struct_prefix = color_doc("#SteamID", :map, opts) start_sep = color_doc("<", :map, opts) end_sep = color_doc(">", :map, opts) {fields, opts} = container_doc_with_opts(start_sep, [steam_id], end_sep, opts, fn ^steam_id, opts -> opts = Keyword.take(opts.custom_options, [:format]) SteamID.to_string(steam_id, opts) end) {concat(struct_prefix, fields), opts} end end defimpl String.Chars do defdelegate to_string(steam_id), to: SteamID end defimpl JSON.Encoder do def encode(%SteamID{} = steam_id, encoder) do steam_id |> to_string() |> JSON.Encoder.encode(encoder) end end if Code.ensure_loaded?(Ecto.ParameterizedType) do use Ecto.ParameterizedType @opts_schema NimbleOptions.new!( format: [ default: :int64, type: {:in, ~w[id2 id3 id32 id64 int32 int64]a}, type_doc: "`t:SteamID.format/0`", type_spec: quote(do: SteamID.format()) ], normalize_before_dump?: [ default: true, type: :boolean ], normalize_before_compare?: [ default: true, type: :boolean ] ) @impl Ecto.ParameterizedType def init(opts \\ []) do {:ok, opts} = with {:error, error} <- opts |> Keyword.take([:format, :normalize_before_dump?, :normalize_before_compare?]) |> NimbleOptions.validate(@opts_schema) do raise ArgumentError, Exception.message(error) end Map.new(opts) end @impl Ecto.ParameterizedType def cast(%SteamID{} = steam_id, _params) do {:ok, steam_id} end @impl Ecto.ParameterizedType def cast(data, _params) when is_binary(data) do data |> SteamID.parse() |> convert_error() end @impl Ecto.ParameterizedType def cast(data, _params) when is_integer(data) do data |> SteamID.from_integer() |> convert_error() end @impl Ecto.ParameterizedType def cast(_data, _params) do :error end @impl Ecto.ParameterizedType def load(nil, _loader, _params) do {:ok, nil} end @impl Ecto.ParameterizedType def load(data, _loader, params) do cast(data, params) end @impl Ecto.ParameterizedType def dump(nil, _dumper, _params) do {:ok, nil} end @impl Ecto.ParameterizedType def dump(%SteamID{} = steam_id, _dumper, params) do steam_id = if params.normalize_before_dump? do SteamID.normalize(steam_id) else steam_id end case params.format do format when format in ~w[id2 id3 id32 id64]a -> {:ok, SteamID.to_string(steam_id, format: params.format)} :int32 -> {:ok, SteamID.to_integer(steam_id, size: 32)} :int64 -> {:ok, SteamID.to_integer(steam_id, size: 64)} end end @impl Ecto.ParameterizedType def dump(_term, _dumper, _params) do :error end @impl Ecto.ParameterizedType def type(params) do case params.format do format when format in ~w[id2 id3 id32 id64]a -> :string format when format in ~w[int32 int64]a -> :integer end end @impl Ecto.ParameterizedType def equal?(%SteamID{} = a, %SteamID{} = b, params) do {a, b} = if params.normalize_before_compare? do {SteamID.normalize(a), SteamID.normalize(b)} else {a, b} end a == b end @impl Ecto.ParameterizedType def equal?(a, b, params) do with {:ok, a} <- cast(a, params), {:ok, b} <- cast(b, params) do equal?(a, b, params) else _ -> false end end defp convert_error({:ok, _} = res), do: res defp convert_error({:error, _}), do: {:error, message: "is not a SteamID"} defp convert_error(:error), do: {:error, message: "is not a SteamID"} end if Code.ensure_loaded?(Jason) do defimpl Jason.Encoder do def encode(%SteamID{} = steam_id, opts) do steam_id |> to_string() |> Jason.Encode.string(opts) end end end if Code.ensure_loaded?(JSV) do @spec json_schema() :: JSV.Schema.t() def json_schema do %JSV.Schema{ description: """ A [SteamID]. [SteamID]: https://developer.valvesoftware.com/wiki/SteamID """, examples: [ "STEAM_1:1:161178172", "U:1:322356345", "[U:1:322356345]", "322356345", "76561198282622073" ], oneOf: [ %JSV.Schema{type: :string}, %JSV.Schema{type: :integer} ], title: "SteamID" } |> JSV.Schema.xcast(jsv_cast()) end require JSV @doc false JSV.defcast jsv_cast(data) do cond do is_binary(data) -> with :error <- parse(data) do {:error, "is not a SteamID"} end is_integer(data) -> with :error <- from_integer(data) do {:error, "is not a SteamID"} end true -> {:error, "is not a SteamID"} end end end end