Fix up ExploreService APIs and add unit tests

This commit is contained in:
BillyCool
2026-04-22 01:57:48 +10:00
parent 235688520f
commit c696810e83
12 changed files with 705 additions and 126 deletions

View File

@@ -0,0 +1,14 @@
namespace MariesWonderland.Constants;
public static class ExploreErrors
{
public const string InvalidExploreId = "Invalid ExploreId";
public const string ExploreNotStarted = "Explore has not started yet";
public const string UnlockConditionNotMet = "Explore unlock condition not met";
public const string UserExploreNotFound = "User explore state not found";
public const string TicketNotFound = "Explore ticket not found";
public const string InsufficientTickets = "Insufficient explore tickets";
public const string UserStatusNotFound = "User status not found";
public const string NoMatchingActiveExploreToFinish = "No matching active explore to finish";
public const string NoMatchingActiveExploreToRetire = "No matching active explore to retire";
}

View File

@@ -1,6 +1,6 @@
namespace MariesWonderland; namespace MariesWonderland.Constants;
public static class Constants public static class GameConstants
{ {
public const long MinPlayerId = 1_000_000_000_000L; // 1e12, 1 trillion public const long MinPlayerId = 1_000_000_000_000L; // 1e12, 1 trillion

View File

@@ -25,11 +25,14 @@ public class GameConfig
public int MaterialSameWeaponExpCoefficientPermil { get; init; } public int MaterialSameWeaponExpCoefficientPermil { get; init; }
public int UserStaminaRecoverySecond { get; init; } public int UserStaminaRecoverySecond { get; init; }
public int StaminaMaxCount { get; init; }
public int RewardGachaDailyMaxCount { get; init; } public int RewardGachaDailyMaxCount { get; init; }
public int QuestSkipMaxCountAtOnce { get; init; } public int QuestSkipMaxCountAtOnce { get; init; }
public int WeaponLimitBreakAvailableCount { get; init; } public int WeaponLimitBreakAvailableCount { get; init; }
public int UserLevelExpNumericalParameterMapId { get; init; }
/// <summary>Builds a <see cref="GameConfig"/> from the loaded master config rows.</summary> /// <summary>Builds a <see cref="GameConfig"/> from the loaded master config rows.</summary>
public static GameConfig From(IEnumerable<EntityMConfig> configs) public static GameConfig From(IEnumerable<EntityMConfig> configs)
{ {
@@ -55,10 +58,13 @@ public class GameConfig
MaterialSameWeaponExpCoefficientPermil = ParseInt(kv, "MATERIAL_SAME_WEAPON_EXP_COEFFICIENT_PERMIL"), MaterialSameWeaponExpCoefficientPermil = ParseInt(kv, "MATERIAL_SAME_WEAPON_EXP_COEFFICIENT_PERMIL"),
UserStaminaRecoverySecond = ParseInt(kv, "USER_STAMINA_RECOVERY_SECOND"), UserStaminaRecoverySecond = ParseInt(kv, "USER_STAMINA_RECOVERY_SECOND"),
StaminaMaxCount = ParseInt(kv, "POSSESSION_COUNT_LIMIT_STAMINA"),
RewardGachaDailyMaxCount = ParseInt(kv, "REWARD_GACHA_DAILY_MAX_COUNT"), RewardGachaDailyMaxCount = ParseInt(kv, "REWARD_GACHA_DAILY_MAX_COUNT"),
QuestSkipMaxCountAtOnce = ParseInt(kv, "QUEST_SKIP_MAX_COUNT_AT_ONCE"), QuestSkipMaxCountAtOnce = ParseInt(kv, "QUEST_SKIP_MAX_COUNT_AT_ONCE"),
WeaponLimitBreakAvailableCount = ParseInt(kv, "WEAPON_LIMIT_BREAK_AVAILABLE_COUNT"), WeaponLimitBreakAvailableCount = ParseInt(kv, "WEAPON_LIMIT_BREAK_AVAILABLE_COUNT"),
UserLevelExpNumericalParameterMapId = ParseInt(kv, "USER_LEVEL_EXP_NUMERICAL_PARAMETER_MAP_ID"),
}; };
} }

View File

