mirror of
https://github.com/BillyCool/MariesWonderland.git
synced 2026-05-06 20:53:42 +02:00
Fix up ExploreService APIs and add unit tests
This commit is contained in:
14
src/Constants/ExploreErrors.cs
Normal file
14
src/Constants/ExploreErrors.cs
Normal 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";
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
26
src/Helpers/StaminaHelper.cs
Normal file
26
src/Helpers/StaminaHelper.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/Helpers/UnlockConditionHelper.cs
Normal file
55
src/Helpers/UnlockConditionHelper.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
return Task.FromResult(new StartExploreResponse());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduct consumable ticket if required
|
EntityMExploreUnlockCondition? unlockCondition = _masterDb.EntityMExploreUnlockCondition
|
||||||
if (request.UseConsumableItemId > 0 && explore.ConsumeItemCount > 0)
|
.FirstOrDefault(c => c.ExploreUnlockConditionId == explore.ExploreUnlockConditionId);
|
||||||
{
|
|
||||||
EntityIUserConsumableItem? item = userDb.EntityIUserConsumableItem
|
|
||||||
.FirstOrDefault(i => i.ConsumableItemId == request.UseConsumableItemId);
|
|
||||||
|
|
||||||
if (item is not null)
|
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)
|
||||||
{
|
{
|
||||||
|
EntityIUserConsumableItem item = userDb.EntityIUserConsumableItem
|
||||||
|
.FirstOrDefault(i => i.ConsumableItemId == request.UseConsumableItemId)
|
||||||
|
?? throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.TicketNotFound));
|
||||||
|
|
||||||
|
if (item.Count < explore.ConsumeItemCount)
|
||||||
|
throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.InsufficientTickets));
|
||||||
|
|
||||||
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
|
|
||||||
.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.PlayingExploreId = request.ExploreId;
|
||||||
userExplore.IsUseExploreTicket = false;
|
userExplore.IsUseExploreTicket = isUsingTicket;
|
||||||
userExplore.LatestPlayDatetime = nowMs;
|
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
|
|
||||||
.FirstOrDefault(e => e.UserId == userId);
|
|
||||||
|
|
||||||
if (userExplore is not null)
|
|
||||||
{
|
|
||||||
userExplore.PlayingExploreId = 0;
|
userExplore.PlayingExploreId = 0;
|
||||||
userExplore.IsUseExploreTicket = false;
|
userExplore.IsUseExploreTicket = false;
|
||||||
}
|
|
||||||
|
|
||||||
// Recover stamina
|
// Recover stamina (capped at the configured maximum)
|
||||||
EntityIUserStatus? status = userDb.EntityIUserStatus
|
StaminaHelper.AddStamina(status, staminaReward, _gameConfig, nowMs);
|
||||||
.FirstOrDefault(s => s.UserId == userId);
|
|
||||||
|
|
||||||
if (status is not null)
|
// Grant EXP; max gain per run is 5.5% of a level so at most one level-up is possible
|
||||||
{
|
status.Exp += expReward;
|
||||||
status.StaminaMilliValue += StaminaRecovery;
|
int mapId = _gameConfig.UserLevelExpNumericalParameterMapId;
|
||||||
status.StaminaUpdateDatetime = nowMs;
|
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++;
|
||||||
|
|
||||||
// Grant material reward
|
// Resolve grade and compute per-draw stack size
|
||||||
EntityIUserMaterial? material = userDb.EntityIUserMaterial
|
int gradeId = ResolveGradeId(request.ExploreId, request.Score);
|
||||||
.FirstOrDefault(m => m.MaterialId == RewardMaterialId);
|
int assetGradeIconId = _masterDb.EntityMExploreGradeAsset
|
||||||
|
.FirstOrDefault(a => a.ExploreGradeId == gradeId)?.AssetGradeIconId ?? 0;
|
||||||
if (material is not null)
|
int stackPerDraw = GradeRewardStack(gradeId);
|
||||||
{
|
|
||||||
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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Grade 106 (worst) yields no material; skip drawing entirely to avoid zero-count reward entries
|
||||||
|
if (stackPerDraw > 0)
|
||||||
|
{
|
||||||
|
// Draw RewardLotteryCount items from the pool (with replacement); stack size per draw is grade-dependent
|
||||||
|
Dictionary<int, int> 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
|
response.ExploreReward.Add(new ExploreReward
|
||||||
{
|
{
|
||||||
PossessionType = (int)Models.Type.PossessionType.MATERIAL,
|
PossessionType = (int)PossessionType.MATERIAL,
|
||||||
PossessionId = RewardMaterialId,
|
PossessionId = materialId,
|
||||||
Count = rewardCount
|
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.PlayingExploreId != request.ExploreId)
|
||||||
|
throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.NoMatchingActiveExploreToRetire));
|
||||||
|
|
||||||
if (userExplore is not null)
|
|
||||||
{
|
|
||||||
userExplore.PlayingExploreId = 0;
|
userExplore.PlayingExploreId = 0;
|
||||||
userExplore.IsUseExploreTicket = false;
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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.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.
|
// 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 });
|
||||||
|
|||||||
42
tests/Helpers/StaminaHelperTests.cs
Normal file
42
tests/Helpers/StaminaHelperTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
400
tests/Services/ExploreServiceTests.cs
Normal file
400
tests/Services/ExploreServiceTests.cs
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user