Implement more APIs. Optimize asset and resource loading.

This commit is contained in:
BillyCool
2026-03-15 00:06:35 +11:00
parent ca31192d55
commit f68d410d9f
17 changed files with 612 additions and 26 deletions

View File

@@ -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;
/// <summary>
/// Path to the JSON file used to persist user data between server restarts.
/// If relative, resolved against the application base directory.
/// </summary>
public string UserDatabase { get; init; } = "userdata.json";
}

View File

@@ -83,11 +83,11 @@ public static class UserDataDiffBuilder
/// <summary>
/// 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).
/// </summary>
public static Dictionary<string, DiffData> Delta(Dictionary<string, string> before, DarkUserMemoryDatabase db)
{
var diff = new Dictionary<string, DiffData>();
Dictionary<string, DiffData> diff = [];
foreach (var (table, serialize) in Serializers)
{
var afterJson = serialize(db);

View File

@@ -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<long, DarkUserMemoryDatabase> All => _users;
/// <summary>
/// Serialize all user data to a JSON file on disk.
/// </summary>
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);
}
/// <summary>
/// Deserialize user data from a JSON file on disk, replacing the current in-memory state.
/// Returns the number of users loaded.
/// </summary>
public int Load(string filePath)
{
if (!File.Exists(filePath))
return 0;
string json = File.ReadAllText(filePath);
UserDataSnapshot? snapshot = JsonSerializer.Deserialize<UserDataSnapshot>(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
}
}
/// <summary>
/// Serializable snapshot of all user data for persistence.
/// </summary>
file record UserDataSnapshot
{
public Dictionary<long, DarkUserMemoryDatabase> Users { get; init; } = [];
public Dictionary<string, long> UuidToUserId { get; init; } = [];
public List<UserSession> Sessions { get; init; } = [];
}

View File

@@ -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<AssetDatabase> assetLogger = app.Services.GetRequiredService<ILoggerFactory>().CreateLogger<AssetDatabase>();
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<UserDataStore>();
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<UserDataStore>();
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) => $"<html><head><title>Terms of Service</title></head><body><h1>Terms of Service</h1><p>Language: {languagePath}</p><p>Version: ###123###</p></body></html>");

View File

@@ -8,7 +8,14 @@ namespace MariesWonderland.Http;
/// <summary>
/// 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.
///
/// <para>
/// 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. <see cref="Resolve"/> 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.
/// </para>
/// </summary>
public sealed class AssetDatabase(string basePath, ILogger<AssetDatabase> logger)
{
@@ -35,9 +42,21 @@ public sealed class AssetDatabase(string basePath, ILogger<AssetDatabase> 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public IEnumerable<AssetCandidate> 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<string, ListBinEntry>? currentIndex = LoadListBinIndex(revision);
if ((currentIndex is null || !currentIndex.ContainsKey(objectId)) && revision != "0")
revision = "0";
return ResolveForRevision(revision, assetType, objectId);
}

View File

@@ -0,0 +1,9 @@
namespace MariesWonderland.Models.Type;
public enum QuestStateType
{
UNKNOWN = 0,
ACTIVE = 1,
CLEARED = 2,
CHALLENGE = 4,
}

View File

@@ -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
}

View File

@@ -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<UserDataStore>();
ServerOptions serverOptions = app.Services.GetRequiredService<IOptions<ServerOptions>>().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();

View File

@@ -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<CheckBeforeGamePlayResponse> 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);
}
}

View File

@@ -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<UpdateSequenceResponse> UpdateSequence(UpdateSequenceRequest request, ServerCallContext context)
{
return Task.FromResult(new UpdateSequenceResponse());
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
Dictionary<string, string> 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<UpdateGimmickProgressResponse> UpdateGimmickProgress(UpdateGimmickProgressRequest request, ServerCallContext context)
{
return Task.FromResult(new UpdateGimmickProgressResponse());
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
Dictionary<string, string> 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<InitSequenceScheduleResponse> InitSequenceSchedule(Empty request, ServerCallContext context)
{
return Task.FromResult(new InitSequenceScheduleResponse());
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
Dictionary<string, string> 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<UnlockResponse> Unlock(UnlockRequest request, ServerCallContext context)
{
return Task.FromResult(new UnlockResponse());
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
Dictionary<string, string> 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);
}
}

View File

@@ -1,6 +1,6 @@
using MariesWonderland.Proto.IndividualPop;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Proto.IndividualPop;
namespace MariesWonderland.Services;

View File

@@ -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<GetHeaderNotificationResponse> GetHeaderNotification(Empty request, ServerCallContext context)
{
return Task.FromResult(new GetHeaderNotificationResponse());
return Task.FromResult(new GetHeaderNotificationResponse()
{
FriendRequestReceiveCount = 0,
GiftNotReceiveCount = 0,
IsExistUnreadInformation = false
});
}
}