@@ -1,3 +1,4 @@
using MariesWonderland.Constants;
using MariesWonderland.Models.Entities; using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type; using MariesWonderland.Models.Type;
using System.Text.Json; using System.Text.Json;
@@ -182,7 +183,7 @@ public class UserDataStore(DarkMasterMemoryDatabase masterDb)
private static long GenerateUserId() private static long GenerateUserId()
{ {
// Random 19-digit positive long (range: 1e18 to 2e18) // Random 19-digit positive long (range: 1e18 to 2e18)
return Random.Shared.NextInt64(Constants.MinUserId, Constants.MaxUserId); return Random.Shared.NextInt64(GameConstants.MinUserId, GameConstants.MaxUserId);
} }
/// <summary> /// <summary>
@@ -191,7 +192,7 @@ public class UserDataStore(DarkMasterMemoryDatabase masterDb)
private static long GeneratePlayerId() private static long GeneratePlayerId()
{ {
// Random 12-digit positive long (range: 1e12 to 2e12) // Random 12-digit positive long (range: 1e12 to 2e12)
return Random.Shared.NextInt64(Constants.MinPlayerId, Constants.MaxPlayerId); return Random.Shared.NextInt64(GameConstants.MinPlayerId, GameConstants.MaxPlayerId);
} }
/// <summary> /// <summary>
@@ -265,7 +266,7 @@ public class UserDataStore(DarkMasterMemoryDatabase masterDb)
ChoiceId = 0 ChoiceId = 0
}); });
foreach (int weaponId in Constants.StartingWeaponIds) foreach (int weaponId in GameConstants.StartingWeaponIds)
{ {
string uuid = Guid.NewGuid().ToString(); string uuid = Guid.NewGuid().ToString();

View File

@@ -17,6 +17,7 @@ public static class EntityHelper
/// <summary>Returns the first element matching <paramref name="predicate"/>, creating and adding one via <paramref name="factory"/> if none match.</summary> /// <summary>Returns the first element matching <paramref name="predicate"/>, creating and adding one via <paramref name="factory"/> if none match.</summary>
public static T GetOrCreate<T>(this List<T> list, Func<T, bool> predicate, Func<T> factory) public static T GetOrCreate<T>(this List<T> list, Func<T, bool> predicate, Func<T> factory)
where T : IUserEntity, new()
{ {
T? existing = list.FirstOrDefault(predicate); T? existing = list.FirstOrDefault(predicate);
if (existing is not null) return existing; if (existing is not null) return existing;

View File

@@ -0,0 +1,26 @@
using MariesWonderland.Data;
using MariesWonderland.Models.Entities;
namespace MariesWonderland.Helpers;
/// <summary>
/// Centralises stamina mutation logic. All code that adds or modifies stamina pools should go through here.
/// PVP stamina is a separate pool and will be added here when implemented.
/// </summary>
public static class StaminaHelper
{
/// <summary>
/// Adds <paramref name="amount"/> stamina to the user's regular stamina pool,
/// capped at <see cref="GameConfig.StaminaMaxCount"/>. Updates the stamina timestamp.
/// </summary>
/// <param name="status">The user status entity to mutate.</param>
/// <param name="amount">Amount in regular stamina units (not milli).</param>
/// <param name="gameConfig">Config providing the stamina cap.</param>
/// <param name="nowMs">Current Unix timestamp in milliseconds for the update datetime.</param>
public static void AddStamina(EntityIUserStatus status, int amount, GameConfig gameConfig, long nowMs)
{
int capMilliValue = gameConfig.StaminaMaxCount * 1000;
status.StaminaMilliValue = Math.Min(status.StaminaMilliValue + amount * 1000, capMilliValue);
status.StaminaUpdateDatetime = nowMs;
}
}

View File

@@ -0,0 +1,55 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
namespace MariesWonderland.Helpers;
/// <summary>
/// Evaluates unlock conditions for various game systems against the current user's save data.
/// </summary>
public static class UnlockConditionHelper
{
/// <summary>
/// Returns true if the given explore unlock condition is satisfied for the specified user.
/// Throws <see cref="RpcException"/> with <see cref="StatusCode.Internal"/> for unhandled condition types.
/// </summary>
public static bool IsExploreUnlocked(int exploreId, EntityMExploreUnlockCondition condition,
DarkUserMemoryDatabase userDb, DarkMasterMemoryDatabase masterDb)
{
return condition.ExploreUnlockConditionType switch
{
ExploreUnlockConditionType.MAIN_QUEST_CLEAR =>
IsQuestCleared(userDb, condition.ConditionValue),
ExploreUnlockConditionType.EXPLORE_SCORE_OVER_IN_SAME_GROUP_AND_ONE_LOW_DIFFICULTY =>
HasScoreInLowerDifficulty(exploreId, condition.ConditionValue, userDb, masterDb),
_ => throw new RpcException(new Status(StatusCode.Internal,
$"Unhandled ExploreUnlockConditionType: {condition.ExploreUnlockConditionType}"))
};
}
private static bool IsQuestCleared(DarkUserMemoryDatabase userDb, int questId)
=> userDb.EntityIUserQuest.Any(q => q.QuestId == questId && q.QuestStateType == (int)QuestStateType.CLEARED);
private static bool HasScoreInLowerDifficulty(int exploreId, int requiredScore,
DarkUserMemoryDatabase userDb, DarkMasterMemoryDatabase masterDb)
{
EntityMExploreGroup? groupEntry = masterDb.EntityMExploreGroup
.FirstOrDefault(g => g.ExploreId == exploreId);
if (groupEntry is null) return false;
EntityMExploreGroup? lowerDiffEntry = masterDb.EntityMExploreGroup
.FirstOrDefault(g => g.ExploreGroupId == groupEntry.ExploreGroupId
&& g.DifficultyType == groupEntry.DifficultyType - 1);
if (lowerDiffEntry is null) return false;
EntityIUserExploreScore? score = userDb.EntityIUserExploreScore
.FirstOrDefault(s => s.ExploreId == lowerDiffEntry.ExploreId);
return score is not null && score.MaxScore >= requiredScore;
}
}

View File

@@ -1,68 +1,74 @@
using Grpc.Core; using Grpc.Core;
using MariesWonderland.Constants;
using MariesWonderland.Data; using MariesWonderland.Data;
using MariesWonderland.Extensions; using MariesWonderland.Extensions;
using MariesWonderland.Helpers;
using MariesWonderland.Models.Entities; using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.Explore; using MariesWonderland.Proto.Explore;
namespace MariesWonderland.Services; namespace MariesWonderland.Services;
public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase masterDb) public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase masterDb, GameConfig gameConfig)
: MariesWonderland.Proto.Explore.ExploreService.ExploreServiceBase : MariesWonderland.Proto.Explore.ExploreService.ExploreServiceBase
{ {
private const int StaminaRecovery = 1000; // EXP granted per run = 0.5% of the user's current level EXP requirement × RewardLotteryCount.
private const int RewardMaterialId = 100001; // Normal explore (×1) grants 0.5%; Hard (×11) grants 5.5%.
private const int RewardBaseCount = 1; private const double ExploreExpRewardBasePercent = 0.005;
// Stamina units (not milli); total = base × RewardLotteryCount
private const int ExploreStaminaRewardBase = 50;
// Each lottery draw grants this many of the pulled material
// TODO: Review and add more material IDs to expand the loot pool
private static readonly int[] RewardMaterialPool = [100001];
private readonly UserDataStore _store = store; private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb; private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
private readonly GameConfig _gameConfig = gameConfig;
/// <summary>Begins an explore expedition: deducts the consumable ticket and records the active expedition.</summary> /// <summary>Begins an explore expedition: deducts the consumable ticket and records the active expedition.</summary>
public override Task<StartExploreResponse> StartExplore(StartExploreRequest request, ServerCallContext context) public override Task<StartExploreResponse> StartExplore(StartExploreRequest request, ServerCallContext context)
{ {
long userId = context.GetUserId(); DarkUserMemoryDatabase userDb = _store.GetOrCreate(context.GetUserId());
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityMExplore? explore = _masterDb.EntityMExplore EntityMExplore explore = _masterDb.EntityMExplore
.FirstOrDefault(e => e.ExploreId == request.ExploreId); .FirstOrDefault(e => e.ExploreId == request.ExploreId)
?? throw new RpcException(new Status(StatusCode.InvalidArgument, ExploreErrors.InvalidExploreId));
if (explore is null) if (nowMs < explore.StartDatetime)
throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.ExploreNotStarted));
EntityMExploreUnlockCondition? unlockCondition = _masterDb.EntityMExploreUnlockCondition
.FirstOrDefault(c => c.ExploreUnlockConditionId == explore.ExploreUnlockConditionId);
if (unlockCondition is not null && !UnlockConditionHelper.IsExploreUnlocked(request.ExploreId, unlockCondition, userDb, _masterDb))
throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.UnlockConditionNotMet));
EntityIUserExplore userExplore = userDb.EntityIUserExplore.FirstOrDefault()
?? throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.UserExploreNotFound));
bool isUsingTicket = request.UseConsumableItemId == _gameConfig.ConsumableItemIdForExploreTicket;
// Deduct explore ticket if the request specifies it
if (isUsingTicket && explore.ConsumeItemCount > 0)
{ {
return Task.FromResult(new StartExploreResponse()); EntityIUserConsumableItem item = userDb.EntityIUserConsumableItem
} .FirstOrDefault(i => i.ConsumableItemId == request.UseConsumableItemId)
?? throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.TicketNotFound));
// Deduct consumable ticket if required if (item.Count < explore.ConsumeItemCount)
if (request.UseConsumableItemId > 0 && explore.ConsumeItemCount > 0) throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.InsufficientTickets));
{
EntityIUserConsumableItem? item = userDb.EntityIUserConsumableItem
.FirstOrDefault(i => i.ConsumableItemId == request.UseConsumableItemId);
if (item is not null) item.Count -= explore.ConsumeItemCount;
{
item.Count -= explore.ConsumeItemCount;
}
} }
// Record or update the active expedition state // Record or update the active expedition state
EntityIUserExplore? userExplore = userDb.EntityIUserExplore userExplore.PlayingExploreId = request.ExploreId;
.FirstOrDefault(e => e.UserId == userId); userExplore.IsUseExploreTicket = isUsingTicket;
userExplore.LatestPlayDatetime = nowMs;
if (userExplore is null)
{
userDb.EntityIUserExplore.Add(new EntityIUserExplore
{
UserId = userId,
PlayingExploreId = request.ExploreId,
IsUseExploreTicket = false,
LatestPlayDatetime = nowMs
});
}
else
{
userExplore.PlayingExploreId = request.ExploreId;
userExplore.IsUseExploreTicket = false;
userExplore.LatestPlayDatetime = nowMs;
}
return Task.FromResult(new StartExploreResponse()); return Task.FromResult(new StartExploreResponse());
} }
@@ -70,21 +76,26 @@ public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase master
/// <summary>Completes an explore expedition: updates the high score, clears the active state, recovers stamina, and grants material rewards.</summary> /// <summary>Completes an explore expedition: updates the high score, clears the active state, recovers stamina, and grants material rewards.</summary>
public override Task<FinishExploreResponse> FinishExplore(FinishExploreRequest request, ServerCallContext context) public override Task<FinishExploreResponse> FinishExplore(FinishExploreRequest request, ServerCallContext context)
{ {
long userId = context.GetUserId(); DarkUserMemoryDatabase userDb = _store.GetOrCreate(context.GetUserId());
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityMExplore? explore = _masterDb.EntityMExplore EntityMExplore explore = _masterDb.EntityMExplore
.FirstOrDefault(e => e.ExploreId == request.ExploreId); .FirstOrDefault(e => e.ExploreId == request.ExploreId)
?? throw new RpcException(new Status(StatusCode.InvalidArgument, ExploreErrors.InvalidExploreId));
if (explore is null) EntityIUserExplore userExplore = userDb.EntityIUserExplore.FirstOrDefault()
{ ?? throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.UserExploreNotFound));
return Task.FromResult(new FinishExploreResponse());
}
int rewardCount = RewardBaseCount * explore.RewardLotteryCount; if (userExplore.PlayingExploreId != request.ExploreId)
throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.NoMatchingActiveExploreToFinish));
// Update or create score EntityIUserStatus status = userDb.EntityIUserStatus.FirstOrDefault()
?? throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.UserStatusNotFound));
int staminaReward = ExploreStaminaRewardBase * explore.RewardLotteryCount;
int expReward = ComputeExpReward(status.Level, explore.RewardLotteryCount);
// Update or create score record — only track personal best
EntityIUserExploreScore? score = userDb.EntityIUserExploreScore EntityIUserExploreScore? score = userDb.EntityIUserExploreScore
.FirstOrDefault(s => s.ExploreId == request.ExploreId); .FirstOrDefault(s => s.ExploreId == request.ExploreId);
@@ -92,7 +103,7 @@ public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase master
{ {
userDb.EntityIUserExploreScore.Add(new EntityIUserExploreScore userDb.EntityIUserExploreScore.Add(new EntityIUserExploreScore
{ {
UserId = userId, UserId = userDb.UserId,
ExploreId = request.ExploreId, ExploreId = request.ExploreId,
MaxScore = request.Score, MaxScore = request.Score,
MaxScoreUpdateDatetime = nowMs MaxScoreUpdateDatetime = nowMs
@@ -105,59 +116,55 @@ public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase master
} }
// Clear playing state // Clear playing state
EntityIUserExplore? userExplore = userDb.EntityIUserExplore userExplore.PlayingExploreId = 0;
.FirstOrDefault(e => e.UserId == userId); userExplore.IsUseExploreTicket = false;
if (userExplore is not null) // Recover stamina (capped at the configured maximum)
{ StaminaHelper.AddStamina(status, staminaReward, _gameConfig, nowMs);
userExplore.PlayingExploreId = 0;
userExplore.IsUseExploreTicket = false;
}
// Recover stamina // Grant EXP; max gain per run is 5.5% of a level so at most one level-up is possible
EntityIUserStatus? status = userDb.EntityIUserStatus status.Exp += expReward;
.FirstOrDefault(s => s.UserId == userId); int mapId = _gameConfig.UserLevelExpNumericalParameterMapId;
EntityMNumericalParameterMap? nextLevelEntry = _masterDb.EntityMNumericalParameterMap
.FirstOrDefault(x => x.NumericalParameterMapId == mapId && x.ParameterKey == status.Level + 1);
if (nextLevelEntry is not null && status.Exp >= nextLevelEntry.ParameterValue)
status.Level++;
if (status is not null) // Resolve grade and compute per-draw stack size
{ int gradeId = ResolveGradeId(request.ExploreId, request.Score);
status.StaminaMilliValue += StaminaRecovery; int assetGradeIconId = _masterDb.EntityMExploreGradeAsset
status.StaminaUpdateDatetime = nowMs; .FirstOrDefault(a => a.ExploreGradeId == gradeId)?.AssetGradeIconId ?? 0;
} int stackPerDraw = GradeRewardStack(gradeId);
// Grant material reward
EntityIUserMaterial? material = userDb.EntityIUserMaterial
.FirstOrDefault(m => m.MaterialId == RewardMaterialId);
if (material is not null)
{
material.Count += rewardCount;
}
else
{
userDb.EntityIUserMaterial.Add(new EntityIUserMaterial
{
UserId = userId,
MaterialId = RewardMaterialId,
Count = rewardCount,
FirstAcquisitionDatetime = nowMs
});
}
// Determine grade icon
int assetGradeIconId = GradeForScore(request.ExploreId, request.Score);
FinishExploreResponse response = new() FinishExploreResponse response = new()
{ {
AcquireStaminaCount = StaminaRecovery, AcquireStaminaCount = staminaReward,
AssetGradeIconId = assetGradeIconId AssetGradeIconId = assetGradeIconId
}; };
response.ExploreReward.Add(new ExploreReward // Grade 106 (worst) yields no material; skip drawing entirely to avoid zero-count reward entries
if (stackPerDraw > 0)
{ {
PossessionType = (int)Models.Type.PossessionType.MATERIAL, // Draw RewardLotteryCount items from the pool (with replacement); stack size per draw is grade-dependent
PossessionId = RewardMaterialId, Dictionary<int, int> draws = [];
Count = rewardCount for (int i = 0; i < explore.RewardLotteryCount; i++)
}); {
int materialId = RewardMaterialPool[Random.Shared.Next(RewardMaterialPool.Length)];
draws[materialId] = draws.GetValueOrDefault(materialId) + 1;
}
foreach (var (materialId, drawCount) in draws)
{
int totalCount = drawCount * stackPerDraw;
PossessionHelper.Apply(userDb, PossessionType.MATERIAL, materialId, totalCount, _masterDb);
response.ExploreReward.Add(new ExploreReward
{
PossessionType = (int)PossessionType.MATERIAL,
PossessionId = materialId,
Count = totalCount
});
}
}
return Task.FromResult(response); return Task.FromResult(response);
} }
@@ -165,40 +172,69 @@ public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase master
/// <summary>Cancels an in-progress explore expedition without granting rewards.</summary> /// <summary>Cancels an in-progress explore expedition without granting rewards.</summary>
public override Task<RetireExploreResponse> RetireExplore(RetireExploreRequest request, ServerCallContext context) public override Task<RetireExploreResponse> RetireExplore(RetireExploreRequest request, ServerCallContext context)
{ {
long userId = context.GetUserId(); DarkUserMemoryDatabase userDb = _store.GetOrCreate(context.GetUserId());
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserExplore? userExplore = userDb.EntityIUserExplore EntityIUserExplore userExplore = userDb.EntityIUserExplore.FirstOrDefault()
.FirstOrDefault(e => e.UserId == userId); ?? throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.UserExploreNotFound));
if (userExplore is not null) if (userExplore.PlayingExploreId != request.ExploreId)
{ throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.NoMatchingActiveExploreToRetire));
userExplore.PlayingExploreId = 0;
userExplore.IsUseExploreTicket = false; userExplore.PlayingExploreId = 0;
} userExplore.IsUseExploreTicket = false;
return Task.FromResult(new RetireExploreResponse()); return Task.FromResult(new RetireExploreResponse());
} }
/// <summary>Resolves the grade icon for a given explore score by checking thresholds in descending order.</summary> /// <summary>
private int GradeForScore(int exploreId, int score) /// Computes EXP to grant for an explore run: 0.5% of the EXP required to complete
/// the user's current level, multiplied by <paramref name="lotteryCount"/>.
/// Returns 0 if the level thresholds are not available in master data.
/// </summary>
private int ComputeExpReward(int level, int lotteryCount)
{ {
// Grade scores sorted descending by NecessaryScore; first match where score >= threshold wins int mapId = _gameConfig.UserLevelExpNumericalParameterMapId;
List<EntityMExploreGradeScore> gradeScores = [.. _masterDb.EntityMExploreGradeScore
EntityMNumericalParameterMap? current = _masterDb.EntityMNumericalParameterMap
.FirstOrDefault(x => x.NumericalParameterMapId == mapId && x.ParameterKey == level);
EntityMNumericalParameterMap? next = _masterDb.EntityMNumericalParameterMap
.FirstOrDefault(x => x.NumericalParameterMapId == mapId && x.ParameterKey == level + 1);
if (current is null || next is null)
return 0;
int levelRequirement = next.ParameterValue - current.ParameterValue;
return (int)(levelRequirement * ExploreExpRewardBasePercent * lotteryCount);
}
/// <summary>Resolves the ExploreGradeId for a given score by checking thresholds in descending order.</summary>
private int ResolveGradeId(int exploreId, int score)
{
IEnumerable<EntityMExploreGradeScore> gradeScores = _masterDb.EntityMExploreGradeScore
.Where(gs => gs.ExploreId == exploreId) .Where(gs => gs.ExploreId == exploreId)
.OrderByDescending(gs => gs.NecessaryScore)]; .OrderByDescending(gs => gs.NecessaryScore);
foreach (EntityMExploreGradeScore gs in gradeScores) foreach (EntityMExploreGradeScore gs in gradeScores)
{ {
if (score >= gs.NecessaryScore) if (score >= gs.NecessaryScore)
{ return gs.ExploreGradeId;
EntityMExploreGradeAsset? asset = _masterDb.EntityMExploreGradeAsset
.FirstOrDefault(a => a.ExploreGradeId == gs.ExploreGradeId);
return asset?.AssetGradeIconId ?? 0;
}
} }
return 0; return 0;
} }
/// <summary>
/// Returns the number of material items to grant per lottery draw based on the grade.
/// Grades 101 (best) through 106 (worst). Grade 106 yields 0 — no material rewards.
/// Values are design estimates — no master data source.
/// </summary>
private static int GradeRewardStack(int gradeId) => gradeId switch
{
101 => 10,
102 => 8,
103 => 6,
104 => 4,
105 => 2,
_ => 0 // grade 106 (worst) or unknown — no material rewards
};
} }

