2021-09-04 17:17:22 +02:00
|
|
|
# vim: set fileencoding=utf-8
|
|
|
|
import base64
|
2023-08-07 02:56:43 +02:00
|
|
|
import random
|
2021-09-06 02:16:06 +02:00
|
|
|
from typing import List
|
2021-09-04 17:17:22 +02:00
|
|
|
|
|
|
|
from bemani.backend.mga.base import MetalGearArcadeBase
|
|
|
|
from bemani.backend.ess import EventLogHandler
|
2023-08-07 02:56:43 +02:00
|
|
|
from bemani.common import Profile, VersionConstants, Time
|
2021-09-04 17:17:22 +02:00
|
|
|
from bemani.data import UserID
|
|
|
|
from bemani.protocol import Node
|
|
|
|
|
|
|
|
|
|
|
|
class MetalGearArcade(
|
|
|
|
EventLogHandler,
|
|
|
|
MetalGearArcadeBase,
|
|
|
|
):
|
2021-09-07 19:56:15 +02:00
|
|
|
name: str = "Metal Gear Arcade"
|
|
|
|
version: int = VersionConstants.MGA
|
2021-09-04 17:17:22 +02:00
|
|
|
|
|
|
|
def __update_shop_name(self, profiledata: bytes) -> None:
|
|
|
|
# Figure out the profile type
|
2022-10-15 20:56:30 +02:00
|
|
|
csvs = profiledata.split(b",")
|
2021-09-04 17:17:22 +02:00
|
|
|
if len(csvs) < 2:
|
|
|
|
# Not long enough to care about
|
|
|
|
return
|
2022-10-15 20:56:30 +02:00
|
|
|
datatype = csvs[1].decode("ascii")
|
|
|
|
if datatype != "PLAYDATA":
|
2021-09-04 17:17:22 +02:00
|
|
|
# Not the right profile type requested
|
|
|
|
return
|
|
|
|
|
|
|
|
# Grab the shop name
|
|
|
|
try:
|
2022-10-15 20:56:30 +02:00
|
|
|
shopname = csvs[30].decode("shift-jis")
|
2021-09-04 17:17:22 +02:00
|
|
|
except Exception:
|
|
|
|
return
|
|
|
|
self.update_machine_name(shopname)
|
|
|
|
|
|
|
|
def handle_system_getmaster_request(self, request: Node) -> Node:
|
|
|
|
# See if we can grab the request
|
2022-10-15 20:56:30 +02:00
|
|
|
data = request.child("data")
|
2021-09-04 17:17:22 +02:00
|
|
|
if not data:
|
2022-10-15 20:56:30 +02:00
|
|
|
root = Node.void("system")
|
|
|
|
root.add_child(Node.s32("result", 0))
|
2021-09-04 17:17:22 +02:00
|
|
|
return root
|
|
|
|
|
|
|
|
# Figure out what type of messsage this is
|
2022-10-15 20:56:30 +02:00
|
|
|
reqtype = data.child_value("datatype")
|
|
|
|
reqkey = data.child_value("datakey")
|
2021-09-04 17:17:22 +02:00
|
|
|
|
|
|
|
# System message
|
2022-10-15 20:56:30 +02:00
|
|
|
root = Node.void("system")
|
2021-09-04 17:17:22 +02:00
|
|
|
|
|
|
|
if reqtype == "S_SRVMSG" and reqkey == "INFO":
|
|
|
|
# Generate system message
|
|
|
|
settings1_str = "2011081000:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1:1"
|
|
|
|
settings2_str = "1,1,1,1,1,1,1,1,1,1,1,1,1,1"
|
|
|
|
|
|
|
|
# Send it to the client, making sure to inform the client that it was valid.
|
2022-10-15 20:56:30 +02:00
|
|
|
root.add_child(
|
|
|
|
Node.string(
|
|
|
|
"strdata1",
|
|
|
|
base64.b64encode(settings1_str.encode("ascii")).decode("ascii"),
|
|
|
|
)
|
|
|
|
)
|
|
|
|
root.add_child(
|
|
|
|
Node.string(
|
|
|
|
"strdata2",
|
|
|
|
base64.b64encode(settings2_str.encode("ascii")).decode("ascii"),
|
|
|
|
)
|
|
|
|
)
|
|
|
|
root.add_child(Node.u64("updatedate", Time.now() * 1000))
|
|
|
|
root.add_child(Node.s32("result", 1))
|
2021-09-04 17:17:22 +02:00
|
|
|
else:
|
|
|
|
# Unknown message.
|
2022-10-15 20:56:30 +02:00
|
|
|
root.add_child(Node.s32("result", 0))
|
2021-09-04 17:17:22 +02:00
|
|
|
|
|
|
|
return root
|
|
|
|
|
|
|
|
def handle_playerdata_usergamedata_send_request(self, request: Node) -> Node:
|
|
|
|
# Look up user by refid
|
2022-10-15 20:56:30 +02:00
|
|
|
refid = request.child_value("data/eaid")
|
2021-09-04 17:17:22 +02:00
|
|
|
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
|
|
|
|
if userid is None:
|
2022-10-15 20:56:30 +02:00
|
|
|
root = Node.void("playerdata")
|
|
|
|
root.add_child(
|
|
|
|
Node.s32("result", 1)
|
|
|
|
) # Unclear if this is the right thing to do here.
|
2021-09-04 17:17:22 +02:00
|
|
|
return root
|
|
|
|
|
|
|
|
# Extract new profile info from old profile
|
|
|
|
oldprofile = self.get_profile(userid)
|
|
|
|
is_new = False
|
|
|
|
if oldprofile is None:
|
|
|
|
oldprofile = Profile(self.game, self.version, refid, 0)
|
|
|
|
is_new = True
|
|
|
|
newprofile = self.unformat_profile(userid, request, oldprofile, is_new)
|
|
|
|
|
|
|
|
# Write new profile
|
|
|
|
self.put_profile(userid, newprofile)
|
|
|
|
|
|
|
|
# Return success!
|
2022-10-15 20:56:30 +02:00
|
|
|
root = Node.void("playerdata")
|
|
|
|
root.add_child(Node.s32("result", 0))
|
2021-09-04 17:17:22 +02:00
|
|
|
return root
|
|
|
|
|
|
|
|
def handle_playerdata_usergamedata_recv_request(self, request: Node) -> Node:
|
|
|
|
# Look up user by refid
|
2022-10-15 20:56:30 +02:00
|
|
|
refid = request.child_value("data/eaid")
|
|
|
|
profiletypes = request.child_value("data/recv_csv").split(",")
|
2021-09-04 17:17:22 +02:00
|
|
|
profile = None
|
|
|
|
userid = None
|
|
|
|
if refid is not None:
|
|
|
|
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
|
|
|
|
if userid is not None:
|
|
|
|
profile = self.get_profile(userid)
|
|
|
|
if profile is not None:
|
|
|
|
return self.format_profile(userid, profiletypes, profile)
|
|
|
|
else:
|
2022-10-15 20:56:30 +02:00
|
|
|
root = Node.void("playerdata")
|
|
|
|
root.add_child(
|
|
|
|
Node.s32("result", 1)
|
|
|
|
) # Unclear if this is the right thing to do here.
|
2021-09-04 17:17:22 +02:00
|
|
|
return root
|
|
|
|
|
2023-08-05 19:55:27 +02:00
|
|
|
def handle_playerdata_usergamedata_scorerank_request(self, request: Node) -> Node:
|
|
|
|
# Not sure what this should do, looked like a thing to look up global rank
|
|
|
|
# but it doesn't always send the player's ID, so possibly useless?
|
|
|
|
#
|
|
|
|
# The request looks like this:
|
|
|
|
# <playerdata method="usergamedata_scorerank">
|
|
|
|
# <data>
|
|
|
|
# <eaid __type="str"></eaid>
|
|
|
|
# <gamekind __type="str">I36</gamekind>
|
|
|
|
# <vkey __type="str"></vkey>
|
|
|
|
# <conditionkey __type="str">HISTATTR</conditionkey>
|
|
|
|
# <score __type="s64">-1</score>
|
|
|
|
# </data>
|
|
|
|
# </playerdata>
|
|
|
|
root = Node.void("playerdata")
|
|
|
|
root.add_child(Node.s32("result", 1))
|
|
|
|
|
|
|
|
rank = Node.void("rank")
|
|
|
|
root.add_child(rank)
|
|
|
|
rank.add_child(Node.s32("rank", -1))
|
|
|
|
rank.add_child(Node.u64("updatetime", Time.now() * 1000))
|
|
|
|
return root
|
|
|
|
|
|
|
|
def handle_matching_request_request(self, request: Node) -> Node:
|
|
|
|
# Stand up this client as a possible matching host in the future.
|
|
|
|
refid = request.child_value("data/eaid")
|
|
|
|
userid = self.data.remote.user.from_refid(self.game, self.version, refid)
|
|
|
|
if userid is None:
|
|
|
|
root = Node.void("matching")
|
|
|
|
root.add_child(
|
2023-08-07 02:56:43 +02:00
|
|
|
Node.s32("result", -1)
|
|
|
|
) # Set to error so matching doesn't happen.
|
2023-08-05 19:55:27 +02:00
|
|
|
return root
|
|
|
|
|
2023-08-07 02:56:43 +02:00
|
|
|
# Game sends how long it intends to wait, so we should use that.
|
|
|
|
wait_time = request.child_value("data/waittime")
|
|
|
|
|
|
|
|
# Look up active lobbies, see if there was a previous one for us.
|
|
|
|
# Matchmaking takes at most 60 seconds, so assume any lobbies older
|
|
|
|
# than this are dead.
|
|
|
|
lobbies = self.data.local.lobby.get_all_lobbies(
|
|
|
|
self.game, self.version, max_age=wait_time
|
|
|
|
)
|
|
|
|
previous_hosted_lobbies = [True for uid, _ in lobbies if uid == userid]
|
|
|
|
previous_joined_lobbies = [
|
|
|
|
(uid, lobby) for uid, lobby in lobbies if userid in lobby["participants"]
|
|
|
|
]
|
|
|
|
|
|
|
|
# See if there's a random lobby we can be slotted into. Don't choose potentially
|
|
|
|
# our old one, since it will be overwritten by a new entry, if we were ever a host.
|
|
|
|
nonfull_lobbies = [
|
2023-08-07 03:05:01 +02:00
|
|
|
(uid, lobby)
|
|
|
|
for uid, lobby in lobbies
|
|
|
|
if len(lobby["participants"]) < lobby["lobbysize"]
|
2023-08-07 02:56:43 +02:00
|
|
|
]
|
|
|
|
|
|
|
|
# Make sure to put our session information somewhere that we can find again.
|
|
|
|
self.data.local.lobby.put_play_session_info(
|
2023-08-05 19:55:27 +02:00
|
|
|
self.game,
|
|
|
|
self.version,
|
|
|
|
userid,
|
|
|
|
{
|
|
|
|
"joinip": request.child_value("data/joinip"),
|
|
|
|
"joinport": request.child_value("data/joinport"),
|
|
|
|
"localip": request.child_value("data/localip"),
|
|
|
|
"localport": request.child_value("data/localport"),
|
|
|
|
"pcbid": self.config.machine.pcbid,
|
2023-08-07 02:56:43 +02:00
|
|
|
},
|
|
|
|
)
|
|
|
|
|
|
|
|
play_session_info = self.data.local.lobby.get_play_session_info(
|
|
|
|
self.game,
|
|
|
|
self.version,
|
|
|
|
userid,
|
|
|
|
)
|
|
|
|
|
|
|
|
if (nonfull_lobbies or previous_joined_lobbies) and not previous_hosted_lobbies:
|
|
|
|
if previous_joined_lobbies:
|
|
|
|
# If we're already "in" a lobby, we should go back to that one.
|
|
|
|
uid, lobby = previous_joined_lobbies[0]
|
|
|
|
else:
|
|
|
|
# Pick a random one, assign ourselves to it.
|
|
|
|
uid, lobby = random.choice(nonfull_lobbies)
|
|
|
|
|
|
|
|
# Look up the host's information.
|
|
|
|
host_play_session_info = self.data.local.lobby.get_play_session_info(
|
|
|
|
self.game,
|
|
|
|
self.version,
|
|
|
|
uid,
|
|
|
|
)
|
|
|
|
|
|
|
|
# Join this lobby.
|
|
|
|
participants = set(lobby["participants"])
|
|
|
|
participants.add(userid)
|
|
|
|
lobby["participants"] = list(participants)
|
|
|
|
self.data.local.lobby.put_lobby(self.game, self.version, uid, lobby)
|
|
|
|
|
|
|
|
# Now that we've joined the lobby, tell the game about our host ID.
|
|
|
|
root = Node.void("matching")
|
|
|
|
root.add_child(
|
|
|
|
Node.s32("result", 1)
|
|
|
|
) # Setting this to 1 makes the client consider itself a guest and join a host.
|
|
|
|
root.add_child(Node.s64("hostid", lobby.get_int("id")))
|
|
|
|
root.add_child(
|
|
|
|
Node.string("hostip_g", host_play_session_info.get_str("joinip"))
|
|
|
|
)
|
|
|
|
root.add_child(
|
|
|
|
Node.s32("hostport_g", host_play_session_info.get_int("joinport"))
|
|
|
|
)
|
|
|
|
root.add_child(
|
|
|
|
Node.string("hostip_l", host_play_session_info.get_str("localip"))
|
|
|
|
)
|
|
|
|
root.add_child(
|
|
|
|
Node.s32("hostport_l", host_play_session_info.get_int("localport"))
|
|
|
|
)
|
|
|
|
return root
|
|
|
|
|
|
|
|
# The game does weird things if you let it wait as long as its own countdown,
|
|
|
|
# so subtract a bit of wiggle-room from the wait time as reported by the game.
|
|
|
|
wait_time -= 1
|
|
|
|
|
|
|
|
# Create a lobby with this player as the "host", since there are no non-full lobbies
|
|
|
|
# or we were previously a host and want to be one again.
|
|
|
|
self.data.local.lobby.put_lobby(
|
|
|
|
self.game,
|
|
|
|
self.version,
|
|
|
|
userid,
|
|
|
|
{
|
|
|
|
"matchgrp": request.child_value("data/matchgrp"),
|
2023-08-07 03:05:01 +02:00
|
|
|
"lobbysize": request.child_value("data/waituser"),
|
2023-08-07 02:56:43 +02:00
|
|
|
"waittime": wait_time,
|
|
|
|
"createtime": Time.now(),
|
|
|
|
"participants": [userid],
|
2023-08-05 19:55:27 +02:00
|
|
|
},
|
|
|
|
)
|
|
|
|
lobby = self.data.local.lobby.get_lobby(
|
|
|
|
self.game,
|
|
|
|
self.version,
|
|
|
|
userid,
|
|
|
|
)
|
|
|
|
|
|
|
|
# Now that we've created a lobby for ourselves, tell the game about our host ID.
|
|
|
|
root = Node.void("matching")
|
|
|
|
root.add_child(
|
|
|
|
Node.s32("result", 0)
|
2023-08-07 02:56:43 +02:00
|
|
|
) # Setting this to 0 makes the client consider itself a host and listen for guests.
|
2023-08-05 19:55:27 +02:00
|
|
|
root.add_child(Node.s64("hostid", lobby.get_int("id")))
|
2023-08-07 02:56:43 +02:00
|
|
|
root.add_child(Node.string("hostip_g", play_session_info.get_str("joinip")))
|
|
|
|
root.add_child(Node.s32("hostport_g", play_session_info.get_int("joinport")))
|
|
|
|
root.add_child(Node.string("hostip_l", play_session_info.get_str("localip")))
|
|
|
|
root.add_child(Node.s32("hostport_l", play_session_info.get_int("localport")))
|
2023-08-05 19:55:27 +02:00
|
|
|
return root
|
|
|
|
|
|
|
|
def handle_matching_wait_request(self, request: Node) -> Node:
|
|
|
|
host_id = request.child_value("data/hostid")
|
|
|
|
|
2023-08-07 02:56:43 +02:00
|
|
|
# List all lobbies out, find the one that we're either a host or a guest of.
|
2023-08-05 19:55:27 +02:00
|
|
|
lobbies = self.data.local.lobby.get_all_lobbies(self.game, self.version)
|
2023-08-07 02:56:43 +02:00
|
|
|
info_by_uid = {
|
|
|
|
uid: data
|
|
|
|
for uid, data in self.data.local.lobby.get_all_play_session_infos(
|
|
|
|
self.game, self.version
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
# We should be able to filter by host_id that the game gave us.
|
|
|
|
joined_lobby = [
|
|
|
|
(uid, lobby) for uid, lobby in lobbies if lobby.get_int("id") == host_id
|
|
|
|
]
|
|
|
|
if len(joined_lobby) != 1:
|
|
|
|
# This shouldn't happen.
|
|
|
|
root = Node.void("matching")
|
|
|
|
root.add_child(Node.s32("result", -1))
|
|
|
|
return root
|
2023-08-05 19:55:27 +02:00
|
|
|
|
2023-08-07 02:56:43 +02:00
|
|
|
# Calculate creation time, figure out when to join the match after that.
|
|
|
|
host_uid, lobby = joined_lobby[0]
|
|
|
|
time_left = max(
|
|
|
|
lobby.get_int("waittime") - (Time.now() - lobby.get_int("createtime")), 0
|
|
|
|
)
|
2023-08-05 20:41:15 +02:00
|
|
|
|
2023-08-05 19:55:27 +02:00
|
|
|
root = Node.void("matching")
|
2023-08-07 02:56:43 +02:00
|
|
|
root.add_child(
|
|
|
|
Node.s32("result", 0 if time_left > 0 else 1)
|
|
|
|
) # We send 1 to start the match.
|
|
|
|
root.add_child(Node.s32("prwtime", time_left))
|
2023-08-05 19:55:27 +02:00
|
|
|
matchlist = Node.void("matchlist")
|
|
|
|
root.add_child(matchlist)
|
|
|
|
|
2023-08-07 02:56:43 +02:00
|
|
|
playercount = 0
|
|
|
|
for uid in lobby["participants"]:
|
|
|
|
# Grab player-specific IPs and stuff.
|
|
|
|
if uid not in info_by_uid:
|
2023-08-05 20:41:15 +02:00
|
|
|
continue
|
2023-08-07 02:56:43 +02:00
|
|
|
uinfo = info_by_uid[uid]
|
2023-08-05 20:41:15 +02:00
|
|
|
|
2023-08-07 03:05:01 +02:00
|
|
|
# Technically, the game only takes up to 8 of these records, but we only
|
|
|
|
# let users join the lobbies based on the size that the game requests. So,
|
|
|
|
# we don't need to worry about that.
|
|
|
|
playercount += 1
|
|
|
|
|
2023-08-05 19:55:27 +02:00
|
|
|
record = Node.void("record")
|
2023-08-07 02:56:43 +02:00
|
|
|
record.add_child(Node.string("pcbid", uinfo.get_str("pcbid")))
|
2023-08-05 19:55:27 +02:00
|
|
|
record.add_child(Node.string("statusflg", ""))
|
|
|
|
record.add_child(Node.s32("matchgrp", lobby.get_int("matchgrp")))
|
2023-08-05 20:41:15 +02:00
|
|
|
record.add_child(Node.s64("hostid", lobby.get_int("id")))
|
2023-08-07 02:56:43 +02:00
|
|
|
record.add_child(Node.u64("jointime", uinfo.get_int("time") * 1000))
|
|
|
|
record.add_child(Node.string("connip_g", uinfo.get_str("joinip")))
|
|
|
|
record.add_child(Node.s32("connport_g", uinfo.get_int("joinport")))
|
|
|
|
record.add_child(Node.string("connip_l", uinfo.get_str("localip")))
|
|
|
|
record.add_child(Node.s32("connport_l", uinfo.get_int("localport")))
|
2023-08-05 19:55:27 +02:00
|
|
|
matchlist.add_child(record)
|
|
|
|
|
2023-08-07 02:56:43 +02:00
|
|
|
matchlist.add_child(Node.u32("record_num", playercount))
|
2023-08-05 19:55:27 +02:00
|
|
|
|
|
|
|
return root
|
|
|
|
|
2022-10-15 20:56:30 +02:00
|
|
|
def format_profile(
|
|
|
|
self, userid: UserID, profiletypes: List[str], profile: Profile
|
|
|
|
) -> Node:
|
|
|
|
root = Node.void("playerdata")
|
|
|
|
root.add_child(Node.s32("result", 0))
|
|
|
|
player = Node.void("player")
|
2021-09-04 17:17:22 +02:00
|
|
|
root.add_child(player)
|
|
|
|
records = 0
|
2022-10-15 20:56:30 +02:00
|
|
|
record = Node.void("record")
|
2021-09-04 17:17:22 +02:00
|
|
|
player.add_child(record)
|
|
|
|
|
|
|
|
for profiletype in profiletypes:
|
|
|
|
if profiletype == "3fffffffff":
|
|
|
|
continue
|
2022-10-15 20:56:30 +02:00
|
|
|
for j in range(len(profile["strdatas"])):
|
|
|
|
strdata = profile["strdatas"][j]
|
|
|
|
bindata = profile["bindatas"][j]
|
2021-09-04 17:17:22 +02:00
|
|
|
|
|
|
|
# Figure out the profile type
|
2022-10-15 20:56:30 +02:00
|
|
|
csvs = strdata.split(b",")
|
2021-09-04 17:17:22 +02:00
|
|
|
if len(csvs) < 2:
|
|
|
|
# Not long enough to care about
|
|
|
|
continue
|
2022-10-15 20:56:30 +02:00
|
|
|
datatype = csvs[1].decode("ascii")
|
2021-09-04 17:17:22 +02:00
|
|
|
if datatype != profiletype:
|
|
|
|
# Not the right profile type requested
|
|
|
|
continue
|
|
|
|
|
|
|
|
# This is a valid profile node for this type, lets return only the profile values
|
2022-10-15 20:56:30 +02:00
|
|
|
strdata = b",".join(csvs[2:])
|
|
|
|
d = Node.string("d", base64.b64encode(strdata).decode("ascii"))
|
2021-09-04 17:17:22 +02:00
|
|
|
record.add_child(d)
|
2022-10-15 20:56:30 +02:00
|
|
|
d.add_child(
|
|
|
|
Node.string("bin1", base64.b64encode(bindata).decode("ascii"))
|
|
|
|
)
|
2021-09-04 17:17:22 +02:00
|
|
|
|
|
|
|
# Remember that we had this record
|
|
|
|
records = records + 1
|
|
|
|
|
2022-10-15 20:56:30 +02:00
|
|
|
player.add_child(Node.u32("record_num", records))
|
2021-09-04 17:17:22 +02:00
|
|
|
return root
|
|
|
|
|
2022-10-15 20:56:30 +02:00
|
|
|
def unformat_profile(
|
|
|
|
self, userid: UserID, request: Node, oldprofile: Profile, is_new: bool
|
|
|
|
) -> Profile:
|
2021-09-04 17:17:22 +02:00
|
|
|
# Profile save request, data values are base64 encoded.
|
|
|
|
# d is a CSV, and bin1 is binary data.
|
2021-09-07 19:56:15 +02:00
|
|
|
newprofile = oldprofile.clone()
|
2021-09-04 17:17:22 +02:00
|
|
|
strdatas: List[bytes] = []
|
|
|
|
bindatas: List[bytes] = []
|
|
|
|
|
2022-10-15 20:56:30 +02:00
|
|
|
record = request.child("data/record")
|
2021-09-04 17:17:22 +02:00
|
|
|
for node in record.children:
|
2022-10-15 20:56:30 +02:00
|
|
|
if node.name != "d":
|
2021-09-04 17:17:22 +02:00
|
|
|
continue
|
|
|
|
|
|
|
|
profile = base64.b64decode(node.value)
|
|
|
|
# Update the shop name if this is a new profile, since we know it came
|
|
|
|
# from this cabinet. This is the only source of truth for what the
|
|
|
|
# cabinet shop name is set to.
|
|
|
|
if is_new:
|
|
|
|
self.__update_shop_name(profile)
|
|
|
|
strdatas.append(profile)
|
2022-10-15 20:56:30 +02:00
|
|
|
bindatas.append(base64.b64decode(node.child_value("bin1")))
|
2021-09-04 17:17:22 +02:00
|
|
|
|
2022-10-15 20:56:30 +02:00
|
|
|
newprofile["strdatas"] = strdatas
|
|
|
|
newprofile["bindatas"] = bindatas
|
2021-09-04 17:17:22 +02:00
|
|
|
|
|
|
|
# Keep track of play statistics across all versions
|
|
|
|
self.update_play_statistics(userid)
|
|
|
|
|
|
|
|
return newprofile
|