Packages

A library for working with Valve's SteamID format

Current section

Files

Jump to
steamid lib steamid.ex
Raw

lib/steamid.ex

# Copyright (C) AlphaKeks <alphakeks@dawn.sh>
# 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
<<account_number::31, authentication_server::1>> = <<value::32>>
{: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
>> = <<value::64>>
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
<<account_number::31, authentication_server::1>> = <<account_id::32>>
{: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::32>> = <<account_number::31, authentication_server::1>>
steamid32
64 ->
<<steamid64::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
end