From c696810e839913a44667c2e4cf152d5480a5ded0 Mon Sep 17 00:00:00 2001 From: BillyCool Date: Wed, 22 Apr 2026 01:57:48 +1000 Subject: [PATCH] Fix up ExploreService APIs and add unit tests --- src/Constants/ExploreErrors.cs | 14 + .../GameConstants.cs} | 4 +- src/Data/GameConfig.cs | 6 + src/Data/UserDataStore.cs | 7 +- src/Helpers/EntityHelper.cs | 1 + src/Helpers/StaminaHelper.cs | 26 ++ src/Helpers/UnlockConditionHelper.cs | 55 +++ src/Services/ExploreService.cs | 262 +++++++----- src/Services/TutorialService.cs | 5 +- src/Services/UserService.cs | 9 +- tests/Helpers/StaminaHelperTests.cs | 42 ++ tests/Services/ExploreServiceTests.cs | 400 ++++++++++++++++++ 12 files changed, 705 insertions(+), 126 deletions(-) create mode 100644 src/Constants/ExploreErrors.cs rename src/{Constants.cs => Constants/GameConstants.cs} (89%) create mode 100644 src/Helpers/StaminaHelper.cs create mode 100644 src/Helpers/UnlockConditionHelper.cs create mode 100644 tests/Helpers/StaminaHelperTests.cs create mode 100644 tests/Services/ExploreServiceTests.cs diff --git a/src/Constants/ExploreErrors.cs b/src/Constants/ExploreErrors.cs new file mode 100644 index 0000000..8ecd6db --- /dev/null +++ b/src/Constants/ExploreErrors.cs @@ -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"; +} diff --git a/src/Constants.cs b/src/Constants/GameConstants.cs similarity index 89% rename from src/Constants.cs rename to src/Constants/GameConstants.cs index faae953..0d5bcae 100644 --- a/src/Constants.cs +++ b/src/Constants/GameConstants.cs @@ -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 diff --git a/src/Data/GameConfig.cs b/src/Data/GameConfig.cs index f098f71..2675c74 100644 --- a/src/Data/GameConfig.cs +++ b/src/Data/GameConfig.cs @@ -25,11 +25,14 @@ public class GameConfig public int MaterialSameWeaponExpCoefficientPermil { get; init; } public int UserStaminaRecoverySecond { get; init; } + public int StaminaMaxCount { get; init; } public int RewardGachaDailyMaxCount { get; init; } public int QuestSkipMaxCountAtOnce { get; init; } public int WeaponLimitBreakAvailableCount { get; init; } + public int UserLevelExpNumericalParameterMapId { get; init; } + /// Builds a from the loaded master config rows. public static GameConfig From(IEnumerable configs) { @@ -55,10 +58,13 @@ public class GameConfig MaterialSameWeaponExpCoefficientPermil = ParseInt(kv, "MATERIAL_SAME_WEAPON_EXP_COEFFICIENT_PERMIL"), UserStaminaRecoverySecond = ParseInt(kv, "USER_STAMINA_RECOVERY_SECOND"), + StaminaMaxCount = ParseInt(kv, "POSSESSION_COUNT_LIMIT_STAMINA"), RewardGachaDailyMaxCount = ParseInt(kv, "REWARD_GACHA_DAILY_MAX_COUNT"), QuestSkipMaxCountAtOnce = ParseInt(kv, "QUEST_SKIP_MAX_COUNT_AT_ONCE"), WeaponLimitBreakAvailableCount = ParseInt(kv, "WEAPON_LIMIT_BREAK_AVAILABLE_COUNT"), + + UserLevelExpNumericalParameterMapId = ParseInt(kv, "USER_LEVEL_EXP_NUMERICAL_PARAMETER_MAP_ID"), }; } diff --git a/src/Data/UserDataStore.cs b/src/Data/UserDataStore.cs index 661d398..894389d 100644 --- a/src/Data/UserDataStore.cs +++ b/src/Data/UserDataStore.cs @@ -1,3 +1,4 @@ +using MariesWonderland.Constants; using MariesWonderland.Models.Entities; using MariesWonderland.Models.Type; using System.Text.Json; @@ -182,7 +183,7 @@ public class UserDataStore(DarkMasterMemoryDatabase masterDb) private static long GenerateUserId() { // 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); } /// @@ -191,7 +192,7 @@ public class UserDataStore(DarkMasterMemoryDatabase masterDb) private static long GeneratePlayerId() { // 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); } /// @@ -265,7 +266,7 @@ public class UserDataStore(DarkMasterMemoryDatabase masterDb) ChoiceId = 0 }); - foreach (int weaponId in Constants.StartingWeaponIds) + foreach (int weaponId in GameConstants.StartingWeaponIds) { string uuid = Guid.NewGuid().ToString(); diff --git a/src/Helpers/EntityHelper.cs b/src/Helpers/EntityHelper.cs index c0f125c..96572cc 100644 --- a/src/Helpers/EntityHelper.cs +++ b/src/Helpers/EntityHelper.cs @@ -17,6 +17,7 @@ public static class EntityHelper /// Returns the first element matching , creating and adding one via if none match. public static T GetOrCreate(this List list, Func predicate, Func factory) + where T : IUserEntity, new() { T? existing = list.FirstOrDefault(predicate); if (existing is not null) return existing; diff --git a/src/Helpers/StaminaHelper.cs b/src/Helpers/StaminaHelper.cs new file mode 100644 index 0000000..0f64ee8 --- /dev/null +++ b/src/Helpers/StaminaHelper.cs @@ -0,0 +1,26 @@ +using MariesWonderland.Data; +using MariesWonderland.Models.Entities; + +namespace MariesWonderland.Helpers; + +/// +/// 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. +/// +public static class StaminaHelper +{ + /// + /// Adds stamina to the user's regular stamina pool, + /// capped at . Updates the stamina timestamp. + /// + /// The user status entity to mutate. + /// Amount in regular stamina units (not milli). + /// Config providing the stamina cap. + /// Current Unix timestamp in milliseconds for the update datetime. + 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; + } +} diff --git a/src/Helpers/UnlockConditionHelper.cs b/src/Helpers/UnlockConditionHelper.cs new file mode 100644 index 0000000..e3f3749 --- /dev/null +++ b/src/Helpers/UnlockConditionHelper.cs @@ -0,0 +1,55 @@ +using Grpc.Core; +using MariesWonderland.Data; +using MariesWonderland.Models.Entities; +using MariesWonderland.Models.Type; + +namespace MariesWonderland.Helpers; + +/// +/// Evaluates unlock conditions for various game systems against the current user's save data. +/// +public static class UnlockConditionHelper +{ + /// + /// Returns true if the given explore unlock condition is satisfied for the specified user. + /// Throws with for unhandled condition types. + /// + 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; + } +} diff --git a/src/Services/ExploreService.cs b/src/Services/ExploreService.cs index 4dcfc8a..3c2feed 100644 --- a/src/Services/ExploreService.cs +++ b/src/Services/ExploreService.cs @@ -1,68 +1,74 @@ using Grpc.Core; +using MariesWonderland.Constants; using MariesWonderland.Data; using MariesWonderland.Extensions; +using MariesWonderland.Helpers; using MariesWonderland.Models.Entities; +using MariesWonderland.Models.Type; using MariesWonderland.Proto.Explore; namespace MariesWonderland.Services; -public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase masterDb) +public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase masterDb, GameConfig gameConfig) : MariesWonderland.Proto.Explore.ExploreService.ExploreServiceBase { - private const int StaminaRecovery = 1000; - private const int RewardMaterialId = 100001; - private const int RewardBaseCount = 1; + // EXP granted per run = 0.5% of the user's current level EXP requirement × RewardLotteryCount. + // Normal explore (×1) grants 0.5%; Hard (×11) grants 5.5%. + 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 DarkMasterMemoryDatabase _masterDb = masterDb; + private readonly GameConfig _gameConfig = gameConfig; /// Begins an explore expedition: deducts the consumable ticket and records the active expedition. public override Task StartExplore(StartExploreRequest request, ServerCallContext context) { - long userId = context.GetUserId(); - DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId); + DarkUserMemoryDatabase userDb = _store.GetOrCreate(context.GetUserId()); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - EntityMExplore? explore = _masterDb.EntityMExplore - .FirstOrDefault(e => e.ExploreId == request.ExploreId); + EntityMExplore explore = _masterDb.EntityMExplore + .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 (request.UseConsumableItemId > 0 && explore.ConsumeItemCount > 0) - { - EntityIUserConsumableItem? item = userDb.EntityIUserConsumableItem - .FirstOrDefault(i => i.ConsumableItemId == request.UseConsumableItemId); + if (item.Count < explore.ConsumeItemCount) + throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.InsufficientTickets)); - if (item is not null) - { - item.Count -= explore.ConsumeItemCount; - } + item.Count -= explore.ConsumeItemCount; } // Record or update the active expedition state - EntityIUserExplore? userExplore = userDb.EntityIUserExplore - .FirstOrDefault(e => e.UserId == userId); - - 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; - } + userExplore.PlayingExploreId = request.ExploreId; + userExplore.IsUseExploreTicket = isUsingTicket; + userExplore.LatestPlayDatetime = nowMs; return Task.FromResult(new StartExploreResponse()); } @@ -70,21 +76,26 @@ public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase master /// Completes an explore expedition: updates the high score, clears the active state, recovers stamina, and grants material rewards. public override Task FinishExplore(FinishExploreRequest request, ServerCallContext context) { - long userId = context.GetUserId(); - DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId); + DarkUserMemoryDatabase userDb = _store.GetOrCreate(context.GetUserId()); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - EntityMExplore? explore = _masterDb.EntityMExplore - .FirstOrDefault(e => e.ExploreId == request.ExploreId); + EntityMExplore explore = _masterDb.EntityMExplore + .FirstOrDefault(e => e.ExploreId == request.ExploreId) + ?? throw new RpcException(new Status(StatusCode.InvalidArgument, ExploreErrors.InvalidExploreId)); - if (explore is null) - { - return Task.FromResult(new FinishExploreResponse()); - } + EntityIUserExplore userExplore = userDb.EntityIUserExplore.FirstOrDefault() + ?? throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.UserExploreNotFound)); - 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 .FirstOrDefault(s => s.ExploreId == request.ExploreId); @@ -92,7 +103,7 @@ public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase master { userDb.EntityIUserExploreScore.Add(new EntityIUserExploreScore { - UserId = userId, + UserId = userDb.UserId, ExploreId = request.ExploreId, MaxScore = request.Score, MaxScoreUpdateDatetime = nowMs @@ -105,59 +116,55 @@ public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase master } // Clear playing state - EntityIUserExplore? userExplore = userDb.EntityIUserExplore - .FirstOrDefault(e => e.UserId == userId); + userExplore.PlayingExploreId = 0; + userExplore.IsUseExploreTicket = false; - if (userExplore is not null) - { - userExplore.PlayingExploreId = 0; - userExplore.IsUseExploreTicket = false; - } + // Recover stamina (capped at the configured maximum) + StaminaHelper.AddStamina(status, staminaReward, _gameConfig, nowMs); - // Recover stamina - EntityIUserStatus? status = userDb.EntityIUserStatus - .FirstOrDefault(s => s.UserId == userId); + // Grant EXP; max gain per run is 5.5% of a level so at most one level-up is possible + status.Exp += expReward; + 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) - { - status.StaminaMilliValue += StaminaRecovery; - status.StaminaUpdateDatetime = nowMs; - } - - // 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); + // Resolve grade and compute per-draw stack size + int gradeId = ResolveGradeId(request.ExploreId, request.Score); + int assetGradeIconId = _masterDb.EntityMExploreGradeAsset + .FirstOrDefault(a => a.ExploreGradeId == gradeId)?.AssetGradeIconId ?? 0; + int stackPerDraw = GradeRewardStack(gradeId); FinishExploreResponse response = new() { - AcquireStaminaCount = StaminaRecovery, + AcquireStaminaCount = staminaReward, 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, - PossessionId = RewardMaterialId, - Count = rewardCount - }); + // Draw RewardLotteryCount items from the pool (with replacement); stack size per draw is grade-dependent + Dictionary draws = []; + 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); } @@ -165,40 +172,69 @@ public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase master /// Cancels an in-progress explore expedition without granting rewards. public override Task RetireExplore(RetireExploreRequest request, ServerCallContext context) { - long userId = context.GetUserId(); - DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId); + DarkUserMemoryDatabase userDb = _store.GetOrCreate(context.GetUserId()); - EntityIUserExplore? userExplore = userDb.EntityIUserExplore - .FirstOrDefault(e => e.UserId == userId); + EntityIUserExplore userExplore = userDb.EntityIUserExplore.FirstOrDefault() + ?? throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.UserExploreNotFound)); - if (userExplore is not null) - { - userExplore.PlayingExploreId = 0; - userExplore.IsUseExploreTicket = false; - } + if (userExplore.PlayingExploreId != request.ExploreId) + throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.NoMatchingActiveExploreToRetire)); + + userExplore.PlayingExploreId = 0; + userExplore.IsUseExploreTicket = false; return Task.FromResult(new RetireExploreResponse()); } - /// Resolves the grade icon for a given explore score by checking thresholds in descending order. - 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 . + /// Returns 0 if the level thresholds are not available in master data. + /// + private int ComputeExpReward(int level, int lotteryCount) { - // Grade scores sorted descending by NecessaryScore; first match where score >= threshold wins - List gradeScores = [.. _masterDb.EntityMExploreGradeScore + int mapId = _gameConfig.UserLevelExpNumericalParameterMapId; + + 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); + } + + /// Resolves the ExploreGradeId for a given score by checking thresholds in descending order. + private int ResolveGradeId(int exploreId, int score) + { + IEnumerable gradeScores = _masterDb.EntityMExploreGradeScore .Where(gs => gs.ExploreId == exploreId) - .OrderByDescending(gs => gs.NecessaryScore)]; + .OrderByDescending(gs => gs.NecessaryScore); foreach (EntityMExploreGradeScore gs in gradeScores) { if (score >= gs.NecessaryScore) - { - EntityMExploreGradeAsset? asset = _masterDb.EntityMExploreGradeAsset - .FirstOrDefault(a => a.ExploreGradeId == gs.ExploreGradeId); - - return asset?.AssetGradeIconId ?? 0; - } + return gs.ExploreGradeId; } return 0; } + + /// + /// 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. + /// + 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 + }; } diff --git a/src/Services/TutorialService.cs b/src/Services/TutorialService.cs index 0ef7147..828abd8 100644 --- a/src/Services/TutorialService.cs +++ b/src/Services/TutorialService.cs @@ -1,4 +1,5 @@ using Grpc.Core; +using MariesWonderland.Constants; using MariesWonderland.Data; using MariesWonderland.Extensions; using MariesWonderland.Helpers; @@ -93,12 +94,12 @@ public class TutorialService(UserDataStore store, DarkMasterMemoryDatabase maste // Hardcoded to Rion & Everlasting Cardia string costumeUuid = db.EntityIUserCostume - .Where(c => c.CostumeId == Constants.StartingDeckCostumeId) + .Where(c => c.CostumeId == GameConstants.StartingDeckCostumeId) .Select(c => c.UserCostumeUuid) .Single(); string weaponUuid = db.EntityIUserWeapon - .Where(w => w.WeaponId == Constants.StartingDeckWeaponId) + .Where(w => w.WeaponId == GameConstants.StartingDeckWeaponId) .Select(w => w.UserWeaponUuid) .Single(); diff --git a/src/Services/UserService.cs b/src/Services/UserService.cs index c0b8bc5..d1c4e27 100644 --- a/src/Services/UserService.cs +++ b/src/Services/UserService.cs @@ -78,18 +78,15 @@ public class UserService(UserDataStore store, UserDataSeeder seeder) : MariesWon EntityIUser user = userDb.EntityIUser.GetOrCreate(userId); user.GameStartDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - // Initialize gem balance with 0/0 if not exists - if (!userDb.EntityIUserGem.Any(g => g.UserId == userId)) - { - userDb.EntityIUserGem.Add(new EntityIUserGem { UserId = userId, PaidGem = 0, FreeGem = 0 }); - } + // Initialize singleton entities + userDb.EntityIUserGem.Add(new EntityIUserGem { UserId = userId, PaidGem = 0, FreeGem = 0 }); + userDb.EntityIUserExplore.Add(new EntityIUserExplore { UserId = userId }); // TODO: Investigate if these singleton tables need to be pre-initialized at registration. // Uncomment to enable initialization: // userDb.EntityIUserBigHuntProgressStatus.AddNew(new EntityIUserBigHuntProgressStatus { UserId = userId }); // userDb.EntityIUserEventQuestGuerrillaFreeOpen.AddNew(new EntityIUserEventQuestGuerrillaFreeOpen { UserId = userId }); // userDb.EntityIUserEventQuestProgressStatus.AddNew(new EntityIUserEventQuestProgressStatus { UserId = userId }); - // userDb.EntityIUserExplore.AddNew(new EntityIUserExplore { UserId = userId }); // userDb.EntityIUserExtraQuestProgressStatus.AddNew(new EntityIUserExtraQuestProgressStatus { UserId = userId }); // userDb.EntityIUserMainQuestFlowStatus.AddNew(new EntityIUserMainQuestFlowStatus { UserId = userId }); // userDb.EntityIUserMainQuestMainFlowStatus.AddNew(new EntityIUserMainQuestMainFlowStatus { UserId = userId }); diff --git a/tests/Helpers/StaminaHelperTests.cs b/tests/Helpers/StaminaHelperTests.cs new file mode 100644 index 0000000..13adfd6 --- /dev/null +++ b/tests/Helpers/StaminaHelperTests.cs @@ -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); + } +} diff --git a/tests/Services/ExploreServiceTests.cs b/tests/Services/ExploreServiceTests.cs new file mode 100644 index 0000000..b38fb67 --- /dev/null +++ b/tests/Services/ExploreServiceTests.cs @@ -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 +{ + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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(() => + 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 +}