README.md

# Soulless

Unofficial Mahjong Soul game API client for elixir.

**This is an early prototype and should not be used for anything serious**

It provides:
- socket clients for every known endpoint
- message encoding and decoding, including "decrypting" certain obfuscated ones
- generated functions for RPC
- simplified authentication

## Installation

The package can be installed by adding the following to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:soulless, "~> 0.3.0"}
  ]
end
```

## Getting started

See [hexdocs](https://hexdocs.pm/soulless/) for a list of generated fetch functions.

### Authentication

Before you can connect, you'll need to get yourself a set of necessary credentials.
Which credentials you'll need depends on the server you want to connect to and the method you chose.

Supported flows are:
- `:yostar`
  This method is **not recommended** due to reliance on a token which can issue new sessions and can't be invalidated.
  Available on `:en`, `:jp` and `:kr` servers.
  It will perform login as a new device.
  To use it, you'll need to set `:email` and `:token`. You can also set the `:device_id`, which by default is derived from the `:token`.
  In order to obtain a token, submit a code sent to specified email.
  ```elixir
  email = "nya@gmail.com"
  device_id = Soulless.Util.generate_uuid_v4() # or any other uuid-looking string
  server = :en # or :jp / :kr
  :ok = Soulless.Auth.Yostar.request_email_code(email, device_id, server)
  {:ok, token} = Soulless.Auth.Yostar.submit_email_code(email, code, device_id, server)
  # supplied credentials should have this shape
  opts = [
    login_method: :yostar,
    server: server,
    device_id: device_id,
    email: email,
    token: token
  ]
  ```

- `:yostar_session`
  This is the **recommended** method. Available on `:en`, `:jp` and `:kr` servers.
  It will perform login to saved session.
  To use it, you'll need to set `:user_id`, `:token` and `:device_id`.
  The `:token` is only valid for a specific `:device_id`.
  In order to obtain a token, continue from the steps in the `:yostar` section and initialize a session:
  ```elixir
  {:ok, login_result} = Soulless.Auth.Yostar.login(email, token_from_email_code_submission, device_id, server)

  # supplied credentials should have this shape
  opts = [
    login_method: :yostar_session,
    server: server,
    device_id: device_id,
    user_id: login_result.user_id,
    token: login_result.token
  ]
  ```

- `:password`
  Available on `:cn` server. It will perform login using your password.
  To use it, you'll need to set `:email` and `:password`.
  ```elixir
  # supplied credentials should have this shape
  opts = [
    login_method: :password,
    server: :cn,
    email: "nya@gmail.com",
    password: "hunter1"
  ]
  ```

- Login methods using 3rd party services (Google, X, Steam) are **not supported**.

### Connecting

Mahjong Soul is a bit weird and makes connections to up to four different servers, each with a different protocol. To that end, this library implements each of them as a separate module. Currently we have: `Soulless.LobbyClient`, `Soulless.GameClient`, `Soulless.SpectatorClient`, `Soulless.ChatClient`.

- `Soulless.LobbyClient` is used to obtain connection details necessary to use other clients, so you wanna start with this one.
- `Soulless.GameClient` is used to actually play mahjong.
- `Soulless.SpectatorClient` is used to spectate mahjong games.
- `Soulless.ChatClient` is used to **read** the tourney chat. Sending messages to tourney chat still happens via `Soulless.LobbyClient`. Don't ask me why they designed it this way.

```elixir
# see the "authentication" section for information on what to put here
opts = [
  user_id: "123456789",
  token: "effeffeffeffeffeffeffeffeffeff",
  login_method: :yostar_session,
  server: :en
]

# start the client
{:ok, client} = Soulless.LobbyClient.start_link(opts)

# you will find relevant fetch functions in the same module
# these are automatically generated from the SoullessProto.descriptor/1 along with typespecs
# check the documentation for a full list

# simple request that do not require a payload can just be called with the client pid
{:ok, %Soulless.Game.Lq.ResFetchInfo{}} = Soulless.LobbyClient.fetch_info(client)

# some request may require the game version to be included in the payload
# you can retrieve it from the client like so
version = Soulless.LobbyClient.version(client)

{:ok, %Soulless.Game.Lq.ResGameRecord{}} =
  Soulless.LobbyClient.fetch_game_record(
    client,
    %Soulless.Game.Lq.ReqGameRecord{
      client_version_string: "web-#{version}", 
      game_uuid: "260413-133c2058-a806-4122-9b89-2350bbb83c29"
    }
  )
```

Note: there are currently no plans to document every generated function and their arguments.
Figuring them out is a bit of a challenge and left as an exercise for the reader.
The easiest approach would be to proxy the real game traffic to observe what is being sent for which actions.

Certain messages, known as notices, are sent unconditionally by the server. This includes things like: friends logging on, decoration changes while in a friendly room, actions that happened in a mahjong game.
In order to do something with them, you'll need to implement the `Soulless.Handler` behaviour and pass it to `start/1` or `start_link/1`. You almost certainly need it to use anything but the `Soulless.LobbyClient` effectively (and it's useful even for that one).

```elixir
defmodule ExampleHandler do
  use Soulless.Handler

  @impl Soulless.Handler
  # called with either :connected or :authenticated `state`
  # in case you need to know when the connection is ready to use
  def handle_ready(_client, _state) do
    :ok
  end

  @impl Soulless.Handler
  # this handler is executed in a spawned task, meaning it is safe to make requests to the client
  def handle_notice(_client, %Soulless.Game.Lq.NotifyFriendStateChange{} = message) do
    message_prefix = "Our friend UID #{message.active_state.account_id} has"

    case {message.active_state.is_online, message.active_state.playing} do
      {false, _} -> IO.puts(message_prefix <> " logged out")
      {true, nil} -> IO.puts(message_prefix <> " is online")
      {true, _} -> IO.puts(message_prefix <> " joined a match")
    end
  end

  # don't forget about the default clause!
  def handle_notice(_client, _notice) do
    :ok
  end
end

# use it like so
{:ok, client} = Soulless.LobbyClient.start_link([handler: ExampleHandler, ...])
```

While hopefully not necessary, you can alter the lower level aspects of the client by providing your own implementation of the `Soulless.Websocket.Implementation` behaviour.
That's what encodes and decodes messages, performs endpoint discovery, runs the authentication flow and decides what to do after connecting and disconnecting.
You can find the default implementations in `lib/websocket/implementation/`.

```elixir
{:ok, client} = Soulless.LobbyClient.start_link([implementation: MyOwn.Websocket.Implementation, ...])
```