View File

@@ -1,4 +1,5 @@
using Grpc.Core; using Grpc.Core;
using MariesWonderland.Constants;
using MariesWonderland.Data; using MariesWonderland.Data;
using MariesWonderland.Extensions; using MariesWonderland.Extensions;
using MariesWonderland.Helpers; using MariesWonderland.Helpers;
@@ -93,12 +94,12 @@ public class TutorialService(UserDataStore store, DarkMasterMemoryDatabase maste
// Hardcoded to Rion & Everlasting Cardia // Hardcoded to Rion & Everlasting Cardia
string costumeUuid = db.EntityIUserCostume string costumeUuid = db.EntityIUserCostume
.Where(c => c.CostumeId == Constants.StartingDeckCostumeId) .Where(c => c.CostumeId == GameConstants.StartingDeckCostumeId)
.Select(c => c.UserCostumeUuid) .Select(c => c.UserCostumeUuid)
.Single(); .Single();
string weaponUuid = db.EntityIUserWeapon string weaponUuid = db.EntityIUserWeapon
.Where(w => w.WeaponId == Constants.StartingDeckWeaponId) .Where(w => w.WeaponId == GameConstants.StartingDeckWeaponId)
.Select(w => w.UserWeaponUuid) .Select(w => w.UserWeaponUuid)
.Single(); .Single();

View File

@@ -78,18 +78,15 @@ public class UserService(UserDataStore store, UserDataSeeder seeder) : MariesWon
EntityIUser user = userDb.EntityIUser.GetOrCreate(userId); EntityIUser user = userDb.EntityIUser.GetOrCreate(userId);
user.GameStartDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); user.GameStartDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Initialize gem balance with 0/0 if not exists // Initialize singleton entities
if (!userDb.EntityIUserGem.Any(g => g.UserId == userId)) userDb.EntityIUserGem.Add(new EntityIUserGem { UserId = userId, PaidGem = 0, FreeGem = 0 });
{ userDb.EntityIUserExplore.Add(new EntityIUserExplore { UserId = userId });
userDb.EntityIUserGem.Add(new EntityIUserGem { UserId = userId, PaidGem = 0, FreeGem = 0 });
}
// TODO: Investigate if these singleton tables need to be pre-initialized at registration. // TODO: Investigate if these singleton tables need to be pre-initialized at registration.
// Uncomment to enable initialization: // Uncomment to enable initialization:
// userDb.EntityIUserBigHuntProgressStatus.AddNew(new EntityIUserBigHuntProgressStatus { UserId = userId }); // userDb.EntityIUserBigHuntProgressStatus.AddNew(new EntityIUserBigHuntProgressStatus { UserId = userId });
// userDb.EntityIUserEventQuestGuerrillaFreeOpen.AddNew(new EntityIUserEventQuestGuerrillaFreeOpen { UserId = userId }); // userDb.EntityIUserEventQuestGuerrillaFreeOpen.AddNew(new EntityIUserEventQuestGuerrillaFreeOpen { UserId = userId });
// userDb.EntityIUserEventQuestProgressStatus.AddNew(new EntityIUserEventQuestProgressStatus { UserId = userId }); // userDb.EntityIUserEventQuestProgressStatus.AddNew(new EntityIUserEventQuestProgressStatus { UserId = userId });
// userDb.EntityIUserExplore.AddNew(new EntityIUserExplore { UserId = userId });
// userDb.EntityIUserExtraQuestProgressStatus.AddNew(new EntityIUserExtraQuestProgressStatus { UserId = userId }); // userDb.EntityIUserExtraQuestProgressStatus.AddNew(new EntityIUserExtraQuestProgressStatus { UserId = userId });
// userDb.EntityIUserMainQuestFlowStatus.AddNew(new EntityIUserMainQuestFlowStatus { UserId = userId }); // userDb.EntityIUserMainQuestFlowStatus.AddNew(new EntityIUserMainQuestFlowStatus { UserId = userId });
// userDb.EntityIUserMainQuestMainFlowStatus.AddNew(new EntityIUserMainQuestMainFlowStatus { UserId = userId }); // userDb.EntityIUserMainQuestMainFlowStatus.AddNew(new EntityIUserMainQuestMainFlowStatus { UserId = userId });

