mirror of
https://github.com/BillyCool/MariesWonderland.git
synced 2026-03-22 06:52:24 +01:00
Implement more APIs. Optimize asset and resource loading.
This commit is contained in:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>");
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
9
src/Models/Type/QuestStateType.cs
Normal file
9
src/Models/Type/QuestStateType.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace MariesWonderland.Models.Type;
|
||||
|
||||
public enum QuestStateType
|
||||
{
|
||||
UNKNOWN = 0,
|
||||
ACTIVE = 1,
|
||||
CLEARED = 2,
|
||||
CHALLENGE = 4,
|
||||
}
|
||||
48
src/Models/Type/SystemLanguage.cs
Normal file
48
src/Models/Type/SystemLanguage.cs
Normal 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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using MariesWonderland.Proto.IndividualPop;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using MariesWonderland.Proto.IndividualPop;
|
||||
|
||||
namespace MariesWonderland.Services;
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"Data": {
|
||||
"LatestMasterDataVersion": "20240404193219",
|
||||
"UserDataPath": "Data/UserData",
|
||||
"MasterDataPath": "Data/MasterData"
|
||||
"MasterDataPath": "Data/MasterData",
|
||||
"UserDatabase": "userdata.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"Data": {
|
||||
"LatestMasterDataVersion": "",
|
||||
"UserDataPath": "",
|
||||
"MasterDataPath": ""
|
||||
"MasterDataPath": "",
|
||||
"UserDatabase": "userdata.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user