From bf7c035bbf407c6dbb641a6826e539d224237309 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 24 Oct 2026 02:02:48 +0800 Subject: [PATCH 1/3] feat: serve versioned resource releases and complete session protocol Load a validated resource release with paired catalog, master manifest, and immutable startup snapshot, and serve master tables, catalog markers, and Android bundles from it with local mirrors, HEAD, ranges, and optional upstream caching and fallback. Build releases from a song package baseline with the resource-release command. Complete the session protocol: configurable account login with SHA-256 token digests and server list, HTTP/2 gRPC framing with status mapping and credential validation, feature switches, and the free-live flow that records best score, maximum combo, and clear state with player-data projection. Add the session companion RPCs for player sync, season pass, chat notifications, battle-live invitations, and maintenance checks. Replace checked-in master tables with locally produced release data that stays ignored, and cover everything with synthetic-data tests. --- .gitignore | 4 +- Config/ServerConfig.cs | 16 + Controllers/GrpcEndpointExtensions.cs | 56 +- Controllers/StaticEndpointExtensions.cs | 27 +- Middleware/RequestLoggingScopeMiddleware.cs | 10 +- Models/PlayerRecord.cs | 3 + .../App/Protobuf/Chat/ClientSyncMsgRequest.cs | 153 ++ .../Protobuf/Chat/ClientSyncMsgResponse.cs | 103 + .../Protobuf/Chat/GetNotificationsRequest.cs | 78 + .../Protobuf/Chat/GetNotificationsResponse.cs | 92 + .../App/Protobuf/Chat/NotificationInfo.cs | 292 +++ .../App/Protobuf/Chat/NotificationReward.cs | 153 ++ .../Protobuf/Entity/BattleLiveInvitation.cs | 180 ++ .../App/Protobuf/Entity/SeasonPass.cs | 203 ++ .../App/Protobuf/Entity/SeasonPassDetail.cs | 143 ++ .../App/Protobuf/Entity/SeasonPassMission.cs | 228 ++ .../Live/CheckMaintenanceFreeRequest.cs | 78 + .../Live/CheckMaintenanceFreeResponse.cs | 78 + .../Live/FetchBattleLiveInvitationRequest.cs | 78 + .../Live/FetchBattleLiveInvitationResponse.cs | 92 + .../Protobuf/Masterdata/VersionResponse.cs | 22 + .../App/Protobuf/PlayerExt/SyncDataRequest.cs | 78 + .../Protobuf/PlayerExt/SyncDataResponse.cs | 180 ++ .../App/Protobuf/SeasonPass/FetchRequest.cs | 78 + .../App/Protobuf/SeasonPass/FetchResponse.cs | 106 + MyNotes.csproj | 8 +- Program.cs | 147 +- Services/ClientStateProtocol.cs | 50 + Services/FeatureSwitchService.cs | 34 + Services/FreeLiveService.cs | 56 + Services/GrpcFraming.cs | 66 +- Services/HomeProtocolBuilder.cs | 1 + Services/MasterDataService.cs | 56 +- Services/MasterdataProtocolBuilder.cs | 3 +- Services/PlayerLoginService.cs | 108 + Services/PlayerManager.cs | 43 +- Services/PlayerProtocolBuilder.cs | 22 +- Services/ResourcePaths.cs | 31 + Services/ResourceReleaseBuilder.cs | 139 ++ Services/ResourceReleaseService.cs | 106 + Services/StaticAssetService.cs | 210 +- appsettings.example.json | 28 +- data/master/MasterLiveMusic.json | 2073 ----------------- data/master/MasterLiveMusicComboReward.json | 148 -- data/master/MasterLiveMusicScore.json | 928 -------- data/master/MasterMemberCard.json | 708 ------ data/master/MasterPlayerData.json | 1796 -------------- data/master/MasterSupportCard.json | 518 ---- tests/MyNotes.Tests/AssemblyInfo.cs | 3 + tests/MyNotes.Tests/ClientStateTests.cs | 80 + tests/MyNotes.Tests/FeatureSwitchTests.cs | 49 + tests/MyNotes.Tests/FreeLiveTests.cs | 132 ++ tests/MyNotes.Tests/GrpcTests.cs | 456 ++++ tests/MyNotes.Tests/IdentityTests.cs | 268 +++ tests/MyNotes.Tests/IntegrationTests.cs | 170 ++ tests/MyNotes.Tests/MasterDataTests.cs | 162 ++ tests/MyNotes.Tests/MyNotes.Tests.csproj | 19 + tests/MyNotes.Tests/PlayerLoginTests.cs | 209 ++ tests/MyNotes.Tests/ResourceBuilderTests.cs | 330 +++ tests/MyNotes.Tests/ResourceReleaseTests.cs | 463 ++++ tests/MyNotes.Tests/SessionStateTests.cs | 126 + tests/MyNotes.Tests/StaticAssetTests.cs | 586 +++++ 62 files changed, 6511 insertions(+), 6352 deletions(-) create mode 100644 Models/Protocol/App/Protobuf/Chat/ClientSyncMsgRequest.cs create mode 100644 Models/Protocol/App/Protobuf/Chat/ClientSyncMsgResponse.cs create mode 100644 Models/Protocol/App/Protobuf/Chat/GetNotificationsRequest.cs create mode 100644 Models/Protocol/App/Protobuf/Chat/GetNotificationsResponse.cs create mode 100644 Models/Protocol/App/Protobuf/Chat/NotificationInfo.cs create mode 100644 Models/Protocol/App/Protobuf/Chat/NotificationReward.cs create mode 100644 Models/Protocol/App/Protobuf/Entity/BattleLiveInvitation.cs create mode 100644 Models/Protocol/App/Protobuf/Entity/SeasonPass.cs create mode 100644 Models/Protocol/App/Protobuf/Entity/SeasonPassDetail.cs create mode 100644 Models/Protocol/App/Protobuf/Entity/SeasonPassMission.cs create mode 100644 Models/Protocol/App/Protobuf/Live/CheckMaintenanceFreeRequest.cs create mode 100644 Models/Protocol/App/Protobuf/Live/CheckMaintenanceFreeResponse.cs create mode 100644 Models/Protocol/App/Protobuf/Live/FetchBattleLiveInvitationRequest.cs create mode 100644 Models/Protocol/App/Protobuf/Live/FetchBattleLiveInvitationResponse.cs create mode 100644 Models/Protocol/App/Protobuf/PlayerExt/SyncDataRequest.cs create mode 100644 Models/Protocol/App/Protobuf/PlayerExt/SyncDataResponse.cs create mode 100644 Models/Protocol/App/Protobuf/SeasonPass/FetchRequest.cs create mode 100644 Models/Protocol/App/Protobuf/SeasonPass/FetchResponse.cs create mode 100644 Services/ClientStateProtocol.cs create mode 100644 Services/FeatureSwitchService.cs create mode 100644 Services/FreeLiveService.cs create mode 100644 Services/PlayerLoginService.cs create mode 100644 Services/ResourcePaths.cs create mode 100644 Services/ResourceReleaseBuilder.cs create mode 100644 Services/ResourceReleaseService.cs delete mode 100644 data/master/MasterLiveMusic.json delete mode 100644 data/master/MasterLiveMusicComboReward.json delete mode 100644 data/master/MasterLiveMusicScore.json delete mode 100644 data/master/MasterMemberCard.json delete mode 100644 data/master/MasterPlayerData.json delete mode 100644 data/master/MasterSupportCard.json create mode 100644 tests/MyNotes.Tests/AssemblyInfo.cs create mode 100644 tests/MyNotes.Tests/ClientStateTests.cs create mode 100644 tests/MyNotes.Tests/FeatureSwitchTests.cs create mode 100644 tests/MyNotes.Tests/FreeLiveTests.cs create mode 100644 tests/MyNotes.Tests/GrpcTests.cs create mode 100644 tests/MyNotes.Tests/IdentityTests.cs create mode 100644 tests/MyNotes.Tests/IntegrationTests.cs create mode 100644 tests/MyNotes.Tests/MasterDataTests.cs create mode 100644 tests/MyNotes.Tests/MyNotes.Tests.csproj create mode 100644 tests/MyNotes.Tests/PlayerLoginTests.cs create mode 100644 tests/MyNotes.Tests/ResourceBuilderTests.cs create mode 100644 tests/MyNotes.Tests/ResourceReleaseTests.cs create mode 100644 tests/MyNotes.Tests/SessionStateTests.cs create mode 100644 tests/MyNotes.Tests/StaticAssetTests.cs diff --git a/.gitignore b/.gitignore index 6d05b22..7c5657d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # Build output bin/ obj/ -data/static-cache/ +data/ +releases/ # Local IDE/editor state .vs/ @@ -21,5 +22,4 @@ appsettings.json appsettings.*.json !appsettings.example.json -docs/ wip.md diff --git a/Config/ServerConfig.cs b/Config/ServerConfig.cs index 3bd6f25..9cc4c76 100644 --- a/Config/ServerConfig.cs +++ b/Config/ServerConfig.cs @@ -4,6 +4,8 @@ public static class ServerConfig { public static int GrpcPort { get; private set; } = 9831; public static int HttpPort { get; private set; } = 9832; + public static string CertificatePath { get; private set; } = ""; + public static string CertificatePassword { get; private set; } = ""; public static string MasterVersion { get; private set; } = "9c23d88a191e4648f0a28f4bbd4edb6403d5adf99ac5793efd7cc1cb3d49104c"; public static string InitialDataGroup { get; private set; } = "cbt_0.1.8_"; public static IReadOnlyList InitialStampIds { get; private set; } = []; @@ -15,11 +17,17 @@ public static class ServerConfig public static int OfficialAssetRequestTimeoutSeconds { get; private set; } = 30; public static bool StaticAssetCacheEnabled { get; private set; } public static string StaticAssetCacheDirectory { get; private set; } = "data/static-cache"; + public static string StaticAssetLocalDirectory { get; private set; } = ""; + public static bool StaticAssetUpstreamEnabled { get; private set; } = true; + public static string ResourceReleaseManifest { get; private set; } = ""; + public static string ResourceVersion { get; private set; } = ""; public static void Load(IConfiguration configuration) { GrpcPort = configuration.GetValue("Server:GrpcPort", configuration.GetValue("Server:Port", GrpcPort)); HttpPort = configuration.GetValue("Server:HttpPort", HttpPort); + CertificatePath = configuration.GetValue("Server:CertificatePath", "") ?? ""; + CertificatePassword = configuration.GetValue("Server:CertificatePassword", "") ?? ""; MasterVersion = configuration.GetValue("Game:MasterVersion", MasterVersion) ?? MasterVersion; InitialDataGroup = configuration.GetValue("Game:InitialDataGroup", InitialDataGroup) ?? InitialDataGroup; InitialStampIds = configuration.GetSection("Game:InitialStampIds").Get()? @@ -41,6 +49,14 @@ public static void Load(IConfiguration configuration) StaticAssetCacheDirectory = configuration.GetValue( "Static:CacheDirectory", StaticAssetCacheDirectory) ?? StaticAssetCacheDirectory; + StaticAssetLocalDirectory = configuration.GetValue("Static:LocalDirectory", "") ?? ""; + StaticAssetUpstreamEnabled = configuration.GetValue("Static:UpstreamEnabled", true); + ResourceReleaseManifest = configuration.GetValue("Resources:ReleaseManifest", "") ?? ""; + ResourceVersion = configuration.GetValue("Resources:ResourceVersion", "") ?? ""; + if (OfficialAssetRequestTimeoutSeconds <= 0) + throw new InvalidOperationException("Static:OfficialAssetRequestTimeoutSeconds must be positive"); + if (RequireBasicAuth && !HasBasicAuth) + throw new InvalidOperationException("Basic authentication requires a configured user and password"); } public static bool HasBasicAuth => diff --git a/Controllers/GrpcEndpointExtensions.cs b/Controllers/GrpcEndpointExtensions.cs index fe78c61..78098a6 100644 --- a/Controllers/GrpcEndpointExtensions.cs +++ b/Controllers/GrpcEndpointExtensions.cs @@ -34,21 +34,32 @@ public static IEndpointRouteBuilder MapGrpcUnary( { endpoints.MapPost(pattern, async context => { - if (!IsAuthorized(context)) + try { - await GrpcFraming.WriteErrorAsync(context, "16", "unauthenticated"); - return; - } + if (context.Request.Protocol != "HTTP/2") + { + await GrpcFraming.WriteErrorAsync(context, "12", "grpc requires HTTP/2"); + return; + } + if (!IsAuthorized(context)) + { + await GrpcFraming.WriteErrorAsync(context, "16", "unauthenticated"); + return; + } - var frame = await GrpcFraming.ReadRequestAsync(context.Request, context.RequestAborted); - if (frame.Compressed) - { - await GrpcFraming.WriteErrorAsync(context, "12", "compressed grpc messages are not supported"); - return; - } + if (!GrpcFraming.IsGrpcRequest(context.Request)) + { + await GrpcFraming.WriteErrorAsync(context, "3", "grpc content type is required"); + return; + } + + var frame = await GrpcFraming.ReadRequestAsync(context.Request, context.RequestAborted); + if (frame.Compressed) + { + await GrpcFraming.WriteErrorAsync(context, "12", "compressed grpc messages are not supported"); + return; + } - try - { var response = await handler(context, frame.Payload); await GrpcFraming.WriteResponseAsync(context, response); } @@ -56,6 +67,22 @@ public static IEndpointRouteBuilder MapGrpcUnary( { await GrpcFraming.WriteErrorAsync(context, exception.Status, exception.Message); } + catch (GrpcFrameException exception) + { + await GrpcFraming.WriteErrorAsync(context, exception.Status, exception.Message); + } + catch (InvalidProtocolBufferException) + { + await GrpcFraming.WriteErrorAsync(context, "3", "invalid protobuf message"); + } + catch (BadHttpRequestException exception) when (exception.StatusCode is 400 or 413) + { + await GrpcFraming.WriteErrorAsync(context, exception.StatusCode == 413 ? "8" : "3", "invalid grpc request body"); + } + catch (UnauthorizedAccessException) + { + await GrpcFraming.WriteErrorAsync(context, "16", "unauthenticated"); + } }); return endpoints; @@ -65,7 +92,10 @@ private static bool IsAuthorized(HttpContext context) { var request = context.Request; if (!request.Headers.TryGetValue("authorization", out var value)) - return true; + return !ServerConfig.RequireBasicAuth; + + if (value.Count != 1) + return false; var authorization = value.ToString(); if (ServerConfig.IsExpectedBasicAuth(authorization)) diff --git a/Controllers/StaticEndpointExtensions.cs b/Controllers/StaticEndpointExtensions.cs index a0a8467..e3f62f5 100644 --- a/Controllers/StaticEndpointExtensions.cs +++ b/Controllers/StaticEndpointExtensions.cs @@ -11,6 +11,11 @@ public static IEndpointRouteBuilder MapStaticAssetEndpoints(this IEndpointRouteB MapAndroidAssetProxy(endpoints, "/asset/Android/{**assetPath}"); MapAndroidAssetProxy(endpoints, "/Android/{**assetPath}"); + endpoints.MapMethods("/master/{version}/{fileName}", [HttpMethods.Get, HttpMethods.Head], + (string version, string fileName, ResourceReleaseService release) => + release.FindMaster(version, fileName) is { } path + ? Results.File(path, "application/octet-stream", enableRangeProcessing: true) + : Results.NotFound()); return endpoints; } @@ -23,10 +28,22 @@ private static void MapAndroidAssetProxy(IEndpointRouteBuilder endpoints, string endpoints.MapMethods( pattern, [HttpMethods.Get, HttpMethods.Head], - (HttpContext context, StaticAssetService assets, string assetPath) => - assets.ProxyAndroidAssetAsync( - context, - assetPath, - HttpMethods.IsHead(context.Request.Method))); + async (HttpContext context, StaticAssetService assets, ResourceReleaseService release, string assetPath) => + { + if (release.FindAndroid(assetPath) is { } path) + await Results.File(path, "application/octet-stream", enableRangeProcessing: true).ExecuteAsync(context); + else if (release.Enabled && IsCatalogMarker(assetPath)) + context.Response.StatusCode = StatusCodes.Status404NotFound; + else + await assets.ProxyAndroidAssetAsync(context, assetPath, HttpMethods.IsHead(context.Request.Method)); + }); + } + + private static bool IsCatalogMarker(string assetPath) + { + var name = assetPath.Split('/').Last(); + return name.StartsWith("catalog", StringComparison.OrdinalIgnoreCase) && + (name.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) || + name.EndsWith(".hash", StringComparison.OrdinalIgnoreCase) || !name.Contains('.')); } } diff --git a/Middleware/RequestLoggingScopeMiddleware.cs b/Middleware/RequestLoggingScopeMiddleware.cs index 9092cd7..dcb6d13 100644 --- a/Middleware/RequestLoggingScopeMiddleware.cs +++ b/Middleware/RequestLoggingScopeMiddleware.cs @@ -5,7 +5,15 @@ public sealed class RequestLoggingScopeMiddleware(RequestDelegate next, ILogger< public async Task Invoke(HttpContext context) { if (context.Request.Headers.TryGetValue("authorization", out var authHeader)) - logger.LogDebug("authorization header present: {Prefix}", authHeader.ToString().Split(' ')[0]); + { + var authorization = authHeader.ToString(); + var scheme = authorization.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase) + ? "Basic" + : authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? "Bearer" + : "Other"; + logger.LogDebug("authorization header present: {Scheme}", scheme); + } if (context.Request.Headers.TryGetValue("user-agent", out var userAgent)) logger.LogDebug("user-agent: {UserAgent}", userAgent.ToString()); diff --git a/Models/PlayerRecord.cs b/Models/PlayerRecord.cs index 6f86051..56f88a9 100644 --- a/Models/PlayerRecord.cs +++ b/Models/PlayerRecord.cs @@ -15,6 +15,9 @@ public sealed class PlayerRecord public string InitialDataGroup { get; init; } = ""; public long FavoriteMemberCardId { get; set; } public byte[] LiveSettingAll { get; set; } = []; + internal object LiveStateLock { get; } = new(); + internal (long MusicId, int Difficulty)? ActiveFreeLive { get; set; } + internal Dictionary<(long MusicId, int Difficulty), LiveScore> LiveScores { get; } = []; internal object DeckStateLock { get; } = new(); internal Dictionary DeckOverrides { get; } = []; internal int MainDeckOverride { get; set; } diff --git a/Models/Protocol/App/Protobuf/Chat/ClientSyncMsgRequest.cs b/Models/Protocol/App/Protobuf/Chat/ClientSyncMsgRequest.cs new file mode 100644 index 0000000..91e6b43 --- /dev/null +++ b/Models/Protocol/App/Protobuf/Chat/ClientSyncMsgRequest.cs @@ -0,0 +1,153 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Chat; + +public sealed partial class ClientSyncMsgRequest : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Chat.ClientSyncMsgRequest()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public ClientSyncMsgRequest() + { + } + + public ClientSyncMsgRequest(ClientSyncMsgRequest other) : this() + { + type_ = other.type_; + lastMsgId_ = other.lastMsgId_; + receiver_ = other.receiver_; + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public ClientSyncMsgRequest Clone() => new(this); + public const int TypeFieldNumber = 1; + private int type_ = 0; + public int Type + { + get => type_; + set => type_ = value; + } + + public const int LastMsgIdFieldNumber = 2; + private long lastMsgId_ = 0; + public long LastMsgId + { + get => lastMsgId_; + set => lastMsgId_ = value; + } + + public const int ReceiverFieldNumber = 3; + private long receiver_ = 0; + public long Receiver + { + get => receiver_; + set => receiver_ = value; + } + + public override bool Equals(object? other) => Equals(other as ClientSyncMsgRequest); + + public bool Equals(ClientSyncMsgRequest? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!Equals(Type, other.Type)) return false; + if (!Equals(LastMsgId, other.LastMsgId)) return false; + if (!Equals(Receiver, other.Receiver)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (Type != 0) hash ^= Type.GetHashCode(); + if (LastMsgId != 0) hash ^= LastMsgId.GetHashCode(); + if (Receiver != 0) hash ^= Receiver.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + if (Type != 0) + { + output.WriteRawTag(0x08); + output.WriteInt32(Type); + } + if (LastMsgId != 0) + { + output.WriteRawTag(0x10); + output.WriteInt64(LastMsgId); + } + if (Receiver != 0) + { + output.WriteRawTag(0x18); + output.WriteInt64(Receiver); + } + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (Type != 0) + { + size += CodedOutputStream.ComputeTagSize(1); + size += CodedOutputStream.ComputeInt32Size(Type); + } + if (LastMsgId != 0) + { + size += CodedOutputStream.ComputeTagSize(2); + size += CodedOutputStream.ComputeInt64Size(LastMsgId); + } + if (Receiver != 0) + { + size += CodedOutputStream.ComputeTagSize(3); + size += CodedOutputStream.ComputeInt64Size(Receiver); + } + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(ClientSyncMsgRequest? other) + { + if (other == null) return; + if (other.Type != 0) Type = other.Type; + if (other.LastMsgId != 0) LastMsgId = other.LastMsgId; + if (other.Receiver != 0) Receiver = other.Receiver; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 8u: + Type = input.ReadInt32(); + break; + case 16u: + LastMsgId = input.ReadInt64(); + break; + case 24u: + Receiver = input.ReadInt64(); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Chat/ClientSyncMsgResponse.cs b/Models/Protocol/App/Protobuf/Chat/ClientSyncMsgResponse.cs new file mode 100644 index 0000000..1eaafb5 --- /dev/null +++ b/Models/Protocol/App/Protobuf/Chat/ClientSyncMsgResponse.cs @@ -0,0 +1,103 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Chat; + +public sealed partial class ClientSyncMsgResponse : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Chat.ClientSyncMsgResponse()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public ClientSyncMsgResponse() + { + } + + public ClientSyncMsgResponse(ClientSyncMsgResponse other) : this() + { + ret_ = other.ret_; + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public ClientSyncMsgResponse Clone() => new(this); + public const int RetFieldNumber = 1; + private int ret_ = 0; + public int Ret + { + get => ret_; + set => ret_ = value; + } + + public override bool Equals(object? other) => Equals(other as ClientSyncMsgResponse); + + public bool Equals(ClientSyncMsgResponse? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!Equals(Ret, other.Ret)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (Ret != 0) hash ^= Ret.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + if (Ret != 0) + { + output.WriteRawTag(0x08); + output.WriteInt32(Ret); + } + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (Ret != 0) + { + size += CodedOutputStream.ComputeTagSize(1); + size += CodedOutputStream.ComputeInt32Size(Ret); + } + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(ClientSyncMsgResponse? other) + { + if (other == null) return; + if (other.Ret != 0) Ret = other.Ret; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 8u: + Ret = input.ReadInt32(); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Chat/GetNotificationsRequest.cs b/Models/Protocol/App/Protobuf/Chat/GetNotificationsRequest.cs new file mode 100644 index 0000000..f00632c --- /dev/null +++ b/Models/Protocol/App/Protobuf/Chat/GetNotificationsRequest.cs @@ -0,0 +1,78 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Chat; + +public sealed partial class GetNotificationsRequest : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Chat.GetNotificationsRequest()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public GetNotificationsRequest() + { + } + + public GetNotificationsRequest(GetNotificationsRequest other) : this() + { + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public GetNotificationsRequest Clone() => new(this); + public override bool Equals(object? other) => Equals(other as GetNotificationsRequest); + + public bool Equals(GetNotificationsRequest? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(GetNotificationsRequest? other) + { + if (other == null) return; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Chat/GetNotificationsResponse.cs b/Models/Protocol/App/Protobuf/Chat/GetNotificationsResponse.cs new file mode 100644 index 0000000..38a1b5b --- /dev/null +++ b/Models/Protocol/App/Protobuf/Chat/GetNotificationsResponse.cs @@ -0,0 +1,92 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Chat; + +public sealed partial class GetNotificationsResponse : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Chat.GetNotificationsResponse()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public GetNotificationsResponse() + { + } + + public GetNotificationsResponse(GetNotificationsResponse other) : this() + { + notifications_ = other.notifications_.Clone(); + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public GetNotificationsResponse Clone() => new(this); + public const int NotificationsFieldNumber = 1; + private static readonly FieldCodec _repeated_notifications__codec = FieldCodec.ForMessage(10u, global::App.Protobuf.Chat.NotificationInfo.Parser); + private readonly RepeatedField notifications_ = new(); + public RepeatedField Notifications => notifications_; + + public override bool Equals(object? other) => Equals(other as GetNotificationsResponse); + + public bool Equals(GetNotificationsResponse? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!notifications_.Equals(other.notifications_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + hash ^= notifications_.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + notifications_.WriteTo(output, _repeated_notifications__codec); + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + size += notifications_.CalculateSize(_repeated_notifications__codec); + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(GetNotificationsResponse? other) + { + if (other == null) return; + notifications_.Add(other.notifications_); + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 10u: + notifications_.AddEntriesFrom(input, _repeated_notifications__codec); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Chat/NotificationInfo.cs b/Models/Protocol/App/Protobuf/Chat/NotificationInfo.cs new file mode 100644 index 0000000..28662a4 --- /dev/null +++ b/Models/Protocol/App/Protobuf/Chat/NotificationInfo.cs @@ -0,0 +1,292 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Chat; + +public sealed partial class NotificationInfo : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Chat.NotificationInfo()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public NotificationInfo() + { + } + + public NotificationInfo(NotificationInfo other) : this() + { + id_ = other.id_; + notificationType_ = other.notificationType_; + title_ = other.title_; + content_ = other.content_; + expireAt_ = other.expireAt_; + hasRewards_ = other.hasRewards_; + claimed_ = other.claimed_; + rewards_ = other.rewards_.Clone(); + createdAt_ = other.createdAt_; + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public NotificationInfo Clone() => new(this); + public const int IdFieldNumber = 1; + private long id_ = 0; + public long Id + { + get => id_; + set => id_ = value; + } + + public const int NotificationTypeFieldNumber = 2; + private int notificationType_ = 0; + public int NotificationType + { + get => notificationType_; + set => notificationType_ = value; + } + + public const int TitleFieldNumber = 3; + private string title_ = ""; + public string Title + { + get => title_; + set => title_ = value; + } + + public const int ContentFieldNumber = 4; + private string content_ = ""; + public string Content + { + get => content_; + set => content_ = value; + } + + public const int ExpireAtFieldNumber = 5; + private long expireAt_ = 0; + public long ExpireAt + { + get => expireAt_; + set => expireAt_ = value; + } + + public const int HasRewardsFieldNumber = 6; + private bool hasRewards_ = false; + public bool HasRewards + { + get => hasRewards_; + set => hasRewards_ = value; + } + + public const int ClaimedFieldNumber = 7; + private bool claimed_ = false; + public bool Claimed + { + get => claimed_; + set => claimed_ = value; + } + + public const int RewardsFieldNumber = 8; + private static readonly FieldCodec _repeated_rewards__codec = FieldCodec.ForMessage(66u, global::App.Protobuf.Chat.NotificationReward.Parser); + private readonly RepeatedField rewards_ = new(); + public RepeatedField Rewards => rewards_; + + public const int CreatedAtFieldNumber = 9; + private long createdAt_ = 0; + public long CreatedAt + { + get => createdAt_; + set => createdAt_ = value; + } + + public override bool Equals(object? other) => Equals(other as NotificationInfo); + + public bool Equals(NotificationInfo? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!Equals(Id, other.Id)) return false; + if (!Equals(NotificationType, other.NotificationType)) return false; + if (!Equals(Title, other.Title)) return false; + if (!Equals(Content, other.Content)) return false; + if (!Equals(ExpireAt, other.ExpireAt)) return false; + if (!Equals(HasRewards, other.HasRewards)) return false; + if (!Equals(Claimed, other.Claimed)) return false; + if (!rewards_.Equals(other.rewards_)) return false; + if (!Equals(CreatedAt, other.CreatedAt)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (Id != 0) hash ^= Id.GetHashCode(); + if (NotificationType != 0) hash ^= NotificationType.GetHashCode(); + if (Title.Length != 0) hash ^= Title.GetHashCode(); + if (Content.Length != 0) hash ^= Content.GetHashCode(); + if (ExpireAt != 0) hash ^= ExpireAt.GetHashCode(); + if (HasRewards != false) hash ^= HasRewards.GetHashCode(); + if (Claimed != false) hash ^= Claimed.GetHashCode(); + hash ^= rewards_.GetHashCode(); + if (CreatedAt != 0) hash ^= CreatedAt.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + if (Id != 0) + { + output.WriteRawTag(0x08); + output.WriteInt64(Id); + } + if (NotificationType != 0) + { + output.WriteRawTag(0x10); + output.WriteInt32(NotificationType); + } + if (Title.Length != 0) + { + output.WriteRawTag(0x18); + output.WriteString(Title); + } + if (Content.Length != 0) + { + output.WriteRawTag(0x20); + output.WriteString(Content); + } + if (ExpireAt != 0) + { + output.WriteRawTag(0x28); + output.WriteInt64(ExpireAt); + } + if (HasRewards != false) + { + output.WriteRawTag(0x30); + output.WriteBool(HasRewards); + } + if (Claimed != false) + { + output.WriteRawTag(0x38); + output.WriteBool(Claimed); + } + rewards_.WriteTo(output, _repeated_rewards__codec); + if (CreatedAt != 0) + { + output.WriteRawTag(0x48); + output.WriteInt64(CreatedAt); + } + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (Id != 0) + { + size += CodedOutputStream.ComputeTagSize(1); + size += CodedOutputStream.ComputeInt64Size(Id); + } + if (NotificationType != 0) + { + size += CodedOutputStream.ComputeTagSize(2); + size += CodedOutputStream.ComputeInt32Size(NotificationType); + } + if (Title.Length != 0) + { + size += CodedOutputStream.ComputeTagSize(3); + size += CodedOutputStream.ComputeStringSize(Title); + } + if (Content.Length != 0) + { + size += CodedOutputStream.ComputeTagSize(4); + size += CodedOutputStream.ComputeStringSize(Content); + } + if (ExpireAt != 0) + { + size += CodedOutputStream.ComputeTagSize(5); + size += CodedOutputStream.ComputeInt64Size(ExpireAt); + } + if (HasRewards != false) + { + size += CodedOutputStream.ComputeTagSize(6); + size += CodedOutputStream.ComputeBoolSize(HasRewards); + } + if (Claimed != false) + { + size += CodedOutputStream.ComputeTagSize(7); + size += CodedOutputStream.ComputeBoolSize(Claimed); + } + size += rewards_.CalculateSize(_repeated_rewards__codec); + if (CreatedAt != 0) + { + size += CodedOutputStream.ComputeTagSize(9); + size += CodedOutputStream.ComputeInt64Size(CreatedAt); + } + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(NotificationInfo? other) + { + if (other == null) return; + if (other.Id != 0) Id = other.Id; + if (other.NotificationType != 0) NotificationType = other.NotificationType; + if (other.Title.Length != 0) Title = other.Title; + if (other.Content.Length != 0) Content = other.Content; + if (other.ExpireAt != 0) ExpireAt = other.ExpireAt; + if (other.HasRewards != false) HasRewards = other.HasRewards; + if (other.Claimed != false) Claimed = other.Claimed; + rewards_.Add(other.rewards_); + if (other.CreatedAt != 0) CreatedAt = other.CreatedAt; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 8u: + Id = input.ReadInt64(); + break; + case 16u: + NotificationType = input.ReadInt32(); + break; + case 24u: + Title = input.ReadString(); + break; + case 32u: + Content = input.ReadString(); + break; + case 40u: + ExpireAt = input.ReadInt64(); + break; + case 48u: + HasRewards = input.ReadBool(); + break; + case 56u: + Claimed = input.ReadBool(); + break; + case 66u: + rewards_.AddEntriesFrom(input, _repeated_rewards__codec); + break; + case 72u: + CreatedAt = input.ReadInt64(); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Chat/NotificationReward.cs b/Models/Protocol/App/Protobuf/Chat/NotificationReward.cs new file mode 100644 index 0000000..7a79c98 --- /dev/null +++ b/Models/Protocol/App/Protobuf/Chat/NotificationReward.cs @@ -0,0 +1,153 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Chat; + +public sealed partial class NotificationReward : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Chat.NotificationReward()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public NotificationReward() + { + } + + public NotificationReward(NotificationReward other) : this() + { + resourceType_ = other.resourceType_; + resourceId_ = other.resourceId_; + resourceCount_ = other.resourceCount_; + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public NotificationReward Clone() => new(this); + public const int ResourceTypeFieldNumber = 1; + private int resourceType_ = 0; + public int ResourceType + { + get => resourceType_; + set => resourceType_ = value; + } + + public const int ResourceIdFieldNumber = 2; + private long resourceId_ = 0; + public long ResourceId + { + get => resourceId_; + set => resourceId_ = value; + } + + public const int ResourceCountFieldNumber = 3; + private int resourceCount_ = 0; + public int ResourceCount + { + get => resourceCount_; + set => resourceCount_ = value; + } + + public override bool Equals(object? other) => Equals(other as NotificationReward); + + public bool Equals(NotificationReward? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!Equals(ResourceType, other.ResourceType)) return false; + if (!Equals(ResourceId, other.ResourceId)) return false; + if (!Equals(ResourceCount, other.ResourceCount)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (ResourceType != 0) hash ^= ResourceType.GetHashCode(); + if (ResourceId != 0) hash ^= ResourceId.GetHashCode(); + if (ResourceCount != 0) hash ^= ResourceCount.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + if (ResourceType != 0) + { + output.WriteRawTag(0x08); + output.WriteInt32(ResourceType); + } + if (ResourceId != 0) + { + output.WriteRawTag(0x10); + output.WriteInt64(ResourceId); + } + if (ResourceCount != 0) + { + output.WriteRawTag(0x18); + output.WriteInt32(ResourceCount); + } + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (ResourceType != 0) + { + size += CodedOutputStream.ComputeTagSize(1); + size += CodedOutputStream.ComputeInt32Size(ResourceType); + } + if (ResourceId != 0) + { + size += CodedOutputStream.ComputeTagSize(2); + size += CodedOutputStream.ComputeInt64Size(ResourceId); + } + if (ResourceCount != 0) + { + size += CodedOutputStream.ComputeTagSize(3); + size += CodedOutputStream.ComputeInt32Size(ResourceCount); + } + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(NotificationReward? other) + { + if (other == null) return; + if (other.ResourceType != 0) ResourceType = other.ResourceType; + if (other.ResourceId != 0) ResourceId = other.ResourceId; + if (other.ResourceCount != 0) ResourceCount = other.ResourceCount; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 8u: + ResourceType = input.ReadInt32(); + break; + case 16u: + ResourceId = input.ReadInt64(); + break; + case 24u: + ResourceCount = input.ReadInt32(); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Entity/BattleLiveInvitation.cs b/Models/Protocol/App/Protobuf/Entity/BattleLiveInvitation.cs new file mode 100644 index 0000000..bc46f85 --- /dev/null +++ b/Models/Protocol/App/Protobuf/Entity/BattleLiveInvitation.cs @@ -0,0 +1,180 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Entity; + +public sealed partial class BattleLiveInvitation : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Entity.BattleLiveInvitation()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public BattleLiveInvitation() + { + } + + public BattleLiveInvitation(BattleLiveInvitation other) : this() + { + sender_ = other.sender_ != null ? other.sender_.Clone() : null; + lobbyId_ = other.lobbyId_; + sendAt_ = other.sendAt_; + host_ = other.host_ != null ? other.host_.Clone() : null; + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public BattleLiveInvitation Clone() => new(this); + public const int SenderFieldNumber = 1; + private global::App.Protobuf.Entity.PlayerSimpleProfile? sender_; + public global::App.Protobuf.Entity.PlayerSimpleProfile Sender + { + get => sender_ ??= new global::App.Protobuf.Entity.PlayerSimpleProfile(); + set => sender_ = value; + } + + public const int LobbyIdFieldNumber = 2; + private long lobbyId_ = 0; + public long LobbyId + { + get => lobbyId_; + set => lobbyId_ = value; + } + + public const int SendAtFieldNumber = 3; + private long sendAt_ = 0; + public long SendAt + { + get => sendAt_; + set => sendAt_ = value; + } + + public const int HostFieldNumber = 4; + private global::App.Protobuf.Entity.PlayerSimpleProfile? host_; + public global::App.Protobuf.Entity.PlayerSimpleProfile Host + { + get => host_ ??= new global::App.Protobuf.Entity.PlayerSimpleProfile(); + set => host_ = value; + } + + public override bool Equals(object? other) => Equals(other as BattleLiveInvitation); + + public bool Equals(BattleLiveInvitation? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!object.Equals(sender_, other.sender_)) return false; + if (!Equals(LobbyId, other.LobbyId)) return false; + if (!Equals(SendAt, other.SendAt)) return false; + if (!object.Equals(host_, other.host_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (sender_ != null) hash ^= sender_.GetHashCode(); + if (LobbyId != 0) hash ^= LobbyId.GetHashCode(); + if (SendAt != 0) hash ^= SendAt.GetHashCode(); + if (host_ != null) hash ^= host_.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + if (sender_ != null) { + output.WriteRawTag(0x0a); + output.WriteMessage(sender_); + } + if (LobbyId != 0) + { + output.WriteRawTag(0x10); + output.WriteInt64(LobbyId); + } + if (SendAt != 0) + { + output.WriteRawTag(0x18); + output.WriteInt64(SendAt); + } + if (host_ != null) { + output.WriteRawTag(0x22); + output.WriteMessage(host_); + } + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (sender_ != null) { + size += 1 + CodedOutputStream.ComputeMessageSize(sender_); + } + if (LobbyId != 0) + { + size += CodedOutputStream.ComputeTagSize(2); + size += CodedOutputStream.ComputeInt64Size(LobbyId); + } + if (SendAt != 0) + { + size += CodedOutputStream.ComputeTagSize(3); + size += CodedOutputStream.ComputeInt64Size(SendAt); + } + if (host_ != null) { + size += 1 + CodedOutputStream.ComputeMessageSize(host_); + } + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(BattleLiveInvitation? other) + { + if (other == null) return; + if (other.sender_ != null) { + if (sender_ == null) sender_ = new global::App.Protobuf.Entity.PlayerSimpleProfile(); + sender_.MergeFrom(other.sender_); + } + if (other.LobbyId != 0) LobbyId = other.LobbyId; + if (other.SendAt != 0) SendAt = other.SendAt; + if (other.host_ != null) { + if (host_ == null) host_ = new global::App.Protobuf.Entity.PlayerSimpleProfile(); + host_.MergeFrom(other.host_); + } + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 10u: + if (sender_ == null) sender_ = new global::App.Protobuf.Entity.PlayerSimpleProfile(); + input.ReadMessage(sender_); + break; + case 16u: + LobbyId = input.ReadInt64(); + break; + case 24u: + SendAt = input.ReadInt64(); + break; + case 34u: + if (host_ == null) host_ = new global::App.Protobuf.Entity.PlayerSimpleProfile(); + input.ReadMessage(host_); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Entity/SeasonPass.cs b/Models/Protocol/App/Protobuf/Entity/SeasonPass.cs new file mode 100644 index 0000000..008bed2 --- /dev/null +++ b/Models/Protocol/App/Protobuf/Entity/SeasonPass.cs @@ -0,0 +1,203 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Entity; + +public sealed partial class SeasonPass : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Entity.SeasonPass()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public SeasonPass() + { + } + + public SeasonPass(SeasonPass other) : this() + { + seasonPassId_ = other.seasonPassId_; + point_ = other.point_; + isPremium_ = other.isPremium_; + lastReceivedLevel_ = other.lastReceivedLevel_; + premiumLastReceivedLevel_ = other.premiumLastReceivedLevel_; + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public SeasonPass Clone() => new(this); + public const int SeasonPassIdFieldNumber = 1; + private long seasonPassId_ = 0; + public long SeasonPassId + { + get => seasonPassId_; + set => seasonPassId_ = value; + } + + public const int PointFieldNumber = 2; + private int point_ = 0; + public int Point + { + get => point_; + set => point_ = value; + } + + public const int IsPremiumFieldNumber = 3; + private bool isPremium_ = false; + public bool IsPremium + { + get => isPremium_; + set => isPremium_ = value; + } + + public const int LastReceivedLevelFieldNumber = 4; + private int lastReceivedLevel_ = 0; + public int LastReceivedLevel + { + get => lastReceivedLevel_; + set => lastReceivedLevel_ = value; + } + + public const int PremiumLastReceivedLevelFieldNumber = 5; + private int premiumLastReceivedLevel_ = 0; + public int PremiumLastReceivedLevel + { + get => premiumLastReceivedLevel_; + set => premiumLastReceivedLevel_ = value; + } + + public override bool Equals(object? other) => Equals(other as SeasonPass); + + public bool Equals(SeasonPass? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!Equals(SeasonPassId, other.SeasonPassId)) return false; + if (!Equals(Point, other.Point)) return false; + if (!Equals(IsPremium, other.IsPremium)) return false; + if (!Equals(LastReceivedLevel, other.LastReceivedLevel)) return false; + if (!Equals(PremiumLastReceivedLevel, other.PremiumLastReceivedLevel)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (SeasonPassId != 0) hash ^= SeasonPassId.GetHashCode(); + if (Point != 0) hash ^= Point.GetHashCode(); + if (IsPremium != false) hash ^= IsPremium.GetHashCode(); + if (LastReceivedLevel != 0) hash ^= LastReceivedLevel.GetHashCode(); + if (PremiumLastReceivedLevel != 0) hash ^= PremiumLastReceivedLevel.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + if (SeasonPassId != 0) + { + output.WriteRawTag(0x08); + output.WriteInt64(SeasonPassId); + } + if (Point != 0) + { + output.WriteRawTag(0x10); + output.WriteInt32(Point); + } + if (IsPremium != false) + { + output.WriteRawTag(0x18); + output.WriteBool(IsPremium); + } + if (LastReceivedLevel != 0) + { + output.WriteRawTag(0x20); + output.WriteInt32(LastReceivedLevel); + } + if (PremiumLastReceivedLevel != 0) + { + output.WriteRawTag(0x28); + output.WriteInt32(PremiumLastReceivedLevel); + } + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (SeasonPassId != 0) + { + size += CodedOutputStream.ComputeTagSize(1); + size += CodedOutputStream.ComputeInt64Size(SeasonPassId); + } + if (Point != 0) + { + size += CodedOutputStream.ComputeTagSize(2); + size += CodedOutputStream.ComputeInt32Size(Point); + } + if (IsPremium != false) + { + size += CodedOutputStream.ComputeTagSize(3); + size += CodedOutputStream.ComputeBoolSize(IsPremium); + } + if (LastReceivedLevel != 0) + { + size += CodedOutputStream.ComputeTagSize(4); + size += CodedOutputStream.ComputeInt32Size(LastReceivedLevel); + } + if (PremiumLastReceivedLevel != 0) + { + size += CodedOutputStream.ComputeTagSize(5); + size += CodedOutputStream.ComputeInt32Size(PremiumLastReceivedLevel); + } + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(SeasonPass? other) + { + if (other == null) return; + if (other.SeasonPassId != 0) SeasonPassId = other.SeasonPassId; + if (other.Point != 0) Point = other.Point; + if (other.IsPremium != false) IsPremium = other.IsPremium; + if (other.LastReceivedLevel != 0) LastReceivedLevel = other.LastReceivedLevel; + if (other.PremiumLastReceivedLevel != 0) PremiumLastReceivedLevel = other.PremiumLastReceivedLevel; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 8u: + SeasonPassId = input.ReadInt64(); + break; + case 16u: + Point = input.ReadInt32(); + break; + case 24u: + IsPremium = input.ReadBool(); + break; + case 32u: + LastReceivedLevel = input.ReadInt32(); + break; + case 40u: + PremiumLastReceivedLevel = input.ReadInt32(); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Entity/SeasonPassDetail.cs b/Models/Protocol/App/Protobuf/Entity/SeasonPassDetail.cs new file mode 100644 index 0000000..4bbdc81 --- /dev/null +++ b/Models/Protocol/App/Protobuf/Entity/SeasonPassDetail.cs @@ -0,0 +1,143 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Entity; + +public sealed partial class SeasonPassDetail : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Entity.SeasonPassDetail()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public SeasonPassDetail() + { + } + + public SeasonPassDetail(SeasonPassDetail other) : this() + { + seasonPass_ = other.seasonPass_ != null ? other.seasonPass_.Clone() : null; + missions_ = other.missions_.Clone(); + autoGrantedPoint_ = other.autoGrantedPoint_; + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public SeasonPassDetail Clone() => new(this); + public const int SeasonPassFieldNumber = 1; + private global::App.Protobuf.Entity.SeasonPass? seasonPass_; + public global::App.Protobuf.Entity.SeasonPass SeasonPass + { + get => seasonPass_ ??= new global::App.Protobuf.Entity.SeasonPass(); + set => seasonPass_ = value; + } + + public const int MissionsFieldNumber = 2; + private static readonly FieldCodec _repeated_missions__codec = FieldCodec.ForMessage(18u, global::App.Protobuf.Entity.SeasonPassMission.Parser); + private readonly RepeatedField missions_ = new(); + public RepeatedField Missions => missions_; + + public const int AutoGrantedPointFieldNumber = 3; + private int autoGrantedPoint_ = 0; + public int AutoGrantedPoint + { + get => autoGrantedPoint_; + set => autoGrantedPoint_ = value; + } + + public override bool Equals(object? other) => Equals(other as SeasonPassDetail); + + public bool Equals(SeasonPassDetail? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!object.Equals(seasonPass_, other.seasonPass_)) return false; + if (!missions_.Equals(other.missions_)) return false; + if (!Equals(AutoGrantedPoint, other.AutoGrantedPoint)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (seasonPass_ != null) hash ^= seasonPass_.GetHashCode(); + hash ^= missions_.GetHashCode(); + if (AutoGrantedPoint != 0) hash ^= AutoGrantedPoint.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + if (seasonPass_ != null) { + output.WriteRawTag(0x0a); + output.WriteMessage(seasonPass_); + } + missions_.WriteTo(output, _repeated_missions__codec); + if (AutoGrantedPoint != 0) + { + output.WriteRawTag(0x18); + output.WriteInt32(AutoGrantedPoint); + } + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (seasonPass_ != null) { + size += 1 + CodedOutputStream.ComputeMessageSize(seasonPass_); + } + size += missions_.CalculateSize(_repeated_missions__codec); + if (AutoGrantedPoint != 0) + { + size += CodedOutputStream.ComputeTagSize(3); + size += CodedOutputStream.ComputeInt32Size(AutoGrantedPoint); + } + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(SeasonPassDetail? other) + { + if (other == null) return; + if (other.seasonPass_ != null) { + if (seasonPass_ == null) seasonPass_ = new global::App.Protobuf.Entity.SeasonPass(); + seasonPass_.MergeFrom(other.seasonPass_); + } + missions_.Add(other.missions_); + if (other.AutoGrantedPoint != 0) AutoGrantedPoint = other.AutoGrantedPoint; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 10u: + if (seasonPass_ == null) seasonPass_ = new global::App.Protobuf.Entity.SeasonPass(); + input.ReadMessage(seasonPass_); + break; + case 18u: + missions_.AddEntriesFrom(input, _repeated_missions__codec); + break; + case 24u: + AutoGrantedPoint = input.ReadInt32(); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Entity/SeasonPassMission.cs b/Models/Protocol/App/Protobuf/Entity/SeasonPassMission.cs new file mode 100644 index 0000000..736df20 --- /dev/null +++ b/Models/Protocol/App/Protobuf/Entity/SeasonPassMission.cs @@ -0,0 +1,228 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Entity; + +public sealed partial class SeasonPassMission : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Entity.SeasonPassMission()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public SeasonPassMission() + { + } + + public SeasonPassMission(SeasonPassMission other) : this() + { + seasonPassId_ = other.seasonPassId_; + seasonPassMissionId_ = other.seasonPassMissionId_; + startCount_ = other.startCount_; + isCompleted_ = other.isCompleted_; + isReceived_ = other.isReceived_; + startAt_ = other.startAt_; + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public SeasonPassMission Clone() => new(this); + public const int SeasonPassIdFieldNumber = 1; + private long seasonPassId_ = 0; + public long SeasonPassId + { + get => seasonPassId_; + set => seasonPassId_ = value; + } + + public const int SeasonPassMissionIdFieldNumber = 2; + private long seasonPassMissionId_ = 0; + public long SeasonPassMissionId + { + get => seasonPassMissionId_; + set => seasonPassMissionId_ = value; + } + + public const int StartCountFieldNumber = 3; + private long startCount_ = 0; + public long StartCount + { + get => startCount_; + set => startCount_ = value; + } + + public const int IsCompletedFieldNumber = 4; + private bool isCompleted_ = false; + public bool IsCompleted + { + get => isCompleted_; + set => isCompleted_ = value; + } + + public const int IsReceivedFieldNumber = 5; + private bool isReceived_ = false; + public bool IsReceived + { + get => isReceived_; + set => isReceived_ = value; + } + + public const int StartAtFieldNumber = 6; + private long startAt_ = 0; + public long StartAt + { + get => startAt_; + set => startAt_ = value; + } + + public override bool Equals(object? other) => Equals(other as SeasonPassMission); + + public bool Equals(SeasonPassMission? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!Equals(SeasonPassId, other.SeasonPassId)) return false; + if (!Equals(SeasonPassMissionId, other.SeasonPassMissionId)) return false; + if (!Equals(StartCount, other.StartCount)) return false; + if (!Equals(IsCompleted, other.IsCompleted)) return false; + if (!Equals(IsReceived, other.IsReceived)) return false; + if (!Equals(StartAt, other.StartAt)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (SeasonPassId != 0) hash ^= SeasonPassId.GetHashCode(); + if (SeasonPassMissionId != 0) hash ^= SeasonPassMissionId.GetHashCode(); + if (StartCount != 0) hash ^= StartCount.GetHashCode(); + if (IsCompleted != false) hash ^= IsCompleted.GetHashCode(); + if (IsReceived != false) hash ^= IsReceived.GetHashCode(); + if (StartAt != 0) hash ^= StartAt.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + if (SeasonPassId != 0) + { + output.WriteRawTag(0x08); + output.WriteInt64(SeasonPassId); + } + if (SeasonPassMissionId != 0) + { + output.WriteRawTag(0x10); + output.WriteInt64(SeasonPassMissionId); + } + if (StartCount != 0) + { + output.WriteRawTag(0x18); + output.WriteInt64(StartCount); + } + if (IsCompleted != false) + { + output.WriteRawTag(0x20); + output.WriteBool(IsCompleted); + } + if (IsReceived != false) + { + output.WriteRawTag(0x28); + output.WriteBool(IsReceived); + } + if (StartAt != 0) + { + output.WriteRawTag(0x30); + output.WriteInt64(StartAt); + } + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (SeasonPassId != 0) + { + size += CodedOutputStream.ComputeTagSize(1); + size += CodedOutputStream.ComputeInt64Size(SeasonPassId); + } + if (SeasonPassMissionId != 0) + { + size += CodedOutputStream.ComputeTagSize(2); + size += CodedOutputStream.ComputeInt64Size(SeasonPassMissionId); + } + if (StartCount != 0) + { + size += CodedOutputStream.ComputeTagSize(3); + size += CodedOutputStream.ComputeInt64Size(StartCount); + } + if (IsCompleted != false) + { + size += CodedOutputStream.ComputeTagSize(4); + size += CodedOutputStream.ComputeBoolSize(IsCompleted); + } + if (IsReceived != false) + { + size += CodedOutputStream.ComputeTagSize(5); + size += CodedOutputStream.ComputeBoolSize(IsReceived); + } + if (StartAt != 0) + { + size += CodedOutputStream.ComputeTagSize(6); + size += CodedOutputStream.ComputeInt64Size(StartAt); + } + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(SeasonPassMission? other) + { + if (other == null) return; + if (other.SeasonPassId != 0) SeasonPassId = other.SeasonPassId; + if (other.SeasonPassMissionId != 0) SeasonPassMissionId = other.SeasonPassMissionId; + if (other.StartCount != 0) StartCount = other.StartCount; + if (other.IsCompleted != false) IsCompleted = other.IsCompleted; + if (other.IsReceived != false) IsReceived = other.IsReceived; + if (other.StartAt != 0) StartAt = other.StartAt; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 8u: + SeasonPassId = input.ReadInt64(); + break; + case 16u: + SeasonPassMissionId = input.ReadInt64(); + break; + case 24u: + StartCount = input.ReadInt64(); + break; + case 32u: + IsCompleted = input.ReadBool(); + break; + case 40u: + IsReceived = input.ReadBool(); + break; + case 48u: + StartAt = input.ReadInt64(); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Live/CheckMaintenanceFreeRequest.cs b/Models/Protocol/App/Protobuf/Live/CheckMaintenanceFreeRequest.cs new file mode 100644 index 0000000..fcc686d --- /dev/null +++ b/Models/Protocol/App/Protobuf/Live/CheckMaintenanceFreeRequest.cs @@ -0,0 +1,78 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Live; + +public sealed partial class CheckMaintenanceFreeRequest : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Live.CheckMaintenanceFreeRequest()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public CheckMaintenanceFreeRequest() + { + } + + public CheckMaintenanceFreeRequest(CheckMaintenanceFreeRequest other) : this() + { + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public CheckMaintenanceFreeRequest Clone() => new(this); + public override bool Equals(object? other) => Equals(other as CheckMaintenanceFreeRequest); + + public bool Equals(CheckMaintenanceFreeRequest? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(CheckMaintenanceFreeRequest? other) + { + if (other == null) return; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Live/CheckMaintenanceFreeResponse.cs b/Models/Protocol/App/Protobuf/Live/CheckMaintenanceFreeResponse.cs new file mode 100644 index 0000000..8473658 --- /dev/null +++ b/Models/Protocol/App/Protobuf/Live/CheckMaintenanceFreeResponse.cs @@ -0,0 +1,78 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Live; + +public sealed partial class CheckMaintenanceFreeResponse : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Live.CheckMaintenanceFreeResponse()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public CheckMaintenanceFreeResponse() + { + } + + public CheckMaintenanceFreeResponse(CheckMaintenanceFreeResponse other) : this() + { + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public CheckMaintenanceFreeResponse Clone() => new(this); + public override bool Equals(object? other) => Equals(other as CheckMaintenanceFreeResponse); + + public bool Equals(CheckMaintenanceFreeResponse? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(CheckMaintenanceFreeResponse? other) + { + if (other == null) return; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Live/FetchBattleLiveInvitationRequest.cs b/Models/Protocol/App/Protobuf/Live/FetchBattleLiveInvitationRequest.cs new file mode 100644 index 0000000..7556640 --- /dev/null +++ b/Models/Protocol/App/Protobuf/Live/FetchBattleLiveInvitationRequest.cs @@ -0,0 +1,78 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Live; + +public sealed partial class FetchBattleLiveInvitationRequest : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Live.FetchBattleLiveInvitationRequest()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public FetchBattleLiveInvitationRequest() + { + } + + public FetchBattleLiveInvitationRequest(FetchBattleLiveInvitationRequest other) : this() + { + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public FetchBattleLiveInvitationRequest Clone() => new(this); + public override bool Equals(object? other) => Equals(other as FetchBattleLiveInvitationRequest); + + public bool Equals(FetchBattleLiveInvitationRequest? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(FetchBattleLiveInvitationRequest? other) + { + if (other == null) return; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Live/FetchBattleLiveInvitationResponse.cs b/Models/Protocol/App/Protobuf/Live/FetchBattleLiveInvitationResponse.cs new file mode 100644 index 0000000..415320f --- /dev/null +++ b/Models/Protocol/App/Protobuf/Live/FetchBattleLiveInvitationResponse.cs @@ -0,0 +1,92 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.Live; + +public sealed partial class FetchBattleLiveInvitationResponse : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.Live.FetchBattleLiveInvitationResponse()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public FetchBattleLiveInvitationResponse() + { + } + + public FetchBattleLiveInvitationResponse(FetchBattleLiveInvitationResponse other) : this() + { + invitations_ = other.invitations_.Clone(); + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public FetchBattleLiveInvitationResponse Clone() => new(this); + public const int InvitationsFieldNumber = 1; + private static readonly FieldCodec _repeated_invitations__codec = FieldCodec.ForMessage(10u, global::App.Protobuf.Entity.BattleLiveInvitation.Parser); + private readonly RepeatedField invitations_ = new(); + public RepeatedField Invitations => invitations_; + + public override bool Equals(object? other) => Equals(other as FetchBattleLiveInvitationResponse); + + public bool Equals(FetchBattleLiveInvitationResponse? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!invitations_.Equals(other.invitations_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + hash ^= invitations_.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + invitations_.WriteTo(output, _repeated_invitations__codec); + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + size += invitations_.CalculateSize(_repeated_invitations__codec); + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(FetchBattleLiveInvitationResponse? other) + { + if (other == null) return; + invitations_.Add(other.invitations_); + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 10u: + invitations_.AddEntriesFrom(input, _repeated_invitations__codec); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/Masterdata/VersionResponse.cs b/Models/Protocol/App/Protobuf/Masterdata/VersionResponse.cs index fe418be..adc095b 100644 --- a/Models/Protocol/App/Protobuf/Masterdata/VersionResponse.cs +++ b/Models/Protocol/App/Protobuf/Masterdata/VersionResponse.cs @@ -23,6 +23,7 @@ public VersionResponse() public VersionResponse(VersionResponse other) : this() { version_ = other.version_; + resourceVersion_ = other.resourceVersion_; _unknownFields = UnknownFieldSet.Clone(other._unknownFields); } @@ -36,6 +37,14 @@ public string Version set => version_ = value ?? ""; } + public const int ResourceVersionFieldNumber = 2; + private string resourceVersion_ = ""; + public string ResourceVersion + { + get => resourceVersion_; + set => resourceVersion_ = value ?? ""; + } + public override bool Equals(object? other) => Equals(other as VersionResponse); public bool Equals(VersionResponse? other) @@ -43,6 +52,7 @@ public bool Equals(VersionResponse? other) if (ReferenceEquals(other, null)) return false; if (ReferenceEquals(other, this)) return true; if (!Equals(Version, other.Version)) return false; + if (!Equals(ResourceVersion, other.ResourceVersion)) return false; return Equals(_unknownFields, other._unknownFields); } @@ -50,6 +60,7 @@ public override int GetHashCode() { var hash = 1; if (Version.Length != 0) hash ^= Version.GetHashCode(); + if (ResourceVersion.Length != 0) hash ^= ResourceVersion.GetHashCode(); if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); return hash; } @@ -63,6 +74,11 @@ public void WriteTo(CodedOutputStream output) output.WriteRawTag(0x0A); output.WriteString(Version); } + if (ResourceVersion.Length != 0) + { + output.WriteRawTag(0x12); + output.WriteString(ResourceVersion); + } _unknownFields?.WriteTo(output); } @@ -74,6 +90,8 @@ public int CalculateSize() size += CodedOutputStream.ComputeTagSize(1); size += CodedOutputStream.ComputeStringSize(Version); } + if (ResourceVersion.Length != 0) + size += CodedOutputStream.ComputeTagSize(2) + CodedOutputStream.ComputeStringSize(ResourceVersion); if (_unknownFields != null) size += _unknownFields.CalculateSize(); return size; } @@ -82,6 +100,7 @@ public void MergeFrom(VersionResponse? other) { if (other == null) return; if (other.Version.Length != 0) Version = other.Version; + if (other.ResourceVersion.Length != 0) ResourceVersion = other.ResourceVersion; _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -95,6 +114,9 @@ public void MergeFrom(CodedInputStream input) case 10u: Version = input.ReadString(); break; + case 18u: + ResourceVersion = input.ReadString(); + break; default: _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; diff --git a/Models/Protocol/App/Protobuf/PlayerExt/SyncDataRequest.cs b/Models/Protocol/App/Protobuf/PlayerExt/SyncDataRequest.cs new file mode 100644 index 0000000..a3babaf --- /dev/null +++ b/Models/Protocol/App/Protobuf/PlayerExt/SyncDataRequest.cs @@ -0,0 +1,78 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.PlayerExt; + +public sealed partial class SyncDataRequest : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.PlayerExt.SyncDataRequest()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public SyncDataRequest() + { + } + + public SyncDataRequest(SyncDataRequest other) : this() + { + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public SyncDataRequest Clone() => new(this); + public override bool Equals(object? other) => Equals(other as SyncDataRequest); + + public bool Equals(SyncDataRequest? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(SyncDataRequest? other) + { + if (other == null) return; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/PlayerExt/SyncDataResponse.cs b/Models/Protocol/App/Protobuf/PlayerExt/SyncDataResponse.cs new file mode 100644 index 0000000..214fd24 --- /dev/null +++ b/Models/Protocol/App/Protobuf/PlayerExt/SyncDataResponse.cs @@ -0,0 +1,180 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.PlayerExt; + +public sealed partial class SyncDataResponse : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.PlayerExt.SyncDataResponse()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public SyncDataResponse() + { + } + + public SyncDataResponse(SyncDataResponse other) : this() + { + accountid_ = other.accountid_; + worldRoomId_ = other.worldRoomId_; + gem_ = other.gem_ != null ? other.gem_.Clone() : null; + liveBoost_ = other.liveBoost_ != null ? other.liveBoost_.Clone() : null; + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public SyncDataResponse Clone() => new(this); + public const int AccountidFieldNumber = 1; + private long accountid_ = 0; + public long Accountid + { + get => accountid_; + set => accountid_ = value; + } + + public const int WorldRoomIdFieldNumber = 2; + private long worldRoomId_ = 0; + public long WorldRoomId + { + get => worldRoomId_; + set => worldRoomId_ = value; + } + + public const int GemFieldNumber = 3; + private global::App.Protobuf.Entity.Gem? gem_; + public global::App.Protobuf.Entity.Gem Gem + { + get => gem_ ??= new global::App.Protobuf.Entity.Gem(); + set => gem_ = value; + } + + public const int LiveBoostFieldNumber = 4; + private global::App.Protobuf.Entity.LiveBoost? liveBoost_; + public global::App.Protobuf.Entity.LiveBoost LiveBoost + { + get => liveBoost_ ??= new global::App.Protobuf.Entity.LiveBoost(); + set => liveBoost_ = value; + } + + public override bool Equals(object? other) => Equals(other as SyncDataResponse); + + public bool Equals(SyncDataResponse? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!Equals(Accountid, other.Accountid)) return false; + if (!Equals(WorldRoomId, other.WorldRoomId)) return false; + if (!object.Equals(gem_, other.gem_)) return false; + if (!object.Equals(liveBoost_, other.liveBoost_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (Accountid != 0) hash ^= Accountid.GetHashCode(); + if (WorldRoomId != 0) hash ^= WorldRoomId.GetHashCode(); + if (gem_ != null) hash ^= gem_.GetHashCode(); + if (liveBoost_ != null) hash ^= liveBoost_.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + if (Accountid != 0) + { + output.WriteRawTag(0x08); + output.WriteInt64(Accountid); + } + if (WorldRoomId != 0) + { + output.WriteRawTag(0x10); + output.WriteInt64(WorldRoomId); + } + if (gem_ != null) { + output.WriteRawTag(0x1a); + output.WriteMessage(gem_); + } + if (liveBoost_ != null) { + output.WriteRawTag(0x22); + output.WriteMessage(liveBoost_); + } + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (Accountid != 0) + { + size += CodedOutputStream.ComputeTagSize(1); + size += CodedOutputStream.ComputeInt64Size(Accountid); + } + if (WorldRoomId != 0) + { + size += CodedOutputStream.ComputeTagSize(2); + size += CodedOutputStream.ComputeInt64Size(WorldRoomId); + } + if (gem_ != null) { + size += 1 + CodedOutputStream.ComputeMessageSize(gem_); + } + if (liveBoost_ != null) { + size += 1 + CodedOutputStream.ComputeMessageSize(liveBoost_); + } + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(SyncDataResponse? other) + { + if (other == null) return; + if (other.Accountid != 0) Accountid = other.Accountid; + if (other.WorldRoomId != 0) WorldRoomId = other.WorldRoomId; + if (other.gem_ != null) { + if (gem_ == null) gem_ = new global::App.Protobuf.Entity.Gem(); + gem_.MergeFrom(other.gem_); + } + if (other.liveBoost_ != null) { + if (liveBoost_ == null) liveBoost_ = new global::App.Protobuf.Entity.LiveBoost(); + liveBoost_.MergeFrom(other.liveBoost_); + } + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 8u: + Accountid = input.ReadInt64(); + break; + case 16u: + WorldRoomId = input.ReadInt64(); + break; + case 26u: + if (gem_ == null) gem_ = new global::App.Protobuf.Entity.Gem(); + input.ReadMessage(gem_); + break; + case 34u: + if (liveBoost_ == null) liveBoost_ = new global::App.Protobuf.Entity.LiveBoost(); + input.ReadMessage(liveBoost_); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/SeasonPass/FetchRequest.cs b/Models/Protocol/App/Protobuf/SeasonPass/FetchRequest.cs new file mode 100644 index 0000000..82bde08 --- /dev/null +++ b/Models/Protocol/App/Protobuf/SeasonPass/FetchRequest.cs @@ -0,0 +1,78 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.SeasonPass; + +public sealed partial class FetchRequest : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.SeasonPass.FetchRequest()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public FetchRequest() + { + } + + public FetchRequest(FetchRequest other) : this() + { + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public FetchRequest Clone() => new(this); + public override bool Equals(object? other) => Equals(other as FetchRequest); + + public bool Equals(FetchRequest? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(FetchRequest? other) + { + if (other == null) return; + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/Models/Protocol/App/Protobuf/SeasonPass/FetchResponse.cs b/Models/Protocol/App/Protobuf/SeasonPass/FetchResponse.cs new file mode 100644 index 0000000..c0236fb --- /dev/null +++ b/Models/Protocol/App/Protobuf/SeasonPass/FetchResponse.cs @@ -0,0 +1,106 @@ +// +#nullable enable + +using Google.Protobuf; +using Google.Protobuf.Collections; +using Google.Protobuf.Reflection; + +namespace App.Protobuf.SeasonPass; + +public sealed partial class FetchResponse : IMessage +{ + private static readonly MessageParser _parser = new(() => new global::App.Protobuf.SeasonPass.FetchResponse()); + private UnknownFieldSet? _unknownFields; + + public static MessageParser Parser => _parser; + public static MessageDescriptor Descriptor => null!; + MessageDescriptor IMessage.Descriptor => Descriptor; + + public FetchResponse() + { + } + + public FetchResponse(FetchResponse other) : this() + { + missionCounters_ = other.missionCounters_.Clone(); + seasonPasses_ = other.seasonPasses_.Clone(); + _unknownFields = UnknownFieldSet.Clone(other._unknownFields); + } + + public FetchResponse Clone() => new(this); + public const int MissionCountersFieldNumber = 1; + private static readonly FieldCodec _repeated_missionCounters__codec = FieldCodec.ForMessage(10u, global::App.Protobuf.Entity.MissionCounter.Parser); + private readonly RepeatedField missionCounters_ = new(); + public RepeatedField MissionCounters => missionCounters_; + + public const int SeasonPassesFieldNumber = 2; + private static readonly FieldCodec _repeated_seasonPasses__codec = FieldCodec.ForMessage(18u, global::App.Protobuf.Entity.SeasonPassDetail.Parser); + private readonly RepeatedField seasonPasses_ = new(); + public RepeatedField SeasonPasses => seasonPasses_; + + public override bool Equals(object? other) => Equals(other as FetchResponse); + + public bool Equals(FetchResponse? other) + { + if (ReferenceEquals(other, null)) return false; + if (ReferenceEquals(other, this)) return true; + if (!missionCounters_.Equals(other.missionCounters_)) return false; + if (!seasonPasses_.Equals(other.seasonPasses_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + public override int GetHashCode() + { + var hash = 1; + hash ^= missionCounters_.GetHashCode(); + hash ^= seasonPasses_.GetHashCode(); + if (_unknownFields != null) hash ^= _unknownFields.GetHashCode(); + return hash; + } + + public override string ToString() => base.ToString() ?? string.Empty; + + public void WriteTo(CodedOutputStream output) + { + missionCounters_.WriteTo(output, _repeated_missionCounters__codec); + seasonPasses_.WriteTo(output, _repeated_seasonPasses__codec); + _unknownFields?.WriteTo(output); + } + + public int CalculateSize() + { + var size = 0; + size += missionCounters_.CalculateSize(_repeated_missionCounters__codec); + size += seasonPasses_.CalculateSize(_repeated_seasonPasses__codec); + if (_unknownFields != null) size += _unknownFields.CalculateSize(); + return size; + } + + public void MergeFrom(FetchResponse? other) + { + if (other == null) return; + missionCounters_.Add(other.missionCounters_); + seasonPasses_.Add(other.seasonPasses_); + _unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + public void MergeFrom(CodedInputStream input) + { + uint tag; + while ((tag = input.ReadTag()) != 0) + { + switch (tag) + { + case 10u: + missionCounters_.AddEntriesFrom(input, _repeated_missionCounters__codec); + break; + case 18u: + seasonPasses_.AddEntriesFrom(input, _repeated_seasonPasses__codec); + break; + default: + _unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + } +} diff --git a/MyNotes.csproj b/MyNotes.csproj index 579198d..88be6a9 100644 --- a/MyNotes.csproj +++ b/MyNotes.csproj @@ -8,12 +8,14 @@ - - + + + - + + diff --git a/Program.cs b/Program.cs index 58350fe..921f4f5 100644 --- a/Program.cs +++ b/Program.cs @@ -1,29 +1,82 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.Server.Kestrel.Core; +using System.Security.Cryptography.X509Certificates; +using System.Net.Security; using MyNotes.Controllers; using MyNotes.Config; using MyNotes.Middleware; using MyNotes.Services; using Protocol = App.Protobuf; +if (args.FirstOrDefault() == "resource-release") +{ + if (args.Length != 7) + throw new ArgumentException("usage: resource-release BASELINE PACKAGE MASTER_JSON CATALOG_NAME RESOURCE_VERSION OUTPUT"); + Console.WriteLine(ResourceReleaseBuilder.Build(args[1], args[2], args[3], args[4], args[5], args[6])); + return; +} + var builder = WebApplication.CreateBuilder(args); +var configIndex = Array.IndexOf(args, "--config"); +if (configIndex >= 0) +{ + if (configIndex + 1 >= args.Length) + throw new ArgumentException("--config requires a JSON file path"); + builder.Configuration.AddJsonFile(Path.GetFullPath(args[configIndex + 1]), optional: false, reloadOnChange: false) + .AddEnvironmentVariables().AddCommandLine(args); +} builder.Logging.ClearProviders(); builder.Logging.AddConsole(); builder.Logging.AddDebug(); ServerConfig.Load(builder.Configuration); +X509Certificate2? serverCertificate = null; +X509Certificate2Collection? serverCertificateChain = null; +SslStreamCertificateContext? serverCertificateContext = null; +if (!string.IsNullOrWhiteSpace(ServerConfig.CertificatePath)) +{ + var certificates = X509CertificateLoader.LoadPkcs12CollectionFromFile( + Path.GetFullPath(ServerConfig.CertificatePath, builder.Environment.ContentRootPath), + ServerConfig.CertificatePassword); + serverCertificate = certificates.Cast().First(certificate => certificate.HasPrivateKey); + serverCertificateChain = new X509Certificate2Collection(); + foreach (var certificate in certificates) + if (!certificate.Equals(serverCertificate)) serverCertificateChain.Add(certificate); + var roots = new X509Certificate2Collection(); + foreach (var certificate in serverCertificateChain) + if (certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData)) + roots.Add(certificate); + serverCertificateContext = roots.Count == 0 + ? SslStreamCertificateContext.Create(serverCertificate, serverCertificateChain, offline: true) + : SslStreamCertificateContext.Create(serverCertificate, serverCertificateChain, offline: true, + SslCertificateTrust.CreateForX509Collection(roots, sendTrustInHandshake: false)); +} builder.WebHost.ConfigureKestrel(options => { options.ListenAnyIP(ServerConfig.GrpcPort, listen => { listen.Protocols = HttpProtocols.Http2; + if (serverCertificate != null) + listen.UseHttps(https => + { + https.ServerCertificate = serverCertificate; + https.ServerCertificateChain = serverCertificateChain; + https.OnAuthenticate = (_, options) => options.ServerCertificateContext = serverCertificateContext; + }); }); options.ListenAnyIP(ServerConfig.HttpPort, listen => { listen.Protocols = HttpProtocols.Http1; + if (serverCertificate != null) + listen.UseHttps(https => + { + https.ServerCertificate = serverCertificate; + https.ServerCertificateChain = serverCertificateChain; + https.OnAuthenticate = (_, options) => options.ServerCertificateContext = serverCertificateContext; + }); }); }); @@ -33,6 +86,7 @@ }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -40,8 +94,14 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); +_ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); var appLogger = app.Services.GetRequiredService().CreateLogger("MyNotes"); app.Use(async (ctx, next) => @@ -60,22 +120,38 @@ if (feature != null) appLogger.LogError(feature.Error, "Unhandled request error"); - ctx.Response.StatusCode = StatusCodes.Status500InternalServerError; if (GrpcFraming.IsGrpcRequest(ctx.Request)) { - ctx.Response.ContentType = GrpcFraming.ContentType; - ctx.Response.Headers.Append("grpc-status", "13"); - ctx.Response.Headers.Append("grpc-message", "internal server error"); - await ctx.Response.Body.FlushAsync(ctx.RequestAborted); + await GrpcFraming.WriteErrorAsync(ctx, "13", "internal server error"); return; } + ctx.Response.StatusCode = StatusCodes.Status500InternalServerError; await ctx.Response.WriteAsJsonAsync(new { error = "Internal Server Error" }); })); app.UseRouting(); app.UseMiddleware(); +app.MapGrpcUnary("/app.playerlogin.PlayerLoginService/GetServerList", + (ctx, payload) => + { + _ = Empty.Parser.ParseFrom(payload.ToArray()); + return Task.FromResult(ctx.RequestServices.GetRequiredService().ServerList()); + }); +app.MapGrpcUnary("/app.playerlogin.PlayerLoginService/PlayerPreLogin", + (ctx, payload) => Task.FromResult(ctx.RequestServices.GetRequiredService().PreLogin(payload))); +app.MapGrpcUnary("/app.playerlogin.PlayerLoginService/PlayerLogin", + (ctx, payload) => Task.FromResult(ctx.RequestServices.GetRequiredService().Login(payload))); + +app.MapGrpcUnary("/app.feature_switch.FeatureSwitchService/Get", + (ctx, payload) => + { + _ = Empty.Parser.ParseFrom(payload.ToArray()); + _ = ctx.RequestServices.GetRequiredService().GetFromRequest(ctx.Request); + return Task.FromResult(ctx.RequestServices.GetRequiredService().Get()); + }); + app.MapGrpcUnary( "/app.masterdata.MasterdataService/Version", Protocol.Masterdata.VersionRequest.Parser, @@ -759,6 +835,37 @@ Protocol.Announcement.GetRequest.Parser, static (_, _) => Task.FromResult(new Protocol.Announcement.GetResponse())); +app.MapGrpcUnary( + "/app.seasonpass.SeasonPassService/Fetch", + Protocol.SeasonPass.FetchRequest.Parser, + static (_, _) => Task.FromResult(new Protocol.SeasonPass.FetchResponse())); + +app.MapGrpcUnary( + "/app.playerext.PlayerExtService/SyncData", + Protocol.PlayerExt.SyncDataRequest.Parser, + (ctx, _) => + { + var players = ctx.RequestServices.GetRequiredService(); + var protocol = ctx.RequestServices.GetRequiredService(); + var player = players.GetFromRequest(ctx.Request); + return Task.FromResult(protocol.SyncData(player)); + }); + +app.MapGrpcUnary( + "/app.chat.ClientChatService/SyncMsg", + Protocol.Chat.ClientSyncMsgRequest.Parser, + static (_, _) => Task.FromResult(new Protocol.Chat.ClientSyncMsgResponse())); + +app.MapGrpcUnary( + "/app.chat.ClientChatService/GetNotifications", + Protocol.Chat.GetNotificationsRequest.Parser, + static (_, _) => Task.FromResult(new Protocol.Chat.GetNotificationsResponse())); + +app.MapGrpcUnary( + "/app.live.LiveService/FetchBattleLiveInvitation", + Protocol.Live.FetchBattleLiveInvitationRequest.Parser, + static (_, _) => Task.FromResult(new Protocol.Live.FetchBattleLiveInvitationResponse())); + app.MapGrpcUnary( "/app.carousel_help.CarouselHelpService/Shown", Protocol.CarouselHelp.ShownRequest.Parser, @@ -826,23 +933,30 @@ app.MapGrpcUnary( "/app.live.LiveService/StartFree", Protocol.Live.StartFreeRequest.Parser, - static (_, _) => Task.FromResult(new Protocol.Live.StartFreeResponse())); + (ctx, request) => Task.FromResult(ctx.RequestServices.GetRequiredService() + .Start(ctx.RequestServices.GetRequiredService().GetFromRequest(ctx.Request), request))); app.MapGrpcUnary( "/app.live.LiveService/FinishFree", Protocol.Live.FinishFreeRequest.Parser, - static (_, _) => Task.FromResult(new Protocol.Live.FinishFreeResponse())); + (ctx, request) => Task.FromResult(ctx.RequestServices.GetRequiredService() + .Finish(ctx.RequestServices.GetRequiredService().GetFromRequest(ctx.Request), request))); -app.MapGrpcUnary( +app.MapGrpcUnary( "/app.live.LiveService/SkipFree", Protocol.Live.SkipFreeRequest.Parser, - static (_, _) => Task.FromResult(new Protocol.Live.SkipFreeResponse())); + static (_, _) => throw new GrpcStatusException("12", "live skipping is not supported")); app.MapGrpcUnary( "/app.live.LiveService/CheckMaintenanceCasualMatch", Protocol.Live.CheckMaintenanceCasualMatchRequest.Parser, static (_, _) => Task.FromResult(new Protocol.Live.CheckMaintenanceCasualMatchResponse())); +app.MapGrpcUnary( + "/app.live.LiveService/CheckMaintenanceFree", + Protocol.Live.CheckMaintenanceFreeRequest.Parser, + static (_, _) => Task.FromResult(new Protocol.Live.CheckMaintenanceFreeResponse())); + app.MapGrpcUnary( "/app.live.LiveService/CheckMaintenanceRankMatch", Protocol.Live.CheckMaintenanceRankMatchRequest.Parser, @@ -959,12 +1073,23 @@ app.MapStaticAssetEndpoints(); +app.MapFallback(async context => +{ + if (GrpcFraming.IsGrpcRequest(context.Request)) + await GrpcFraming.WriteErrorAsync(context, "12", "rpc method is not supported"); + else + context.Response.StatusCode = StatusCodes.Status404NotFound; +}); + app.MapGet("/", () => Results.Json(new { name = "MyNotes", status = "ok" })); -Console.WriteLine($"MyNotes gRPC h2c is running on port {ServerConfig.GrpcPort}"); -Console.WriteLine($"MyNotes HTTP static/admin is running on port {ServerConfig.HttpPort}"); +var scheme = serverCertificate == null ? "http" : "https"; +Console.WriteLine($"MyNotes gRPC HTTP/2 is running on {scheme} port {ServerConfig.GrpcPort}"); +Console.WriteLine($"MyNotes static/admin HTTP/1.1 is running on {scheme} port {ServerConfig.HttpPort}"); app.Run(); + +public partial class Program { } diff --git a/Services/ClientStateProtocol.cs b/Services/ClientStateProtocol.cs new file mode 100644 index 0000000..a15f6a9 --- /dev/null +++ b/Services/ClientStateProtocol.cs @@ -0,0 +1,50 @@ +using App.Protobuf.Entity; +using App.Protobuf.Home; +using App.Protobuf.Player; +using Google.Protobuf; + +namespace MyNotes.Services; + +internal static class ClientStateProtocol +{ + private const int MemoryStatusField = 49; + private const int VipField = 50; + private const int TutorialProgressField = 53; + private const int PlayerPersonalNotificationField = 60; + private const int AccountIdField = 6; + private const int HomePersonalNotificationField = 10; + + public static void InitializePlayer(PlayerData data) => InitializeMessages(data, + MemoryStatusField, VipField, TutorialProgressField, PlayerPersonalNotificationField); + + public static void SetAccountId(GetPlayerDataResponse response, long accountId) + { + MergeFields(response, output => + { + output.WriteTag(AccountIdField, WireFormat.WireType.Varint); + output.WriteInt64(accountId); + }); + } + + public static void InitializeHome(GetHomeResponse response) => + InitializeMessages(response, HomePersonalNotificationField); + + private static void InitializeMessages(IMessage message, params int[] fields) => + MergeFields(message, output => + { + foreach (var field in fields) + { + output.WriteTag(field, WireFormat.WireType.LengthDelimited); + output.WriteBytes(ByteString.Empty); + } + }); + + // Extension fields use the message's unknown-field storage, which preserves them through cloning and parsing. + private static void MergeFields(IMessage message, Action write) + { + using var stream = new MemoryStream(); + using (var output = new CodedOutputStream(stream, leaveOpen: true)) + write(output); + message.MergeFrom(stream.ToArray()); + } +} diff --git a/Services/FeatureSwitchService.cs b/Services/FeatureSwitchService.cs new file mode 100644 index 0000000..a75767b --- /dev/null +++ b/Services/FeatureSwitchService.cs @@ -0,0 +1,34 @@ +using Google.Protobuf; + +namespace MyNotes.Services; + +public sealed class FeatureSwitchService +{ + private readonly byte[] _response; + + public FeatureSwitchService(IConfiguration configuration) + { + using var stream = new MemoryStream(); + using (var output = new CodedOutputStream(stream, leaveOpen: true)) + { + foreach (var section in configuration.GetSection("FeatureSwitches").GetChildren()) + { + if (!bool.TryParse(section.Value, out var enabled)) + throw new InvalidOperationException("Feature switches must have boolean values"); + using var entryStream = new MemoryStream(); + using (var entry = new CodedOutputStream(entryStream, leaveOpen: true)) + { + entry.WriteTag(1, WireFormat.WireType.LengthDelimited); + entry.WriteString(section.Key); + entry.WriteTag(2, WireFormat.WireType.Varint); + entry.WriteBool(enabled); + } + output.WriteTag(1, WireFormat.WireType.LengthDelimited); + output.WriteBytes(ByteString.CopyFrom(entryStream.ToArray())); + } + } + _response = stream.ToArray(); + } + + public byte[] Get() => _response; +} diff --git a/Services/FreeLiveService.cs b/Services/FreeLiveService.cs new file mode 100644 index 0000000..7fc8c1e --- /dev/null +++ b/Services/FreeLiveService.cs @@ -0,0 +1,56 @@ +using App.Protobuf.Entity; +using App.Protobuf.Live; +using MyNotes.Middleware; +using MyNotes.Models; + +namespace MyNotes.Services; + +public sealed class FreeLiveService(MasterDataService master) +{ + public StartFreeResponse Start(PlayerRecord player, StartFreeRequest request) + { + if (!master.LiveScoreSeeds.Contains(new LiveScoreSeed(request.MusicId, request.Difficulty))) + throw new GrpcStatusException("3", "unknown song or difficulty"); + if (request.Boost is < 0 or > 10) + throw new GrpcStatusException("3", "boost must be between zero and ten"); + var initial = master.GetInitialPlayerData(player.InitialDataGroup); + lock (player.DeckStateLock) + { + if (!player.DeckOverrides.ContainsKey(request.DeckId) && !initial.Decks.Any(deck => deck.Id == request.DeckId)) + throw new GrpcStatusException("3", "unknown deck"); + } + lock (player.LiveStateLock) + player.ActiveFreeLive = (request.MusicId, request.Difficulty); + return new StartFreeResponse(); + } + + public FinishFreeResponse Finish(PlayerRecord player, FinishFreeRequest request) + { + var finish = request.LiveFinish; + var score = finish?.SoloScore ?? request.Score; + var combo = finish?.Combo ?? request.Combo; + var status = finish?.ClearStatus ?? request.ClearStatus; + if (score < 0 || combo < 0 || status is < 1 or > 5 || + finish?.JudgementCounts.Any(count => count.TotalCount < 0) == true) + throw new GrpcStatusException("3", "invalid live result"); + if (finish != null && (request.Score != 0 && request.Score != score || + request.Combo != 0 && request.Combo != combo || request.ClearStatus != 0 && request.ClearStatus != status)) + throw new GrpcStatusException("3", "live result fields disagree"); + + lock (player.LiveStateLock) + { + if (player.ActiveFreeLive is not { } active) + throw new GrpcStatusException("9", "no active free live"); + if (!player.LiveScores.TryGetValue(active, out var saved)) + { + saved = new LiveScore { MusicId = active.MusicId, Difficulty = active.Difficulty, Mode = 1 }; + player.LiveScores.Add(active, saved); + } + saved.HighScore = Math.Max(saved.HighScore, score); + saved.MaxCombo = Math.Max(saved.MaxCombo, combo); + saved.ClearStatus = Math.Max(saved.ClearStatus, status); + player.ActiveFreeLive = null; + } + return new FinishFreeResponse { Notification = new Notification() }; + } +} diff --git a/Services/GrpcFraming.cs b/Services/GrpcFraming.cs index 470dccd..4405fdd 100644 --- a/Services/GrpcFraming.cs +++ b/Services/GrpcFraming.cs @@ -1,35 +1,72 @@ using System.Buffers.Binary; using Microsoft.AspNetCore.Http.Features; +using Microsoft.Net.Http.Headers; namespace MyNotes.Services; public readonly record struct GrpcFrame(bool Compressed, ReadOnlyMemory Payload); +public sealed class GrpcFrameException(string status, string message) : Exception(message) +{ + public string Status { get; } = status; +} + public static class GrpcFraming { public const string ContentType = "application/grpc"; + public const int MaxMessageLength = 16 * 1024 * 1024; - public static bool IsGrpcRequest(HttpRequest request) => - request.ContentType?.StartsWith("application/grpc", StringComparison.OrdinalIgnoreCase) == true; + public static bool IsGrpcRequest(HttpRequest request) + { + if (!MediaTypeHeaderValue.TryParse(request.ContentType, out var contentType) || + contentType.Parameters.Any(parameter => parameter.Value.Length == 0)) + return false; + + var mediaType = contentType.MediaType.Value; + return string.Equals(mediaType, ContentType, StringComparison.OrdinalIgnoreCase) || + string.Equals(mediaType, ContentType + "+proto", StringComparison.OrdinalIgnoreCase); + } public static async Task ReadRequestAsync(HttpRequest request, CancellationToken cancellationToken) { - using var memory = new MemoryStream(); - await request.Body.CopyToAsync(memory, cancellationToken); - var data = memory.ToArray(); + if (request.ContentLength > MaxMessageLength + 5L) + throw new GrpcFrameException("8", "grpc message exceeds the size limit"); + + var prefix = new byte[5]; + try + { + await request.Body.ReadExactlyAsync(prefix, cancellationToken); + } + catch (EndOfStreamException) + { + throw new GrpcFrameException("3", "grpc request requires a complete frame header"); + } + + if (prefix[0] is not (0 or 1)) + throw new GrpcFrameException("3", "invalid grpc compression flag"); + + var length = BinaryPrimitives.ReadUInt32BigEndian(prefix.AsSpan(1, 4)); + if (length > MaxMessageLength) + throw new GrpcFrameException("8", "grpc message exceeds the size limit"); - if (data.Length == 0) - return new GrpcFrame(false, ReadOnlyMemory.Empty); + if (request.ContentLength.HasValue && request.ContentLength.Value != length + 5L) + throw new GrpcFrameException("3", "grpc frame length must match the request body"); - if (data.Length < 5) - throw new InvalidDataException("Invalid gRPC frame: shorter than 5 bytes."); + var payload = new byte[(int)length]; + try + { + await request.Body.ReadExactlyAsync(payload, cancellationToken); + } + catch (EndOfStreamException) + { + throw new GrpcFrameException("3", "grpc frame payload is incomplete"); + } - var compressed = data[0] != 0; - var length = BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(1, 4)); - if (length > data.Length - 5) - throw new InvalidDataException("Invalid gRPC frame: length exceeds body size."); + var trailingByte = new byte[1]; + if (await request.Body.ReadAsync(trailingByte, cancellationToken) != 0) + throw new GrpcFrameException("3", "unary grpc requests require exactly one frame"); - return new GrpcFrame(compressed, data.AsMemory(5, (int)length)); + return new GrpcFrame(prefix[0] == 1, payload); } public static async Task WriteResponseAsync(HttpContext context, byte[] protobuf) @@ -55,6 +92,7 @@ public static async Task WriteResponseAsync(HttpContext context, byte[] protobuf public static async Task WriteErrorAsync(HttpContext context, string status, string message) { + context.Response.StatusCode = StatusCodes.Status200OK; context.Response.ContentType = ContentType; if (context.Response.SupportsTrailers()) { diff --git a/Services/HomeProtocolBuilder.cs b/Services/HomeProtocolBuilder.cs index 8cfba05..0362e83 100644 --- a/Services/HomeProtocolBuilder.cs +++ b/Services/HomeProtocolBuilder.cs @@ -13,6 +13,7 @@ public GetHomeResponse Get(HomeSnapshot snapshot, PlayerRecord player) Notification = snapshot.HasNotification ? new Notification() : null, Friends = snapshot.HasFriends ? playerProtocol.BuildFriends(player, players) : null }; + ClientStateProtocol.InitializeHome(response); foreach (var invitation in players.GetInvitationProfiles(player)) { diff --git a/Services/MasterDataService.cs b/Services/MasterDataService.cs index bdedfaa..f691b55 100644 --- a/Services/MasterDataService.cs +++ b/Services/MasterDataService.cs @@ -8,16 +8,19 @@ public sealed class MasterDataService private readonly ILogger _logger; private readonly Dictionary _initialDataByGroup; - public string Version => ServerConfig.MasterVersion; + public string Version { get; } + public string ResourceVersion { get; } public IReadOnlyList DefaultUnlockedLiveMusicIds { get; } public IReadOnlyList LiveScoreSeeds { get; } public IReadOnlyList LiveComboRewardDifficulties { get; } - public MasterDataService(IHostEnvironment environment, ILogger logger) + public MasterDataService(IHostEnvironment environment, ILogger logger, ResourceReleaseService release) { _logger = logger; + Version = release.MasterVersion; + ResourceVersion = release.ResourceVersion; - var masterDirectory = ResolveMasterDirectory(environment.ContentRootPath); + var masterDirectory = release.MasterJsonDirectory ?? ResolveMasterDirectory(environment.ContentRootPath); if (masterDirectory == null) { _logger.LogWarning("Master data directory was not found; using baked fallback ids."); @@ -58,10 +61,7 @@ public InitialPlayerData GetInitialPlayerData(string? initialDataGroup) { var candidates = new[] { - Path.Combine(contentRootPath, ServerConfig.DataDirectory, "master"), - Path.Combine(contentRootPath, "data", "master"), - Path.GetFullPath(Path.Combine(contentRootPath, "..", "tmp", "master_dec")), - Path.GetFullPath(Path.Combine(contentRootPath, "tmp", "master_dec")) + Path.Combine(contentRootPath, ServerConfig.DataDirectory, "master") }; return candidates.FirstOrDefault(path => @@ -142,11 +142,12 @@ private static Dictionary LoadInitialPlayerData(strin } } - if (members.Count > 0 && supports.Count > 0) - result[groupRows.Key] = new InitialPlayerData(members, supports, decks); + result[groupRows.Key] = new InitialPlayerData(members, supports, decks); } - return result.Count > 0 ? result : BuildFallbackInitialData(); + if (result.Count == 0) + result["Default"] = new InitialPlayerData([], [], []); + return result; } private static IReadOnlyList LoadLiveMusicIds(string masterDirectory) @@ -159,24 +160,31 @@ private static IReadOnlyList LoadLiveMusicIds(string masterDirectory) .Order() .ToArray(); - return ids.Length > 0 ? ids : FallbackLiveMusicIds; + return ids; } private static IReadOnlyList LoadLiveScoreSeeds(string masterDirectory) { var liveMusicIds = LoadLiveMusicIds(masterDirectory).ToHashSet(); - var seeds = ReadRows(Path.Combine(masterDirectory, "MasterLiveMusicScore.json")) - .Select(row => GetLong(row, "_id")) - .Where(id => id > 0) - .Select(id => new LiveScoreSeed(id / 100, ToServerDifficulty((int)(id % 100)))) - .Where(seed => liveMusicIds.Contains(seed.MusicId)) - .Where(seed => seed.Difficulty > 0) - .Distinct() - .OrderBy(seed => seed.MusicId) - .ThenBy(seed => seed.Difficulty) - .ToArray(); - - return seeds.Length > 0 ? seeds : FallbackLiveScoreSeeds; + var scores = ReadRows(Path.Combine(masterDirectory, "MasterLiveMusicScore.json")) + .Select(row => GetLong(row, "_id")).Where(id => id > 0).ToHashSet(); + var seeds = new List(); + var fields = new[] { "_easyID", "_normalID", "_hardID", "_expertID" }; + foreach (var music in ReadRows(Path.Combine(masterDirectory, "MasterLiveMusic.json"))) + { + var id = GetLong(music, "_id"); + if (!liveMusicIds.Contains(id)) + continue; + for (var index = 0; index < fields.Length; index++) + { + var scoreId = GetLong(music, fields[index]); + if (scoreId > 0 && !scores.Contains(scoreId)) + throw new InvalidDataException("song references a missing score"); + if (scoreId > 0) + seeds.Add(new LiveScoreSeed(id, index + 1)); + } + } + return seeds.Distinct().OrderBy(seed => seed.MusicId).ThenBy(seed => seed.Difficulty).ToArray(); } private static IReadOnlyList LoadComboRewardDifficulties(string masterDirectory) @@ -189,7 +197,7 @@ private static IReadOnlyList LoadComboRewardDifficulties(string masterDirec .Order() .ToArray(); - return difficulties.Length > 0 ? difficulties : FallbackLiveDifficulties; + return difficulties; } private static HashSet ReadIdSet(string masterDirectory, string fileName) => diff --git a/Services/MasterdataProtocolBuilder.cs b/Services/MasterdataProtocolBuilder.cs index fe77616..e847aed 100644 --- a/Services/MasterdataProtocolBuilder.cs +++ b/Services/MasterdataProtocolBuilder.cs @@ -6,6 +6,7 @@ public sealed class MasterdataProtocolBuilder(MasterDataService masterData) { public VersionResponse Version() => new() { - Version = masterData.Version + Version = masterData.Version, + ResourceVersion = masterData.ResourceVersion }; } diff --git a/Services/PlayerLoginService.cs b/Services/PlayerLoginService.cs new file mode 100644 index 0000000..9d91503 --- /dev/null +++ b/Services/PlayerLoginService.cs @@ -0,0 +1,108 @@ +using System.Security.Cryptography; +using System.Text; +using Google.Protobuf; +using MyNotes.Middleware; +using MyNotes.Models; + +namespace MyNotes.Services; + +public sealed class PlayerLoginService +{ + private readonly PlayerManager _players; + private readonly PlayerProtocolBuilder _protocol; + private readonly Dictionary _accounts = new(StringComparer.Ordinal); + private readonly Dictionary _sessions = new(StringComparer.Ordinal); + private readonly object _lock = new(); + private readonly string[] _server; + + public PlayerLoginService(IConfiguration configuration, PlayerManager players, PlayerProtocolBuilder protocol) + { + _players = players; + _protocol = protocol; + foreach (var section in configuration.GetSection("Login:Accounts").GetChildren()) + { + var uid = section["Uid"] ?? ""; + var hash = section["AccessTokenSha256"] ?? ""; + if (string.IsNullOrWhiteSpace(uid) || hash.Length != 64 || !hash.All(Uri.IsHexDigit) || + !_accounts.TryAdd(uid, new(Convert.FromHexString(hash), section["InitialDataGroup"] ?? "Default"))) + throw new InvalidOperationException("Login accounts require unique UIDs and SHA-256 token digests"); + } + _server = [configuration["Login:Server:Name"] ?? "MyNotes", + configuration["Login:Server:CdnRoot"] ?? "", + configuration["Login:Server:ApiServerRoot"] ?? "", + configuration["Login:Server:ChatServerRoot"] ?? "", + configuration["Login:Server:AtServerRoot"] ?? "", + configuration["Login:Server:LiveServer"] ?? "", + configuration["Login:Server:DisplayName"] ?? "MyNotes", + configuration["Login:Server:AreaId"] ?? "", + configuration["Login:Server:AgeIconSpriteName"] ?? ""]; + } + + public byte[] ServerList() => Encode(output => + WriteBytes(output, 1, Encode(server => + { + for (var index = 0; index < _server.Length; index++) + if (_server[index].Length != 0) + { + server.WriteTag(index + 1, WireFormat.WireType.LengthDelimited); + server.WriteString(_server[index]); + } + }))); + + public byte[] PreLogin(ReadOnlyMemory payload) + { + _ = Authenticate(payload); + return Encode(output => { output.WriteTag(1, WireFormat.WireType.Varint); output.WriteBool(true); }); + } + + public byte[] Login(ReadOnlyMemory payload) + { + var (uid, account) = Authenticate(payload); + PlayerRecord player; + lock (_lock) + { + if (!_sessions.TryGetValue(uid, out player!)) + _sessions.Add(uid, player = _players.Register(account.InitialDataGroup)); + } + return Encode(output => + { + WriteBytes(output, 1, _protocol.Register(player).Credential!.ToByteArray()); + output.WriteTag(2, WireFormat.WireType.LengthDelimited); + output.WriteString(_server[0]); + output.WriteTag(3, WireFormat.WireType.LengthDelimited); + output.WriteString(_server[6]); + }); + } + + private (string Uid, Account Account) Authenticate(ReadOnlyMemory payload) + { + using var input = new CodedInputStream(payload.ToArray()); + string uid = "", token = ""; + uint tag; + while ((tag = input.ReadTag()) != 0) + { + if (tag == 10) uid = input.ReadString(); + else if (tag == 18) token = input.ReadString(); + else input.SkipLastField(); + } + if (token.Length == 0 || !_accounts.TryGetValue(uid, out var account) || + !CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(token)), account.TokenHash)) + throw new GrpcStatusException("16", "invalid login credential"); + return (uid, account); + } + + private static byte[] Encode(Action write) + { + using var stream = new MemoryStream(); + using (var output = new CodedOutputStream(stream, leaveOpen: true)) write(output); + return stream.ToArray(); + } + + private static void WriteBytes(CodedOutputStream output, int field, byte[] bytes) + { + output.WriteTag(field, WireFormat.WireType.LengthDelimited); + output.WriteBytes(ByteString.CopyFrom(bytes)); + } + + private sealed record Account(byte[] TokenHash, string InitialDataGroup); +} diff --git a/Services/PlayerManager.cs b/Services/PlayerManager.cs index a941a1b..ba2a903 100644 --- a/Services/PlayerManager.cs +++ b/Services/PlayerManager.cs @@ -72,7 +72,7 @@ public PlayerRecord GetFromRequest(HttpRequest request) if (TryGetFromRequest(request, out var player)) return player; - return _playersById.Values.OrderBy(p => p.ProfileId).FirstOrDefault() ?? Register(ServerConfig.InitialDataGroup); + throw new UnauthorizedAccessException("Valid player credentials are required."); } public bool TryGetByProfileId(long profileId, out PlayerRecord player) => @@ -1045,23 +1045,39 @@ public void SaveShownContentUnlocks(PlayerRecord player, IEnumerable maste player.ShownContentUnlockIds.Count); } - public bool IsKnownCredential(HttpRequest request) + public bool IsKnownCredential(HttpRequest request) => TryGetFromRequest(request, out _); + + private bool TryGetFromRequest(HttpRequest request, out PlayerRecord player) { - if (TryGetFromRequest(request, out _)) - return true; + player = null!; + if (request.Headers.TryGetValue("x-player-credential", out var credentials)) + { + if (credentials.Count != 1 || string.IsNullOrEmpty(credentials[0]) || + !request.Headers.TryGetValue("x-player-id", out var ids) || ids.Count != 1 || + !_playersByAuthorization.TryGetValue(credentials[0]!, out var authenticated) || + !string.Equals(authenticated.PlayerId, ids[0], StringComparison.Ordinal)) + return false; + + if (request.Headers.TryGetValue("authorization", out var suppliedAuthorization) && + (suppliedAuthorization.Count != 1 || string.IsNullOrWhiteSpace(suppliedAuthorization[0]) || + (!ServerConfig.IsExpectedBasicAuth(suppliedAuthorization[0]!) && + !string.Equals(StripBearerPrefix(suppliedAuthorization[0]!), authenticated.AuthorizationKey, + StringComparison.Ordinal)))) + return false; - if (!request.Headers.TryGetValue("authorization", out var value)) + player = authenticated; return true; + } - var authorization = value.ToString(); - return string.IsNullOrWhiteSpace(authorization) || ServerConfig.IsExpectedBasicAuth(authorization); - } + if (!request.Headers.TryGetValue("authorization", out var values) || values.Count != 1) + return false; - private bool TryGetFromRequest(HttpRequest request, out PlayerRecord player) - { - var authorization = ReadHeader(request, "authorization"); - if (authorization != null && _playersByAuthorization.TryGetValue(StripBearerPrefix(authorization), out player!)) - return true; + var authorization = values[0]; + if (string.IsNullOrWhiteSpace(authorization)) + return false; + + if (!ServerConfig.IsExpectedBasicAuth(authorization)) + return _playersByAuthorization.TryGetValue(StripBearerPrefix(authorization), out player!); var playerId = ReadHeader(request, "x-player-id") ?? ReadHeader(request, "player-id") @@ -1074,7 +1090,6 @@ private bool TryGetFromRequest(HttpRequest request, out PlayerRecord player) return true; } - player = null!; return false; } diff --git a/Services/PlayerProtocolBuilder.cs b/Services/PlayerProtocolBuilder.cs index e679d4f..8490412 100644 --- a/Services/PlayerProtocolBuilder.cs +++ b/Services/PlayerProtocolBuilder.cs @@ -1,5 +1,6 @@ using App.Protobuf.Entity; using App.Protobuf.Player; +using App.Protobuf.PlayerExt; using Google.Protobuf; using MyNotes.Models; @@ -43,9 +44,17 @@ public GetPlayerDataResponse GetPlayerData(PlayerRecord player, PlayerManager pl } }; response.Invitation.NewInvitationPlayerIds.Add(invitationState.NewPlayerIds); + ClientStateProtocol.SetAccountId(response, player.ProfileId); return response; } + public SyncDataResponse SyncData(PlayerRecord player) => new() + { + Accountid = player.ProfileId, + Gem = new Gem(), + LiveBoost = new LiveBoost() + }; + public PlayerSimpleProfile BuildSimpleProfile(PlayerRecord player) { var initialData = master.GetInitialPlayerData(player.InitialDataGroup); @@ -111,6 +120,7 @@ private PlayerData BuildPlayerData(PlayerRecord player) LiveStampReward = new LiveStampReward(), Comeback = new Comeback() }; + ClientStateProtocol.InitializePlayer(data); if (player.CircleId != 0) data.CircleId = player.CircleId; @@ -124,13 +134,13 @@ private PlayerData BuildPlayerData(PlayerRecord player) foreach (var deck in decks) data.Decks.Add(deck.Clone()); - foreach (var score in master.LiveScoreSeeds) + lock (player.LiveStateLock) { - data.LiveScore.Add(new LiveScore + foreach (var score in master.LiveScoreSeeds) { - MusicId = score.MusicId, - Difficulty = score.Difficulty - }); + data.LiveScore.Add(player.LiveScores.TryGetValue((score.MusicId, score.Difficulty), out var saved) + ? saved.Clone() : new LiveScore { MusicId = score.MusicId, Difficulty = score.Difficulty }); + } } foreach (var musicId in master.DefaultUnlockedLiveMusicIds) @@ -265,7 +275,7 @@ private static PlayerSimpleProfile BuildSimpleProfile( var seed = initialData.MemberCards[(int)displayMemberCardId - 1]; profile.FavoriteMemberCard = BuildDeckMemberCardDetail(seed); if (requestedMemberCardId > 0 && requestedMemberCardId <= initialData.MemberCards.Count) - profile.FavoriteMemberCardMasterId = requestedMemberCardId; + profile.FavoriteMemberCardMasterId = seed.MasterId; } return profile; diff --git a/Services/ResourcePaths.cs b/Services/ResourcePaths.cs new file mode 100644 index 0000000..d0e36c2 --- /dev/null +++ b/Services/ResourcePaths.cs @@ -0,0 +1,31 @@ +namespace MyNotes.Services; + +public static class ResourcePaths +{ + public static bool IsSegment(string value) => value.Length > 0 && value.Length <= 160 && + value.All(c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-' or '.') && + value is not "." and not ".." && !value.EndsWith('.') && + !new[] { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" } + .Contains(value.Split('.')[0], StringComparer.OrdinalIgnoreCase); + + public static string Resolve(string root, string relative) + { + var parts = relative.Split('/'); + if (parts.Length == 0 || parts.Any(part => !IsSegment(part))) + throw new InvalidDataException("resource paths must contain safe relative segments"); + var fullRoot = Path.GetFullPath(root); + for (var ancestor = new DirectoryInfo(fullRoot); ancestor != null; ancestor = ancestor.Parent) + if (ancestor.Exists && ancestor.LinkTarget != null) + throw new InvalidDataException("resource directories must be regular directories"); + var current = fullRoot; + foreach (var part in parts) + { + current = Path.Combine(current, part); + if ((File.Exists(current) || Directory.Exists(current)) && + (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + throw new InvalidDataException("resource paths must use regular files and directories"); + } + return current; + } +} diff --git a/Services/ResourceReleaseBuilder.cs b/Services/ResourceReleaseBuilder.cs new file mode 100644 index 0000000..5743aec --- /dev/null +++ b/Services/ResourceReleaseBuilder.cs @@ -0,0 +1,139 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MyNotes.Services; + +public static class ResourceReleaseBuilder +{ + public static string Build(string baseline, string package, string masterJson, string catalogName, string resourceVersion, string output) + { + baseline = Path.GetFullPath(baseline); + package = Path.GetFullPath(package); + masterJson = Path.GetFullPath(masterJson); + output = Path.GetFullPath(output); + if (!ResourcePaths.IsSegment(catalogName) || !ResourcePaths.IsSegment(resourceVersion)) + throw new InvalidDataException("invalid catalog or resource version"); + if (Directory.Exists(output) || File.Exists(output)) + throw new IOException("output directory already exists"); + foreach (var input in new[] { baseline, package, masterJson }) + { + var relative = Path.GetRelativePath(input, output); + if (relative == "." || !Path.IsPathFullyQualified(relative) && relative != ".." && + !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + throw new InvalidDataException("output must be outside all input directories"); + } + using var packageDoc = JsonDocument.Parse(File.ReadAllBytes(ResourcePaths.Resolve(package, "package.json"))); + var build = packageDoc.RootElement; + if (build.GetProperty("format").GetString() != "nnnotes.song-build/1") + throw new InvalidDataException("unsupported song package format"); + var version = build.GetProperty("version").GetString()!; + if (!ResourcePaths.IsSegment(version)) + throw new InvalidDataException("invalid package version"); + var catalog = ResourcePaths.Resolve(baseline, $"Android/{catalogName}.bin"); + if (!Hash(catalog).Equals(build.GetProperty("baseline").GetProperty("catalogSha256").GetString(), StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("package baseline catalog differs"); + var listed = new Dictionary(StringComparer.Ordinal); + foreach (var entry in build.GetProperty("files").EnumerateArray()) + { + var relative = entry.GetProperty("path").GetString()!; + var expectedHash = entry.GetProperty("sha256").GetString()!; + if (!listed.TryAdd(relative, expectedHash)) + throw new InvalidDataException("duplicate package file"); + var file = ResourcePaths.Resolve(package, relative); + if (new FileInfo(file).Length != entry.GetProperty("size").GetInt64() || + !Hash(file).Equals(expectedHash, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("package file verification failed"); + } + foreach (var file in Directory.EnumerateFiles(package, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(package, file).Replace('\\', '/'); + if (relative == "package.json" || relative == "validation.json") + continue; + if (!listed.ContainsKey(relative)) + throw new InvalidDataException("package contains an unlisted file"); + } + foreach (var required in new[] { "catalog/catalog.bin", "catalog/catalog.hash", "master/MasterManifest.json" }) + if (!listed.ContainsKey(required)) + throw new InvalidDataException("required package file is missing"); + var masterVersions = Directory.EnumerateDirectories(ResourcePaths.Resolve(baseline, "master")) + .Where(path => File.Exists(Path.Combine(path, "MasterManifest.json"))).ToArray(); + if (masterVersions.Length != 1) + throw new InvalidDataException("baseline must contain exactly one master version"); + + var staging = output + ".staging-" + Guid.NewGuid().ToString("N"); + Directory.CreateDirectory(staging); + CopyTree(ResourcePaths.Resolve(baseline, "Android"), Path.Combine(staging, "Android")); + var masterOut = Path.Combine(staging, "master", version); + CopyTree(masterVersions[0], masterOut); + CopyTree(Path.Combine(package, "master"), masterOut, overwrite: true); + foreach (var bundle in build.GetProperty("bundles").EnumerateArray()) + { + var file = ResourcePaths.Resolve(package, bundle.GetProperty("path").GetString()!); + if (!listed.ContainsKey(bundle.GetProperty("path").GetString()!)) + throw new InvalidDataException("bundle is not listed in the package"); + var uri = new Uri(bundle.GetProperty("internal_id").GetString()!); + if (Uri.UnescapeDataString(uri.AbsolutePath.Split('/').Last()) != Path.GetFileName(file)) + throw new InvalidDataException("bundle filename differs from catalog URL"); + File.Copy(file, Path.Combine(staging, "Android", Path.GetFileName(file)), false); + if (!Hash(Path.Combine(staging, "Android", Path.GetFileName(file))).Equals( + listed[bundle.GetProperty("path").GetString()!], StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("bundle changed during import"); + } + File.Copy(ResourcePaths.Resolve(package, "catalog/catalog.bin"), Path.Combine(staging, "Android", catalogName + ".bin"), true); + File.Copy(ResourcePaths.Resolve(package, "catalog/catalog.hash"), Path.Combine(staging, "Android", catalogName + ".hash"), true); + var bareCatalog = Path.Combine(staging, "Android", catalogName); + if (File.Exists(bareCatalog)) + File.Delete(bareCatalog); + foreach (var (relative, expectedHash) in listed) + { + var destination = relative.StartsWith("master/", StringComparison.Ordinal) ? + Path.Combine(masterOut, relative[7..].Replace('/', Path.DirectorySeparatorChar)) : + relative == "catalog/catalog.bin" ? Path.Combine(staging, "Android", catalogName + ".bin") : + relative == "catalog/catalog.hash" ? Path.Combine(staging, "Android", catalogName + ".hash") : null; + if (destination != null && !Hash(destination).Equals(expectedHash, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("package changed during import"); + } + CopyTree(masterJson, Path.Combine(staging, "master-json")); + var files = Directory.EnumerateFiles(staging, "*", SearchOption.AllDirectories).Order(StringComparer.Ordinal) + .Select(path => new { path = Path.GetRelativePath(staging, path).Replace('\\', '/'), + size = new FileInfo(path).Length, sha256 = Hash(path) }).ToArray(); + var release = new { format = "mynotes.resource-release/1", version, resourceVersion, catalogName, + masterJsonDirectory = "master-json", files }; + var manifestPath = Path.Combine(staging, "release.json"); + File.WriteAllBytes(manifestPath, JsonSerializer.SerializeToUtf8Bytes(release, new JsonSerializerOptions { WriteIndented = true })); + var environment = new ReleaseEnvironment(staging); + var validated = new ResourceReleaseService(environment, manifestPath); + _ = new MasterDataService(environment, NullLogger.Instance, validated); + Directory.Move(staging, output); + return Path.Combine(output, "release.json"); + } + + private static string Hash(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + + private static void CopyTree(string source, string target, bool overwrite = false) + { + Directory.CreateDirectory(target); + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, file).Replace('\\', '/'); + var safe = ResourcePaths.Resolve(source, relative); + var destination = ResourcePaths.Resolve(target, relative); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(safe, destination, overwrite); + } + } + + private sealed class ReleaseEnvironment(string root) : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Production"; + public string ApplicationName { get; set; } = "MyNotes"; + public string ContentRootPath { get; set; } = root; + public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = + new Microsoft.Extensions.FileProviders.NullFileProvider(); + } +} diff --git a/Services/ResourceReleaseService.cs b/Services/ResourceReleaseService.cs new file mode 100644 index 0000000..37864ca --- /dev/null +++ b/Services/ResourceReleaseService.cs @@ -0,0 +1,106 @@ +using System.Security.Cryptography; +using System.Text.Json; +using MyNotes.Config; + +namespace MyNotes.Services; + +public sealed class ResourceReleaseService +{ + private readonly Dictionary _files = new(StringComparer.Ordinal); + private readonly string? _root; + public string MasterVersion { get; } + public string ResourceVersion { get; } + public string? MasterJsonDirectory { get; } + public string? CatalogName { get; } + public bool Enabled => _root != null; + + public ResourceReleaseService(IHostEnvironment environment) : this(environment, ServerConfig.ResourceReleaseManifest) { } + + public ResourceReleaseService(IHostEnvironment environment, string manifestPath) + { + MasterVersion = ServerConfig.MasterVersion; + ResourceVersion = ServerConfig.ResourceVersion; + if (string.IsNullOrWhiteSpace(manifestPath)) + return; + + var path = Path.GetFullPath(manifestPath, environment.ContentRootPath); + _root = Path.GetDirectoryName(path)!; + _ = ResourcePaths.Resolve(_root, Path.GetFileName(path)); + using var source = JsonDocument.Parse(File.ReadAllBytes(path)); + var doc = source.RootElement; + if (doc.GetProperty("format").GetString() != "mynotes.resource-release/1") + throw new InvalidDataException("unsupported resource release format"); + MasterVersion = Segment(doc, "version"); + ResourceVersion = Segment(doc, "resourceVersion"); + CatalogName = Segment(doc, "catalogName"); + foreach (var entry in doc.GetProperty("files").EnumerateArray()) + { + var relative = entry.GetProperty("path").GetString()!; + var file = ResourcePaths.Resolve(_root, relative); + var hash = entry.GetProperty("sha256").GetString()!; + var size = entry.GetProperty("size").GetInt64(); + if (size < 0 || hash.Length != 64 || hash.Any(c => !Uri.IsHexDigit(c)) || !_files.TryAdd(relative, new(file, size, hash))) + throw new InvalidDataException("invalid or duplicate release file entry"); + using var stream = File.OpenRead(file); + if (stream.Length != size || !Convert.ToHexString(SHA256.HashData(stream)).Equals(hash, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("resource release file verification failed"); + } + + var masterPrefix = $"master/{MasterVersion}/"; + var manifest = Required(masterPrefix + "MasterManifest.json"); + using var manifestSource = JsonDocument.Parse(File.ReadAllBytes(manifest.Path)); + var master = manifestSource.RootElement; + if (master.GetProperty("version").GetString() != MasterVersion) + throw new InvalidDataException("master manifest version differs from release version"); + var masterNames = new HashSet(StringComparer.Ordinal); + foreach (var entry in master.GetProperty("files").EnumerateArray()) + { + var name = Segment(entry, "name"); + if (!masterNames.Add(name)) + throw new InvalidDataException("duplicate master file"); + var file = Required(masterPrefix + name); + if (file.Size != entry.GetProperty("size").GetInt64() || + !file.Hash.Equals(entry.GetProperty("hash").GetString(), StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("master manifest file differs from release entry"); + } + var catalog = Required($"Android/{CatalogName}.bin"); + var marker = Required($"Android/{CatalogName}.hash"); + if (File.ReadAllText(marker.Path).Trim() != catalog.Hash[..32].ToLowerInvariant()) + throw new InvalidDataException("catalog marker differs from catalog content"); + + if (doc.TryGetProperty("masterJsonDirectory", out var jsonPath)) + { + var relative = jsonPath.GetString()!; + MasterJsonDirectory = ResourcePaths.Resolve(_root, relative); + foreach (var name in new[] { "MasterPlayerData", "MasterMemberCard", "MasterSupportCard", "MasterLiveMusic", "MasterLiveMusicScore", "MasterLiveMusicComboReward" }) + { + var file = Required($"{relative}/{name}.json"); + using var table = JsonDocument.Parse(File.ReadAllBytes(file.Path)); + if (table.RootElement.GetProperty("_allData").ValueKind != JsonValueKind.Array) + throw new InvalidDataException("master JSON tables require an array of rows"); + } + } + } + + public string? FindAndroid(string name) + { + if (!Enabled) + return null; + var key = name == CatalogName ? $"Android/{CatalogName}.bin" : "Android/" + name; + return _files.TryGetValue(key, out var file) ? file.Path : null; + } + + public string? FindMaster(string version, string name) => Enabled && version == MasterVersion && + _files.TryGetValue($"master/{version}/{name}", out var file) ? file.Path : null; + + private FileEntry Required(string relative) => _files.TryGetValue(relative, out var file) ? file : + throw new InvalidDataException("required release file is missing"); + + private static string Segment(JsonElement doc, string property) + { + var value = doc.GetProperty(property).GetString() ?? ""; + return ResourcePaths.IsSegment(value) ? value : throw new InvalidDataException("invalid release identity"); + } + + private sealed record FileEntry(string Path, long Size, string Hash); +} diff --git a/Services/StaticAssetService.cs b/Services/StaticAssetService.cs index 380875c..08e7a6b 100644 --- a/Services/StaticAssetService.cs +++ b/Services/StaticAssetService.cs @@ -32,6 +32,7 @@ public sealed class StaticAssetService private readonly ILogger _logger; private readonly HttpClient _officialAssets; + private readonly string? _localRoot; private readonly string? _cacheRoot; private readonly ConcurrentDictionary _cacheLocks = new(StringComparer.OrdinalIgnoreCase); @@ -42,6 +43,9 @@ public StaticAssetService( { _logger = logger; _officialAssets = httpClientFactory.CreateClient("official-assets"); + _localRoot = string.IsNullOrWhiteSpace(ServerConfig.StaticAssetLocalDirectory) + ? null + : Path.GetFullPath(ServerConfig.StaticAssetLocalDirectory, environment.ContentRootPath); _cacheRoot = ServerConfig.StaticAssetCacheEnabled ? ResolveCacheRoot(environment.ContentRootPath) : null; @@ -60,28 +64,43 @@ public async Task ProxyAndroidAssetAsync(HttpContext context, string assetPath, } var officialAssetPath = $"Android/{normalizedPath}"; - var cachePath = GetCachePath(officialAssetPath); - if (cachePath == null) + var localPath = _localRoot == null ? null : ResolveSafeFilePath(_localRoot, officialAssetPath); + if (localPath != null && File.Exists(localPath)) { - await ProxyOfficialAssetAsync(context, officialAssetPath, headOnly, null); + await ServeFileAsync(context, localPath); return; } - if (File.Exists(cachePath)) + var cachePath = IsCatalogMarker(normalizedPath) ? null : GetCachePath(officialAssetPath); + if (cachePath != null && File.Exists(cachePath)) { - await ServeCachedAssetAsync(context, cachePath); + await ServeFileAsync(context, cachePath); return; } - if (headOnly) + if (!ServerConfig.StaticAssetUpstreamEnabled) { - await ProxyOfficialAssetAsync(context, officialAssetPath, true, null); + context.Response.StatusCode = StatusCodes.Status404NotFound; return; } - var cacheLock = await AcquireCacheLockAsync(cachePath, context.RequestAborted); + if (cachePath == null || headOnly || context.Request.Headers.ContainsKey("Range")) + { + await ProxyOfficialAssetAsync(context, officialAssetPath, headOnly, null); + return; + } + + var cacheLockPath = cachePath; + var cacheLock = await AcquireCacheLockAsync(cacheLockPath, context.RequestAborted); try { + cachePath = GetCachePath(officialAssetPath); + if (cachePath == null) + { + await ProxyOfficialAssetAsync(context, officialAssetPath, false, null); + return; + } + if (!File.Exists(cachePath)) { await ProxyOfficialAssetAsync(context, officialAssetPath, false, cachePath); @@ -90,10 +109,10 @@ public async Task ProxyAndroidAssetAsync(HttpContext context, string assetPath, } finally { - ReleaseCacheLock(cachePath, cacheLock); + ReleaseCacheLock(cacheLockPath, cacheLock); } - await ServeCachedAssetAsync(context, cachePath); + await ServeFileAsync(context, cachePath); } private async Task ProxyOfficialAssetAsync( @@ -105,76 +124,32 @@ private async Task ProxyOfficialAssetAsync( var upstreamUrl = BuildOfficialAssetUrl(assetPath); if (upstreamUrl == null) { - context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; - await context.Response.WriteAsync("official asset root is not configured", context.RequestAborted); + await WriteProxyErrorAsync(context, StatusCodes.Status503ServiceUnavailable, + "official asset root is not configured", headOnly); return; } _logger.LogInformation("Proxying asset {AssetPath} from {UpstreamUrl}", assetPath, upstreamUrl); - var populateCacheForRange = cachePath != null && context.Request.Headers.ContainsKey("Range"); - using var upstreamRequest = new HttpRequestMessage(headOnly ? HttpMethod.Head : HttpMethod.Get, upstreamUrl); if (ServerConfig.HasBasicAuth) upstreamRequest.Headers.TryAddWithoutValidation("AUTHORIZATION", ServerConfig.ExpectedBasicAuth); upstreamRequest.Headers.TryAddWithoutValidation("User-Agent", "MyNotes/1.0"); upstreamRequest.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("identity")); - if (!populateCacheForRange && context.Request.Headers.TryGetValue("Range", out var range)) + if (context.Request.Headers.TryGetValue("Range", out var range)) upstreamRequest.Headers.TryAddWithoutValidation("Range", range.ToString()); + if (context.Request.Headers.TryGetValue("If-Range", out var ifRange)) + upstreamRequest.Headers.TryAddWithoutValidation("If-Range", ifRange.ToString()); - HttpResponseMessage upstreamResponse; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); + timeout.CancelAfter(TimeSpan.FromSeconds(ServerConfig.OfficialAssetRequestTimeoutSeconds)); try { - upstreamResponse = await _officialAssets.SendAsync( + using var upstreamResponse = await _officialAssets.SendAsync( upstreamRequest, HttpCompletionOption.ResponseHeadersRead, - context.RequestAborted); - } - catch (TaskCanceledException) when (!context.RequestAborted.IsCancellationRequested) - { - _logger.LogWarning("Official asset proxy timed out for {UpstreamUrl}", upstreamUrl); - context.Response.StatusCode = StatusCodes.Status504GatewayTimeout; - await context.Response.WriteAsync("official asset upstream timed out", context.RequestAborted); - return; - } - catch (HttpRequestException ex) - { - _logger.LogWarning(ex, "Official asset proxy failed for {UpstreamUrl}", upstreamUrl); - context.Response.StatusCode = StatusCodes.Status502BadGateway; - await context.Response.WriteAsync("official asset upstream failed", context.RequestAborted); - return; - } - - using (upstreamResponse) - { - if (populateCacheForRange && IsCacheable(upstreamResponse)) - { - var upstreamBody = await upstreamResponse.Content.ReadAsStreamAsync(context.RequestAborted); - if (await CopyAndCacheAsync( - upstreamBody, - null, - cachePath!, - upstreamResponse.Content.Headers.ContentLength, - context.RequestAborted)) - { - await ServeCachedAssetAsync(context, cachePath!); - return; - } - - _logger.LogWarning( - "Could not populate cache before range response for {AssetPath}; falling back to direct proxy", - assetPath); - await ProxyOfficialAssetAsync(context, assetPath, false, null); - return; - } - - if (populateCacheForRange && upstreamResponse.IsSuccessStatusCode) - { - upstreamResponse.Dispose(); - await ProxyOfficialAssetAsync(context, assetPath, false, null); - return; - } + timeout.Token); context.Response.StatusCode = (int)upstreamResponse.StatusCode; context.Response.Headers["X-Accel-Buffering"] = "no"; @@ -190,7 +165,7 @@ private async Task ProxyOfficialAssetAsync( if (!headOnly) { - var upstreamBody = await upstreamResponse.Content.ReadAsStreamAsync(context.RequestAborted); + var upstreamBody = await upstreamResponse.Content.ReadAsStreamAsync(timeout.Token); if (cachePath != null && IsCacheable(upstreamResponse)) { if (await CopyAndCacheAsync( @@ -198,32 +173,62 @@ private async Task ProxyOfficialAssetAsync( context.Response.Body, cachePath, upstreamResponse.Content.Headers.ContentLength, - context.RequestAborted)) + timeout.Token)) { _logger.LogInformation("Cached static asset at {CachePath}", cachePath); } } else { - await upstreamBody.CopyToAsync(context.Response.Body, context.RequestAborted); + await upstreamBody.CopyToAsync(context.Response.Body, timeout.Token); } } } + catch (Exception ex) when (!context.RequestAborted.IsCancellationRequested && + (ex is OperationCanceledException || + timeout.IsCancellationRequested && (ex is HttpRequestException or IOException))) + { + _logger.LogWarning("Official asset proxy timed out for {UpstreamUrl}", upstreamUrl); + await WriteProxyErrorAsync(context, StatusCodes.Status504GatewayTimeout, + "official asset upstream timed out", headOnly); + } + catch (Exception ex) when (ex is HttpRequestException or IOException) + { + _logger.LogWarning(ex, "Official asset proxy failed for {UpstreamUrl}", upstreamUrl); + await WriteProxyErrorAsync(context, StatusCodes.Status502BadGateway, + "official asset upstream failed", headOnly); + } } - private async Task ServeCachedAssetAsync(HttpContext context, string cachePath) + private async Task ServeFileAsync(HttpContext context, string filePath) { - if (!ContentTypes.TryGetContentType(cachePath, out var contentType)) + if (!ContentTypes.TryGetContentType(filePath, out var contentType)) contentType = "application/octet-stream"; - _logger.LogInformation("Serving static asset from cache {CachePath}", cachePath); + _logger.LogInformation("Serving static asset from file {FilePath}", filePath); await Results.File( - cachePath, + filePath, contentType, + lastModified: File.GetLastWriteTimeUtc(filePath), enableRangeProcessing: true) .ExecuteAsync(context); } + private static async Task WriteProxyErrorAsync( + HttpContext context, int statusCode, string message, bool headOnly) + { + if (context.Response.HasStarted) + { + context.Abort(); + return; + } + + context.Response.Clear(); + context.Response.StatusCode = statusCode; + if (!headOnly) + await context.Response.WriteAsync(message, context.RequestAborted); + } + private async Task CopyAndCacheAsync( Stream source, Stream? responseBody, @@ -383,31 +388,58 @@ private void RemoveCacheLock(string cachePath, CacheLockEntry entry) => if (_cacheRoot == null) return null; - var segments = assetPath.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0 || segments.Any(static segment => !IsCacheableFileName(segment))) - { - _logger.LogWarning("Static asset path cannot be represented safely in the cache: {AssetPath}", assetPath); - return null; - } + return ResolveSafeFilePath(_cacheRoot, $"asset/{assetPath}"); + } + private string? ResolveSafeFilePath(string root, string assetPath) + { try { - var relativePath = Path.Combine(["asset", .. segments]); - var cachePath = Path.GetFullPath(Path.Combine(_cacheRoot, relativePath)); - var relativeToRoot = Path.GetRelativePath(_cacheRoot, cachePath); + var segments = assetPath.Split('/'); + if (segments.Length == 0 || segments.Any(static segment => !IsCacheableFileName(segment))) + return null; + + var fullPath = Path.GetFullPath(Path.Combine(root, Path.Combine(segments))); + var relativeToRoot = Path.GetRelativePath(root, fullPath); if (Path.IsPathFullyQualified(relativeToRoot) || relativeToRoot == ".." || relativeToRoot.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) { - _logger.LogWarning("Static asset cache path escaped its configured root: {AssetPath}", assetPath); + _logger.LogWarning("Static asset path escaped its configured root: {AssetPath}", assetPath); return null; } - return cachePath; + var currentPath = root; + for (var ancestor = new DirectoryInfo(root); ancestor != null; ancestor = ancestor.Parent) + if (ancestor.Exists && (ancestor.Attributes & FileAttributes.ReparsePoint) != 0) + return null; + foreach (var segment in new[] { "" }.Concat(segments)) + { + if (segment.Length > 0) + currentPath = Path.Combine(currentPath, segment); + + FileAttributes attributes; + try + { + attributes = File.GetAttributes(currentPath); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + break; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + _logger.LogWarning("Static asset path contains a symbolic link: {AssetPath}", assetPath); + return null; + } + } + + return fullPath; } catch (Exception ex) when (IsCacheStorageException(ex) || ex is ArgumentException) { - _logger.LogWarning(ex, "Could not resolve static asset cache path for {AssetPath}", assetPath); + _logger.LogWarning(ex, "Could not resolve static asset path for {AssetPath}", assetPath); return null; } } @@ -474,14 +506,24 @@ private static Task WriteBadRequestAsync(HttpContext context) { var normalizedPath = assetPath.Replace('\\', '/'); var segments = normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (Path.IsPathFullyQualified(normalizedPath) || + if (normalizedPath.StartsWith('/') || + Path.IsPathRooted(normalizedPath) || segments.Length == 0 || - segments.Any(static s => s is "." or "..")) + segments.Any(static s => s is "." or ".." || !IsCacheableFileName(s))) return null; return string.Join('/', segments); } + private static bool IsCatalogMarker(string assetPath) + { + var fileName = assetPath[(assetPath.LastIndexOf('/') + 1)..]; + return fileName.StartsWith("catalog", StringComparison.OrdinalIgnoreCase) && + (!fileName.Contains('.') || + fileName.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".hash", StringComparison.OrdinalIgnoreCase)); + } + private static Uri? BuildOfficialAssetUrl(string assetPath) { if (string.IsNullOrWhiteSpace(ServerConfig.OfficialAssetRootUrl)) diff --git a/appsettings.example.json b/appsettings.example.json index 6ec94da..e710721 100644 --- a/appsettings.example.json +++ b/appsettings.example.json @@ -1,11 +1,13 @@ { "Server": { "GrpcPort": 9831, - "HttpPort": 9832 + "HttpPort": 9832, + "CertificatePath": "", + "CertificatePassword": "" }, "Game": { - "MasterVersion": "9c23d88a191e4648f0a28f4bbd4edb6403d5adf99ac5793efd7cc1cb3d49104c", - "InitialDataGroup": "cbt_0.1.8_", + "MasterVersion": "local-master-v1", + "InitialDataGroup": "Default", "InitialStampIds": [], "BasicAuthUser": "replace-me", "BasicAuthPass": "replace-me", @@ -13,9 +15,29 @@ "DataDirectory": "data" }, "Static": { + "LocalDirectory": "data/static", + "UpstreamEnabled": false, "OfficialAssetRootUrl": "https://example.invalid", "OfficialAssetRequestTimeoutSeconds": 30, "CacheEnabled": false, "CacheDirectory": "data/static-cache" + }, + "Resources": { + "ReleaseManifest": "", + "ResourceVersion": "" + }, + "Login": { + "Server": { + "Name": "MyNotes", + "DisplayName": "MyNotes", + "ApiServerRoot": "https://api.example.invalid", + "CdnRoot": "https://cdn.example.invalid", + "ChatServerRoot": "", + "AtServerRoot": "", + "LiveServer": "", + "AreaId": "", + "AgeIconSpriteName": "" + }, + "Accounts": [] } } diff --git a/data/master/MasterLiveMusic.json b/data/master/MasterLiveMusic.json deleted file mode 100644 index 6bad220..0000000 --- a/data/master/MasterLiveMusic.json +++ /dev/null @@ -1,2073 +0,0 @@ -{ - "_allData": [ - { - "_id": 100001, - "_phoneticTextID": "Music_Phonetic_Tilte_100001", - "_rubyTitleTextID": "Music_Ruby_Tilte_100001", - "_sortOrder": 100100001, - "_titleTextID": "Music_Tilte_100001", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 1, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000001, - "_jingleSoundID": 1400000000001, - "_easyID": 10000100, - "_normalID": 10000101, - "_hardID": 10000102, - "_expertID": 10000103, - "_liveScoreRankGroup": 1100001, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100001", - "_composerTextID": "Composer_Name_100001", - "_arrangerTextID": "Arranger_Name_100001", - "_jacketAssetName": "jkt_001_100001", - "_musicVideoIDs": [ - 4302100001 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 1, - "_gekisouMission2": 1, - "_gekisouMission3": 1, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100002, - "_phoneticTextID": "Music_Phonetic_Tilte_100002", - "_rubyTitleTextID": "Music_Ruby_Tilte_100002", - "_sortOrder": 100100002, - "_titleTextID": "Music_Tilte_100002", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 4, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000002, - "_jingleSoundID": 1400000000002, - "_easyID": 10000200, - "_normalID": 10000201, - "_hardID": 10000202, - "_expertID": 10000203, - "_liveScoreRankGroup": 1100002, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100002", - "_composerTextID": "Composer_Name_100002", - "_arrangerTextID": "Arranger_Name_100002", - "_jacketAssetName": "jkt_001_100002", - "_musicVideoIDs": [ - 4302100002 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 3, - "_gekisouMission2": 3, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100003, - "_phoneticTextID": "Music_Phonetic_Tilte_100003", - "_rubyTitleTextID": "Music_Ruby_Tilte_100003", - "_sortOrder": 100100003, - "_titleTextID": "Music_Tilte_100003", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 3, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000003, - "_jingleSoundID": 1400000000003, - "_easyID": 10000300, - "_normalID": 10000301, - "_hardID": 10000302, - "_expertID": 10000303, - "_liveScoreRankGroup": 1100003, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100003", - "_composerTextID": "Composer_Name_100003", - "_arrangerTextID": "Arranger_Name_100003", - "_jacketAssetName": "jkt_001_100003", - "_musicVideoIDs": [ - 4302100003 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 2, - "_gekisouMission3": 2, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100004, - "_phoneticTextID": "Music_Phonetic_Tilte_100004", - "_rubyTitleTextID": "Music_Ruby_Tilte_100004", - "_sortOrder": 100100004, - "_titleTextID": "Music_Tilte_100004", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 2, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000004, - "_jingleSoundID": 1400000000004, - "_easyID": 10000400, - "_normalID": 10000401, - "_hardID": 10000402, - "_expertID": 10000403, - "_liveScoreRankGroup": 1100004, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2026/06/26 15:00:00", - "_lyricistTextID": "Lyricist_Name_100004", - "_composerTextID": "Composer_Name_100004", - "_arrangerTextID": "Arranger_Name_100004", - "_jacketAssetName": "jkt_001_100004", - "_musicVideoIDs": [ - 4302100004 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 3, - "_gekisouMission2": 3, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100007, - "_phoneticTextID": "Music_Phonetic_Tilte_100007", - "_rubyTitleTextID": "Music_Ruby_Tilte_100007", - "_sortOrder": 100100007, - "_titleTextID": "Music_Tilte_100007", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 5, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000007, - "_jingleSoundID": 1400000000007, - "_easyID": 10000700, - "_normalID": 10000701, - "_hardID": 10000702, - "_expertID": 10000703, - "_liveScoreRankGroup": 1100007, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100007", - "_composerTextID": "Composer_Name_100007", - "_arrangerTextID": "Arranger_Name_100007", - "_jacketAssetName": "jkt_001_100007", - "_musicVideoIDs": [ - 4302100007 - ], - "_vjVideoPattern": 1, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 2, - "_gekisouMission3": 2, - "_gekisouCallSe": 1, - "_backgroundCameraType": 0, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100008, - "_phoneticTextID": "Music_Phonetic_Tilte_100008", - "_rubyTitleTextID": "Music_Ruby_Tilte_100008", - "_sortOrder": 100100008, - "_titleTextID": "Music_Tilte_100008", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 1, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000008, - "_jingleSoundID": 1400000000008, - "_easyID": 10000800, - "_normalID": 10000801, - "_hardID": 10000802, - "_expertID": 10000803, - "_liveScoreRankGroup": 1100008, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100008", - "_composerTextID": "Composer_Name_100008", - "_arrangerTextID": "Arranger_Name_100008", - "_jacketAssetName": "jkt_001_100008", - "_musicVideoIDs": [ - 4302100008 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 1, - "_gekisouMission2": 2, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100009, - "_phoneticTextID": "Music_Phonetic_Tilte_100009", - "_rubyTitleTextID": "Music_Ruby_Tilte_100009", - "_sortOrder": 100100009, - "_titleTextID": "Music_Tilte_100009", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 4, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000009, - "_jingleSoundID": 1400000000009, - "_easyID": 10000900, - "_normalID": 10000901, - "_hardID": 10000902, - "_expertID": 10000903, - "_liveScoreRankGroup": 1100009, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100009", - "_composerTextID": "Composer_Name_100009", - "_arrangerTextID": "Arranger_Name_100009", - "_jacketAssetName": "jkt_001_100009", - "_musicVideoIDs": [ - 4302100009 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 3, - "_gekisouMission3": 1, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100011, - "_phoneticTextID": "Music_Phonetic_Tilte_100011", - "_rubyTitleTextID": "Music_Ruby_Tilte_100011", - "_sortOrder": 100100011, - "_titleTextID": "Music_Tilte_100011", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 5, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000011, - "_jingleSoundID": 1400000000011, - "_easyID": 10001100, - "_normalID": 10001101, - "_hardID": 10001102, - "_expertID": 10001103, - "_liveScoreRankGroup": 1100011, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100011", - "_composerTextID": "Composer_Name_100011", - "_arrangerTextID": "Arranger_Name_100011", - "_jacketAssetName": "jkt_001_100011", - "_musicVideoIDs": [ - 4302100011 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 2, - "_gekisouMission3": 2, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100012, - "_phoneticTextID": "Music_Phonetic_Tilte_100012", - "_rubyTitleTextID": "Music_Ruby_Tilte_100012", - "_sortOrder": 100100012, - "_titleTextID": "Music_Tilte_100012", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 4, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000012, - "_jingleSoundID": 1400000000012, - "_easyID": 10001200, - "_normalID": 10001201, - "_hardID": 10001202, - "_expertID": 10001203, - "_liveScoreRankGroup": 1100012, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2026/06/27 15:00:00", - "_lyricistTextID": "Lyricist_Name_100012", - "_composerTextID": "Composer_Name_100012", - "_arrangerTextID": "Arranger_Name_100012", - "_jacketAssetName": "jkt_001_100012", - "_musicVideoIDs": [ - 4302100012 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 3, - "_gekisouMission2": 3, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100013, - "_phoneticTextID": "Music_Phonetic_Tilte_100013", - "_rubyTitleTextID": "Music_Ruby_Tilte_100013", - "_sortOrder": 100100013, - "_titleTextID": "Music_Tilte_100013", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 4, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000013, - "_jingleSoundID": 1400000000013, - "_easyID": 10001300, - "_normalID": 10001301, - "_hardID": 10001302, - "_expertID": 10001303, - "_liveScoreRankGroup": 1100013, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2026/06/26 15:00:00", - "_lyricistTextID": "Lyricist_Name_100013", - "_composerTextID": "Composer_Name_100013", - "_arrangerTextID": "Arranger_Name_100013", - "_jacketAssetName": "jkt_001_100013", - "_musicVideoIDs": [ - 4302100013 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 3, - "_gekisouMission2": 1, - "_gekisouMission3": 2, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100014, - "_phoneticTextID": "Music_Phonetic_Tilte_100014", - "_rubyTitleTextID": "Music_Ruby_Tilte_100014", - "_sortOrder": 100100014, - "_titleTextID": "Music_Tilte_100014", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 3, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000014, - "_jingleSoundID": 1400000000014, - "_easyID": 10001400, - "_normalID": 10001401, - "_hardID": 10001402, - "_expertID": 10001403, - "_liveScoreRankGroup": 1100014, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100014", - "_composerTextID": "Composer_Name_100014", - "_arrangerTextID": "Arranger_Name_100014", - "_jacketAssetName": "jkt_001_100014", - "_musicVideoIDs": [ - 4302100014 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 2, - "_gekisouMission3": 2, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100016, - "_phoneticTextID": "Music_Phonetic_Tilte_100016", - "_rubyTitleTextID": "Music_Ruby_Tilte_100016", - "_sortOrder": 100100016, - "_titleTextID": "Music_Tilte_100016", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 1, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000016, - "_jingleSoundID": 1400000000016, - "_easyID": 10001600, - "_normalID": 10001601, - "_hardID": 10001602, - "_expertID": 10001603, - "_liveScoreRankGroup": 1100016, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2026/06/28 15:00:00", - "_lyricistTextID": "Lyricist_Name_100016", - "_composerTextID": "Composer_Name_100016", - "_arrangerTextID": "Arranger_Name_100016", - "_jacketAssetName": "jkt_001_100016", - "_musicVideoIDs": [ - 4302100016 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 1, - "_gekisouMission2": 1, - "_gekisouMission3": 1, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100017, - "_phoneticTextID": "Music_Phonetic_Tilte_100017", - "_rubyTitleTextID": "Music_Ruby_Tilte_100017", - "_sortOrder": 100100018, - "_titleTextID": "Music_Tilte_100017", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 2, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000017, - "_jingleSoundID": 1400000000017, - "_easyID": 10001700, - "_normalID": 10001701, - "_hardID": 10001702, - "_expertID": 10001703, - "_liveScoreRankGroup": 1100017, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100017", - "_composerTextID": "Composer_Name_100017", - "_arrangerTextID": "Arranger_Name_100017", - "_jacketAssetName": "jkt_001_100017", - "_musicVideoIDs": [], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 1, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100018, - "_phoneticTextID": "Music_Phonetic_Tilte_100018", - "_rubyTitleTextID": "Music_Ruby_Tilte_100018", - "_sortOrder": 100110001, - "_titleTextID": "Music_Tilte_100018", - "_bandIDs": [ - 1 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 1 - ], - "_musicType": 4, - "_musicCategories": [ - 3 - ], - "_bestMusicTagIDs": [ - 1 - ], - "_musicSoundID": 1300000000018, - "_jingleSoundID": 1400000000018, - "_easyID": 10001800, - "_normalID": 10001801, - "_hardID": 10001802, - "_expertID": 10001803, - "_liveScoreRankGroup": 1100018, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 101, - "_startAt": "2026/06/27 15:00:00", - "_lyricistTextID": "Lyricist_Name_100018", - "_composerTextID": "Composer_Name_100018", - "_arrangerTextID": "Arranger_Name_100018", - "_jacketAssetName": "jkt_001_100018", - "_musicVideoIDs": [], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 1, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100026, - "_phoneticTextID": "Music_Phonetic_Tilte_100026", - "_rubyTitleTextID": "Music_Ruby_Tilte_100026", - "_sortOrder": 100200001, - "_titleTextID": "Music_Tilte_100026", - "_bandIDs": [ - 2 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 6 - ], - "_musicType": 2, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 2 - ], - "_musicSoundID": 1300000000026, - "_jingleSoundID": 1400000000026, - "_easyID": 10002600, - "_normalID": 10002601, - "_hardID": 10002602, - "_expertID": 10002603, - "_liveScoreRankGroup": 1100026, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 201, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100026", - "_composerTextID": "Composer_Name_100026", - "_arrangerTextID": "Arranger_Name_100026", - "_jacketAssetName": "jkt_002_100026", - "_musicVideoIDs": [ - 4302100026 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 1, - "_gekisouMission2": 1, - "_gekisouMission3": 1, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100027, - "_phoneticTextID": "Music_Phonetic_Tilte_100027", - "_rubyTitleTextID": "Music_Ruby_Tilte_100027", - "_sortOrder": 100200002, - "_titleTextID": "Music_Tilte_100027", - "_bandIDs": [ - 2 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 6 - ], - "_musicType": 3, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 2 - ], - "_musicSoundID": 1300000000027, - "_jingleSoundID": 1400000000027, - "_easyID": 10002700, - "_normalID": 10002701, - "_hardID": 10002702, - "_expertID": 10002703, - "_liveScoreRankGroup": 1100027, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 201, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100027", - "_composerTextID": "Composer_Name_100027", - "_arrangerTextID": "Arranger_Name_100027", - "_jacketAssetName": "jkt_002_100027", - "_musicVideoIDs": [ - 4302100027 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 3, - "_gekisouMission2": 3, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100028, - "_phoneticTextID": "Music_Phonetic_Tilte_100028", - "_rubyTitleTextID": "Music_Ruby_Tilte_100028", - "_sortOrder": 100200003, - "_titleTextID": "Music_Tilte_100028", - "_bandIDs": [ - 2 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 6 - ], - "_musicType": 3, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 2 - ], - "_musicSoundID": 1300000000028, - "_jingleSoundID": 1400000000028, - "_easyID": 10002800, - "_normalID": 10002801, - "_hardID": 10002802, - "_expertID": 10002803, - "_liveScoreRankGroup": 1100028, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 201, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100028", - "_composerTextID": "Composer_Name_100028", - "_arrangerTextID": "Arranger_Name_100028", - "_jacketAssetName": "jkt_002_100028", - "_musicVideoIDs": [ - 4302100028 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 1, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100029, - "_phoneticTextID": "Music_Phonetic_Tilte_100029", - "_rubyTitleTextID": "Music_Ruby_Tilte_100029", - "_sortOrder": 100200004, - "_titleTextID": "Music_Tilte_100029", - "_bandIDs": [ - 2 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 6 - ], - "_musicType": 4, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 2 - ], - "_musicSoundID": 1300000000029, - "_jingleSoundID": 1400000000029, - "_easyID": 10002900, - "_normalID": 10002901, - "_hardID": 10002902, - "_expertID": 10002903, - "_liveScoreRankGroup": 1100029, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 201, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100029", - "_composerTextID": "Composer_Name_100029", - "_arrangerTextID": "Arranger_Name_100029", - "_jacketAssetName": "jkt_002_100029", - "_musicVideoIDs": [ - 4302100029 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 1, - "_gekisouMission2": 1, - "_gekisouMission3": 1, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100030, - "_phoneticTextID": "Music_Phonetic_Tilte_100030", - "_rubyTitleTextID": "Music_Ruby_Tilte_100030", - "_sortOrder": 100200005, - "_titleTextID": "Music_Tilte_100030", - "_bandIDs": [ - 2 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 6 - ], - "_musicType": 1, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 2 - ], - "_musicSoundID": 1300000000030, - "_jingleSoundID": 1400000000030, - "_easyID": 10003000, - "_normalID": 10003001, - "_hardID": 10003002, - "_expertID": 10003003, - "_liveScoreRankGroup": 1100030, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 201, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100030", - "_composerTextID": "Composer_Name_100030", - "_arrangerTextID": "Arranger_Name_100030", - "_jacketAssetName": "jkt_002_100030", - "_musicVideoIDs": [ - 4302100030 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 2, - "_gekisouMission3": 2, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100031, - "_phoneticTextID": "Music_Phonetic_Tilte_100031", - "_rubyTitleTextID": "Music_Ruby_Tilte_100031", - "_sortOrder": 100200006, - "_titleTextID": "Music_Tilte_100031", - "_bandIDs": [ - 2 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 6 - ], - "_musicType": 3, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 2 - ], - "_musicSoundID": 1300000000031, - "_jingleSoundID": 1400000000031, - "_easyID": 10003100, - "_normalID": 10003101, - "_hardID": 10003102, - "_expertID": 10003103, - "_liveScoreRankGroup": 1100031, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 201, - "_startAt": "2026/06/27 15:00:00", - "_lyricistTextID": "Lyricist_Name_100031", - "_composerTextID": "Composer_Name_100031", - "_arrangerTextID": "Arranger_Name_100031", - "_jacketAssetName": "jkt_002_100031", - "_musicVideoIDs": [ - 4302100031 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 3, - "_gekisouMission2": 3, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100032, - "_phoneticTextID": "Music_Phonetic_Tilte_100032", - "_rubyTitleTextID": "Music_Ruby_Tilte_100032", - "_sortOrder": 100200007, - "_titleTextID": "Music_Tilte_100032", - "_bandIDs": [ - 2 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 6 - ], - "_musicType": 2, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 2 - ], - "_musicSoundID": 1300000000032, - "_jingleSoundID": 1400000000032, - "_easyID": 10003200, - "_normalID": 10003201, - "_hardID": 10003202, - "_expertID": 10003203, - "_liveScoreRankGroup": 1100032, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 201, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100032", - "_composerTextID": "Composer_Name_100032", - "_arrangerTextID": "Arranger_Name_100032", - "_jacketAssetName": "jkt_002_100032", - "_musicVideoIDs": [ - 4302100032 - ], - "_vjVideoPattern": 1, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 1, - "_gekisouMission2": 1, - "_gekisouMission3": 1, - "_gekisouCallSe": 1, - "_backgroundCameraType": 0, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100038, - "_phoneticTextID": "Music_Phonetic_Tilte_100038", - "_rubyTitleTextID": "Music_Ruby_Tilte_100038", - "_sortOrder": 100200013, - "_titleTextID": "Music_Tilte_100038", - "_bandIDs": [ - 2 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 6 - ], - "_musicType": 3, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 2 - ], - "_musicSoundID": 1300000000038, - "_jingleSoundID": 1400000000038, - "_easyID": 10003800, - "_normalID": 10003801, - "_hardID": 10003802, - "_expertID": 10003803, - "_liveScoreRankGroup": 1100038, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 201, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100038", - "_composerTextID": "Composer_Name_100038", - "_arrangerTextID": "Arranger_Name_100038", - "_jacketAssetName": "jkt_002_100038", - "_musicVideoIDs": [ - 4302100038 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 1, - "_gekisouMission2": 2, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100039, - "_phoneticTextID": "Music_Phonetic_Tilte_100039", - "_rubyTitleTextID": "Music_Ruby_Tilte_100039", - "_sortOrder": 100200014, - "_titleTextID": "Music_Tilte_100039", - "_bandIDs": [ - 2 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 6 - ], - "_musicType": 1, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 2 - ], - "_musicSoundID": 1300000000039, - "_jingleSoundID": 1400000000039, - "_easyID": 10003900, - "_normalID": 10003901, - "_hardID": 10003902, - "_expertID": 10003903, - "_liveScoreRankGroup": 1100039, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 201, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100039", - "_composerTextID": "Composer_Name_100039", - "_arrangerTextID": "Arranger_Name_100039", - "_jacketAssetName": "jkt_002_100039", - "_musicVideoIDs": [ - 4302100039 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 2, - "_gekisouMission3": 2, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100040, - "_phoneticTextID": "Music_Phonetic_Tilte_100040", - "_rubyTitleTextID": "Music_Ruby_Tilte_100040", - "_sortOrder": 100200015, - "_titleTextID": "Music_Tilte_100040", - "_bandIDs": [ - 2 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 6 - ], - "_musicType": 5, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 2 - ], - "_musicSoundID": 1300000000040, - "_jingleSoundID": 1400000000040, - "_easyID": 10004000, - "_normalID": 10004001, - "_hardID": 10004002, - "_expertID": 10004003, - "_liveScoreRankGroup": 1100040, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 201, - "_startAt": "2026/06/26 15:00:00", - "_lyricistTextID": "Lyricist_Name_100040", - "_composerTextID": "Composer_Name_100040", - "_arrangerTextID": "Arranger_Name_100040", - "_jacketAssetName": "jkt_002_100040", - "_musicVideoIDs": [ - 4302100040 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 3, - "_gekisouMission2": 3, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100042, - "_phoneticTextID": "Music_Phonetic_Tilte_100042", - "_rubyTitleTextID": "Music_Ruby_Tilte_100042", - "_sortOrder": 100210001, - "_titleTextID": "Music_Tilte_100042", - "_bandIDs": [ - 2 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 6 - ], - "_musicType": 4, - "_musicCategories": [ - 4 - ], - "_bestMusicTagIDs": [ - 2 - ], - "_musicSoundID": 1300000000042, - "_jingleSoundID": 1400000000042, - "_easyID": 10004200, - "_normalID": 10004201, - "_hardID": 10004202, - "_expertID": 10004203, - "_liveScoreRankGroup": 1100042, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 201, - "_startAt": "2026/06/28 15:00:00", - "_lyricistTextID": "Lyricist_Name_100042", - "_composerTextID": "Composer_Name_100042", - "_arrangerTextID": "Arranger_Name_100042", - "_jacketAssetName": "jkt_002_100042", - "_musicVideoIDs": [], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 2, - "_gekisouMission3": 2, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100050, - "_phoneticTextID": "Music_Phonetic_Tilte_100050", - "_rubyTitleTextID": "Music_Ruby_Tilte_100050", - "_sortOrder": 100300001, - "_titleTextID": "Music_Tilte_100050", - "_bandIDs": [ - 3 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 11 - ], - "_musicType": 3, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 3 - ], - "_musicSoundID": 1300000000050, - "_jingleSoundID": 1400000000050, - "_easyID": 10005000, - "_normalID": 10005001, - "_hardID": 10005002, - "_expertID": 10005003, - "_liveScoreRankGroup": 1100050, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 301, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100050", - "_composerTextID": "Composer_Name_100050", - "_arrangerTextID": "Arranger_Name_100050", - "_jacketAssetName": "jkt_003_100050", - "_musicVideoIDs": [ - 4302100050 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 2, - "_gekisouMission3": 2, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100051, - "_phoneticTextID": "Music_Phonetic_Tilte_100051", - "_rubyTitleTextID": "Music_Ruby_Tilte_100051", - "_sortOrder": 100300002, - "_titleTextID": "Music_Tilte_100051", - "_bandIDs": [ - 3 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 11 - ], - "_musicType": 2, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 3 - ], - "_musicSoundID": 1300000000051, - "_jingleSoundID": 1400000000051, - "_easyID": 10005100, - "_normalID": 10005101, - "_hardID": 10005102, - "_expertID": 10005103, - "_liveScoreRankGroup": 1100051, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 301, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100051", - "_composerTextID": "Composer_Name_100051", - "_arrangerTextID": "Arranger_Name_100051", - "_jacketAssetName": "jkt_003_100051", - "_musicVideoIDs": [ - 4302100051 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 1, - "_gekisouMission2": 1, - "_gekisouMission3": 1, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100052, - "_phoneticTextID": "Music_Phonetic_Tilte_100052", - "_rubyTitleTextID": "Music_Ruby_Tilte_100052", - "_sortOrder": 100300003, - "_titleTextID": "Music_Tilte_100052", - "_bandIDs": [ - 3 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 11 - ], - "_musicType": 5, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 3 - ], - "_musicSoundID": 1300000000052, - "_jingleSoundID": 1400000000052, - "_easyID": 10005200, - "_normalID": 10005201, - "_hardID": 10005202, - "_expertID": 10005203, - "_liveScoreRankGroup": 1100052, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 301, - "_startAt": "2026/06/26 15:00:00", - "_lyricistTextID": "Lyricist_Name_100052", - "_composerTextID": "Composer_Name_100052", - "_arrangerTextID": "Arranger_Name_100052", - "_jacketAssetName": "jkt_003_100052", - "_musicVideoIDs": [ - 4302100052 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 2, - "_gekisouMission3": 2, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100053, - "_phoneticTextID": "Music_Phonetic_Tilte_100053", - "_rubyTitleTextID": "Music_Ruby_Tilte_100053", - "_sortOrder": 100300004, - "_titleTextID": "Music_Tilte_100053", - "_bandIDs": [ - 3 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 11 - ], - "_musicType": 1, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 3 - ], - "_musicSoundID": 1300000000053, - "_jingleSoundID": 1400000000053, - "_easyID": 10005300, - "_normalID": 10005301, - "_hardID": 10005302, - "_expertID": 10005303, - "_liveScoreRankGroup": 1100053, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 301, - "_startAt": "2026/06/28 15:00:00", - "_lyricistTextID": "Lyricist_Name_100053", - "_composerTextID": "Composer_Name_100053", - "_arrangerTextID": "Arranger_Name_100053", - "_jacketAssetName": "jkt_003_100053", - "_musicVideoIDs": [ - 4302100053 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 3, - "_gekisouMission2": 3, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100054, - "_phoneticTextID": "Music_Phonetic_Tilte_100054", - "_rubyTitleTextID": "Music_Ruby_Tilte_100054", - "_sortOrder": 100300005, - "_titleTextID": "Music_Tilte_100054", - "_bandIDs": [ - 3 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 11 - ], - "_musicType": 1, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 3 - ], - "_musicSoundID": 1300000000054, - "_jingleSoundID": 1400000000054, - "_easyID": 10005400, - "_normalID": 10005401, - "_hardID": 10005402, - "_expertID": 10005403, - "_liveScoreRankGroup": 1100054, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 301, - "_startAt": "2025/01/01 0:00:00", - "_lyricistTextID": "Lyricist_Name_100054", - "_composerTextID": "Composer_Name_100054", - "_arrangerTextID": "Arranger_Name_100054", - "_jacketAssetName": "jkt_003_100054", - "_musicVideoIDs": [ - 4302100054 - ], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 3, - "_gekisouMission2": 3, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100055, - "_phoneticTextID": "Music_Phonetic_Tilte_100055", - "_rubyTitleTextID": "Music_Ruby_Tilte_100055", - "_sortOrder": 100300006, - "_titleTextID": "Music_Tilte_100055", - "_bandIDs": [ - 3 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 11 - ], - "_musicType": 4, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 3 - ], - "_musicSoundID": 1300000000055, - "_jingleSoundID": 1400000000055, - "_easyID": 10005500, - "_normalID": 10005501, - "_hardID": 10005502, - "_expertID": 10005503, - "_liveScoreRankGroup": 1100055, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 301, - "_startAt": "2026/06/27 15:00:00", - "_lyricistTextID": "Lyricist_Name_100055", - "_composerTextID": "Composer_Name_100055", - "_arrangerTextID": "Arranger_Name_100055", - "_jacketAssetName": "jkt_003_100055", - "_musicVideoIDs": [ - 4302100055 - ], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 1, - "_gekisouMission2": 1, - "_gekisouMission3": 1, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100069, - "_phoneticTextID": "Music_Phonetic_Tilte_100069", - "_rubyTitleTextID": "Music_Ruby_Tilte_100069", - "_sortOrder": 100400001, - "_titleTextID": "Music_Tilte_100069", - "_bandIDs": [ - 4 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 16 - ], - "_musicType": 2, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 4 - ], - "_musicSoundID": 1300000000069, - "_jingleSoundID": 1400000000069, - "_easyID": 10006900, - "_normalID": 10006901, - "_hardID": 10006902, - "_expertID": 10006903, - "_liveScoreRankGroup": 1100069, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 401, - "_startAt": "2026/06/27 15:00:00", - "_lyricistTextID": "Lyricist_Name_100069", - "_composerTextID": "Composer_Name_100069", - "_arrangerTextID": "Arranger_Name_100069", - "_jacketAssetName": "jkt_004_100069", - "_musicVideoIDs": [], - "_vjVideoPattern": 2, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 2, - "_gekisouMission2": 1, - "_gekisouMission3": 3, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - }, - { - "_id": 100080, - "_phoneticTextID": "Music_Phonetic_Tilte_100080", - "_rubyTitleTextID": "Music_Ruby_Tilte_100080", - "_sortOrder": 100500001, - "_titleTextID": "Music_Tilte_100080", - "_bandIDs": [ - 5 - ], - "_bandNameTextID": "", - "_vocalCharacterIDs": [ - 21 - ], - "_musicType": 5, - "_musicCategories": [ - 1 - ], - "_bestMusicTagIDs": [ - 5 - ], - "_musicSoundID": 1300000000080, - "_jingleSoundID": 1400000000080, - "_easyID": 10008000, - "_normalID": 10008001, - "_hardID": 10008002, - "_expertID": 10008003, - "_liveScoreRankGroup": 1100080, - "_defaultUnlock": true, - "_unlockConditionID": 0, - "_liveMusicPenLightColorID": 501, - "_startAt": "2026/06/27 15:00:00", - "_lyricistTextID": "Lyricist_Name_100080", - "_composerTextID": "Composer_Name_100080", - "_arrangerTextID": "Arranger_Name_100080", - "_jacketAssetName": "jkt_005_100080", - "_musicVideoIDs": [], - "_vjVideoPattern": 3, - "_anotherVocalIDs": [], - "_scoreRankRewardGroup": 1, - "_comboRewardGroup": 1, - "_gekisouMission1": 1, - "_gekisouMission2": 3, - "_gekisouMission3": 2, - "_gekisouCallSe": 1, - "_backgroundCameraType": 1, - "_musicLightColorIDNormal": 1, - "_musicLightColorIDChorus": 1, - "_lazerLightMotionNormal": 3, - "_lazerLightMotionChorus": 3, - "_stageLightMotionNormal": 3, - "_stageLightMotionChorus": 3, - "_scoreCLiveMusicRewardID": 1, - "_scoreBLiveMusicRewardID": 2, - "_scoreALiveMusicRewardID": 3, - "_scoreSLiveMusicRewardID": 4, - "_scoreSSLiveMusicRewardID": 5, - "_comboEasyLiveMusicRewardID": 6, - "_comboNormalLiveMusicRewardID": 7, - "_comboHardLiveMusicRewardID": 8, - "_comboExpertLiveMusicRewardID": 9 - } - ] -} \ No newline at end of file diff --git a/data/master/MasterLiveMusicComboReward.json b/data/master/MasterLiveMusicComboReward.json deleted file mode 100644 index d70c36f..0000000 --- a/data/master/MasterLiveMusicComboReward.json +++ /dev/null @@ -1,148 +0,0 @@ -{ - "_allData": [ - { - "_id": 1, - "_group": 1, - "_difficulty": 0, - "_comboRateType": 0, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 500 - }, - { - "_id": 2, - "_group": 1, - "_difficulty": 0, - "_comboRateType": 1, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 1500 - }, - { - "_id": 3, - "_group": 1, - "_difficulty": 0, - "_comboRateType": 2, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 2500 - }, - { - "_id": 4, - "_group": 1, - "_difficulty": 0, - "_comboRateType": 3, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 5000 - }, - { - "_id": 5, - "_group": 1, - "_difficulty": 1, - "_comboRateType": 0, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 1000 - }, - { - "_id": 6, - "_group": 1, - "_difficulty": 1, - "_comboRateType": 1, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 3000 - }, - { - "_id": 7, - "_group": 1, - "_difficulty": 1, - "_comboRateType": 2, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 5000 - }, - { - "_id": 8, - "_group": 1, - "_difficulty": 1, - "_comboRateType": 3, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 10000 - }, - { - "_id": 9, - "_group": 1, - "_difficulty": 2, - "_comboRateType": 0, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 1500 - }, - { - "_id": 10, - "_group": 1, - "_difficulty": 2, - "_comboRateType": 1, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 4500 - }, - { - "_id": 11, - "_group": 1, - "_difficulty": 2, - "_comboRateType": 2, - "_resourceType": 1, - "_resourceId": 1, - "_resourceCount": 15 - }, - { - "_id": 12, - "_group": 1, - "_difficulty": 2, - "_comboRateType": 3, - "_resourceType": 1, - "_resourceId": 1, - "_resourceCount": 50 - }, - { - "_id": 13, - "_group": 1, - "_difficulty": 3, - "_comboRateType": 0, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 2000 - }, - { - "_id": 14, - "_group": 1, - "_difficulty": 3, - "_comboRateType": 1, - "_resourceType": 1, - "_resourceId": 3, - "_resourceCount": 6000 - }, - { - "_id": 15, - "_group": 1, - "_difficulty": 3, - "_comboRateType": 2, - "_resourceType": 1, - "_resourceId": 1, - "_resourceCount": 15 - }, - { - "_id": 16, - "_group": 1, - "_difficulty": 3, - "_comboRateType": 3, - "_resourceType": 1, - "_resourceId": 1, - "_resourceCount": 50 - } - ] -} \ No newline at end of file diff --git a/data/master/MasterLiveMusicScore.json b/data/master/MasterLiveMusicScore.json deleted file mode 100644 index 37115e4..0000000 --- a/data/master/MasterLiveMusicScore.json +++ /dev/null @@ -1,928 +0,0 @@ -{ - "_allData": [ - { - "_id": 10000100, - "_musicScoreTextFileName": "0001/0001_00", - "_musicScoreLevel": 12, - "_musicScoreDisplayLevel": 12.0, - "_fullComboCount": 455 - }, - { - "_id": 10000101, - "_musicScoreTextFileName": "0001/0001_01", - "_musicScoreLevel": 15, - "_musicScoreDisplayLevel": 15.5, - "_fullComboCount": 542 - }, - { - "_id": 10000102, - "_musicScoreTextFileName": "0001/0001_02", - "_musicScoreLevel": 19, - "_musicScoreDisplayLevel": 19.0, - "_fullComboCount": 682 - }, - { - "_id": 10000103, - "_musicScoreTextFileName": "0001/0001_03", - "_musicScoreLevel": 27, - "_musicScoreDisplayLevel": 27.0, - "_fullComboCount": 818 - }, - { - "_id": 10000200, - "_musicScoreTextFileName": "0002/0002_00", - "_musicScoreLevel": 9, - "_musicScoreDisplayLevel": 9.0, - "_fullComboCount": 219 - }, - { - "_id": 10000201, - "_musicScoreTextFileName": "0002/0002_01", - "_musicScoreLevel": 14, - "_musicScoreDisplayLevel": 14.0, - "_fullComboCount": 364 - }, - { - "_id": 10000202, - "_musicScoreTextFileName": "0002/0002_02", - "_musicScoreLevel": 21, - "_musicScoreDisplayLevel": 21.0, - "_fullComboCount": 572 - }, - { - "_id": 10000203, - "_musicScoreTextFileName": "0002/0002_03", - "_musicScoreLevel": 28, - "_musicScoreDisplayLevel": 28.0, - "_fullComboCount": 842 - }, - { - "_id": 10000300, - "_musicScoreTextFileName": "0003/0003_00", - "_musicScoreLevel": 6, - "_musicScoreDisplayLevel": 6.0, - "_fullComboCount": 342 - }, - { - "_id": 10000301, - "_musicScoreTextFileName": "0003/0003_01", - "_musicScoreLevel": 12, - "_musicScoreDisplayLevel": 12.0, - "_fullComboCount": 413 - }, - { - "_id": 10000302, - "_musicScoreTextFileName": "0003/0003_02", - "_musicScoreLevel": 19, - "_musicScoreDisplayLevel": 19.0, - "_fullComboCount": 631 - }, - { - "_id": 10000303, - "_musicScoreTextFileName": "0003/0003_03", - "_musicScoreLevel": 27, - "_musicScoreDisplayLevel": 27.0, - "_fullComboCount": 1029 - }, - { - "_id": 10000400, - "_musicScoreTextFileName": "0004/0004_00", - "_musicScoreLevel": 6, - "_musicScoreDisplayLevel": 6.0, - "_fullComboCount": 255 - }, - { - "_id": 10000401, - "_musicScoreTextFileName": "0004/0004_01", - "_musicScoreLevel": 11, - "_musicScoreDisplayLevel": 11.0, - "_fullComboCount": 362 - }, - { - "_id": 10000402, - "_musicScoreTextFileName": "0004/0004_02", - "_musicScoreLevel": 18, - "_musicScoreDisplayLevel": 18.5, - "_fullComboCount": 670 - }, - { - "_id": 10000403, - "_musicScoreTextFileName": "0004/0004_03", - "_musicScoreLevel": 25, - "_musicScoreDisplayLevel": 25.7, - "_fullComboCount": 720 - }, - { - "_id": 10000700, - "_musicScoreTextFileName": "0007/0007_00", - "_musicScoreLevel": 6, - "_musicScoreDisplayLevel": 6.0, - "_fullComboCount": 261 - }, - { - "_id": 10000701, - "_musicScoreTextFileName": "0007/0007_01", - "_musicScoreLevel": 11, - "_musicScoreDisplayLevel": 11.0, - "_fullComboCount": 327 - }, - { - "_id": 10000702, - "_musicScoreTextFileName": "0007/0007_02", - "_musicScoreLevel": 15, - "_musicScoreDisplayLevel": 15.5, - "_fullComboCount": 448 - }, - { - "_id": 10000703, - "_musicScoreTextFileName": "0007/0007_03", - "_musicScoreLevel": 21, - "_musicScoreDisplayLevel": 21.5, - "_fullComboCount": 610 - }, - { - "_id": 10000800, - "_musicScoreTextFileName": "0008/0008_00", - "_musicScoreLevel": 11, - "_musicScoreDisplayLevel": 11.0, - "_fullComboCount": 678 - }, - { - "_id": 10000801, - "_musicScoreTextFileName": "0008/0008_01", - "_musicScoreLevel": 12, - "_musicScoreDisplayLevel": 12.8, - "_fullComboCount": 798 - }, - { - "_id": 10000802, - "_musicScoreTextFileName": "0008/0008_02", - "_musicScoreLevel": 19, - "_musicScoreDisplayLevel": 19.0, - "_fullComboCount": 868 - }, - { - "_id": 10000803, - "_musicScoreTextFileName": "0008/0008_03", - "_musicScoreLevel": 27, - "_musicScoreDisplayLevel": 27.0, - "_fullComboCount": 1005 - }, - { - "_id": 10000900, - "_musicScoreTextFileName": "0009/0009_00", - "_musicScoreLevel": 6, - "_musicScoreDisplayLevel": 6.0, - "_fullComboCount": 638 - }, - { - "_id": 10000901, - "_musicScoreTextFileName": "0009/0009_01", - "_musicScoreLevel": 12, - "_musicScoreDisplayLevel": 12.0, - "_fullComboCount": 1019 - }, - { - "_id": 10000902, - "_musicScoreTextFileName": "0009/0009_02", - "_musicScoreLevel": 17, - "_musicScoreDisplayLevel": 17.5, - "_fullComboCount": 1262 - }, - { - "_id": 10000903, - "_musicScoreTextFileName": "0009/0009_03", - "_musicScoreLevel": 27, - "_musicScoreDisplayLevel": 27.0, - "_fullComboCount": 1323 - }, - { - "_id": 10001100, - "_musicScoreTextFileName": "0011/0011_00", - "_musicScoreLevel": 6, - "_musicScoreDisplayLevel": 6.5, - "_fullComboCount": 379 - }, - { - "_id": 10001101, - "_musicScoreTextFileName": "0011/0011_01", - "_musicScoreLevel": 10, - "_musicScoreDisplayLevel": 10.0, - "_fullComboCount": 569 - }, - { - "_id": 10001102, - "_musicScoreTextFileName": "0011/0011_02", - "_musicScoreLevel": 20, - "_musicScoreDisplayLevel": 20.0, - "_fullComboCount": 747 - }, - { - "_id": 10001103, - "_musicScoreTextFileName": "0011/0011_03", - "_musicScoreLevel": 25, - "_musicScoreDisplayLevel": 25.5, - "_fullComboCount": 937 - }, - { - "_id": 10001200, - "_musicScoreTextFileName": "0012/0012_00", - "_musicScoreLevel": 5, - "_musicScoreDisplayLevel": 5.8, - "_fullComboCount": 264 - }, - { - "_id": 10001201, - "_musicScoreTextFileName": "0012/0012_01", - "_musicScoreLevel": 16, - "_musicScoreDisplayLevel": 16.0, - "_fullComboCount": 527 - }, - { - "_id": 10001202, - "_musicScoreTextFileName": "0012/0012_02", - "_musicScoreLevel": 19, - "_musicScoreDisplayLevel": 19.0, - "_fullComboCount": 625 - }, - { - "_id": 10001203, - "_musicScoreTextFileName": "0012/0012_03", - "_musicScoreLevel": 23, - "_musicScoreDisplayLevel": 23.0, - "_fullComboCount": 722 - }, - { - "_id": 10001300, - "_musicScoreTextFileName": "0013/0013_00", - "_musicScoreLevel": 10, - "_musicScoreDisplayLevel": 10.0, - "_fullComboCount": 408 - }, - { - "_id": 10001301, - "_musicScoreTextFileName": "0013/0013_01", - "_musicScoreLevel": 10, - "_musicScoreDisplayLevel": 10.5, - "_fullComboCount": 527 - }, - { - "_id": 10001302, - "_musicScoreTextFileName": "0013/0013_02", - "_musicScoreLevel": 19, - "_musicScoreDisplayLevel": 19.0, - "_fullComboCount": 608 - }, - { - "_id": 10001303, - "_musicScoreTextFileName": "0013/0013_03", - "_musicScoreLevel": 24, - "_musicScoreDisplayLevel": 24.5, - "_fullComboCount": 768 - }, - { - "_id": 10001400, - "_musicScoreTextFileName": "0014/0014_00", - "_musicScoreLevel": 6, - "_musicScoreDisplayLevel": 6.5, - "_fullComboCount": 209 - }, - { - "_id": 10001401, - "_musicScoreTextFileName": "0014/0014_01", - "_musicScoreLevel": 13, - "_musicScoreDisplayLevel": 13.0, - "_fullComboCount": 323 - }, - { - "_id": 10001402, - "_musicScoreTextFileName": "0014/0014_02", - "_musicScoreLevel": 20, - "_musicScoreDisplayLevel": 20.0, - "_fullComboCount": 532 - }, - { - "_id": 10001403, - "_musicScoreTextFileName": "0014/0014_03", - "_musicScoreLevel": 25, - "_musicScoreDisplayLevel": 25.0, - "_fullComboCount": 697 - }, - { - "_id": 10001600, - "_musicScoreTextFileName": "0016/0016_00", - "_musicScoreLevel": 8, - "_musicScoreDisplayLevel": 8.0, - "_fullComboCount": 359 - }, - { - "_id": 10001601, - "_musicScoreTextFileName": "0016/0016_01", - "_musicScoreLevel": 11, - "_musicScoreDisplayLevel": 11.0, - "_fullComboCount": 456 - }, - { - "_id": 10001602, - "_musicScoreTextFileName": "0016/0016_02", - "_musicScoreLevel": 17, - "_musicScoreDisplayLevel": 17.5, - "_fullComboCount": 590 - }, - { - "_id": 10001603, - "_musicScoreTextFileName": "0016/0016_03", - "_musicScoreLevel": 25, - "_musicScoreDisplayLevel": 25.4, - "_fullComboCount": 743 - }, - { - "_id": 10001700, - "_musicScoreTextFileName": "0017/0017_00", - "_musicScoreLevel": 9, - "_musicScoreDisplayLevel": 9.0, - "_fullComboCount": 301 - }, - { - "_id": 10001701, - "_musicScoreTextFileName": "0017/0017_01", - "_musicScoreLevel": 13, - "_musicScoreDisplayLevel": 13.0, - "_fullComboCount": 720 - }, - { - "_id": 10001702, - "_musicScoreTextFileName": "0017/0017_02", - "_musicScoreLevel": 22, - "_musicScoreDisplayLevel": 22.0, - "_fullComboCount": 752 - }, - { - "_id": 10001703, - "_musicScoreTextFileName": "0017/0017_03", - "_musicScoreLevel": 27, - "_musicScoreDisplayLevel": 27.7, - "_fullComboCount": 1111 - }, - { - "_id": 10001800, - "_musicScoreTextFileName": "0018/0018_00", - "_musicScoreLevel": 9, - "_musicScoreDisplayLevel": 9.0, - "_fullComboCount": 323 - }, - { - "_id": 10001801, - "_musicScoreTextFileName": "0018/0018_01", - "_musicScoreLevel": 13, - "_musicScoreDisplayLevel": 13.0, - "_fullComboCount": 518 - }, - { - "_id": 10001802, - "_musicScoreTextFileName": "0018/0018_02", - "_musicScoreLevel": 21, - "_musicScoreDisplayLevel": 21.0, - "_fullComboCount": 600 - }, - { - "_id": 10001803, - "_musicScoreTextFileName": "0018/0018_03", - "_musicScoreLevel": 25, - "_musicScoreDisplayLevel": 25.9, - "_fullComboCount": 900 - }, - { - "_id": 10002600, - "_musicScoreTextFileName": "0026/0026_00", - "_musicScoreLevel": 10, - "_musicScoreDisplayLevel": 10.5, - "_fullComboCount": 305 - }, - { - "_id": 10002601, - "_musicScoreTextFileName": "0026/0026_01", - "_musicScoreLevel": 14, - "_musicScoreDisplayLevel": 14.5, - "_fullComboCount": 587 - }, - { - "_id": 10002602, - "_musicScoreTextFileName": "0026/0026_02", - "_musicScoreLevel": 22, - "_musicScoreDisplayLevel": 22.0, - "_fullComboCount": 769 - }, - { - "_id": 10002603, - "_musicScoreTextFileName": "0026/0026_03", - "_musicScoreLevel": 27, - "_musicScoreDisplayLevel": 27.5, - "_fullComboCount": 917 - }, - { - "_id": 10002700, - "_musicScoreTextFileName": "0027/0027_00", - "_musicScoreLevel": 8, - "_musicScoreDisplayLevel": 8.0, - "_fullComboCount": 309 - }, - { - "_id": 10002701, - "_musicScoreTextFileName": "0027/0027_01", - "_musicScoreLevel": 15, - "_musicScoreDisplayLevel": 15.5, - "_fullComboCount": 391 - }, - { - "_id": 10002702, - "_musicScoreTextFileName": "0027/0027_02", - "_musicScoreLevel": 20, - "_musicScoreDisplayLevel": 20.0, - "_fullComboCount": 508 - }, - { - "_id": 10002703, - "_musicScoreTextFileName": "0027/0027_03", - "_musicScoreLevel": 27, - "_musicScoreDisplayLevel": 27.0, - "_fullComboCount": 762 - }, - { - "_id": 10002800, - "_musicScoreTextFileName": "0028/0028_00", - "_musicScoreLevel": 10, - "_musicScoreDisplayLevel": 10.0, - "_fullComboCount": 336 - }, - { - "_id": 10002801, - "_musicScoreTextFileName": "0028/0028_01", - "_musicScoreLevel": 12, - "_musicScoreDisplayLevel": 12.0, - "_fullComboCount": 417 - }, - { - "_id": 10002802, - "_musicScoreTextFileName": "0028/0028_02", - "_musicScoreLevel": 18, - "_musicScoreDisplayLevel": 18.0, - "_fullComboCount": 631 - }, - { - "_id": 10002803, - "_musicScoreTextFileName": "0028/0028_03", - "_musicScoreLevel": 22, - "_musicScoreDisplayLevel": 22.5, - "_fullComboCount": 623 - }, - { - "_id": 10002900, - "_musicScoreTextFileName": "0029/0029_00", - "_musicScoreLevel": 7, - "_musicScoreDisplayLevel": 7.5, - "_fullComboCount": 188 - }, - { - "_id": 10002901, - "_musicScoreTextFileName": "0029/0029_01", - "_musicScoreLevel": 11, - "_musicScoreDisplayLevel": 11.0, - "_fullComboCount": 289 - }, - { - "_id": 10002902, - "_musicScoreTextFileName": "0029/0029_02", - "_musicScoreLevel": 16, - "_musicScoreDisplayLevel": 16.5, - "_fullComboCount": 427 - }, - { - "_id": 10002903, - "_musicScoreTextFileName": "0029/0029_03", - "_musicScoreLevel": 23, - "_musicScoreDisplayLevel": 23.4, - "_fullComboCount": 630 - }, - { - "_id": 10003000, - "_musicScoreTextFileName": "0030/0030_00", - "_musicScoreLevel": 6, - "_musicScoreDisplayLevel": 6.5, - "_fullComboCount": 144 - }, - { - "_id": 10003001, - "_musicScoreTextFileName": "0030/0030_01", - "_musicScoreLevel": 14, - "_musicScoreDisplayLevel": 14.0, - "_fullComboCount": 295 - }, - { - "_id": 10003002, - "_musicScoreTextFileName": "0030/0030_02", - "_musicScoreLevel": 17, - "_musicScoreDisplayLevel": 17.5, - "_fullComboCount": 499 - }, - { - "_id": 10003003, - "_musicScoreTextFileName": "0030/0030_03", - "_musicScoreLevel": 26, - "_musicScoreDisplayLevel": 26.4, - "_fullComboCount": 662 - }, - { - "_id": 10003100, - "_musicScoreTextFileName": "0031/0031_00", - "_musicScoreLevel": 6, - "_musicScoreDisplayLevel": 6.0, - "_fullComboCount": 292 - }, - { - "_id": 10003101, - "_musicScoreTextFileName": "0031/0031_01", - "_musicScoreLevel": 11, - "_musicScoreDisplayLevel": 11.0, - "_fullComboCount": 381 - }, - { - "_id": 10003102, - "_musicScoreTextFileName": "0031/0031_02", - "_musicScoreLevel": 17, - "_musicScoreDisplayLevel": 17.0, - "_fullComboCount": 550 - }, - { - "_id": 10003103, - "_musicScoreTextFileName": "0031/0031_03", - "_musicScoreLevel": 24, - "_musicScoreDisplayLevel": 24.0, - "_fullComboCount": 771 - }, - { - "_id": 10003200, - "_musicScoreTextFileName": "0032/0032_00", - "_musicScoreLevel": 7, - "_musicScoreDisplayLevel": 7.5, - "_fullComboCount": 549 - }, - { - "_id": 10003201, - "_musicScoreTextFileName": "0032/0032_01", - "_musicScoreLevel": 11, - "_musicScoreDisplayLevel": 11.0, - "_fullComboCount": 663 - }, - { - "_id": 10003202, - "_musicScoreTextFileName": "0032/0032_02", - "_musicScoreLevel": 18, - "_musicScoreDisplayLevel": 18.5, - "_fullComboCount": 1022 - }, - { - "_id": 10003203, - "_musicScoreTextFileName": "0032/0032_03", - "_musicScoreLevel": 25, - "_musicScoreDisplayLevel": 25.5, - "_fullComboCount": 1276 - }, - { - "_id": 10003800, - "_musicScoreTextFileName": "0038/0038_00", - "_musicScoreLevel": 7, - "_musicScoreDisplayLevel": 7.0, - "_fullComboCount": 169 - }, - { - "_id": 10003801, - "_musicScoreTextFileName": "0038/0038_01", - "_musicScoreLevel": 12, - "_musicScoreDisplayLevel": 12.0, - "_fullComboCount": 344 - }, - { - "_id": 10003802, - "_musicScoreTextFileName": "0038/0038_02", - "_musicScoreLevel": 18, - "_musicScoreDisplayLevel": 18.5, - "_fullComboCount": 513 - }, - { - "_id": 10003803, - "_musicScoreTextFileName": "0038/0038_03", - "_musicScoreLevel": 26, - "_musicScoreDisplayLevel": 26.3, - "_fullComboCount": 774 - }, - { - "_id": 10003900, - "_musicScoreTextFileName": "0039/0039_00", - "_musicScoreLevel": 7, - "_musicScoreDisplayLevel": 7.0, - "_fullComboCount": 241 - }, - { - "_id": 10003901, - "_musicScoreTextFileName": "0039/0039_01", - "_musicScoreLevel": 13, - "_musicScoreDisplayLevel": 13.0, - "_fullComboCount": 490 - }, - { - "_id": 10003902, - "_musicScoreTextFileName": "0039/0039_02", - "_musicScoreLevel": 18, - "_musicScoreDisplayLevel": 18.0, - "_fullComboCount": 623 - }, - { - "_id": 10003903, - "_musicScoreTextFileName": "0039/0039_03", - "_musicScoreLevel": 25, - "_musicScoreDisplayLevel": 25.5, - "_fullComboCount": 793 - }, - { - "_id": 10004000, - "_musicScoreTextFileName": "0040/0040_00", - "_musicScoreLevel": 11, - "_musicScoreDisplayLevel": 11.0, - "_fullComboCount": 393 - }, - { - "_id": 10004001, - "_musicScoreTextFileName": "0040/0040_01", - "_musicScoreLevel": 15, - "_musicScoreDisplayLevel": 15.0, - "_fullComboCount": 584 - }, - { - "_id": 10004002, - "_musicScoreTextFileName": "0040/0040_02", - "_musicScoreLevel": 24, - "_musicScoreDisplayLevel": 24.5, - "_fullComboCount": 754 - }, - { - "_id": 10004003, - "_musicScoreTextFileName": "0040/0040_03", - "_musicScoreLevel": 28, - "_musicScoreDisplayLevel": 28.5, - "_fullComboCount": 1048 - }, - { - "_id": 10004200, - "_musicScoreTextFileName": "0042/0042_00", - "_musicScoreLevel": 8, - "_musicScoreDisplayLevel": 8.7, - "_fullComboCount": 306 - }, - { - "_id": 10004201, - "_musicScoreTextFileName": "0042/0042_01", - "_musicScoreLevel": 13, - "_musicScoreDisplayLevel": 13.5, - "_fullComboCount": 467 - }, - { - "_id": 10004202, - "_musicScoreTextFileName": "0042/0042_02", - "_musicScoreLevel": 18, - "_musicScoreDisplayLevel": 18.5, - "_fullComboCount": 707 - }, - { - "_id": 10004203, - "_musicScoreTextFileName": "0042/0042_03", - "_musicScoreLevel": 26, - "_musicScoreDisplayLevel": 26.1, - "_fullComboCount": 934 - }, - { - "_id": 10005000, - "_musicScoreTextFileName": "0050/0050_00", - "_musicScoreLevel": 8, - "_musicScoreDisplayLevel": 8.0, - "_fullComboCount": 196 - }, - { - "_id": 10005001, - "_musicScoreTextFileName": "0050/0050_01", - "_musicScoreLevel": 11, - "_musicScoreDisplayLevel": 11.0, - "_fullComboCount": 487 - }, - { - "_id": 10005002, - "_musicScoreTextFileName": "0050/0050_02", - "_musicScoreLevel": 19, - "_musicScoreDisplayLevel": 19.0, - "_fullComboCount": 658 - }, - { - "_id": 10005003, - "_musicScoreTextFileName": "0050/0050_03", - "_musicScoreLevel": 26, - "_musicScoreDisplayLevel": 26.2, - "_fullComboCount": 985 - }, - { - "_id": 10005100, - "_musicScoreTextFileName": "0051/0051_00", - "_musicScoreLevel": 8, - "_musicScoreDisplayLevel": 8.0, - "_fullComboCount": 311 - }, - { - "_id": 10005101, - "_musicScoreTextFileName": "0051/0051_01", - "_musicScoreLevel": 14, - "_musicScoreDisplayLevel": 14.0, - "_fullComboCount": 474 - }, - { - "_id": 10005102, - "_musicScoreTextFileName": "0051/0051_02", - "_musicScoreLevel": 18, - "_musicScoreDisplayLevel": 18.0, - "_fullComboCount": 712 - }, - { - "_id": 10005103, - "_musicScoreTextFileName": "0051/0051_03", - "_musicScoreLevel": 26, - "_musicScoreDisplayLevel": 26.5, - "_fullComboCount": 857 - }, - { - "_id": 10005200, - "_musicScoreTextFileName": "0052/0052_00", - "_musicScoreLevel": 8, - "_musicScoreDisplayLevel": 8.0, - "_fullComboCount": 269 - }, - { - "_id": 10005201, - "_musicScoreTextFileName": "0052/0052_01", - "_musicScoreLevel": 11, - "_musicScoreDisplayLevel": 11.0, - "_fullComboCount": 419 - }, - { - "_id": 10005202, - "_musicScoreTextFileName": "0052/0052_02", - "_musicScoreLevel": 19, - "_musicScoreDisplayLevel": 19.0, - "_fullComboCount": 631 - }, - { - "_id": 10005203, - "_musicScoreTextFileName": "0052/0052_03", - "_musicScoreLevel": 26, - "_musicScoreDisplayLevel": 26.6, - "_fullComboCount": 958 - }, - { - "_id": 10005300, - "_musicScoreTextFileName": "0053/0053_00", - "_musicScoreLevel": 9, - "_musicScoreDisplayLevel": 9.0, - "_fullComboCount": 294 - }, - { - "_id": 10005301, - "_musicScoreTextFileName": "0053/0053_01", - "_musicScoreLevel": 16, - "_musicScoreDisplayLevel": 16.0, - "_fullComboCount": 419 - }, - { - "_id": 10005302, - "_musicScoreTextFileName": "0053/0053_02", - "_musicScoreLevel": 19, - "_musicScoreDisplayLevel": 19.5, - "_fullComboCount": 570 - }, - { - "_id": 10005303, - "_musicScoreTextFileName": "0053/0053_03", - "_musicScoreLevel": 26, - "_musicScoreDisplayLevel": 26.0, - "_fullComboCount": 743 - }, - { - "_id": 10005400, - "_musicScoreTextFileName": "0054/0054_00", - "_musicScoreLevel": 6, - "_musicScoreDisplayLevel": 6.0, - "_fullComboCount": 191 - }, - { - "_id": 10005401, - "_musicScoreTextFileName": "0054/0054_01", - "_musicScoreLevel": 12, - "_musicScoreDisplayLevel": 12.0, - "_fullComboCount": 286 - }, - { - "_id": 10005402, - "_musicScoreTextFileName": "0054/0054_02", - "_musicScoreLevel": 19, - "_musicScoreDisplayLevel": 19.5, - "_fullComboCount": 588 - }, - { - "_id": 10005403, - "_musicScoreTextFileName": "0054/0054_03", - "_musicScoreLevel": 26, - "_musicScoreDisplayLevel": 26.5, - "_fullComboCount": 839 - }, - { - "_id": 10005500, - "_musicScoreTextFileName": "0055/0055_00", - "_musicScoreLevel": 12, - "_musicScoreDisplayLevel": 12.8, - "_fullComboCount": 590 - }, - { - "_id": 10005501, - "_musicScoreTextFileName": "0055/0055_01", - "_musicScoreLevel": 16, - "_musicScoreDisplayLevel": 16.0, - "_fullComboCount": 826 - }, - { - "_id": 10005502, - "_musicScoreTextFileName": "0055/0055_02", - "_musicScoreLevel": 19, - "_musicScoreDisplayLevel": 19.5, - "_fullComboCount": 917 - }, - { - "_id": 10005503, - "_musicScoreTextFileName": "0055/0055_03", - "_musicScoreLevel": 26, - "_musicScoreDisplayLevel": 26.0, - "_fullComboCount": 1086 - }, - { - "_id": 10006900, - "_musicScoreTextFileName": "0069/0069_00", - "_musicScoreLevel": 9, - "_musicScoreDisplayLevel": 9.0, - "_fullComboCount": 261 - }, - { - "_id": 10006901, - "_musicScoreTextFileName": "0069/0069_01", - "_musicScoreLevel": 13, - "_musicScoreDisplayLevel": 13.0, - "_fullComboCount": 420 - }, - { - "_id": 10006902, - "_musicScoreTextFileName": "0069/0069_02", - "_musicScoreLevel": 21, - "_musicScoreDisplayLevel": 21.0, - "_fullComboCount": 670 - }, - { - "_id": 10006903, - "_musicScoreTextFileName": "0069/0069_03", - "_musicScoreLevel": 27, - "_musicScoreDisplayLevel": 27.0, - "_fullComboCount": 887 - }, - { - "_id": 10008000, - "_musicScoreTextFileName": "0080/0080_00", - "_musicScoreLevel": 10, - "_musicScoreDisplayLevel": 10.0, - "_fullComboCount": 166 - }, - { - "_id": 10008001, - "_musicScoreTextFileName": "0080/0080_01", - "_musicScoreLevel": 16, - "_musicScoreDisplayLevel": 16.0, - "_fullComboCount": 277 - }, - { - "_id": 10008002, - "_musicScoreTextFileName": "0080/0080_02", - "_musicScoreLevel": 22, - "_musicScoreDisplayLevel": 22.0, - "_fullComboCount": 456 - }, - { - "_id": 10008003, - "_musicScoreTextFileName": "0080/0080_03", - "_musicScoreLevel": 26, - "_musicScoreDisplayLevel": 26.8, - "_fullComboCount": 717 - } - ] -} \ No newline at end of file diff --git a/data/master/MasterMemberCard.json b/data/master/MasterMemberCard.json deleted file mode 100644 index dbb86d6..0000000 --- a/data/master/MasterMemberCard.json +++ /dev/null @@ -1,708 +0,0 @@ -{ - "_allData": [ - { - "_id": 1, - "_assetID": 1, - "_nameTextID": "Character_Name_Tomori", - "_subtitleTextID": "MemberSubtitle_1", - "_characterID": 1, - "_rarity": 2, - "_cardType": 2, - "_bestMusicTagIDs": [ - 1 - ], - "_performancePowerMax": 8350, - "_technicPowerMax": 8213, - "_visualPowerMax": 8214, - "_memberCardLevelGroup": 1, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 2, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000001, - "_leaderSkillID": 106, - "_liveSkillID": 28, - "_gekisouSkillID": 21, - "_liveSkillLevelResourceGroup": 1, - "_gekisouSkillLevelResourceGroup": 5, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "Live_Skill_Name_16", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 2, - "_assetID": 2, - "_nameTextID": "Character_Name_Anon", - "_subtitleTextID": "MemberSubtitle_2", - "_characterID": 2, - "_rarity": 2, - "_cardType": 3, - "_bestMusicTagIDs": [ - 1 - ], - "_performancePowerMax": 7731, - "_technicPowerMax": 6986, - "_visualPowerMax": 10060, - "_memberCardLevelGroup": 1, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 3, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000002, - "_leaderSkillID": 107, - "_liveSkillID": 28, - "_gekisouSkillID": 31, - "_liveSkillLevelResourceGroup": 1, - "_gekisouSkillLevelResourceGroup": 5, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024/08/01 14:00:00", - "_liveSkillNameTextID": "Live_Skill_Name_17", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 3, - "_assetID": 3, - "_nameTextID": "Character_Name_Rana", - "_subtitleTextID": "MemberSubtitle_3", - "_characterID": 3, - "_rarity": 2, - "_cardType": 1, - "_bestMusicTagIDs": [ - 1 - ], - "_performancePowerMax": 8472, - "_technicPowerMax": 8232, - "_visualPowerMax": 8073, - "_memberCardLevelGroup": 1, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 1, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000003, - "_leaderSkillID": 108, - "_liveSkillID": 28, - "_gekisouSkillID": 27, - "_liveSkillLevelResourceGroup": 1, - "_gekisouSkillLevelResourceGroup": 5, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "Live_Skill_Name_18", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 4, - "_assetID": 4, - "_nameTextID": "Character_Name_Soyo", - "_subtitleTextID": "MemberSubtitle_4", - "_characterID": 4, - "_rarity": 2, - "_cardType": 5, - "_bestMusicTagIDs": [ - 1 - ], - "_performancePowerMax": 7545, - "_technicPowerMax": 9774, - "_visualPowerMax": 7458, - "_memberCardLevelGroup": 1, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 5, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000004, - "_leaderSkillID": 109, - "_liveSkillID": 28, - "_gekisouSkillID": 31, - "_liveSkillLevelResourceGroup": 1, - "_gekisouSkillLevelResourceGroup": 5, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "Live_Skill_Name_19", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 5, - "_assetID": 5, - "_nameTextID": "Character_Name_Taki", - "_subtitleTextID": "MemberSubtitle_5", - "_characterID": 5, - "_rarity": 2, - "_cardType": 4, - "_bestMusicTagIDs": [ - 1 - ], - "_performancePowerMax": 10384, - "_technicPowerMax": 7608, - "_visualPowerMax": 6785, - "_memberCardLevelGroup": 1, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 4, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000005, - "_leaderSkillID": 110, - "_liveSkillID": 28, - "_gekisouSkillID": 21, - "_liveSkillLevelResourceGroup": 1, - "_gekisouSkillLevelResourceGroup": 5, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "Live_Skill_Name_21", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 6, - "_assetID": 6, - "_nameTextID": "Character_Live_Name_Doloris", - "_subtitleTextID": "MemberSubtitle_6", - "_characterID": 6, - "_rarity": 2, - "_cardType": 3, - "_bestMusicTagIDs": [ - 2 - ], - "_performancePowerMax": 9474, - "_technicPowerMax": 7685, - "_visualPowerMax": 7618, - "_memberCardLevelGroup": 1, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 3, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000006, - "_leaderSkillID": 117, - "_liveSkillID": 28, - "_gekisouSkillID": 21, - "_liveSkillLevelResourceGroup": 1, - "_gekisouSkillLevelResourceGroup": 5, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 7, - "_assetID": 7, - "_nameTextID": "Character_Live_Name_Mortis", - "_subtitleTextID": "MemberSubtitle_7", - "_characterID": 7, - "_rarity": 2, - "_cardType": 2, - "_bestMusicTagIDs": [ - 2 - ], - "_performancePowerMax": 8375, - "_technicPowerMax": 8144, - "_visualPowerMax": 8258, - "_memberCardLevelGroup": 1, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 2, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000007, - "_leaderSkillID": 118, - "_liveSkillID": 28, - "_gekisouSkillID": 27, - "_liveSkillLevelResourceGroup": 1, - "_gekisouSkillLevelResourceGroup": 5, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 8, - "_assetID": 8, - "_nameTextID": "Character_Live_Name_Timoris", - "_subtitleTextID": "MemberSubtitle_8", - "_characterID": 8, - "_rarity": 2, - "_cardType": 4, - "_bestMusicTagIDs": [ - 2 - ], - "_performancePowerMax": 7373, - "_technicPowerMax": 9566, - "_visualPowerMax": 7838, - "_memberCardLevelGroup": 1, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 4, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000008, - "_leaderSkillID": 119, - "_liveSkillID": 28, - "_gekisouSkillID": 27, - "_liveSkillLevelResourceGroup": 1, - "_gekisouSkillLevelResourceGroup": 5, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 9, - "_assetID": 9, - "_nameTextID": "Character_Live_Name_Amoris", - "_subtitleTextID": "MemberSubtitle_9", - "_characterID": 9, - "_rarity": 2, - "_cardType": 1, - "_bestMusicTagIDs": [ - 2 - ], - "_performancePowerMax": 7613, - "_technicPowerMax": 7406, - "_visualPowerMax": 9758, - "_memberCardLevelGroup": 1, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 1, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000009, - "_leaderSkillID": 120, - "_liveSkillID": 28, - "_gekisouSkillID": 31, - "_liveSkillLevelResourceGroup": 1, - "_gekisouSkillLevelResourceGroup": 5, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 10, - "_assetID": 10, - "_nameTextID": "Character_Live_Name_Oblivionis", - "_subtitleTextID": "MemberSubtitle_10", - "_characterID": 10, - "_rarity": 2, - "_cardType": 5, - "_bestMusicTagIDs": [ - 2 - ], - "_performancePowerMax": 8078, - "_technicPowerMax": 8389, - "_visualPowerMax": 8310, - "_memberCardLevelGroup": 1, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 5, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000010, - "_leaderSkillID": 121, - "_liveSkillID": 28, - "_gekisouSkillID": 21, - "_liveSkillLevelResourceGroup": 1, - "_gekisouSkillLevelResourceGroup": 5, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 26, - "_assetID": 26, - "_nameTextID": "Character_Name_Tomori", - "_subtitleTextID": "MemberSubtitle_26", - "_characterID": 1, - "_rarity": 3, - "_cardType": 2, - "_bestMusicTagIDs": [ - 1 - ], - "_performancePowerMax": 18821, - "_technicPowerMax": 14603, - "_visualPowerMax": 18659, - "_memberCardLevelGroup": 2, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 7, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000026, - "_leaderSkillID": 101, - "_liveSkillID": 29, - "_gekisouSkillID": 22, - "_liveSkillLevelResourceGroup": 2, - "_gekisouSkillLevelResourceGroup": 6, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 27, - "_assetID": 27, - "_nameTextID": "Character_Name_Anon", - "_subtitleTextID": "MemberSubtitle_27", - "_characterID": 2, - "_rarity": 3, - "_cardType": 3, - "_bestMusicTagIDs": [ - 1 - ], - "_performancePowerMax": 15065, - "_technicPowerMax": 15065, - "_visualPowerMax": 21953, - "_memberCardLevelGroup": 2, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 8, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000027, - "_leaderSkillID": 102, - "_liveSkillID": 30, - "_gekisouSkillID": 33, - "_liveSkillLevelResourceGroup": 2, - "_gekisouSkillLevelResourceGroup": 6, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 28, - "_assetID": 28, - "_nameTextID": "Character_Name_Rana", - "_subtitleTextID": "MemberSubtitle_28", - "_characterID": 3, - "_rarity": 3, - "_cardType": 1, - "_bestMusicTagIDs": [ - 1 - ], - "_performancePowerMax": 17711, - "_technicPowerMax": 17055, - "_visualPowerMax": 17317, - "_memberCardLevelGroup": 2, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 6, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000028, - "_leaderSkillID": 103, - "_liveSkillID": 32, - "_gekisouSkillID": 30, - "_liveSkillLevelResourceGroup": 2, - "_gekisouSkillLevelResourceGroup": 6, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 29, - "_assetID": 29, - "_nameTextID": "Character_Name_Soyo", - "_subtitleTextID": "MemberSubtitle_29", - "_characterID": 4, - "_rarity": 3, - "_cardType": 5, - "_bestMusicTagIDs": [ - 1 - ], - "_performancePowerMax": 15405, - "_technicPowerMax": 20393, - "_visualPowerMax": 16285, - "_memberCardLevelGroup": 2, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 10, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000029, - "_leaderSkillID": 104, - "_liveSkillID": 29, - "_gekisouSkillID": 32, - "_liveSkillLevelResourceGroup": 2, - "_gekisouSkillLevelResourceGroup": 6, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 30, - "_assetID": 30, - "_nameTextID": "Character_Name_Taki", - "_subtitleTextID": "MemberSubtitle_30", - "_characterID": 5, - "_rarity": 3, - "_cardType": 4, - "_bestMusicTagIDs": [ - 1 - ], - "_performancePowerMax": 20032, - "_technicPowerMax": 16312, - "_visualPowerMax": 15739, - "_memberCardLevelGroup": 2, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 9, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000030, - "_leaderSkillID": 105, - "_liveSkillID": 30, - "_gekisouSkillID": 23, - "_liveSkillLevelResourceGroup": 2, - "_gekisouSkillLevelResourceGroup": 6, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 31, - "_assetID": 31, - "_nameTextID": "Character_Live_Name_Doloris", - "_subtitleTextID": "MemberSubtitle_31", - "_characterID": 6, - "_rarity": 3, - "_cardType": 3, - "_bestMusicTagIDs": [ - 2 - ], - "_performancePowerMax": 20387, - "_technicPowerMax": 15178, - "_visualPowerMax": 16518, - "_memberCardLevelGroup": 2, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 8, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000031, - "_leaderSkillID": 112, - "_liveSkillID": 32, - "_gekisouSkillID": 25, - "_liveSkillLevelResourceGroup": 2, - "_gekisouSkillLevelResourceGroup": 6, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 32, - "_assetID": 32, - "_nameTextID": "Character_Live_Name_Mortis", - "_subtitleTextID": "MemberSubtitle_32", - "_characterID": 7, - "_rarity": 3, - "_cardType": 2, - "_bestMusicTagIDs": [ - 2 - ], - "_performancePowerMax": 16997, - "_technicPowerMax": 17482, - "_visualPowerMax": 17604, - "_memberCardLevelGroup": 2, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 7, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000032, - "_leaderSkillID": 113, - "_liveSkillID": 30, - "_gekisouSkillID": 29, - "_liveSkillLevelResourceGroup": 2, - "_gekisouSkillLevelResourceGroup": 6, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 33, - "_assetID": 33, - "_nameTextID": "Character_Live_Name_Timoris", - "_subtitleTextID": "MemberSubtitle_33", - "_characterID": 8, - "_rarity": 3, - "_cardType": 4, - "_bestMusicTagIDs": [ - 2 - ], - "_performancePowerMax": 16377, - "_technicPowerMax": 19867, - "_visualPowerMax": 15839, - "_memberCardLevelGroup": 2, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 9, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000033, - "_leaderSkillID": 114, - "_liveSkillID": 29, - "_gekisouSkillID": 28, - "_liveSkillLevelResourceGroup": 2, - "_gekisouSkillLevelResourceGroup": 6, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 34, - "_assetID": 34, - "_nameTextID": "Character_Live_Name_Amoris", - "_subtitleTextID": "MemberSubtitle_34", - "_characterID": 9, - "_rarity": 3, - "_cardType": 1, - "_bestMusicTagIDs": [ - 2 - ], - "_performancePowerMax": 16089, - "_technicPowerMax": 15426, - "_visualPowerMax": 20568, - "_memberCardLevelGroup": 2, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 6, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000034, - "_leaderSkillID": 115, - "_liveSkillID": 32, - "_gekisouSkillID": 34, - "_liveSkillLevelResourceGroup": 2, - "_gekisouSkillLevelResourceGroup": 6, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 35, - "_assetID": 35, - "_nameTextID": "Character_Live_Name_Oblivionis", - "_subtitleTextID": "MemberSubtitle_35", - "_characterID": 10, - "_rarity": 3, - "_cardType": 5, - "_bestMusicTagIDs": [ - 2 - ], - "_performancePowerMax": 14748, - "_technicPowerMax": 18734, - "_visualPowerMax": 18601, - "_memberCardLevelGroup": 2, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 10, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000035, - "_leaderSkillID": 116, - "_liveSkillID": 29, - "_gekisouSkillID": 22, - "_liveSkillLevelResourceGroup": 2, - "_gekisouSkillLevelResourceGroup": 6, - "_gachaVoiceTextId": "", - "_gachaVoiceSoundId": 0, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 51, - "_assetID": 51, - "_nameTextID": "Character_Name_Tomori", - "_subtitleTextID": "MemberSubtitle_51", - "_characterID": 1, - "_rarity": 4, - "_cardType": 4, - "_bestMusicTagIDs": [ - 1 - ], - "_performancePowerMax": 39830, - "_technicPowerMax": 30495, - "_visualPowerMax": 30807, - "_memberCardLevelGroup": 3, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 14, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000051, - "_leaderSkillID": 100, - "_liveSkillID": 31, - "_gekisouSkillID": 24, - "_liveSkillLevelResourceGroup": 3, - "_gekisouSkillLevelResourceGroup": 7, - "_gachaVoiceTextId": "MemberGachaVoice_51", - "_gachaVoiceSoundId": 3200102000001, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - }, - { - "_id": 60, - "_assetID": 60, - "_nameTextID": "Character_Name_Oblivionis_Sakiko", - "_subtitleTextID": "MemberSubtitle_60", - "_characterID": 10, - "_rarity": 4, - "_cardType": 3, - "_bestMusicTagIDs": [ - 2 - ], - "_performancePowerMax": 29585, - "_technicPowerMax": 39547, - "_visualPowerMax": 32000, - "_memberCardLevelGroup": 3, - "_memberCardAwakeGroup": 1, - "_memberCardAwakeResourceGroup": 13, - "_memberCardRankGroup": 1, - "_rankUpItemID": 10000060, - "_leaderSkillID": 111, - "_liveSkillID": 33, - "_gekisouSkillID": 26, - "_liveSkillLevelResourceGroup": 3, - "_gekisouSkillLevelResourceGroup": 7, - "_gachaVoiceTextId": "MemberGachaVoice_60", - "_gachaVoiceSoundId": 3201002000001, - "_fullAnimationUse": false, - "_illustAnimationVideoId": 0, - "_startAt": "2024-08-01 14:00:00", - "_liveSkillNameTextID": "LiveSkillName_99999", - "_linkSkillLevelResourceGroup": 1 - } - ] -} \ No newline at end of file diff --git a/data/master/MasterPlayerData.json b/data/master/MasterPlayerData.json deleted file mode 100644 index 05acc2e..0000000 --- a/data/master/MasterPlayerData.json +++ /dev/null @@ -1,1796 +0,0 @@ -{ - "_allData": [ - { - "_id": 100001, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "1", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100002, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "2", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100003, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "3", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100004, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "4", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100005, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "5", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100006, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "6", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100007, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "7", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100008, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "8", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100009, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "9", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100010, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "10", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100011, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "26", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100012, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "27", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100013, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "28", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100014, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "29", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100015, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "30", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100016, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "31", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100017, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "32", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100018, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "33", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100019, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "34", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100020, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "35", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100021, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "51", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100022, - "_group": "Default", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "60", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100023, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "1", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100024, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "2", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100025, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "3", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100026, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "4", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100027, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "5", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100028, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "6", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100029, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "7", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100030, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "8", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100031, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "9", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100032, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "10", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100033, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "26", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100034, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "27", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100035, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "28", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100036, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "29", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100037, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "30", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100038, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "31", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100039, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "32", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100040, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "33", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100041, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "34", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100042, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "35", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100043, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "51", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100044, - "_group": "Default", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "53", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 100045, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "1", - "_parameter1": "バンド1", - "_parameter2": "2,4,1,5,3", - "_parameter3": "2,4,1,5,3", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100046, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "2", - "_parameter1": "バンド2", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100047, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "3", - "_parameter1": "バンド3", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100048, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "4", - "_parameter1": "バンド4", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100049, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "5", - "_parameter1": "バンド5", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100050, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "6", - "_parameter1": "バンド6", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100051, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "7", - "_parameter1": "バンド7", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100052, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "8", - "_parameter1": "バンド8", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100053, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "9", - "_parameter1": "バンド9", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100054, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "10", - "_parameter1": "バンド10", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100055, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "11", - "_parameter1": "バンド11", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100056, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "12", - "_parameter1": "バンド12", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100057, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "13", - "_parameter1": "バンド13", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100058, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "14", - "_parameter1": "バンド14", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100059, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "15", - "_parameter1": "バンド15", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100060, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "16", - "_parameter1": "バンド16", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100061, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "17", - "_parameter1": "バンド17", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100062, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "18", - "_parameter1": "バンド18", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100063, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "19", - "_parameter1": "バンド19", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100064, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "20", - "_parameter1": "バンド20", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100065, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "21", - "_parameter1": "バンド21", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100066, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "22", - "_parameter1": "バンド22", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100067, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "23", - "_parameter1": "バンド23", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100068, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "24", - "_parameter1": "バンド24", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100069, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "25", - "_parameter1": "バンド25", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100070, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "26", - "_parameter1": "バンド26", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100071, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "27", - "_parameter1": "バンド27", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100072, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "28", - "_parameter1": "バンド28", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100073, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "29", - "_parameter1": "バンド29", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 100074, - "_group": "Default", - "_playerDataType": 5, - "_parameter0": "30", - "_parameter1": "バンド30", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 400001, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "1", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400002, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "2", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400003, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "3", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400004, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "4", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400005, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "5", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400006, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "6", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400007, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "7", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400008, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "8", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400009, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "9", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400010, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "10", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400011, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "26", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400012, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "27", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400013, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "28", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400014, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "29", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400015, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "30", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400016, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "31", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400017, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "32", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400018, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "33", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400019, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "34", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400020, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "35", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400021, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "51", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400022, - "_group": "CBT", - "_playerDataType": 1, - "_parameter0": "", - "_parameter1": "60", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400023, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "1", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400024, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "2", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400025, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "3", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400026, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "4", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400027, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "5", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400028, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "6", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400029, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "7", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400030, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "8", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400031, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "9", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400032, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "10", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "0", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400033, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "26", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400034, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "27", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400035, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "28", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400036, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "29", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400037, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "30", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400038, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "31", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400039, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "32", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400040, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "33", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400041, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "34", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400042, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "35", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400043, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "51", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400044, - "_group": "CBT", - "_playerDataType": 2, - "_parameter0": "", - "_parameter1": "53", - "_parameter2": "0", - "_parameter3": "1", - "_parameter4": "1", - "_parameter5": "1", - "_parameter6": "1", - "_parameter7": "1", - "_parameter8": "" - }, - { - "_id": 400045, - "_group": "CBT", - "_playerDataType": 5, - "_parameter0": "1", - "_parameter1": "バンド1", - "_parameter2": "2,4,1,5,3", - "_parameter3": "2,4,1,5,3", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 400046, - "_group": "CBT", - "_playerDataType": 5, - "_parameter0": "2", - "_parameter1": "バンド2", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 400047, - "_group": "CBT", - "_playerDataType": 5, - "_parameter0": "3", - "_parameter1": "バンド3", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 400048, - "_group": "CBT", - "_playerDataType": 5, - "_parameter0": "4", - "_parameter1": "バンド4", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 400049, - "_group": "CBT", - "_playerDataType": 5, - "_parameter0": "5", - "_parameter1": "バンド5", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 400050, - "_group": "CBT", - "_playerDataType": 5, - "_parameter0": "6", - "_parameter1": "バンド6", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 400051, - "_group": "CBT", - "_playerDataType": 5, - "_parameter0": "7", - "_parameter1": "バンド7", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 400052, - "_group": "CBT", - "_playerDataType": 5, - "_parameter0": "8", - "_parameter1": "バンド8", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 400053, - "_group": "CBT", - "_playerDataType": 5, - "_parameter0": "9", - "_parameter1": "バンド9", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - }, - { - "_id": 400054, - "_group": "CBT", - "_playerDataType": 5, - "_parameter0": "10", - "_parameter1": "バンド10", - "_parameter2": "-1,-1,-1,-1,-1", - "_parameter3": "-1,-1,-1,-1,-1", - "_parameter4": "", - "_parameter5": "", - "_parameter6": "", - "_parameter7": "", - "_parameter8": "" - } - ] -} \ No newline at end of file diff --git a/data/master/MasterSupportCard.json b/data/master/MasterSupportCard.json deleted file mode 100644 index c8132f6..0000000 --- a/data/master/MasterSupportCard.json +++ /dev/null @@ -1,518 +0,0 @@ -{ - "_allData": [ - { - "_id": 1, - "_assetID": 1, - "_nameTextID": "Snap_Name_Tomori", - "_descriptionTextID": "SupportSubtitle_1", - "_diaryTextID": "SupportDiary_1", - "_characterIDs": [ - 1 - ], - "_rarity": 2, - "_cardType": 5, - "_performancePowerMax": 500, - "_technicPowerMax": 600, - "_visualPowerMax": 400, - "_supportCardLevelGroup": 1, - "_supportCardRankGroup": 1, - "_rankUpItemID": 20000001, - "_supportSkillId01": 23, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 22, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 2, - "_assetID": 2, - "_nameTextID": "Snap_Name_Anon", - "_descriptionTextID": "SupportSubtitle_2", - "_diaryTextID": "SupportDiary_2", - "_characterIDs": [ - 2 - ], - "_rarity": 2, - "_cardType": 1, - "_performancePowerMax": 500, - "_technicPowerMax": 500, - "_visualPowerMax": 500, - "_supportCardLevelGroup": 1, - "_supportCardRankGroup": 1, - "_rankUpItemID": 20000001, - "_supportSkillId01": 23, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 17, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 3, - "_assetID": 3, - "_nameTextID": "Snap_Name_Rana", - "_descriptionTextID": "SupportSubtitle_3", - "_diaryTextID": "SupportDiary_3", - "_characterIDs": [ - 3 - ], - "_rarity": 2, - "_cardType": 2, - "_performancePowerMax": 500, - "_technicPowerMax": 500, - "_visualPowerMax": 500, - "_supportCardLevelGroup": 1, - "_supportCardRankGroup": 1, - "_rankUpItemID": 20000001, - "_supportSkillId01": 23, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 9, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 4, - "_assetID": 4, - "_nameTextID": "Snap_Name_Soyo", - "_descriptionTextID": "SupportSubtitle_4", - "_diaryTextID": "SupportDiary_4", - "_characterIDs": [ - 4 - ], - "_rarity": 2, - "_cardType": 4, - "_performancePowerMax": 600, - "_technicPowerMax": 500, - "_visualPowerMax": 400, - "_supportCardLevelGroup": 1, - "_supportCardRankGroup": 1, - "_rankUpItemID": 20000001, - "_supportSkillId01": 23, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 9, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 5, - "_assetID": 5, - "_nameTextID": "Snap_Name_Taki", - "_descriptionTextID": "SupportSubtitle_5", - "_diaryTextID": "SupportDiary_5", - "_characterIDs": [ - 5 - ], - "_rarity": 2, - "_cardType": 3, - "_performancePowerMax": 500, - "_technicPowerMax": 400, - "_visualPowerMax": 600, - "_supportCardLevelGroup": 1, - "_supportCardRankGroup": 1, - "_rankUpItemID": 20000001, - "_supportSkillId01": 23, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 22, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 6, - "_assetID": 6, - "_nameTextID": "Snap_Name_Uika", - "_descriptionTextID": "SupportSubtitle_6", - "_diaryTextID": "SupportDiary_6", - "_characterIDs": [ - 6 - ], - "_rarity": 2, - "_cardType": 5, - "_performancePowerMax": 500, - "_technicPowerMax": 500, - "_visualPowerMax": 500, - "_supportCardLevelGroup": 1, - "_supportCardRankGroup": 1, - "_rankUpItemID": 20000001, - "_supportSkillId01": 25, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 11, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 7, - "_assetID": 7, - "_nameTextID": "Snap_Name_Mutsumi", - "_descriptionTextID": "SupportSubtitle_7", - "_diaryTextID": "SupportDiary_7", - "_characterIDs": [ - 7 - ], - "_rarity": 2, - "_cardType": 3, - "_performancePowerMax": 600, - "_technicPowerMax": 500, - "_visualPowerMax": 400, - "_supportCardLevelGroup": 1, - "_supportCardRankGroup": 1, - "_rankUpItemID": 20000001, - "_supportSkillId01": 25, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 11, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 8, - "_assetID": 8, - "_nameTextID": "Snap_Name_Umiri", - "_descriptionTextID": "SupportSubtitle_8", - "_diaryTextID": "SupportDiary_8", - "_characterIDs": [ - 8 - ], - "_rarity": 2, - "_cardType": 2, - "_performancePowerMax": 500, - "_technicPowerMax": 500, - "_visualPowerMax": 500, - "_supportCardLevelGroup": 1, - "_supportCardRankGroup": 1, - "_rankUpItemID": 20000001, - "_supportSkillId01": 25, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 18, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 9, - "_assetID": 9, - "_nameTextID": "Snap_Name_Nyamu", - "_descriptionTextID": "SupportSubtitle_9", - "_diaryTextID": "SupportDiary_9", - "_characterIDs": [ - 9 - ], - "_rarity": 2, - "_cardType": 4, - "_performancePowerMax": 400, - "_technicPowerMax": 600, - "_visualPowerMax": 500, - "_supportCardLevelGroup": 1, - "_supportCardRankGroup": 1, - "_rankUpItemID": 20000001, - "_supportSkillId01": 25, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 18, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 10, - "_assetID": 10, - "_nameTextID": "Snap_Name_Sakiko", - "_descriptionTextID": "SupportSubtitle_10", - "_diaryTextID": "SupportDiary_10", - "_characterIDs": [ - 10 - ], - "_rarity": 2, - "_cardType": 1, - "_performancePowerMax": 500, - "_technicPowerMax": 400, - "_visualPowerMax": 600, - "_supportCardLevelGroup": 1, - "_supportCardRankGroup": 1, - "_rankUpItemID": 20000001, - "_supportSkillId01": 25, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 24, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 26, - "_assetID": 26, - "_nameTextID": "Snap_Name_Tomori", - "_descriptionTextID": "SupportSubtitle_26", - "_diaryTextID": "SupportDiary_26", - "_characterIDs": [ - 1 - ], - "_rarity": 3, - "_cardType": 3, - "_performancePowerMax": 1500, - "_technicPowerMax": 1500, - "_visualPowerMax": 2100, - "_supportCardLevelGroup": 2, - "_supportCardRankGroup": 2, - "_rankUpItemID": 20000001, - "_supportSkillId01": 27, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 25, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 27, - "_assetID": 27, - "_nameTextID": "Snap_Name_Anon", - "_descriptionTextID": "SupportSubtitle_27", - "_diaryTextID": "SupportDiary_27", - "_characterIDs": [ - 2 - ], - "_rarity": 3, - "_cardType": 4, - "_performancePowerMax": 2000, - "_technicPowerMax": 1600, - "_visualPowerMax": 1500, - "_supportCardLevelGroup": 2, - "_supportCardRankGroup": 2, - "_rankUpItemID": 20000001, - "_supportSkillId01": 27, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 13, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 28, - "_assetID": 28, - "_nameTextID": "Snap_Name_Rana", - "_descriptionTextID": "SupportSubtitle_28", - "_diaryTextID": "SupportDiary_28", - "_characterIDs": [ - 3 - ], - "_rarity": 3, - "_cardType": 5, - "_performancePowerMax": 1500, - "_technicPowerMax": 2000, - "_visualPowerMax": 1600, - "_supportCardLevelGroup": 2, - "_supportCardRankGroup": 2, - "_rankUpItemID": 20000001, - "_supportSkillId01": 24, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 23, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 29, - "_assetID": 29, - "_nameTextID": "Snap_Name_Soyo", - "_descriptionTextID": "SupportSubtitle_29", - "_diaryTextID": "SupportDiary_29", - "_characterIDs": [ - 4 - ], - "_rarity": 3, - "_cardType": 2, - "_performancePowerMax": 1800, - "_technicPowerMax": 1400, - "_visualPowerMax": 1900, - "_supportCardLevelGroup": 2, - "_supportCardRankGroup": 2, - "_rankUpItemID": 20000001, - "_supportSkillId01": 24, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 10, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 30, - "_assetID": 30, - "_nameTextID": "Snap_Name_Taki", - "_descriptionTextID": "SupportSubtitle_30", - "_diaryTextID": "SupportDiary_30", - "_characterIDs": [ - 5 - ], - "_rarity": 3, - "_cardType": 1, - "_performancePowerMax": 1700, - "_technicPowerMax": 1700, - "_visualPowerMax": 1700, - "_supportCardLevelGroup": 2, - "_supportCardRankGroup": 2, - "_rankUpItemID": 20000001, - "_supportSkillId01": 30, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 21, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 31, - "_assetID": 31, - "_nameTextID": "Snap_Name_Uika", - "_descriptionTextID": "SupportSubtitle_31", - "_diaryTextID": "SupportDiary_31", - "_characterIDs": [ - 6 - ], - "_rarity": 3, - "_cardType": 3, - "_performancePowerMax": 2000, - "_technicPowerMax": 1500, - "_visualPowerMax": 1600, - "_supportCardLevelGroup": 2, - "_supportCardRankGroup": 2, - "_rankUpItemID": 20000001, - "_supportSkillId01": 31, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 15, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 32, - "_assetID": 32, - "_nameTextID": "Snap_Name_Mutsumi", - "_descriptionTextID": "SupportSubtitle_32", - "_diaryTextID": "SupportDiary_32", - "_characterIDs": [ - 7 - ], - "_rarity": 3, - "_cardType": 1, - "_performancePowerMax": 1600, - "_technicPowerMax": 1500, - "_visualPowerMax": 2000, - "_supportCardLevelGroup": 2, - "_supportCardRankGroup": 2, - "_rankUpItemID": 20000001, - "_supportSkillId01": 31, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 26, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 33, - "_assetID": 33, - "_nameTextID": "Snap_Name_Umiri", - "_descriptionTextID": "SupportSubtitle_33", - "_diaryTextID": "SupportDiary_33", - "_characterIDs": [ - 8 - ], - "_rarity": 3, - "_cardType": 2, - "_performancePowerMax": 1700, - "_technicPowerMax": 1700, - "_visualPowerMax": 1700, - "_supportCardLevelGroup": 2, - "_supportCardRankGroup": 2, - "_rankUpItemID": 20000001, - "_supportSkillId01": 29, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 20, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 34, - "_assetID": 34, - "_nameTextID": "Snap_Name_Nyamu", - "_descriptionTextID": "SupportSubtitle_34", - "_diaryTextID": "SupportDiary_34", - "_characterIDs": [ - 9 - ], - "_rarity": 3, - "_cardType": 5, - "_performancePowerMax": 1400, - "_technicPowerMax": 1800, - "_visualPowerMax": 1900, - "_supportCardLevelGroup": 2, - "_supportCardRankGroup": 2, - "_rankUpItemID": 20000001, - "_supportSkillId01": 26, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 12, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 35, - "_assetID": 35, - "_nameTextID": "Snap_Name_Sakiko", - "_descriptionTextID": "SupportSubtitle_35", - "_diaryTextID": "SupportDiary_35", - "_characterIDs": [ - 10 - ], - "_rarity": 3, - "_cardType": 4, - "_performancePowerMax": 1600, - "_technicPowerMax": 1900, - "_visualPowerMax": 1600, - "_supportCardLevelGroup": 2, - "_supportCardRankGroup": 2, - "_rankUpItemID": 20000001, - "_supportSkillId01": 26, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 19, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 51, - "_assetID": 51, - "_nameTextID": "Snap_Name_mygo", - "_descriptionTextID": "SupportSubtitle_51", - "_diaryTextID": "SupportDiary_51", - "_characterIDs": [ - 1, - 2, - 3, - 4, - 5 - ], - "_rarity": 4, - "_cardType": 4, - "_performancePowerMax": 4000, - "_technicPowerMax": 3100, - "_visualPowerMax": 3100, - "_supportCardLevelGroup": 3, - "_supportCardRankGroup": 3, - "_rankUpItemID": 20000001, - "_supportSkillId01": 28, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 14, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - }, - { - "_id": 53, - "_assetID": 53, - "_nameTextID": "Snap_Name_mujica", - "_descriptionTextID": "SupportSubtitle_53", - "_diaryTextID": "SupportDiary_53", - "_characterIDs": [ - 6, - 7, - 8, - 9, - 10 - ], - "_rarity": 4, - "_cardType": 3, - "_performancePowerMax": 3000, - "_technicPowerMax": 4000, - "_visualPowerMax": 3200, - "_supportCardLevelGroup": 3, - "_supportCardRankGroup": 3, - "_rankUpItemID": 20000001, - "_supportSkillId01": 32, - "_supportSkillId02": 0, - "_gekisouSupportSkillId01": 16, - "_gekisouSupportSkillId02": 0, - "_startAt": "2024-08-01 14:00:00" - } - ] -} \ No newline at end of file diff --git a/tests/MyNotes.Tests/AssemblyInfo.cs b/tests/MyNotes.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..2171200 --- /dev/null +++ b/tests/MyNotes.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/MyNotes.Tests/ClientStateTests.cs b/tests/MyNotes.Tests/ClientStateTests.cs new file mode 100644 index 0000000..97d2b88 --- /dev/null +++ b/tests/MyNotes.Tests/ClientStateTests.cs @@ -0,0 +1,80 @@ +using App.Protobuf.Home; +using App.Protobuf.Player; +using Google.Protobuf; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using MyNotes.Config; +using MyNotes.Models; +using MyNotes.Services; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class ClientStateTests +{ + [Fact] + public void PlayerDataContainsInitializedClientStateAndStableAccountId() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + ServerConfig.Load(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { ["Game:RequireBasicAuth"] = "false" }).Build()); + var release = fixture.CreateRelease(); + var master = new MasterDataService(fixture.Environment, NullLogger.Instance, release); + var players = new PlayerManager(NullLogger.Instance); + var player = players.Register("Default"); + var protocol = new PlayerProtocolBuilder(master); + var response = protocol.GetPlayerData(player, players); + + foreach (var candidate in new[] { response, response.Clone(), GetPlayerDataResponse.Parser.ParseFrom(response.ToByteArray()) }) + { + var fields = ReadFields(candidate.PlayerData!); + foreach (var field in new[] { 49, 50, 53, 60 }) + Assert.Equal(ByteString.Empty, fields[field]); + Assert.Equal(player.ProfileId, ReadFields(candidate)[6]); + Assert.NotNull(candidate.PlayerData!.MyProfile); + Assert.Equal(player.ProfileId, candidate.PlayerData.MyProfile.ProfileId); + } + } + + [Fact] + public void HomeContainsInitializedPersonalNotifications() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + var master = new MasterDataService(fixture.Environment, NullLogger.Instance, fixture.CreateRelease()); + var players = new PlayerManager(NullLogger.Instance); + var player = players.Register("Default"); + var protocol = new HomeProtocolBuilder(players, new PlayerProtocolBuilder(master)); + var response = protocol.Get(new HomeSnapshot { HasNotification = true, HasFriends = true }, player); + + foreach (var candidate in new[] { response, response.Clone(), GetHomeResponse.Parser.ParseFrom(response.ToByteArray()) }) + { + Assert.Equal(ByteString.Empty, ReadFields(candidate)[10]); + Assert.NotNull(candidate.Notification); + Assert.NotNull(candidate.Friends); + } + } + + private static Dictionary ReadFields(IMessage message) + { + using var input = new CodedInputStream(message.ToByteArray()); + var fields = new Dictionary(); + uint tag; + while ((tag = input.ReadTag()) != 0) + { + var number = WireFormat.GetTagFieldNumber(tag); + switch (WireFormat.GetTagWireType(tag)) + { + case WireFormat.WireType.LengthDelimited: + fields[number] = input.ReadBytes(); + break; + case WireFormat.WireType.Varint: + fields[number] = input.ReadInt64(); + break; + default: + input.SkipLastField(); + break; + } + } + return fields; + } +} diff --git a/tests/MyNotes.Tests/FeatureSwitchTests.cs b/tests/MyNotes.Tests/FeatureSwitchTests.cs new file mode 100644 index 0000000..bcb0aa1 --- /dev/null +++ b/tests/MyNotes.Tests/FeatureSwitchTests.cs @@ -0,0 +1,49 @@ +using Google.Protobuf; +using Microsoft.Extensions.Configuration; +using MyNotes.Services; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class FeatureSwitchTests +{ + [Fact] + public void EmptyConfigurationReturnsEmptyMessage() => + Assert.Empty(new FeatureSwitchService(new ConfigurationBuilder().Build()).Get()); + + [Fact] + public void ConfigurationEncodesBooleanMap() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["FeatureSwitches:exampleEnabled"] = "true", + ["FeatureSwitches:optionalEnabled"] = "false" + }).Build(); + using var message = new CodedInputStream(new FeatureSwitchService(config).Get()); + var switches = new Dictionary(); + while (!message.IsAtEnd) + { + Assert.Equal(10u, message.ReadTag()); + using var entry = new CodedInputStream(message.ReadBytes().ToByteArray()); + Assert.Equal(10u, entry.ReadTag()); + var key = entry.ReadString(); + Assert.Equal(16u, entry.ReadTag()); + switches.Add(key, entry.ReadBool()); + Assert.True(entry.IsAtEnd); + } + Assert.Equal(2, switches.Count); + Assert.True(switches["exampleEnabled"]); + Assert.False(switches["optionalEnabled"]); + } + + [Theory] + [InlineData("invalid")] + [InlineData("")] + [InlineData(null)] + public void InvalidConfigurationIsRejected(string? value) + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { ["FeatureSwitches:exampleEnabled"] = value }).Build(); + Assert.Throws(() => new FeatureSwitchService(config)); + } +} diff --git a/tests/MyNotes.Tests/FreeLiveTests.cs b/tests/MyNotes.Tests/FreeLiveTests.cs new file mode 100644 index 0000000..d6abeb0 --- /dev/null +++ b/tests/MyNotes.Tests/FreeLiveTests.cs @@ -0,0 +1,132 @@ +using App.Protobuf.Entity; +using App.Protobuf.Live; +using Google.Protobuf; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using MyNotes.Config; +using MyNotes.Middleware; +using MyNotes.Models; +using MyNotes.Services; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class FreeLiveTests +{ + private readonly MasterDataService _master; + private readonly PlayerManager _players = new(NullLogger.Instance); + private readonly FreeLiveService _live; + private readonly PlayerRecord _player; + + public FreeLiveTests() + { + var environment = new EmptyEnvironment(); + ServerConfig.Load(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { ["Game:DataDirectory"] = "empty", ["Game:RequireBasicAuth"] = "false" }).Build()); + _master = new MasterDataService(environment, NullLogger.Instance, new ResourceReleaseService(environment)); + _live = new FreeLiveService(_master); + _player = _players.Register("Default"); + } + + private StartFreeRequest StartRequest() => new() + { + MusicId = _master.LiveScoreSeeds[0].MusicId, + Difficulty = _master.LiveScoreSeeds[0].Difficulty, + DeckId = _master.GetInitialPlayerData("Default").Decks[0].Id + }; + + [Fact] + public void CompletedResultsAreReturnedWithPlayerData() + { + var start = StartRequest(); + _live.Start(_player, start); + var response = _live.Finish(_player, new FinishFreeRequest { Score = 300, Combo = 8, ClearStatus = 4 }); + Assert.NotNull(response.Notification); + Assert.NotEmpty(response.ToByteArray()); + Assert.NotNull(FinishFreeResponse.Parser.ParseFrom(response.ToByteArray()).Notification); + var scores = new PlayerProtocolBuilder(_master).GetPlayerData(_player, _players).PlayerData!.LiveScore; + var saved = Assert.Single(scores, score => score.MusicId == start.MusicId && score.Difficulty == start.Difficulty); + Assert.Equal(300, saved.HighScore); + Assert.Equal(8, saved.MaxCombo); + Assert.Equal(4, saved.ClearStatus); + Assert.Equal(1, saved.Mode); + } + + [Fact] + public void BestScoreComboAndClearStateAreTrackedIndependently() + { + var start = StartRequest(); + _live.Start(_player, start); + _live.Finish(_player, new FinishFreeRequest { Score = 500, Combo = 8, ClearStatus = 2 }); + _live.Start(_player, start); + _live.Finish(_player, new FinishFreeRequest { LiveFinish = new LiveFinish { SoloScore = 200, Combo = 10, ClearStatus = 5 } }); + var saved = new PlayerProtocolBuilder(_master).GetPlayerData(_player, _players).PlayerData!.LiveScore + .Single(score => score.MusicId == start.MusicId && score.Difficulty == start.Difficulty); + Assert.Equal(500, saved.HighScore); + Assert.Equal(10, saved.MaxCombo); + Assert.Equal(5, saved.ClearStatus); + } + + [Fact] + public void FinishRequiresAStartAndCanBeConsumedOnce() + { + var request = new FinishFreeRequest { ClearStatus = 1 }; + Assert.Equal("9", Assert.Throws(() => _live.Finish(_player, request)).Status); + _live.Start(_player, StartRequest()); + _live.Finish(_player, request); + Assert.Equal("9", Assert.Throws(() => _live.Finish(_player, request)).Status); + } + + [Fact] + public void ResultsAreIsolatedBetweenPlayersAndDifficulties() + { + var start = StartRequest(); + _live.Start(_player, start); + var other = _players.Register("Default"); + Assert.Equal("9", Assert.Throws(() => _live.Finish(other, new FinishFreeRequest { ClearStatus = 1 })).Status); + _live.Finish(_player, new FinishFreeRequest { Score = 7, ClearStatus = 2 }); + Assert.All(new PlayerProtocolBuilder(_master).GetPlayerData(other, _players).PlayerData!.LiveScore, + score => Assert.Equal(0, score.HighScore)); + } + + [Theory] + [InlineData(-1, 0, 1)] + [InlineData(0, -1, 1)] + [InlineData(0, 0, 0)] + [InlineData(0, 0, 6)] + public void InvalidResultsPreserveTheActiveLive(int score, int combo, int status) + { + _live.Start(_player, StartRequest()); + Assert.Equal("3", Assert.Throws(() => _live.Finish(_player, + new FinishFreeRequest { Score = score, Combo = combo, ClearStatus = status })).Status); + _live.Finish(_player, new FinishFreeRequest { ClearStatus = 1 }); + } + + [Fact] + public void ConflictingNestedResultIsRejected() + { + _live.Start(_player, StartRequest()); + Assert.Equal("3", Assert.Throws(() => _live.Finish(_player, + new FinishFreeRequest { Score = 5, LiveFinish = new LiveFinish { SoloScore = 6, ClearStatus = 2 } })).Status); + } + + [Theory] + [InlineData(0, 1, 1, 0)] + [InlineData(1, 0, 1, 0)] + [InlineData(1, 1, 0, 0)] + public void UnknownSongDifficultyOrDeckIsRejected(long music, int difficulty, int deck, int boost) + { + Assert.Equal("3", Assert.Throws(() => _live.Start(_player, + new StartFreeRequest { MusicId = music, Difficulty = difficulty, DeckId = deck, Boost = boost })).Status); + } + + private sealed class EmptyEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Testing"; + public string ApplicationName { get; set; } = "MyNotes"; + public string ContentRootPath { get; set; } = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/tests/MyNotes.Tests/GrpcTests.cs b/tests/MyNotes.Tests/GrpcTests.cs new file mode 100644 index 0000000..d99a1d5 --- /dev/null +++ b/tests/MyNotes.Tests/GrpcTests.cs @@ -0,0 +1,456 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Http.Headers; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using MyNotes.Config; +using MyNotes.Middleware; +using MyNotes.Services; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class GrpcTests +{ + public GrpcTests() => ConfigureAuthentication(false); + + [Theory] + [InlineData("application/grpc")] + [InlineData("application/grpc+proto")] + [InlineData("APPLICATION/GRPC")] + [InlineData("application/grpc+proto; charset=utf-8")] + [InlineData("application/grpc; charset=\"utf-8\"")] + public void RecognizesGrpcContentTypes(string contentType) + { + var context = new DefaultHttpContext(); + context.Request.ContentType = contentType; + + Assert.True(GrpcFraming.IsGrpcRequest(context.Request)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("application/grpc-web")] + [InlineData("application/grpc+json")] + [InlineData("application/grpc-extra")] + [InlineData("application/grpc/extra")] + [InlineData("application/json")] + [InlineData("application/grpc; invalid")] + [InlineData("application/grpc; invalid=")] + [InlineData("application/grpc; charset=utf-8; invalid")] + [InlineData("application/grpc; charset=\"utf-8")] + public void RejectsOtherContentTypes(string? contentType) + { + var context = new DefaultHttpContext(); + context.Request.ContentType = contentType; + + Assert.False(GrpcFraming.IsGrpcRequest(context.Request)); + } + + public static IEnumerable MalformedFrames() + { + yield return [Array.Empty()]; + yield return [new byte[] { 0 }]; + yield return [new byte[] { 0, 0, 0, 0 }]; + yield return [new byte[] { 2, 0, 0, 0, 0 }]; + yield return [new byte[] { 255, 0, 0, 0, 0 }]; + yield return [new byte[] { 0, 0, 0, 0, 1 }]; + yield return [new byte[] { 0, 0, 0, 0, 2, 1 }]; + yield return [new byte[] { 0, 0, 0, 0, 0, 0 }]; + yield return [new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }]; + } + + [Theory] + [MemberData(nameof(MalformedFrames))] + public async Task RejectsMalformedUnaryFrames(byte[] body) + { + var request = RequestWithBody(body); + + var exception = await Assert.ThrowsAsync( + () => GrpcFraming.ReadRequestAsync(request, CancellationToken.None)); + + Assert.Equal("3", exception.Status); + } + + [Fact] + public async Task ReadsAnEmptyProtobufMessageFromACompleteFrame() + { + var frame = await GrpcFraming.ReadRequestAsync(RequestWithBody(new byte[5]), CancellationToken.None); + + Assert.False(frame.Compressed); + Assert.True(frame.Payload.IsEmpty); + } + + [Fact] + public async Task ReadsAFrameAcrossPartialStreamReads() + { + var request = new DefaultHttpContext().Request; + request.Body = new PartialReadStream(Frame([1, 2, 3])); + + var frame = await GrpcFraming.ReadRequestAsync(request, CancellationToken.None); + + Assert.Equal(new byte[] { 1, 2, 3 }, frame.Payload.ToArray()); + } + + [Theory] + [InlineData(4)] + [InlineData(6)] + public async Task RequiresContentLengthToMatchTheFrame(long contentLength) + { + var request = RequestWithBody(new byte[5]); + request.ContentLength = contentLength; + + var exception = await Assert.ThrowsAsync( + () => GrpcFraming.ReadRequestAsync(request, CancellationToken.None)); + + Assert.Equal("3", exception.Status); + } + + [Fact] + public async Task RejectsOversizedDeclaredMessagesBeforeReadingPayload() + { + var prefix = new byte[5]; + BinaryPrimitives.WriteUInt32BigEndian(prefix.AsSpan(1), (uint)GrpcFraming.MaxMessageLength + 1); + var request = RequestWithBody(prefix); + + var exception = await Assert.ThrowsAsync( + () => GrpcFraming.ReadRequestAsync(request, CancellationToken.None)); + + Assert.Equal("8", exception.Status); + Assert.Equal(5, request.Body.Position); + } + + [Fact] + public async Task RejectsOversizedContentLengthBeforeReadingTheBody() + { + var request = RequestWithBody(new byte[5]); + request.ContentLength = GrpcFraming.MaxMessageLength + 6L; + + var exception = await Assert.ThrowsAsync( + () => GrpcFraming.ReadRequestAsync(request, CancellationToken.None)); + + Assert.Equal("8", exception.Status); + Assert.Equal(0, request.Body.Position); + } + + [Fact] + public async Task AcceptsTheMessageSizeLimit() + { + var body = new byte[GrpcFraming.MaxMessageLength + 5]; + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(1), GrpcFraming.MaxMessageLength); + var request = RequestWithBody(body); + request.ContentLength = body.Length; + + var frame = await GrpcFraming.ReadRequestAsync(request, CancellationToken.None); + + Assert.Equal(GrpcFraming.MaxMessageLength, frame.Payload.Length); + } + + [Theory] + [MemberData(nameof(MalformedFrames))] + public async Task EndpointMapsMalformedFramesToInvalidArgument(byte[] body) + { + using var host = await StartHostAsync(); + using var response = await SendAsync(host, "/echo", body); + + AssertGrpcStatus(response, "3"); + } + + [Fact] + public async Task EndpointMapsMalformedProtobufToInvalidArgument() + { + using var host = await StartHostAsync(); + using var response = await SendAsync(host, "/echo", Frame([0])); + + AssertGrpcStatus(response, "3"); + } + + [Fact] + public async Task EndpointMapsOversizedMessagesToResourceExhausted() + { + var prefix = new byte[5]; + BinaryPrimitives.WriteUInt32BigEndian(prefix.AsSpan(1), (uint)GrpcFraming.MaxMessageLength + 1); + using var host = await StartHostAsync(); + using var response = await SendAsync(host, "/echo", prefix); + + AssertGrpcStatus(response, "8"); + } + + [Fact] + public async Task EndpointMapsCompressedMessagesToUnimplemented() + { + using var host = await StartHostAsync(); + using var response = await SendAsync(host, "/echo", Frame([], compressed: true)); + + AssertGrpcStatus(response, "12"); + } + + [Theory] + [InlineData("application/json")] + [InlineData("application/grpc-web")] + public async Task EndpointRequiresAGrpcContentType(string contentType) + { + using var host = await StartHostAsync(); + using var response = await SendAsync(host, "/echo", Frame([]), contentType: contentType); + + AssertGrpcStatus(response, "3"); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public async Task EndpointRejectsHttp1BeforeExecutingHandler(int minorVersion) + { + var handlerCalls = 0; + using var host = await StartHostAsync(echoHandlerExecuted: () => Interlocked.Increment(ref handlerCalls)); + using var response = await SendAsync(host, "/echo", Frame([]), version: new Version(1, minorVersion)); + + AssertGrpcStatus(response, "12"); + Assert.Equal(0, handlerCalls); + } + + [Theory] + [InlineData(400, "3", 0)] + [InlineData(413, "8", 0)] + [InlineData(400, "3", 5)] + [InlineData(413, "8", 5)] + [InlineData(400, "3", 7)] + [InlineData(413, "8", 7)] + public async Task EndpointMapsRequestBodyFailuresBeforeExecutingHandler(int statusCode, string grpcStatus, int failurePosition) + { + var handlerCalls = 0; + var body = Frame([8, 1]); + using var host = await StartHostAsync( + echoHandlerExecuted: () => Interlocked.Increment(ref handlerCalls), + requestBody: () => new BadRequestReadStream(body, statusCode, failurePosition)); + using var response = await SendAsync(host, "/echo", body); + + AssertGrpcStatus(response, grpcStatus); + Assert.Equal(0, handlerCalls); + } + + [Fact] + public async Task EndpointWritesOneSuccessFrame() + { + using var host = await StartHostAsync(); + using var response = await SendAsync(host, "/echo", Frame([]), contentType: "application/grpc+proto"); + + AssertGrpcStatus(response, "0"); + Assert.Equal(new byte[5], await response.Content.ReadAsByteArrayAsync()); + } + + [Fact] + public async Task EndpointPreservesTypedHandlerStatus() + { + using var host = await StartHostAsync(); + using var response = await SendAsync(host, "/status", Frame([])); + + AssertGrpcStatus(response, "9"); + } + + [Fact] + public async Task PublicEndpointAcceptsRequestsWithoutAuthorization() + { + using var host = await StartHostAsync(); + using var response = await SendAsync(host, "/echo", Frame([])); + + AssertGrpcStatus(response, "0"); + } + + [Fact] + public async Task BasicAuthenticationRequirementRejectsAMissingHeader() + { + ConfigureAuthentication(true); + using var host = await StartHostAsync(); + using var response = await SendAsync(host, "/echo", Frame([])); + + AssertGrpcStatus(response, "16"); + } + + [Fact] + public async Task BasicAuthenticationRequirementAcceptsTheConfiguredCredential() + { + ConfigureAuthentication(true); + using var host = await StartHostAsync(); + using var response = await SendAsync(host, "/echo", Frame([]), ServerConfig.ExpectedBasicAuth); + + AssertGrpcStatus(response, "0"); + } + + [Fact] + public async Task BasicAuthenticationRequirementRejectsAPlayerCredential() + { + ConfigureAuthentication(true); + var players = NewPlayers(); + var player = players.Register("test"); + using var host = await StartHostAsync(players); + using var response = await SendAsync(host, "/echo", Frame([]), "Bearer " + player.AuthorizationKey); + + AssertGrpcStatus(response, "16"); + } + + [Fact] + public async Task PlayerEndpointRequiresAnAuthenticatedIdentity() + { + using var host = await StartHostAsync(); + using var response = await SendAsync(host, "/identity", Frame([])); + + AssertGrpcStatus(response, "16"); + } + + [Fact] + public async Task InvalidAuthorizationCannotSelectAPlayerByHeader() + { + var players = NewPlayers(); + var player = players.Register("test"); + using var host = await StartHostAsync(players); + using var response = await SendAsync(host, "/identity", Frame([]), "Bearer invalid", playerId: player.PlayerId); + + AssertGrpcStatus(response, "16"); + } + + [Fact] + public async Task PlayerEndpointAcceptsARegisteredBearerCredential() + { + var players = NewPlayers(); + var player = players.Register("test"); + using var host = await StartHostAsync(players); + using var response = await SendAsync(host, "/identity", Frame([]), "Bearer " + player.AuthorizationKey); + + AssertGrpcStatus(response, "0"); + } + + [Fact] + public async Task ConfiguredBasicAuthenticationCanSelectAnExplicitPlayer() + { + var players = NewPlayers(); + var player = players.Register("test"); + using var host = await StartHostAsync(players); + using var response = await SendAsync(host, "/identity", Frame([]), ServerConfig.ExpectedBasicAuth, playerId: player.PlayerId); + + AssertGrpcStatus(response, "0"); + } + + private static void ConfigureAuthentication(bool required) => ServerConfig.Load( + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Game:BasicAuthUser"] = "test-user", + ["Game:BasicAuthPass"] = "test-password", + ["Game:RequireBasicAuth"] = required.ToString(), + ["Game:InitialDataGroup"] = "test" + }).Build()); + + private static PlayerManager NewPlayers() => new(NullLogger.Instance); + + private static async Task StartHostAsync( + PlayerManager? players = null, + Action? echoHandlerExecuted = null, + Func? requestBody = null) => + await new HostBuilder().ConfigureWebHost(builder => builder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddSingleton(players ?? NewPlayers()); + }) + .Configure(app => + { + if (requestBody != null) + app.Use(async (context, next) => + { + using var body = requestBody(); + context.Request.Body = body; + await next(context); + }); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapGrpcUnary("/echo", Empty.Parser, (_, request) => + { + echoHandlerExecuted?.Invoke(); + return Task.FromResult(request); + }); + endpoints.MapGrpcUnary("/status", static (_, _) => + throw new GrpcStatusException("9", "precondition failed")); + endpoints.MapGrpcUnary("/identity", Empty.Parser, (context, request) => + { + context.RequestServices.GetRequiredService().GetFromRequest(context.Request); + return Task.FromResult(request); + }); + }); + })).StartAsync(); + + private static async Task SendAsync( + IHost host, + string path, + byte[] body, + string? authorization = null, + string contentType = GrpcFraming.ContentType, + string? playerId = null, + Version? version = null) + { + using var client = host.GetTestClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, path) + { + Content = new ByteArrayContent(body), + Version = version ?? HttpVersion.Version20, + VersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + if (authorization != null) + request.Headers.TryAddWithoutValidation("authorization", authorization); + if (playerId != null) + request.Headers.TryAddWithoutValidation("x-player-id", playerId); + return await client.SendAsync(request); + } + + private static void AssertGrpcStatus(HttpResponseMessage response, string expected) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(GrpcFraming.ContentType, response.Content.Headers.ContentType?.MediaType); + Assert.True(response.Headers.TryGetValues("grpc-status", out var values) || + response.TrailingHeaders.TryGetValues("grpc-status", out values)); + Assert.Equal(expected, Assert.Single(values!)); + } + + private static HttpRequest RequestWithBody(byte[] body) + { + var request = new DefaultHttpContext().Request; + request.Body = new MemoryStream(body, writable: false); + return request; + } + + private static byte[] Frame(byte[] payload, bool compressed = false) + { + var body = new byte[payload.Length + 5]; + body[0] = compressed ? (byte)1 : (byte)0; + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(1), (uint)payload.Length); + payload.CopyTo(body, 5); + return body; + } + + private sealed class PartialReadStream(byte[] body) : MemoryStream(body, writable: false) + { + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + base.ReadAsync(buffer[..Math.Min(buffer.Length, 1)], cancellationToken); + } + + private sealed class BadRequestReadStream(byte[] body, int statusCode, int failurePosition) : MemoryStream(body, writable: false) + { + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (Position >= failurePosition) + return ValueTask.FromException(new BadHttpRequestException("request body could not be read", statusCode)); + + return base.ReadAsync(buffer[..(int)Math.Min(buffer.Length, failurePosition - Position)], cancellationToken); + } + } +} diff --git a/tests/MyNotes.Tests/IdentityTests.cs b/tests/MyNotes.Tests/IdentityTests.cs new file mode 100644 index 0000000..1fa971d --- /dev/null +++ b/tests/MyNotes.Tests/IdentityTests.cs @@ -0,0 +1,268 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MyNotes.Config; +using MyNotes.Middleware; +using MyNotes.Services; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class IdentityTests +{ + private readonly PlayerManager _players = new(NullLogger.Instance); + + public IdentityTests() => ConfigureAuthentication(); + + [Fact] + public void MissingCredentialsRequireAuthentication() + { + var request = new DefaultHttpContext().Request; + + Assert.False(_players.IsKnownCredential(request)); + Assert.Throws(() => _players.GetFromRequest(request)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("Bearer invalid")] + [InlineData("Basic invalid")] + [InlineData("unknown")] + public void InvalidCredentialsRequireAuthentication(string authorization) + { + var request = Request(authorization); + + Assert.False(_players.IsKnownCredential(request)); + Assert.Throws(() => _players.GetFromRequest(request)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RegisteredCredentialsResolveThePlayer(bool bearer) + { + var player = _players.Register("test"); + var request = Request((bearer ? "Bearer " : "") + player.AuthorizationKey); + + Assert.True(_players.IsKnownCredential(request)); + Assert.Same(player, _players.GetFromRequest(request)); + } + + [Fact] + public void BearerSchemeIsCaseInsensitive() + { + var player = _players.Register("test"); + + Assert.Same(player, _players.GetFromRequest(Request("bearer " + player.AuthorizationKey))); + } + + [Fact] + public void PlayerCredentialHeadersResolveMatchingPlayer() + { + var player = _players.Register("test"); + var request = new DefaultHttpContext().Request; + request.Headers["x-player-id"] = player.PlayerId; + request.Headers["x-player-credential"] = player.AuthorizationKey; + + Assert.Same(player, _players.GetFromRequest(request)); + request.Headers.Authorization = ServerConfig.ExpectedBasicAuth; + Assert.Same(player, _players.GetFromRequest(request)); + request.Headers.Authorization = "Bearer " + player.AuthorizationKey; + Assert.Same(player, _players.GetFromRequest(request)); + _players.Remove(player); + Assert.False(_players.IsKnownCredential(request)); + } + + [Theory] + [InlineData("missing-id")] + [InlineData("wrong-id")] + [InlineData("wrong-credential")] + [InlineData("empty-credential")] + [InlineData("duplicate-id")] + [InlineData("duplicate-credential")] + [InlineData("conflicting-authorization")] + [InlineData("duplicate-authorization")] + public void InvalidPlayerCredentialHeadersRequireAuthentication(string scenario) + { + var player = _players.Register("test"); + var other = _players.Register("test"); + var request = new DefaultHttpContext().Request; + request.Headers["x-player-id"] = player.PlayerId; + request.Headers["x-player-credential"] = player.AuthorizationKey; + switch (scenario) + { + case "missing-id": request.Headers.Remove("x-player-id"); break; + case "wrong-id": request.Headers["x-player-id"] = other.PlayerId; break; + case "wrong-credential": request.Headers["x-player-credential"] = "invalid"; break; + case "empty-credential": request.Headers["x-player-credential"] = ""; break; + case "duplicate-id": request.Headers.Append("x-player-id", player.PlayerId); break; + case "duplicate-credential": request.Headers.Append("x-player-credential", player.AuthorizationKey); break; + case "conflicting-authorization": request.Headers.Authorization = "Bearer " + other.AuthorizationKey; break; + case "duplicate-authorization": + request.Headers.Append("authorization", player.AuthorizationKey); + request.Headers.Append("authorization", player.AuthorizationKey); + break; + } + + Assert.False(_players.IsKnownCredential(request)); + Assert.Throws(() => _players.GetFromRequest(request)); + } + + [Fact] + public void RegisteredCredentialSelectsItsOwnPlayer() + { + var first = _players.Register("test"); + var second = _players.Register("test"); + var request = Request("Bearer " + first.AuthorizationKey, second.PlayerId); + + Assert.Same(first, _players.GetFromRequest(request)); + } + + [Theory] + [InlineData("x-player-id")] + [InlineData("player-id")] + [InlineData("playerid")] + [InlineData("x-playerid")] + public void PlayerSelectionRequiresConfiguredBasicAuthentication(string header) + { + var player = _players.Register("test"); + var request = new DefaultHttpContext().Request; + request.Headers[header] = player.PlayerId; + + Assert.False(_players.IsKnownCredential(request)); + Assert.Throws(() => _players.GetFromRequest(request)); + + request.Headers.Authorization = ServerConfig.ExpectedBasicAuth; + + Assert.True(_players.IsKnownCredential(request)); + Assert.Same(player, _players.GetFromRequest(request)); + } + + [Theory] + [InlineData("Bearer invalid")] + [InlineData("Basic invalid")] + [InlineData("")] + public void InvalidAuthorizationCannotUsePlayerSelection(string authorization) + { + var player = _players.Register("test"); + var request = Request(authorization, player.PlayerId); + + Assert.False(_players.IsKnownCredential(request)); + Assert.Throws(() => _players.GetFromRequest(request)); + } + + [Fact] + public void BasicAuthenticationRequiresAnExplicitPlayer() + { + _players.Register("test"); + var request = Request(ServerConfig.ExpectedBasicAuth); + + Assert.False(_players.IsKnownCredential(request)); + Assert.Throws(() => _players.GetFromRequest(request)); + } + + [Fact] + public void MultipleAuthorizationValuesRequireAuthentication() + { + var player = _players.Register("test"); + var request = new DefaultHttpContext().Request; + request.Headers.Append("authorization", player.AuthorizationKey); + request.Headers.Append("authorization", ServerConfig.ExpectedBasicAuth); + request.Headers["x-player-id"] = player.PlayerId; + + Assert.False(_players.IsKnownCredential(request)); + Assert.Throws(() => _players.GetFromRequest(request)); + } + + [Fact] + public void RemovedCredentialsRequireAuthentication() + { + var player = _players.Register("test"); + var request = Request("Bearer " + player.AuthorizationKey); + _players.Remove(player); + + Assert.False(_players.IsKnownCredential(request)); + Assert.Throws(() => _players.GetFromRequest(request)); + } + + [Fact] + public void MissingCredentialsDoNotRegisterAPlayer() + { + var expectedProfileId = new PlayerManager(NullLogger.Instance).Register("test").ProfileId; + Assert.Throws(() => _players.GetFromRequest(new DefaultHttpContext().Request)); + var player = _players.Register("test"); + + Assert.Equal(expectedProfileId, player.ProfileId); + } + + [Fact] + public void BasicAuthenticationMustBeConfiguredForPlayerSelection() + { + var authorization = ServerConfig.ExpectedBasicAuth; + var player = _players.Register("test"); + ConfigureAuthentication(user: "", password: ""); + var request = Request(authorization, player.PlayerId); + + Assert.False(_players.IsKnownCredential(request)); + Assert.Throws(() => _players.GetFromRequest(request)); + } + + [Theory] + [InlineData("Basic credential", "Basic")] + [InlineData("Bearer credential", "Bearer")] + [InlineData("credential", "Other")] + [InlineData("custom-scheme credential", "Other")] + public async Task AuthorizationLoggingUsesFixedSchemeNames(string authorization, string scheme) + { + var logger = new CapturingLogger(); + var invoked = false; + var middleware = new RequestLoggingScopeMiddleware(_ => + { + invoked = true; + return Task.CompletedTask; + }, logger); + var context = new DefaultHttpContext(); + context.Request.Headers.Authorization = authorization; + + await middleware.Invoke(context); + + Assert.True(invoked); + Assert.Equal("authorization header present: " + scheme, Assert.Single(logger.Messages)); + } + + private static void ConfigureAuthentication(string user = "test-user", string password = "test-password") => + ServerConfig.Load(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Game:BasicAuthUser"] = user, + ["Game:BasicAuthPass"] = password, + ["Game:RequireBasicAuth"] = "false", + ["Game:InitialDataGroup"] = "test" + }).Build()); + + private static HttpRequest Request(string authorization, string? playerId = null) + { + var request = new DefaultHttpContext().Request; + request.Headers.Authorization = authorization; + if (playerId != null) + request.Headers["x-player-id"] = playerId; + return request; + } + + private sealed class CapturingLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => Messages.Add(formatter(state, exception)); + } +} diff --git a/tests/MyNotes.Tests/IntegrationTests.cs b/tests/MyNotes.Tests/IntegrationTests.cs new file mode 100644 index 0000000..9acb0c5 --- /dev/null +++ b/tests/MyNotes.Tests/IntegrationTests.cs @@ -0,0 +1,170 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using App.Protobuf.Entity; +using App.Protobuf.Live; +using App.Protobuf.Masterdata; +using App.Protobuf.Player; +using Google.Protobuf; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MyNotes.Config; +using MyNotes.Controllers; +using MyNotes.Middleware; +using MyNotes.Services; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class IntegrationTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task VersionDownloadRegistrationAndFreeLiveUseOneRelease(bool tls, bool playerHeaders) + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + fixture.WriteTable("MasterLiveMusic", new { _id = 11, _easyID = 73, _normalID = 0, _hardID = 0, _expertID = 0 }); + fixture.WriteTable("MasterLiveMusicScore", new { _id = 73 }); + fixture.WriteTable("MasterMemberCard", new { _id = 41 }); + fixture.WriteTable("MasterSupportCard", new { _id = 42 }); + fixture.WriteTable("MasterPlayerData", + new { _id = 1, _group = "Default", _playerDataType = 1, _parameter1 = "41" }, + new { _id = 2, _group = "Default", _playerDataType = 2, _parameter1 = "42" }, + new { _id = 3, _group = "Default", _playerDataType = 5, _parameter0 = "1", _parameter1 = "Deck", _parameter2 = "1", _parameter3 = "1" }); + var release = fixture.CreateRelease(); + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { ["Static:UpstreamEnabled"] = "false", ["Game:RequireBasicAuth"] = "false" }).Build(); + ServerConfig.Load(configuration); + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + using var key = RSA.Create(2048); + var certificateRequest = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + certificateRequest.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); + certificateRequest.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); + using var generated = certificateRequest.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddHours(1)); + using var certificate = X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null, + X509KeyStorageFlags.Exportable); + builder.WebHost.ConfigureKestrel(options => + { + options.Listen(IPAddress.Loopback, 0, listener => + { + listener.Protocols = HttpProtocols.Http2; + if (tls) listener.UseHttps(certificate); + }); + options.Listen(IPAddress.Loopback, 0, listener => + { + listener.Protocols = HttpProtocols.Http1; + if (tls) listener.UseHttps(certificate); + }); + }); + builder.Services.AddSingleton(fixture.Environment); + builder.Services.AddSingleton(release); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient("official-assets"); + await using var app = builder.Build(); + app.MapGrpcUnary("/app.masterdata.MasterdataService/Version", VersionRequest.Parser, + (_, _) => Task.FromResult(new VersionResponse { Version = release.MasterVersion, ResourceVersion = release.ResourceVersion })); + app.MapGrpcUnary("/app.player.PlayerService/Register", RegisterRequest.Parser, + (context, request) => Task.FromResult(context.RequestServices.GetRequiredService() + .Register(context.RequestServices.GetRequiredService().Register(request.InitialDataGroup)))); + app.MapGrpcUnary("/app.live.LiveService/StartFree", StartFreeRequest.Parser, + (context, request) => Task.FromResult(context.RequestServices.GetRequiredService() + .Start(context.RequestServices.GetRequiredService().GetFromRequest(context.Request), request))); + app.MapGrpcUnary("/app.live.LiveService/FinishFree", FinishFreeRequest.Parser, + (context, request) => Task.FromResult(context.RequestServices.GetRequiredService() + .Finish(context.RequestServices.GetRequiredService().GetFromRequest(context.Request), request))); + app.MapGrpcUnary("/app.player.PlayerService/GetPlayerData", GetPlayerDataRequest.Parser, + (context, _) => + { + var players = context.RequestServices.GetRequiredService(); + return Task.FromResult(context.RequestServices.GetRequiredService() + .GetPlayerData(players.GetFromRequest(context.Request), players)); + }); + app.MapStaticAssetEndpoints(); + await app.StartAsync(); + var urls = app.Urls.ToArray(); + using var grpcHandler = new HttpClientHandler(); + using var httpHandler = new HttpClientHandler(); + if (tls) + { + grpcHandler.ServerCertificateCustomValidationCallback = (_, presented, _, _) => presented?.Thumbprint == certificate.Thumbprint; + httpHandler.ServerCertificateCustomValidationCallback = (_, presented, _, _) => presented?.Thumbprint == certificate.Thumbprint; + } + using var grpc = new HttpClient(grpcHandler) { BaseAddress = new Uri(urls[0]), DefaultRequestVersion = HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact }; + using var http = new HttpClient(httpHandler) { BaseAddress = new Uri(urls[1]) }; + using var http1Body = new ByteArrayContent(new byte[5]); + http1Body.Headers.ContentType = new MediaTypeHeaderValue("application/grpc"); + using var http1Rpc = await http.PostAsync("/app.masterdata.MasterdataService/Version", http1Body); + Assert.Equal("12", http1Rpc.Headers.GetValues("grpc-status").Single()); + var version = VersionResponse.Parser.ParseFrom(await Call(grpc, "/app.masterdata.MasterdataService/Version", new VersionRequest())); + Assert.Equal(release.MasterVersion, version.Version); + Assert.Equal(release.ResourceVersion, version.ResourceVersion); + using var head = await http.SendAsync(new HttpRequestMessage(HttpMethod.Head, "/asset/Android/" + fixture.CatalogName + ".bin")); + Assert.Equal(HttpStatusCode.OK, head.StatusCode); + Assert.Equal(new FileInfo(fixture.FilePath(fixture.CatalogPath)).Length, head.Content.Headers.ContentLength); + Assert.Empty(await head.Content.ReadAsByteArrayAsync()); + using var range = new HttpRequestMessage(HttpMethod.Get, "/asset/Android/sample.bundle"); + range.Headers.Range = new RangeHeaderValue(1, 3); + using var partial = await http.SendAsync(range); + Assert.Equal(HttpStatusCode.PartialContent, partial.StatusCode); + Assert.Equal(File.ReadAllBytes(fixture.FilePath("Android/sample.bundle"))[1..4], await partial.Content.ReadAsByteArrayAsync()); + Assert.Equal(File.ReadAllBytes(fixture.FilePath(fixture.MasterManifestPath)), + await http.GetByteArrayAsync($"/master/{version.Version}/MasterManifest.json")); + Assert.Equal(HttpStatusCode.NotFound, (await http.GetAsync("/master/unknown/sample.bytes")).StatusCode); + var registration = RegisterResponse.Parser.ParseFrom(await Call(grpc, "/app.player.PlayerService/Register", + new RegisterRequest { InitialDataGroup = "Default" })); + if (playerHeaders) + { + grpc.DefaultRequestHeaders.Add("x-player-id", registration.Credential!.Id); + grpc.DefaultRequestHeaders.Add("x-player-credential", registration.Credential.Credential); + } + else + grpc.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", registration.Credential!.Credential); + await Call(grpc, "/app.live.LiveService/StartFree", new StartFreeRequest { MusicId = 11, Difficulty = 1, DeckId = 1 }); + var finish = FinishFreeResponse.Parser.ParseFrom(await Call(grpc, "/app.live.LiveService/FinishFree", + new FinishFreeRequest { Score = 55, Combo = 7, ClearStatus = 4 })); + Assert.NotNull(finish.Notification); + var player = GetPlayerDataResponse.Parser.ParseFrom(await Call(grpc, "/app.player.PlayerService/GetPlayerData", new GetPlayerDataRequest())); + var saved = Assert.Single(player.PlayerData!.LiveScore); + Assert.Equal(55, saved.HighScore); + Assert.Equal(7, saved.MaxCombo); + Assert.Equal(11, saved.MusicId); + await app.StopAsync(); + } + + private static async Task Call(HttpClient client, string path, IMessage message) + { + var payload = message.ToByteArray(); + var body = new byte[payload.Length + 5]; + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(1, 4), (uint)payload.Length); + payload.CopyTo(body, 5); + using var content = new ByteArrayContent(body); + content.Headers.ContentType = new MediaTypeHeaderValue("application/grpc"); + using var response = await client.PostAsync(path, content); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpVersion.Version20, response.Version); + var data = await response.Content.ReadAsByteArrayAsync(); + var status = response.TrailingHeaders.TryGetValues("grpc-status", out var trailer) ? trailer.Single() : + response.Headers.GetValues("grpc-status").Single(); + var grpcMessage = response.TrailingHeaders.TryGetValues("grpc-message", out var messages) ? messages.Single() : + response.Headers.TryGetValues("grpc-message", out var headers) ? headers.Single() : ""; + Assert.True(status == "0", $"{path}: grpc-status={status}, message={grpcMessage}"); + Assert.True(data.Length >= 5); + Assert.Equal(data.Length - 5, (int)BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(1, 4))); + return data[5..]; + } +} diff --git a/tests/MyNotes.Tests/MasterDataTests.cs b/tests/MyNotes.Tests/MasterDataTests.cs new file mode 100644 index 0000000..591368e --- /dev/null +++ b/tests/MyNotes.Tests/MasterDataTests.cs @@ -0,0 +1,162 @@ +using App.Protobuf.Masterdata; +using Google.Protobuf; +using MyNotes.Services; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class MasterDataTests +{ + [Fact] + public void ScoreSeedsFollowEachSongsExplicitDifficultyReferences() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + fixture.WriteTable("MasterLiveMusic", + new { _id = 17, _easyID = 0, _normalID = 84, _hardID = 0, _expertID = 121 }, + new { _id = 11, _defaultUnlock = true, _easyID = 71, _normalID = 83, _hardID = 94, _expertID = 120 }, + new { _id = 23, _defaultUnlock = false, _easyID = 333, _normalID = 0, _hardID = 0, _expertID = 0 }); + fixture.WriteTable("MasterLiveMusicScore", + new { _id = 120 }, new { _id = 94 }, new { _id = 71 }, new { _id = 83 }, + new { _id = 121 }, new { _id = 84 }, new { _id = 199 }); + + var master = fixture.CreateMasterData(); + + Assert.Equal(new long[] { 11, 17 }, master.DefaultUnlockedLiveMusicIds); + Assert.Equal(new[] + { + new LiveScoreSeed(11, 1), new LiveScoreSeed(11, 2), new LiveScoreSeed(11, 3), + new LiveScoreSeed(11, 4), new LiveScoreSeed(17, 2), new LiveScoreSeed(17, 4) + }, master.LiveScoreSeeds); + } + + [Theory] + [InlineData("_easyID")] + [InlineData("_normalID")] + [InlineData("_hardID")] + [InlineData("_expertID")] + public void UnlockedSongsMustReferenceExistingScores(string field) + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + fixture.WriteTable("MasterLiveMusic", new Dictionary + { + ["_id"] = 11, ["_defaultUnlock"] = true, [field] = 91 + }); + fixture.WriteTable("MasterLiveMusicScore", new { _id = 71 }); + + Assert.Throws(() => fixture.CreateMasterData()); + } + + [Fact] + public void EmptyConfiguredTablesProduceEmptyCollections() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + + var master = fixture.CreateMasterData(); + var initial = master.GetInitialPlayerData("Default"); + + Assert.Empty(master.DefaultUnlockedLiveMusicIds); + Assert.Empty(master.LiveScoreSeeds); + Assert.Empty(master.LiveComboRewardDifficulties); + Assert.Empty(initial.MemberCards); + Assert.Empty(initial.SupportCards); + Assert.Empty(initial.Decks); + } + + [Fact] + public void ConfiguredSongsCanHaveNoUnlockedEntries() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + fixture.WriteTable("MasterLiveMusic", new { _id = 11, _defaultUnlock = false, _easyID = 71 }); + fixture.WriteTable("MasterLiveMusicScore", new { _id = 71 }); + + var master = fixture.CreateMasterData(); + + Assert.Empty(master.DefaultUnlockedLiveMusicIds); + Assert.Empty(master.LiveScoreSeeds); + } + + [Fact] + public void ComboRewardDifficultiesAreDistinctSortedServerValues() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + fixture.WriteTable("MasterLiveMusicComboReward", + new { _id = 1, _difficulty = 3 }, new { _id = 2, _difficulty = 0 }, + new { _id = 3, _difficulty = 2 }, new { _id = 4, _difficulty = 1 }, + new { _id = 5, _difficulty = 2 }, new { _id = 6, _difficulty = -1 }, + new { _id = 7, _difficulty = 4 }); + + var master = fixture.CreateMasterData(); + + Assert.Equal(new[] { 1, 2, 3, 4 }, master.LiveComboRewardDifficulties); + } + + [Fact] + public void InitialCardsUseConfiguredRowsAndValidMasterReferences() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + fixture.WriteTable("MasterMemberCard", new { _id = 7 }, new { _id = 9 }); + fixture.WriteTable("MasterSupportCard", new { _id = 8 }); + fixture.WriteTable("MasterPlayerData", + new + { + _id = 1, _group = "Default", _playerDataType = 1, _parameter1 = "7", _parameter2 = "12", + _parameter3 = "2", _parameter4 = "3", _parameter5 = "4", _parameter6 = "5", + _parameter7 = "6", _parameter8 = "7" + }, + new { _id = 2, _group = "Default", _playerDataType = 1, _parameter1 = "99" }, + new + { + _id = 3, _group = "Default", _playerDataType = 2, _parameter1 = "8", _parameter2 = "13", + _parameter3 = "4", _parameter4 = "2" + }, + new { _id = 4, _group = "Default", _playerDataType = 2, _parameter1 = "98" }, + new + { + _id = 5, _group = "Default", _playerDataType = 5, _parameter0 = "1", _parameter1 = "Practice", + _parameter2 = "7, 0, invalid", _parameter3 = "8" + }, + new { _id = 6, _group = "CBT", _playerDataType = 1, _parameter1 = "9" }); + + var master = fixture.CreateMasterData(); + var initial = master.GetInitialPlayerData("Default"); + + Assert.Equal(new InitialMemberCardSeed(7, 12, 2, 3, 4, 5, 6, 7, 1), Assert.Single(initial.MemberCards)); + Assert.Equal(new InitialSupportCardSeed(8, 13, 4, 2), Assert.Single(initial.SupportCards)); + var deck = Assert.Single(initial.Decks); + Assert.Equal(1, deck.Id); + Assert.Equal("Practice", deck.Name); + Assert.Equal(new long[] { 7 }, deck.MemberCardIds); + Assert.Equal(new long[] { 8 }, deck.SupportCardIds); + Assert.Equal(9, Assert.Single(master.GetInitialPlayerData("cbt").MemberCards).MasterId); + } + + [Fact] + public void EmptyConfiguredCardMastersProduceNoInitialCards() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + fixture.WriteTable("MasterPlayerData", + new { _id = 1, _group = "Default", _playerDataType = 1, _parameter1 = "7" }, + new { _id = 2, _group = "Default", _playerDataType = 2, _parameter1 = "8" }); + + var initial = fixture.CreateMasterData().GetInitialPlayerData("Default"); + + Assert.Empty(initial.MemberCards); + Assert.Empty(initial.SupportCards); + Assert.Empty(initial.Decks); + } + + [Fact] + public void VersionResponseUsesTheVerifiedReleaseVersions() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + + var master = fixture.CreateMasterData(); + var response = new MasterdataProtocolBuilder(master).Version(); + var decoded = VersionResponse.Parser.ParseFrom(response.ToByteArray()); + + Assert.Equal(ResourceReleaseFixture.MasterVersion, master.Version); + Assert.Equal(ResourceReleaseFixture.ResourceVersion, master.ResourceVersion); + Assert.Equal(ResourceReleaseFixture.MasterVersion, decoded.Version); + Assert.Equal(ResourceReleaseFixture.ResourceVersion, decoded.ResourceVersion); + } +} diff --git a/tests/MyNotes.Tests/MyNotes.Tests.csproj b/tests/MyNotes.Tests/MyNotes.Tests.csproj new file mode 100644 index 0000000..f8b6a64 --- /dev/null +++ b/tests/MyNotes.Tests/MyNotes.Tests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + true + + + + + + + + all + + + + diff --git a/tests/MyNotes.Tests/PlayerLoginTests.cs b/tests/MyNotes.Tests/PlayerLoginTests.cs new file mode 100644 index 0000000..e2768f2 --- /dev/null +++ b/tests/MyNotes.Tests/PlayerLoginTests.cs @@ -0,0 +1,209 @@ +using System.Security.Cryptography; +using System.Text; +using App.Protobuf.Entity; +using Google.Protobuf; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using MyNotes.Config; +using MyNotes.Middleware; +using MyNotes.Services; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class PlayerLoginTests +{ + private const string FirstUid = "test-account-one"; + private const string FirstToken = "test-token-one"; + private const string SecondUid = "test-account-two"; + private const string SecondToken = "test-token-two"; + private readonly PlayerManager _players = new(NullLogger.Instance); + private readonly PlayerProtocolBuilder _protocol; + + public PlayerLoginTests() + { + var environment = new EmptyEnvironment(); + ServerConfig.Load(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { ["Game:DataDirectory"] = "empty", ["Game:RequireBasicAuth"] = "false" }).Build()); + var master = new MasterDataService(environment, NullLogger.Instance, new ResourceReleaseService(environment)); + _protocol = new PlayerProtocolBuilder(master); + } + + public static IEnumerable MalformedPayloads() + { + yield return [new byte[] { 0 }]; + yield return [new byte[] { 10, 2, 97 }]; + yield return [new byte[] { 18, 128 }]; + } + + [Theory] + [MemberData(nameof(MalformedPayloads))] + public void MalformedProtobufIsRejected(byte[] payload) + { + var service = CreateService(); + + Assert.Throws(() => service.PreLogin(payload)); + Assert.Throws(() => service.Login(payload)); + } + + [Theory] + [InlineData(null, null)] + [InlineData(FirstUid, null)] + [InlineData(null, FirstToken)] + [InlineData(FirstUid, "")] + [InlineData("unknown-account", FirstToken)] + [InlineData(FirstUid, "wrong-token")] + [InlineData(FirstUid, SecondToken)] + public void InvalidCredentialsReturnUnauthenticated(string? uid, string? token) + { + var service = CreateService(); + var payload = LoginPayload(uid, token); + + Assert.Equal("16", Assert.Throws(() => service.PreLogin(payload)).Status); + Assert.Equal("16", Assert.Throws(() => service.Login(payload)).Status); + } + + [Fact] + public void ConfiguredTokenDigestAuthenticatesAndPreLoginReturnsTrue() + { + var service = CreateService(); + var payload = LoginPayload(FirstUid, FirstToken); + using var input = new CodedInputStream(service.PreLogin(payload)); + + Assert.Equal(8u, input.ReadTag()); + Assert.True(input.ReadBool()); + Assert.Equal(0u, input.ReadTag()); + + var (credential, _, _) = ReadLogin(service.Login(payload)); + Assert.False(string.IsNullOrEmpty(credential.Id)); + Assert.False(string.IsNullOrEmpty(credential.Credential)); + Assert.False(string.IsNullOrEmpty(credential.DeviceId)); + var request = new DefaultHttpContext().Request; + request.Headers.Authorization = "Bearer " + credential.Credential; + Assert.Equal(credential.Id, _players.GetFromRequest(request).PlayerId); + } + + [Fact] + public void RepeatedLoginReturnsTheSamePlayerCredential() + { + var service = CreateService(); + var payload = LoginPayload(FirstUid, FirstToken); + + var (first, _, _) = ReadLogin(service.Login(payload)); + var (second, _, _) = ReadLogin(service.Login(payload)); + + Assert.Equal(first.Id, second.Id); + Assert.Equal(first.Credential, second.Credential); + Assert.Equal(first.DeviceId, second.DeviceId); + } + + [Fact] + public void DistinctAccountsHaveDistinctPlayersAndCredentials() + { + var service = CreateService(); + + var (first, _, _) = ReadLogin(service.Login(LoginPayload(FirstUid, FirstToken))); + var (second, _, _) = ReadLogin(service.Login(LoginPayload(SecondUid, SecondToken))); + + Assert.NotEqual(first.Id, second.Id); + Assert.NotEqual(first.Credential, second.Credential); + Assert.NotEqual(first.DeviceId, second.DeviceId); + } + + [Fact] + public void ServerListContainsConfiguredWireFields() + { + var values = new[] { "TestServer", "https://cdn.example.test/", "https://api.example.test/", "https://chat.example.test/", + "https://at.example.test/", "https://live.example.test/", "Test Display", "test-area", "test-age-icon" }; + var keys = new[] { "Name", "CdnRoot", "ApiServerRoot", "ChatServerRoot", "AtServerRoot", "LiveServer", "DisplayName", "AreaId", "AgeIconSpriteName" }; + var settings = AccountSettings(); + for (var index = 0; index < keys.Length; index++) + settings["Login:Server:" + keys[index]] = values[index]; + var service = CreateService(settings); + using var response = new CodedInputStream(service.ServerList()); + Assert.Equal(10u, response.ReadTag()); + using var server = new CodedInputStream(response.ReadBytes().ToByteArray()); + for (var index = 0; index < values.Length; index++) + { + Assert.Equal((uint)((index + 1) << 3 | 2), server.ReadTag()); + Assert.Equal(values[index], server.ReadString()); + } + Assert.Equal(0u, server.ReadTag()); + Assert.Equal(0u, response.ReadTag()); + + var (_, name, displayName) = ReadLogin(service.Login(LoginPayload(FirstUid, FirstToken))); + Assert.Equal(values[0], name); + Assert.Equal(values[6], displayName); + } + + [Theory] + [InlineData("")] + [InlineData("00")] + [InlineData("gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg")] + public void InvalidTokenDigestsAreRejectedAtConstruction(string digest) + { + var settings = AccountSettings(); + settings["Login:Accounts:0:AccessTokenSha256"] = digest; + + Assert.Throws(() => CreateService(settings)); + } + + [Fact] + public void DuplicateAccountUidsAreRejectedAtConstruction() + { + var settings = AccountSettings(); + settings["Login:Accounts:1:Uid"] = FirstUid; + + Assert.Throws(() => CreateService(settings)); + } + + private PlayerLoginService CreateService(Dictionary? settings = null) => + new(new ConfigurationBuilder().AddInMemoryCollection(settings ?? AccountSettings()).Build(), _players, _protocol); + + private static Dictionary AccountSettings() => new() + { + ["Login:Accounts:0:Uid"] = FirstUid, + ["Login:Accounts:0:AccessTokenSha256"] = TokenDigest(FirstToken), + ["Login:Accounts:0:InitialDataGroup"] = "Default", + ["Login:Accounts:1:Uid"] = SecondUid, + ["Login:Accounts:1:AccessTokenSha256"] = TokenDigest(SecondToken), + ["Login:Accounts:1:InitialDataGroup"] = "Default" + }; + + private static string TokenDigest(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); + + private static byte[] LoginPayload(string? uid, string? token) + { + using var stream = new MemoryStream(); + using (var output = new CodedOutputStream(stream, leaveOpen: true)) + { + if (uid != null) { output.WriteTag(1, WireFormat.WireType.LengthDelimited); output.WriteString(uid); } + if (token != null) { output.WriteTag(2, WireFormat.WireType.LengthDelimited); output.WriteString(token); } + } + return stream.ToArray(); + } + + private static (PlayerCredential Credential, string Name, string DisplayName) ReadLogin(byte[] payload) + { + using var input = new CodedInputStream(payload); + Assert.Equal(10u, input.ReadTag()); + var credential = PlayerCredential.Parser.ParseFrom(input.ReadBytes()); + Assert.Equal(18u, input.ReadTag()); + var name = input.ReadString(); + Assert.Equal(26u, input.ReadTag()); + var displayName = input.ReadString(); + Assert.Equal(0u, input.ReadTag()); + return (credential, name, displayName); + } + + private sealed class EmptyEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Testing"; + public string ApplicationName { get; set; } = "MyNotes"; + public string ContentRootPath { get; set; } = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/tests/MyNotes.Tests/ResourceBuilderTests.cs b/tests/MyNotes.Tests/ResourceBuilderTests.cs new file mode 100644 index 0000000..5961d42 --- /dev/null +++ b/tests/MyNotes.Tests/ResourceBuilderTests.cs @@ -0,0 +1,330 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using MyNotes.Services; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class ResourceBuilderTests +{ + [Fact] + public void OverlayBuildKeepsUnchangedMasterFilesAndPublishesCatalogPair() + { + using var fixture = new SyntheticSongPackageFixture(); + + var path = fixture.Build(); + var release = new ResourceReleaseService(new BuilderEnvironment(fixture.Output), path); + + Assert.Equal(Path.Combine(fixture.Output, "release.json"), path); + Assert.Equal(new byte[] { 9, 8, 7 }, File.ReadAllBytes(release.FindAndroid("new.bundle")!)); + Assert.Equal(File.ReadAllBytes(fixture.Baseline.FilePath("Android/sample.bundle")), + File.ReadAllBytes(release.FindAndroid("sample.bundle")!)); + Assert.Equal(File.ReadAllBytes(fixture.Baseline.FilePath(fixture.Baseline.MasterFilePath)), + File.ReadAllBytes(release.FindMaster(ResourceReleaseFixture.MasterVersion, "sample.bytes")!)); + Assert.Equal(new byte[] { 9, 8, 7, 6 }, + File.ReadAllBytes(release.FindMaster(ResourceReleaseFixture.MasterVersion, "overlay.bytes")!)); + Assert.Equal(new byte[] { 4, 3, 2, 1 }, File.ReadAllBytes(release.FindAndroid(fixture.Baseline.CatalogName)!)); + Assert.Equal(File.ReadAllBytes(release.FindAndroid(fixture.Baseline.CatalogName)!), + File.ReadAllBytes(release.FindAndroid(fixture.Baseline.CatalogName + ".bin")!)); + Assert.Throws(() => fixture.Build()); + } + + [Fact] + public void EmptyPackageFileListCannotPublishARelease() + { + using var fixture = new SyntheticSongPackageFixture(); + fixture.Files.Clear(); + + Assert.Throws(() => fixture.Build()); + + fixture.AssertOutputAbsent(); + } + + [Theory] + [InlineData("catalog/catalog.bin")] + [InlineData("catalog/catalog.hash")] + [InlineData("master/MasterManifest.json")] + [InlineData("master/overlay.bytes")] + [InlineData("bundles/new.bundle")] + public void EveryConsumedPackageFileMustBeDeclared(string path) + { + using var fixture = new SyntheticSongPackageFixture(); + fixture.Files.Remove(fixture.Entry(path)); + + Assert.Throws(() => fixture.Build()); + + fixture.AssertOutputAbsent(); + } + + [Theory] + [InlineData("catalog/catalog.bin")] + [InlineData("catalog/catalog.hash")] + [InlineData("master/MasterManifest.json")] + public void RequiredPackageFilesCannotBeAbsent(string path) + { + using var fixture = new SyntheticSongPackageFixture(); + fixture.Files.Remove(fixture.Entry(path)); + File.Delete(fixture.PackageFile(path)); + + Assert.Throws(() => fixture.Build()); + + fixture.AssertOutputAbsent(); + } + + [Theory] + [InlineData("baseline")] + [InlineData("package")] + [InlineData("masterJson")] + public void OutputCannotBeInsideAnInputDirectory(string input) + { + using var fixture = new SyntheticSongPackageFixture(); + var root = input switch + { + "baseline" => fixture.Baseline.Root, + "package" => fixture.Package, + _ => fixture.MasterJson + }; + var output = Path.Combine(root, "new-release"); + + Assert.Throws(() => fixture.Build(output)); + + fixture.AssertOutputAbsent(output); + } + + [Theory] + [InlineData("baseline")] + [InlineData("package")] + [InlineData("masterJson")] + public void OutputCannotReuseAnInputDirectory(string input) + { + using var fixture = new SyntheticSongPackageFixture(); + var output = input switch + { + "baseline" => fixture.Baseline.Root, + "package" => fixture.Package, + _ => fixture.MasterJson + }; + var baselineBytes = File.ReadAllBytes(fixture.Baseline.FilePath(fixture.Baseline.MasterFilePath)); + + Assert.Throws(() => fixture.Build(output)); + + Assert.Equal(baselineBytes, File.ReadAllBytes(fixture.Baseline.FilePath(fixture.Baseline.MasterFilePath))); + Assert.False(File.Exists(Path.Combine(output, "release.json"))); + } + + [Theory] + [InlineData("catalog/catalog.bin")] + [InlineData("master/overlay.bytes")] + [InlineData("bundles/new.bundle")] + public void SameLengthPackageChangesFailHashVerification(string path) + { + using var fixture = new SyntheticSongPackageFixture(); + var bytes = File.ReadAllBytes(fixture.PackageFile(path)); + var declaredSize = fixture.Entry(path)["size"]!.GetValue(); + bytes[0] ^= 0xFF; + fixture.WritePackageBytes(path, bytes, refreshEntry: false); + Assert.Equal(declaredSize, new FileInfo(fixture.PackageFile(path)).Length); + + Assert.Throws(() => fixture.Build()); + + fixture.AssertOutputAbsent(); + } + + [Fact] + public void DuplicatePackagePathsCannotPublishARelease() + { + using var fixture = new SyntheticSongPackageFixture(); + fixture.Files.Add(fixture.Entry("bundles/new.bundle").DeepClone()); + + Assert.Throws(() => fixture.Build()); + + fixture.AssertOutputAbsent(); + } + + [Fact] + public void MissingScoreReferencesFailBeforeOutputIsPublished() + { + using var fixture = new SyntheticSongPackageFixture(); + fixture.Baseline.WriteTable("MasterLiveMusic", new { _id = 11, _defaultUnlock = true, _easyID = 91 }); + fixture.Baseline.WriteTable("MasterLiveMusicScore", new { _id = 71 }); + + Assert.Throws(() => fixture.Build()); + + fixture.AssertOutputAbsent(); + } + + [Fact] + public void BundleReferencesMustHaveDeclaredPackageFiles() + { + using var fixture = new SyntheticSongPackageFixture(); + fixture.Files.Remove(fixture.Entry("bundles/new.bundle")); + File.Delete(fixture.PackageFile("bundles/new.bundle")); + + Assert.Throws(() => fixture.Build()); + + fixture.AssertOutputAbsent(); + } + + [Fact] + public void BundleFilenamesMustBeUniqueInTheOutput() + { + using var fixture = new SyntheticSongPackageFixture(); + fixture.AddBundle("other/new.bundle", [1, 2, 3]); + + Assert.Throws(() => fixture.Build()); + + fixture.AssertOutputAbsent(); + } + + [Fact] + public void DuplicateBundleReferencesCannotPublishARelease() + { + using var fixture = new SyntheticSongPackageFixture(); + fixture.Bundles.Add(fixture.Bundles[0]!.DeepClone()); + + Assert.Throws(() => fixture.Build()); + + fixture.AssertOutputAbsent(); + } + + [Fact] + public void BundleFilenamesCannotCollideWithTheCatalog() + { + using var fixture = new SyntheticSongPackageFixture(); + fixture.AddBundle($"bundles/{fixture.Baseline.CatalogName}.bin", [1, 2, 3]); + + Assert.Throws(() => fixture.Build()); + + fixture.AssertOutputAbsent(); + } + + [Fact] + public void BundleUrlMustEndWithTheBundleFilename() + { + using var fixture = new SyntheticSongPackageFixture(); + fixture.Bundles[0]!["internal_id"] = "https://example.invalid/asset/Android/other.bundle"; + + Assert.Throws(() => fixture.Build()); + + fixture.AssertOutputAbsent(); + } + + private sealed class SyntheticSongPackageFixture : IDisposable + { + private readonly string _root = Path.Combine(Path.GetTempPath(), "mynotes-tests", Guid.NewGuid().ToString("N")); + public ResourceReleaseFixture Baseline { get; } = new(includeMasterJson: true); + public string Package => Path.Combine(_root, "package"); + public string MasterJson => Baseline.FilePath("tables"); + public string Output => Path.Combine(_root, "release"); + public JsonObject Manifest { get; } + public JsonArray Files => Manifest["files"]!.AsArray(); + public JsonArray Bundles => Manifest["bundles"]!.AsArray(); + + public SyntheticSongPackageFixture() + { + Directory.CreateDirectory(Package); + var baselineOverlay = $"master/{ResourceReleaseFixture.MasterVersion}/overlay.bytes"; + Baseline.WriteBytes(baselineOverlay, [1, 1, 1, 1]); + Baseline.MasterManifest["files"]!.AsArray().Add(MasterEntry("overlay.bytes", Baseline.Entry(baselineOverlay))); + Baseline.SaveMasterManifest(); + Manifest = new JsonObject + { + ["format"] = "nnnotes.song-build/1", + ["version"] = ResourceReleaseFixture.MasterVersion, + ["baseline"] = new JsonObject + { + ["catalogSha256"] = Baseline.Entry(Baseline.CatalogPath)["sha256"]!.GetValue() + }, + ["bundles"] = new JsonArray(), + ["files"] = new JsonArray() + }; + WritePackageBytes("catalog/catalog.bin", [4, 3, 2, 1]); + WritePackageText("catalog/catalog.hash", Entry("catalog/catalog.bin")["sha256"]!.GetValue()[..32]); + WritePackageBytes("master/overlay.bytes", [9, 8, 7, 6]); + var masterManifest = new JsonObject + { + ["version"] = ResourceReleaseFixture.MasterVersion, + ["files"] = new JsonArray( + MasterEntry("sample.bytes", Baseline.Entry(Baseline.MasterFilePath)), + MasterEntry("overlay.bytes", Entry("master/overlay.bytes"))) + }; + WritePackageText("master/MasterManifest.json", masterManifest.ToJsonString()); + AddBundle("bundles/new.bundle", [9, 8, 7]); + } + + public string PackageFile(string relative) => + Path.Combine(Package, relative.Replace('/', Path.DirectorySeparatorChar)); + + public JsonObject Entry(string relative) => + Files.Single(entry => entry!["path"]!.GetValue() == relative)!.AsObject(); + + public void WritePackageBytes(string relative, byte[] bytes, bool refreshEntry = true) + { + var path = PackageFile(relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, bytes); + if (!refreshEntry) + return; + var previous = Files.SingleOrDefault(entry => entry!["path"]!.GetValue() == relative); + if (previous != null) + Files.Remove(previous); + Files.Add(new JsonObject + { + ["path"] = relative, + ["size"] = (long)bytes.Length, + ["sha256"] = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant() + }); + } + + private void WritePackageText(string relative, string text) => + WritePackageBytes(relative, Encoding.UTF8.GetBytes(text)); + + public void AddBundle(string relative, byte[] bytes) + { + WritePackageBytes(relative, bytes); + Bundles.Add(new JsonObject + { + ["path"] = relative, + ["internal_id"] = "https://example.invalid/asset/Android/" + Path.GetFileName(PackageFile(relative)) + }); + } + + public string Build(string? output = null) + { + File.WriteAllText(PackageFile("package.json"), Manifest.ToJsonString()); + return ResourceReleaseBuilder.Build(Baseline.Root, Package, MasterJson, Baseline.CatalogName, + ResourceReleaseFixture.ResourceVersion, output ?? Output); + } + + public void AssertOutputAbsent(string? output = null) + { + var directory = output ?? Output; + Assert.False(Directory.Exists(directory)); + Assert.False(File.Exists(directory)); + Assert.False(File.Exists(Path.Combine(directory, "release.json"))); + } + + public void Dispose() + { + Baseline.Dispose(); + Directory.Delete(_root, recursive: true); + } + + private static JsonObject MasterEntry(string name, JsonObject file) => new() + { + ["name"] = name, + ["size"] = file["size"]!.GetValue(), + ["hash"] = file["sha256"]!.GetValue() + }; + } + + private sealed class BuilderEnvironment(string root) : Microsoft.Extensions.Hosting.IHostEnvironment + { + public string EnvironmentName { get; set; } = "Testing"; + public string ApplicationName { get; set; } = "MyNotes"; + public string ContentRootPath { get; set; } = root; + public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = + new Microsoft.Extensions.FileProviders.NullFileProvider(); + } +} diff --git a/tests/MyNotes.Tests/ResourceReleaseTests.cs b/tests/MyNotes.Tests/ResourceReleaseTests.cs new file mode 100644 index 0000000..47ed82e --- /dev/null +++ b/tests/MyNotes.Tests/ResourceReleaseTests.cs @@ -0,0 +1,463 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using App.Protobuf.Masterdata; +using Google.Protobuf; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using MyNotes.Config; +using MyNotes.Services; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class ResourceReleaseTests +{ + [Fact] + public void VerifiedReleaseExposesItsVersionsAndRegisteredFiles() + { + using var fixture = new ResourceReleaseFixture(); + + var release = fixture.CreateRelease(); + + Assert.True(release.Enabled); + Assert.Equal(ResourceReleaseFixture.MasterVersion, release.MasterVersion); + Assert.Equal(ResourceReleaseFixture.ResourceVersion, release.ResourceVersion); + Assert.Equal(fixture.CatalogName, release.CatalogName); + Assert.Null(release.MasterJsonDirectory); + Assert.Equal(fixture.FilePath(fixture.CatalogPath), release.FindAndroid(fixture.CatalogName)); + Assert.Equal(fixture.FilePath(fixture.CatalogPath), release.FindAndroid(fixture.CatalogName + ".bin")); + Assert.Equal(fixture.FilePath(fixture.MarkerPath), release.FindAndroid(fixture.CatalogName + ".hash")); + Assert.Equal(fixture.FilePath("Android/sample.bundle"), release.FindAndroid("sample.bundle")); + Assert.Equal(fixture.FilePath(fixture.MasterManifestPath), + release.FindMaster(ResourceReleaseFixture.MasterVersion, "MasterManifest.json")); + Assert.Equal(fixture.FilePath(fixture.MasterFilePath), + release.FindMaster(ResourceReleaseFixture.MasterVersion, "sample.bytes")); + Assert.Null(release.FindMaster("other-version", "sample.bytes")); + Assert.Null(release.FindMaster(ResourceReleaseFixture.MasterVersion, "missing.bytes")); + Assert.Null(release.FindAndroid("missing.bundle")); + } + + [Fact] + public void CatalogNamesCanDifferForTheSameVersionsAndContent() + { + using var first = new ResourceReleaseFixture(catalogName: "catalog_one"); + using var second = new ResourceReleaseFixture(catalogName: "catalog_two"); + + var firstRelease = first.CreateRelease(); + var secondRelease = second.CreateRelease(); + + Assert.Equal(firstRelease.MasterVersion, secondRelease.MasterVersion); + Assert.Equal(firstRelease.ResourceVersion, secondRelease.ResourceVersion); + Assert.NotEqual(firstRelease.CatalogName, secondRelease.CatalogName); + Assert.Equal(File.ReadAllBytes(firstRelease.FindAndroid(first.CatalogName)!), + File.ReadAllBytes(secondRelease.FindAndroid(second.CatalogName)!)); + Assert.Equal(File.ReadAllText(firstRelease.FindAndroid(first.CatalogName + ".hash")!), + File.ReadAllText(secondRelease.FindAndroid(second.CatalogName + ".hash")!)); + } + + [Fact] + public void ConfiguredJsonDirectoryContainsVerifiedTables() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + + var release = fixture.CreateRelease(); + + Assert.Equal(fixture.FilePath("tables"), release.MasterJsonDirectory); + Assert.All(ResourceReleaseFixture.TableNames, name => + { + using var table = JsonDocument.Parse(File.ReadAllBytes(Path.Combine(release.MasterJsonDirectory!, name + ".json"))); + Assert.Equal(JsonValueKind.Array, table.RootElement.GetProperty("_allData").ValueKind); + }); + } + + [Fact] + public void ModifiedFileContentFailsVerification() + { + using var fixture = new ResourceReleaseFixture(); + var bytes = File.ReadAllBytes(fixture.FilePath(fixture.CatalogPath)); + bytes[0] ^= 0xFF; + fixture.WriteBytes(fixture.CatalogPath, bytes, refreshEntry: false); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Theory] + [InlineData("size")] + [InlineData("sha256")] + public void IncorrectFileMetadataFailsVerification(string property) + { + using var fixture = new ResourceReleaseFixture(); + var entry = fixture.Entry(fixture.CatalogPath); + if (property == "size") + entry[property] = entry[property]!.GetValue() + 1; + else + entry[property] = new string('0', 64); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Fact] + public void CatalogMarkerMustMatchTheCatalogHashPrefix() + { + using var fixture = new ResourceReleaseFixture(); + fixture.WriteText(fixture.MarkerPath, new string('0', 32)); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Theory] + [InlineData("name")] + [InlineData("size")] + [InlineData("hash")] + public void MasterReferencesMustMatchRegisteredFiles(string property) + { + using var fixture = new ResourceReleaseFixture(); + var entry = fixture.MasterManifest["files"]![0]!.AsObject(); + if (property == "name") + entry[property] = "missing.bytes"; + else if (property == "size") + entry[property] = entry[property]!.GetValue() + 1; + else + entry[property] = new string('0', 64); + fixture.SaveMasterManifest(); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Fact] + public void MasterManifestVersionMustMatchTheRelease() + { + using var fixture = new ResourceReleaseFixture(); + fixture.MasterManifest["version"] = "other-version"; + fixture.SaveMasterManifest(); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Fact] + public void DuplicateReleasePathsAreRejected() + { + using var fixture = new ResourceReleaseFixture(); + fixture.Files.Add(fixture.Entry(fixture.CatalogPath).DeepClone()); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Fact] + public void DuplicateMasterNamesAreRejected() + { + using var fixture = new ResourceReleaseFixture(); + var files = fixture.MasterManifest["files"]!.AsArray(); + files.Add(files[0]!.DeepClone()); + fixture.SaveMasterManifest(); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Theory] + [InlineData("catalog")] + [InlineData("marker")] + [InlineData("master")] + public void RequiredFilesMustBeRegistered(string file) + { + using var fixture = new ResourceReleaseFixture(); + var path = file switch + { + "catalog" => fixture.CatalogPath, + "marker" => fixture.MarkerPath, + _ => fixture.MasterManifestPath + }; + fixture.Files.Remove(fixture.Entry(path)); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Theory] + [InlineData("../outside.bin")] + [InlineData("Android/../outside.bin")] + [InlineData("/catalog.bin")] + [InlineData("Android//catalog.bin")] + [InlineData("Android\\catalog.bin")] + [InlineData("C:/catalog.bin")] + public void ReleasePathsMustContainSafeRelativeSegments(string path) + { + using var fixture = new ResourceReleaseFixture(); + fixture.Files.Add(new JsonObject { ["path"] = path, ["size"] = 0, ["sha256"] = new string('0', 64) }); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Theory] + [InlineData("version")] + [InlineData("resourceVersion")] + [InlineData("catalogName")] + public void ReleaseIdentitiesMustBeSafeSegments(string property) + { + using var fixture = new ResourceReleaseFixture(); + fixture.Manifest[property] = "../other"; + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Theory] + [InlineData("")] + [InlineData(".")] + [InlineData("..")] + [InlineData("CON")] + [InlineData("nul.bin")] + [InlineData("sample.")] + [InlineData("sample:part")] + public void UnsafeResourceSegmentsAreRejected(string segment) + { + Assert.False(ResourcePaths.IsSegment(segment)); + } + + [Fact] + public void UnsupportedReleaseFormatIsRejected() + { + using var fixture = new ResourceReleaseFixture(); + fixture.Manifest["format"] = "mynotes.resource-release/2"; + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Fact] + public void JsonTablesMustContainAnArrayOfRows() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + fixture.WriteText("tables/MasterLiveMusic.json", "{\"_allData\":{}}"); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [Fact] + public void JsonTablesMustBeRegisteredInTheRelease() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + fixture.Files.Remove(fixture.Entry("tables/MasterLiveMusicScore.json")); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [SymbolicLinkFact] + public void ReleaseFilesCannotBeSymbolicLinks() + { + using var fixture = new ResourceReleaseFixture(); + var source = fixture.FilePath(fixture.CatalogPath); + var target = fixture.FilePath("catalog-target.bin"); + File.Move(source, target); + File.CreateSymbolicLink(source, target); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [SymbolicLinkFact] + public void ReleaseDirectoriesCannotBeSymbolicLinks() + { + using var fixture = new ResourceReleaseFixture(); + var source = fixture.FilePath("Android"); + var target = fixture.FilePath("catalog-target"); + Directory.Move(source, target); + Directory.CreateSymbolicLink(source, target); + + Assert.Throws(() => fixture.CreateRelease()); + } + + [SymbolicLinkFact] + public void ResourceRootCannotBeASymbolicLink() + { + using var fixture = new ResourceReleaseFixture(); + var link = fixture.FilePath("linked-root"); + Directory.CreateSymbolicLink(link, fixture.Root); + try + { + Assert.Throws(() => ResourcePaths.Resolve(link, "sample.bin")); + } + finally + { + Directory.Delete(link); + } + } + + [Fact] + public void VersionResponsePreservesBothVersionsOnTheWire() + { + var response = new VersionResponse { Version = "master-v1", ResourceVersion = "resources-v2" }; + + var bytes = response.ToByteArray(); + var decoded = VersionResponse.Parser.ParseFrom(bytes); + + Assert.Equal(bytes.Length, response.CalculateSize()); + Assert.Equal(response, decoded); + Assert.Equal(response, response.Clone()); + Assert.Equal(2, VersionResponse.ResourceVersionFieldNumber); + using var input = new CodedInputStream(bytes); + Assert.Equal(10u, input.ReadTag()); + Assert.Equal("master-v1", input.ReadString()); + Assert.Equal(18u, input.ReadTag()); + Assert.Equal("resources-v2", input.ReadString()); + Assert.Equal(0u, input.ReadTag()); + } + + [Fact] + public void VersionResponseMergesNonemptyResourceVersion() + { + var response = new VersionResponse { Version = "master-v1", ResourceVersion = "resources-v1" }; + + response.MergeFrom(new VersionResponse { ResourceVersion = "resources-v2" }); + response.MergeFrom(new VersionResponse()); + + Assert.Equal("master-v1", response.Version); + Assert.Equal("resources-v2", response.ResourceVersion); + } +} + +internal sealed class ResourceReleaseFixture : IDisposable +{ + public const string MasterVersion = "master-v1"; + public const string ResourceVersion = "resources-v2"; + public static readonly string[] TableNames = + [ + "MasterPlayerData", "MasterMemberCard", "MasterSupportCard", "MasterLiveMusic", + "MasterLiveMusicScore", "MasterLiveMusicComboReward" + ]; + + private readonly Dictionary _configuration = CaptureConfiguration(); + public string Root { get; } = Path.Combine(Path.GetTempPath(), "mynotes-tests", Guid.NewGuid().ToString("N")); + public string CatalogName { get; } + public string CatalogPath => $"Android/{CatalogName}.bin"; + public string MarkerPath => $"Android/{CatalogName}.hash"; + public string MasterManifestPath => $"master/{MasterVersion}/MasterManifest.json"; + public string MasterFilePath => $"master/{MasterVersion}/sample.bytes"; + public JsonObject Manifest { get; } + public JsonObject MasterManifest { get; } + public JsonArray Files => Manifest["files"]!.AsArray(); + public IHostEnvironment Environment { get; } + + public ResourceReleaseFixture(bool includeMasterJson = false, string catalogName = "catalog_sample") + { + CatalogName = catalogName; + Directory.CreateDirectory(Root); + Environment = new TestHostEnvironment(Root); + Manifest = new JsonObject + { + ["format"] = "mynotes.resource-release/1", + ["version"] = MasterVersion, + ["resourceVersion"] = ResourceVersion, + ["catalogName"] = CatalogName, + ["files"] = new JsonArray() + }; + WriteBytes(MasterFilePath, [1, 4, 7, 10]); + var masterEntry = Entry(MasterFilePath); + MasterManifest = new JsonObject + { + ["version"] = MasterVersion, + ["files"] = new JsonArray(new JsonObject + { + ["name"] = "sample.bytes", + ["size"] = masterEntry["size"]!.GetValue(), + ["hash"] = masterEntry["sha256"]!.GetValue() + }) + }; + SaveMasterManifest(); + WriteBytes(CatalogPath, [2, 5, 8, 11, 14, 17]); + WriteText(MarkerPath, Entry(CatalogPath)["sha256"]!.GetValue()[..32]); + WriteText("Android/sample.bundle", "sample bundle content"); + if (includeMasterJson) + { + Manifest["masterJsonDirectory"] = "tables"; + foreach (var name in TableNames) + WriteTable(name, []); + } + } + + public string FilePath(string relative) => Path.Combine(Root, relative.Replace('/', Path.DirectorySeparatorChar)); + + public JsonObject Entry(string path) => Files.Single(entry => entry!["path"]!.GetValue() == path)!.AsObject(); + + public void WriteBytes(string relative, byte[] bytes, bool refreshEntry = true) + { + var path = FilePath(relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, bytes); + if (!refreshEntry) + return; + var existing = Files.SingleOrDefault(entry => entry!["path"]!.GetValue() == relative); + if (existing != null) + Files.Remove(existing); + Files.Add(new JsonObject + { + ["path"] = relative, + ["size"] = (long)bytes.Length, + ["sha256"] = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant() + }); + } + + public void WriteText(string relative, string text, bool refreshEntry = true) => + WriteBytes(relative, Encoding.UTF8.GetBytes(text), refreshEntry); + + public void WriteTable(string name, params object[] rows) => + WriteText($"tables/{name}.json", JsonSerializer.Serialize(new { _allData = rows })); + + public void SaveMasterManifest() => WriteText(MasterManifestPath, MasterManifest.ToJsonString()); + + public ResourceReleaseService CreateRelease() + { + var settings = new Dictionary(_configuration) + { + ["Game:MasterVersion"] = "configured-master", + ["Resources:ResourceVersion"] = "configured-resources", + ["Resources:ReleaseManifest"] = "release.json" + }; + ServerConfig.Load(new ConfigurationBuilder().AddInMemoryCollection(settings).Build()); + File.WriteAllText(FilePath("release.json"), Manifest.ToJsonString()); + return new ResourceReleaseService(Environment); + } + + public MasterDataService CreateMasterData() => + new(Environment, NullLogger.Instance, CreateRelease()); + + public void Dispose() + { + ServerConfig.Load(new ConfigurationBuilder().AddInMemoryCollection(_configuration).Build()); + Directory.Delete(Root, recursive: true); + } + + private static Dictionary CaptureConfiguration() + { + var settings = new Dictionary + { + ["Server:GrpcPort"] = ServerConfig.GrpcPort.ToString(), + ["Server:HttpPort"] = ServerConfig.HttpPort.ToString(), + ["Game:MasterVersion"] = ServerConfig.MasterVersion, + ["Game:InitialDataGroup"] = ServerConfig.InitialDataGroup, + ["Game:BasicAuthUser"] = ServerConfig.BasicAuthUser, + ["Game:BasicAuthPass"] = ServerConfig.BasicAuthPass, + ["Game:RequireBasicAuth"] = ServerConfig.RequireBasicAuth.ToString(), + ["Game:DataDirectory"] = ServerConfig.DataDirectory, + ["Static:OfficialAssetRootUrl"] = ServerConfig.OfficialAssetRootUrl, + ["Static:OfficialAssetRequestTimeoutSeconds"] = ServerConfig.OfficialAssetRequestTimeoutSeconds.ToString(), + ["Static:CacheEnabled"] = ServerConfig.StaticAssetCacheEnabled.ToString(), + ["Static:CacheDirectory"] = ServerConfig.StaticAssetCacheDirectory, + ["Static:LocalDirectory"] = ServerConfig.StaticAssetLocalDirectory, + ["Static:UpstreamEnabled"] = ServerConfig.StaticAssetUpstreamEnabled.ToString(), + ["Resources:ReleaseManifest"] = ServerConfig.ResourceReleaseManifest, + ["Resources:ResourceVersion"] = ServerConfig.ResourceVersion + }; + for (var index = 0; index < ServerConfig.InitialStampIds.Count; index++) + settings[$"Game:InitialStampIds:{index}"] = ServerConfig.InitialStampIds[index].ToString(); + return settings; + } + + private sealed class TestHostEnvironment(string root) : IHostEnvironment + { + public string EnvironmentName { get; set; } = Environments.Development; + public string ApplicationName { get; set; } = "MyNotes.Tests"; + public string ContentRootPath { get; set; } = root; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/tests/MyNotes.Tests/SessionStateTests.cs b/tests/MyNotes.Tests/SessionStateTests.cs new file mode 100644 index 0000000..7a2b285 --- /dev/null +++ b/tests/MyNotes.Tests/SessionStateTests.cs @@ -0,0 +1,126 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Http.Headers; +using App.Protobuf.Chat; +using App.Protobuf.Live; +using App.Protobuf.Player; +using App.Protobuf.PlayerExt; +using App.Protobuf.SeasonPass; +using Google.Protobuf; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MyNotes.Config; +using MyNotes.Middleware; +using MyNotes.Services; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class SessionStateTests +{ + [Fact] + public void SyncDataReportsAccountIdWithDefaultGemAndBoostSections() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + ServerConfig.Load(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { ["Game:RequireBasicAuth"] = "false" }).Build()); + var master = new MasterDataService(fixture.Environment, NullLogger.Instance, fixture.CreateRelease()); + var players = new PlayerManager(NullLogger.Instance); + var player = players.Register("Default"); + var protocol = new PlayerProtocolBuilder(master); + + foreach (var candidate in new[] { protocol.SyncData(player), SyncDataResponse.Parser.ParseFrom(protocol.SyncData(player).ToByteArray()) }) + { + Assert.Equal(player.ProfileId, candidate.Accountid); + Assert.NotNull(candidate.Gem); + Assert.NotNull(candidate.LiveBoost); + Assert.Equal(0, candidate.Gem.Free); + Assert.Equal(0, candidate.LiveBoost.Amount); + } + } + + [Fact] + public async Task CompanionEndpointsReplyWithDefaultState() + { + using var fixture = new ResourceReleaseFixture(includeMasterJson: true); + ServerConfig.Load(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { ["Game:RequireBasicAuth"] = "false" }).Build()); + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.ConfigureKestrel(options => options.Listen(System.Net.IPAddress.Loopback, 0, + listener => listener.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2)); + builder.Services.AddSingleton(fixture.Environment); + builder.Services.AddSingleton(fixture.CreateRelease()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + await using var app = builder.Build(); + app.MapGrpcUnary("/app.player.PlayerService/Register", RegisterRequest.Parser, + (context, request) => Task.FromResult(context.RequestServices.GetRequiredService() + .Register(context.RequestServices.GetRequiredService().Register(request.InitialDataGroup)))); + app.MapGrpcUnary("/app.playerext.PlayerExtService/SyncData", SyncDataRequest.Parser, + (context, _) => Task.FromResult(context.RequestServices.GetRequiredService() + .SyncData(context.RequestServices.GetRequiredService().GetFromRequest(context.Request)))); + app.MapGrpcUnary("/app.seasonpass.SeasonPassService/Fetch", FetchRequest.Parser, + static (_, _) => Task.FromResult(new FetchResponse())); + app.MapGrpcUnary("/app.chat.ClientChatService/SyncMsg", ClientSyncMsgRequest.Parser, + static (_, _) => Task.FromResult(new ClientSyncMsgResponse())); + app.MapGrpcUnary("/app.chat.ClientChatService/GetNotifications", GetNotificationsRequest.Parser, + static (_, _) => Task.FromResult(new GetNotificationsResponse())); + app.MapGrpcUnary("/app.live.LiveService/FetchBattleLiveInvitation", FetchBattleLiveInvitationRequest.Parser, + static (_, _) => Task.FromResult(new FetchBattleLiveInvitationResponse())); + app.MapGrpcUnary("/app.live.LiveService/CheckMaintenanceFree", CheckMaintenanceFreeRequest.Parser, + static (_, _) => Task.FromResult(new CheckMaintenanceFreeResponse())); + await app.StartAsync(); + using var handler = new HttpClientHandler(); + using var client = new HttpClient(handler) + { + BaseAddress = new Uri(app.Urls.ToArray()[0]), + DefaultRequestVersion = System.Net.HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact + }; + var registration = RegisterResponse.Parser.ParseFrom( + await Call(client, "/app.player.PlayerService/Register", new RegisterRequest { InitialDataGroup = "Default" })); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", registration.Credential!.Credential); + + var sync = SyncDataResponse.Parser.ParseFrom(await Call(client, "/app.playerext.PlayerExtService/SyncData", new SyncDataRequest())); + Assert.Equal(registration.ProfileId, sync.Accountid); + Assert.NotNull(sync.Gem); + Assert.NotNull(sync.LiveBoost); + Assert.Empty(new FetchResponse().SeasonPasses); + Assert.Empty(new GetNotificationsResponse().Notifications); + Assert.Empty(new FetchBattleLiveInvitationResponse().Invitations); + Assert.Empty(await Call(client, "/app.seasonpass.SeasonPassService/Fetch", new FetchRequest())); + Assert.Empty(await Call(client, "/app.chat.ClientChatService/SyncMsg", new ClientSyncMsgRequest())); + Assert.Empty(await Call(client, "/app.chat.ClientChatService/GetNotifications", new GetNotificationsRequest())); + Assert.Empty(await Call(client, "/app.live.LiveService/FetchBattleLiveInvitation", new FetchBattleLiveInvitationRequest())); + Assert.Empty(await Call(client, "/app.live.LiveService/CheckMaintenanceFree", new CheckMaintenanceFreeRequest())); + await app.StopAsync(); + } + + private static async Task Call(HttpClient client, string path, IMessage message) + { + var payload = message.ToByteArray(); + var body = new byte[payload.Length + 5]; + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(1, 4), (uint)payload.Length); + payload.CopyTo(body, 5); + using var content = new ByteArrayContent(body); + content.Headers.ContentType = new MediaTypeHeaderValue("application/grpc"); + using var response = await client.PostAsync(path, content); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(System.Net.HttpVersion.Version20, response.Version); + var data = await response.Content.ReadAsByteArrayAsync(); + var status = response.TrailingHeaders.TryGetValues("grpc-status", out var trailer) ? trailer.Single() : + response.Headers.GetValues("grpc-status").Single(); + var grpcMessage = response.TrailingHeaders.TryGetValues("grpc-message", out var messages) ? messages.Single() : + response.Headers.TryGetValues("grpc-message", out var headers) ? headers.Single() : ""; + Assert.True(status == "0", $"{path}: grpc-status={status}, message={grpcMessage}"); + Assert.True(data.Length >= 5); + Assert.Equal(data.Length - 5, (int)BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(1, 4))); + return data[5..]; + } +} diff --git a/tests/MyNotes.Tests/StaticAssetTests.cs b/tests/MyNotes.Tests/StaticAssetTests.cs new file mode 100644 index 0000000..0c2c54f --- /dev/null +++ b/tests/MyNotes.Tests/StaticAssetTests.cs @@ -0,0 +1,586 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MyNotes.Config; +using MyNotes.Services; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Xunit; + +namespace MyNotes.Tests; + +public sealed class StaticAssetTests +{ + [Fact] + public async Task LocalFilesTakePriorityOverCachedAndUpstreamFiles() + { + using var assets = new AssetHost(upstreamEnabled: false); + assets.WriteLocal("sample.bundle", "local"); + assets.WriteCache("sample.bundle", "cached"); + + using var response = await assets.Client.GetAsync("/asset/Android/sample.bundle"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("local", await response.Content.ReadAsStringAsync()); + Assert.Empty(assets.UpstreamRequests); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LocalFilesSupportHeadWithOrWithoutCaching(bool cacheEnabled) + { + using var assets = new AssetHost(cacheEnabled: cacheEnabled, upstreamEnabled: false); + assets.WriteLocal("sample.bundle", "abcdef"); + using var request = new HttpRequestMessage(HttpMethod.Head, "/asset/Android/sample.bundle"); + + using var response = await assets.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(6L, response.Content.Headers.ContentLength); + Assert.Empty(await response.Content.ReadAsByteArrayAsync()); + Assert.Empty(assets.UpstreamRequests); + } + + [Fact] + public async Task LocalFilesSupportRangesWhenUpstreamIsDisabled() + { + using var assets = new AssetHost(cacheEnabled: false, upstreamEnabled: false); + assets.WriteLocal("sample.bundle", "abcdef"); + using var request = new HttpRequestMessage(HttpMethod.Get, "/asset/Android/sample.bundle"); + request.Headers.Range = new RangeHeaderValue(1, 3); + + using var response = await assets.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.Equal("bcd", await response.Content.ReadAsStringAsync()); + Assert.Equal("bytes 1-3/6", response.Content.Headers.ContentRange?.ToString()); + Assert.Empty(assets.UpstreamRequests); + } + + [Fact] + public async Task LocalRangeWithOlderIfRangeReturnsTheFullFile() + { + using var assets = new AssetHost(upstreamEnabled: false); + assets.WriteLocal("sample.bundle", "abcdef"); + using var request = new HttpRequestMessage(HttpMethod.Get, "/asset/Android/sample.bundle"); + request.Headers.Range = new RangeHeaderValue(1, 3); + request.Headers.IfRange = new RangeConditionHeaderValue(DateTimeOffset.UnixEpoch); + + using var response = await assets.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("abcdef", await response.Content.ReadAsStringAsync()); + Assert.NotNull(response.Content.Headers.LastModified); + } + + [Fact] + public async Task CachedFilesAreAvailableWhenUpstreamIsDisabled() + { + using var assets = new AssetHost(upstreamEnabled: false); + assets.WriteCache("sample.bundle", "cached"); + + using var response = await assets.Client.GetAsync("/asset/Android/sample.bundle"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("cached", await response.Content.ReadAsStringAsync()); + Assert.Empty(assets.UpstreamRequests); + } + + [Fact] + public async Task MissingFilesReturnNotFoundWhenUpstreamIsDisabled() + { + using var assets = new AssetHost(upstreamEnabled: false); + + using var response = await assets.Client.GetAsync("/asset/Android/missing.bundle"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Empty(assets.UpstreamRequests); + } + + [Theory] + [InlineData("../outside.bundle")] + [InlineData("nested/../../outside.bundle")] + [InlineData("nested\\..\\outside.bundle")] + [InlineData("/outside.bundle")] + [InlineData("C:\\outside.bundle")] + [InlineData("sample.bundle:alternate")] + [InlineData("nested/./sample.bundle")] + [InlineData("NUL.bundle")] + public async Task InvalidRelativePathsReturnBadRequest(string path) + { + using var assets = new AssetHost(); + + var context = await assets.Server.SendAsync(context => + { + context.Request.Method = HttpMethods.Get; + context.Request.Path = "/asset/Android/" + path; + }); + + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + Assert.Empty(assets.UpstreamRequests); + } + + [SymbolicLinkFact] + public async Task SymbolicLinksCannotExposeFilesOutsideTheLocalDirectory() + { + using var assets = new AssetHost(upstreamEnabled: false); + var outside = Path.Combine(assets.Root, "outside"); + Directory.CreateDirectory(outside); + File.WriteAllText(Path.Combine(outside, "sample.bundle"), "outside"); + Directory.CreateDirectory(Path.Combine(assets.LocalRoot, "Android")); + Directory.CreateSymbolicLink(Path.Combine(assets.LocalRoot, "Android", "linked"), outside); + + using var response = await assets.Client.GetAsync("/asset/Android/linked/sample.bundle"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Empty(assets.UpstreamRequests); + } + + [SymbolicLinkFact] + public async Task FileSymbolicLinksCannotExposeFilesOutsideTheLocalDirectory() + { + using var assets = new AssetHost(upstreamEnabled: false); + var outside = Path.Combine(assets.Root, "outside.bundle"); + File.WriteAllText(outside, "outside"); + Directory.CreateDirectory(Path.Combine(assets.LocalRoot, "Android")); + File.CreateSymbolicLink(Path.Combine(assets.LocalRoot, "Android", "sample.bundle"), outside); + + using var response = await assets.Client.GetAsync("/asset/Android/sample.bundle"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Empty(assets.UpstreamRequests); + } + + [Fact] + public async Task ColdRangesForwardRangeAndIfRangeWithoutPopulatingTheCache() + { + using var assets = new AssetHost((request, _) => + { + var response = Response("bcd", HttpStatusCode.PartialContent); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(1, 3, 6); + return Task.FromResult(response); + }); + using var request = new HttpRequestMessage(HttpMethod.Get, "/asset/Android/sample.bundle"); + request.Headers.Range = new RangeHeaderValue(1, 3); + request.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"sample\"")); + + using var response = await assets.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.Equal("bcd", await response.Content.ReadAsStringAsync()); + Assert.Equal("bytes 1-3/6", response.Content.Headers.ContentRange?.ToString()); + var forwarded = Assert.Single(assets.UpstreamRequests); + Assert.Equal(HttpMethod.Get, forwarded.Method); + Assert.Equal("bytes=1-3", forwarded.Range); + Assert.Equal("\"sample\"", forwarded.IfRange); + Assert.False(File.Exists(assets.CachePath("sample.bundle"))); + Assert.Empty(assets.CacheTemporaryFiles()); + } + + [Fact] + public async Task AFullUpstreamRangeResponseIsNotCached() + { + using var assets = new AssetHost(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/asset/Android/sample.bundle"); + request.Headers.Range = new RangeHeaderValue(1, 3); + + using var response = await assets.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("upstream", await response.Content.ReadAsStringAsync()); + Assert.Single(assets.UpstreamRequests); + Assert.False(File.Exists(assets.CachePath("sample.bundle"))); + } + + [Theory] + [InlineData("catalog.bin")] + [InlineData("catalog.hash")] + [InlineData("catalog_alpha.bin")] + [InlineData("nested/CATALOG_alpha.HASH")] + [InlineData("catalog")] + [InlineData("catalog_alpha")] + public async Task CatalogMarkersBypassExistingCacheFiles(string path) + { + using var assets = new AssetHost(); + assets.WriteCache(path, "cached"); + + using var response = await assets.Client.GetAsync("/asset/Android/" + path); + + Assert.Equal("upstream", await response.Content.ReadAsStringAsync()); + Assert.Equal("cached", File.ReadAllText(assets.CachePath(path))); + Assert.Single(assets.UpstreamRequests); + Assert.Empty(assets.CacheTemporaryFiles()); + } + + [Fact] + public async Task LocalCatalogMarkersAreAvailableWhenUpstreamIsDisabled() + { + using var assets = new AssetHost(upstreamEnabled: false); + assets.WriteLocal("catalog_alpha.bin", "local"); + assets.WriteCache("catalog_alpha.bin", "cached"); + + using var response = await assets.Client.GetAsync("/asset/Android/catalog_alpha.bin"); + + Assert.Equal("local", await response.Content.ReadAsStringAsync()); + Assert.Empty(assets.UpstreamRequests); + } + + [Fact] + public async Task CatalogMarkersWithoutLocalFilesAreNotFoundWhenUpstreamIsDisabled() + { + using var assets = new AssetHost(upstreamEnabled: false); + assets.WriteCache("catalog_alpha.hash", "cached"); + + using var response = await assets.Client.GetAsync("/asset/Android/catalog_alpha.hash"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Empty(assets.UpstreamRequests); + } + + [Fact] + public async Task CompletedDownloadsAreCachedAndServedWithoutAnotherUpstreamRequest() + { + using var assets = new AssetHost(); + + using var first = await assets.Client.GetAsync("/asset/Android/nested/sample.bundle"); + Assert.Equal("upstream", await first.Content.ReadAsStringAsync()); + using var second = await assets.Client.GetAsync("/asset/Android/nested/sample.bundle"); + + Assert.Equal("upstream", await second.Content.ReadAsStringAsync()); + Assert.Equal("upstream", File.ReadAllText(assets.CachePath("nested/sample.bundle"))); + Assert.Single(assets.UpstreamRequests); + Assert.Empty(assets.CacheTemporaryFiles()); + } + + [Fact] + public async Task ConcurrentRequestsShareOneCacheDownload() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var assets = new AssetHost(async (_, cancellationToken) => + { + started.TrySetResult(); + await release.Task.WaitAsync(cancellationToken); + return Response("complete"); + }); + + var first = assets.Client.GetAsync("/asset/Android/sample.bundle"); + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var second = assets.Client.GetAsync("/asset/Android/sample.bundle"); + release.SetResult(); + var responses = await Task.WhenAll(first, second); + try + { + foreach (var response in responses) + Assert.Equal("complete", await response.Content.ReadAsStringAsync()); + Assert.Single(assets.UpstreamRequests); + Assert.Equal("complete", File.ReadAllText(assets.CachePath("sample.bundle"))); + Assert.Empty(assets.CacheTemporaryFiles()); + } + finally + { + foreach (var response in responses) + response.Dispose(); + } + } + + [Fact] + public async Task HeadRequestsForwardTheMethodWithoutPopulatingTheCache() + { + using var assets = new AssetHost(); + using var request = new HttpRequestMessage(HttpMethod.Head, "/asset/Android/sample.bundle"); + + using var response = await assets.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpMethod.Head, Assert.Single(assets.UpstreamRequests).Method); + Assert.Empty(await response.Content.ReadAsByteArrayAsync()); + Assert.False(File.Exists(assets.CachePath("sample.bundle"))); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task HeaderTimeoutReturnsGatewayTimeout(bool headOnly) + { + using var assets = new AssetHost(async (_, cancellationToken) => + { + await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); + return Response("upstream"); + }, timeoutSeconds: 1); + using var request = new HttpRequestMessage(headOnly ? HttpMethod.Head : HttpMethod.Get, + "/asset/Android/sample.bundle"); + + using var response = await assets.Client.SendAsync(request).WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode); + if (headOnly) + Assert.Empty(await response.Content.ReadAsByteArrayAsync()); + Assert.False(File.Exists(assets.CachePath("sample.bundle"))); + Assert.Empty(assets.CacheTemporaryFiles()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BodyTimeoutBeforeResponseStartsReturnsGatewayTimeoutAndRemovesTemporaryFiles(bool cacheEnabled) + { + using var assets = new AssetHost((_, _) => Task.FromResult(DelayedResponse()), + cacheEnabled: cacheEnabled, timeoutSeconds: 1); + + using var response = await assets.Client.GetAsync("/asset/Android/sample.bundle") + .WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode); + Assert.Equal("official asset upstream timed out", await response.Content.ReadAsStringAsync()); + Assert.False(File.Exists(assets.CachePath("sample.bundle"))); + Assert.Empty(assets.CacheTemporaryFiles()); + } + + [Fact] + public async Task HeaderAndBodyShareOneTimeoutBudget() + { + using var assets = new AssetHost(async (_, cancellationToken) => + { + await Task.Delay(TimeSpan.FromMilliseconds(700), cancellationToken); + return DelayedResponse(delay: TimeSpan.FromMilliseconds(700)); + }, timeoutSeconds: 1); + + using var response = await assets.Client.GetAsync("/asset/Android/sample.bundle") + .WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode); + Assert.False(File.Exists(assets.CachePath("sample.bundle"))); + Assert.Empty(assets.CacheTemporaryFiles()); + } + + [Fact] + public async Task BodyTimeoutAfterResponseStartsAbortsTheResponseAndRemovesTemporaryFiles() + { + using var assets = new AssetHost((_, _) => Task.FromResult(DelayedResponse(firstChunkLength: 1)), + timeoutSeconds: 1); + using var response = await assets.Client.GetAsync("/asset/Android/sample.bundle", + HttpCompletionOption.ResponseHeadersRead).WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var error = await Record.ExceptionAsync(() => response.Content.ReadAsByteArrayAsync() + .WaitAsync(TimeSpan.FromSeconds(5))); + + Assert.True(error is IOException or HttpRequestException or OperationCanceledException, + error?.ToString() ?? "The response completed without an error."); + Assert.False(File.Exists(assets.CachePath("sample.bundle"))); + Assert.Empty(assets.CacheTemporaryFiles()); + } + + [Fact] + public async Task NoStoreResponsesAreNotCached() + { + using var assets = new AssetHost((_, _) => + { + var response = Response("upstream"); + response.Headers.CacheControl = new CacheControlHeaderValue { NoStore = true }; + return Task.FromResult(response); + }); + + using var response = await assets.Client.GetAsync("/asset/Android/sample.bundle"); + + Assert.Equal("upstream", await response.Content.ReadAsStringAsync()); + Assert.False(File.Exists(assets.CachePath("sample.bundle"))); + Assert.Empty(assets.CacheTemporaryFiles()); + } + + [Fact] + public async Task ShortResponsesDoNotCreateCompletedCacheFiles() + { + using var assets = new AssetHost((_, _) => + { + var response = Response("short"); + response.Content.Headers.ContentLength = 10; + return Task.FromResult(response); + }); + + var response = await assets.Client.GetAsync("/asset/Android/sample.bundle", + HttpCompletionOption.ResponseHeadersRead); + using (response) + await Record.ExceptionAsync(() => response.Content.ReadAsByteArrayAsync()); + + Assert.False(File.Exists(assets.CachePath("sample.bundle"))); + Assert.Empty(assets.CacheTemporaryFiles()); + } + + private static HttpResponseMessage Response(string body, HttpStatusCode statusCode = HttpStatusCode.OK) => + new(statusCode) { Content = new ByteArrayContent(Encoding.UTF8.GetBytes(body)) }; + + private static HttpResponseMessage DelayedResponse(int firstChunkLength = 0, TimeSpan? delay = null) + { + var content = new StreamContent(new DelayedBodyStream(Encoding.UTF8.GetBytes("abcdef"), + firstChunkLength, delay ?? TimeSpan.FromSeconds(30))); + content.Headers.ContentLength = 6; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; + } + + private sealed record UpstreamRequest(HttpMethod Method, string? Range, string? IfRange); + + private sealed class AssetHost : IDisposable + { + private readonly IHost _host; + public string Root { get; } = Path.Combine(Path.GetTempPath(), "static-assets-" + Guid.NewGuid().ToString("N")); + public string LocalRoot => Path.Combine(Root, "local"); + private string CacheRoot => Path.Combine(Root, "cache"); + public ConcurrentQueue UpstreamRequests { get; } = new(); + public TestServer Server { get; } + public HttpClient Client { get; } + + public AssetHost( + Func>? respond = null, + bool cacheEnabled = true, + bool upstreamEnabled = true, + int timeoutSeconds = 10) + { + Directory.CreateDirectory(Root); + ServerConfig.Load(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Game:BasicAuthUser"] = "", + ["Game:BasicAuthPass"] = "", + ["Static:OfficialAssetRootUrl"] = "https://assets.example.invalid/", + ["Static:OfficialAssetRequestTimeoutSeconds"] = timeoutSeconds.ToString(), + ["Static:LocalDirectory"] = LocalRoot, + ["Static:CacheEnabled"] = cacheEnabled.ToString(), + ["Static:CacheDirectory"] = CacheRoot, + ["Static:UpstreamEnabled"] = upstreamEnabled.ToString() + }).Build()); + var handler = new UpstreamHandler(async (request, cancellationToken) => + { + UpstreamRequests.Enqueue(new UpstreamRequest(request.Method, + request.Headers.Range?.ToString(), request.Headers.IfRange?.ToString())); + return respond == null ? Response("upstream") : await respond(request, cancellationToken); + }); + _host = new HostBuilder() + .UseContentRoot(Root) + .ConfigureWebHost(builder => builder + .UseTestServer() + .ConfigureServices(services => + { + services.AddLogging(); + services.AddRouting(); + services.AddHttpClient("official-assets", client => client.Timeout = Timeout.InfiniteTimeSpan) + .ConfigurePrimaryHttpMessageHandler(() => handler); + services.AddSingleton(); + }) + .Configure(app => app.Run(context => + { + const string prefix = "/asset/Android/"; + var path = context.Request.Path.Value![prefix.Length..]; + return context.RequestServices.GetRequiredService() + .ProxyAndroidAssetAsync(context, path, HttpMethods.IsHead(context.Request.Method)); + }))) + .Start(); + Server = _host.GetTestServer(); + Client = _host.GetTestClient(); + } + + public void WriteLocal(string path, string body) => Write(Path.Combine(LocalRoot, "Android", path), body); + public void WriteCache(string path, string body) => Write(CachePath(path), body); + public string CachePath(string path) => Path.Combine(CacheRoot, "asset", "Android", path); + public string[] CacheTemporaryFiles() => Directory.Exists(CacheRoot) + ? Directory.GetFiles(CacheRoot, "*.tmp", SearchOption.AllDirectories) + : []; + + private static void Write(string path, string body) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, body); + } + + public void Dispose() + { + Client.Dispose(); + _host.Dispose(); + Directory.Delete(Root, true); + } + } + + private sealed class UpstreamHandler( + Func> respond) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + respond(request, cancellationToken); + } + + private sealed class DelayedBodyStream(byte[] bytes, int firstChunkLength, TimeSpan delay) : Stream + { + private int _position; + private bool _delayed; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => bytes.Length; + public override long Position { get => _position; set => throw new NotSupportedException(); } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_position == bytes.Length) + return 0; + if (_position >= firstChunkLength && !_delayed) + { + _delayed = true; + await Task.Delay(delay, cancellationToken); + } + + var end = _position < firstChunkLength ? firstChunkLength : bytes.Length; + var count = Math.Min(buffer.Length, end - _position); + bytes.AsMemory(_position, count).CopyTo(buffer); + _position += count; + return count; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} + +public sealed class SymbolicLinkFactAttribute : FactAttribute +{ + private static readonly Lazy SkipReason = new(GetSkipReason); + + public SymbolicLinkFactAttribute() => Skip = SkipReason.Value; + + private static string? GetSkipReason() + { + if (!OperatingSystem.IsWindows()) + return null; + + var root = Path.Combine(Path.GetTempPath(), "symbolic-link-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + var directory = Path.Combine(root, "directory"); + var file = Path.Combine(root, "file"); + Directory.CreateDirectory(directory); + File.WriteAllText(file, "sample"); + Directory.CreateSymbolicLink(Path.Combine(root, "directory-link"), directory); + File.CreateSymbolicLink(Path.Combine(root, "file-link"), file); + return null; + } + catch (IOException ex) when ((ex.HResult & 0xffff) == 1314) + { + return "Windows symbolic link creation requires Developer Mode or the Create symbolic links privilege."; + } + finally + { + Directory.Delete(root, true); + } + } +} From a26028a54a3be59b0ff529866f2fab40d98ed714 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 24 Oct 2026 02:02:48 +0800 Subject: [PATCH 2/3] docs: document resource releases and protocol behavior Describe the release build and serving layout, the identity and framing rules, the free-live flow, and the default states of the session companion RPCs. --- README.md | 9 ++++ docs/protocol.md | 61 +++++++++++++++++++++++++++ docs/resource-releases.md | 88 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 docs/protocol.md create mode 100644 docs/resource-releases.md diff --git a/README.md b/README.md index 963ce48..82a3e8b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # MyNotes +## 功能与文档 + +- 校验并加载版本化资源发布清单,提供 Catalog、MasterManifest、Master 文件和 Android 资源下载。 +- 从 nnnotes 0.3.1 歌曲包生成完整资源更新目录,支持本地镜像、HEAD、Range 和可选上游缓存。 +- 自由演奏开始、完成和成绩回读;gRPC 单帧校验、状态返回及玩家凭证认证。 +- HTTP/2、可配置 TLS,以及使用合成资源的集成测试。 + +[资源发布与下载](docs/resource-releases.md) · [协议行为与测试](docs/protocol.md) + ## 项目简介 本项目用于模拟个人独立游戏 **MyNotes** 测试服务端,供个人技术研究、协议分析、逆向学习等用途。所有功能仅限于本地测试,严禁用于任何形式的公开传播、开服或商业用途。 diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..c4f2d31 --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,61 @@ +# Protocol behavior + +The server exposes unary Protocol Buffer RPCs over HTTP/2. A request uses `application/grpc` or +`application/grpc+proto` and exactly one five-byte-prefixed message frame. The maximum message size is 16 MiB. +Responses use HTTP 200 and gRPC status headers or trailers. Malformed messages return `INVALID_ARGUMENT`, oversized +messages return `RESOURCE_EXHAUSTED`, and unsupported compression or RPCs return `UNIMPLEMENTED`. +`Server:CertificatePath` and `Server:CertificatePassword` enable TLS on the gRPC and HTTP listeners with a local +PFX certificate. An empty certificate path uses plaintext HTTP/2 on `GrpcPort` and HTTP/1.1 on `HttpPort`. +The PFX can contain the leaf certificate, intermediate certificates, and root needed to build its chain. +Start with `dotnet run -- --config settings.json` to load a separate JSON configuration file. Environment +variables and command-line values take precedence over that file. + +## Player identity + +Registration returns a player credential. Player operations accept that credential as a raw authorization value +or `Bearer `. Clients can also send `x-player-credential` with the matching `x-player-id`. +Duplicate or conflicting identity headers are rejected. A valid configured Basic authorization can select an +existing player with `x-player-id`. +Missing or invalid player credentials return `UNAUTHENTICATED`. When `Game:RequireBasicAuth` is enabled, every RPC +requires the configured Basic authorization. Authorization logs contain scheme labels only. + +`PlayerLoginService/GetServerList` reports the configured `Login:Server` fields. `PlayerPreLogin` and +`PlayerLogin` authenticate `sdkUid` and `sdkAccessToken` against `Login:Accounts`. Each account contains +`Uid`, `AccessTokenSha256` (the SHA-256 digest of the UTF-8 token), and `InitialDataGroup`. Login returns a +stable player credential for that account during the server process. An empty account list disables login. +The server validates locally configured tokens; the account gateway is configured separately. Store account +configuration outside the repository and use high-entropy tokens. + +Player data includes initialized memory, VIP, tutorial, and personal-notification sections, plus an account ID +that matches the player's profile ID. Home replies include initialized personal notifications. Empty sections +represent their default state. + +`PlayerExtService/SyncData` reports the player's account ID with initialized gem and live-boost sections in +their default state. `SeasonPassService/Fetch` returns the default season-pass state with no passes and no +mission counters. `ClientChatService/SyncMsg` and `ClientChatService/GetNotifications` return the default chat +state with no notifications, and `LiveService/FetchBattleLiveInvitation` returns no invitations. + +`FeatureSwitchService/Get` returns the boolean map configured under `FeatureSwitches` for an authenticated +player. The default map is empty. Invalid configuration values prevent startup. + +## Free live + +`StartFree` validates the song difficulty, deck, and boost range and records one active free live per player. A +subsequent start changes the active selection. `FinishFree` consumes that context and records the best score, +maximum combo, and highest clear state independently. `GetPlayerData` projects these records back to the client. +Results from another player or without an active live return `FAILED_PRECONDITION`. + +Finish replies contain an initialized notification message. Rewards and boost accounting are not implemented by +this free-live service. `CheckMaintenanceFree` reports no maintenance, and `SkipFree` returns `UNIMPLEMENTED`. +Player state, including scores, is held for the current server process and resets on restart. + +## Checks + +```sh +dotnet test tests/MyNotes.Tests/MyNotes.Tests.csproj +dotnet publish MyNotes.csproj -c Release -o publish +``` + +Tests create synthetic resources and protobuf requests in temporary directories. They cover framing, credentials, +local downloads and ranges, upstream caching and timeouts, release validation, explicit score relationships, and +free-live result projection. Symbolic-link checks run when the test host can create links. diff --git a/docs/resource-releases.md b/docs/resource-releases.md new file mode 100644 index 0000000..dd4f225 --- /dev/null +++ b/docs/resource-releases.md @@ -0,0 +1,88 @@ +# Resource releases + +The resource server can load a validated release and serve its catalog, encoded master tables, and bundles. +The client obtains these files through its normal resource update requests. Resource bytes are served unchanged; +bundle-provider encryption and audio encoding are handled by the packaging tools. + +## Build from a song package + +Create a song package with [nnnotes](https://github.com/MetaSekaiLab/nnnotes) 0.3.1 or later using `pack build`. +The target must use bundle URLs routed to this server's `/asset/Android/` endpoint. The complete baseline and +decoded master tables are local inputs. A baseline directory has this layout: + +```text +baseline/ + Android/catalog_example.bin + master/base-version/MasterManifest.json + master/base-version/Master*.bin +``` + +The baseline contains exactly one master version. `MASTER_JSON` contains the complete decoded tables for the +resulting package, including `MasterPlayerData`, `MasterMemberCard`, `MasterSupportCard`, `MasterLiveMusic`, +`MasterLiveMusicScore`, and `MasterLiveMusicComboReward`. + +```sh +dotnet run -- resource-release baseline song-package master-json catalog_example resource-v1 releases/song-v1 +``` + +The command verifies package file sizes and SHA-256 hashes, checks the baseline catalog, combines unchanged and +updated master files, preserves baseline Android assets, and creates `release.json`. It validates the complete master manifest and catalog marker +before making the output directory available. A new output directory is required. An interrupted build can leave +a staging directory for inspection. + +Set `Resources:ReleaseManifest` to the resulting `release.json`, then restart the server. Validation finishes +before the server accepts requests. Treat activated files as immutable; build another directory for an update. +The active directory should be read-only to the server process and other applications. Startup validation does +not protect files subsequently changed by another process. + +## Release manifest + +Paths in `release.json` are relative to that file's directory: + +```json +{ + "format": "mynotes.resource-release/1", + "version": "master-v1", + "resourceVersion": "resource-v1", + "catalogName": "catalog_example", + "masterJsonDirectory": "master-json", + "files": [ + {"path": "Android/catalog_example.bin", "size": 123, "sha256": ""} + ] +} +``` + +The `files` list covers every served file. The required catalog files are `Android/.bin` and +`Android/.hash`; the marker is the first 32 lowercase hexadecimal characters of the catalog's +SHA-256. The release contains `master//MasterManifest.json` and every file that manifest lists, with +matching names, sizes, and hashes. `masterJsonDirectory` is optional for a manually prepared release; when +provided, its six initialization tables must be listed and parse as `_allData` arrays. + +The version RPC reports `version` and `resourceVersion` from this manifest. Initialization reads the listed JSON +directory. Song difficulties resolve through `_easyID`, `_normalID`, `_hardID`, and `_expertID` references. +Configured empty tables produce empty collections. + +## Download paths + +| Request | Content | +| --- | --- | +| `/MasterDataSystemVersion.txt` | Active master version | +| `/Master/MasterDataSystemVersion.txt` | Active master version | +| `/master//MasterManifest.json` | Complete master manifest | +| `/master//` | Listed encoded master file | +| `/asset/Android/.bin` | Catalog bytes | +| `/asset/Android/.hash` | Matching catalog marker | +| `/asset/Android/` | Catalog bytes alias | +| `/asset/Android/` or `/Android/` | Bundle or other Android resource | + +File endpoints support GET, HEAD, and byte ranges. HEAD reports the actual length without a body. Unsatisfiable +ranges return 416. A release serves only its registered master version. + +Android requests resolve in this order: release files, `Static:LocalDirectory/Android/`, the download cache, then +the upstream when `Static:UpstreamEnabled` is true. `Static:LocalDirectory` works with caching disabled. Set +`UpstreamEnabled` to false for an offline mirror. Missing files return 404 in that mode. + +Cold upstream range requests forward `Range` and `If-Range` immediately. Complete successful responses can +populate the cache through a temporary file and atomic move. Catalog markers bypass the download cache. +`Static:OfficialAssetRequestTimeoutSeconds` bounds upstream headers and body transfer. An upstream timeout +returns 504 before response streaming begins, or ends the connection once streaming has begun. From 2fc267f1d7da1a66d469b4b0ff7bb924e0eb69e1 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 24 Oct 2026 02:02:48 +0800 Subject: [PATCH 3/3] ci: run tests and publish on GitHub Actions Run the synthetic-data test suite and publish the server on Linux and Windows for pushes and pull requests. --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..06fe474 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + - run: dotnet test tests/MyNotes.Tests/MyNotes.Tests.csproj --configuration Release + - run: dotnet publish MyNotes.csproj --configuration Release --output publish