mirror of
https://github.com/BillyCool/MariesWonderland.git
synced 2026-05-06 12:53:38 +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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>Builds a <see cref="GameConfig"/> from the loaded master config rows.</summary>
|
||||
public static GameConfig From(IEnumerable<EntityMConfig> 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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
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);
|
||||
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 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;
|
||||
|
||||
/// <summary>Begins an explore expedition: deducts the consumable ticket and records the active expedition.</summary>
|
||||
public override Task<StartExploreResponse> 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)
|
||||
{
|
||||
return Task.FromResult(new StartExploreResponse());
|
||||
}
|
||||
if (nowMs < explore.StartDatetime)
|
||||
throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.ExploreNotStarted));
|
||||
|
||||
// Deduct consumable ticket if required
|
||||
if (request.UseConsumableItemId > 0 && explore.ConsumeItemCount > 0)
|
||||
{
|
||||
EntityIUserConsumableItem? item = userDb.EntityIUserConsumableItem
|
||||
.FirstOrDefault(i => i.ConsumableItemId == request.UseConsumableItemId);
|
||||
EntityMExploreUnlockCondition? unlockCondition = _masterDb.EntityMExploreUnlockCondition
|
||||
.FirstOrDefault(c => c.ExploreUnlockConditionId == explore.ExploreUnlockConditionId);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.IsUseExploreTicket = isUsingTicket;
|
||||
userExplore.LatestPlayDatetime = nowMs;
|
||||
}
|
||||
|
||||
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>
|
||||
public override Task<FinishExploreResponse> 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);
|
||||
|
||||
if (userExplore is not null)
|
||||
{
|
||||
userExplore.PlayingExploreId = 0;
|
||||
userExplore.IsUseExploreTicket = false;
|
||||
}
|
||||
|
||||
// Recover stamina
|
||||
EntityIUserStatus? status = userDb.EntityIUserStatus
|
||||
.FirstOrDefault(s => s.UserId == userId);
|
||||
// Recover stamina (capped at the configured maximum)
|
||||
StaminaHelper.AddStamina(status, staminaReward, _gameConfig, nowMs);
|
||||
|
||||
if (status is not null)
|
||||
{
|
||||
status.StaminaMilliValue += StaminaRecovery;
|
||||
status.StaminaUpdateDatetime = nowMs;
|
||||
}
|
||||
// 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++;
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
// 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
|
||||
{
|
||||
PossessionType = (int)Models.Type.PossessionType.MATERIAL,
|
||||
PossessionId = RewardMaterialId,
|
||||
Count = rewardCount
|
||||
PossessionType = (int)PossessionType.MATERIAL,
|
||||
PossessionId = materialId,
|
||||
Count = totalCount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
public override Task<RetireExploreResponse> 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.PlayingExploreId != request.ExploreId)
|
||||
throw new RpcException(new Status(StatusCode.FailedPrecondition, ExploreErrors.NoMatchingActiveExploreToRetire));
|
||||
|
||||
if (userExplore is not null)
|
||||
{
|
||||
userExplore.PlayingExploreId = 0;
|
||||
userExplore.IsUseExploreTicket = false;
|
||||
}
|
||||
|
||||
return Task.FromResult(new RetireExploreResponse());
|
||||
}
|
||||
|
||||
/// <summary>Resolves the grade icon for a given explore score by checking thresholds in descending order.</summary>
|
||||
private int GradeForScore(int exploreId, int score)
|
||||
/// <summary>
|
||||
/// 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
|
||||
List<EntityMExploreGradeScore> 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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
.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;
|
||||
}
|
||||
|
||||
/// <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 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();
|
||||
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
// 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 });
|
||||
|
||||
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