From f68d410d9fb1c88551e8710a101b07890b565579 Mon Sep 17 00:00:00 2001 From: BillyCool Date: Sun, 15 Mar 2026 00:06:35 +1100 Subject: [PATCH] Implement more APIs. Optimize asset and resource loading. --- src/Configuration/ServerOptions.cs | 6 + src/Data/UserDataDiffBuilder.cs | 4 +- src/Data/UserDataStore.cs | 63 ++++++++++ src/Extensions/HttpApiExtensions.cs | 35 ++++++ src/Http/AssetDatabase.cs | 21 +++- src/Models/Type/QuestStateType.cs | 9 ++ src/Models/Type/SystemLanguage.cs | 48 ++++++++ src/Program.cs | 16 +++ src/Services/GameplayService.cs | 15 ++- src/Services/GimmickService.cs | 128 +++++++++++++++++++- src/Services/IndividualPopService.cs | 2 +- src/Services/NotificationService.cs | 9 +- src/Services/QuestService.cs | 167 ++++++++++++++++++++++++++- src/Services/TutorialService.cs | 96 ++++++++++++++- src/Services/UserService.cs | 13 ++- src/appsettings.Development.json | 3 +- src/appsettings.json | 3 +- 17 files changed, 612 insertions(+), 26 deletions(-) create mode 100644 src/Models/Type/QuestStateType.cs create mode 100644 src/Models/Type/SystemLanguage.cs diff --git a/src/Configuration/ServerOptions.cs b/src/Configuration/ServerOptions.cs index 8552ee1..0d20e2c 100644 --- a/src/Configuration/ServerOptions.cs +++ b/src/Configuration/ServerOptions.cs @@ -26,4 +26,10 @@ public sealed class DataOptions public string LatestMasterDataVersion { get; init; } = string.Empty; public string UserDataPath { get; init; } = string.Empty; public string MasterDataPath { get; init; } = string.Empty; + + /// + /// Path to the JSON file used to persist user data between server restarts. + /// If relative, resolved against the application base directory. + /// + public string UserDatabase { get; init; } = "userdata.json"; } diff --git a/src/Data/UserDataDiffBuilder.cs b/src/Data/UserDataDiffBuilder.cs index 5fc0fd1..efc1954 100644 --- a/src/Data/UserDataDiffBuilder.cs +++ b/src/Data/UserDataDiffBuilder.cs @@ -83,11 +83,11 @@ public static class UserDataDiffBuilder /// /// Computes only the tables that changed since the snapshot. - /// Use this for incremental API responses (e.g. SetUserName, GameStart). + /// Use this for incremental API responses (e.g. SetUserName). /// public static Dictionary Delta(Dictionary before, DarkUserMemoryDatabase db) { - var diff = new Dictionary(); + Dictionary diff = []; foreach (var (table, serialize) in Serializers) { var afterJson = serialize(db); diff --git a/src/Data/UserDataStore.cs b/src/Data/UserDataStore.cs index b9f4499..d58cd71 100644 --- a/src/Data/UserDataStore.cs +++ b/src/Data/UserDataStore.cs @@ -1,5 +1,6 @@ using MariesWonderland.Models.Entities; using MariesWonderland.Models.Type; +using System.Text.Json; namespace MariesWonderland.Data; @@ -63,6 +64,58 @@ public class UserDataStore public IReadOnlyDictionary All => _users; + /// + /// Serialize all user data to a JSON file on disk. + /// + public void Save(string filePath) + { + UserDataSnapshot snapshot = new() + { + Users = _users, + UuidToUserId = _uuidToUserId, + Sessions = _sessions.Values.ToList() + }; + + JsonSerializerOptions options = new() + { + WriteIndented = true + }; + + string json = JsonSerializer.Serialize(snapshot, options); + File.WriteAllText(filePath, json); + } + + /// + /// Deserialize user data from a JSON file on disk, replacing the current in-memory state. + /// Returns the number of users loaded. + /// + public int Load(string filePath) + { + if (!File.Exists(filePath)) + return 0; + + string json = File.ReadAllText(filePath); + UserDataSnapshot? snapshot = JsonSerializer.Deserialize(json); + + if (snapshot is null) + return 0; + + _users.Clear(); + _uuidToUserId.Clear(); + _sessions.Clear(); + + foreach (var (userId, db) in snapshot.Users) + _users[userId] = db; + + foreach (var (uuid, userId) in snapshot.UuidToUserId) + _uuidToUserId[uuid] = userId; + + foreach (UserSession session in snapshot.Sessions) + _sessions[session.SessionKey] = session; + + return _users.Count; + } + private static long GenerateUserId() { // Random 19-digit positive long (range: 1e18 to long.MaxValue) @@ -137,3 +190,13 @@ public class UserDataStore } } +/// +/// Serializable snapshot of all user data for persistence. +/// +file record UserDataSnapshot +{ + public Dictionary Users { get; init; } = []; + public Dictionary UuidToUserId { get; init; } = []; + public List Sessions { get; init; } = []; +} + diff --git a/src/Extensions/HttpApiExtensions.cs b/src/Extensions/HttpApiExtensions.cs index f9571b8..9866321 100644 --- a/src/Extensions/HttpApiExtensions.cs +++ b/src/Extensions/HttpApiExtensions.cs @@ -1,4 +1,5 @@ using MariesWonderland.Configuration; +using MariesWonderland.Data; using MariesWonderland.Http; using Microsoft.Extensions.Options; using System.Text; @@ -17,12 +18,46 @@ public static class HttpApiExtensions string assetDatabaseBasePath = options.Paths.AssetDatabase; string masterDatabaseBasePath = options.Paths.MasterDatabase; string resourcesBaseUrl = options.Paths.ResourcesBaseUrl; + string userDatabasePath = Path.IsPathRooted(options.Data.UserDatabase) + ? options.Data.UserDatabase + : Path.Combine(AppContext.BaseDirectory, options.Data.UserDatabase); ILogger assetLogger = app.Services.GetRequiredService().CreateLogger(); AssetDatabase assetDb = new(assetDatabaseBasePath, assetLogger); app.MapGet("/", () => "Marie's Wonderland is open for business :marie:"); + // Debug endpoints for manual save/load + app.MapGet("/debug/save", () => + { + try + { + UserDataStore store = app.Services.GetRequiredService(); + store.Save(userDatabasePath); + return Results.Ok($"Saved {store.All.Count} users to {userDatabasePath}"); + } + catch (Exception ex) + { + return Results.Problem($"Save failed: {ex.Message}"); + } + }); + + app.MapGet("/debug/load", () => + { + try + { + UserDataStore store = app.Services.GetRequiredService(); + int count = store.Load(userDatabasePath); + return count > 0 + ? Results.Ok($"Loaded {count} users from {userDatabasePath}") + : Results.Ok("No save file found."); + } + catch (Exception ex) + { + return Results.Problem($"Load failed: {ex.Message}"); + } + }); + // ToS. Expects the version wrapped in delimiters like "###123###". app.MapGet("/web/static/{languagePath}/terms/termsofuse", (string languagePath) => $"Terms of Service

Terms of Service

Language: {languagePath}

Version: ###123###

"); diff --git a/src/Http/AssetDatabase.cs b/src/Http/AssetDatabase.cs index 47d1bf3..a145598 100644 --- a/src/Http/AssetDatabase.cs +++ b/src/Http/AssetDatabase.cs @@ -8,7 +8,14 @@ namespace MariesWonderland.Http; /// /// Resolves asset bundle/resource requests by parsing list.bin protobuf indexes and info.json alias maps. -/// Tracks the last-served list.bin revision per client IP so asset requests use the matching revision. +/// +/// +/// Asset revisions are deltas, but revision 0 is a complete superset: it contains every objectId +/// that appears across all 818 revisions (confirmed by exhaustive analysis). Later revisions carry +/// updated versions of existing assets, not new ones. therefore checks the +/// client's current revision first, then falls back to revision 0 — a single 25 MB index that +/// covers the entire asset catalogue. +/// /// public sealed class AssetDatabase(string basePath, ILogger logger) { @@ -35,9 +42,21 @@ public sealed class AssetDatabase(string basePath, ILogger logger /// Resolves an asset request to ordered file-path candidates. /// Caller should try each candidate in order, validating size and MD5, and serve the first valid one. /// + /// + /// The client's current revision is checked first. If the objectId is not present (e.g. a + /// recent revision carries only updated entries, not the full catalogue), revision 0 is used + /// as the fallback. Revision 0 is a confirmed superset of all objectIds across every revision. + /// public IEnumerable Resolve(string clientIp, string assetType, string objectId) { string revision = _clientRevisions.TryGetValue(clientIp, out string? rev) ? rev : _lastKnownRevision; + + // If the objectId isn't in the client's current revision, fall back to revision 0. + // Revision 0 is a complete superset — every objectId in the game is present there. + Dictionary? currentIndex = LoadListBinIndex(revision); + if ((currentIndex is null || !currentIndex.ContainsKey(objectId)) && revision != "0") + revision = "0"; + return ResolveForRevision(revision, assetType, objectId); } diff --git a/src/Models/Type/QuestStateType.cs b/src/Models/Type/QuestStateType.cs new file mode 100644 index 0000000..db88314 --- /dev/null +++ b/src/Models/Type/QuestStateType.cs @@ -0,0 +1,9 @@ +namespace MariesWonderland.Models.Type; + +public enum QuestStateType +{ + UNKNOWN = 0, + ACTIVE = 1, + CLEARED = 2, + CHALLENGE = 4, +} diff --git a/src/Models/Type/SystemLanguage.cs b/src/Models/Type/SystemLanguage.cs new file mode 100644 index 0000000..6c8c35a --- /dev/null +++ b/src/Models/Type/SystemLanguage.cs @@ -0,0 +1,48 @@ +namespace MariesWonderland.Models.Type; + +public enum SystemLanguage +{ + Afrikaans = 0, + Arabic = 1, + Basque = 2, + Belarusian = 3, + Bulgarian = 4, + Catalan = 5, + Chinese = 6, + Czech = 7, + Danish = 8, + Dutch = 9, + English = 10, + Estonian = 11, + Faroese = 12, + Finnish = 13, + French = 14, + German = 15, + Greek = 16, + Hebrew = 17, + Icelandic = 19, + Indonesian = 20, + Italian = 21, + Japanese = 22, + Korean = 23, + Latvian = 24, + Lithuanian = 25, + Norwegian = 26, + Polish = 27, + Portuguese = 28, + Romanian = 29, + Russian = 30, + SerboCroatian = 31, + Slovak = 32, + Slovenian = 33, + Spanish = 34, + Swedish = 35, + Thai = 36, + Turkish = 37, + Ukrainian = 38, + Vietnamese = 39, + ChineseSimplified = 40, + ChineseTraditional = 41, + Unknown = 42, + Hungarian = 18 +} diff --git a/src/Program.cs b/src/Program.cs index 573c483..94e60aa 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -1,5 +1,8 @@ +using MariesWonderland.Configuration; +using MariesWonderland.Data; using MariesWonderland.Extensions; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Options; namespace MariesWonderland; @@ -26,6 +29,19 @@ public static class Program var app = builder.Build(); + // Load user data on startup + UserDataStore userDataStore = app.Services.GetRequiredService(); + ServerOptions serverOptions = app.Services.GetRequiredService>().Value; + string userDatabasePath = Path.IsPathRooted(serverOptions.Data.UserDatabase) + ? serverOptions.Data.UserDatabase + : Path.Combine(AppContext.BaseDirectory, serverOptions.Data.UserDatabase); + + int loadedUsers = userDataStore.Load(userDatabasePath); + if (loadedUsers > 0) + { + app.Logger.LogInformation("Loaded {Count} users from {Path}", loadedUsers, userDatabasePath); + } + app.MapGrpcServices(); app.MapHttpApis(); diff --git a/src/Services/GameplayService.cs b/src/Services/GameplayService.cs index ea75f4c..f7251e7 100644 --- a/src/Services/GameplayService.cs +++ b/src/Services/GameplayService.cs @@ -1,5 +1,6 @@ -using MariesWonderland.Proto.GamePlay; using Grpc.Core; +using MariesWonderland.Proto.Gacha; +using MariesWonderland.Proto.GamePlay; namespace MariesWonderland.Services; @@ -7,6 +8,16 @@ public class GameplayService : MariesWonderland.Proto.GamePlay.GameplayService.G { public override Task CheckBeforeGamePlay(CheckBeforeGamePlayRequest request, ServerCallContext context) { - return Task.FromResult(new CheckBeforeGamePlayResponse()); + CheckBeforeGamePlayResponse response = new() + { + IsExistUnreadPop = true + }; + response.MenuGachaBadgeInfo.Add(new MenuGachaBadgeInfo() + { + DisplayStartDatetime = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.MinValue), + DisplayEndDatetime = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.MaxValue), + }); + + return Task.FromResult(response); } } diff --git a/src/Services/GimmickService.cs b/src/Services/GimmickService.cs index b2b3118..f8cf4c4 100644 --- a/src/Services/GimmickService.cs +++ b/src/Services/GimmickService.cs @@ -1,28 +1,144 @@ -using MariesWonderland.Proto.Gimmick; using Google.Protobuf.WellKnownTypes; using Grpc.Core; +using MariesWonderland.Data; +using MariesWonderland.Extensions; +using MariesWonderland.Models.Entities; +using MariesWonderland.Proto.Gimmick; namespace MariesWonderland.Services; -public class GimmickService : MariesWonderland.Proto.Gimmick.GimmickService.GimmickServiceBase +public class GimmickService(UserDataStore store) : MariesWonderland.Proto.Gimmick.GimmickService.GimmickServiceBase { + private readonly UserDataStore _store = store; + public override Task UpdateSequence(UpdateSequenceRequest request, ServerCallContext context) { - return Task.FromResult(new UpdateSequenceResponse()); + long userId = context.GetUserId(); + DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId); + Dictionary before = UserDataDiffBuilder.Snapshot(userDb); + + // Ensure the sequence row exists so the client can track it + if (!userDb.EntityIUserGimmickSequence.Any(s => + s.UserId == userId && + s.GimmickSequenceScheduleId == request.GimmickSequenceScheduleId && + s.GimmickSequenceId == request.GimmickSequenceId)) + { + userDb.EntityIUserGimmickSequence.Add(new EntityIUserGimmickSequence + { + UserId = userId, + GimmickSequenceScheduleId = request.GimmickSequenceScheduleId, + GimmickSequenceId = request.GimmickSequenceId, + }); + } + + UpdateSequenceResponse response = new(); + foreach (var (k, v) in UserDataDiffBuilder.Delta(before, userDb)) response.DiffUserData[k] = v; + + return Task.FromResult(response); } public override Task UpdateGimmickProgress(UpdateGimmickProgressRequest request, ServerCallContext context) { - return Task.FromResult(new UpdateGimmickProgressResponse()); + long userId = context.GetUserId(); + DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId); + Dictionary before = UserDataDiffBuilder.Snapshot(userDb); + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Upsert the base gimmick progress row + EntityIUserGimmick? gimmick = userDb.EntityIUserGimmick.FirstOrDefault(g => + g.UserId == userId && + g.GimmickSequenceScheduleId == request.GimmickSequenceScheduleId && + g.GimmickSequenceId == request.GimmickSequenceId && + g.GimmickId == request.GimmickId); + + if (gimmick == null) + { + gimmick = new EntityIUserGimmick + { + UserId = userId, + GimmickSequenceScheduleId = request.GimmickSequenceScheduleId, + GimmickSequenceId = request.GimmickSequenceId, + GimmickId = request.GimmickId, + }; + userDb.EntityIUserGimmick.Add(gimmick); + } + gimmick.StartDatetime = nowMs; + + // Upsert the ornament progress row, recording the progress bit from the client + EntityIUserGimmickOrnamentProgress? ornament = userDb.EntityIUserGimmickOrnamentProgress.FirstOrDefault(o => + o.UserId == userId && + o.GimmickSequenceScheduleId == request.GimmickSequenceScheduleId && + o.GimmickSequenceId == request.GimmickSequenceId && + o.GimmickId == request.GimmickId && + o.GimmickOrnamentIndex == request.GimmickOrnamentIndex); + + if (ornament == null) + { + ornament = new EntityIUserGimmickOrnamentProgress + { + UserId = userId, + GimmickSequenceScheduleId = request.GimmickSequenceScheduleId, + GimmickSequenceId = request.GimmickSequenceId, + GimmickId = request.GimmickId, + GimmickOrnamentIndex = request.GimmickOrnamentIndex, + }; + userDb.EntityIUserGimmickOrnamentProgress.Add(ornament); + } + ornament.ProgressValueBit = request.ProgressValueBit; + ornament.BaseDatetime = nowMs; + + UpdateGimmickProgressResponse response = new() + { + IsSequenceCleared = false, + }; + foreach (var (k, v) in UserDataDiffBuilder.Delta(before, userDb)) response.DiffUserData[k] = v; + + return Task.FromResult(response); } public override Task InitSequenceSchedule(Empty request, ServerCallContext context) { - return Task.FromResult(new InitSequenceScheduleResponse()); + long userId = context.GetUserId(); + DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId); + Dictionary before = UserDataDiffBuilder.Snapshot(userDb); + + // Read-only — returns all gimmick tables so the client can initialise its state + InitSequenceScheduleResponse response = new(); + foreach (var (k, v) in UserDataDiffBuilder.Delta(before, userDb)) response.DiffUserData[k] = v; + return Task.FromResult(response); } public override Task Unlock(UnlockRequest request, ServerCallContext context) { - return Task.FromResult(new UnlockResponse()); + long userId = context.GetUserId(); + DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId); + Dictionary before = UserDataDiffBuilder.Snapshot(userDb); + + foreach (GimmickKey key in request.GimmickKey) + { + EntityIUserGimmickUnlock? unlock = userDb.EntityIUserGimmickUnlock.FirstOrDefault(u => + u.UserId == userId && + u.GimmickSequenceScheduleId == key.GimmickSequenceScheduleId && + u.GimmickSequenceId == key.GimmickSequenceId && + u.GimmickId == key.GimmickId); + if (unlock == null) + { + unlock = new EntityIUserGimmickUnlock + { + UserId = userId, + GimmickSequenceScheduleId = key.GimmickSequenceScheduleId, + GimmickSequenceId = key.GimmickSequenceId, + GimmickId = key.GimmickId, + }; + userDb.EntityIUserGimmickUnlock.Add(unlock); + } + unlock.IsUnlocked = true; + } + + UnlockResponse response = new(); + foreach (var (k, v) in UserDataDiffBuilder.Delta(before, userDb)) response.DiffUserData[k] = v; + + return Task.FromResult(response); } } diff --git a/src/Services/IndividualPopService.cs b/src/Services/IndividualPopService.cs index 22e457e..2285210 100644 --- a/src/Services/IndividualPopService.cs +++ b/src/Services/IndividualPopService.cs @@ -1,6 +1,6 @@ -using MariesWonderland.Proto.IndividualPop; using Google.Protobuf.WellKnownTypes; using Grpc.Core; +using MariesWonderland.Proto.IndividualPop; namespace MariesWonderland.Services; diff --git a/src/Services/NotificationService.cs b/src/Services/NotificationService.cs index 11c71f6..fa51578 100644 --- a/src/Services/NotificationService.cs +++ b/src/Services/NotificationService.cs @@ -1,6 +1,6 @@ -using MariesWonderland.Proto.Notification; using Google.Protobuf.WellKnownTypes; using Grpc.Core; +using MariesWonderland.Proto.Notification; namespace MariesWonderland.Services; @@ -8,6 +8,11 @@ public class NotificationService : MariesWonderland.Proto.Notification.Notificat { public override Task GetHeaderNotification(Empty request, ServerCallContext context) { - return Task.FromResult(new GetHeaderNotificationResponse()); + return Task.FromResult(new GetHeaderNotificationResponse() + { + FriendRequestReceiveCount = 0, + GiftNotReceiveCount = 0, + IsExistUnreadInformation = false + }); } } diff --git a/src/Services/QuestService.cs b/src/Services/QuestService.cs index 895fa72..9e81316 100644 --- a/src/Services/QuestService.cs +++ b/src/Services/QuestService.cs @@ -1,14 +1,66 @@ -using MariesWonderland.Proto.Quest; using Google.Protobuf.WellKnownTypes; using Grpc.Core; +using MariesWonderland.Data; +using MariesWonderland.Extensions; +using MariesWonderland.Models.Entities; +using MariesWonderland.Models.Type; +using MariesWonderland.Proto.Quest; namespace MariesWonderland.Services; -public class QuestService : MariesWonderland.Proto.Quest.QuestService.QuestServiceBase +public class QuestService(UserDataStore store, DarkMasterMemoryDatabase masterDb, ILogger logger) : MariesWonderland.Proto.Quest.QuestService.QuestServiceBase { + private readonly UserDataStore _store = store; + private readonly DarkMasterMemoryDatabase _masterDb = masterDb; + private readonly ILogger _logger = logger; + public override Task UpdateMainFlowSceneProgress(UpdateMainFlowSceneProgressRequest request, ServerCallContext context) { - return Task.FromResult(new UpdateMainFlowSceneProgressResponse()); + long userId = context.GetUserId(); + DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId); + Dictionary before = UserDataDiffBuilder.Snapshot(userDb); + + int questSceneId = request.QuestSceneId; + + // Resolve questId from the scene, then walk the master data chain to find the route: + // QuestScene -> MainQuestSequence (by QuestId) -> MainQuestSequenceGroup (by MainQuestSequenceId) + // -> MainQuestChapter (by MainQuestSequenceGroupId) -> MainQuestRouteId + EntityMQuestScene? scene = _masterDb.EntityMQuestScene.FirstOrDefault(s => s.QuestSceneId == questSceneId); + int routeId = 0; + if (scene != null) + { + EntityMMainQuestSequence? sequence = _masterDb.EntityMMainQuestSequence.FirstOrDefault(s => s.QuestId == scene.QuestId); + if (sequence != null) + { + EntityMMainQuestSequenceGroup? sequenceGroup = _masterDb.EntityMMainQuestSequenceGroup.FirstOrDefault(g => g.MainQuestSequenceId == sequence.MainQuestSequenceId); + if (sequenceGroup != null) + { + EntityMMainQuestChapter? chapter = _masterDb.EntityMMainQuestChapter.FirstOrDefault(c => c.MainQuestSequenceGroupId == sequenceGroup.MainQuestSequenceGroupId); + routeId = chapter?.MainQuestRouteId ?? 0; + } + } + } + + EntityIUserMainQuestFlowStatus flowStatus = userDb.EntityIUserMainQuestFlowStatus.FirstOrDefault(s => s.UserId == userId) + ?? AddEntity(userDb.EntityIUserMainQuestFlowStatus, new EntityIUserMainQuestFlowStatus { UserId = userId }); + flowStatus.CurrentQuestFlowType = QuestFlowType.MAIN_FLOW; + + EntityIUserMainQuestMainFlowStatus mainFlowStatus = userDb.EntityIUserMainQuestMainFlowStatus.FirstOrDefault(s => s.UserId == userId) + ?? AddEntity(userDb.EntityIUserMainQuestMainFlowStatus, new EntityIUserMainQuestMainFlowStatus { UserId = userId }); + mainFlowStatus.CurrentQuestSceneId = questSceneId; + mainFlowStatus.HeadQuestSceneId = Math.Max(mainFlowStatus.HeadQuestSceneId, questSceneId); + if (routeId != 0) mainFlowStatus.CurrentMainQuestRouteId = routeId; + + UpdateMainFlowSceneProgressResponse response = new(); + foreach (var (k, v) in UserDataDiffBuilder.Delta(before, userDb)) response.DiffUserData[k] = v; + return Task.FromResult(response); + } + + /// Adds an entity to a list and returns it (convenience for inline new-entity seeding). + private static T AddEntity(List list, T entity) + { + list.Add(entity); + return entity; } public override Task UpdateReplayFlowSceneProgress(UpdateReplayFlowSceneProgressRequest request, ServerCallContext context) @@ -18,7 +70,57 @@ public class QuestService : MariesWonderland.Proto.Quest.QuestService.QuestServi public override Task UpdateMainQuestSceneProgress(UpdateMainQuestSceneProgressRequest request, ServerCallContext context) { - return Task.FromResult(new UpdateMainQuestSceneProgressResponse()); + long userId = context.GetUserId(); + DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId); + Dictionary before = UserDataDiffBuilder.Snapshot(userDb); + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + int questSceneId = request.QuestSceneId; + + EntityMQuestScene? scene = _masterDb.EntityMQuestScene.FirstOrDefault(s => s.QuestSceneId == questSceneId); + EntityMQuest? quest = scene != null ? _masterDb.EntityMQuest.FirstOrDefault(q => q.QuestId == scene.QuestId) : null; + + // IUserMainQuestMainFlowStatus — tracks current scene position and whether we've reached the last scene + EntityIUserMainQuestMainFlowStatus mainFlowStatus = userDb.EntityIUserMainQuestMainFlowStatus.FirstOrDefault(s => s.UserId == userId) + ?? AddEntity(userDb.EntityIUserMainQuestMainFlowStatus, new EntityIUserMainQuestMainFlowStatus { UserId = userId }); + mainFlowStatus.CurrentQuestSceneId = questSceneId; + mainFlowStatus.HeadQuestSceneId = Math.Max(mainFlowStatus.HeadQuestSceneId, questSceneId); + if (scene?.QuestResultType is QuestResultType.HALF_RESULT or QuestResultType.FULL_RESULT) + mainFlowStatus.IsReachedLastQuestScene = true; + + // IUserMainQuestFlowStatus and IUserMainQuestProgressStatus — sub-flow tracking for playable quests + bool isPlayable = quest != null && !quest.IsRunInTheBackground && quest.IsCountedAsQuest; + if (isPlayable) + { + EntityIUserMainQuestFlowStatus flowStatus = userDb.EntityIUserMainQuestFlowStatus.FirstOrDefault(s => s.UserId == userId) + ?? AddEntity(userDb.EntityIUserMainQuestFlowStatus, new EntityIUserMainQuestFlowStatus { UserId = userId }); + flowStatus.CurrentQuestFlowType = QuestFlowType.SUB_FLOW; + + EntityIUserMainQuestProgressStatus progressStatus = userDb.EntityIUserMainQuestProgressStatus.FirstOrDefault(s => s.UserId == userId) + ?? AddEntity(userDb.EntityIUserMainQuestProgressStatus, new EntityIUserMainQuestProgressStatus { UserId = userId }); + progressStatus.CurrentQuestSceneId = questSceneId; + progressStatus.HeadQuestSceneId = Math.Max(progressStatus.HeadQuestSceneId, questSceneId); + progressStatus.CurrentQuestFlowType = QuestFlowType.SUB_FLOW; + + // Reaching a result scene clears the quest; a full-result scene also increments clear counts + if (scene?.QuestResultType is QuestResultType.HALF_RESULT or QuestResultType.FULL_RESULT) + { + EntityIUserQuest userQuest = userDb.EntityIUserQuest.FirstOrDefault(q => q.UserId == userId && q.QuestId == quest!.QuestId) + ?? AddEntity(userDb.EntityIUserQuest, new EntityIUserQuest { UserId = userId, QuestId = quest!.QuestId }); + userQuest.QuestStateType = (int)QuestStateType.CLEARED; + + if (scene.QuestResultType == QuestResultType.FULL_RESULT) + { + userQuest.ClearCount++; + userQuest.DailyClearCount++; + userQuest.LastClearDatetime = nowMs; + } + } + } + + UpdateMainQuestSceneProgressResponse response = new(); + foreach (var (k, v) in UserDataDiffBuilder.Delta(before, userDb)) response.DiffUserData[k] = v; + return Task.FromResult(response); } public override Task UpdateExtraQuestSceneProgress(UpdateExtraQuestSceneProgressRequest request, ServerCallContext context) @@ -33,7 +135,62 @@ public class QuestService : MariesWonderland.Proto.Quest.QuestService.QuestServi public override Task StartMainQuest(StartMainQuestRequest request, ServerCallContext context) { - return Task.FromResult(new StartMainQuestResponse()); + long userId = context.GetUserId(); + DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId); + Dictionary before = UserDataDiffBuilder.Snapshot(userDb); + + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + int questId = request.QuestId; + + EntityMQuest? quest = _masterDb.EntityMQuest.FirstOrDefault(q => q.QuestId == questId); + + // Ensure user quest and mission rows exist before any state transitions. + EntityIUserQuest userQuest = userDb.EntityIUserQuest.FirstOrDefault(q => q.UserId == userId && q.QuestId == questId) + ?? AddEntity(userDb.EntityIUserQuest, new EntityIUserQuest { UserId = userId, QuestId = questId }); + + if (quest != null && quest.QuestMissionGroupId != 0) + { + List missionIds = [.. _masterDb.EntityMQuestMissionGroup + .Where(g => g.QuestMissionGroupId == quest.QuestMissionGroupId) + .Select(g => g.QuestMissionId)]; + + foreach (int missionId in missionIds) + { + if (!userDb.EntityIUserQuestMission.Any(m => m.UserId == userId && m.QuestId == questId && m.QuestMissionId == missionId)) + userDb.EntityIUserQuestMission.Add(new EntityIUserQuestMission { UserId = userId, QuestId = questId, QuestMissionId = missionId }); + } + } + + // Don't touch state on already-cleared quests (e.g. replaying a finished story quest). + if (userQuest.QuestStateType == (int)QuestStateType.CLEARED) + { + StartMainQuestResponse earlyResponse = new(); + foreach (var (k, v) in UserDataDiffBuilder.Delta(before, userDb)) earlyResponse.DiffUserData[k] = v; + return Task.FromResult(earlyResponse); + } + + userQuest.IsBattleOnly = request.IsBattleOnly; + + // Background quests (cutscenes, non-combat) have no playable content — auto-clear them immediately. + // Playable quests transition to ACTIVE so the client knows a quest session is in progress. + bool isPlayable = quest != null && !quest.IsRunInTheBackground && quest.IsCountedAsQuest; + if (isPlayable) + { + userQuest.QuestStateType = (int)QuestStateType.ACTIVE; + userQuest.LatestStartDatetime = nowMs; + } + else + { + userQuest.QuestStateType = (int)QuestStateType.CLEARED; + userQuest.ClearCount++; + userQuest.DailyClearCount++; + userQuest.LastClearDatetime = nowMs; + } + + StartMainQuestResponse response = new(); + foreach (var (k, v) in UserDataDiffBuilder.Delta(before, userDb)) response.DiffUserData[k] = v; + + return Task.FromResult(response); } public override Task RestartMainQuest(RestartMainQuestRequest request, ServerCallContext context) diff --git a/src/Services/TutorialService.cs b/src/Services/TutorialService.cs index 143b5b6..7e92d57 100644 --- a/src/Services/TutorialService.cs +++ b/src/Services/TutorialService.cs @@ -1,17 +1,105 @@ -using MariesWonderland.Proto.Tutorial; using Grpc.Core; +using MariesWonderland.Data; +using MariesWonderland.Extensions; +using MariesWonderland.Models.Entities; +using MariesWonderland.Models.Type; +using MariesWonderland.Proto.Tutorial; namespace MariesWonderland.Services; -public class TutorialService : MariesWonderland.Proto.Tutorial.TutorialService.TutorialServiceBase +public class TutorialService(UserDataStore store) : MariesWonderland.Proto.Tutorial.TutorialService.TutorialServiceBase { public override Task SetTutorialProgress(SetTutorialProgressRequest request, ServerCallContext context) { - return Task.FromResult(new SetTutorialProgressResponse()); + long userId = context.GetUserId(); + DarkUserMemoryDatabase userDb = store.GetOrCreate(userId); + Dictionary before = UserDataDiffBuilder.Snapshot(userDb); + + UpsertTutorialProgress(userDb, userId, (TutorialType)request.TutorialType, request.ProgressPhase, request.ChoiceId); + + SetTutorialProgressResponse response = new(); + foreach ((string k, Proto.Data.DiffData v) in UserDataDiffBuilder.Delta(before, userDb)) + response.DiffUserData[k] = v; + + return Task.FromResult(response); } public override Task SetTutorialProgressAndReplaceDeck(SetTutorialProgressAndReplaceDeckRequest request, ServerCallContext context) { - return Task.FromResult(new SetTutorialProgressAndReplaceDeckResponse()); + long userId = context.GetUserId(); + DarkUserMemoryDatabase userDb = store.GetOrCreate(userId); + Dictionary before = UserDataDiffBuilder.Snapshot(userDb); + + UpsertTutorialProgress(userDb, userId, (TutorialType)request.TutorialType, request.ProgressPhase, choiceId: 0); + UpsertDeck(userDb, userId, request); + + SetTutorialProgressAndReplaceDeckResponse response = new(); + foreach ((string k, Proto.Data.DiffData v) in UserDataDiffBuilder.Delta(before, userDb)) + response.DiffUserData[k] = v; + + return Task.FromResult(response); + } + + private static void UpsertTutorialProgress(DarkUserMemoryDatabase db, long userId, TutorialType type, int phase, int choiceId) + { + EntityIUserTutorialProgress? existing = db.EntityIUserTutorialProgress + .FirstOrDefault(t => t.UserId == userId && t.TutorialType == type); + + if (existing is null) + { + db.EntityIUserTutorialProgress.Add(new EntityIUserTutorialProgress + { + UserId = userId, + TutorialType = type, + ProgressPhase = phase, + ChoiceId = choiceId + }); + } + else + { + existing.ProgressPhase = phase; + existing.ChoiceId = choiceId; + } + } + + /// + /// Upserts the user's deck from a SetTutorialProgressAndReplaceDeck request. + /// During tutorial the client sends the deck it wants to play with — we record the + /// costume UUIDs from each slot and seed sensible defaults for name and power. + /// + private static void UpsertDeck(DarkUserMemoryDatabase db, long userId, SetTutorialProgressAndReplaceDeckRequest request) + { + DeckType deckType = (DeckType)request.DeckType; + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + string uuid01 = request.Deck?.Character01?.UserCostumeUuid ?? ""; + string uuid02 = request.Deck?.Character02?.UserCostumeUuid ?? ""; + string uuid03 = request.Deck?.Character03?.UserCostumeUuid ?? ""; + + EntityIUserDeck? existing = db.EntityIUserDeck + .FirstOrDefault(d => d.UserId == userId && d.DeckType == deckType && d.UserDeckNumber == request.UserDeckNumber); + + if (existing is null) + { + db.EntityIUserDeck.Add(new EntityIUserDeck( + UserId: userId, + DeckType: deckType, + UserDeckNumber: request.UserDeckNumber, + UserDeckCharacterUuid01: uuid01, + UserDeckCharacterUuid02: uuid02, + UserDeckCharacterUuid03: uuid03, + Name: "Deck 1", + Power: 100, + LatestVersion: now + )); + } + else + { + if (!string.IsNullOrEmpty(uuid01)) existing.UserDeckCharacterUuid01 = uuid01; + if (!string.IsNullOrEmpty(uuid02)) existing.UserDeckCharacterUuid02 = uuid02; + if (!string.IsNullOrEmpty(uuid03)) existing.UserDeckCharacterUuid03 = uuid03; + existing.LatestVersion = now; + } } } + diff --git a/src/Services/UserService.cs b/src/Services/UserService.cs index 3033962..1d753f5 100644 --- a/src/Services/UserService.cs +++ b/src/Services/UserService.cs @@ -65,7 +65,18 @@ public class UserService(UserDataStore store) : MariesWonderland.Proto.User.User public override Task GameStart(Empty request, ServerCallContext context) { - return Task.FromResult(new GameStartResponse()); + long userId = context.GetUserId(); + DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId); + + Dictionary before = UserDataDiffBuilder.Snapshot(userDb); + + EntityIUser user = userDb.EntityIUser.FirstOrDefault(u => u.UserId == userId) + ?? AddEntity(userDb.EntityIUser, new EntityIUser { UserId = userId }); + user.GameStartDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + GameStartResponse response = new(); + foreach (var (k, v) in UserDataDiffBuilder.Delta(before, userDb)) response.DiffUserData[k] = v; + return Task.FromResult(response); } public override Task GetBackupToken(GetBackupTokenRequest request, ServerCallContext context) diff --git a/src/appsettings.Development.json b/src/appsettings.Development.json index cadd9ab..100819a 100644 --- a/src/appsettings.Development.json +++ b/src/appsettings.Development.json @@ -14,7 +14,8 @@ "Data": { "LatestMasterDataVersion": "20240404193219", "UserDataPath": "Data/UserData", - "MasterDataPath": "Data/MasterData" + "MasterDataPath": "Data/MasterData", + "UserDatabase": "userdata.json" } } } diff --git a/src/appsettings.json b/src/appsettings.json index a318bbf..dfe0ab0 100644 --- a/src/appsettings.json +++ b/src/appsettings.json @@ -22,7 +22,8 @@ "Data": { "LatestMasterDataVersion": "", "UserDataPath": "", - "MasterDataPath": "" + "MasterDataPath": "", + "UserDatabase": "userdata.json" } } }