Soulless
Unofficial Mahjong Soul game API client for Elixir.
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"}
]
end
Getting started
See hexdocs for a list of generated fetch functions.
Authentication
Before connecting, prepare the credentials required by your chosen server and login method.
Supported lobby login methods 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. This performs Yostar login as a new device. Set:email,:tokenand:device_id. Obtain:tokenby submitting a code sent to the specified email.email = "nya@gmail.com"device_id = Soulless.Util.generate_uuid_v4() # or any other uuid-looking stringserver = :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)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. This performs login to a saved Yostar session. Set:user_id,:tokenand:device_id. The:tokenis tied to a specific:device_id. Initialize a session after completing the:yostaremail code flow:{:ok, login_result} =Soulless.Auth.Yostar.login(email, token_from_email_code_submission, device_id, server)opts = [login_method: :yostar_session,server: server,device_id: device_id,user_id: login_result.user_id,token: login_result.token]:passwordAvailable on the:cnserver. Set:emailand:password.opts = [login_method: :password,server: :cn,email: "nya@gmail.com",password: "hunter1"]Login methods using 3rd party services (Google, X, Steam) are not supported.
Endpoint and version options
This library uses hardcoded regional URLs and versions.
Sending the server an outdated client version will usually lead to rejection.
When that happens, update the soulless_proto dependency which contains hardcoded versions, which are dumped straight from the Unity Web version of the game and updated regularly.
If that fails, you can try these overrides:
:endpointonSoulless.LobbyClient,Soulless.SpectatorClientandSoulless.ChatClientreplaces the regional websocket URL.:sdk_urlonSoulless.LobbyClientreplaces the Yostar platform API base URL used during lobby authentication.sdk_urlcan also be passed as the final argument toSoulless.Auth.Yostarhelpers, afterproxy.:resource_versionand:product_versiononSoulless.LobbyClientreplace the values sent in login payloads.
opts = [
login_method: :yostar_session,
server: :en,
device_id: device_id,
user_id: user_id,
token: token,
endpoint: "wss://example.test/gateway",
sdk_url: "https://sdk.example.test",
resource_version: "0.16.193",
product_version: "4.0.7"
]
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.
opts = [
user_id: "123456789",
token: "effeffeffeffeffeffeffeffeffeff",
device_id: "c82178a7-b05a-09a6-41f1-721a676e83eb",
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: client_version_string,
game_uuid: "260413-133c2058-a806-4122-9b89-2350bbb83c29"
}
)
Soulless.SpectatorClient and Soulless.ChatClient select their websocket endpoint from :server and accept the same :endpoint override:
{:ok, spectator} = Soulless.SpectatorClient.start_link(server: :en, token: spectator_token)
{:ok, chat} = Soulless.ChatClient.start_link(server: :en, token: contest_chat_token)
Soulless.GameClient receives its game websocket URL from game/lobby data:
{:ok, game} =
Soulless.GameClient.start_link(
endpoint_uri: URI.parse(game_endpoint_url),
user_id: user_id,
token: game_token,
game_uuid: game_uuid
)
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, ...])