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