View File

@@ -0,0 +1,42 @@
using MariesWonderland.Data;
using MariesWonderland.Helpers;
using MariesWonderland.Models.Entities;
namespace MariesWonderland.Tests.Helpers;
public class StaminaHelperTests
{
private static GameConfig ConfigWithCap(int cap) => new() { StaminaMaxCount = cap };
[Fact]
public void AddStamina_BelowCap_AddsFullAmount()
{
var status = new EntityIUserStatus { StaminaMilliValue = 0 };
StaminaHelper.AddStamina(status, 50, ConfigWithCap(999), nowMs: 1000L);
Assert.Equal(50_000, status.StaminaMilliValue);
Assert.Equal(1000L, status.StaminaUpdateDatetime);
}
[Fact]
public void AddStamina_ExceedsCap_ClampsToMax()
{
// Start near cap (990 stamina), add 50 → should clamp to 999
var status = new EntityIUserStatus { StaminaMilliValue = 990_000 };
StaminaHelper.AddStamina(status, 50, ConfigWithCap(999), nowMs: 2000L);
Assert.Equal(999_000, status.StaminaMilliValue);
}
[Fact]
public void AddStamina_AlreadyAtCap_DoesNotExceed()
{
var status = new EntityIUserStatus { StaminaMilliValue = 999_000 };
StaminaHelper.AddStamina(status, 50, ConfigWithCap(999), nowMs: 3000L);
Assert.Equal(999_000, status.StaminaMilliValue);
}
}

