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 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/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/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/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. 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); + } + } +}