Writing a ruleset requires knowing the JSON specification, which involves a lot of balancing brackets and not forgetting commas. MahjongScript was created for the purpose of avoiding these errors. Based on Elixir syntax, MahjongScript (.majs) is essentially a list of commands which compile down to jq. This jq is then applied to the empty object in order to create a JSON ruleset, or it can be applied to an existing JSON ruleset to mod it.
There are four types of data in MahjongScript:
- JSON: numbers, strings, arrays, and objects
- Conditions: conditions with
and,or,not - Actions: function calls or
do-blocks containing actions - Sigils: set specifications, match specifications
The entirety of a MahjongScript file is a list of commands that use these data in some way. A list of commands is provided at the bottom.
The following values are JSON:
- Numbers:
42,1.5 - Strings:
"example" - Arrays:
["points", 1000] - Objects:
%{points: 1000}
Note that objects differ from typical JSON syntax {"points": 1000}. This is a consequence of this language being based on Elixir's map syntax. However, this also means you can declare multiline strings:
%{str: """
This is my multiline string
The indents before these lines are removed
since the ending quotes below are indented too
"""}
In addition, there is a concept of a numeric "amount". An amount is either a number or one of the following strings which translate into some amount:
- the name of a counter
"tiles_in_wall""num_discards""num_aside""num_facedown_tiles""num_facedown_tiles_others""half_score""dice""pot""honba""riichi_value""honba_value""points""points2""score""minipoints"
Any condition in the supported list of conditions can be written like
(our_turn and has_score(1000)) or has_score(0, as: "shimocha")This compiles down to the equivalent bracket-ridden JSON
[[["our_turn", {"name": "has_score", "opts": [1000]}], {"name": "has_score", "as": "shimocha", "opts": [0]}]]You can also write comparisons where the left side is a counter name and the right side is an amount. For example:
"my_score" >= 100
compiles to {"name": "counter_at_least", "opts": ["my_score", 100]}.
Supported comparisons are ==, !=, <, >, <=, >=.
Actions are always seen in do-blocks. For example you can define a function with a do-block:
def myfun do
put_down_riichi_stick
add_score(-1000)
endThis compiles to the JQ
.functions.myfun = [
["put_down_riichi_stick"]
["add_score", -1000]
]You can also write the above as a one-liner using semicolons to separate lines:
def myfun do put_down_riichi_stick; add_score(-1000) endThis works for all do-blocks.
You may use any action in the supported list of actions. In addition, there is shorthand for some often-used actions:
def myfun2 do
# if-blocks compile down to "when" actions
if true do
push_message("Hello world!")
end
# if-else-blocks compile down to "ite" actions
if no_tiles_remaining do
ryuukyoku
else
draw
end
# unless-blocks compile down to "unless" actions
unless no_tiles_remaining do
draw(1, "opposite_end")
end
# as-blocks compile to "as" actions
as everyone do
push_message("says hi")
end
# this becomes ["set_counter", "counter_name", "score"]
counter_name = "score"
# this becomes ["add_counter", "counter_name", 1]
counter_name += 1
endIn addition, you may call any user-defined function:
def myfun3 do
myfun
myfun2(val: 1)
endThis compiles to [["run", "myfun"], ["run", "myfun2", {"val": 1}]]
To make a function take parameters like this, simply use the parameter e.g. "$myparam" somewhere in the function. Then if a parameter myparam: 100 is provided, it will replace all instances of "$myparam" with 100 before running the function.
def myfun3 do
push_message("has a score of $score!", %{"score": "$myscore"})
end
# elsewhere:
def myfun4 do
my_score = "score"
myfun3(score: "my_score")
endSigils look like ~s"mysigil". They're just specially marked strings that expect some special syntax.
In particular, ~s specifies a set to be used, for example, in the define_set command.
define_set myset, ~s"0 1 2"This compiles to
.set_definitions["myset"] = [[0, 1, 2]]A more complex set using all aspects of the set grammar is the following:
define_set myset, ~s"0 1 2@myattr&myattr2 | 1z 2z 3z"This compiles to
.set_definitions["myset"] = [[0, 1, {"offset": 2, "attrs": ["myattr", "myattr2"]}], ["1z", "2z", "3z"]]Sets are mostly used in match definitions, but they are also extensively used in fu calculations.
~m specifies a match definition. The most common use for these is in use in the match condition:
match(["hand", "calls", "winning_tile"], ~m"exhaustive, iipeikou:1, mentsu:2, pair:1")This compiles to
{"name": "match", "opts": [
["hand", "calls", "winning_tile"],
[["exhaustive", [["iipeikou"], 1], [["mentsu"], 2], [["pair"], 2]]]
]}Another example:
match(["hand", "calls", "winning_tile"], ~m"(haku hatsu chun):2, (haku_pair hatsu_pair chun_pair):1 | (haku hatsu chun):3")This compiles to
{"name": "match", "opts": [
["hand", "calls", "winning_tile"],
[
[[["haku", "hatsu", "chun"], 2], [["haku_pair", "hatsu_pair", "chun_pair"], 1]],
[[["haku", "hatsu", "chun"], 3]]
]
]}~t"111m222p333s@attribute" and ~T"11m 12m 13m@attribute" are both ways to specify tiles. The lowercase ~t lets you specify an array of tiles using the standard mahjong compact format. The uppercase ~t lets you specify any of Riichi Advanced's extended tiles (see tiles.md).
~t"111m222p333s@attribute"
# compiles to [
# "1m","1m","1m","2p","2p","2p",
# {tile: "3s", attrs: ["attribute"]},
# {tile: "3s", attrs: ["attribute"]},
# {tile: "3s", attrs: ["attribute"]}
# ]
~T"11m 12m 13m@attr1&attr2"
# compiles to [
# "11m", "12m",
# {tile: "13m", attrs: ["attr1", "attr2"]}
# ]Tile sigils are basically only used for interpolation into rules text and for rigging the hand/wall. In the future it will also be used to interpolate into messages.
on after_initialization do
add_rule("2 Han", "Honitsu", "%{example_hand}", %{example_hand: ~t"123345888p11z22z 2z"})
end
set starting_hand, %{east: ~t"19m19p19s1234567z"}
set starting_draws, ~T"45m@_rainbow&_anim&_dora"Consider the following snippet from the Sichuan Bloody ruleset (sichuan.majs):
define_button pon,
...
call_conditions:
(status("void_manzu") and not_call_contains(["1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m"], 1))
or (status("void_pinzu") and not_call_contains(["1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p"], 1))
or (status("void_souzu") and not_call_contains(["1s", "2s", "3s", "4s", "5s", "6s", "7s", "8s", "9s"], 1)),
...
define_button daiminkan,
...
call_conditions:
(status("void_manzu") and not_call_contains(["1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m"], 1))
or (status("void_pinzu") and not_call_contains(["1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p"], 1))
or (status("void_souzu") and not_call_contains(["1s", "2s", "3s", "4s", "5s", "6s", "7s", "8s", "9s"], 1)),
...
define_button ankan,
...
call_conditions:
(status("void_manzu") and not_call_contains(["1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m"], 1))
or (status("void_pinzu") and not_call_contains(["1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p"], 1))
or (status("void_souzu") and not_call_contains(["1s", "2s", "3s", "4s", "5s", "6s", "7s", "8s", "9s"], 1)),
...
define_button kakan,
...
call_conditions:
(status("void_manzu") and not_call_contains(["1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m"], 1))
or (status("void_pinzu") and not_call_contains(["1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p"], 1))
or (status("void_souzu") and not_call_contains(["1s", "2s", "3s", "4s", "5s", "6s", "7s", "8s", "9s"], 1)),
...To avoid repeating code, the command define_const can be used to define constants. Constants can hold any MahjongScript data type, including do-blocks of actions! So the above becomes:
define_const no_voided_calls,
(status("void_manzu") and not_call_contains(["1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m"], 1))
or (status("void_pinzu") and not_call_contains(["1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p"], 1))
or (status("void_souzu") and not_call_contains(["1s", "2s", "3s", "4s", "5s", "6s", "7s", "8s", "9s"], 1))
define_button pon,
...
call_conditions: @no_voided_calls,
...
define_button daiminkan,
...
call_conditions: @no_voided_calls,
...
define_button ankan,
...
call_conditions: @no_voided_calls,
...
define_button kakan,
...
call_conditions: @no_voided_calls,
...Constants are referenced with the @ symbol. The underlying mechanism is that each reference to a constant is replaced by the string "@my_constant", and the engine walks through the entire JSON, replacing instances of "@my_constant" with its corresponding value.
When writing mods, you can extend the item at the path "constants.my_constant" using the apply command, for example:
# from the ruleset
define_const always_yakuhai, ["5z", "6z", "7z"]
# from the mod
apply append, "constants.always_yakuhai", "4z"Since constants are expanded at runtime, one way to configure behavior is to put your configurable condition or action into a constant, and have mods modify or replace the constant. For instance:
define_const closed_hand, has_no_call_named("chii", "pon", "daiminkan", "kakan")
# but in cosmic riichi:
define_const closed_hand, has_no_call_named("ton", "chii", "chon", "chon_honors", "daiminfuun", "pon", "daiminkan", "kapon", "kakakan", "kafuun", "kakan")
# all instances of @closed_hand will now use the latest definitionFor security reasons, constant names cannot contain uppercase letters.
Consider the following:
define_const can_chankan, status("can_chankan", as: "caller")
define_button chankan,
display_name: "Ron",
show_when: not_our_turn
and someone_else_just_called
and status_missing("furiten", "just_reached")
and +@can_chankan
and match(["hand", "calls"], ["tenpai"])
and match(["hand", "calls", "last_called_tile"], ["win"]),
...The idea is that one of the conditions in show_when has been factored out as a constant, so that the Kokushi Ankan Chankan mod can just modify that constant, instead of modifying every button. Since conditions are internally represented by a list [cond1, cond2, ...], the constant @can_chankan is actually internally an array [{"name": "status", "as": "caller", "opts": ["can_chankan"]}]. Replacing it directly would result in something like [cond1, cond2, [{"name": "status", "as": "caller", "opts": ["can_chankan"]}], ...] -- turning it into an OR condition, which may be undesirable.
The prefix +@ solves this by "splatting" the constant. That is, whenever the splatted constant reference appears inside an array, it injects its contents directly into the surrounding array.
You can splat conditions and even do-blocks, since both are represented internally as arrays.
The user of a .majs mod (e.g. a ruleset) can pass variables into it. For instance, the Tobi mod accepts a below variable specifying the minimum score a player can have.
Variables are referenced by prepending with !. Here's the Tobi mod:
apply set, "score_calculation.tobi", !belowFor security reasons, variables cannot contain uppercase letters.
def my_function_name do
action1
action2
if condition do
action3
else
action4
end
unless condition2 do
action5
end
as everyone do
action6
end
endThe above showcases all the special forms: if/do/end and if/do/else/end is are typical conditionals, unless is like if but inverts its condition, and as lets you switch the current player (and sort of serves as a loop, if multiple players are specified).
set initial_points, 25000To set (or otherwise modify) arbitrary paths, see apply below.
# append to list of existing handlers
on before_win do
actions
end
# prepend to list of existing handlers
on before_win, prepend: true do
actions
endon can be used to append or prepend to the list of existing handlers. In the above example, the second handler will run before the first handler.
define_set pair ~s"0 0"This command can only take set sigils (see above).
Defined sets are used in match sigils.
define_match mymatch1, ~m"pair:7"
define_match mymatch2, ~a"FF XXXX0a NEWS XXXX0b"
define_match mymatch3, "existing_match_1", "existing_match_2"This command can only take match sigils (see above). You may specify multiple match sigils, separated by commas -- it will act as an OR of the given matches.
define_match mymatch1, ~m"pair:7", ~a"FF XXXX0a NEWS XXXX0b"After defining them, you may use these match definitions by referencing them in the match condition:
# mymatch1 OR mymatch2
match(["hand", "calls", "winning_tile"], ["mymatch1", "mymatch2"])Note that you can directly pass in a match sigil to match, so define_match is simply a convenience command.
match(["hand", "calls", "winning_tile"], ~m"pair:7")Same syntax and function as define_match, but if the match exists, it will extend the match with the given match definitions.
define_match mymatch, ~m"mentsu:4, pair:1"
extend_match mymatch, ~m"pair:7"is the same as
define_match mymatch, ~m"mentsu:4, pair:1", ~m"pair:7"# arbitrary json
define_const foo, "asdf"
def bar do
print(@foo) # prints "asdf"
end
# sigils
define_const bar, ~m"pair:7"
# conditions
define_const bar, match(["hand", "calls", "winning_tile"], ~m"pair:7")
# do-blocks
define_const baz do # note the lack of comma before do
action1
action2
endSee the explanation of constants above.
define_yaku list_name, display_name, value, conditionIf you define a yaku with the same display_name of an existing yaku, then obtaining both yaku adds the value of both yakus.
After the condition you may optionally specify a list of yaku names as shorthand for the below:
define_yaku list_name, display_name, value, condition, supercedes_list
# is the same as
define_yaku list_name, display_name, value, condition
define_yaku_precedence display_name, supercedes_listdefine_yaku_precedence "Daisangen", ["Shousangen", "Haku", "Hatsu", "Chun"]
define_yaku_precedence "Renhou", [1,2,3,4]You may specify a list of yaku display_names that the given yaku overrides. This means whenever the given yaku on the left is awarded, it erases all of the overridden yaku on the right. You can also have it override itself, effectively making it so the yaku only exists to override other yaku.
You can also specify a list of numbers -- this specifies all yaku of a given value. You can also mix display_names and numbers.
remove_yaku yaku_list, name
remove_yaku yaku_list, [name1, name2]replace_yaku list_name, display_name, value, condition, optional_supercedes_listThis is essentially the same as remove_yaku display_name followed by define_yaku, except it will do nothing if the yaku doesn't exist in the first place.
define_button id,
display_name: display_name,
show_when: condition,
precedence_over: list_of_ids,
unskippable: false,
cancellable: false
do
actions
endNote that any existing button of the same id will be overwritten. See the button documentation in the main documentation.
define_auto_button id,
display_name: display_name,
desc: string,
enabled_at_start: false
do
actions
endNote that any existing auto button of the same id will be overwritten. See the auto buttons documentation in the main documentation.
# append a mod category to the list
define_mod_category "Other"
# prepend a mod category to the list
define_mod_category "Rules", prepend: trueYou can have multiple instances of a category, but this is largely useless since define_mod below only adds to the first instance.
define_mod id,
name: string,
desc: string,
default: false,
order: 0,
deps: list of ids,
conflicts: list of ids
category: nameNote that if category is not specified, the mod is simply appended to the end of the mod list.
config_mod id,
name: config name,
values: ["Mangan", "Yakuman"],
default: "Yakuman"remove_mod id
remove_mod id1, id2, id2# add to an existing path
apply add, "initial_points", 5000
# set values at arbitrary paths
apply set, "score_calculation.tenpairenchan", true
# append to a function
apply append, "functions.myfunc" do
as everyone do
add_score(1000)
end
endThe allowed methods for apply <method> are:
"set": set the value at the given path."initialize": set the value at the given path, but only if it doesn't exist"add": add a numeric value."prepend": prepend an element or an array to an array."append": append an element or an array to an array, or create the array if it doesn't exist."merge": merge an object to an existing object."subtract": subtract a numeric value."delete": remove an element or an array of elements from an array."multiply": multiply a numeric value."deep_merge": merge an object to an existing object, and merge shared keys recursively."divide": divide a numeric value."modulo": modulo a numeric value."delete_key": remove a key or an array of keys from an object. (specify keys as strings)- any of the following C binary numeric operations theoretically work too:
"atan2", "copysign", "drem", "fdim", "fmax", "fmin", "fmod", "frexp", "hypot", "jn", "ldexp", "modf", "nextafter", "nexttoward", "pow", "remainder", "scalb", "scalbln", "yn"
If the parent node for the given path doesn't exist, the command does nothing (with the exception of apply initialize or apply set, in which case the path is created). You can also make an exception for this by prepending set_ to the method, such as apply set_append -- this will append but default to set if the path doesn't exist.
The path syntax is simple: it's .key to access a key and [0] to access the first element of an array. e.g. toplevel_key.some_key[1].
replace all, "", ["pon"], ["pon", "daiminkan", "kakan"]
replace all, "available_mods",
%{type: "dropdown", name: "below", values: [0, 1, 1000, 1001]},
%{type: "dropdown", name: "below", values: [0, 1, 1000, 1001], default: 1}Essentially: given a path and two values from and to, look at all subnodes of the given path and replace all instances of from with to.
define_preset "Mahjong Soul", [
"riichi_kan",
%{name: "honba", config: %{value: 100}},
%{name: "yaku/riichi", config: %{bet: 1000, drawless: false}},
%{name: "nagashi", config: %{is: "Mangan"}},
%{name: "tobi", config: %{below: 0}},
%{name: "uma", config: %{_1st: 10, _2nd: 5, _3rd: -5, _4th: -10}},
"agarirenchan",
...
}