Now I can retrieve history thx to p1nkl0bst3r api

But it's slow (because my code is shit)
This commit is contained in:
dan63047 2023-07-17 20:57:24 +03:00
parent c69ba0a90f
commit 501832c9aa
12 changed files with 125 additions and 43 deletions

View File

@ -2,7 +2,7 @@
<center>Track your and other players stats in TETR.IO</center>
![Screenshot of the app](https://imgur.com/GGL0fux.png)
![Screenshot of the app](https://imgur.com/eAtFeBF.png)
# Development Roadmap
- ~~Ability to fetch player~~
@ -21,10 +21,13 @@
- ~~Ability to compare player with avgRank~~
- UI Animations *lol*
- ~~i18n, EN and RU locales~~ *dev build are here*
- Talk with osk about CORS and EndContext in TL matches *idk lol*
- ~~Talk with osk about CORS and EndContext in TL matches~~ *k*
- storeState becomes slow when there a lot of entries, needs to fix
- im still not rendering distinguishment
- RELEASE ??? *that will be v1.0.0*
---
Special thanks to kerrmunism for formulas
and to osk for TETR.IO

View File

@ -127,7 +127,11 @@ class TetrioPlayer {
userId = json['_id'];
username = json['username'];
state = stateTime;
if (json['role'] == "retrived from p1nkl0bst3r api"){ //i fucked my own db lol i remove it later
role = "p1nkl0bst3r";
}else{
role = json['role'];
}
registrationTime = json['ts'] != null ? DateTime.parse(json['ts']) : null;
if (json['badges'] != null) {
json['badges'].forEach((v) {
@ -179,7 +183,7 @@ class TetrioPlayer {
return data;
}
bool isSameState(TetrioPlayer other) {
bool isSameState(covariant TetrioPlayer other) {
if (userId != other.userId) return false;
if (username != other.username) return false;
if (role != other.role) return false;
@ -201,6 +205,10 @@ class TetrioPlayer {
return true;
}
bool checkForRetrivedHistory(covariant TetrioPlayer other) {
return tlSeason1.lessStrictCheck(other.tlSeason1);
}
@override
String toString() {
return "$username ($state)";
@ -712,7 +720,8 @@ class TetraLeagueAlpha {
List? records;
TetraLeagueAlpha(
{required this.gamesPlayed,
{required this.timestamp,
required this.gamesPlayed,
required this.gamesWon,
required this.bestRank,
required this.decaying,
@ -769,6 +778,8 @@ class TetraLeagueAlpha {
@override
bool operator ==(covariant TetraLeagueAlpha other) => gamesPlayed == other.gamesPlayed && rd == other.rd;
bool lessStrictCheck (covariant TetraLeagueAlpha other) => gamesPlayed == other.gamesPlayed && glicko == other.glicko;
double? get esttracc => (estTr != null) ? estTr!.esttr - rating : null;
Map<String, dynamic> toJson() {
@ -911,7 +922,7 @@ class TetrioPlayersLeaderboard {
avgRD /= filtredLeaderboard.length;
avgGamesPlayed = (totalGamesPlayed / filtredLeaderboard.length).floor();
avgGamesWon = (totalGamesWon / filtredLeaderboard.length).floor();
return [TetraLeagueAlpha(apm: avgAPM, pps: avgPPS, vs: avgVS, glicko: avgGlicko, rd: avgRD, gamesPlayed: avgGamesPlayed, gamesWon: avgGamesWon, bestRank: rank, decaying: false, rating: avgTR, rank: rank, percentileRank: rank, percentile: 0, standing: -1, standingLocal: -1, nextAt: -1, prevAt: -1),
return [TetraLeagueAlpha(timestamp: DateTime.now(), apm: avgAPM, pps: avgPPS, vs: avgVS, glicko: avgGlicko, rd: avgRD, gamesPlayed: avgGamesPlayed, gamesWon: avgGamesWon, bestRank: rank, decaying: false, rating: avgTR, rank: rank, percentileRank: rank, percentile: 0, standing: -1, standingLocal: -1, nextAt: -1, prevAt: -1),
{"totalGamesPlayed": totalGamesPlayed, "totalGamesWon": totalGamesWon, "players": filtredLeaderboard.length, "lowestTR": lowestTR, "toEnterTR": leaderboard[(leaderboard.length * rankCutoffs[rank]!).floor()-1].rating}];
}

View File

@ -1,9 +1,9 @@
/// Generated file. Do not edit.
///
/// Locales: 2
/// Strings: 848 (424 per locale)
/// Strings: 850 (425 per locale)
///
/// Built on 2023-07-15 at 16:11 UTC
/// Built on 2023-07-17 at 17:33 UTC
// coverage:ignore-file
// ignore_for_file: type=lint
@ -234,7 +234,7 @@ class _StringsEn implements BaseTranslations<AppLocale, _StringsEn> {
String get winChance => 'Win Chance';
String get byGlicko => 'By Glicko';
String get byEstTR => 'By Est. TR';
String get compareViewNoValues => 'Please, enter username, user ID, or APM-PPS-VS values (divider doesn\'t matter, only order matter) to both of fields';
String compareViewNoValues({required Object avgR}) => 'Please, enter username, user ID, APM-PPS-VS values (divider doesn\'t matter, only order matter) or ${avgR} (where R is rank) to both of fields';
String compareViewWrongValue({required Object value}) => 'Falied to assign ${value}';
String get mostRecentOne => 'Most recent one';
String get yes => 'Yes';
@ -249,6 +249,7 @@ class _StringsEn implements BaseTranslations<AppLocale, _StringsEn> {
String get lbViewZeroEntrys => 'Empty list. Looks like something is wrong...';
String get lbViewOneEntry => 'There is only one player... What?';
String lbViewManyEntrys({required Object numberOfPlayers}) => 'There are ${numberOfPlayers} ranked players.';
String get p1nkl0bst3rAlert => 'That data was retrived from third party API maintained by p1nkl0bst3r';
late final _StringsStatCellNumEn statCellNum = _StringsStatCellNumEn._(_root);
Map<String, String> get playerRole => {
'user': 'User',
@ -731,7 +732,7 @@ class _StringsRu implements _StringsEn {
@override String get winChance => 'Шансы на победу';
@override String get byGlicko => 'По Glicko';
@override String get byEstTR => 'По расч. TR';
@override String get compareViewNoValues => 'Пожалуйста, введите никнейм, ID или значения APM-PPS-VS (неважно, какой разделитель, важен порядок) в оба поля';
@override String compareViewNoValues({required Object avgR}) => 'Пожалуйста, введите никнейм, ID, APM-PPS-VS (неважно, какой разделитель, важен порядок) или ${avgR} (где R это ранг), в оба поля';
@override String compareViewWrongValue({required Object value}) => 'Не удалось получить ${value}';
@override String get mostRecentOne => 'Самый последний';
@override String get yes => 'Да';
@ -746,6 +747,7 @@ class _StringsRu implements _StringsEn {
@override String get lbViewZeroEntrys => 'Рейтинговая таблица пуста. Похоже, что-то здесь не так...';
@override String get lbViewOneEntry => 'В рейтинговой таблице всего один игрок... Чего?';
@override String lbViewManyEntrys({required Object numberOfPlayers}) => 'В рейтинговой таблице находится ${numberOfPlayers} игроков.';
@override String get p1nkl0bst3rAlert => 'Эти данные были получены из стороннего API, который поддерживается p1nkl0bst3r';
@override late final _StringsStatCellNumRu statCellNum = _StringsStatCellNumRu._(_root);
@override Map<String, String> get playerRole => {
'user': 'Пользователь',
@ -1207,7 +1209,7 @@ extension on _StringsEn {
case 'winChance': return 'Win Chance';
case 'byGlicko': return 'By Glicko';
case 'byEstTR': return 'By Est. TR';
case 'compareViewNoValues': return 'Please, enter username, user ID, or APM-PPS-VS values (divider doesn\'t matter, only order matter) to both of fields';
case 'compareViewNoValues': return ({required Object avgR}) => 'Please, enter username, user ID, APM-PPS-VS values (divider doesn\'t matter, only order matter) or ${avgR} (where R is rank) to both of fields';
case 'compareViewWrongValue': return ({required Object value}) => 'Falied to assign ${value}';
case 'mostRecentOne': return 'Most recent one';
case 'yes': return 'Yes';
@ -1222,6 +1224,7 @@ extension on _StringsEn {
case 'lbViewZeroEntrys': return 'Empty list. Looks like something is wrong...';
case 'lbViewOneEntry': return 'There is only one player... What?';
case 'lbViewManyEntrys': return ({required Object numberOfPlayers}) => 'There are ${numberOfPlayers} ranked players.';
case 'p1nkl0bst3rAlert': return 'That data was retrived from third party API maintained by p1nkl0bst3r';
case 'statCellNum.xpLevel': return 'XP Level';
case 'statCellNum.xpProgress': return 'Progress to next level';
case 'statCellNum.xpFrom0To5000': return 'Progress from 0 XP to level 5000';
@ -1639,7 +1642,7 @@ extension on _StringsRu {
case 'winChance': return 'Шансы на победу';
case 'byGlicko': return 'По Glicko';
case 'byEstTR': return 'По расч. TR';
case 'compareViewNoValues': return 'Пожалуйста, введите никнейм, ID или значения APM-PPS-VS (неважно, какой разделитель, важен порядок) в оба поля';
case 'compareViewNoValues': return ({required Object avgR}) => 'Пожалуйста, введите никнейм, ID, APM-PPS-VS (неважно, какой разделитель, важен порядок) или ${avgR} (где R это ранг), в оба поля';
case 'compareViewWrongValue': return ({required Object value}) => 'Не удалось получить ${value}';
case 'mostRecentOne': return 'Самый последний';
case 'yes': return 'Да';
@ -1654,6 +1657,7 @@ extension on _StringsRu {
case 'lbViewZeroEntrys': return 'Рейтинговая таблица пуста. Похоже, что-то здесь не так...';
case 'lbViewOneEntry': return 'В рейтинговой таблице всего один игрок... Чего?';
case 'lbViewManyEntrys': return ({required Object numberOfPlayers}) => 'В рейтинговой таблице находится ${numberOfPlayers} игроков.';
case 'p1nkl0bst3rAlert': return 'Эти данные были получены из стороннего API, который поддерживается p1nkl0bst3r';
case 'statCellNum.xpLevel': return 'Уровень\nопыта';
case 'statCellNum.xpProgress': return 'Прогресс до следующего уровня';
case 'statCellNum.xpFrom0To5000': return 'Прогресс от 0 XP до 5000 уровня';

View File

@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:tetra_stats/services/crud_exceptions.dart';
import 'package:tetra_stats/services/sqlite_db_controller.dart';
import 'package:tetra_stats/data_objects/tetrio.dart';
import 'package:csv/csv.dart';
const String dbName = "TetraStats.db";
const String tetrioUsersTable = "tetrioUsers";
@ -88,8 +89,49 @@ class TetrioService extends DB {
Future<String> getNicknameByID(String id) async {
if (id.length <= 16) return id;
TetrioPlayer player = await getPlayer(id).then((value) => value.last);
return player.username;
try{
return await getPlayer(id).then((value) => value.last.username);
} catch (e){
return await fetchPlayer(id).then((value) => value.username);
}
}
Future<List<TetrioPlayer>> fetchAndsaveTLHistory(String id) async {
var url = Uri.https('api.p1nkl0bst3r.xyz', 'tlhist/$id');
final response = await http.get(url);
if (response.statusCode == 200) {
List<List<dynamic>> csv = const CsvToListConverter().convert(response.body)..removeAt(0);
List<TetrioPlayer> history = [];
String nick = await getNicknameByID(id);
for (List<dynamic> entry in csv){
TetrioPlayer state = TetrioPlayer(
userId: id,
username: nick,
role: "p1nkl0bst3r",
state: DateTime.parse(entry[9]),
badges: [],
friendCount: -1,
gamesPlayed: -1,
gamesWon: -1,
gameTime: const Duration(seconds: -1),
xp: -1,
supporterTier: 0,
verified: false,
connections: Connections(),
tlSeason1: TetraLeagueAlpha(timestamp: DateTime.parse(entry[9]), apm: entry[6], pps: entry[7], vs: entry[8], glicko: entry[4], rd: noTrRd, gamesPlayed: entry[1], gamesWon: entry[2], bestRank: "z", decaying: false, rating: entry[3], rank: entry[5], percentileRank: entry[5], percentile: rankCutoffs[entry[5]]!, standing: -1, standingLocal: -1, nextAt: -1, prevAt: -1),
sprint: [],
blitz: []
);
history.add(state);
// _players.addEntries({state.userId: [state]}.entries);
await storeState(state, isFromHistory: true);
}
return history;
}
else {
developer.log("fetchTLHistory: Probably, history doesn't exist", name: "services/tetrio_crud", error: response.statusCode);
throw Exception('Failed to fetch player');
}
}
Future<TetrioPlayersLeaderboard> fetchTLLeaderboard() async {
@ -277,25 +319,26 @@ class TetrioService extends DB {
}
}
Future<void> storeState(TetrioPlayer tetrioPlayer) async {
Future<void> storeState(TetrioPlayer tetrioPlayer, {bool isFromHistory = false}) async {
ensureDbIsOpen();
final db = getDatabaseOrThrow();
late List<TetrioPlayer> states;
try {
states = await getPlayer(tetrioPlayer.userId);
} on TetrioPlayerNotExist {
states = _players[tetrioPlayer.userId]!;
//states = await getPlayer(tetrioPlayer.userId);
} catch (e) {
await createPlayer(tetrioPlayer);
states = await getPlayer(tetrioPlayer.userId);
}
bool test = _players[tetrioPlayer.userId]!.last.isSameState(tetrioPlayer);
if (test == false) states.add(tetrioPlayer);
bool test = isFromHistory ? _players[tetrioPlayer.userId]!.last.checkForRetrivedHistory(tetrioPlayer) : _players[tetrioPlayer.userId]!.last.isSameState(tetrioPlayer);
if (test == false) isFromHistory ? states.insert(0, tetrioPlayer) : states.add(tetrioPlayer);
final Map<String, dynamic> statesJson = {};
for (var e in states) {
statesJson.addEntries({e.state.millisecondsSinceEpoch.toString(): e.toJson()}.entries);
}
db.update(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)},
await db.update(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)},
where: '$idCol = ?', whereArgs: [tetrioPlayer.userId]);
_players[tetrioPlayer.userId]!.add(tetrioPlayer);
isFromHistory ? _players[tetrioPlayer.userId]!.insert(0, tetrioPlayer) : _players[tetrioPlayer.userId]!.add(tetrioPlayer);
_tetrioStreamController.add(_players);
}
@ -310,7 +353,7 @@ class TetrioService extends DB {
for (var e in states) {
statesJson.addEntries({e.state.millisecondsSinceEpoch.toString(): e.toJson()}.entries);
}
db.update(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)},
await db.update(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)},
where: '$idCol = ?', whereArgs: [tetrioPlayer.userId]);
_players[tetrioPlayer.userId]!.add(tetrioPlayer);
_tetrioStreamController.add(_players);
@ -322,7 +365,7 @@ class TetrioService extends DB {
List<TetrioPlayer> states = [];
final results = await db.query(tetrioUsersTable, limit: 1, where: '$idCol = ?', whereArgs: [id.toLowerCase()]);
if (results.isEmpty) {
throw TetrioPlayerNotExist();
return states;
} else {
dynamic rawStates = results.first['jsonStates'] as String;
rawStates = json.decode(rawStates);

View File

@ -78,6 +78,7 @@ class CompareState extends State<CompareView> {
theRedSide = [null,
null,
TetraLeagueAlpha(
timestamp: DateTime.now(),
apm: apm,
pps: pps,
vs: vs,
@ -153,6 +154,7 @@ class CompareState extends State<CompareView> {
theGreenSide = [null,
null,
TetraLeagueAlpha(
timestamp: DateTime.now(),
apm: apm,
pps: pps,
vs: vs,
@ -847,7 +849,7 @@ class CompareState extends State<CompareView> {
)
],
)
] : [Text(t.compareViewNoValues)],
] : [Text(t.compareViewNoValues(avgR: "\$avdR"))], // This is so fucked up holy shit
)
),
),

View File

@ -16,7 +16,7 @@ import 'package:tetra_stats/widgets/tl_thingy.dart';
import 'package:tetra_stats/widgets/user_thingy.dart';
late Future<List> me;
String _searchFor = "dan63047";
String _searchFor = "6098518e3d5155e6ec429cdc";
String _titleNickname = "dan63047";
final TetrioService teto = TetrioService();
late SharedPreferences prefs;
@ -103,15 +103,16 @@ class _MainState extends State<MainView> with SingleTickerProviderStateMixin {
prefs = await SharedPreferences.getInstance();
}
void changePlayer(String player) {
void changePlayer(String player, {bool fetchHistory = false}) {
setState(() {
_searchFor = player;
me = fetch(_searchFor);
me = fetch(_searchFor, fetchHistory: fetchHistory);
});
}
Future<List> fetch(String nickOrID) async {
Future<List> fetch(String nickOrID, {bool fetchHistory = false}) async {
TetrioPlayer me = await teto.fetchPlayer(nickOrID);
_searchFor = me.userId;
setState((){_titleNickname = me.username;});
var tlStream = await teto.getTLStream(me.userId);
List<TetraLeagueAlphaRecord> tlMatches = [];
@ -119,10 +120,8 @@ class _MainState extends State<MainView> with SingleTickerProviderStateMixin {
List<TetrioPlayer> states = [];
TetraLeagueAlpha? compareWith;
var uniqueTL = <dynamic>{};
if (isTracking){
teto.storeState(me);
teto.saveTLMatchesFromStream(await teto.getTLStream(me.userId));
states.addAll(await teto.getPlayer(me.userId));
if(fetchHistory) await teto.fetchAndsaveTLHistory(_searchFor);
for (var element in states) {
if (uniqueTL.isNotEmpty && uniqueTL.last != element.tlSeason1) uniqueTL.add(element.tlSeason1);
if (uniqueTL.isEmpty) uniqueTL.add(element.tlSeason1);
@ -151,6 +150,9 @@ class _MainState extends State<MainView> with SingleTickerProviderStateMixin {
DropdownMenuItem(value: [for (var tl in uniqueTL) if (tl.estTr != null) FlSpot(tl.timestamp.millisecondsSinceEpoch.toDouble(), tl.estTr!.esttr)], child: Text(t.statCellNum.estOfTR.replaceAll(RegExp(r'\n'), " "))),
DropdownMenuItem(value: [for (var tl in uniqueTL) if (tl.esttracc != null) FlSpot(tl.timestamp.millisecondsSinceEpoch.toDouble(), tl.esttracc!)], child: Text(t.statCellNum.accOfEst.replaceAll(RegExp(r'\n'), " "))),
];
if (isTracking){
await teto.storeState(me);
await teto.saveTLMatchesFromStream(await teto.getTLStream(me.userId));
tlMatches.addAll(await teto.getTLMatchesbyPlayerID(me.userId));
for (var match in tlStream.records) {
if (!tlMatches.contains(match)) tlMatches.add(match);
@ -220,6 +222,10 @@ class _MainState extends State<MainView> with SingleTickerProviderStateMixin {
),
PopupMenuButton(
itemBuilder: (BuildContext context) => <PopupMenuEntry>[
PopupMenuItem(
value: "test",
child: Text("fetchAndsaveTLHistory"),
),
PopupMenuItem(
value: "refresh",
child: Text(t.refresh),
@ -240,6 +246,8 @@ class _MainState extends State<MainView> with SingleTickerProviderStateMixin {
onSelected: (value) {
if (value == "refresh") {changePlayer(_searchFor);
return;}
if (value == "test"){changePlayer(_searchFor, fetchHistory: true);
return;}
Navigator.pushNamed(context, value);
},
),

View File

@ -54,14 +54,14 @@ class TLThingy extends StatelessWidget {
),
),
Text(
"${t.top} ${f2.format(tl.percentile * 100)}% (${tl.percentileRank.toUpperCase()})${t.topRank}: ${tl.bestRank.toUpperCase()} • Glicko: ${f2.format(tl.glicko!)}±${f2.format(tl.rd!)}${tl.decaying ? '${t.decaying}' : ''}",
"${t.top} ${f2.format(tl.percentile * 100)}% (${tl.percentileRank.toUpperCase()})${tl.bestRank != "z" ? "${t.topRank}: ${tl.bestRank.toUpperCase()}" : ""} Glicko: ${f2.format(tl.glicko!)}±${f2.format(tl.rd!)}${tl.decaying ? '${t.decaying}' : ''}",
textAlign: TextAlign.center,
),
],
),
],
),
if (tl.gamesPlayed >= 10 && tl.rd! < 100) Padding(
if (tl.gamesPlayed >= 10 && tl.rd! < 100 && tl.nextAt >=0 && tl.prevAt >= 0) Padding(
padding: const EdgeInsets.all(8.0),
child: SfLinearGauge(
minimum: tl.nextAt.toDouble(),

View File

@ -158,8 +158,8 @@ class UserThingy extends StatelessWidget {
]),
],
),
(player.role != "banned")
? Wrap(
if (!["banned", "p1nkl0bst3r"].contains(player.role))
Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.center,
spacing: 25,
@ -200,8 +200,8 @@ class UserThingy extends StatelessWidget {
playerStatLabel: t.statCellNum.friends,
higherIsBetter: true,),
],
)
: Text(
),
if (player.role == "banned") Text(
t.bigRedBanned,
textAlign: TextAlign.center,
style: TextStyle(
@ -211,6 +211,14 @@ class UserThingy extends StatelessWidget {
fontSize: bigScreen ? 60 : 45,
),
),
if (player.role == "p1nkl0bst3r") Text(
t.p1nkl0bst3rAlert,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: "Eurostile Round",
fontSize: 16,
)
),
if (player.badstanding != null && player.badstanding!)
Text(
t.bigRedBadStanding,
@ -222,7 +230,7 @@ class UserThingy extends StatelessWidget {
fontSize: bigScreen ? 60 : 45,
),
),
Row(
if (player.role != "p1nkl0bst3r") Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(

View File

@ -98,7 +98,7 @@ packages:
source: hosted
version: "3.0.3"
csv:
dependency: transitive
dependency: "direct main"
description:
name: csv
sha256: "016b31a51a913744a0a1655c74ff13c9379e1200e246a03d96c81c5d9ed297b5"

View File

@ -35,6 +35,7 @@ dependencies:
file_picker: ^5.3.2
slang: ^3.20.0
slang_flutter: ^3.20.0
csv: ^5.0.2
dev_dependencies:
flutter_test:

View File

@ -86,7 +86,7 @@
"winChance": "Win Chance",
"byGlicko": "By Glicko",
"byEstTR": "By Est. TR",
"compareViewNoValues": "Please, enter username, user ID, or APM-PPS-VS values (divider doesn't matter, only order matter) to both of fields",
"compareViewNoValues": "Please, enter username, user ID, APM-PPS-VS values (divider doesn't matter, only order matter) or $avgR (where R is rank) to both of fields",
"compareViewWrongValue": "Falied to assign ${value}",
"mostRecentOne": "Most recent one",
"yes": "Yes",
@ -101,6 +101,7 @@
"lbViewZeroEntrys": "Empty list. Looks like something is wrong...",
"lbViewOneEntry": "There is only one player... What?",
"lbViewManyEntrys": "There are ${numberOfPlayers} ranked players.",
"p1nkl0bst3rAlert": "That data was retrived from third party API maintained by p1nkl0bst3r",
"statCellNum":{
"xpLevel": "XP Level",
"xpProgress": "Progress to next level",

View File

@ -86,7 +86,7 @@
"winChance": "Шансы на победу",
"byGlicko": "По Glicko",
"byEstTR": "По расч. TR",
"compareViewNoValues": "Пожалуйста, введите никнейм, ID или значения APM-PPS-VS (неважно, какой разделитель, важен порядок) в оба поля",
"compareViewNoValues": "Пожалуйста, введите никнейм, ID, APM-PPS-VS (неважно, какой разделитель, важен порядок) или $avgR (где R это ранг), в оба поля",
"compareViewWrongValue": "Не удалось получить ${value}",
"mostRecentOne": "Самый последний",
"yes": "Да",
@ -101,6 +101,7 @@
"lbViewZeroEntrys": "Рейтинговая таблица пуста. Похоже, что-то здесь не так...",
"lbViewOneEntry": "В рейтинговой таблице всего один игрок... Чего?",
"lbViewManyEntrys": "В рейтинговой таблице находится ${numberOfPlayers} игроков.",
"p1nkl0bst3rAlert": "Эти данные были получены из стороннего API, который поддерживается p1nkl0bst3r",
"statCellNum": {
"xpLevel": "Уровень\nопыта",
"xpProgress": "Прогресс до следующего уровня",