View File

@@ -0,0 +1,400 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.Explore;
using MariesWonderland.Tests.Infrastructure;
using ExploreService = MariesWonderland.Services.ExploreService;
namespace MariesWonderland.Tests.Services;
public class ExploreServiceTests(MasterDatabaseFixture fixture) : ServiceTestBase(fixture), IClassFixture<MasterDatabaseFixture>
{
private const long UserId = 1L;
private const int ExploreId = 1; // Normal explore, requires quest 31 cleared
private const int UnlockQuestId = 31;
private const int TicketItemId = 2001; // ConsumableItemIdForExploreTicket
private ExploreService CreateService(UserDataStore store) => new(store, MasterDb, GameConfig);
private static void AddQuestCleared(DarkUserMemoryDatabase userDb, int questId = UnlockQuestId)
=> userDb.EntityIUserQuest.Add(new EntityIUserQuest
{
UserId = UserId,
QuestId = questId,
QuestStateType = (int)QuestStateType.CLEARED
});
private static EntityIUserExplore AddUserExplore(DarkUserMemoryDatabase userDb,
int playingExploreId = 0, bool isUsingTicket = false)
{
var entity = new EntityIUserExplore
{
UserId = UserId,
PlayingExploreId = playingExploreId,
IsUseExploreTicket = isUsingTicket
};
userDb.EntityIUserExplore.Add(entity);
return entity;
}
private static EntityIUserStatus AddUserStatus(DarkUserMemoryDatabase userDb,
int staminaMilliValue = 5000, int level = 0, int exp = 0)
{
var entity = new EntityIUserStatus { UserId = UserId, StaminaMilliValue = staminaMilliValue, Level = level, Exp = exp };
userDb.EntityIUserStatus.Add(entity);
return entity;
}
#region StartExplore
[Fact]
public async Task StartExplore_FreePlay_SetsPlayingStateWithoutDeductingItem()
{
var userDb = CreateUserDb();
AddQuestCleared(userDb);
var userExplore = AddUserExplore(userDb);
userDb.EntityIUserConsumableItem.Add(new EntityIUserConsumableItem
{
UserId = UserId, ConsumableItemId = TicketItemId, Count = 3
});
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
await service.StartExplore(
new StartExploreRequest { ExploreId = ExploreId, UseConsumableItemId = 0 },
ContextFor(UserId));
Assert.Equal(ExploreId, userExplore.PlayingExploreId);
Assert.False(userExplore.IsUseExploreTicket);
Assert.True(userExplore.LatestPlayDatetime > 0);
Assert.Equal(3, userDb.EntityIUserConsumableItem[0].Count); // not deducted
}
[Fact]
public async Task StartExplore_WithTicket_DeductsTicketAndSetsFlag()
{
var userDb = CreateUserDb();
AddQuestCleared(userDb);
var userExplore = AddUserExplore(userDb);
userDb.EntityIUserConsumableItem.Add(new EntityIUserConsumableItem
{
UserId = UserId, ConsumableItemId = TicketItemId, Count = 3
});
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
await service.StartExplore(
new StartExploreRequest { ExploreId = ExploreId, UseConsumableItemId = TicketItemId },
ContextFor(UserId));
Assert.Equal(ExploreId, userExplore.PlayingExploreId);
Assert.True(userExplore.IsUseExploreTicket);
Assert.Equal(2, userDb.EntityIUserConsumableItem[0].Count); // 3 - 1 = 2
}
[Fact]
public async Task StartExplore_InvalidExploreId_ThrowsInvalidArgument()
{
var store = CreateStore(UserId, CreateUserDb(), MasterDb);
var service = CreateService(store);
var ex = await Assert.ThrowsAsync<RpcException>(() =>
service.StartExplore(
new StartExploreRequest { ExploreId = 9999 },
ContextFor(UserId)));
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
[Fact]
public async Task StartExplore_UnlockConditionNotMet_ThrowsFailedPrecondition()
{
var userDb = CreateUserDb();
AddUserExplore(userDb); // quest 31 NOT cleared
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
var ex = await Assert.ThrowsAsync<RpcException>(() =>
service.StartExplore(
new StartExploreRequest { ExploreId = ExploreId },
ContextFor(UserId)));
Assert.Equal(StatusCode.FailedPrecondition, ex.StatusCode);
}
[Fact]
public async Task StartExplore_TicketNotFound_ThrowsFailedPrecondition()
{
var userDb = CreateUserDb();
AddQuestCleared(userDb);
AddUserExplore(userDb); // no consumable item record
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
var ex = await Assert.ThrowsAsync<RpcException>(() =>
service.StartExplore(
new StartExploreRequest { ExploreId = ExploreId, UseConsumableItemId = TicketItemId },
ContextFor(UserId)));
Assert.Equal(StatusCode.FailedPrecondition, ex.StatusCode);
}
[Fact]
public async Task StartExplore_InsufficientTickets_ThrowsFailedPrecondition()
{
var userDb = CreateUserDb();
AddQuestCleared(userDb);
AddUserExplore(userDb);
userDb.EntityIUserConsumableItem.Add(new EntityIUserConsumableItem
{
UserId = UserId, ConsumableItemId = TicketItemId, Count = 0
});
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
var ex = await Assert.ThrowsAsync<RpcException>(() =>
service.StartExplore(
new StartExploreRequest { ExploreId = ExploreId, UseConsumableItemId = TicketItemId },
ContextFor(UserId)));
Assert.Equal(StatusCode.FailedPrecondition, ex.StatusCode);
}
#endregion
#region RetireExplore
[Fact]
public async Task RetireExplore_WithActiveExplore_ClearsState()
{
var userDb = CreateUserDb();
var userExplore = AddUserExplore(userDb, playingExploreId: ExploreId, isUsingTicket: true);
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
await service.RetireExplore(
new RetireExploreRequest { ExploreId = ExploreId },
ContextFor(UserId));
Assert.Equal(0, userExplore.PlayingExploreId);
Assert.False(userExplore.IsUseExploreTicket);
}
[Fact]
public async Task RetireExplore_NoMatchingActiveExplore_ThrowsFailedPrecondition()
{
var userDb = CreateUserDb();
AddUserExplore(userDb, playingExploreId: 0);
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
var ex = await Assert.ThrowsAsync<RpcException>(() =>
service.RetireExplore(
new RetireExploreRequest { ExploreId = ExploreId },
ContextFor(UserId)));
Assert.Equal(StatusCode.FailedPrecondition, ex.StatusCode);
}
[Fact]
public async Task RetireExplore_UserExploreNotFound_ThrowsFailedPrecondition()
{
var store = CreateStore(UserId, CreateUserDb(), MasterDb);
var service = CreateService(store);
var ex = await Assert.ThrowsAsync<RpcException>(() =>
service.RetireExplore(
new RetireExploreRequest { ExploreId = ExploreId },
ContextFor(UserId)));
Assert.Equal(StatusCode.FailedPrecondition, ex.StatusCode);
}
#endregion
#region FinishExplore
[Fact]
public async Task FinishExplore_NewHighScore_GrantsRewardsAndClearsState()
{
// Score 50000 hits the 50000 threshold → ExploreGradeId 102 → AssetGradeIconId 17000
const int score = 50000;
const int expectedGradeIconId = 17000;
var userDb = CreateUserDb();
var userExplore = AddUserExplore(userDb, playingExploreId: ExploreId, isUsingTicket: true);
var status = AddUserStatus(userDb, staminaMilliValue: 5000, level: 229);
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
var response = await service.FinishExplore(
new FinishExploreRequest { ExploreId = ExploreId, Score = score },
ContextFor(UserId));
// Score record created
Assert.Single(userDb.EntityIUserExploreScore);
Assert.Equal(score, userDb.EntityIUserExploreScore[0].MaxScore);
// Active state cleared
Assert.Equal(0, userExplore.PlayingExploreId);
Assert.False(userExplore.IsUseExploreTicket);
// Stamina recovered (50 base × RewardLotteryCount 1 = 50 stamina = 50000 milli)
Assert.Equal(55000, status.StaminaMilliValue);
// EXP granted: 0.5% of level 229 requirement (884,516) × 1 = 4,422
Assert.Equal(4422, status.Exp);
// Material granted: 1 draw × 8 per draw (grade 102) = 8 of material 100001
Assert.Single(userDb.EntityIUserMaterial);
Assert.Equal(8, userDb.EntityIUserMaterial[0].Count);
// Response populated
Assert.Equal(50, response.AcquireStaminaCount);
Assert.Equal(expectedGradeIconId, response.AssetGradeIconId);
Assert.Single(response.ExploreReward);
Assert.Equal(8, response.ExploreReward[0].Count);
}
[Fact]
public async Task FinishExplore_ImprovedScore_UpdatesMaxScore()
{
var userDb = CreateUserDb();
AddUserExplore(userDb, playingExploreId: ExploreId);
AddUserStatus(userDb);
userDb.EntityIUserExploreScore.Add(new EntityIUserExploreScore
{
UserId = UserId, ExploreId = ExploreId, MaxScore = 50000
});
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
await service.FinishExplore(
new FinishExploreRequest { ExploreId = ExploreId, Score = 80000 },
ContextFor(UserId));
Assert.Equal(80000, userDb.EntityIUserExploreScore[0].MaxScore);
}
[Fact]
public async Task FinishExplore_LowerScore_DoesNotUpdateMaxScore()
{
var userDb = CreateUserDb();
AddUserExplore(userDb, playingExploreId: ExploreId);
AddUserStatus(userDb);
userDb.EntityIUserExploreScore.Add(new EntityIUserExploreScore
{
UserId = UserId, ExploreId = ExploreId, MaxScore = 80000
});
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
await service.FinishExplore(
new FinishExploreRequest { ExploreId = ExploreId, Score = 50000 },
ContextFor(UserId));
Assert.Equal(80000, userDb.EntityIUserExploreScore[0].MaxScore);
}
[Fact]
public async Task FinishExplore_InvalidExploreId_ThrowsInvalidArgument()
{
var store = CreateStore(UserId, CreateUserDb(), MasterDb);
var service = CreateService(store);
var ex = await Assert.ThrowsAsync<RpcException>(() =>
service.FinishExplore(
new FinishExploreRequest { ExploreId = 9999 },
ContextFor(UserId)));
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
[Fact]
public async Task FinishExplore_NoMatchingActiveExplore_ThrowsFailedPrecondition()
{
var userDb = CreateUserDb();
AddUserExplore(userDb, playingExploreId: 0); // nothing active
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
var ex = await Assert.ThrowsAsync<RpcException>(() =>
service.FinishExplore(
new FinishExploreRequest { ExploreId = ExploreId },
ContextFor(UserId)));
Assert.Equal(StatusCode.FailedPrecondition, ex.StatusCode);
}
[Fact]
public async Task FinishExplore_UserExploreNotFound_ThrowsFailedPrecondition()
{
var store = CreateStore(UserId, CreateUserDb(), MasterDb);
var service = CreateService(store);
var ex = await Assert.ThrowsAsync<RpcException>(() =>
service.FinishExplore(
new FinishExploreRequest { ExploreId = ExploreId },
ContextFor(UserId)));
Assert.Equal(StatusCode.FailedPrecondition, ex.StatusCode);
}
[Fact]
public async Task FinishExplore_UserStatusNotFound_ThrowsFailedPrecondition()
{
var userDb = CreateUserDb();
AddUserExplore(userDb, playingExploreId: ExploreId); // no status record
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
var ex = await Assert.ThrowsAsync<RpcException>(() =>
service.FinishExplore(
new FinishExploreRequest { ExploreId = ExploreId },
ContextFor(UserId)));
Assert.Equal(StatusCode.FailedPrecondition, ex.StatusCode);
}
[Fact]
public async Task FinishExplore_ExpGrantCrossesThreshold_AdvancesLevel()
{
// Level 229 → 230 threshold is 87,191,772. Start 100 EXP below so the grant (4,422) pushes us over.
const int level230Threshold = 87_191_772;
const int startExp = level230Threshold - 100;
var userDb = CreateUserDb();
AddUserExplore(userDb, playingExploreId: ExploreId);
var status = AddUserStatus(userDb, staminaMilliValue: 0, level: 229, exp: startExp);
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
await service.FinishExplore(
new FinishExploreRequest { ExploreId = ExploreId, Score = 0 },
ContextFor(UserId));
Assert.Equal(230, status.Level);
Assert.True(status.Exp >= level230Threshold);
}
[Fact]
public async Task FinishExplore_WorstGrade_GrantsNoMaterials()
{
// Score 0 → grade 106 (worst) → stackPerDraw = 0 → no ExploreReward entries
var userDb = CreateUserDb();
AddUserExplore(userDb, playingExploreId: ExploreId);
AddUserStatus(userDb, level: 229);
var store = CreateStore(UserId, userDb, MasterDb);
var service = CreateService(store);
var response = await service.FinishExplore(
new FinishExploreRequest { ExploreId = ExploreId, Score = 0 },
ContextFor(UserId));
Assert.Empty(userDb.EntityIUserMaterial);
Assert.Empty(response.ExploreReward);
}
#endregion
}