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:
def deps do
[
{:soulless, "~> 0.3.0"}
]
endGetting started
See hexdocs 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:
:yostarThis method is not recommended due to reliance on a token which can issue new sessions and can't be invalidated. Available on:en,:jpand:krservers. It will perform login as a new device. To use it, you'll need to set:emailand: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.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_sessionThis is the recommended method. Available on:en,:jpand:krservers. It will perform login to saved session. To use it, you'll need to set:user_id,:tokenand:device_id. The:tokenis only valid for a specific:device_id. In order to obtain a token, continue from the steps in the:yostarsection and initialize a session:{: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 ]:passwordAvailable on:cnserver. It will perform login using your password. To use it, you'll need to set:emailand:password.# 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.LobbyClientis used to obtain connection details necessary to use other clients, so you wanna start with this one.Soulless.GameClientis used to actually play mahjong.Soulless.SpectatorClientis used to spectate mahjong games.Soulless.ChatClientis used to read the tourney chat. Sending messages to tourney chat still happens viaSoulless.LobbyClient. Don't ask me why they designed it this way.
# 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).
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/.
{:ok, client} = Soulless.LobbyClient.start_link([implementation: MyOwn.Websocket.Implementation, ...])