forked from Hanabi/hanabi-league
Compare commits
No commits in common. "edcb230365480c7784e0a417446c66d49dff12c5" and "4e459c48881735f0a78cbce3b254c87430593a97" have entirely different histories.
edcb230365
...
4e459c4888
14 changed files with 10 additions and 26524 deletions
|
@ -85,7 +85,3 @@ body {
|
||||||
.history-bullets ul {
|
.history-bullets ul {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.endgame-table td, .endgame-table th {
|
|
||||||
border: 1px solid black;
|
|
||||||
}
|
|
||||||
|
|
25134
data/games.json
25134
data/games.json
File diff suppressed because it is too large
Load diff
2
deps/py-hanabi
vendored
2
deps/py-hanabi
vendored
|
@ -1 +1 @@
|
||||||
Subproject commit 51e09cd94393de64e07191d6ca544139417acb3b
|
Subproject commit 3ac51d574e65aff9b3420fdebd467d7b98ea1d28
|
|
@ -16,15 +16,10 @@ import fetch_games
|
||||||
import ratings
|
import ratings
|
||||||
import stats
|
import stats
|
||||||
import render_site
|
import render_site
|
||||||
import endgames
|
|
||||||
|
|
||||||
from log_setup import logger
|
from log_setup import logger
|
||||||
|
|
||||||
|
|
||||||
def subcommand_analyze_endgames():
|
|
||||||
endgames.work_thread()
|
|
||||||
|
|
||||||
|
|
||||||
def subcommand_init(force: bool, no_fetch_variants: bool):
|
def subcommand_init(force: bool, no_fetch_variants: bool):
|
||||||
tables = database.get_existing_tables()
|
tables = database.get_existing_tables()
|
||||||
if len(tables) > 0 and not force:
|
if len(tables) > 0 and not force:
|
||||||
|
@ -106,7 +101,6 @@ def get_parser() -> argparse.ArgumentParser:
|
||||||
subparsers.add_parser('process-ratings', help="Process ratings of all games.")
|
subparsers.add_parser('process-ratings', help="Process ratings of all games.")
|
||||||
subparsers.add_parser('process-stats', help="Process statistics for all players.")
|
subparsers.add_parser('process-stats', help="Process statistics for all players.")
|
||||||
subparsers.add_parser('generate-site', help="Generate the website from the DB.")
|
subparsers.add_parser('generate-site', help="Generate the website from the DB.")
|
||||||
subparsers.add_parser('analyze-endgames', help="Run endgame analysis on games in DB. Resource intensive!")
|
|
||||||
subparsers.add_parser('run', help="Run the automatic suite: Fetch + process games and render site.")
|
subparsers.add_parser('run', help="Run the automatic suite: Fetch + process games and render site.")
|
||||||
|
|
||||||
fetch_parser = subparsers.add_parser('fetch', help='Fetch new data.')
|
fetch_parser = subparsers.add_parser('fetch', help='Fetch new data.')
|
||||||
|
@ -128,7 +122,6 @@ def main():
|
||||||
'generate-site': subcommand_generate_site,
|
'generate-site': subcommand_generate_site,
|
||||||
'fetch': subcommand_fetch,
|
'fetch': subcommand_fetch,
|
||||||
'run': subcommand_run,
|
'run': subcommand_run,
|
||||||
'analyze-endgames': subcommand_analyze_endgames
|
|
||||||
}[args.command]
|
}[args.command]
|
||||||
|
|
||||||
if args.verbose:
|
if args.verbose:
|
||||||
|
|
|
@ -359,45 +359,3 @@ CREATE TABLE user_statistics (
|
||||||
average_game_moves REAL GENERATED ALWAYS AS (CASE WHEN games_played != 0 THEN CAST(total_game_moves AS REAL) / games_played ELSE NULL END) STORED,
|
average_game_moves REAL GENERATED ALWAYS AS (CASE WHEN games_played != 0 THEN CAST(total_game_moves AS REAL) / games_played ELSE NULL END) STORED,
|
||||||
PRIMARY KEY (user_id, variant_type)
|
PRIMARY KEY (user_id, variant_type)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS endgames;
|
|
||||||
CREATE TABLE endgames (
|
|
||||||
game_id INTEGER REFERENCES games (id),
|
|
||||||
turn SMALLINT,
|
|
||||||
/**
|
|
||||||
* We want to be able to store probabilities for different actions that can be taken.
|
|
||||||
* Action type can be
|
|
||||||
0 for play actions
|
|
||||||
1 for discard actions
|
|
||||||
2 for clues
|
|
||||||
*/
|
|
||||||
action_type SMALLINT CHECK (0 <= action_type AND action_type <= 2),
|
|
||||||
/**
|
|
||||||
We store cards as (suit_index, rank) here for uniqueness of representation.
|
|
||||||
If we want to refer to known trash, we will use (0,0) as representation.
|
|
||||||
*/
|
|
||||||
suit_index SMALLINT, /* 0 for clue actions */
|
|
||||||
rank SMALLINT, /* 0 for clue actions */
|
|
||||||
enumerator BIGINT NOT NULL CHECK (enumerator >= 0),
|
|
||||||
denominator BIGINT NOT NULL CHECK (denominator > 0),
|
|
||||||
chance REAL GENERATED ALWAYS AS (CAST(enumerator AS REAL) / denominator) STORED,
|
|
||||||
PRIMARY KEY (game_id, turn, action_type, suit_index, rank)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
We store separately whether we analyzed a certain game already and what the termination reason for the analysis was:
|
|
||||||
0 if evaluation completed within specified time and memory
|
|
||||||
1 if evaluation ran into timeout
|
|
||||||
2 if evaluation was empty because state is unreachable
|
|
||||||
3 if evaluation ran out of memory
|
|
||||||
This is also necessary because for some endgames, because in case 2 we will not have data,
|
|
||||||
simply because the game replay ended too early.
|
|
||||||
To avoid re-analyzing these seeds, we mark all seeds analyzed in this table.
|
|
||||||
*/
|
|
||||||
DROP TABLE IF EXISTS endgames_analyzed;
|
|
||||||
CREATE TABLE endgames_analyzed (
|
|
||||||
game_id INTEGER REFERENCES games (id),
|
|
||||||
termination_reason SMALLINT NOT NULL,
|
|
||||||
PRIMARY KEY (game_id)
|
|
||||||
);
|
|
|
@ -1,468 +0,0 @@
|
||||||
{
|
|
||||||
"players": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"rating": 1656.9933584592068,
|
|
||||||
"username": "asaelr",
|
|
||||||
"stdev": 176.95337811507542
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"rating": 1547.1931967851367,
|
|
||||||
"username": "ElenaDhynho",
|
|
||||||
"stdev": 42.422674257282964
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"rating": 1596.6512180445707,
|
|
||||||
"username": "Eliclax",
|
|
||||||
"stdev": 25.554046049882707
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"rating": 1687.0065708056572,
|
|
||||||
"username": "Fafrd",
|
|
||||||
"stdev": 26.138849069401964
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"rating": 1495.04192425988,
|
|
||||||
"username": "Feich59",
|
|
||||||
"stdev": 73.58522619789503
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 10,
|
|
||||||
"rating": 1613.9053316660961,
|
|
||||||
"username": "gw12346",
|
|
||||||
"stdev": 15.446846483972713
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 11,
|
|
||||||
"rating": 1534.2115497751606,
|
|
||||||
"username": "hakha",
|
|
||||||
"stdev": 17.89293430394373
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 12,
|
|
||||||
"rating": 1702.8665184294564,
|
|
||||||
"username": "Helana",
|
|
||||||
"stdev": 17.959192573261596
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 13,
|
|
||||||
"rating": 1592.3685480643976,
|
|
||||||
"username": "hnter",
|
|
||||||
"stdev": 109.75405196408813
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 14,
|
|
||||||
"rating": 1684.5329659244817,
|
|
||||||
"username": "inseres",
|
|
||||||
"stdev": 5.193191918103997
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 15,
|
|
||||||
"rating": 1678.1068142163554,
|
|
||||||
"username": "Jay",
|
|
||||||
"stdev": 11.220557444400496
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 16,
|
|
||||||
"rating": 1657.7422053946832,
|
|
||||||
"username": "kimbifille",
|
|
||||||
"stdev": 71.62732692961684
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 17,
|
|
||||||
"rating": 1571.2263147756535,
|
|
||||||
"username": "Kowalski1337",
|
|
||||||
"stdev": 59.88063309820496
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 18,
|
|
||||||
"rating": 1673.652278716787,
|
|
||||||
"username": "macanek",
|
|
||||||
"stdev": 37.248874749072684
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 19,
|
|
||||||
"rating": 1734.2840478574078,
|
|
||||||
"username": "MarkusKahlsen",
|
|
||||||
"stdev": 137.263308306954
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 20,
|
|
||||||
"rating": 1599.1013632247043,
|
|
||||||
"username": "Neema",
|
|
||||||
"stdev": 29.99279133170806
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 21,
|
|
||||||
"rating": 1535.5621323285413,
|
|
||||||
"username": "newduke",
|
|
||||||
"stdev": 28.69029514671429
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 22,
|
|
||||||
"rating": 1439.1271150938064,
|
|
||||||
"username": "NishaNoire",
|
|
||||||
"stdev": 135.00876312408192
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 25,
|
|
||||||
"rating": 1589.9273430803019,
|
|
||||||
"username": "omegaxis",
|
|
||||||
"stdev": 45.681434487254656
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 27,
|
|
||||||
"rating": 1719.856499810081,
|
|
||||||
"username": "pianoblook",
|
|
||||||
"stdev": 43.91423347247681
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 28,
|
|
||||||
"rating": 1645.6070307223388,
|
|
||||||
"username": "posij118",
|
|
||||||
"stdev": 69.56052128100919
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 29,
|
|
||||||
"rating": 1700.7312163612453,
|
|
||||||
"username": "purplejoe",
|
|
||||||
"stdev": 4.333849603201881
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 31,
|
|
||||||
"rating": 1754.8673876868077,
|
|
||||||
"username": "rahsosprout",
|
|
||||||
"stdev": 24.20149808460386
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 32,
|
|
||||||
"rating": 1749.8186567295743,
|
|
||||||
"username": "Ramanujan",
|
|
||||||
"stdev": 35.186989633353924
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 33,
|
|
||||||
"rating": 1505.8114349633183,
|
|
||||||
"username": "ricardodd",
|
|
||||||
"stdev": 63.063499555331056
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 34,
|
|
||||||
"rating": 1424.7454795953922,
|
|
||||||
"username": "RIMBarisax",
|
|
||||||
"stdev": 54.62925500990815
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 35,
|
|
||||||
"rating": 1709.7754274336994,
|
|
||||||
"username": "rz",
|
|
||||||
"stdev": 34.75350346761236
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 36,
|
|
||||||
"rating": 1645.505505262562,
|
|
||||||
"username": "Sagnik Saha",
|
|
||||||
"stdev": 82.56415392788715
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 37,
|
|
||||||
"rating": 1607.8618746654392,
|
|
||||||
"username": "sodiumdebt",
|
|
||||||
"stdev": 74.74531010470325
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 38,
|
|
||||||
"rating": 1799.3860289683867,
|
|
||||||
"username": "spring",
|
|
||||||
"stdev": 6.935110645012115
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 39,
|
|
||||||
"rating": 1547.0924841832866,
|
|
||||||
"username": "StKildaFan",
|
|
||||||
"stdev": 22.21102514134186
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 40,
|
|
||||||
"rating": 1676.6787745059055,
|
|
||||||
"username": "str8tsknacker",
|
|
||||||
"stdev": 50.74354553034183
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 41,
|
|
||||||
"rating": 1731.4770087590437,
|
|
||||||
"username": "Sturm",
|
|
||||||
"stdev": 57.142346148910896
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 42,
|
|
||||||
"rating": 1501.164246026532,
|
|
||||||
"username": "TimeHoodie",
|
|
||||||
"stdev": 35.10667883309847
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 43,
|
|
||||||
"rating": 1655.211023199,
|
|
||||||
"username": "vEnhance",
|
|
||||||
"stdev": 42.389156840017165
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 44,
|
|
||||||
"rating": 1533.6010216854027,
|
|
||||||
"username": "vermling",
|
|
||||||
"stdev": 90.93922443450435
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 45,
|
|
||||||
"rating": 1586.770150886957,
|
|
||||||
"username": "wateroffire",
|
|
||||||
"stdev": 3.9655080603422466
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 46,
|
|
||||||
"rating": 1614.507394752425,
|
|
||||||
"username": "WillFlame",
|
|
||||||
"stdev": 9.831543835666807
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 47,
|
|
||||||
"rating": 1713.6463481565615,
|
|
||||||
"username": "Yagami Black",
|
|
||||||
"stdev": 25.99563483333066
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 48,
|
|
||||||
"rating": 1457.1982216219058,
|
|
||||||
"username": "youisme",
|
|
||||||
"stdev": 81.81998675205648
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 51,
|
|
||||||
"rating": 1437.5089899938134,
|
|
||||||
"username": "Libster",
|
|
||||||
"stdev": 8.378590826005142
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 52,
|
|
||||||
"rating": 1672.8512878685628,
|
|
||||||
"username": "maxeymo",
|
|
||||||
"stdev": 36.0712267697332
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 53,
|
|
||||||
"rating": 1642.5304416034514,
|
|
||||||
"username": "joano580",
|
|
||||||
"stdev": 100.76861593939701
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 54,
|
|
||||||
"rating": 1550.2628143305851,
|
|
||||||
"username": "ReaverSe",
|
|
||||||
"stdev": 17.2818922023245
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 55,
|
|
||||||
"rating": 1642.9660148054686,
|
|
||||||
"username": "benzloeb",
|
|
||||||
"stdev": 3.4106951597543786
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 56,
|
|
||||||
"rating": 1598.4171994249805,
|
|
||||||
"username": "percolate",
|
|
||||||
"stdev": 30.720436445911385
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 58,
|
|
||||||
"rating": 1464.6446630379342,
|
|
||||||
"username": "Random Guy JCI",
|
|
||||||
"stdev": 12.310038039511529
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 59,
|
|
||||||
"rating": 1606.062637102755,
|
|
||||||
"username": "aara",
|
|
||||||
"stdev": 21.876906585784877
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 60,
|
|
||||||
"rating": 1601.4626434355132,
|
|
||||||
"username": "amattias",
|
|
||||||
"stdev": 33.079466261234664
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 64,
|
|
||||||
"rating": 1601.5458400329035,
|
|
||||||
"username": "wtfitsnotbutter",
|
|
||||||
"stdev": 53.56517099030838
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 65,
|
|
||||||
"rating": 1480.6158683098095,
|
|
||||||
"username": "Alix_Eisenhardt",
|
|
||||||
"stdev": 50.30711678640308
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 67,
|
|
||||||
"rating": 1617.5730419198144,
|
|
||||||
"username": "Vivarus",
|
|
||||||
"stdev": 9.56814210631875
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 69,
|
|
||||||
"rating": 1543.1725821109499,
|
|
||||||
"username": "FrozenStella",
|
|
||||||
"stdev": 47.349220108955066
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 70,
|
|
||||||
"rating": 1589.17602492783,
|
|
||||||
"username": "GameConqueror",
|
|
||||||
"stdev": 34.77764553932631
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 71,
|
|
||||||
"rating": 1631.0886330291135,
|
|
||||||
"username": "seungapark",
|
|
||||||
"stdev": 36.35644480232967
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 72,
|
|
||||||
"rating": 1544.058017455111,
|
|
||||||
"username": "sinuni_hung",
|
|
||||||
"stdev": 8.068740309638756
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 73,
|
|
||||||
"rating": 1652.5253246400134,
|
|
||||||
"username": "TheDaniMan",
|
|
||||||
"stdev": 43.89447022365573
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 79,
|
|
||||||
"rating": 1487.3404436278843,
|
|
||||||
"username": "Kaznad",
|
|
||||||
"stdev": 15.830803035745209
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 80,
|
|
||||||
"rating": 1392.331302957674,
|
|
||||||
"username": "Kernel",
|
|
||||||
"stdev": 42.02247099621528
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 81,
|
|
||||||
"rating": 1428.4800076632162,
|
|
||||||
"username": "Le Codex",
|
|
||||||
"stdev": 43.789622164916196
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 82,
|
|
||||||
"rating": 1521.363531664333,
|
|
||||||
"username": "Nipalup",
|
|
||||||
"stdev": 104.0370045655497
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"variants": [
|
|
||||||
{
|
|
||||||
"id": 0,
|
|
||||||
"rating": 1359.6612370979074,
|
|
||||||
"num_players": 3,
|
|
||||||
"name": "No Variant",
|
|
||||||
"num_suits": 5,
|
|
||||||
"stdev": 37.51830058593259
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"rating": 1409.8285544138594,
|
|
||||||
"num_players": 3,
|
|
||||||
"name": "6 Suits",
|
|
||||||
"num_suits": 6,
|
|
||||||
"stdev": 56.644590533491034
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 51,
|
|
||||||
"rating": 1719.7607226091918,
|
|
||||||
"num_players": 3,
|
|
||||||
"name": "Clue Starved (6 Suits)",
|
|
||||||
"num_suits": 6,
|
|
||||||
"stdev": 8.202101579431847
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 52,
|
|
||||||
"rating": 1662.868864963005,
|
|
||||||
"num_players": 3,
|
|
||||||
"name": "Clue Starved (5 Suits)",
|
|
||||||
"num_suits": 5,
|
|
||||||
"stdev": 8.674234234615462
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 0,
|
|
||||||
"rating": 1439.4451347766544,
|
|
||||||
"num_players": 4,
|
|
||||||
"name": "No Variant",
|
|
||||||
"num_suits": 5,
|
|
||||||
"stdev": 2.016825515539241
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"rating": 1434.2720502574575,
|
|
||||||
"num_players": 4,
|
|
||||||
"name": "6 Suits",
|
|
||||||
"num_suits": 6,
|
|
||||||
"stdev": 29.836778320728673
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 51,
|
|
||||||
"rating": 1779.845972756269,
|
|
||||||
"num_players": 4,
|
|
||||||
"name": "Clue Starved (6 Suits)",
|
|
||||||
"num_suits": 6,
|
|
||||||
"stdev": 47.696178458669856
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 52,
|
|
||||||
"rating": 1701.8431047063805,
|
|
||||||
"num_players": 4,
|
|
||||||
"name": "Clue Starved (5 Suits)",
|
|
||||||
"num_suits": 5,
|
|
||||||
"stdev": 42.02213931310847
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 0,
|
|
||||||
"rating": 1426.7937800667978,
|
|
||||||
"num_players": 5,
|
|
||||||
"name": "No Variant",
|
|
||||||
"num_suits": 5,
|
|
||||||
"stdev": 54.921511042199526
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"rating": 1495.2514337712873,
|
|
||||||
"num_players": 5,
|
|
||||||
"name": "6 Suits",
|
|
||||||
"num_suits": 6,
|
|
||||||
"stdev": 43.16247744670021
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 51,
|
|
||||||
"rating": 1898.843058275536,
|
|
||||||
"num_players": 5,
|
|
||||||
"name": null,
|
|
||||||
"num_suits": 6,
|
|
||||||
"stdev": 22.62832823639932
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 52,
|
|
||||||
"rating": 1916.5106100141977,
|
|
||||||
"num_players": 5,
|
|
||||||
"name": "Clue Starved (5 Suits)",
|
|
||||||
"num_suits": 5,
|
|
||||||
"stdev": 37.61403719598357
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -48,9 +48,5 @@ USER_HISTORY_CACHE_TIME = 60 * 60
|
||||||
# Fraction of seeds which is assumed to be unwinnable
|
# Fraction of seeds which is assumed to be unwinnable
|
||||||
UNWINNABLE_SEED_FRACTION = 0.02
|
UNWINNABLE_SEED_FRACTION = 0.02
|
||||||
|
|
||||||
WEBSITE_OUTPUT_DIRECTORY = 'build'
|
|
||||||
|
|
||||||
ENDGAME_MAX_DRAW_PILE_SIZE = 15 # Not interested in game states with more than 15 cards, this should be enough.
|
WEBSITE_OUTPUT_DIRECTORY = 'build'
|
||||||
ENDGAME_MEMORY_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB of memory
|
|
||||||
ENDGAME_TIMEOUT_SECONDS = 60 * 15 # 15 Minutes per game by default
|
|
||||||
ENDGAME_ANALYSIS_QUERY_INTERVAL_MINUTES = 5 # Re-query database every 5 minutes
|
|
||||||
|
|
254
src/endgames.py
254
src/endgames.py
|
@ -1,254 +0,0 @@
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import re
|
|
||||||
import resource
|
|
||||||
import time
|
|
||||||
from typing import List, Dict, Tuple
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import platformdirs
|
|
||||||
import psycopg2.extras
|
|
||||||
import psycopg2.errors
|
|
||||||
|
|
||||||
import hanabi.hanab_game
|
|
||||||
import hanabi.constants
|
|
||||||
import hanabi.live.compress
|
|
||||||
|
|
||||||
import constants
|
|
||||||
import games_db_interface
|
|
||||||
from database import conn_manager
|
|
||||||
from log_setup import logger
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EndgameAction:
|
|
||||||
turn: int
|
|
||||||
action_type: hanabi.hanab_game.ActionType
|
|
||||||
card: hanabi.hanab_game.DeckCard
|
|
||||||
enumerator: int
|
|
||||||
denominator: int
|
|
||||||
|
|
||||||
@property
|
|
||||||
def win_rate(self):
|
|
||||||
return self.enumerator / self.denominator
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_and_store_game(game_id: int) -> int:
|
|
||||||
actions, return_code = analyze_game_from_db(game_id)
|
|
||||||
store_endgame_actions(game_id, actions, return_code)
|
|
||||||
return return_code
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_game_from_db(game_id: int, refresh_cache=False) -> Tuple[List[EndgameAction], int]:
|
|
||||||
# In order to pass the game replay to the endgame analyzer (a C++ program), we use the hanab.live json format.
|
|
||||||
# In order to avoid to need to use the /export endpoint of hanab.live, we create the json replay ourselves from
|
|
||||||
# the information stored in the database. Note that this is partially lossy, since the GameState class we use here
|
|
||||||
# does not support stuff like notes or player names, but we don't care about this anyway and can pass the relevant
|
|
||||||
# information, i.e. deck, number of players and actions.
|
|
||||||
# We can additionally use the cache dir
|
|
||||||
filename = Path(platformdirs.user_cache_dir(constants.APP_NAME)) / "replays" / "{}.json".format(game_id)
|
|
||||||
if not filename.exists() or refresh_cache:
|
|
||||||
filename.parent.mkdir(exist_ok=True, parents=True)
|
|
||||||
game, variant_name = games_db_interface.load_game(game_id)
|
|
||||||
game_json = game.to_json()
|
|
||||||
game_json["options"]["variant"] = variant_name
|
|
||||||
|
|
||||||
with open(filename, 'w') as f:
|
|
||||||
f.write(json.dumps(game_json))
|
|
||||||
|
|
||||||
return analyze_endgame_from_file(str(filename))
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_endgame_from_file(filename: str) -> Tuple[List[EndgameAction], int]:
|
|
||||||
"""
|
|
||||||
Analyzes endgame of replay specified in given file using appropriate time and memory limits.
|
|
||||||
@param filename: Name of file containing replay of game in hanab.live format
|
|
||||||
@return: List of all evaluated actions and return code why evaluation finished:
|
|
||||||
0 if evaluation completed within specified time and memory
|
|
||||||
1 if evaluation ran into timeout
|
|
||||||
2 if evaluation was empty because state is unreachable
|
|
||||||
3 if evaluation ran out of memory
|
|
||||||
|
|
||||||
No guarantee can be made on what actions are actually evaluated, these might be more or less depending on
|
|
||||||
timeouts and/or resource limitation.
|
|
||||||
@raise
|
|
||||||
"""
|
|
||||||
raw_output: bytes
|
|
||||||
return_code = 0
|
|
||||||
args = [
|
|
||||||
'./endgame-analyzer', '--file', filename,
|
|
||||||
'--draw-pile-size', str(constants.ENDGAME_MAX_DRAW_PILE_SIZE), '--list-actions', '--quiet'
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
args,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
timeout=constants.ENDGAME_TIMEOUT_SECONDS,
|
|
||||||
preexec_fn=set_memory_limit
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
# 2 is the return code to report that the specified game state is not reachable
|
|
||||||
# In this case, there is nothing to analyze, so we will return an empty list and normal program termination.
|
|
||||||
if result.returncode == 2:
|
|
||||||
return [], 2
|
|
||||||
# 3 is the return code used by the subprocess to indicate an out of memory exception
|
|
||||||
# Since we intentionally limited the memory, this is actually not an exception for us,
|
|
||||||
# we will simply parse the results we have and report the OOM exception.
|
|
||||||
if result.returncode == 3:
|
|
||||||
return_code = 3
|
|
||||||
else:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Abnormal program termination of endgame-analyzer subprocess: Call of\n"
|
|
||||||
"{}\n"
|
|
||||||
"resulted in returncode '{}' and stderr\n"
|
|
||||||
"{}"
|
|
||||||
.format(
|
|
||||||
" ".join(args),
|
|
||||||
result.returncode,
|
|
||||||
result.stderr.decode('utf-8')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raw_output = result.stdout
|
|
||||||
except subprocess.TimeoutExpired as time_err:
|
|
||||||
return_code = 1
|
|
||||||
raw_output = time_err.stdout
|
|
||||||
|
|
||||||
# It could be that we got no output. In that case, we also cannot parse anything
|
|
||||||
output = raw_output.decode('utf-8') if raw_output else ""
|
|
||||||
|
|
||||||
pattern = r"Turn (?P<turn>\d+), (?P<type>\w+)(?:\s(?P<card>\w\w))?: (?P<enumerator>\d+)/(?P<denominator>\d+)"
|
|
||||||
|
|
||||||
return [parse_match(match.groupdict()) for match in re.finditer(pattern, output)], return_code
|
|
||||||
|
|
||||||
|
|
||||||
def set_memory_limit():
|
|
||||||
resource.setrlimit(resource.RLIMIT_DATA, (constants.ENDGAME_MEMORY_BYTES, constants.ENDGAME_MEMORY_BYTES))
|
|
||||||
|
|
||||||
|
|
||||||
def store_endgame_actions(game_id: int, endgame_actions: List[EndgameAction], result_code) -> None:
|
|
||||||
values = []
|
|
||||||
for action in endgame_actions:
|
|
||||||
values.append((game_id, action.turn, action.action_type.value, action.card.suitIndex, action.card.rank, action.enumerator, action.denominator))
|
|
||||||
|
|
||||||
# Remove duplicates (even though we expect none), otherwise this causes errors on insertion.
|
|
||||||
values = list(set(values))
|
|
||||||
|
|
||||||
conn = conn_manager.get_connection()
|
|
||||||
cur = conn.cursor()
|
|
||||||
psycopg2.extras.execute_values(
|
|
||||||
cur,
|
|
||||||
"INSERT INTO endgames "
|
|
||||||
"VALUES %s "
|
|
||||||
"ON CONFLICT (game_id, turn, action_type, suit_index, rank) "
|
|
||||||
"DO UPDATE "
|
|
||||||
"SET (enumerator, denominator) = (EXCLUDED.enumerator, EXCLUDED.denominator)",
|
|
||||||
values
|
|
||||||
)
|
|
||||||
# Mark this game as analyzed.
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO endgames_analyzed "
|
|
||||||
"VALUES (%s, %s) "
|
|
||||||
"ON CONFLICT (game_id) "
|
|
||||||
"DO UPDATE "
|
|
||||||
"SET termination_reason = EXCLUDED.termination_reason",
|
|
||||||
(game_id, result_code)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def load_endgame_actions(game_id: int) -> List[EndgameAction]:
|
|
||||||
cur = conn_manager.get_new_cursor()
|
|
||||||
cur.execute(
|
|
||||||
"SELECT turn, action_type, suit_index, rank, enumerator, denominator "
|
|
||||||
"FROM endgames "
|
|
||||||
"WHERE game_id = %s "
|
|
||||||
"ORDER BY turn ASC, action_type ASC, suit_index ASC, rank ASC",
|
|
||||||
(game_id,)
|
|
||||||
)
|
|
||||||
ret = []
|
|
||||||
for (turn, action_type, suit_index, rank, enumerator, denominator) in cur.fetchall():
|
|
||||||
ret.append(EndgameAction(
|
|
||||||
turn,
|
|
||||||
hanabi.hanab_game.ActionType(action_type),
|
|
||||||
hanabi.hanab_game.DeckCard(suit_index, rank),
|
|
||||||
enumerator, denominator)
|
|
||||||
)
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def parse_match(action: Dict) -> EndgameAction:
|
|
||||||
turn = action["turn"]
|
|
||||||
action_type = parse_action_type(action["type"])
|
|
||||||
card = hanabi.hanab_game.DeckCard(0, 0) if action["card"] is None else parse_card(action["card"])
|
|
||||||
enumerator = action["enumerator"]
|
|
||||||
denominator = action["denominator"]
|
|
||||||
return EndgameAction(turn, action_type, card, enumerator, denominator)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_action_type(action_type: str) -> hanabi.hanab_game.ActionType:
|
|
||||||
match action_type:
|
|
||||||
case "play":
|
|
||||||
return hanabi.hanab_game.ActionType.Play
|
|
||||||
case "discard":
|
|
||||||
return hanabi.hanab_game.ActionType.Discard
|
|
||||||
case "clue":
|
|
||||||
return hanabi.hanab_game.ActionType.ColorClue
|
|
||||||
raise ValueError("Failed to parse action type: {}".format(action_type))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_card(card: str) -> hanabi.hanab_game.DeckCard:
|
|
||||||
assert len(card) == 2
|
|
||||||
if card == "kt":
|
|
||||||
return hanabi.hanab_game.DeckCard(0, 0)
|
|
||||||
else:
|
|
||||||
rank = int(card[1])
|
|
||||||
suit = hanabi.constants.COLOR_INITIALS.index(card[0])
|
|
||||||
assert suit is not None
|
|
||||||
return hanabi.hanab_game.DeckCard(suit, rank)
|
|
||||||
|
|
||||||
|
|
||||||
def print_action_type(action_type: hanabi.hanab_game.ActionType) -> str:
|
|
||||||
match action_type:
|
|
||||||
case hanabi.hanab_game.ActionType.Play:
|
|
||||||
return "Play"
|
|
||||||
case hanabi.hanab_game.ActionType.Discard:
|
|
||||||
return "Discard"
|
|
||||||
case hanabi.hanab_game.ActionType.RankClue:
|
|
||||||
return "Clue"
|
|
||||||
case hanabi.hanab_game.ActionType.ColorClue:
|
|
||||||
return "Clue"
|
|
||||||
case _:
|
|
||||||
return "Unknown Action"
|
|
||||||
|
|
||||||
|
|
||||||
def work_thread():
|
|
||||||
"""
|
|
||||||
Will continuously query database to analyze endgames.
|
|
||||||
@return:
|
|
||||||
"""
|
|
||||||
conn = conn_manager.get_connection()
|
|
||||||
cur = conn.cursor()
|
|
||||||
while True:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT games.id "
|
|
||||||
"FROM games "
|
|
||||||
"LEFT OUTER JOIN endgames_analyzed "
|
|
||||||
" ON endgames_analyzed.game_id = games.id "
|
|
||||||
"WHERE endgames_analyzed.termination_reason IS NULL "
|
|
||||||
"ORDER BY games.league_id DESC "
|
|
||||||
"LIMIT 1",
|
|
||||||
(False,)
|
|
||||||
)
|
|
||||||
res = cur.fetchone()
|
|
||||||
if res is None:
|
|
||||||
logger.info("No game found to analyze. Going to sleep for {} Minutes".format(
|
|
||||||
constants.ENDGAME_ANALYSIS_QUERY_INTERVAL_MINUTES)
|
|
||||||
)
|
|
||||||
time.sleep(60 * constants.ENDGAME_ANALYSIS_QUERY_INTERVAL_MINUTES)
|
|
||||||
else:
|
|
||||||
(game_id, ) = res
|
|
||||||
logger.info("Analyzing endgame of game {}".format(game_id))
|
|
||||||
return_code = analyze_and_store_game(game_id)
|
|
||||||
print("Finished endgame analysis of {}: Returncode {}".format(game_id, return_code))
|
|
|
@ -81,15 +81,10 @@ def load_deck(seed: str) -> List[hanabi.hanab_game.DeckCard]:
|
||||||
return deck
|
return deck
|
||||||
|
|
||||||
|
|
||||||
def load_game_parts(game_id: int) -> Tuple[hanabi.hanab_game.HanabiInstance, List[hanabi.hanab_game.Action], str]:
|
def load_game(game_id: int) -> Tuple[hanabi.hanab_game.HanabiInstance, List[hanabi.hanab_game.Action]]:
|
||||||
"""
|
|
||||||
Loads information on game from database
|
|
||||||
@param game_id: ID of game
|
|
||||||
@return: Instance (i.e. deck + settings) of game, list of actions, variant name
|
|
||||||
"""
|
|
||||||
cur = conn_manager.get_new_cursor()
|
cur = conn_manager.get_new_cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT games.num_players, games.seed, variants.clue_starved, variants.name "
|
"SELECT games.num_players, games.seed, variants.clue_starved "
|
||||||
"FROM games "
|
"FROM games "
|
||||||
"INNER JOIN variants"
|
"INNER JOIN variants"
|
||||||
" ON games.variant_id = variants.id "
|
" ON games.variant_id = variants.id "
|
||||||
|
@ -103,19 +98,10 @@ def load_game_parts(game_id: int) -> Tuple[hanabi.hanab_game.HanabiInstance, Lis
|
||||||
raise ValueError(err_msg)
|
raise ValueError(err_msg)
|
||||||
|
|
||||||
# Unpack results now
|
# Unpack results now
|
||||||
(num_players, seed, clue_starved, variant_name) = res
|
(num_players, seed, clue_starved) = res
|
||||||
|
|
||||||
actions = load_actions(game_id)
|
actions = load_actions(game_id)
|
||||||
deck = load_deck(seed)
|
deck = load_deck(seed)
|
||||||
|
|
||||||
instance = hanabi.hanab_game.HanabiInstance(deck, num_players, clue_starved=clue_starved)
|
instance = hanabi.hanab_game.HanabiInstance(deck, num_players, clue_starved=clue_starved)
|
||||||
return instance, actions, variant_name
|
return instance, actions
|
||||||
|
|
||||||
|
|
||||||
def load_game(game_id: int) -> Tuple[hanabi.hanab_game.GameState, str]:
|
|
||||||
instance, actions, variant_name = load_game_parts(game_id)
|
|
||||||
game = hanabi.hanab_game.GameState(instance)
|
|
||||||
for action in actions:
|
|
||||||
game.make_action(action)
|
|
||||||
return game, variant_name
|
|
||||||
|
|
||||||
|
|
|
@ -1,406 +0,0 @@
|
||||||
from typing import Iterable, List, Dict
|
|
||||||
|
|
||||||
from config import config_manager
|
|
||||||
from constants import UNWINNABLE_SEED_FRACTION
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import json
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from numpy.typing import NDArray
|
|
||||||
from scipy.optimize import minimize, Bounds
|
|
||||||
|
|
||||||
MAX_RATING_DIFF = 1200
|
|
||||||
RATING_PRIOR = 1600
|
|
||||||
# This allows us to do integer optimization on more fine-grained grid.
|
|
||||||
LCM_FACTOR = 60
|
|
||||||
NUM_RANDOM_INITS = 50
|
|
||||||
NUM_CV_ITERS = 50
|
|
||||||
|
|
||||||
VARIANT_DEFAULT_RATINGS = np.array([1400, 1400, 1800, 1800]).reshape((1, 4))
|
|
||||||
VARIANT_NUM_PLAYERS_MODIFIERS = np.array([0, 0, 100]).reshape((3, 1))
|
|
||||||
|
|
||||||
# As of season 1, this is [1400 1400 1800 1800 1400 1400 1800 1800 1500 1500 1900 1900].
|
|
||||||
VARIANT_RATING_PRIORS = np.ravel(
|
|
||||||
VARIANT_DEFAULT_RATINGS + VARIANT_NUM_PLAYERS_MODIFIERS
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GlobalInfo:
|
|
||||||
rated_ids: Iterable[int]
|
|
||||||
variant_ids: Iterable[int]
|
|
||||||
player_counts: Iterable[int]
|
|
||||||
user_names: Iterable[str]
|
|
||||||
game_counts: Iterable[int]
|
|
||||||
rated_id_indices_in_rating_list: Dict[int, int]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def rating_list_length(self):
|
|
||||||
return len(self.rated_ids) + len(self.variant_ids) * len(self.player_counts)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GameRow:
|
|
||||||
game_id: int
|
|
||||||
num_players: int
|
|
||||||
users: List[str]
|
|
||||||
user_ids: List[int]
|
|
||||||
user_rating_changes: List[float]
|
|
||||||
user_ratings_after: List[float]
|
|
||||||
variant_id: int
|
|
||||||
variant_name: str
|
|
||||||
num_suits: id
|
|
||||||
rating_type: int
|
|
||||||
seed: str
|
|
||||||
score: int
|
|
||||||
num_turns: int
|
|
||||||
datetime_finished: datetime.datetime
|
|
||||||
league_id: int
|
|
||||||
num_bdrs: int
|
|
||||||
num_crits_lost: int
|
|
||||||
game_outcomes: List[str]
|
|
||||||
variant_rating_change: float
|
|
||||||
variant_rating_after: float
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_loss(
|
|
||||||
rating_list: Iterable[int],
|
|
||||||
game_list: Iterable[GameRow],
|
|
||||||
global_info: GlobalInfo,
|
|
||||||
p_win_lookup_table: NDArray[np.float32],
|
|
||||||
player_rating_priors: Iterable[int],
|
|
||||||
player_prior_weight: float,
|
|
||||||
variant_rating_priors: Iterable[int],
|
|
||||||
variant_prior_weight: float,
|
|
||||||
):
|
|
||||||
loss = np.float32(0)
|
|
||||||
|
|
||||||
for game in game_list:
|
|
||||||
team_ratings = [
|
|
||||||
rating_list[global_info.rated_id_indices_in_rating_list[user_id]]
|
|
||||||
for user_id in game["user_ids"]
|
|
||||||
]
|
|
||||||
team_rating = sum(team_ratings) / len(team_ratings)
|
|
||||||
|
|
||||||
variant_rating = rating_list[
|
|
||||||
calculate_variant_index_in_rating_list(
|
|
||||||
len(game["users"]), game["variant_id"], global_info
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
rating_diff = int(np.round(LCM_FACTOR * (team_rating - variant_rating)))
|
|
||||||
p_win = calculate_p_win(rating_diff, p_win_lookup_table)
|
|
||||||
|
|
||||||
game_won = game["game_outcomes"].count("Win") > 0
|
|
||||||
loss += calculate_loss_for_one_game(p_win, game_won)
|
|
||||||
|
|
||||||
# Add two dummy single-player games for each player with prior_weight * square-root of number of games weight - one won and lost
|
|
||||||
for player_rating_prior, rated_id, game_count in zip(
|
|
||||||
player_rating_priors, global_info.rated_ids, global_info.game_counts
|
|
||||||
):
|
|
||||||
team_rating = rating_list[global_info.rated_id_indices_in_rating_list[rated_id]]
|
|
||||||
variant_rating = player_rating_prior
|
|
||||||
|
|
||||||
rating_diff = int(np.round(LCM_FACTOR * (team_rating - variant_rating)))
|
|
||||||
p_win = calculate_p_win(rating_diff, p_win_lookup_table)
|
|
||||||
|
|
||||||
loss += (
|
|
||||||
np.sqrt(game_count)
|
|
||||||
* player_prior_weight
|
|
||||||
* calculate_loss_for_one_game(p_win, True)
|
|
||||||
)
|
|
||||||
loss += (
|
|
||||||
np.sqrt(game_count)
|
|
||||||
* player_prior_weight
|
|
||||||
* calculate_loss_for_one_game(p_win, False)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add two dummy single-player games for each player with prior_weight * square-root of number of games weight - one won and lost
|
|
||||||
for variant_rating, variant_rating_prior in zip(
|
|
||||||
rating_list[len(global_info.rated_ids) :],
|
|
||||||
variant_rating_priors,
|
|
||||||
):
|
|
||||||
team_rating = variant_rating_prior
|
|
||||||
|
|
||||||
rating_diff = int(np.round(LCM_FACTOR * (team_rating - variant_rating)))
|
|
||||||
p_win = calculate_p_win(rating_diff, p_win_lookup_table)
|
|
||||||
|
|
||||||
loss += (
|
|
||||||
np.sqrt(game_count)
|
|
||||||
* variant_prior_weight
|
|
||||||
* calculate_loss_for_one_game(p_win, True)
|
|
||||||
)
|
|
||||||
loss += (
|
|
||||||
np.sqrt(game_count)
|
|
||||||
* variant_prior_weight
|
|
||||||
* calculate_loss_for_one_game(p_win, False)
|
|
||||||
)
|
|
||||||
|
|
||||||
return loss
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_loss_gradient(rating_list, *args):
|
|
||||||
y = calculate_loss(rating_list, *args)
|
|
||||||
eye = np.eye(len(rating_list), dtype=int)
|
|
||||||
|
|
||||||
gradient = [
|
|
||||||
calculate_loss(rating_list + eye[i], *args) - y for i in range(len(rating_list))
|
|
||||||
]
|
|
||||||
|
|
||||||
return gradient
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_minloss_ratings(
|
|
||||||
game_list: List[GameRow],
|
|
||||||
global_info: GlobalInfo,
|
|
||||||
p_win_lookup_table: NDArray[np.float32],
|
|
||||||
player_rating_priors: int | Iterable[int] = RATING_PRIOR,
|
|
||||||
player_random_init_stdev: int = 0,
|
|
||||||
player_prior_weight=0.5,
|
|
||||||
variant_rating_priors: int | Iterable[int] = RATING_PRIOR,
|
|
||||||
variant_prior_weight=0.5,
|
|
||||||
variant_random_init_stdev: int = 0,
|
|
||||||
):
|
|
||||||
player_rating_priors = np.broadcast_to(
|
|
||||||
player_rating_priors, len(global_info.rated_ids)
|
|
||||||
)
|
|
||||||
variant_rating_priors = np.broadcast_to(
|
|
||||||
variant_rating_priors,
|
|
||||||
len(global_info.variant_ids) * len(global_info.player_counts),
|
|
||||||
)
|
|
||||||
|
|
||||||
prior_rating_list = np.concatenate(
|
|
||||||
(
|
|
||||||
player_rating_priors
|
|
||||||
+ np.clip(
|
|
||||||
np.random.normal(
|
|
||||||
0, player_random_init_stdev, len(player_rating_priors)
|
|
||||||
),
|
|
||||||
-3 * player_random_init_stdev,
|
|
||||||
3 * player_random_init_stdev,
|
|
||||||
),
|
|
||||||
variant_rating_priors
|
|
||||||
+ np.clip(
|
|
||||||
np.random.normal(
|
|
||||||
0, variant_random_init_stdev, len(variant_rating_priors)
|
|
||||||
),
|
|
||||||
-3 * variant_random_init_stdev,
|
|
||||||
3 * variant_random_init_stdev,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
dtype=np.float32,
|
|
||||||
)
|
|
||||||
|
|
||||||
rating_list = minimize(
|
|
||||||
calculate_loss,
|
|
||||||
prior_rating_list,
|
|
||||||
(
|
|
||||||
game_list,
|
|
||||||
global_info,
|
|
||||||
p_win_lookup_table,
|
|
||||||
player_rating_priors,
|
|
||||||
player_prior_weight,
|
|
||||||
variant_rating_priors,
|
|
||||||
variant_prior_weight,
|
|
||||||
),
|
|
||||||
bounds=Bounds(RATING_PRIOR - MAX_RATING_DIFF, RATING_PRIOR + MAX_RATING_DIFF),
|
|
||||||
jac=calculate_loss_gradient,
|
|
||||||
tol=0.0001,
|
|
||||||
)
|
|
||||||
|
|
||||||
return rating_list
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_p_win_lookup_table(rating_diff_indices: Iterable[int]):
|
|
||||||
lookup_table = np.full(2 * MAX_RATING_DIFF * LCM_FACTOR + 1, 0.5, dtype=np.float32)
|
|
||||||
|
|
||||||
for rating_diff_index in rating_diff_indices:
|
|
||||||
p_win = calculate_p_win(rating_diff_index, None)
|
|
||||||
lookup_table[rating_diff_index] = p_win
|
|
||||||
|
|
||||||
return lookup_table
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_p_win(
|
|
||||||
rating_diff_index: int, lookup_table: NDArray[np.float32] | None = None
|
|
||||||
):
|
|
||||||
if lookup_table is None:
|
|
||||||
return (1 - UNWINNABLE_SEED_FRACTION) * (
|
|
||||||
1 / (1 + 10 ** (-(rating_diff_index / LCM_FACTOR) / 400))
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return lookup_table[rating_diff_index]
|
|
||||||
|
|
||||||
|
|
||||||
# Cross-entropy loss
|
|
||||||
def calculate_loss_for_one_game(p_win: np.float32, game_won: bool):
|
|
||||||
if game_won:
|
|
||||||
loss = -np.log(p_win)
|
|
||||||
else:
|
|
||||||
loss = -np.log(1 - p_win)
|
|
||||||
|
|
||||||
return loss
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_variant_index_in_rating_list(
|
|
||||||
player_count: int, variant_id: int, global_info: GlobalInfo
|
|
||||||
):
|
|
||||||
return (
|
|
||||||
len(global_info.rated_ids)
|
|
||||||
+ global_info.player_counts.index(player_count) * len(global_info.variant_ids)
|
|
||||||
+ global_info.variant_ids.index(variant_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_player_count_and_variant_id_from_variant_index(
|
|
||||||
index: int, global_info: GlobalInfo
|
|
||||||
):
|
|
||||||
num_players = global_info.player_counts[index // len(global_info.variant_ids)]
|
|
||||||
variant_id = global_info.variant_ids[index % len(global_info.variant_ids)]
|
|
||||||
|
|
||||||
return num_players, variant_id
|
|
||||||
|
|
||||||
|
|
||||||
def write_data(random_init_rating_lists, cv_rating_lists):
|
|
||||||
with open("../results/min_loss_rating_list.json", "w") as f:
|
|
||||||
rating_list = np.average(random_init_rating_lists, axis=0)
|
|
||||||
rating_stdevs = np.std(cv_rating_lists, axis=0, ddof=1)
|
|
||||||
|
|
||||||
players = []
|
|
||||||
for rated_id, user_name, rating, rating_stdev in zip(
|
|
||||||
global_info.rated_ids, global_info.user_names, rating_list, rating_stdevs
|
|
||||||
):
|
|
||||||
players.append(
|
|
||||||
{
|
|
||||||
"id": rated_id,
|
|
||||||
"rating": rating,
|
|
||||||
"username": user_name,
|
|
||||||
"stdev": rating_stdev,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
variants = []
|
|
||||||
|
|
||||||
for i, (rating, rating_stdev) in enumerate(
|
|
||||||
zip(
|
|
||||||
rating_list[len(global_info.rated_ids) :],
|
|
||||||
rating_stdevs[len(global_info.rated_ids) :],
|
|
||||||
)
|
|
||||||
):
|
|
||||||
(
|
|
||||||
num_players,
|
|
||||||
variant_id,
|
|
||||||
) = calculate_player_count_and_variant_id_from_variant_index(i, global_info)
|
|
||||||
variant_name = None
|
|
||||||
|
|
||||||
for game in game_list:
|
|
||||||
if (
|
|
||||||
len(game["users"]) == num_players
|
|
||||||
and game["variant_id"] == variant_id
|
|
||||||
):
|
|
||||||
variant_name = game["variant_name"]
|
|
||||||
num_suits = game["num_suits"]
|
|
||||||
|
|
||||||
variants.append(
|
|
||||||
{
|
|
||||||
"id": variant_id,
|
|
||||||
"rating": rating,
|
|
||||||
"num_players": num_players,
|
|
||||||
"name": variant_name,
|
|
||||||
"num_suits": num_suits,
|
|
||||||
"stdev": rating_stdev,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
s = json.dumps({"players": players, "variants": variants})
|
|
||||||
f.write(s)
|
|
||||||
|
|
||||||
|
|
||||||
def read_data():
|
|
||||||
with open("../data/games.json") as game_data:
|
|
||||||
game_list = json.loads(game_data.read())
|
|
||||||
|
|
||||||
p_win_lookup_table = calculate_p_win_lookup_table(
|
|
||||||
np.arange(-MAX_RATING_DIFF * LCM_FACTOR, MAX_RATING_DIFF * LCM_FACTOR + 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
config = config_manager.get_config()
|
|
||||||
|
|
||||||
rated_ids_dupes = [ID for game in game_list for ID in game["user_ids"]]
|
|
||||||
rated_ids = list(set(rated_ids_dupes))
|
|
||||||
|
|
||||||
variant_ids_dupes = [game["variant_id"] for game in game_list]
|
|
||||||
variant_ids = list(set(variant_ids_dupes))
|
|
||||||
|
|
||||||
nums_players = [game["num_players"] for game in game_list]
|
|
||||||
|
|
||||||
player_counts = list(range(config.min_player_count, config.max_player_count + 1))
|
|
||||||
game_counts = np.concatenate(
|
|
||||||
(
|
|
||||||
[rated_ids_dupes.count(ID) for ID in rated_ids],
|
|
||||||
np.zeros(len(variant_ids) * len(player_counts)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
user_names = list(np.full((len(rated_ids),), ""))
|
|
||||||
for game in game_list:
|
|
||||||
for user_id, user_name in zip(game["user_ids"], game["users"]):
|
|
||||||
user_names[rated_ids.index(user_id)] = user_name
|
|
||||||
|
|
||||||
rated_id_indices_in_rating_list = {
|
|
||||||
id: index for index, id in dict(enumerate(rated_ids)).items()
|
|
||||||
}
|
|
||||||
|
|
||||||
global_info = GlobalInfo(
|
|
||||||
rated_ids,
|
|
||||||
variant_ids,
|
|
||||||
player_counts,
|
|
||||||
user_names,
|
|
||||||
game_counts,
|
|
||||||
rated_id_indices_in_rating_list,
|
|
||||||
)
|
|
||||||
|
|
||||||
for variant_id, num_players in zip(variant_ids_dupes, nums_players):
|
|
||||||
game_counts[
|
|
||||||
calculate_variant_index_in_rating_list(num_players, variant_id, global_info)
|
|
||||||
] += 1
|
|
||||||
global_info.game_counts = game_counts
|
|
||||||
|
|
||||||
return game_list, global_info, p_win_lookup_table
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
game_list, global_info, p_win_lookup_table = read_data()
|
|
||||||
|
|
||||||
random_init_rating_lists = []
|
|
||||||
cv_rating_lists = []
|
|
||||||
|
|
||||||
for _ in range(NUM_RANDOM_INITS):
|
|
||||||
random_init_rating_lists.append(
|
|
||||||
calculate_minloss_ratings(
|
|
||||||
game_list,
|
|
||||||
global_info,
|
|
||||||
p_win_lookup_table,
|
|
||||||
variant_rating_priors=VARIANT_RATING_PRIORS,
|
|
||||||
player_random_init_stdev=200,
|
|
||||||
variant_random_init_stdev=200,
|
|
||||||
).x
|
|
||||||
)
|
|
||||||
|
|
||||||
for _ in range(NUM_CV_ITERS):
|
|
||||||
cv_game_list = np.random.choice(game_list, len(game_list), replace=True)
|
|
||||||
cv_rating_lists.append(
|
|
||||||
calculate_minloss_ratings(
|
|
||||||
cv_game_list,
|
|
||||||
global_info,
|
|
||||||
p_win_lookup_table,
|
|
||||||
variant_rating_priors=VARIANT_RATING_PRIORS,
|
|
||||||
player_random_init_stdev=200,
|
|
||||||
variant_random_init_stdev=200,
|
|
||||||
).x
|
|
||||||
)
|
|
||||||
|
|
||||||
write_data(random_init_rating_lists, cv_rating_lists)
|
|
|
@ -12,18 +12,16 @@ import requests_cache
|
||||||
import platformdirs
|
import platformdirs
|
||||||
|
|
||||||
import stats
|
import stats
|
||||||
import hanabi.hanab_game
|
|
||||||
|
|
||||||
import constants
|
import constants
|
||||||
import config
|
import config
|
||||||
import utils
|
import utils
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import endgames
|
|
||||||
import games_db_interface
|
|
||||||
|
|
||||||
from database import conn_manager
|
from database import conn_manager
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PlayerEntry:
|
class PlayerEntry:
|
||||||
player_name: str
|
player_name: str
|
||||||
|
@ -715,111 +713,6 @@ def build_unique_variants(variant_rows: List[VariantRow]):
|
||||||
return [row for row in variant_rows if row.num_players == config.config_manager.get_config().min_player_count]
|
return [row for row in variant_rows if row.num_players == config.config_manager.get_config().min_player_count]
|
||||||
|
|
||||||
|
|
||||||
def render_game_pages(env: jinja2.Environment, out_dir: Path):
|
|
||||||
endgames = get_endgame_page_data()
|
|
||||||
template = env.get_template("game.html")
|
|
||||||
|
|
||||||
for game_id, data in endgames.items():
|
|
||||||
rendered_template = template.render(
|
|
||||||
total_games_played=get_total_games(),
|
|
||||||
total_players=get_num_players(),
|
|
||||||
latest_run=datetime.datetime.now().isoformat(),
|
|
||||||
game_id=game_id,
|
|
||||||
data=data
|
|
||||||
)
|
|
||||||
output_file = out_dir / 'game' / str(game_id) / 'index.html'
|
|
||||||
output_file.parent.mkdir(exist_ok=True, parents=True)
|
|
||||||
with open(output_file, 'w') as f:
|
|
||||||
f.write(rendered_template)
|
|
||||||
|
|
||||||
|
|
||||||
def format_endgame_action(endgame_action: endgames.EndgameAction):
|
|
||||||
|
|
||||||
return "{} {}: {}/{} ~ {}".format(
|
|
||||||
endgames.print_action_type(endgame_action.action_type),
|
|
||||||
endgame_action.card,
|
|
||||||
endgame_action.enumerator,
|
|
||||||
endgame_action.denominator,
|
|
||||||
endgame_action.action_type.value
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class EndgameActionRow:
|
|
||||||
description: str
|
|
||||||
enumerator: int
|
|
||||||
denominator: int
|
|
||||||
marked: bool = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def win_rate(self):
|
|
||||||
return round(100 * self.enumerator / self.denominator, 3)
|
|
||||||
|
|
||||||
|
|
||||||
def convert_endgame_action(endgame_action: endgames.EndgameAction, game: hanabi.hanab_game.GameState, action: hanabi.hanab_game.Action) -> EndgameActionRow:
|
|
||||||
action_str = endgames.print_action_type(endgame_action.action_type)
|
|
||||||
target_str: str
|
|
||||||
if endgame_action.action_type not in [hanabi.hanab_game.ActionType.ColorClue, hanabi.hanab_game.ActionType.RankClue]:
|
|
||||||
target_str = " {}".format(endgame_action.card)
|
|
||||||
else:
|
|
||||||
target_str = ""
|
|
||||||
description = action_str + target_str
|
|
||||||
|
|
||||||
marked = False
|
|
||||||
# To simplify comparisons, we only work with color clues here. Endgame actions always consist of color clues.
|
|
||||||
if action.type == hanabi.hanab_game.ActionType.RankClue:
|
|
||||||
action.type = hanabi.hanab_game.ActionType.ColorClue
|
|
||||||
if endgame_action.action_type == action.type:
|
|
||||||
if endgame_action.action_type == hanabi.hanab_game.ActionType.ColorClue:
|
|
||||||
marked = True
|
|
||||||
else:
|
|
||||||
game_target = game.instance.deck[action.target]
|
|
||||||
if game.is_trash(game_target):
|
|
||||||
game_target = hanabi.hanab_game.DeckCard(0, 0)
|
|
||||||
if endgame_action.card == game_target:
|
|
||||||
marked = True
|
|
||||||
return EndgameActionRow(description, endgame_action.enumerator, endgame_action.denominator, marked)
|
|
||||||
|
|
||||||
|
|
||||||
def get_endgame_page_data():
|
|
||||||
cur = conn_manager.get_new_cursor()
|
|
||||||
cur.execute(
|
|
||||||
"SELECT games.id, termination_reason "
|
|
||||||
"FROM games "
|
|
||||||
"LEFT OUTER JOIN endgames_analyzed "
|
|
||||||
" ON endgames_analyzed.game_id = games.id "
|
|
||||||
)
|
|
||||||
ret = {}
|
|
||||||
for (game_id, termination_reason) in cur.fetchall():
|
|
||||||
if termination_reason is not None:
|
|
||||||
ret[game_id] = []
|
|
||||||
instance, actions, _ = games_db_interface.load_game_parts(game_id)
|
|
||||||
game = hanabi.hanab_game.GameState(instance)
|
|
||||||
|
|
||||||
endgame_actions = endgames.load_endgame_actions(game_id)
|
|
||||||
while len(endgame_actions) > 0:
|
|
||||||
# Move to current turn and update game
|
|
||||||
cur_turn = endgame_actions[0].turn
|
|
||||||
# Note the -1 here since turns on hanab.live start to count at 1
|
|
||||||
while len(game.actions) < cur_turn - 1:
|
|
||||||
action, *actions = actions
|
|
||||||
game.make_action(action)
|
|
||||||
assert len(actions) > 0
|
|
||||||
|
|
||||||
actions_this_turn: List[endgames.EndgameAction] = []
|
|
||||||
while len(endgame_actions) > 0 and endgame_actions[0].turn == cur_turn:
|
|
||||||
action, *endgame_actions = endgame_actions
|
|
||||||
actions_this_turn.append(action)
|
|
||||||
actions_this_turn.sort(key=lambda a: -a.win_rate)
|
|
||||||
best_action, *other_actions = [convert_endgame_action(a, game, actions[0]) for a in actions_this_turn]
|
|
||||||
ret[game_id].append(
|
|
||||||
(cur_turn, best_action, other_actions)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ret[game_id] = None
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def render_main_site(env: jinja2.Environment, out_dir: Path):
|
def render_main_site(env: jinja2.Environment, out_dir: Path):
|
||||||
rating_lists = get_rating_lists()
|
rating_lists = get_rating_lists()
|
||||||
streak_lists = get_streak_list()
|
streak_lists = get_streak_list()
|
||||||
|
@ -949,7 +842,6 @@ def render_all():
|
||||||
render_main_site(env, out_dir)
|
render_main_site(env, out_dir)
|
||||||
render_player_pages(env, out_dir)
|
render_player_pages(env, out_dir)
|
||||||
render_variant_pages(env, out_dir)
|
render_variant_pages(env, out_dir)
|
||||||
render_game_pages(env, out_dir)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
@ -93,7 +93,7 @@ def analyze_replay(instance: hanab_game.HanabiInstance, actions: List[hanab_game
|
||||||
|
|
||||||
def analyze_game_and_store_stats(game_id: int):
|
def analyze_game_and_store_stats(game_id: int):
|
||||||
logger.verbose("Analysing game {} for BDRs and lost crits".format(game_id))
|
logger.verbose("Analysing game {} for BDRs and lost crits".format(game_id))
|
||||||
instance, actions, _ = games_db_interface.load_game_parts(game_id)
|
instance, actions = games_db_interface.load_game(game_id)
|
||||||
analysis = analyze_replay(instance, actions)
|
analysis = analyze_replay(instance, actions)
|
||||||
|
|
||||||
cur = conn_manager.get_new_cursor()
|
cur = conn_manager.get_new_cursor()
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
{% extends "layout.html" %}
|
|
||||||
|
|
||||||
{% block navbar %}
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
|
||||||
<div class="container">
|
|
||||||
<a class="navbar-brand" href="/">The Hanabi Pro Hunting League</a><a class="navbar-brand" href="#"><small class="text-muted">- Endgame Statistics for {{game_id}}</small></a>
|
|
||||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
|
||||||
<ul class="navbar-nav ml-auto">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" id="Back" href="/">Back</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="tab-content" id="myTabContent">
|
|
||||||
<div class="tab-pane fade show active" id="overview">
|
|
||||||
<div class="container my-5">
|
|
||||||
<h3>
|
|
||||||
Statistics for game {{game_id}}
|
|
||||||
</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Replay: <a href="https://hanab.live/replay/{{game_id}}">https://hanab.live/replay/{{game_id}}</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Shared Replay: <a href="https://hanab.live/shared-replay/{{game_id}}">https://hanab.live/shared-replay/{{game_id}}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<h4>
|
|
||||||
Endgame Analysis table
|
|
||||||
</h4>
|
|
||||||
{% if data %}
|
|
||||||
<table class="endgame-table">
|
|
||||||
<tr>
|
|
||||||
<th>Turn</th>
|
|
||||||
<th>Action</th>
|
|
||||||
<th>Fractional Probability</th>
|
|
||||||
<th>Probability</th>
|
|
||||||
</tr>
|
|
||||||
{% for (turn, best_action, other_actions) in data %}
|
|
||||||
<tr>
|
|
||||||
<td rowspan="{{ other_actions|length + 1 }}"><a href="https://hanab.live/replay/{{game_id}}#{{turn}}">{{ turn }}</a></td>
|
|
||||||
<td>{% if best_action.marked %}<b>{% endif %}{{ best_action.description }}{% if best_action.marked %}</b>{% endif %}</td>
|
|
||||||
<td>{{ best_action.enumerator }}/{{ best_action.denominator }}</td>
|
|
||||||
<td>{{ best_action.win_rate }}%</td>
|
|
||||||
</tr>
|
|
||||||
{% for action in other_actions %}
|
|
||||||
<tr>
|
|
||||||
<td>{% if action.marked %}<b>{% endif %}{{ action.description }}{% if action.marked %}</b>{% endif %}</td>
|
|
||||||
<td>{{ action.enumerator }}/{{ action.denominator }}</td>
|
|
||||||
<td>{{ action.win_rate }}%</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
Currently, there is no endgame analysis available for this game. Since the computation is resource extensive,
|
|
||||||
this might take a while, also depending on how many other games have been played recently.
|
|
||||||
<br>
|
|
||||||
Come back later to check again.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
|
@ -72,7 +72,7 @@ var table_{{div_id}} = new Tabulator("#table-{{div_id}}", {
|
||||||
layout: "fitDataStretch",
|
layout: "fitDataStretch",
|
||||||
columns: [
|
columns: [
|
||||||
{title: "Game", field: "game_id", formatter: "link", formatterParams:{
|
{title: "Game", field: "game_id", formatter: "link", formatterParams:{
|
||||||
urlPrefix: "/game/",
|
urlPrefix: "https://hanab.live/replay/",
|
||||||
target:"_blank"
|
target:"_blank"
|
||||||
}},
|
}},
|
||||||
{title: "Played", field: "datetime_finished", formatter: "datetime", formatterParams:{
|
{title: "Played", field: "datetime_finished", formatter: "datetime", formatterParams:{
|
||||||
|
|
Loading…
Reference in a new issue