Commit 2915981e authored by TanakaKotoha's avatar TanakaKotoha

dededededededededededede

parent 5d134e2e
{
"id": "downloadlogs",
"version": "1.0.2",
"version": "1.1.0",
"name": "按S下载牌谱",
"author": "凉宫杏树",
"description": "太好了,准备把牌谱拿给主人看。",
......
// ==UserScript==
// @name downloadlogs
// @namespace reddit
// @namespace mjg
// @icon https://cdn.myanimelist.net/images/characters/12/75583.jpg
// @version 0.0.2
// @description download logs for akochan
// @version 0.1.0
// @description save mjs logs
// @include https://mahjongsoul.game.yo-star.com/
// @include https://game.mahjongsoul.com/
// @include https://majsoul.union-game.com/0/
// ==/UserScript==
(function() {
//the key we listen for
//var KEY = 17; //ctrl
const KEY = 83; //"s"
const VERBOSELOG = false;
(function()
{ //variables you might actually want to change
const KEY = 83; //key we listen for; "s" is 83 - https://keycode.info/
const NAMEPREF = 1; //2 for english, 1 for sane amount of weeb, 0 for japanese
const VERBOSELOG = false; //dump mjs records to output - will make the file too large for tenhou.net/5 viewer
const PRETTY = true; //make the written log somewhat human readable
const SHOWFU = false; //always show fu/han for scoring - even for limit hands
//words that can end up in log, some are mandatory kanji in places
const JPNAME = 0;
const RONAME = 1;
const ENNAME = 2;
const RUNES = {
/*hand limits*/
"mangan" : ["満貫", "Mangan ", "Mangan " ],
"haneman" : ["跳満", "Haneman ", "Haneman " ],
"baiman" : ["倍満", "Baiman ", "Baiman " ],
"sanbaiman" : ["三倍満", "Sanbaiman ", "Sanbaiman " ],
"yakuman" : ["役満", "Yakuman ", "Yakuman " ],
"kazoeyakuman" : ["数え役満", "Kazoe Yakuman ", "Counted Yakuman " ],
"kiriagemangan" : ["切り上げ満貫", "Kiriage Mangan ", "Rounded Mangan " ],
/*round enders*/
"agari" : ["和了", "Agari", "Agari" ],
"ryuukyoku" : ["流局", "Ryuukyoku", "Exhaustive Draw" ],
"nagashimangan" : ["流し満貫", "Nagashi Mangan", "Mangan at Draw" ],
"suukaikan" : ["四開槓", "Suukaikan", "Four Kan Abortion" ],
"sanchahou" : ["三家和", "Sanchahou", "Three Ron Abortion" ],
"kyuushukyuuhai" : ["九種九牌", "Kyuushu Kyuuhai", "Nine Terminal Abortion"],
"suufonrenda" : ["四風連打", "Suufon Renda", "Four Wind Abortion" ],
"suuchariichi" : ["四家立直", "Suucha Riichi", "Four Riichi Abortion" ],
/*scoring*/
"fu" : ["", /*"Fu",*/"", "Fu" ],
"han" : ["", /*"Han",*/"", "Han" ],
"points" : ["", /*"Points",*/"", "Points" ],
"all" : ["", "", "" ],
"pao" : ["", "pao", "Responsibility" ],
/*rooms*/
"tonpuu" : ["東喰", " East", " East" ],
"hanchan" : ["南喰", " South", " South" ],
"friendly" : ["友人戦", "Friendly", "Friendly" ],
"tournament" : ["大会戦", "Tounament", "Tournament" ],
"sanma" : ["", "3-Player ", "3-Player " ],
"red" : ["", " Red", " Red Fives" ],
"nored" : ["", " Aka Nashi", " No Red Fives" ]
};
//senkinin barai yaku - please don't change, yostar..
const DAISANGEN = 37; //daisangen cfg.fan.fan.map_ index
const DAISUUSHI = 50;
const TSUMOGIRI = 60; //tenhou tsumogiri symbol
//global variables - don't touch
var ALLOW_KIRIAGE = false; //potentially allow this to be true
var TSUMOLOSSOFF = false; //sanma tsumo loss, is set true for sanma when tsumo loss off
//listen for key press, modified from anonymizer mod
function checkscene(scene) {
function checkscene(scene)
{
return scene && ((scene.Inst && scene.Inst._enable) || (scene._Inst && scene._Inst._enable));
}
// GameMgr.Inst.record_uuid becomes populated when we have looked at a log
document.addEventListener("keydown", function(e) {
document.addEventListener("keydown", function(e)
{ // GameMgr.Inst.record_uuid becomes populated when we have looked at a log
e = e || window.event;
if ((KEY == e.keyCode || KEY == e.key) && GameMgr.Inst.record_uuid)
if (checkscene(uiscript.UI_Replay) || checkscene(uiscript.UI_Loading))
......@@ -31,7 +80,8 @@
});
//pop-up window for downloading
function download(filename, text) {
function download(filename, text)
{
var element = document.createElement("a");
element.setAttribute(
"href",
......@@ -42,182 +92,345 @@
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
return;
}
// tenhou's tile encoding:
// 11-19 - 1-9 man
// 21-29 - 1-9 pin
// 31-39 - 1-9 sou
// 41-47 - ESWN WGR
// 51,52,53 - red 5 man, pin, sou
function tm2t(str) {
//take '2m' and return 2 + 10 etc.
//pad a to length l with f, needed to pad log for >sanma
const pad_right = (a, l, f) =>
!Array.from({length: l - a.length})
.map(_ => a.push(f)) || a;
//take '2m' and return 2 + 10 etc.
function tm2t(str)
{ //tenhou's tile encoding:
// 11-19 - 1-9 man
// 21-29 - 1-9 pin
// 31-39 - 1-9 sou
// 41-47 - ESWN WGR
// 51,52,53 - aka 5 man, pin, sou
var num = parseInt(str[0]);
const pad = { m: 1, p: 2, s: 3, z: 4 };
return num ? 10 * pad[str[1]] + num : 50 + pad[str[1]];
const tcon = { m : 1, p : 2, s : 3, z : 4 };
return num ? 10 * tcon[str[1]] + num : 50 + tcon[str[1]];
}
//return normal tile from aka, tenhou rep
function deaka(til)
{ //alternativly - use strings
if (5 == ~~(til/10))
return 10*(til%10)+(~~(til/10));
return til;
}
//return aka version of tile
function makeaka(til)
{
if (5 == (til%10)) //is a five (or haku)
return 10*(til%10)+(~~(til/10));
return til; //can't be/already is aka
}
//round up to nearest hundred iff TSUMOLOSSOFF == true otherwise return 0
function tlround(x)
{
return TSUMOLOSSOFF ? 100*Math.ceil(x/100) : 0;
}
//parse mjs hule into tenhou agari list
function parsehule(h, kyoku)
{ //tenhou log viewer requires 点, 飜) or 役満) to end strings, rest of scoring string is entirely optional
//who won, points from (self if tsumo), who won or if pao: who's responsible
var res = [h.seat, h.zimo ? h.seat : kyoku.ldseat, h.seat];
var delta = []; //we need to compute the delta ourselves to handle double/triple ron
var points = 0;
var rp = (-1 != kyoku.nriichi) ? 1000 * (kyoku.nriichi + kyoku.round[2]) : 0; //riichi stick points, -1 means already taken
var hb = 100 * kyoku.round[1]; //base honba payment
//sekinin barai logic
var pao = false;
var liableseat = -1;
var liablefor = 0;
if (h.yiman)
{ //only worth checking yakuman hands
h.fans.forEach(e =>
{
if (DAISUUSHI == e.id && (-1 != kyoku.paowind))
{ //daisuushi pao
pao = true;
liableseat = kyoku.paowind;
liablefor += e.val; //realistically can only be liable once
}
else if (DAISANGEN == e.id && (-1 != kyoku.paodrag))
{
pao = true;
liableseat = kyoku.paodrag;
liablefor += e.val;
}
});
}
if (h.zimo)
{ //ko-oya payment for non-dealer tsumo
//delta = [...new Array(kyoku.nplayers)].map(()=> (-hb - h.point_zimo_xian));
delta = new Array(kyoku.nplayers).fill(-hb - h.point_zimo_xian - tlround((1/2) * (h.point_zimo_xian)))
if (h.seat == kyoku.dealerseat) //oya tsumo
{
delta[h.seat] = rp + (kyoku.nplayers - 1) * (hb + h.point_zimo_xian) + 2 * tlround((1/2) * (h.point_zimo_xian));
points = h.point_zimo_xian + tlround((1/2) * (h.point_zimo_xian));
}
else //ko tsumo
{
delta[h.seat] = rp + hb + h.point_zimo_qin + (kyoku.nplayers - 2) * (hb + h.point_zimo_xian) + 2 * tlround((1/2) * (h.point_zimo_xian));
delta[kyoku.dealerseat] = -hb - h.point_zimo_qin - tlround((1/2) * (h.point_zimo_xian));
points = h.point_zimo_xian + "-" + h.point_zimo_qin;
}
}
else
{ //ron
delta = new Array(kyoku.nplayers).fill(0.)
delta[h.seat] = rp + (kyoku.nplayers - 1) * hb + h.point_rong;
delta[kyoku.ldseat] = -(kyoku.nplayers - 1) * hb - h.point_rong;
points = h.point_rong;
kyoku.nriichi = -1; //mark the sticks as taken, in case of double ron
}
//sekinin barai payments
// treat pao as the liable player paying back the other players - safe for multiple yakuman
const OYA = 0;
const KO = 1;
const RON = 2;
const YSCORE = [ //yakuman scoring table
//oya, ko, ron pays
[0, 16000, 48000], //oya wins
[16000, 8000, 32000] //ko wins
];
if (pao)
{
res[2] = liableseat; //this is how tenhou does it - doesn't really seem to matter to akochan or tenhou.net/5
if (h.zimo) //liable player needs to payback n yakuman tsumo payments
{
if (h.qinjia) //dealer tsumo
{ //should treat tsumo loss as ron, luckily all yakuman values round safely for north bisection
delta[liableseat] -= 2 * hb + liablefor * 2 * YSCORE[OYA][KO] + tlround((1/2) * liablefor * YSCORE[OYA][KO]); // 1? only paying back other ko
delta.forEach((e, i) =>
{
if (liableseat != i && h.seat != i && kyoku.nplayers >= i)
delta[i] += hb + liablefor * YSCORE[OYA][KO] + tlround((1/2) * liablefor * (YSCORE[OYA][KO]));
});
if (3 == kyoku.nplayers) //dealer should get north's payment from liable
delta[h.seat] += (TSUMOLOSSOFF ? 0 : liablefor * YSCORE[OYA][KO]);
}
else //non-dealer tsumo
{
delta[liableseat] -= (kyoku.nplayers - 2) * hb + liablefor * (YSCORE[KO][OYA] + YSCORE[KO][KO]) + tlround((1/2) * liablefor * YSCORE[KO][KO]); //^^same 1st, but ko
delta.forEach((e, i) =>
{
if (liableseat != i && h.seat != i && kyoku.nplayers >= i)
{
if (kyoku.dealerseat == i)
delta[i] += hb + liablefor * YSCORE[KO][OYA] + tlround((1/2) * liablefor * YSCORE[KO][KO]); //^^same 1st ...
else
delta[i] += hb + liablefor * YSCORE[KO][KO] + tlround((1/2) * liablefor * YSCORE[KO][KO]); //^^same 1st ...
}
});
}
}
else //ron
{
//liable seat pays the deal-in seat 1/2 yakuman + full honba
delta[liableseat] -= (kyoku.nplayers - 1) * hb + (1/2) * liablefor * YSCORE[h.qinjia ? OYA : KO][RON];
delta[kyoku.ldseat] += (kyoku.nplayers - 1) * hb + (1/2) * liablefor * YSCORE[h.qinjia ? OYA : KO][RON];
}
} //if pao
//append point symbol
points += RUNES.points[JPNAME] + ((h.zimo && h.qinjia) ? RUNES.all[NAMEPREF]: "");
//score string
var fuhan = h.fu + RUNES.fu[NAMEPREF] + h.count + RUNES.han[NAMEPREF];
if (h.yiman) //yakuman
res.push((SHOWFU ? fuhan : "") + RUNES.yakuman[NAMEPREF] + points);
else if (13 <= h.count) //kazoe
res.push((SHOWFU ? fuhan : "") + RUNES.kazoeyakuman[NAMEPREF] + points);
else if (11 <= h.count) //sanbaiman
res.push((SHOWFU ? fuhan : "") + RUNES.sanbaiman[NAMEPREF] + points);
else if (8 <= h.count) //baiman
res.push((SHOWFU ? fuhan : "") + RUNES.baiman[NAMEPREF] + points);
else if (6 <= h.count) //haneman
res.push((SHOWFU ? fuhan : "") + RUNES.haneman[NAMEPREF] + points);
else if (5 <= h.count || (4 <= h.count && 40 <= h.fu) || (3 <= h.count && 70 <= h.fu)) //mangan
res.push((SHOWFU ? fuhan : "") + RUNES.mangan[NAMEPREF] + points);
else if (ALLOW_KIRIAGE && ((4 == h.count && 30 == h.fu) || (3 == h.count && 60 == h.fu))) //kiriage
res.push((SHOWFU ? fuhan : "") + RUNES.kiriagemangan[NAMEPREF] + points);
else //ordinary hand
res.push(fuhan + points);
h.fans.forEach(e => res.push(
(JPNAME == NAMEPREF ? cfg.fan.fan.map_[e.id].name_jp : cfg.fan.fan.map_[e.id].name_en)
+ "(" + (h.yiman ? (RUNES.yakuman[JPNAME]) : (e.val + RUNES.han[JPNAME]) )+ ")"
));
return [pad_right(delta, 4, 0.), res];
}
//round information, to be reset every RecordNewRound
var actiontable = [];
actiontable.init = function(haipais) {
this.draws = [];
this.discards = [];
this.haipais = [];
haipais.forEach(e => {
this.draws.push([]);
this.discards.push([]);
this.haipais.push(e);
});
this.ponedfrom = [];
var kyoku = [];
kyoku.init = function(leaf)
{ //[kyoku, honba, riichi sticks] - NOTE: 4 mult. works for sanma
this.nplayers = leaf.scores.length;
this.round = [4 * leaf.chang + leaf.ju, leaf.ben, leaf.liqibang];
this.initscores = leaf.scores; pad_right(this.initscores, 4, 0);
this.doras = leaf.doras.map(e => tm2t(e));
this.draws = [[],[],[],[]];
this.discards = [[],[],[],[]];
this.haipais = this.draws.map( (_, i) => leaf["tiles" + i].map( f => tm2t(f)));
//treat the last tile in the dealer's hand as a drawn tile
this.poppedtile = this.haipais[leaf.ju].pop();
this.draws[leaf.ju].push(this.poppedtile);
//information we need, but can't expect in every record
this.dealerseat = leaf.ju;
this.ldseat = -1; //who dealt the last tile
this.nriichi = 0; //number of current riichis - needed for scores, abort workaround
this.nkan = 0; //number of current kans - only for abort workaround
//pao rule
this.nowinds = new Array(4).fill(0);//counter for each players open wind pons/kans
this.nodrags = new Array(4).fill(0);
this.paowind = -1; //seat of who dealt the final wind, -1 if no one is responsible
this.paodrag = -1;
return this;
};
//general form of how we dump round informaion
//NOTE: doras,uras are the indicators
actiontable.dump = function(uras) {
//dump round informaion
kyoku.dump = function(uras)
{ //NOTE: doras,uras are the indicators
var entry = [];
entry.push(actiontable.round);
entry.push(actiontable.initscores);
entry.push(actiontable.doras);
entry.push(kyoku.round);
entry.push(kyoku.initscores);
entry.push(kyoku.doras);
entry.push(uras);
actiontable.haipais.forEach((f,i) => {
kyoku.haipais.forEach((f,i) =>
{
entry.push(f);
entry.push(actiontable.draws[i]);
entry.push(actiontable.discards[i]);
entry.push(kyoku.draws[i]);
entry.push(kyoku.discards[i]);
});
return entry;
}
function relativeseating(seat0, seat1, dim) {
//take two seats, return 0 if seat1 is kamicha,
// 1 if seat 1 is toimen, 2 if shimocha realative to seat0
//used in generating call symbols, extra +dim b/c .js
return (seat0 - seat1 + dim - 1) % dim;
}
function parse(record) {
var res = {};
res["ver"] = "2.3"; // mlog version number
res["ref"] = record.head.uuid; // game id
//sekinin barai tiles
const WINDS = ["1z", "2z", "3z", "4z"].map(e => tm2t(e));
const DRAGS = ["5z", "6z", "7z", "0z"].map(e => tm2t(e)); //0z would be aka haku
var ruledisp = "";
if (record.head.config.meta.mode_id) //normal room
ruledisp = cfg.desktop.matchmode.map_[record.head.config.meta.mode_id].room_name_en;
else if (record.head.config.meta.room_id) //friendly
ruledisp = "Friendly";
else if (record.head.config.meta.contest_uid)//tourney
ruledisp = "Tournament";
if (1 == record.head.config.mode.mode)
ruledisp += " East";
else if (2 == record.head.config.mode.mode)
ruledisp += " South";
if (! record.head.config.meta.mode_id && ! record.head.config.mode.detail_rule.dora_count)
//senkinin barai incrementer - to be called every pon, daiminkan, ankan
kyoku.countpao = function(tile, owner, feeder)
{ //owner and feeder are seats, tile should be tenhou
if (WINDS.includes(tile))
{
ruledisp += " Aka Nashi";
res["rule"] = {"disp":ruledisp, "aka53" : 0, "aka52" : 0, "aka51": 0};
if(4 == ++this.nowinds[owner])
this.paowind = feeder;
}
else
res["rule"] = {"disp":ruledisp, "aka53" : 1, "aka52" : 1, "aka51": 1};
//NOTE: this works fine for anonymous logs :^)
res["dan"] = record.head.accounts.map(e => cfg.level_definition.level_definition.map_[e.level.id].full_name_en);
res["title"] = [
record.head.config.category, //dummy entries
record.head.config.meta.mode_id //
];
res["name"] = record.head.accounts.map(e => e.nickname);
//scores: doing points and change in rankpoints. it probably
//should be oka,uma'd points. w/e
res["sc"] = record.head.result.players
.map(e => [e.part_point_1, e.grading_score])
.flat();
//game record
res["mjshead"] = record.head;
res["mjslog"] = net.MessageWrapper.decodeMessage(
record.data).records.map(e => net.MessageWrapper.decodeMessage(e));
//make the constructor names available in json output
res["mjsrecordtypes"] = res.mjslog.map(e => e.constructor.name);
//convert to tenhou log
var nplayers = res.name.length;
else if (DRAGS.includes(tile))
{
if(3 == ++this.nodrags[owner])
this.paodrag = feeder;
}
return;
}
//seat1 is seat0's x
function relativeseating(seat0, seat1)
{ //0: kamicha, 1: toimen, 2: if shimocha
return (seat0 - seat1 + 4 - 1) % 4;
}
//convert mjs records to tenhou log
function generatelog(mjslog)
{
var log = [];
res["log"] = res.mjslog.forEach((e, leafidx) => {
switch (e.constructor.name) {
case "RecordNewRound": {
//TODO: move everything into init, or nothing..
actiontable.init(
//get haipais, this way should handle >sanma
res.name.map((f, i) => e["tiles" + i].map(g => tm2t(g)))
);
// kyoku, honba, riichi sticks
actiontable.round = [ nplayers * e.chang + e.ju, e.ben, e.liqibang ];
actiontable.initscores = e.scores; //scores at the beginning of the round
//treat the last tile in the dealer's hand as a drawn tile
actiontable.poppedtile = actiontable.haipais[e.ju].pop();
actiontable.draws[e.ju].push(actiontable.poppedtile);
actiontable.dealerseat = e.ju;
actiontable.doras = e.doras.map(f => tm2t(f));
mjslog.forEach((e, leafidx) =>
{
switch (e.constructor.name)
{
case "RecordNewRound":
{ //new round
kyoku.init(e);
return;
}
case "RecordDiscardTile": {
//sometimes we get dora passed here
if (e.doras && e.doras.length > actiontable.doras.length)
actiontable.doras = e.doras.map(f => tm2t(f));
//record the discard, pre-pending 'r' with riichi
case "RecordDiscardTile":
{ //discard - marking tsumogiri and riichi
var symbol = e.moqie ? TSUMOGIRI : tm2t(e.tile);
if (e.seat == actiontable.dealerseat
&& !actiontable.discards[e.seat].length && symbol == actiontable.poppedtile)
//we pretend that the dealer's initial 14th tile is drawn - so we need to manually check the first discard
if (e.seat == kyoku.dealerseat
&& !kyoku.discards[e.seat].length && symbol == kyoku.poppedtile)
symbol = TSUMOGIRI;
actiontable.discards[e.seat].push(
e.is_liqi ? "r" + symbol : symbol);
actiontable.lastdiscardseat = e.seat; //for ron, pon etc.
if (e.is_liqi) //riichi delcaration
{
kyoku.nriichi++;
symbol = "r" + symbol;
}
kyoku.discards[e.seat].push(symbol);
kyoku.ldseat = e.seat; //for ron, pon etc.
//sometimes we get dora passed here
if (e.doras && e.doras.length > kyoku.doras.length)
kyoku.doras = e.doras.map(f => tm2t(f));
return;
}
case "RecordDealTile": {
//after kan this gets passed the new dora
if (e.doras && e.doras.length > actiontable.doras.length)
actiontable.doras = e.doras.map(f => tm2t(f));
case "RecordDealTile":
{ //draw - after kan this gets passed the new dora
if (e.doras && e.doras.length > kyoku.doras.length)
kyoku.doras = e.doras.map(f => tm2t(f));
actiontable.draws[e.seat].push(tm2t(e.tile));
kyoku.draws[e.seat].push(tm2t(e.tile));
return;
}
case "RecordChiPengGang": {
//we have a call
//TODO: clean this up/simplify
switch (e.type) {
case 0: {
//chii
actiontable.draws[e.seat].push(
case "RecordChiPengGang":
{ //call - chi, pon, daiminkan
switch (e.type)
{
case 0:
{ //chii
kyoku.draws[e.seat].push(
"c" +
tm2t(e.tiles[2]) +
tm2t(e.tiles[0]) +
tm2t(e.tiles[1])
);
return;
}
case 1: {
//pon
case 1:
{ //pon
var worktiles = e.tiles.map(f => tm2t(f));
var idx = relativeseating(
e.seat,
actiontable.lastdiscardseat,
nplayers
);
worktiles.splice(idx, 0, "p");
actiontable.draws[e.seat].push(worktiles.join(""));
//save idx for shouminkan
actiontable.ponedfrom[e.tiles[0]] = idx;
var idx = relativeseating(e.seat, kyoku.ldseat);
kyoku.countpao(worktiles[0], e.seat, kyoku.ldseat);
//pop the called tile a preprend 'p'
worktiles.splice(idx, 0, "p" + worktiles.pop());
kyoku.draws[e.seat].push(worktiles.join(""));
return;
}
case 2: {
///////////////////////////////////////////////////
case 2:
{ ///////////////////////////////////////////////////
// kan naki:
// daiminkan:
// kamicha "m39393939" (0)
// toimen "39m393939" (1)
// shimocha "222222m22" (3)
// (writes to draws; 0 to discards)
// shouminkan: (same as pon)
// shouminkan: (same order as pon; immediate tile after k is the added tile)
// kamicha "k37373737" (0)
// toimen "31k313131" (1)
// shimocha "3737k3737" (2)
......@@ -229,15 +442,16 @@
//daiminkan
var calltiles = e.tiles.map(f => tm2t(f));
// < kamicha 0 | toimen 1 | shimocha 3 >
var idx = relativeseating(
e.seat,
actiontable.lastdiscardseat,
nplayers
);
calltiles.splice( 2 == idx ? 3 : idx, 0, "m");
actiontable.draws[e.seat].push(calltiles.join(""));
var idx = relativeseating(e.seat, kyoku.ldseat);
kyoku.countpao(calltiles[0], e.seat, kyoku.ldseat);
calltiles.splice( 2 == idx ? 3 : idx, 0, "m" + calltiles.pop());
kyoku.draws[e.seat].push(calltiles.join(""));
//tenhou drops a 0 in discards for this
actiontable.discards[e.seat].push(0);
kyoku.discards[e.seat].push(0);
//register kan
kyoku.nkan++;
return;
}
default:
......@@ -245,125 +459,262 @@
"didn't know what to do with " +
e.constructor.name + "(" + leafidx + ")"
);
return;
return;
}
}
case "RecordAnGangAddGang" : {
//keyletter is 'k' for shouminkan, 'a' for ankan
var callstr;
var offset = 0;
switch (e.type) {
case 3: {//ankan
callstr = "a";
offset = 3;
//actiontable.lastdiscardseat = e.seat;
break;
case "RecordAnGangAddGang" :
{ //kan - shouminkan 'k', ankan 'a'
//NOTE: e.tiles here is a single tile; naki is placed in discards
var til = tm2t(e.tiles);
switch (e.type)
{
case 3:
{ //ankan
////////////////////
// mjs chun ankan example record:
//{"seat":0,"type":3,"tiles":"7z"}
////////////////////
kyoku.countpao(til, e.seat, -1); //count the group as visible, but don't set pao
//get the tiles from haipai and draws that
//are involved in ankan, dumb
//because n aka might be involved
var ankantiles = kyoku.haipais[e.seat].filter(t => (deaka(t) == deaka(til) ? true : false))
.concat(kyoku.draws[e.seat].filter(t => (deaka(t) == deaka(til) ? true : false)) );
til = ankantiles.pop(); //doesn't really matter which tile we mark ankan with - chosing last drawn
kyoku.discards[e.seat].push(ankantiles.join("") + "a" + til); //push naki
kyoku.nkan++;
return;
}
case 2: {//shouminkan
callstr = "k";
offset = actiontable.ponedfrom[e.tiles];
break;
case 2:
{ //shouminkan
//get pon naki from .draws and swap in new symbol
var nakis = kyoku.draws[e.seat].filter(w =>
{
if ('string' === typeof w) //naki
return w.includes("p" + deaka(til)) || w.includes("p" + makeaka(til)); //pon involves same tile type
else
return false;
});
if (!nakis.length)
{
console.log("could not find previous pon naki for shouminkan with " + e.tiles);
return;
}
kyoku.discards[e.seat].push(nakis[0].replace(/p/, "k" + til)); //push naki
kyoku.nkan++;
return;
}
default: {
default:
{
console.log("didn't know what to do with "
+ e.constructor.name + " type: " + e.type);
return;
}
}
//NOTE: e.tiles is a single tile here
var til = tm2t(e.tiles);
var calltiles = [til, til, til, til];// e.tiles.map(f => tm2t(f));
calltiles.splice(offset, 0, callstr);//convient case of them matching
//relativeseating(
// e.seat,
// actiontable.lastdiscardseat,
// res.name.length, 0, callstr );
//NOTE: this maps to discards unlike normal calls
actiontable.discards[e.seat].push(calltiles.join(""));
return;
}
case "RecordBaBei" :
{ //kita - this record (only) gives {seat, moqie}
//NOTE: tenhou doesn't mark it's kita based on when they were drawn, so we won't
//if (e.moqie)
// kyoku.discards[e.seat].push("f" + TSUMOGIRI);
//else
kyoku.discards[e.seat].push("f44");
return;
}
/////////////////////////////////////////////////////
// round enders:
// "RecordNoTile" - ryuukoku
// "RecordNoTile" - ryuukyoku
// "RecordHule" - agari - ron/tsumo
// "RecordLiuJu" - abortion
//////////////////////////////////////////////////////
case "RecordLiuJu" : {
//abortion: only have checked 9-terminal..
var entry = actiontable.dump([]);
//TODO: find the types for aborts other than kyushukyuhai
case "RecordLiuJu" :
{ //abortion
var entry = kyoku.dump([]);
if (1 == e.type)
entry.push(["九種九牌"]); //kyushukyuhai
else //assuming this for now
entry.push(["四家立直"]); //4 riichi
entry.push([RUNES.kyuushukyuuhai[NAMEPREF]]); //kyuushukyuhai
else if (2 == e.type)
entry.push([RUNES.suufonrenda[NAMEPREF]]); //suufon renda
else if (4 == kyoku.nriichi) //TODO: actually get the type code
entry.push([RUNES.suuchariichi[NAMEPREF]]); //4 riichi
else if (4 <= kyoku.nkan) //TODO: actually get type code
entry.push([RUNES.suukaikan[NAMEPREF]]); //4 kan, potentially false positive on 3 ron with 4 kans
else
entry.push([RUNES.sanchahou[NAMEPREF]]); //3 ron - can't actually get this in mjs
log.push(entry);
return;
}
case "RecordNoTile" : {
//ryuukoku
var entry = actiontable.dump([]);
entry.push(["流局", (e.scores && e.scores[0] && e.scores[0].delta_scores && e.scores[0].delta_scores.length) ? e.scores[0].delta_scores : [...new Array(nplayers)].map(()=>0.)]); //ryuukoku
case "RecordNoTile" :
{ //ryuukyoku
var entry = kyoku.dump([]);
var delta = new Array(4).fill(0.);
//NOTE: mjs wll not give delta_scores if everyone is (no)ten - TODO: minimize the autism
if (e.scores && e.scores[0] && e.scores[0].delta_scores && e.scores[0].delta_scores.length)
e.scores.forEach(f => f.delta_scores.forEach((g, i) => delta[i] += g)); //for the rare case of multiple nagashi, we sum the arrays
if (e.liujumanguan) //nagashi mangan
entry.push([RUNES.nagashimangan[NAMEPREF], delta])
else //normal ryuukyoku
entry.push([RUNES.ryuukyoku[NAMEPREF], delta]);
log.push(entry);
return;
}
case "RecordHule": {
//agari is all in one list
//TODO: don't show fu for limit hands maybe?
case "RecordHule":
{ //agari
var agari = [];
var ura = [];
e.hules.forEach( f => {
if (ura.length < (f.li_doras ? f.li_doras.length : 0))
e.hules.forEach( f =>
{
if (ura.length < (f.li_doras ? f.li_doras.length : 0)) //take the longest ura list - double ron with riichi + dama
ura = f.li_doras.map(g => tm2t(g));
agari.push(e.delta_scores); //TODO: split scores
//tenhou log viewer requires 点, 飜 to end strings
agari.push([
f.seat, f.zimo ? f.seat : actiontable.lastdiscardseat, f.seat,
//(f.zimo ? "Tsumo "+f.point_zimo_qin +" / "+f.point_zimo_xian : "Ron "+f.point_rong)+"点",
f.fu + ""+ f.count + "" + f.point_sum + "",
f.fans.map(g => cfg.fan.fan.map_[g.id].name_en + "("+ g.val + "飜)")
].flat()); //flatten han
agari.push(parsehule(f, kyoku));
});
var entry = actiontable.dump(ura);
entry.push( ["和了"].concat(agari) );
var entry = kyoku.dump(ura);
entry.push( [RUNES.agari[JPNAME]].concat(agari.flat()) ); //needs the japanese agari
log.push(entry);
return;
}
default:
console.log(
"didn't know what to do with " + e.constructor.name + "(" + leafidx + ")"
);
return;
return;
}
});
res["log"] = log;
//clean up the output
if (! VERBOSELOG)
return log;
}
//this is the json struct that we write to file
function parse(record)
{
var res = {};
var ruledisp = "";
var lobby = ""; //usually 0, is the custom lobby number
var nplayers = record.head.result.players.length;
var nakas = nplayers - 1; //default
var mjslog = net.MessageWrapper.decodeMessage(record.data) //move-by-move details
.records.map(e => net.MessageWrapper.decodeMessage(e));
res["ver"] = "2.3"; // mlog version number
res["ref"] = record.head.uuid; // game id - copy and paste into "other" on the log page to view
res["log"] = generatelog(mjslog);
//PF4 is yonma, PF3 is sanma
res["ratingc"] = "PF" + nplayers;
//rule display
if (3 == nplayers && JPNAME == NAMEPREF)
ruledisp += RUNES.sanma[JPNAME];
if (record.head.config.meta.mode_id) //ranked or casual
ruledisp += (JPNAME == NAMEPREF) ?
cfg.desktop.matchmode.map_[record.head.config.meta.mode_id].room_name_jp
: cfg.desktop.matchmode.map_[record.head.config.meta.mode_id].room_name_en;
else if (record.head.config.meta.room_id) //friendly
{
lobby = ": " + record.head.config.meta.room_id; //can set room number as lobby number
ruledisp += RUNES.friendly[NAMEPREF]; //"Friendly";
nakas = record.head.config.mode.detail_rule.dora_count;
TSUMOLOSSOFF = (3 == nplayers) ? ! record.head.config.mode.detail_rule.have_zimosun : false;
}
else if (record.head.config.meta.contest_uid) //tourney
{
delete res["mjslog"];
delete res["mjshead"];
delete res["mjsrecordtypes"];
lobby = ": " + record.head.config.meta.contest_uid;
ruledisp += RUNES.tournament[NAMEPREF]; //"Tournament";
nakas = record.head.config.mode.detail_rule.dora_count;
TSUMOLOSSOFF = (3 == nplayers) ? ! record.head.config.mode.detail_rule.have_zimosun : false;
}
if (1 == record.head.config.mode.mode)
{
ruledisp += RUNES.tonpuu[NAMEPREF]; //" East";
}
else if (2 == record.head.config.mode.mode)
{
ruledisp += RUNES.hanchan[NAMEPREF]; //" South";
}
if (! record.head.config.meta.mode_id && ! record.head.config.mode.detail_rule.dora_count)
{
if (JPNAME != NAMEPREF)
ruledisp += RUNES.nored[NAMEPREF];
res["rule"] = {"disp" : ruledisp, "aka53" : 0, "aka52" : 0, "aka51": 0};
}
else
{
if (JPNAME == NAMEPREF)
ruledisp += RUNES.red[JPNAME];
res["rule"] = {"disp" : ruledisp, "aka53" : 1, "aka52" : (4 == nakas ? 2 : 1), "aka51": (4 == nplayers ? 1 : 0)};
}
res["lobby"] = 0; //tenhou custom lobby - could be tourney id or friendly room for mjs. appending to title instead to avoid 3->C etc. in tenhou.net/5
res["dan"] = record.head.accounts.map(e =>
(JPNAME == NAMEPREF) ?
cfg.level_definition.level_definition.map_[e.level.id].full_name_jp
: cfg.level_definition.level_definition.map_[e.level.id].full_name_en
);
pad_right(res["dan"], 4, "");
res["rate"] = record.head.accounts.map(e => e.level.score); //level score, closest thing to rate
pad_right(res["rate"],4, 0);
res["sx"] = record.head.accounts
.map(e => cfg.item_definition.character.map_[e.character.charid].sex)
.map(e => (e == 1 ? "F" : (e == 2 ? "M" : "C"))); //player's sex
pad_right(res["sx"], 4, "C");
//mjs results are sorted by placement (giving seats), tenhou sorts by seat
var scores = record.head.result.players
.map(e => [e.seat, e.part_point_1, e.total_point / 1000]);
res["sc"] = new Array(8).fill(0);
scores.forEach((e, i) => {res["sc"][2 * e[0]] = e[1]; res["sc"][2 * e[0] + 1] = e[2];});
res["name"] = record.head.accounts.map(e => e.nickname);
pad_right(res["name"], 4, "");
//optional title - why not give the room and put the timestamp here; 1000 for unix to .js timestamp convention
res["title"] = [ ruledisp + lobby,
(new Date(record.head.end_time * 1000)).toLocaleString()
];
//optionally dump mjs records NOTE: this will likely make the file too large for tenhou.net/5 viewer
if (VERBOSELOG)
{
res["mjshead"] = record.head;
res["mjslog"] = mjslog;
res["mjsrecordtypes"] = mjslog.map(e => e.constructor.name);
}
return res;
}
function downloadlog() {
function downloadlog()
{
app.NetAgent.sendReq2Lobby(
"Lobby", "fetchGameRecord",
"Lobby",
"fetchGameRecord",
{ game_uuid: GameMgr.Inst.record_uuid },
function(i, record) {
download(GameMgr.Inst.record_uuid + ".json",
JSON.stringify(parse(record))
var results = parse(record);
download(
//default filename
((new Date(record.head.end_time * 1000)).toLocaleDateString() + "_" + results["rule"]["disp"] + ".json").replace(/[ \/]/g,"_"),
PRETTY ?
JSON.stringify(results, null, " ")
.replace(/\n \s+/g, " ") //bring up log array items
.replace(/], \[/g,"],\n [") //bump nested lists back down
.replace(/\n\s+]/g," ]") //bring up isolated right brackets
.replace(/\n\s+},\n/g," },\n") //ditto for non-final curly brackets
: JSON.stringify(results)
);
}
);
}
})();
//TODO: fix double ron scores, delta point arrays should be split - too much effort
//TODO: show limit hands with "Mangan" etc. instead of fu/han
//TODO: currently ignoring RecordBaBei - kita/dorara?
//TODO: show abortive draws properly
// vim: ts=4 et
@echo off
SETLOCAL EnableDelayedExpansion
::gets around the windows parameter character limit by writing an .html that opens the tenhou/5 url
:: rem get unique file name
:: :uniqloop
:: set "tmpfile=%tmp%\bat~%RANDOM%.html"
:: if exist "%tmpfile%" goto :uniqloop
set "tmpfile=%tmp%~viewlog.html"
<NUL echo|set /p="<head><meta http-equiv='refresh' content='0; URL=https://tenhou.net/5/?tw=2#json="> %tmpfile%
<NUL type %1>> %tmpfile%
<NUL echo|set /p="'></head>">> %tmpfile%
start %tmpfile%
::you could wait and delete the file..
::pause
::del %tmpfile%
#!/bin/bash
if [[ "" == $1 ]]; then
echo "${0} log.json"
echo "opens your log with tenhou.net/5"
exit 1
fi
url="https://tenhou.net/5/?tw=2#json=""$(cat $1)"
case "$OSTYPE" in
linux*)
xdg-open "${url}"
;;
darwin*)
open "${url}"
;;
cygwin*)
cygstart "${url}"
;;
*)
open "${url}" || start "${url}"
;;
esac
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment