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 ServiceTerms 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"
}
}
}