View File

@@ -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<QuestService> logger) : MariesWonderland.Proto.Quest.QuestService.QuestServiceBase
{
private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
private readonly ILogger<QuestService> _logger = logger;
public override Task<UpdateMainFlowSceneProgressResponse> UpdateMainFlowSceneProgress(UpdateMainFlowSceneProgressRequest request, ServerCallContext context)
{
return Task.FromResult(new UpdateMainFlowSceneProgressResponse());
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
Dictionary<string, string> 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);
}
/// <summary>Adds an entity to a list and returns it (convenience for inline new-entity seeding).</summary>
private static T AddEntity<T>(List<T> list, T entity)
{
list.Add(entity);
return entity;
}
public override Task<UpdateReplayFlowSceneProgressResponse> UpdateReplayFlowSceneProgress(UpdateReplayFlowSceneProgressRequest request, ServerCallContext context)
@@ -18,7 +70,57 @@ public class QuestService : MariesWonderland.Proto.Quest.QuestService.QuestServi
public override Task<UpdateMainQuestSceneProgressResponse> UpdateMainQuestSceneProgress(UpdateMainQuestSceneProgressRequest request, ServerCallContext context)
{
return Task.FromResult(new UpdateMainQuestSceneProgressResponse());
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
Dictionary<string, string> 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<UpdateExtraQuestSceneProgressResponse> UpdateExtraQuestSceneProgress(UpdateExtraQuestSceneProgressRequest request, ServerCallContext context)
@@ -33,7 +135,62 @@ public class QuestService : MariesWonderland.Proto.Quest.QuestService.QuestServi
public override Task<StartMainQuestResponse> StartMainQuest(StartMainQuestRequest request, ServerCallContext context)
{
return Task.FromResult(new StartMainQuestResponse());
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
Dictionary<string, string> 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<int> 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<RestartMainQuestResponse> RestartMainQuest(RestartMainQuestRequest request, ServerCallContext context)

View File

@@ -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<SetTutorialProgressResponse> SetTutorialProgress(SetTutorialProgressRequest request, ServerCallContext context)
{
return Task.FromResult(new SetTutorialProgressResponse());
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = store.GetOrCreate(userId);
Dictionary<string, string> 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<SetTutorialProgressAndReplaceDeckResponse> SetTutorialProgressAndReplaceDeck(SetTutorialProgressAndReplaceDeckRequest request, ServerCallContext context)
{
return Task.FromResult(new SetTutorialProgressAndReplaceDeckResponse());
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = store.GetOrCreate(userId);
Dictionary<string, string> 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;
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}

View File

@@ -65,7 +65,18 @@ public class UserService(UserDataStore store) : MariesWonderland.Proto.User.User
public override Task<GameStartResponse> GameStart(Empty request, ServerCallContext context)
{
return Task.FromResult(new GameStartResponse());
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
Dictionary<string, string> 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<GetBackupTokenResponse> GetBackupToken(GetBackupTokenRequest request, ServerCallContext context)

View File

@@ -14,7 +14,8 @@
"Data": {
"LatestMasterDataVersion": "20240404193219",
"UserDataPath": "Data/UserData",
"MasterDataPath": "Data/MasterData"
"MasterDataPath": "Data/MasterData",
"UserDatabase": "userdata.json"
}
}
}

View File

@@ -22,7 +22,8 @@
"Data": {
"LatestMasterDataVersion": "",
"UserDataPath": "",
"MasterDataPath": ""
"MasterDataPath": "",
"UserDatabase": "userdata.json"
}
}
}