2020-12-19 00:14:50 +08:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable enable
2021-03-01 16:24:54 +08:00
using System ;
2021-02-01 16:54:56 +08:00
using System.Collections.Generic ;
2020-12-19 00:14:50 +08:00
using System.Diagnostics ;
using System.Linq ;
2021-03-01 16:24:54 +08:00
using System.Threading ;
2020-12-19 00:14:50 +08:00
using System.Threading.Tasks ;
using osu.Framework.Allocation ;
using osu.Framework.Bindables ;
2021-03-01 16:24:54 +08:00
using osu.Game.Beatmaps ;
2020-12-19 00:14:50 +08:00
using osu.Game.Online.API ;
2021-11-04 11:30:19 +08:00
using osu.Game.Online.API.Requests.Responses ;
2020-12-25 12:38:11 +08:00
using osu.Game.Online.Multiplayer ;
2021-08-02 16:06:01 +08:00
using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus ;
2021-10-22 15:48:28 +08:00
using osu.Game.Online.Multiplayer.Queueing ;
2021-01-03 09:32:50 +08:00
using osu.Game.Online.Rooms ;
2021-02-01 16:54:56 +08:00
using osu.Game.Rulesets.Mods ;
2021-11-04 17:02:44 +08:00
using APIUser = osu . Game . Online . API . Requests . Responses . APIUser ;
2020-12-19 00:14:50 +08:00
2020-12-25 12:38:11 +08:00
namespace osu.Game.Tests.Visual.Multiplayer
2020-12-19 00:14:50 +08:00
{
2021-06-25 17:02:53 +08:00
/// <summary>
/// A <see cref="MultiplayerClient"/> for use in multiplayer test scenes. Should generally not be used by itself outside of a <see cref="MultiplayerTestScene"/>.
/// </summary>
2021-05-20 14:39:45 +08:00
public class TestMultiplayerClient : MultiplayerClient
2020-12-19 00:14:50 +08:00
{
2020-12-19 00:16:00 +08:00
public override IBindable < bool > IsConnected = > isConnected ;
private readonly Bindable < bool > isConnected = new Bindable < bool > ( true ) ;
2020-12-19 00:14:50 +08:00
2021-07-19 19:19:23 +08:00
public new Room ? APIRoom = > base . APIRoom ;
2021-05-11 18:01:41 +08:00
2021-04-22 22:22:44 +08:00
public Action < MultiplayerRoom > ? RoomSetupAction ;
2020-12-19 00:14:50 +08:00
[Resolved]
private IAPIProvider api { get ; set ; } = null ! ;
2021-03-01 16:24:54 +08:00
[Resolved]
private BeatmapManager beatmaps { get ; set ; } = null ! ;
2021-10-27 15:10:22 +08:00
private readonly TestMultiplayerRoomManager roomManager ;
2021-11-13 00:42:51 +08:00
private PlaylistItem ? currentItem ;
2021-03-03 18:40:19 +08:00
2021-10-27 15:10:22 +08:00
public TestMultiplayerClient ( TestMultiplayerRoomManager roomManager )
2021-03-03 18:40:19 +08:00
{
this . roomManager = roomManager ;
}
2020-12-19 00:16:00 +08:00
public void Connect ( ) = > isConnected . Value = true ;
public void Disconnect ( ) = > isConnected . Value = false ;
2021-11-04 17:02:44 +08:00
public MultiplayerRoomUser AddUser ( APIUser user , bool markAsPlaying = false )
2021-08-09 18:18:13 +08:00
{
var roomUser = new MultiplayerRoomUser ( user . Id ) { User = user } ;
2021-10-14 23:10:39 +08:00
addUser ( roomUser ) ;
2021-08-09 18:18:13 +08:00
if ( markAsPlaying )
PlayingUserIds . Add ( user . Id ) ;
return roomUser ;
}
2020-12-19 00:14:50 +08:00
2021-11-02 15:51:27 +08:00
public void TestAddUnresolvedUser ( ) = > addUser ( new MultiplayerRoomUser ( TestUserLookupCache . UNRESOLVED_USER_ID ) ) ;
2021-10-14 23:10:39 +08:00
private void addUser ( MultiplayerRoomUser user )
{
( ( IMultiplayerClient ) this ) . UserJoined ( user ) . Wait ( ) ;
2021-10-14 23:20:45 +08:00
// We want the user to be immediately available for testing, so force a scheduler update to run the update-bound continuation.
2021-10-14 23:10:39 +08:00
Scheduler . Update ( ) ;
2021-11-11 15:39:59 +08:00
switch ( Room ? . MatchState )
{
case TeamVersusRoomState teamVersus :
Debug . Assert ( Room ! = null ) ;
// simulate the server's automatic assignment of users to teams on join.
// the "best" team is the one with the least users on it.
int bestTeam = teamVersus . Teams
. Select ( team = > ( teamID : team . ID , userCount : Room . Users . Count ( u = > ( u . MatchState as TeamVersusUserState ) ? . TeamID = = team . ID ) ) )
. OrderBy ( pair = > pair . userCount )
. First ( ) . teamID ;
( ( IMultiplayerClient ) this ) . MatchUserStateChanged ( user . UserID , new TeamVersusUserState { TeamID = bestTeam } ) . Wait ( ) ;
break ;
}
2021-10-14 23:10:39 +08:00
}
2020-12-28 13:56:53 +08:00
2021-11-04 17:02:44 +08:00
public void RemoveUser ( APIUser user )
2020-12-19 00:14:50 +08:00
{
Debug . Assert ( Room ! = null ) ;
2020-12-24 04:00:47 +08:00
( ( IMultiplayerClient ) this ) . UserLeft ( new MultiplayerRoomUser ( user . Id ) ) ;
2020-12-19 00:14:50 +08:00
Schedule ( ( ) = >
{
if ( Room . Users . Any ( ) )
TransferHost ( Room . Users . First ( ) . UserID ) ;
} ) ;
}
2021-04-07 15:35:36 +08:00
public void ChangeRoomState ( MultiplayerRoomState newState )
{
Debug . Assert ( Room ! = null ) ;
( ( IMultiplayerClient ) this ) . RoomStateChanged ( newState ) ;
}
2020-12-19 00:14:50 +08:00
public void ChangeUserState ( int userId , MultiplayerUserState newState )
{
Debug . Assert ( Room ! = null ) ;
( ( IMultiplayerClient ) this ) . UserStateChanged ( userId , newState ) ;
Schedule ( ( ) = >
{
2021-10-22 20:16:10 +08:00
switch ( Room . State )
2020-12-19 00:14:50 +08:00
{
2021-10-22 20:16:10 +08:00
case MultiplayerRoomState . WaitingForLoad :
2020-12-19 00:14:50 +08:00
if ( Room . Users . All ( u = > u . State ! = MultiplayerUserState . WaitingForLoad ) )
{
foreach ( var u in Room . Users . Where ( u = > u . State = = MultiplayerUserState . Loaded ) )
ChangeUserState ( u . UserID , MultiplayerUserState . Playing ) ;
( ( IMultiplayerClient ) this ) . MatchStarted ( ) ;
2021-10-22 20:16:10 +08:00
ChangeRoomState ( MultiplayerRoomState . Playing ) ;
2020-12-19 00:14:50 +08:00
}
break ;
2021-10-22 20:16:10 +08:00
case MultiplayerRoomState . Playing :
2020-12-19 00:14:50 +08:00
if ( Room . Users . All ( u = > u . State ! = MultiplayerUserState . Playing ) )
{
foreach ( var u in Room . Users . Where ( u = > u . State = = MultiplayerUserState . FinishedPlay ) )
ChangeUserState ( u . UserID , MultiplayerUserState . Results ) ;
2021-10-22 20:16:10 +08:00
ChangeRoomState ( MultiplayerRoomState . Open ) ;
2020-12-19 00:14:50 +08:00
( ( IMultiplayerClient ) this ) . ResultsReady ( ) ;
2021-10-22 21:07:41 +08:00
2021-11-13 00:42:51 +08:00
Task . Run ( finishCurrentItem ) ;
2020-12-19 00:14:50 +08:00
}
break ;
}
} ) ;
}
2021-01-03 09:32:50 +08:00
public void ChangeUserBeatmapAvailability ( int userId , BeatmapAvailability newBeatmapAvailability )
{
Debug . Assert ( Room ! = null ) ;
( ( IMultiplayerClient ) this ) . UserBeatmapAvailabilityChanged ( userId , newBeatmapAvailability ) ;
}
2021-11-13 00:42:51 +08:00
protected override async Task < MultiplayerRoom > JoinRoom ( long roomId , string? password = null )
2020-12-19 00:14:50 +08:00
{
2021-08-17 08:42:17 +08:00
var apiRoom = roomManager . ServerSideRooms . Single ( r = > r . RoomID . Value = = roomId ) ;
2020-12-19 00:14:50 +08:00
2021-07-19 19:01:44 +08:00
if ( password ! = apiRoom . Password . Value )
throw new InvalidOperationException ( "Invalid password." ) ;
2021-04-22 22:22:44 +08:00
var localUser = new MultiplayerRoomUser ( api . LocalUser . Value . Id )
2021-03-01 16:24:32 +08:00
{
User = api . LocalUser . Value
} ;
2020-12-19 00:14:50 +08:00
2021-11-13 00:42:51 +08:00
await updateCurrentItem ( apiRoom : apiRoom ) . ConfigureAwait ( false ) ;
Debug . Assert ( currentItem ! = null ) ;
2021-03-01 16:24:32 +08:00
var room = new MultiplayerRoom ( roomId )
{
Settings =
{
Name = apiRoom . Name . Value ,
2021-08-02 16:06:01 +08:00
MatchType = apiRoom . Type . Value ,
2021-11-13 00:42:51 +08:00
PlaylistItemId = currentItem . ID ,
2021-10-22 19:14:04 +08:00
Password = password ,
QueueMode = apiRoom . QueueMode . Value
2021-03-01 16:24:32 +08:00
} ,
2021-04-22 22:22:44 +08:00
Users = { localUser } ,
Host = localUser
2021-03-01 16:24:32 +08:00
} ;
2020-12-19 00:14:50 +08:00
2021-04-22 22:22:44 +08:00
RoomSetupAction ? . Invoke ( room ) ;
RoomSetupAction = null ;
2021-11-13 00:42:51 +08:00
return room ;
2020-12-19 00:14:50 +08:00
}
2021-08-03 16:08:19 +08:00
protected override void OnRoomJoined ( )
{
Debug . Assert ( Room ! = null ) ;
// emulate the server sending this after the join room. scheduler required to make sure the join room event is fired first (in Join).
changeMatchType ( Room . Settings . MatchType ) . Wait ( ) ;
}
2021-07-19 19:19:23 +08:00
protected override Task LeaveRoomInternal ( ) = > Task . CompletedTask ;
2021-01-25 19:41:51 +08:00
2020-12-19 00:14:50 +08:00
public override Task TransferHost ( int userId ) = > ( ( IMultiplayerClient ) this ) . HostChanged ( userId ) ;
2021-08-11 16:20:41 +08:00
public override Task KickUser ( int userId )
{
Debug . Assert ( Room ! = null ) ;
2021-08-14 13:08:29 +08:00
return ( ( IMultiplayerClient ) this ) . UserKicked ( Room . Users . Single ( u = > u . UserID = = userId ) ) ;
2021-08-11 16:20:41 +08:00
}
2020-12-19 00:14:50 +08:00
public override async Task ChangeSettings ( MultiplayerRoomSettings settings )
{
Debug . Assert ( Room ! = null ) ;
2021-10-22 21:07:41 +08:00
Debug . Assert ( APIRoom ! = null ) ;
2021-11-13 00:42:51 +08:00
Debug . Assert ( currentItem ! = null ) ;
2021-10-22 21:07:41 +08:00
2021-11-11 22:39:15 +08:00
// Server is authoritative for the time being.
settings . PlaylistItemId = Room . Settings . PlaylistItemId ;
2020-12-19 00:14:50 +08:00
2021-11-13 00:42:51 +08:00
await changeQueueMode ( settings . QueueMode ) . ConfigureAwait ( false ) ;
await ( ( IMultiplayerClient ) this ) . SettingsChanged ( settings ) . ConfigureAwait ( false ) ;
foreach ( var user in Room . Users . Where ( u = > u . State = = MultiplayerUserState . Ready ) )
ChangeUserState ( user . UserID , MultiplayerUserState . Idle ) ;
await changeMatchType ( settings . MatchType ) . ConfigureAwait ( false ) ;
2020-12-19 00:14:50 +08:00
}
public override Task ChangeState ( MultiplayerUserState newState )
{
ChangeUserState ( api . LocalUser . Value . Id , newState ) ;
return Task . CompletedTask ;
}
2021-01-03 09:32:50 +08:00
public override Task ChangeBeatmapAvailability ( BeatmapAvailability newBeatmapAvailability )
{
ChangeUserBeatmapAvailability ( api . LocalUser . Value . Id , newBeatmapAvailability ) ;
return Task . CompletedTask ;
}
2021-02-01 16:57:32 +08:00
public void ChangeUserMods ( int userId , IEnumerable < Mod > newMods )
= > ChangeUserMods ( userId , newMods . Select ( m = > new APIMod ( m ) ) . ToList ( ) ) ;
2021-02-01 16:54:56 +08:00
2021-02-01 16:57:32 +08:00
public void ChangeUserMods ( int userId , IEnumerable < APIMod > newMods )
2021-02-01 16:54:56 +08:00
{
Debug . Assert ( Room ! = null ) ;
2021-02-01 16:57:32 +08:00
( ( IMultiplayerClient ) this ) . UserModsChanged ( userId , newMods . ToList ( ) ) ;
2021-02-01 16:54:56 +08:00
}
2021-02-01 16:57:32 +08:00
public override Task ChangeUserMods ( IEnumerable < APIMod > newMods )
2021-02-01 16:54:56 +08:00
{
2021-02-01 16:57:32 +08:00
ChangeUserMods ( api . LocalUser . Value . Id , newMods ) ;
2021-02-01 16:54:56 +08:00
return Task . CompletedTask ;
}
2021-08-02 16:06:01 +08:00
public override async Task SendMatchRequest ( MatchUserRequest request )
{
Debug . Assert ( Room ! = null ) ;
Debug . Assert ( LocalUser ! = null ) ;
switch ( request )
{
case ChangeTeamRequest changeTeam :
TeamVersusRoomState roomState = ( TeamVersusRoomState ) Room . MatchState ! ;
TeamVersusUserState userState = ( TeamVersusUserState ) LocalUser . MatchState ! ;
var targetTeam = roomState . Teams . FirstOrDefault ( t = > t . ID = = changeTeam . TeamID ) ;
if ( targetTeam ! = null )
{
userState . TeamID = targetTeam . ID ;
await ( ( IMultiplayerClient ) this ) . MatchUserStateChanged ( LocalUser . UserID , userState ) . ConfigureAwait ( false ) ;
}
break ;
}
}
2021-07-21 18:13:56 +08:00
2020-12-20 17:24:13 +08:00
public override Task StartMatch ( )
2020-12-19 00:14:50 +08:00
{
Debug . Assert ( Room ! = null ) ;
2021-04-07 19:46:30 +08:00
ChangeRoomState ( MultiplayerRoomState . WaitingForLoad ) ;
2020-12-19 00:14:50 +08:00
foreach ( var user in Room . Users . Where ( u = > u . State = = MultiplayerUserState . Ready ) )
ChangeUserState ( user . UserID , MultiplayerUserState . WaitingForLoad ) ;
2020-12-20 17:24:13 +08:00
return ( ( IMultiplayerClient ) this ) . LoadRequested ( ) ;
2020-12-19 00:14:50 +08:00
}
2021-03-01 16:24:54 +08:00
2021-10-22 15:48:28 +08:00
public override async Task AddPlaylistItem ( APIPlaylistItem item )
{
Debug . Assert ( Room ! = null ) ;
Debug . Assert ( APIRoom ! = null ) ;
2021-11-13 00:42:51 +08:00
Debug . Assert ( currentItem ! = null ) ;
if ( Room . Settings . QueueMode = = QueueModes . HostOnly & & Room . Host ? . UserID ! = LocalUser ? . UserID )
throw new InvalidOperationException ( "Local user is not the room host." ) ;
2021-10-22 15:48:28 +08:00
2021-11-13 00:42:51 +08:00
switch ( Room . Settings . QueueMode )
2021-10-22 15:48:28 +08:00
{
2021-11-13 00:42:51 +08:00
case QueueModes . HostOnly :
// In host-only mode, the current item is re-used.
item . ID = currentItem . ID ;
await ( ( IMultiplayerClient ) this ) . PlaylistItemChanged ( item ) . ConfigureAwait ( false ) ;
// Note: Unlike the server, this is the easiest way to update the current item at this point.
await updateCurrentItem ( false ) . ConfigureAwait ( false ) ;
break ;
2021-10-22 15:48:28 +08:00
2021-11-13 00:42:51 +08:00
default :
item . ID = APIRoom . Playlist . Last ( ) . ID + 1 ;
await ( ( IMultiplayerClient ) this ) . PlaylistItemAdded ( item ) . ConfigureAwait ( false ) ;
await updateCurrentItem ( ) . ConfigureAwait ( false ) ;
break ;
2021-10-22 15:48:28 +08:00
}
}
2021-11-10 18:58:25 +08:00
public override Task RemovePlaylistItem ( long playlistItemId )
2021-10-22 15:48:28 +08:00
{
Debug . Assert ( Room ! = null ) ;
if ( Room . Host ? . UserID ! = LocalUser ? . UserID )
throw new InvalidOperationException ( "Local user is not the room host." ) ;
2021-11-10 18:58:25 +08:00
return ( ( IMultiplayerClient ) this ) . PlaylistItemRemoved ( playlistItemId ) ;
2021-10-22 15:48:28 +08:00
}
2021-11-04 11:30:19 +08:00
protected override Task < APIBeatmapSet > GetOnlineBeatmapSet ( int beatmapId , CancellationToken cancellationToken = default )
2021-03-01 16:24:54 +08:00
{
Debug . Assert ( Room ! = null ) ;
2021-08-17 08:42:17 +08:00
var apiRoom = roomManager . ServerSideRooms . Single ( r = > r . RoomID . Value = = Room . RoomID ) ;
2021-11-04 11:30:19 +08:00
IBeatmapSetInfo ? set = apiRoom . Playlist . FirstOrDefault ( p = > p . BeatmapID = = beatmapId ) ? . Beatmap . Value . BeatmapSet
2021-11-12 16:45:05 +08:00
? ? beatmaps . QueryBeatmap ( b = > b . OnlineID = = beatmapId ) ? . BeatmapSet ;
2021-03-01 16:24:54 +08:00
if ( set = = null )
throw new InvalidOperationException ( "Beatmap not found." ) ;
2021-11-04 11:30:19 +08:00
var apiSet = new APIBeatmapSet
{
OnlineID = set . OnlineID ,
2021-11-04 15:44:05 +08:00
Beatmaps = set . Beatmaps . Select ( b = > new APIBeatmap { OnlineID = b . OnlineID } ) . ToArray ( ) ,
2021-11-04 11:30:19 +08:00
} ;
return Task . FromResult ( apiSet ) ;
2021-03-01 16:24:54 +08:00
}
2021-08-03 16:08:19 +08:00
private async Task changeMatchType ( MatchType type )
{
Debug . Assert ( Room ! = null ) ;
switch ( type )
{
case MatchType . HeadToHead :
await ( ( IMultiplayerClient ) this ) . MatchRoomStateChanged ( null ) . ConfigureAwait ( false ) ;
foreach ( var user in Room . Users )
await ( ( IMultiplayerClient ) this ) . MatchUserStateChanged ( user . UserID , null ) . ConfigureAwait ( false ) ;
break ;
case MatchType . TeamVersus :
await ( ( IMultiplayerClient ) this ) . MatchRoomStateChanged ( TeamVersusRoomState . CreateDefault ( ) ) . ConfigureAwait ( false ) ;
foreach ( var user in Room . Users )
await ( ( IMultiplayerClient ) this ) . MatchUserStateChanged ( user . UserID , new TeamVersusUserState ( ) ) . ConfigureAwait ( false ) ;
break ;
}
}
2021-10-22 21:07:41 +08:00
2021-11-13 00:42:51 +08:00
private async Task changeQueueMode ( QueueModes newMode )
2021-10-22 21:07:41 +08:00
{
Debug . Assert ( APIRoom ! = null ) ;
2021-11-13 00:42:51 +08:00
Debug . Assert ( currentItem ! = null ) ;
2021-10-22 21:07:41 +08:00
2021-11-13 00:42:51 +08:00
if ( newMode = = QueueModes . HostOnly )
{
foreach ( var playlistItem in APIRoom . Playlist . Where ( i = > ! i . Expired & & i . ID ! = currentItem . ID ) . ToArray ( ) )
await ( ( IMultiplayerClient ) this ) . PlaylistItemRemoved ( playlistItem . ID ) . ConfigureAwait ( false ) ;
}
2021-10-22 21:07:41 +08:00
2021-11-13 00:42:51 +08:00
// When changing modes, items could have been added (above) or the queueing order could have changed.
await updateCurrentItem ( ) . ConfigureAwait ( false ) ;
2021-10-22 21:07:41 +08:00
}
2021-11-13 00:42:51 +08:00
private async Task finishCurrentItem ( )
2021-10-22 21:07:41 +08:00
{
Debug . Assert ( Room ! = null ) ;
Debug . Assert ( APIRoom ! = null ) ;
2021-11-13 00:42:51 +08:00
Debug . Assert ( currentItem ! = null ) ;
2021-10-22 21:07:41 +08:00
// Expire the current playlist item.
await ( ( IMultiplayerClient ) this ) . PlaylistItemChanged ( new APIPlaylistItem ( currentItem ) { Expired = true } ) . ConfigureAwait ( false ) ;
// In host-only mode, a duplicate playlist item will be used for the next round.
if ( Room . Settings . QueueMode = = QueueModes . HostOnly )
2021-11-13 00:42:51 +08:00
await duplicateCurrentItem ( ) . ConfigureAwait ( false ) ;
await updateCurrentItem ( ) . ConfigureAwait ( false ) ;
2021-10-22 21:07:41 +08:00
}
2021-11-13 00:42:51 +08:00
private async Task duplicateCurrentItem ( )
2021-10-22 21:07:41 +08:00
{
Debug . Assert ( Room ! = null ) ;
Debug . Assert ( APIRoom ! = null ) ;
2021-11-13 00:42:51 +08:00
Debug . Assert ( currentItem ! = null ) ;
var newItem = new PlaylistItem
{
ID = APIRoom . Playlist . Last ( ) . ID + 1 ,
BeatmapID = currentItem . BeatmapID ,
RulesetID = currentItem . RulesetID ,
} ;
2021-10-22 21:07:41 +08:00
2021-11-13 00:42:51 +08:00
newItem . AllowedMods . AddRange ( currentItem . AllowedMods ) ;
newItem . RequiredMods . AddRange ( currentItem . AllowedMods ) ;
2021-10-22 21:07:41 +08:00
2021-11-13 00:42:51 +08:00
await ( ( IMultiplayerClient ) this ) . PlaylistItemAdded ( new APIPlaylistItem ( newItem ) ) . ConfigureAwait ( false ) ;
}
private async Task updateCurrentItem ( bool notify = true , Room ? apiRoom = null )
{
if ( apiRoom = = null )
{
Debug . Assert ( APIRoom ! = null ) ;
apiRoom = APIRoom ;
}
switch ( apiRoom . QueueMode . Value )
2021-10-22 21:07:41 +08:00
{
2021-11-10 20:27:20 +08:00
default :
2021-10-22 21:07:41 +08:00
// Pick the single non-expired playlist item.
2021-11-13 00:42:51 +08:00
currentItem = apiRoom . Playlist . FirstOrDefault ( i = > ! i . Expired ) ? ? apiRoom . Playlist . Last ( ) ;
2021-10-22 21:07:41 +08:00
break ;
case QueueModes . FairRotate :
// Group playlist items by (user_id -> count_expired), and select the first available playlist item from a user that has available beatmaps where count_expired is the lowest.
throw new NotImplementedException ( ) ;
}
2021-11-13 00:42:51 +08:00
if ( Room ! = null )
2021-10-22 21:07:41 +08:00
{
2021-11-13 00:42:51 +08:00
long lastItem = Room . Settings . PlaylistItemId ;
Room . Settings . PlaylistItemId = currentItem . ID ;
if ( notify & & currentItem . ID ! = lastItem )
await ( ( IMultiplayerClient ) this ) . SettingsChanged ( Room . Settings ) . ConfigureAwait ( false ) ;
2021-10-22 21:07:41 +08:00
}
}
2020-12-19 00:14:50 +08:00
}
}