Initial commit

This commit is contained in:
BillyCool
2026-04-21 01:10:25 +10:00
commit c5595ea083
1752 changed files with 45767 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.Banner;
namespace MariesWonderland.Services;
public class BannerService(DarkMasterMemoryDatabase masterDb) : MariesWonderland.Proto.Banner.BannerService.BannerServiceBase
{
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
/// <summary>Returns available gacha banners grouped into term-limited and chapter categories for the Mama banner screen.</summary>
public override Task<GetMamaBannerResponse> GetMamaBanner(GetMamaBannerRequest request, ServerCallContext context)
{
Dictionary<int, EntityMGachaMedal> medalByGachaId = [];
foreach (EntityMGachaMedal medal in _masterDb.EntityMGachaMedal)
{
medalByGachaId[medal.ShopTransitionGachaId] = medal;
}
List<GachaBanner> termLimited = [];
HashSet<int> seenStepUpGroups = [];
GachaBanner? latestChapter = null;
foreach (EntityMMomBanner banner in _masterDb.EntityMMomBanner)
{
if (banner.DestinationDomainType != DomainType.GACHA)
{
continue;
}
int gachaId = banner.DestinationDomainId;
GachaLabelType labelType = InferGachaLabelType(banner.BannerAssetName);
// Chapter gachas (common_ prefix) are exempt from the medal requirement.
if (!medalByGachaId.ContainsKey(gachaId) && labelType != GachaLabelType.CHAPTER)
{
continue;
}
// Skip portal cage and recycle banners (not displayed on home screen).
if (labelType is GachaLabelType.PORTAL_CAGE or GachaLabelType.RECYCLE)
{
continue;
}
// Step-up banners: truncate to group ID (562001 → 562) using StepUpGroupDivisor.
// Multiple raw step IDs map to the same group, so deduplicate.
if (banner.BannerAssetName.StartsWith("step_up_", StringComparison.Ordinal))
{
gachaId /= 1000;
if (!seenStepUpGroups.Add(gachaId))
{
continue;
}
}
GachaBanner b = new()
{
GachaLabelType = (int)labelType,
GachaAssetName = banner.BannerAssetName,
GachaId = gachaId
};
switch (labelType)
{
case GachaLabelType.EVENT:
case GachaLabelType.PREMIUM:
termLimited.Add(b);
break;
case GachaLabelType.CHAPTER:
if (latestChapter is null || gachaId > latestChapter.GachaId)
{
latestChapter = b;
}
break;
}
}
GetMamaBannerResponse response = new()
{
LatestChapterGacha = latestChapter,
IsExistUnreadPop = false
};
response.TermLimitedGacha.AddRange(termLimited);
return Task.FromResult(response);
}
/// <summary>
/// Master data has no GachaLabelType field, so we infer from the banner asset name prefix.
/// All term-limited gachas (step_up_, limited_, etc.) use PREMIUM (value 1).
/// </summary>
private static GachaLabelType InferGachaLabelType(string assetName)
{
if (assetName.StartsWith("common_", StringComparison.Ordinal))
{
return GachaLabelType.CHAPTER;
}
if (assetName.StartsWith("portal_cage_", StringComparison.Ordinal))
{
return GachaLabelType.PORTAL_CAGE;
}
if (assetName.StartsWith("recycle_", StringComparison.Ordinal))
{
return GachaLabelType.RECYCLE;
}
// All term-limited gachas (step_up_, limited_, event_, etc.) default to PREMIUM (value 1).
return GachaLabelType.PREMIUM;
}
}

View File

@@ -0,0 +1,69 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.Battle;
namespace MariesWonderland.Services;
public class BattleService(UserDataStore store) : MariesWonderland.Proto.Battle.BattleService.BattleServiceBase
{
private readonly UserDataStore _store = store;
/// <summary>Initializes a battle wave; currently a no-op that returns empty response.</summary>
public override Task<StartWaveResponse> StartWave(StartWaveRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
return Task.FromResult(new StartWaveResponse());
}
/// <summary>Records battle results and tracks shortest clear time.</summary>
public override Task<FinishWaveResponse> FinishWave(FinishWaveRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
BattleDetail? detail = request.BattleDetail;
// Find the currently active quest so we can associate this battle with it.
EntityIUserQuest? activeQuest = userDb.EntityIUserQuest
.FirstOrDefault(q => q.QuestStateType == (int)QuestStateType.ACTIVE);
// Save full battle record mirroring the proto request structure.
EntitySBattleDetail battleRecord = new()
{
QuestId = activeQuest?.QuestId ?? 0,
UserId = userId,
ElapsedFrameCount = request.ElapsedFrameCount,
BattleBinary = request.BattleBinary.ToBase64(),
RecordedAt = DateTime.UtcNow,
MaxDamage = detail?.MaxDamage ?? 0,
PlayerCostumeActiveSkillUsedCount = detail?.PlayerCostumeActiveSkillUsedCount ?? 0,
PlayerWeaponActiveSkillUsedCount = detail?.PlayerWeaponActiveSkillUsedCount ?? 0,
PlayerCompanionSkillUsedCount = detail?.PlayerCompanionSkillUsedCount ?? 0,
CriticalCount = detail?.CriticalCount ?? 0,
ComboCount = detail?.ComboCount ?? 0,
ComboMaxDamage = detail?.ComboMaxDamage ?? 0,
TotalRecoverPoint = detail?.TotalRecoverPoint ?? 0,
CostumeBattleInfoList = detail != null ? [.. detail.CostumeBattleInfo] : [],
UserPartyResultInfoList = [.. request.UserPartyResultInfoList],
NpcPartyResultInfoList = [.. request.NpcPartyResultInfoList],
};
userDb.BattleDetails.Add(battleRecord);
if (activeQuest != null)
{
// Track shortest clear time.
if (activeQuest.ShortestClearFrames == 0 || request.ElapsedFrameCount < activeQuest.ShortestClearFrames)
{
activeQuest.ShortestClearFrames = (int)request.ElapsedFrameCount;
}
}
return Task.FromResult(new FinishWaveResponse());
}
}

View File

@@ -0,0 +1,772 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Helpers;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.BigHunt;
namespace MariesWonderland.Services;
public class BigHuntService(UserDataStore store, DarkMasterMemoryDatabase masterDb)
: MariesWonderland.Proto.BigHunt.BighuntService.BighuntServiceBase
{
private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
/// <summary>
/// Begins a BigHunt quest run: deducts stamina for the selected quest, records the player's deck choice,
/// and initialises per-boss-quest status tracking.
/// </summary>
public override Task<StartBigHuntQuestResponse> StartBigHuntQuest(StartBigHuntQuestRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityMBigHuntQuest? bhQuest = _masterDb.EntityMBigHuntQuest
.FirstOrDefault(q => q.BigHuntQuestId == request.BigHuntQuestId);
if (bhQuest is not null)
{
HandleBigHuntQuestStart(userDb, userId, bhQuest.QuestId, request.UserDeckNumber, nowMs);
}
// Set progress status
EntityIUserBigHuntProgressStatus progress = GetOrCreateProgress(userDb, userId);
progress.CurrentBigHuntBossQuestId = request.BigHuntBossQuestId;
progress.CurrentBigHuntQuestId = request.BigHuntQuestId;
progress.CurrentQuestSceneId = 0;
progress.IsDryRun = request.IsDryRun;
// Store deck number in server-side session
EntitySBigHuntSession session = GetOrCreateSession(userDb, userId);
session.DeckNumber = request.UserDeckNumber;
// Update per-boss-quest status
EntityIUserBigHuntStatus status = GetOrCreateStatus(userDb, userId, request.BigHuntBossQuestId);
status.DailyChallengeCount++;
status.LatestChallengeDatetime = nowMs;
return Task.FromResult(new StartBigHuntQuestResponse());
}
/// <summary>
/// Advances the player's current quest scene checkpoint during an active hunt run.
/// </summary>
public override Task<UpdateBigHuntQuestSceneProgressResponse> UpdateBigHuntQuestSceneProgress(UpdateBigHuntQuestSceneProgressRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserBigHuntProgressStatus progress = GetOrCreateProgress(userDb, userId);
progress.CurrentQuestSceneId = request.QuestSceneId;
return Task.FromResult(new UpdateBigHuntQuestSceneProgressResponse());
}
/// <summary>
/// Concludes a hunt run: computes the final score from damage and permil bonuses (difficulty, alive, combo),
/// updates high scores at boss/schedule/weekly levels, issues newly unlocked threshold rewards, and clears
/// the in-progress session state. Returns an empty score info for retired or dry-run runs.
/// </summary>
public override Task<FinishBigHuntQuestResponse> FinishBigHuntQuest(FinishBigHuntQuestRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityMBigHuntQuest? bhQuest = _masterDb.EntityMBigHuntQuest
.FirstOrDefault(q => q.BigHuntQuestId == request.BigHuntQuestId);
EntityMBigHuntBossQuest? bossQuest = _masterDb.EntityMBigHuntBossQuest
.FirstOrDefault(q => q.BigHuntBossQuestId == request.BigHuntBossQuestId);
EntityMBigHuntBoss? boss = bossQuest is not null
? _masterDb.EntityMBigHuntBoss.FirstOrDefault(b => b.BigHuntBossId == bossQuest.BigHuntBossId)
: null;
if (bhQuest is not null)
{
HandleBigHuntQuestFinish(userDb, userId, bhQuest.QuestId, request.IsRetired, nowMs);
}
EntityIUserBigHuntProgressStatus progress = GetOrCreateProgress(userDb, userId);
EntitySBigHuntSession session = GetOrCreateSession(userDb, userId);
// Retired or dry run — clear progress and return empty score info.
if (request.IsRetired || progress.IsDryRun)
{
ClearProgress(progress);
return Task.FromResult(new FinishBigHuntQuestResponse
{
ScoreInfo = new BigHuntScoreInfo(),
BattleReport = new BigHuntBattleReport()
});
}
// --- Scoring engine ---
long totalDamage = session.TotalDamage;
long baseScore = totalDamage;
int difficultyBonusPermil = 0;
if (bhQuest is not null)
{
EntityMBigHuntQuestScoreCoefficient? coeff = _masterDb.EntityMBigHuntQuestScoreCoefficient
.FirstOrDefault(c => c.BigHuntQuestScoreCoefficientId == bhQuest.BigHuntQuestScoreCoefficientId);
if (coeff is not null)
{
difficultyBonusPermil = coeff.ScoreDifficultBonusPermil;
}
}
int aliveBonusPermil = 500;
int maxComboBonusPermil = session.MaxComboCount switch
{
>= 100 => 300,
>= 50 => 200,
>= 20 => 100,
_ => 0
};
long userScore = baseScore * (1000 + difficultyBonusPermil + aliveBonusPermil + maxComboBonusPermil) / 1000;
// --- High-score tracking (per-boss) ---
bool isHighScore = false;
long oldMaxScore = 0;
if (bossQuest is not null)
{
EntityIUserBigHuntMaxScore? maxScoreEntry = userDb.EntityIUserBigHuntMaxScore
.FirstOrDefault(m => m.BigHuntBossId == bossQuest.BigHuntBossId);
oldMaxScore = maxScoreEntry?.MaxScore ?? 0;
if (userScore > oldMaxScore)
{
isHighScore = true;
if (maxScoreEntry is null)
{
userDb.EntityIUserBigHuntMaxScore.Add(new EntityIUserBigHuntMaxScore
{
UserId = userId,
BigHuntBossId = bossQuest.BigHuntBossId,
MaxScore = userScore,
MaxScoreUpdateDatetime = nowMs
});
}
else
{
maxScoreEntry.MaxScore = userScore;
maxScoreEntry.MaxScoreUpdateDatetime = nowMs;
}
}
}
// --- High-score tracking (per-schedule) ---
int activeScheduleId = ResolveActiveScheduleId(nowMs);
if (bossQuest is not null && activeScheduleId > 0)
{
EntityIUserBigHuntScheduleMaxScore? schedMax = userDb.EntityIUserBigHuntScheduleMaxScore
.FirstOrDefault(m => m.UserId == userId
&& m.BigHuntScheduleId == activeScheduleId
&& m.BigHuntBossId == bossQuest.BigHuntBossId);
if (schedMax is null)
{
if (userScore > 0)
{
userDb.EntityIUserBigHuntScheduleMaxScore.Add(new EntityIUserBigHuntScheduleMaxScore
{
UserId = userId,
BigHuntScheduleId = activeScheduleId,
BigHuntBossId = bossQuest.BigHuntBossId,
MaxScore = userScore,
MaxScoreUpdateDatetime = nowMs
});
}
}
else if (userScore > schedMax.MaxScore)
{
schedMax.MaxScore = userScore;
schedMax.MaxScoreUpdateDatetime = nowMs;
}
}
// --- High-score tracking (per-weekly-attribute) ---
long weeklyVersion = WeeklyVersion(nowMs);
if (boss is not null)
{
EntityIUserBigHuntWeeklyMaxScore? weeklyMax = userDb.EntityIUserBigHuntWeeklyMaxScore
.FirstOrDefault(m => m.UserId == userId
&& m.BigHuntWeeklyVersion == weeklyVersion
&& m.AttributeType == boss.AttributeType);
if (weeklyMax is null)
{
if (userScore > 0)
{
userDb.EntityIUserBigHuntWeeklyMaxScore.Add(new EntityIUserBigHuntWeeklyMaxScore
{
UserId = userId,
BigHuntWeeklyVersion = weeklyVersion,
AttributeType = boss.AttributeType,
MaxScore = userScore
});
}
}
else if (userScore > weeklyMax.MaxScore)
{
weeklyMax.MaxScore = userScore;
}
}
// Grade icon
int assetGradeIconId = bossQuest is not null
? ResolveGradeIconId(bossQuest.BigHuntBossId, userScore)
: 0;
BigHuntScoreInfo scoreInfo = new()
{
UserScore = userScore,
IsHighScore = isHighScore,
TotalDamage = totalDamage,
BaseScore = baseScore,
DifficultyBonusPermil = difficultyBonusPermil,
AliveBonusPermil = aliveBonusPermil,
MaxComboBonusPermil = maxComboBonusPermil,
AssetGradeIconId = assetGradeIconId
};
// --- Reward collection on high score ---
List<BigHuntReward> scoreRewards = [];
if (isHighScore && bossQuest is not null)
{
int rewardGroupId = ResolveActiveScoreRewardGroupId(bossQuest.BigHuntScoreRewardGroupScheduleId, nowMs);
if (rewardGroupId > 0)
{
List<(PossessionType Type, int Id, int Count)> newItems = CollectNewRewards(rewardGroupId, oldMaxScore, userScore);
foreach ((PossessionType type, int id, int count) in newItems)
{
GrantPossessionViaPossessionHelper(userDb, userId, type, id, count);
scoreRewards.Add(new BigHuntReward
{
PossessionType = (int)type,
PossessionId = id,
Count = count
});
}
}
}
// Clear progress and battle state
ClearProgress(progress);
session.BattleBinary = [];
session.TotalDamage = 0;
session.MaxComboCount = 0;
session.BossKnockDownCount = 0;
session.DeckType = 0;
session.UserTripleDeckNumber = 0;
FinishBigHuntQuestResponse response = new()
{
ScoreInfo = scoreInfo,
BattleReport = new BigHuntBattleReport()
};
response.ScoreReward.AddRange(scoreRewards);
return Task.FromResult(response);
}
/// <summary>
/// Resumes a BigHunt quest after a disconnect: restores the saved battle binary and deck choice,
/// and increments the daily challenge count for the boss quest.
/// </summary>
public override Task<RestartBigHuntQuestResponse> RestartBigHuntQuest(RestartBigHuntQuestRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityMBigHuntQuest? bhQuest = _masterDb.EntityMBigHuntQuest
.FirstOrDefault(q => q.BigHuntQuestId == request.BigHuntQuestId);
EntitySBigHuntSession session = GetOrCreateSession(userDb, userId);
if (bhQuest is not null)
{
HandleBigHuntQuestStart(userDb, userId, bhQuest.QuestId, session.DeckNumber, nowMs);
}
// Reset scene progress
EntityIUserBigHuntProgressStatus progress = GetOrCreateProgress(userDb, userId);
progress.CurrentQuestSceneId = 0;
// Increment daily challenge count
EntityIUserBigHuntStatus status = GetOrCreateStatus(userDb, userId, request.BigHuntBossQuestId);
status.DailyChallengeCount++;
status.LatestChallengeDatetime = nowMs;
RestartBigHuntQuestResponse response = new()
{
BattleBinary = Google.Protobuf.ByteString.CopyFrom(session.BattleBinary),
DeckNumber = session.DeckNumber
};
return Task.FromResult(response);
}
/// <summary>
/// Applies a bulk skip of one or more hunt attempts, incrementing the daily challenge counter
/// without entering combat.
/// </summary>
public override Task<SkipBigHuntQuestResponse> SkipBigHuntQuest(SkipBigHuntQuestRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityIUserBigHuntStatus status = GetOrCreateStatus(userDb, userId, request.BigHuntBossQuestId);
status.DailyChallengeCount += request.SkipCount;
status.LatestChallengeDatetime = nowMs;
return Task.FromResult(new SkipBigHuntQuestResponse());
}
/// <summary>
/// Persists mid-battle state to the server session: the raw battle binary snapshot plus
/// accumulated damage, combo, and boss knockdown statistics for restart recovery and scoring.
/// </summary>
/// <summary>
/// Persists the battle binary and detail (damage, combo, knock-downs) from the client into the session.
/// </summary>
public override Task<SaveBigHuntBattleInfoResponse> SaveBigHuntBattleInfo(SaveBigHuntBattleInfoRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntitySBigHuntSession session = GetOrCreateSession(userDb, userId);
session.BattleBinary = request.BattleBinary.ToByteArray();
if (request.BigHuntBattleDetail is not null)
{
// Sum damage across all costumes in the party
long totalDamage = 0;
foreach (Proto.Battle.CostumeBattleInfo ci in request.BigHuntBattleDetail.CostumeBattleInfo)
{
if (ci is not null)
{
totalDamage += ci.TotalDamage;
}
}
// Persist battle statistics used for scoring and restart
session.DeckType = request.BigHuntBattleDetail.DeckType;
session.UserTripleDeckNumber = request.BigHuntBattleDetail.UserTripleDeckNumber;
session.BossKnockDownCount = request.BigHuntBattleDetail.BossKnockDownCount;
session.MaxComboCount = request.BigHuntBattleDetail.MaxComboCount;
session.TotalDamage = totalDamage;
}
return Task.FromResult(new SaveBigHuntBattleInfoResponse());
}
/// <summary>
/// Returns the player's BigHunt overview: weekly score results and grade icons per boss attribute,
/// the current week's uncollected rewards, and last week's earned rewards.
/// </summary>
/// <summary>
/// Returns the top-level summary view: weekly score results, weekly rewards, and last week's rewards for all bosses.
/// </summary>
public override Task<GetBigHuntTopDataResponse> GetBigHuntTopData(Empty request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long weeklyVersion = WeeklyVersion(nowMs);
// Build weekly score results for each boss
List<WeeklyScoreResult> weeklyScoreResults = [];
foreach (EntityMBigHuntBoss boss in _masterDb.EntityMBigHuntBoss)
{
EntityIUserBigHuntWeeklyMaxScore? ws = userDb.EntityIUserBigHuntWeeklyMaxScore
.FirstOrDefault(m => m.UserId == userId
&& m.BigHuntWeeklyVersion == weeklyVersion
&& m.AttributeType == boss.AttributeType);
long maxScore = ws?.MaxScore ?? 0;
int gradeIconId = ResolveGradeIconId(boss.BigHuntBossId, maxScore);
weeklyScoreResults.Add(new WeeklyScoreResult
{
AttributeType = (int)boss.AttributeType,
BeforeMaxScore = maxScore,
CurrentMaxScore = maxScore,
BeforeAssetGradeIconId = gradeIconId,
CurrentAssetGradeIconId = gradeIconId,
AfterMaxScore = maxScore,
AfterAssetGradeIconId = gradeIconId
});
}
// Check if weekly reward was already received
EntityIUserBigHuntWeeklyStatus? weeklyStatus = userDb.EntityIUserBigHuntWeeklyStatus
.FirstOrDefault(s => s.BigHuntWeeklyVersion == weeklyVersion);
// Resolve current week rewards
List<BigHuntReward> weeklyRewards = ResolveWeeklyRewards(userDb, userId, weeklyVersion, nowMs);
// Resolve last week rewards
long lastWeekVersion = weeklyVersion - (7L * 24 * 60 * 60 * 1000);
List<BigHuntReward> lastWeekRewards = ResolveWeeklyRewards(userDb, userId, lastWeekVersion, nowMs);
GetBigHuntTopDataResponse response = new()
{
IsReceivedWeeklyScoreReward = weeklyStatus?.IsReceivedWeeklyReward ?? false
};
response.WeeklyScoreResult.AddRange(weeklyScoreResults);
response.WeeklyScoreReward.AddRange(weeklyRewards);
response.LastWeekWeeklyScoreReward.AddRange(lastWeekRewards);
return Task.FromResult(response);
}
// ────────── Quest state helpers ──────────
/// <summary>
/// Initializes quest and mission state for the underlying quest, and transitions its state to active.
/// </summary>
private void HandleBigHuntQuestStart(DarkUserMemoryDatabase userDb, long userId, int questId, int deckNumber, long nowMs)
{
EntityMQuest? masterQuest = _masterDb.EntityMQuest.FirstOrDefault(q => q.QuestId == questId);
EntityIUserQuest userQuest = userDb.EntityIUserQuest
.FirstOrDefault(q => q.QuestId == questId)
?? AddEntity(userDb.EntityIUserQuest, new EntityIUserQuest { UserId = userId, QuestId = questId });
// Initialize quest missions
if (masterQuest is not null && masterQuest.QuestMissionGroupId != 0)
{
foreach (EntityMQuestMissionGroup missionGroupRow in _masterDb.EntityMQuestMissionGroup
.Where(g => g.QuestMissionGroupId == masterQuest.QuestMissionGroupId))
{
if (!userDb.EntityIUserQuestMission.Any(m => m.QuestId == questId && m.QuestMissionId == missionGroupRow.QuestMissionId))
userDb.EntityIUserQuestMission.Add(new EntityIUserQuestMission { UserId = userId, QuestId = questId, QuestMissionId = missionGroupRow.QuestMissionId });
}
}
userQuest.QuestStateType = (int)QuestStateType.ACTIVE;
userQuest.LatestStartDatetime = nowMs;
}
/// <summary>
/// Marks the quest cleared, applies first-clear and drop rewards on success,
/// and clears quest missions.
/// </summary>
private void HandleBigHuntQuestFinish(DarkUserMemoryDatabase userDb, long userId, int questId, bool isRetired, long nowMs)
{
EntityMQuest? masterQuest = _masterDb.EntityMQuest.FirstOrDefault(q => q.QuestId == questId);
EntityIUserQuest? userQuest = userDb.EntityIUserQuest
.FirstOrDefault(q => q.QuestId == questId);
if (userQuest is not null && !isRetired)
{
// First-clear rewards
if (userQuest.ClearCount == 0 && masterQuest is not null && masterQuest.QuestFirstClearRewardGroupId != 0)
{
int rewardGroupId = masterQuest.QuestFirstClearRewardGroupId;
EntityMQuestFirstClearRewardSwitch? switchRow = _masterDb.EntityMQuestFirstClearRewardSwitch
.FirstOrDefault(s => s.QuestId == questId);
if (switchRow != null)
{
EntityIUserQuest? prereq = userDb.EntityIUserQuest
.FirstOrDefault(q => q.QuestId == switchRow.SwitchConditionClearQuestId);
if (prereq != null && prereq.QuestStateType == (int)QuestStateType.CLEARED)
rewardGroupId = switchRow.QuestFirstClearRewardGroupId;
}
foreach (EntityMQuestFirstClearRewardGroup reward in _masterDb.EntityMQuestFirstClearRewardGroup
.Where(r => r.QuestFirstClearRewardGroupId == rewardGroupId))
{
PossessionHelper.Apply(userDb, userId, reward.PossessionType, reward.PossessionId, reward.Count, _masterDb);
}
}
// Drop rewards
if (masterQuest is not null && masterQuest.QuestPickupRewardGroupId != 0)
{
foreach (EntityMQuestPickupRewardGroup pickup in _masterDb.EntityMQuestPickupRewardGroup
.Where(p => p.QuestPickupRewardGroupId == masterQuest.QuestPickupRewardGroupId))
{
EntityMBattleDropReward? drop = _masterDb.EntityMBattleDropReward
.FirstOrDefault(d => d.BattleDropRewardId == pickup.BattleDropRewardId);
if (drop != null)
PossessionHelper.Apply(userDb, userId, drop.PossessionType, drop.PossessionId, drop.Count, _masterDb);
}
}
userQuest.QuestStateType = (int)QuestStateType.CLEARED;
userQuest.ClearCount++;
userQuest.DailyClearCount++;
userQuest.LastClearDatetime = nowMs;
}
// Clear quest missions
if (masterQuest is not null && masterQuest.QuestMissionGroupId != 0)
{
foreach (EntityMQuestMissionGroup missionGroupRow in _masterDb.EntityMQuestMissionGroup
.Where(g => g.QuestMissionGroupId == masterQuest.QuestMissionGroupId))
{
EntityIUserQuestMission? userMission = userDb.EntityIUserQuestMission
.FirstOrDefault(m => m.QuestId == questId && m.QuestMissionId == missionGroupRow.QuestMissionId);
if (userMission is null)
{
userMission = new EntityIUserQuestMission { UserId = userId, QuestId = questId, QuestMissionId = missionGroupRow.QuestMissionId };
userDb.EntityIUserQuestMission.Add(userMission);
}
userMission.IsClear = true;
userMission.ProgressValue = 1;
userMission.LatestClearDatetime = nowMs;
}
}
}
// ────────── Catalog resolution helpers ──────────
/// <summary>
/// Returns the ID of the schedule whose challenge window contains <paramref name="nowMs"/>,
/// falling back to the schedule with the most recently ended window.
/// </summary>
private int ResolveActiveScheduleId(long nowMs)
{
EntityMBigHuntSchedule? active = _masterDb.EntityMBigHuntSchedule
.FirstOrDefault(s => nowMs >= s.ChallengeStartDatetime && nowMs <= s.ChallengeEndDatetime);
if (active is not null)
{
return active.BigHuntScheduleId;
}
// Fall back to schedule with latest end datetime.
return _masterDb.EntityMBigHuntSchedule
.OrderByDescending(s => s.ChallengeEndDatetime)
.Select(s => s.BigHuntScheduleId)
.FirstOrDefault();
}
/// <summary>
/// Returns the highest grade icon ID whose score threshold the player's score meets for the given boss,
/// or 0 if no threshold is reached.
/// </summary>
private int ResolveGradeIconId(int bossId, long score)
{
EntityMBigHuntBoss? boss = _masterDb.EntityMBigHuntBoss
.FirstOrDefault(b => b.BigHuntBossId == bossId);
if (boss is null)
{
return 0;
}
// Thresholds sorted ascending by NecessaryScore; last one where score >= threshold wins.
int iconId = 0;
foreach (EntityMBigHuntBossGradeGroup g in _masterDb.EntityMBigHuntBossGradeGroup
.Where(g => g.BigHuntBossGradeGroupId == boss.BigHuntBossGradeGroupId)
.OrderBy(g => g.NecessaryScore))
{
if (score >= g.NecessaryScore)
{
iconId = g.AssetGradeIconId;
}
else
{
break;
}
}
return iconId;
}
/// <summary>
/// Returns the score reward group ID in effect for a given boss-quest score reward group schedule at <paramref name="nowMs"/>.
/// </summary>
private int ResolveActiveScoreRewardGroupId(int scheduleId, long nowMs)
{
// Entries sorted descending by start datetime; first one where nowMs >= start wins.
List<EntityMBigHuntScoreRewardGroupSchedule> entries = [.. _masterDb.EntityMBigHuntScoreRewardGroupSchedule
.Where(e => e.BigHuntScoreRewardGroupScheduleId == scheduleId)
.OrderByDescending(e => e.StartDatetime)];
foreach (EntityMBigHuntScoreRewardGroupSchedule entry in entries)
{
if (nowMs >= entry.StartDatetime)
{
return entry.BigHuntScoreRewardGroupId;
}
}
return entries.Count > 0 ? entries[^1].BigHuntScoreRewardGroupId : 0;
}
/// <summary>
/// Returns the weekly score reward group ID in effect for a given schedule and boss attribute type at <paramref name="nowMs"/>.
/// </summary>
private int ResolveActiveWeeklyRewardGroupId(int scheduleId, AttributeType attributeType, long nowMs)
{
List<EntityMBigHuntWeeklyAttributeScoreRewardGroupSchedule> entries =
[.. _masterDb.EntityMBigHuntWeeklyAttributeScoreRewardGroupSchedule
.Where(e => e.BigHuntWeeklyAttributeScoreRewardGroupScheduleId == scheduleId
&& e.AttributeType == attributeType)
.OrderByDescending(e => e.StartDatetime)];
foreach (EntityMBigHuntWeeklyAttributeScoreRewardGroupSchedule entry in entries)
{
if (nowMs >= entry.StartDatetime)
{
return entry.BigHuntScoreRewardGroupId;
}
}
return entries.Count > 0 ? entries[^1].BigHuntScoreRewardGroupId : 0;
}
/// <summary>
/// Returns all reward items whose score thresholds fall within the range (<paramref name="oldMax"/>, <paramref name="newMax"/>],
/// i.e. thresholds newly crossed by the player's improved score.
/// </summary>
private List<(PossessionType Type, int Id, int Count)> CollectNewRewards(int scoreRewardGroupId, long oldMax, long newMax)
{
List<(PossessionType, int, int)> items = [];
foreach (EntityMBigHuntScoreRewardGroup threshold in _masterDb.EntityMBigHuntScoreRewardGroup
.Where(t => t.BigHuntScoreRewardGroupId == scoreRewardGroupId)
.OrderBy(t => t.NecessaryScore))
{
if (threshold.NecessaryScore > oldMax && threshold.NecessaryScore <= newMax)
{
foreach (EntityMBigHuntRewardGroup reward in _masterDb.EntityMBigHuntRewardGroup
.Where(r => r.BigHuntRewardGroupId == threshold.BigHuntRewardGroupId))
{
items.Add((reward.PossessionType, reward.PossessionId, reward.Count));
}
}
}
return items;
}
/// <summary>
/// Builds the list of weekly reward items earned across all boss attributes for a given week version,
/// based on the player's weekly max scores.
/// </summary>
private List<BigHuntReward> ResolveWeeklyRewards(DarkUserMemoryDatabase userDb, long userId, long weeklyVersion, long nowMs)
{
List<BigHuntReward> rewards = [];
foreach (EntityMBigHuntBoss boss in _masterDb.EntityMBigHuntBoss)
{
int rewardGroupId = ResolveActiveWeeklyRewardGroupId(1, boss.AttributeType, nowMs);
if (rewardGroupId == 0)
{
continue;
}
EntityIUserBigHuntWeeklyMaxScore? ws = userDb.EntityIUserBigHuntWeeklyMaxScore
.FirstOrDefault(m => m.UserId == userId
&& m.BigHuntWeeklyVersion == weeklyVersion
&& m.AttributeType == boss.AttributeType);
long maxScore = ws?.MaxScore ?? 0;
foreach ((PossessionType type, int id, int count) in CollectNewRewards(rewardGroupId, 0, maxScore))
{
rewards.Add(new BigHuntReward
{
PossessionType = (int)type,
PossessionId = id,
Count = count
});
}
}
return rewards;
}
// ────────── State accessors ──────────
/// <summary>
/// Gets or initialises the player's BigHunt in-progress quest status record.
/// </summary>
private static EntityIUserBigHuntProgressStatus GetOrCreateProgress(DarkUserMemoryDatabase userDb, long userId)
{
return userDb.EntityIUserBigHuntProgressStatus
.FirstOrDefault(p => p.UserId == userId)
?? AddEntity(userDb.EntityIUserBigHuntProgressStatus, new EntityIUserBigHuntProgressStatus { UserId = userId });
}
/// <summary>
/// Gets or initialises the player's per-boss-quest challenge status record.
/// </summary>
private static EntityIUserBigHuntStatus GetOrCreateStatus(DarkUserMemoryDatabase userDb, long userId, int bossQuestId)
{
return userDb.EntityIUserBigHuntStatus
.FirstOrDefault(s => s.BigHuntBossQuestId == bossQuestId)
?? AddEntity(userDb.EntityIUserBigHuntStatus, new EntityIUserBigHuntStatus
{
UserId = userId,
BigHuntBossQuestId = bossQuestId
});
}
/// <summary>
/// Gets or initialises the player's server-side battle session record.
/// </summary>
private static EntitySBigHuntSession GetOrCreateSession(DarkUserMemoryDatabase userDb, long userId)
{
return userDb.EntitySBigHuntSession
.FirstOrDefault(s => s.UserId == userId)
?? AddEntity(userDb.EntitySBigHuntSession, new EntitySBigHuntSession { UserId = userId });
}
/// <summary>
/// Resets all fields on a BigHunt progress status record to their default (no active hunt) values.
/// </summary>
private static void ClearProgress(EntityIUserBigHuntProgressStatus progress)
{
progress.CurrentBigHuntBossQuestId = 0;
progress.CurrentBigHuntQuestId = 0;
progress.CurrentQuestSceneId = 0;
progress.IsDryRun = false;
}
// ────────── Possession grant ──────────
/// <summary>
/// Routes possession grants through PossessionHelper.Apply for consistent handling.
/// </summary>
private void GrantPossessionViaPossessionHelper(DarkUserMemoryDatabase userDb, long userId, PossessionType possessionType, int possessionId, int count)
{
PossessionHelper.Apply(userDb, userId, possessionType, possessionId, count, _masterDb);
}
// ────────── Time helpers ──────────
/// <summary>
/// Returns Monday 00:00 UTC of the week containing the given timestamp, as millis.
/// </summary>
private static long WeeklyVersion(long millis)
{
DateTimeOffset dto = DateTimeOffset.FromUnixTimeMilliseconds(millis);
DateTime utc = dto.UtcDateTime;
int weekday = (int)utc.DayOfWeek;
if (weekday == 0) { weekday = 7; } // Sunday = 7
DateTime monday = utc.Date.AddDays(-(weekday - 1));
return new DateTimeOffset(monday, TimeSpan.Zero).ToUnixTimeMilliseconds();
}
/// <summary>
/// Appends an entity to the given list and returns it, enabling inline initialisation.
/// </summary>
private static T AddEntity<T>(List<T> list, T entity)
{
list.Add(entity);
return entity;
}
}

View File

@@ -0,0 +1,94 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Helpers;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.CageOrnament;
namespace MariesWonderland.Services;
public class CageOrnamentService(UserDataStore store, DarkMasterMemoryDatabase masterDb)
: MariesWonderland.Proto.CageOrnament.CageornamentService.CageornamentServiceBase
{
private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
/// <summary>
/// Claims the ornament's reward: records the acquisition, grants the possession, and returns the reward details.
/// </summary>
public override Task<ReceiveRewardResponse> ReceiveReward(ReceiveRewardRequest request, ServerCallContext context)
{
EntityMCageOrnament? ornament = _masterDb.EntityMCageOrnament
.FirstOrDefault(o => o.CageOrnamentId == request.CageOrnamentId);
if (ornament is null)
{
return Task.FromResult(new ReceiveRewardResponse());
}
EntityMCageOrnamentReward? reward = _masterDb.EntityMCageOrnamentReward
.FirstOrDefault(r => r.CageOrnamentRewardId == ornament.CageOrnamentRewardId);
if (reward is null)
{
return Task.FromResult(new ReceiveRewardResponse());
}
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Track the reward acquisition state.
EntityIUserCageOrnamentReward? existing = userDb.EntityIUserCageOrnamentReward
.FirstOrDefault(r => r.CageOrnamentId == request.CageOrnamentId);
if (existing is null)
{
userDb.EntityIUserCageOrnamentReward.Add(new EntityIUserCageOrnamentReward
{
UserId = userId,
CageOrnamentId = request.CageOrnamentId,
AcquisitionDatetime = nowMs
});
}
PossessionHelper.Apply(userDb, userId, reward.PossessionType, reward.PossessionId, reward.Count, _masterDb);
ReceiveRewardResponse response = new();
response.CageOrnamentReward.Add(new CageOrnamentReward
{
PossessionType = (int)reward.PossessionType,
PossessionId = reward.PossessionId,
Count = reward.Count
});
return Task.FromResult(response);
}
/// <summary>
/// Records that the user has accessed this cage ornament, creating an access entry if one does not yet exist.
/// </summary>
public override Task<RecordAccessResponse> RecordAccess(RecordAccessRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Create access record if it doesn't exist
bool exists = userDb.EntityIUserCageOrnamentReward
.Any(r => r.CageOrnamentId == request.CageOrnamentId);
if (!exists)
{
userDb.EntityIUserCageOrnamentReward.Add(new EntityIUserCageOrnamentReward
{
UserId = userId,
CageOrnamentId = request.CageOrnamentId,
AcquisitionDatetime = nowMs
});
}
return Task.FromResult(new RecordAccessResponse());
}
}

View File

@@ -0,0 +1,362 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.CharacterBoard;
namespace MariesWonderland.Services;
public class CharacterBoardService(DarkMasterMemoryDatabase masterDb, UserDataStore store)
: MariesWonderland.Proto.CharacterBoard.CharacterboardService.CharacterboardServiceBase
{
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
private readonly UserDataStore _store = store;
/// <summary>Unlocks character board panels: deducts material costs, sets release bits, and applies stat/ability effects.</summary>
public override Task<ReleasePanelResponse> ReleasePanel(ReleasePanelRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
foreach (int panelId in request.CharacterBoardPanelId)
{
EntityMCharacterBoardPanel? panel = null;
foreach (EntityMCharacterBoardPanel p in _masterDb.EntityMCharacterBoardPanel)
{
if (p.CharacterBoardPanelId == panelId)
{
panel = p;
break;
}
}
if (panel == null)
{
continue;
}
ConsumeCosts(userDb, panel);
SetReleaseBit(userDb, userId, panel);
ApplyEffects(userDb, userId, panel);
}
return Task.FromResult(new ReleasePanelResponse());
}
/// <summary>Deducts the material/gem costs required to release a character board panel.</summary>
private void ConsumeCosts(DarkUserMemoryDatabase userDb, EntityMCharacterBoardPanel panel)
{
foreach (EntityMCharacterBoardPanelReleasePossessionGroup cost in _masterDb.EntityMCharacterBoardPanelReleasePossessionGroup)
{
if (cost.CharacterBoardPanelReleasePossessionGroupId != panel.CharacterBoardPanelReleasePossessionGroupId)
{
continue;
}
DeductPossession(userDb, cost.PossessionType, cost.PossessionId, cost.Count);
}
}
/// <summary>Subtracts a possession (material, consumable, or gem) from the user's inventory.</summary>
private static void DeductPossession(DarkUserMemoryDatabase userDb, PossessionType possessionType, int possessionId, int count)
{
switch (possessionType)
{
case PossessionType.MATERIAL:
{
EntityIUserMaterial? mat = null;
foreach (EntityIUserMaterial m in userDb.EntityIUserMaterial)
{
if (m.MaterialId == possessionId)
{
mat = m;
break;
}
}
if (mat != null)
{
mat.Count -= count;
if (mat.Count <= 0)
{
userDb.EntityIUserMaterial.Remove(mat);
}
}
break;
}
case PossessionType.CONSUMABLE_ITEM:
{
EntityIUserConsumableItem? item = null;
foreach (EntityIUserConsumableItem ci in userDb.EntityIUserConsumableItem)
{
if (ci.ConsumableItemId == possessionId)
{
item = ci;
break;
}
}
if (item != null)
{
item.Count -= count;
if (item.Count <= 0)
{
userDb.EntityIUserConsumableItem.Remove(item);
}
}
break;
}
case PossessionType.PAID_GEM:
{
EntityIUserGem? gem = userDb.EntityIUserGem.Count > 0 ? userDb.EntityIUserGem[0] : null;
if (gem != null)
{
gem.PaidGem -= count;
}
break;
}
case PossessionType.FREE_GEM:
{
EntityIUserGem? gem = userDb.EntityIUserGem.Count > 0 ? userDb.EntityIUserGem[0] : null;
if (gem != null)
{
gem.FreeGem -= count;
}
break;
}
}
}
/// <summary>Sets the release bit for a panel on the user's character board, using bitfield-packed storage (32 panels per field).</summary>
private static void SetReleaseBit(DarkUserMemoryDatabase userDb, long userId, EntityMCharacterBoardPanel panel)
{
int boardId = panel.CharacterBoardId;
EntityIUserCharacterBoard? board = null;
foreach (EntityIUserCharacterBoard b in userDb.EntityIUserCharacterBoard)
{
if (b.CharacterBoardId == boardId)
{
board = b;
break;
}
}
if (board == null)
{
board = new EntityIUserCharacterBoard
{
UserId = userId,
CharacterBoardId = boardId
};
userDb.EntityIUserCharacterBoard.Add(board);
}
int bitFieldIndex = (panel.SortOrder - 1) / 32;
int bitPosition = (panel.SortOrder - 1) % 32;
int mask = 1 << bitPosition;
switch (bitFieldIndex)
{
case 0:
board.PanelReleaseBit1 |= mask;
break;
case 1:
board.PanelReleaseBit2 |= mask;
break;
case 2:
board.PanelReleaseBit3 |= mask;
break;
case 3:
board.PanelReleaseBit4 |= mask;
break;
}
}
/// <summary>Applies the panel's release effects (ability unlocks or stat boosts) to the character.</summary>
private void ApplyEffects(DarkUserMemoryDatabase userDb, long userId, EntityMCharacterBoardPanel panel)
{
foreach (EntityMCharacterBoardPanelReleaseEffectGroup eff in _masterDb.EntityMCharacterBoardPanelReleaseEffectGroup)
{
if (eff.CharacterBoardPanelReleaseEffectGroupId != panel.CharacterBoardPanelReleaseEffectGroupId)
{
continue;
}
switch (eff.CharacterBoardEffectType)
{
case CharacterBoardEffectType.ABILITY:
ApplyAbilityEffect(userDb, userId, eff);
break;
case CharacterBoardEffectType.STATUS_UP:
ApplyStatusUpEffect(userDb, userId, eff);
break;
}
}
}
/// <summary>Grants or levels up a character ability from a board panel release, capped by the master-defined max level.</summary>
private void ApplyAbilityEffect(DarkUserMemoryDatabase userDb, long userId, EntityMCharacterBoardPanelReleaseEffectGroup eff)
{
EntityMCharacterBoardAbility? ability = null;
foreach (EntityMCharacterBoardAbility a in _masterDb.EntityMCharacterBoardAbility)
{
if (a.CharacterBoardAbilityId == eff.CharacterBoardEffectId)
{
ability = a;
break;
}
}
if (ability == null)
{
return;
}
int characterId = ResolveCharacterId(ability.CharacterBoardEffectTargetGroupId);
if (characterId == 0)
{
return;
}
// Find or create ability state
EntityIUserCharacterBoardAbility? state = null;
foreach (EntityIUserCharacterBoardAbility a in userDb.EntityIUserCharacterBoardAbility)
{
if (a.CharacterId == characterId && a.AbilityId == ability.AbilityId)
{
state = a;
break;
}
}
if (state == null)
{
state = new EntityIUserCharacterBoardAbility
{
UserId = userId,
CharacterId = characterId,
AbilityId = ability.AbilityId,
Level = 0
};
userDb.EntityIUserCharacterBoardAbility.Add(state);
}
state.Level += eff.EffectValue;
// Clamp to max level if defined
foreach (EntityMCharacterBoardAbilityMaxLevel maxLvl in _masterDb.EntityMCharacterBoardAbilityMaxLevel)
{
if (maxLvl.CharacterId == characterId && maxLvl.AbilityId == ability.AbilityId)
{
if (state.Level > maxLvl.MaxLevel)
{
state.Level = maxLvl.MaxLevel;
}
break;
}
}
}
/// <summary>Applies a stat increase (HP, ATK, AGI, VIT, CRIT) to a character from a board panel release.</summary>
private void ApplyStatusUpEffect(DarkUserMemoryDatabase userDb, long userId, EntityMCharacterBoardPanelReleaseEffectGroup eff)
{
EntityMCharacterBoardStatusUp? statusUp = null;
foreach (EntityMCharacterBoardStatusUp s in _masterDb.EntityMCharacterBoardStatusUp)
{
if (s.CharacterBoardStatusUpId == eff.CharacterBoardEffectId)
{
statusUp = s;
break;
}
}
if (statusUp == null)
{
return;
}
int characterId = ResolveCharacterId(statusUp.CharacterBoardEffectTargetGroupId);
if (characterId == 0)
{
return;
}
StatusCalculationType calcType = StatusUpTypeToCalcType(statusUp.CharacterBoardStatusUpType);
// Find or create status up state
EntityIUserCharacterBoardStatusUp? state = null;
foreach (EntityIUserCharacterBoardStatusUp s in userDb.EntityIUserCharacterBoardStatusUp)
{
if (s.CharacterId == characterId && s.StatusCalculationType == calcType)
{
state = s;
break;
}
}
if (state == null)
{
state = new EntityIUserCharacterBoardStatusUp
{
UserId = userId,
CharacterId = characterId,
StatusCalculationType = calcType
};
userDb.EntityIUserCharacterBoardStatusUp.Add(state);
}
switch (statusUp.CharacterBoardStatusUpType)
{
case CharacterBoardStatusUpType.AGILITY_ADD:
case CharacterBoardStatusUpType.AGILITY_MULTIPLY:
state.Agility += eff.EffectValue;
break;
case CharacterBoardStatusUpType.ATTACK_ADD:
case CharacterBoardStatusUpType.ATTACK_MULTIPLY:
state.Attack += eff.EffectValue;
break;
case CharacterBoardStatusUpType.CRITICAL_ATTACK_ADD:
state.CriticalAttack += eff.EffectValue;
break;
case CharacterBoardStatusUpType.CRITICAL_RATIO_ADD:
state.CriticalRatio += eff.EffectValue;
break;
case CharacterBoardStatusUpType.HP_ADD:
case CharacterBoardStatusUpType.HP_MULTIPLY:
state.Hp += eff.EffectValue;
break;
case CharacterBoardStatusUpType.VITALITY_ADD:
case CharacterBoardStatusUpType.VITALITY_MULTIPLY:
state.Vitality += eff.EffectValue;
break;
}
}
/// <summary>Resolves a CharacterBoardEffectTargetGroupId to its target character ID.</summary>
private int ResolveCharacterId(int targetGroupId)
{
foreach (EntityMCharacterBoardEffectTargetGroup t in _masterDb.EntityMCharacterBoardEffectTargetGroup)
{
if (t.CharacterBoardEffectTargetGroupId == targetGroupId && t.TargetValue != 0)
{
return t.TargetValue;
}
}
return 0;
}
/// <summary>Maps a CharacterBoardStatusUpType to its calculation type (ADD or MULTIPLY).</summary>
private static StatusCalculationType StatusUpTypeToCalcType(CharacterBoardStatusUpType t)
{
return t switch
{
CharacterBoardStatusUpType.AGILITY_MULTIPLY or
CharacterBoardStatusUpType.ATTACK_MULTIPLY or
CharacterBoardStatusUpType.HP_MULTIPLY or
CharacterBoardStatusUpType.VITALITY_MULTIPLY => StatusCalculationType.MULTIPLY,
_ => StatusCalculationType.ADD
};
}
}

View File

@@ -0,0 +1,135 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.Character;
namespace MariesWonderland.Services;
public class CharacterService(DarkMasterMemoryDatabase masterDb, UserDataStore store, GameConfig gameConfig)
: MariesWonderland.Proto.Character.CharacterService.CharacterServiceBase
{
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
private readonly UserDataStore _store = store;
private readonly GameConfig _gameConfig = gameConfig;
/// <summary>Performs character rebirth: deducts gold and materials per step, advancing the rebirth count.</summary>
public override Task<RebirthResponse> Rebirth(RebirthRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
// Look up the rebirth step group for this character
EntityMCharacterRebirth? rebirthMaster = null;
foreach (EntityMCharacterRebirth r in _masterDb.EntityMCharacterRebirth)
{
if (r.CharacterId == request.CharacterId)
{
rebirthMaster = r;
break;
}
}
if (rebirthMaster == null)
{
return Task.FromResult(new RebirthResponse());
}
int stepGroupId = rebirthMaster.CharacterRebirthStepGroupId;
// Find or create user rebirth record
EntityIUserCharacterRebirth? userRebirth = null;
foreach (EntityIUserCharacterRebirth ur in userDb.EntityIUserCharacterRebirth)
{
if (ur.CharacterId == request.CharacterId)
{
userRebirth = ur;
break;
}
}
if (userRebirth == null)
{
userRebirth = new EntityIUserCharacterRebirth
{
UserId = userId,
CharacterId = request.CharacterId,
RebirthCount = 0
};
userDb.EntityIUserCharacterRebirth.Add(userRebirth);
}
int currentCount = userRebirth.RebirthCount;
int targetCount = currentCount + request.RebirthCount;
int completedCount = currentCount;
for (int count = currentCount; count < targetCount; count++)
{
// Find the step row for this group and count
EntityMCharacterRebirthStepGroup? step = null;
foreach (EntityMCharacterRebirthStepGroup s in _masterDb.EntityMCharacterRebirthStepGroup)
{
if (s.CharacterRebirthStepGroupId == stepGroupId && s.BeforeRebirthCount == count)
{
step = s;
break;
}
}
if (step == null)
{
break;
}
// Deduct gold
EntityIUserConsumableItem? gold = null;
foreach (EntityIUserConsumableItem ci in userDb.EntityIUserConsumableItem)
{
if (ci.ConsumableItemId == _gameConfig.ConsumableItemIdForGold)
{
gold = ci;
break;
}
}
if (gold != null)
{
gold.Count = Math.Max(gold.Count - _gameConfig.CharacterRebirthConsumeGold, 0);
}
// Deduct materials
foreach (EntityMCharacterRebirthMaterialGroup mat in _masterDb.EntityMCharacterRebirthMaterialGroup)
{
if (mat.CharacterRebirthMaterialGroupId != step.CharacterRebirthMaterialGroupId)
{
continue;
}
EntityIUserMaterial? userMat = null;
foreach (EntityIUserMaterial m in userDb.EntityIUserMaterial)
{
if (m.MaterialId == mat.MaterialId)
{
userMat = m;
break;
}
}
if (userMat != null)
{
userMat.Count -= mat.Count;
if (userMat.Count <= 0)
{
userDb.EntityIUserMaterial.Remove(userMat);
}
}
}
completedCount = count + 1;
}
userRebirth.RebirthCount = completedCount;
return Task.FromResult(new RebirthResponse());
}
}

View File

@@ -0,0 +1,113 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.CharacterViewer;
namespace MariesWonderland.Services;
public class CharacterViewerService(UserDataStore store, DarkMasterMemoryDatabase masterDb)
: MariesWonderland.Proto.CharacterViewer.CharacterviewerService.CharacterviewerServiceBase
{
private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
/// <summary>Returns the character viewer top screen with all unlocked field IDs based on quest clear conditions.</summary>
public override Task<CharacterViewerTopResponse> CharacterViewerTop(Empty request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
Dictionary<int, long> conditionToQuestId = BuildConditionQuestMap();
// Collect cleared quest IDs to evaluate unlock prerequisites
HashSet<int> clearedQuests = [];
foreach (EntityIUserQuest quest in userDb.EntityIUserQuest)
{
if (quest.QuestStateType == (int)QuestStateType.CLEARED)
{
clearedQuests.Add(quest.QuestId);
}
}
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
List<int> releasedFieldIds = [];
// Check each field's release condition and track newly unlocked ones
foreach (EntityMCharacterViewerField field in _masterDb.EntityMCharacterViewerField)
{
bool isReleased;
if (field.ReleaseEvaluateConditionId == 0)
{
isReleased = true;
}
else if (conditionToQuestId.TryGetValue(field.ReleaseEvaluateConditionId, out long requiredQuestId))
{
isReleased = clearedQuests.Contains((int)requiredQuestId);
}
else
{
isReleased = false;
}
if (!isReleased)
{
continue;
}
releasedFieldIds.Add(field.CharacterViewerFieldId);
bool alreadyTracked = userDb.EntityIUserCharacterViewerField
.Any(f => f.CharacterViewerFieldId == field.CharacterViewerFieldId);
if (!alreadyTracked)
{
userDb.EntityIUserCharacterViewerField.Add(new EntityIUserCharacterViewerField
{
UserId = userId,
CharacterViewerFieldId = field.CharacterViewerFieldId,
ReleaseDatetime = nowMs
});
}
}
releasedFieldIds.Sort();
CharacterViewerTopResponse response = new();
response.ReleaseCharacterViewerFieldId.AddRange(releasedFieldIds);
return Task.FromResult(response);
}
/// <summary>Builds a mapping from EvaluateConditionId to required quest ID for QUEST_CLEAR conditions.</summary>
private Dictionary<int, long> BuildConditionQuestMap()
{
Dictionary<(int GroupId, int GroupIndex), long> vgByKey = [];
foreach (EntityMEvaluateConditionValueGroup vg in _masterDb.EntityMEvaluateConditionValueGroup)
{
vgByKey[(vg.EvaluateConditionValueGroupId, vg.GroupIndex)] = vg.Value;
}
Dictionary<int, long> conditionToQuestId = [];
foreach (EntityMEvaluateCondition cond in _masterDb.EntityMEvaluateCondition)
{
if (cond.EvaluateConditionFunctionType != EvaluateConditionFunctionType.QUEST_CLEAR)
{
continue;
}
if (cond.EvaluateConditionEvaluateType != EvaluateConditionEvaluateType.ID_CONTAIN)
{
continue;
}
if (vgByKey.TryGetValue((cond.EvaluateConditionValueGroupId, 1), out long questId))
{
conditionToQuestId[cond.EvaluateConditionId] = questId;
}
}
return conditionToQuestId;
}
}

View File

@@ -0,0 +1,191 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.Companion;
namespace MariesWonderland.Services;
public class CompanionService(DarkMasterMemoryDatabase masterDb, UserDataStore store, GameConfig gameConfig)
: MariesWonderland.Proto.Companion.CompanionService.CompanionServiceBase
{
private const int CompanionMaxLevel = 50;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
private readonly UserDataStore _store = store;
private readonly GameConfig _gameConfig = gameConfig;
/// <summary>Levels up a companion by deducting gold and materials per level, capping at the max companion level.</summary>
public override Task<EnhanceResponse> Enhance(EnhanceRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
// Find the user's companion by UUID
EntityIUserCompanion? companion = null;
foreach (EntityIUserCompanion c in userDb.EntityIUserCompanion)
{
if (c.UserCompanionUuid == request.UserCompanionUuid)
{
companion = c;
break;
}
}
if (companion == null)
{
return Task.FromResult(new EnhanceResponse());
}
// Find companion master data
EntityMCompanion? compDef = null;
foreach (EntityMCompanion mc in _masterDb.EntityMCompanion)
{
if (mc.CompanionId == companion.CompanionId)
{
compDef = mc;
break;
}
}
if (compDef == null)
{
return Task.FromResult(new EnhanceResponse());
}
int targetLevel = companion.Level + request.AddLevelCount;
if (targetLevel > CompanionMaxLevel)
{
targetLevel = CompanionMaxLevel;
}
// Find gold cost function for this category
EntityMCompanionCategory? category = null;
foreach (EntityMCompanionCategory cat in _masterDb.EntityMCompanionCategory)
{
if (cat.CompanionCategoryType == compDef.CompanionCategoryType)
{
category = cat;
break;
}
}
for (int lvl = companion.Level; lvl < targetLevel; lvl++)
{
// Deduct gold cost
if (category != null)
{
int goldCost = EvaluateNumericalFunction(category.EnhancementCostNumericalFunctionId, lvl);
EntityIUserConsumableItem? gold = null;
foreach (EntityIUserConsumableItem ci in userDb.EntityIUserConsumableItem)
{
if (ci.ConsumableItemId == _gameConfig.ConsumableItemIdForGold)
{
gold = ci;
break;
}
}
if (gold != null)
{
gold.Count -= goldCost;
}
}
// Deduct materials for this level
foreach (EntityMCompanionEnhancementMaterial mat in _masterDb.EntityMCompanionEnhancementMaterial)
{
if (mat.CompanionCategoryType == compDef.CompanionCategoryType && mat.Level == lvl)
{
EntityIUserMaterial? userMat = null;
foreach (EntityIUserMaterial m in userDb.EntityIUserMaterial)
{
if (m.MaterialId == mat.MaterialId)
{
userMat = m;
break;
}
}
if (userMat != null)
{
userMat.Count -= mat.Count;
}
}
}
}
companion.Level = targetLevel;
return Task.FromResult(new EnhanceResponse());
}
/// <summary>Evaluates a master data numerical function (linear, monomial, polynomial) for a given input value.</summary>
private int EvaluateNumericalFunction(int functionId, int value)
{
EntityMNumericalFunction? func = null;
foreach (EntityMNumericalFunction f in _masterDb.EntityMNumericalFunction)
{
if (f.NumericalFunctionId == functionId)
{
func = f;
break;
}
}
if (func == null)
{
return 0;
}
List<(int Index, int Value)> paramEntries = [];
foreach (EntityMNumericalFunctionParameterGroup pg in _masterDb.EntityMNumericalFunctionParameterGroup)
{
if (pg.NumericalFunctionParameterGroupId == func.NumericalFunctionParameterGroupId)
{
paramEntries.Add((pg.ParameterIndex, pg.ParameterValue));
}
}
paramEntries.Sort((a, b) => a.Index.CompareTo(b.Index));
int[] p = new int[paramEntries.Count];
for (int i = 0; i < paramEntries.Count; i++)
{
p[i] = paramEntries[i].Value;
}
return func.NumericalFunctionType switch
{
NumericalFunctionType.LINEAR when p.Length >= 2 => p[1] + p[0] * value,
NumericalFunctionType.MONOMIAL when p.Length >= 2 => EvaluateMonomial(p, value),
NumericalFunctionType.LINEAR_PERMIL when p.Length >= 2 => p[0] * value / 1000 + p[1],
NumericalFunctionType.POLYNOMIAL_THIRD when p.Length >= 4 => p[3] + (p[2] + (p[1] + p[0] * value) * value) * value,
NumericalFunctionType.POLYNOMIAL_THIRD_PERMIL when p.Length >= 4 =>
p[0] * value * value * value / 1000 +
p[1] * value * value / 1000 +
p[2] * value / 1000 +
p[3],
_ => 0
};
}
/// <summary>Evaluates a monomial function: p[0] * (value - 1) ^ p[1].</summary>
private static int EvaluateMonomial(int[] p, int value)
{
int v = value - 1;
int result = v;
int counter = p[1];
if (counter > 1)
{
counter--;
while (counter > 0)
{
counter--;
result *= v;
}
}
return result * p[0];
}
}

View File

@@ -0,0 +1,14 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Proto.Config;
namespace MariesWonderland.Services;
public class ConfigService : MariesWonderland.Proto.Config.ConfigService.ConfigServiceBase
{
/// <summary>Returns an empty response. Review server configuration not yet implemented.</summary>
public override Task<GetReviewServerConfigResponse> GetReviewServerConfig(Empty request, ServerCallContext context)
{
return Task.FromResult(new GetReviewServerConfigResponse());
}
}

View File

@@ -0,0 +1,99 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.ConsumableItem;
namespace MariesWonderland.Services;
public class ConsumableItemService(DarkMasterMemoryDatabase masterDb, UserDataStore store, GameConfig gameConfig)
: MariesWonderland.Proto.ConsumableItem.ConsumableitemService.ConsumableitemServiceBase
{
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
private readonly UserDataStore _store = store;
private readonly GameConfig _gameConfig = gameConfig;
/// <summary>Returns an empty response. Consumable item use effects not yet implemented.</summary>
public override Task<UseEffectItemResponse> UseEffectItem(UseEffectItemRequest request, ServerCallContext context)
{
return Task.FromResult(new UseEffectItemResponse());
}
/// <summary>Sells consumable items for gold, removing depleted entries from inventory.</summary>
public override Task<SellResponse> Sell(SellRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
int totalGold = 0;
foreach (SellPossession item in request.ConsumableItemPossession)
{
EntityMConsumableItem? masterRow = null;
foreach (EntityMConsumableItem m in _masterDb.EntityMConsumableItem)
{
if (m.ConsumableItemId == item.ConsumableItemId)
{
masterRow = m;
break;
}
}
if (masterRow == null)
{
continue;
}
EntityIUserConsumableItem? userItem = null;
foreach (EntityIUserConsumableItem u in userDb.EntityIUserConsumableItem)
{
if (u.ConsumableItemId == item.ConsumableItemId)
{
userItem = u;
break;
}
}
if (userItem == null || userItem.Count < item.Count)
{
continue;
}
userItem.Count -= item.Count;
if (userItem.Count == 0)
{
userDb.EntityIUserConsumableItem.Remove(userItem);
}
totalGold += masterRow.SellPrice * item.Count;
}
if (totalGold > 0)
{
AddGold(userDb, userId, totalGold);
}
return Task.FromResult(new SellResponse());
}
/// <summary>Adds gold (consumable item ID 1) to the user's inventory, creating the entry if needed.</summary>
private void AddGold(DarkUserMemoryDatabase userDb, long userId, int amount)
{
foreach (EntityIUserConsumableItem ci in userDb.EntityIUserConsumableItem)
{
if (ci.ConsumableItemId == _gameConfig.ConsumableItemIdForGold)
{
ci.Count += amount;
return;
}
}
userDb.EntityIUserConsumableItem.Add(new EntityIUserConsumableItem
{
UserId = userId,
ConsumableItemId = _gameConfig.ConsumableItemIdForGold,
Count = amount,
FirstAcquisitionDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
}
}

View File

@@ -0,0 +1,40 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.ContentsStory;
namespace MariesWonderland.Services;
public class ContentsStoryService(UserDataStore store)
: MariesWonderland.Proto.ContentsStory.ContentsstoryService.ContentsstoryServiceBase
{
private readonly UserDataStore _store = store;
/// <summary>Records that a contents story entry has been played, tracking the play timestamp.</summary>
public override Task<RegisterPlayedResponse> RegisterPlayed(RegisterPlayedRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityIUserContentsStory? existing = userDb.EntityIUserContentsStory
.FirstOrDefault(s => s.ContentsStoryId == request.ContentsStoryId);
if (existing is null)
{
userDb.EntityIUserContentsStory.Add(new EntityIUserContentsStory
{
UserId = userId,
ContentsStoryId = request.ContentsStoryId,
PlayDatetime = nowMs
});
}
else
{
existing.PlayDatetime = nowMs;
}
return Task.FromResult(new RegisterPlayedResponse());
}
}

View File

@@ -0,0 +1,806 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.Costume;
namespace MariesWonderland.Services;
public class CostumeService(DarkMasterMemoryDatabase masterDb, UserDataStore store, GameConfig gameConfig)
: MariesWonderland.Proto.Costume.CostumeService.CostumeServiceBase
{
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
private readonly UserDataStore _store = store;
private readonly GameConfig _gameConfig = gameConfig;
/// <summary>
/// Enhances a costume using materials: deducts materials and gold, adds EXP, recalculates level.
/// </summary>
/// <summary>
/// Enhances a costume using enhancement materials to gain EXP. Materials matching the costume's weapon type grant a 1.5x EXP bonus.
/// </summary>
public override Task<EnhanceResponse> Enhance(EnhanceRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserCostume? costume = null;
foreach (EntityIUserCostume c in userDb.EntityIUserCostume)
{
if (c.UserCostumeUuid == request.UserCostumeUuid)
{
costume = c;
break;
}
}
if (costume == null)
{
return Task.FromResult(new EnhanceResponse());
}
EntityMCostume? costumeMaster = null;
foreach (EntityMCostume cm in _masterDb.EntityMCostume)
{
if (cm.CostumeId == costume.CostumeId)
{
costumeMaster = cm;
break;
}
}
if (costumeMaster == null)
{
return Task.FromResult(new EnhanceResponse());
}
// Filter master data to only costume-enhancement materials
Dictionary<int, EntityMMaterial> materialCatalog = [];
foreach (EntityMMaterial mat in _masterDb.EntityMMaterial)
{
if (mat.MaterialType == MaterialType.COSTUME_ENHANCEMENT)
{
materialCatalog[mat.MaterialId] = mat;
}
}
// Consume materials and calculate total EXP gained
int totalExp = 0;
int totalMaterialCount = 0;
foreach (KeyValuePair<int, int> entry in request.Materials)
{
int materialId = entry.Key;
int count = entry.Value;
if (!materialCatalog.TryGetValue(materialId, out EntityMMaterial? mat))
{
continue;
}
EntityIUserMaterial? userMat = null;
foreach (EntityIUserMaterial m in userDb.EntityIUserMaterial)
{
if (m.MaterialId == materialId)
{
userMat = m;
break;
}
}
if (userMat == null || userMat.Count < count)
{
continue;
}
userMat.Count -= count;
totalMaterialCount += count;
// Apply 1.5x EXP bonus when material weapon type matches the costume's proficient weapon type
int expPerUnit = mat.EffectValue;
if (mat.WeaponType != WeaponType.UNKNOWN && mat.WeaponType == costumeMaster.SkillfulWeaponType)
{
expPerUnit = expPerUnit * _gameConfig.MaterialSameWeaponExpCoefficientPermil / 1000;
}
totalExp += expPerUnit * count;
}
// Look up rarity-based cost and EXP threshold parameters
EntityMCostumeRarity? rarityRow = null;
foreach (EntityMCostumeRarity r in _masterDb.EntityMCostumeRarity)
{
if (r.RarityType == costumeMaster.RarityType)
{
rarityRow = r;
break;
}
}
// Deduct gold cost scaled by number of materials used
if (totalMaterialCount > 0 && rarityRow != null)
{
int goldCost = EvaluateNumericalFunction(rarityRow.EnhancementCostByMaterialNumericalFunctionId, totalMaterialCount);
EntityIUserConsumableItem? gold = null;
foreach (EntityIUserConsumableItem ci in userDb.EntityIUserConsumableItem)
{
if (ci.ConsumableItemId == _gameConfig.ConsumableItemIdForGold)
{
gold = ci;
break;
}
}
if (gold != null)
{
gold.Count -= goldCost;
}
}
// Apply EXP and recalculate level from rarity-specific thresholds
costume.Exp += totalExp;
if (rarityRow != null)
{
(costume.Level, costume.Exp) = CalculateCostumeLevelAndCap(costume.Exp, rarityRow.RequiredExpForLevelUpNumericalParameterMapId);
}
return Task.FromResult(new EnhanceResponse { IsGreatSuccess = false });
}
/// <summary>
/// Calculates the costume level from accumulated EXP and caps EXP at the max threshold.
/// </summary>
private (int Level, int CappedExp) CalculateCostumeLevelAndCap(int exp, int paramMapId)
{
int maxKey = 0;
foreach (EntityMNumericalParameterMap row in _masterDb.EntityMNumericalParameterMap)
{
if (row.NumericalParameterMapId == paramMapId && row.ParameterKey > maxKey)
{
maxKey = row.ParameterKey;
}
}
int[] thresholds = new int[maxKey + 1];
foreach (EntityMNumericalParameterMap row in _masterDb.EntityMNumericalParameterMap)
{
if (row.NumericalParameterMapId == paramMapId && row.ParameterKey < thresholds.Length)
{
thresholds[row.ParameterKey] = row.ParameterValue;
}
}
int level = 1;
for (int lvl = 1; lvl < thresholds.Length; lvl++)
{
if (exp >= thresholds[lvl])
{
level = lvl;
}
else
{
break;
}
}
// Cap EXP at the last threshold (max level cap)
if (thresholds.Length > 0 && exp > thresholds[^1])
{
exp = thresholds[^1];
}
return (level, exp);
}
/// <summary>
/// Evaluates a numerical function (linear, monomial, polynomial) from master data parameters.
/// </summary>
/// <summary>
/// Evaluates a master data numerical function (LINEAR, MONOMIAL, POLYNOMIAL, etc.) used for cost and threshold calculations.
/// </summary>
private int EvaluateNumericalFunction(int functionId, int value)
{
EntityMNumericalFunction? func = null;
foreach (EntityMNumericalFunction f in _masterDb.EntityMNumericalFunction)
{
if (f.NumericalFunctionId == functionId)
{
func = f;
break;
}
}
if (func == null)
{
return 0;
}
List<(int Index, int Value)> paramEntries = [];
foreach (EntityMNumericalFunctionParameterGroup pg in _masterDb.EntityMNumericalFunctionParameterGroup)
{
if (pg.NumericalFunctionParameterGroupId == func.NumericalFunctionParameterGroupId)
{
paramEntries.Add((pg.ParameterIndex, pg.ParameterValue));
}
}
paramEntries.Sort((a, b) => a.Index.CompareTo(b.Index));
int[] p = new int[paramEntries.Count];
for (int i = 0; i < paramEntries.Count; i++)
{
p[i] = paramEntries[i].Value;
}
return func.NumericalFunctionType switch
{
NumericalFunctionType.LINEAR when p.Length >= 2 => p[1] + p[0] * value,
NumericalFunctionType.MONOMIAL when p.Length >= 2 => EvaluateMonomial(p, value),
NumericalFunctionType.LINEAR_PERMIL when p.Length >= 2 => p[0] * value / 1000 + p[1],
NumericalFunctionType.POLYNOMIAL_THIRD when p.Length >= 4 => p[3] + (p[2] + (p[1] + p[0] * value) * value) * value,
NumericalFunctionType.POLYNOMIAL_THIRD_PERMIL when p.Length >= 4 =>
p[0] * value * value * value / 1000 +
p[1] * value * value / 1000 +
p[2] * value / 1000 +
p[3],
_ => 0
};
}
/// <summary>
/// Evaluates a monomial function: p[0] * (value - 1)^p[1].
/// </summary>
/// <summary>
/// Computes a monomial function: coefficient * (value - 1) ^ exponent.
/// </summary>
private static int EvaluateMonomial(int[] p, int value)
{
int v = value - 1;
int result = v;
int counter = p[1];
if (counter > 1)
{
counter--;
while (counter > 0)
{
counter--;
result *= v;
}
}
return result * p[0];
}
/// <summary>
/// Limit-breaks a costume using materials: deducts materials and gold, increments break count.
/// </summary>
/// <summary>
/// Limit breaks a costume using materials, raising its max level cap. Capped at 4 total limit breaks.
/// </summary>
public override Task<LimitBreakResponse> LimitBreak(LimitBreakRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserCostume? costume = null;
foreach (EntityIUserCostume c in userDb.EntityIUserCostume)
{
if (c.UserCostumeUuid == request.UserCostumeUuid)
{
costume = c;
break;
}
}
if (costume == null || costume.LimitBreakCount >= _gameConfig.CostumeLimitBreakAvailableCount)
{
return Task.FromResult(new LimitBreakResponse());
}
EntityMCostume? costumeMaster = null;
foreach (EntityMCostume cm in _masterDb.EntityMCostume)
{
if (cm.CostumeId == costume.CostumeId)
{
costumeMaster = cm;
break;
}
}
if (costumeMaster == null)
{
return Task.FromResult(new LimitBreakResponse());
}
// Consume limit break materials
int totalMaterialCount = 0;
foreach (KeyValuePair<int, int> entry in request.Materials)
{
int materialId = entry.Key;
int count = entry.Value;
EntityIUserMaterial? userMat = FindUserMaterial(userDb, materialId);
if (userMat == null)
{
continue;
}
if (userMat.Count < count)
{
count = userMat.Count;
}
userMat.Count -= count;
totalMaterialCount += count;
}
// Deduct gold cost based on costume rarity
if (totalMaterialCount > 0)
{
EntityMCostumeRarity? rarityRow = null;
foreach (EntityMCostumeRarity r in _masterDb.EntityMCostumeRarity)
{
if (r.RarityType == costumeMaster.RarityType)
{
rarityRow = r;
break;
}
}
if (rarityRow != null)
{
int goldCost = EvaluateNumericalFunction(rarityRow.LimitBreakCostNumericalFunctionId, totalMaterialCount);
SubtractGold(userDb, goldCost);
}
}
costume.LimitBreakCount++;
return Task.FromResult(new LimitBreakResponse());
}
/// <summary>
/// Awakens a costume: deducts materials and gold, applies awaken effects (status up, item acquire).
/// </summary>
/// <summary>
/// Awakens a costume to the next step, consuming materials and gold. Each step may grant stat bonuses, abilities, or items.
/// </summary>
public override Task<AwakenResponse> Awaken(AwakenRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserCostume? costume = null;
foreach (EntityIUserCostume c in userDb.EntityIUserCostume)
{
if (c.UserCostumeUuid == request.UserCostumeUuid)
{
costume = c;
break;
}
}
if (costume == null)
{
return Task.FromResult(new AwakenResponse());
}
EntityMCostumeAwaken? awakenRow = null;
foreach (EntityMCostumeAwaken a in _masterDb.EntityMCostumeAwaken)
{
if (a.CostumeId == costume.CostumeId)
{
awakenRow = a;
break;
}
}
if (awakenRow == null)
{
return Task.FromResult(new AwakenResponse());
}
int nextStep = costume.AwakenCount + 1;
// Find gold cost from the price tier matching this awaken step
int goldCost = 0;
int bestStepLimit = -1;
foreach (EntityMCostumeAwakenPriceGroup pg in _masterDb.EntityMCostumeAwakenPriceGroup)
{
if (pg.CostumeAwakenPriceGroupId == awakenRow.CostumeAwakenPriceGroupId
&& pg.AwakenStepLowerLimit <= nextStep
&& pg.AwakenStepLowerLimit > bestStepLimit)
{
bestStepLimit = pg.AwakenStepLowerLimit;
goldCost = pg.Gold;
}
}
if (goldCost > 0)
{
SubtractGold(userDb, goldCost);
}
// Consume awakening materials
foreach (KeyValuePair<int, int> entry in request.Materials)
{
int materialId = entry.Key;
int count = entry.Value;
EntityIUserMaterial? userMat = FindUserMaterial(userDb, materialId);
if (userMat == null)
{
continue;
}
if (userMat.Count < count)
{
count = userMat.Count;
}
userMat.Count -= count;
}
costume.AwakenCount = nextStep;
// Apply the awaken step's effect (stat boost, ability unlock, or item grant)
EntityMCostumeAwakenEffectGroup? effect = null;
foreach (EntityMCostumeAwakenEffectGroup eg in _masterDb.EntityMCostumeAwakenEffectGroup)
{
if (eg.CostumeAwakenEffectGroupId == awakenRow.CostumeAwakenEffectGroupId
&& eg.AwakenStep == nextStep)
{
effect = eg;
break;
}
}
if (effect != null)
{
switch (effect.CostumeAwakenEffectType)
{
case CostumeAwakenEffectType.STATUS_UP:
ApplyAwakenStatusUp(userDb, userId, request.UserCostumeUuid, effect.CostumeAwakenEffectId);
break;
case CostumeAwakenEffectType.ABILITY:
break;
case CostumeAwakenEffectType.ITEM_ACQUIRE:
ApplyAwakenItemAcquire(userDb, userId, effect.CostumeAwakenEffectId);
break;
}
}
return Task.FromResult(new AwakenResponse());
}
/// <summary>
/// Levels up a costume's active skill: deducts materials and gold per level.
/// </summary>
/// <summary>
/// Levels up a costume's active skill by spending materials and gold. Max skill level is determined by costume rarity.
/// </summary>
public override Task<EnhanceActiveSkillResponse> EnhanceActiveSkill(EnhanceActiveSkillRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserCostume? costume = null;
foreach (EntityIUserCostume c in userDb.EntityIUserCostume)
{
if (c.UserCostumeUuid == request.UserCostumeUuid)
{
costume = c;
break;
}
}
if (costume == null)
{
return Task.FromResult(new EnhanceActiveSkillResponse());
}
EntityMCostume? costumeMaster = null;
foreach (EntityMCostume cm in _masterDb.EntityMCostume)
{
if (cm.CostumeId == costume.CostumeId)
{
costumeMaster = cm;
break;
}
}
if (costumeMaster == null)
{
return Task.FromResult(new EnhanceActiveSkillResponse());
}
// Select the skill group tier unlocked by the costume's limit break count
int enhanceMatId = -1;
int bestLbThreshold = -1;
foreach (EntityMCostumeActiveSkillGroup g in _masterDb.EntityMCostumeActiveSkillGroup)
{
if (g.CostumeActiveSkillGroupId == costumeMaster.CostumeActiveSkillGroupId
&& g.CostumeLimitBreakCountLowerLimit <= costume.LimitBreakCount
&& g.CostumeLimitBreakCountLowerLimit > bestLbThreshold)
{
bestLbThreshold = g.CostumeLimitBreakCountLowerLimit;
enhanceMatId = g.CostumeActiveSkillEnhancementMaterialId;
}
}
if (enhanceMatId < 0)
{
return Task.FromResult(new EnhanceActiveSkillResponse());
}
// Look up the user's current active skill level
EntityIUserCostumeActiveSkill? skill = null;
foreach (EntityIUserCostumeActiveSkill s in userDb.EntityIUserCostumeActiveSkill)
{
if (s.UserCostumeUuid == request.UserCostumeUuid)
{
skill = s;
break;
}
}
int currentLevel = skill?.Level ?? 0;
// Determine max skill level from costume rarity
EntityMCostumeRarity? rarityRow = null;
foreach (EntityMCostumeRarity r in _masterDb.EntityMCostumeRarity)
{
if (r.RarityType == costumeMaster.RarityType)
{
rarityRow = r;
break;
}
}
if (rarityRow == null)
{
return Task.FromResult(new EnhanceActiveSkillResponse());
}
int maxLevel = EvaluateNumericalFunction(rarityRow.ActiveSkillMaxLevelNumericalFunctionId, 1);
// Cap the requested level increase at the max
int addCount = request.AddLevelCount;
if (currentLevel + addCount > maxLevel)
{
addCount = maxLevel - currentLevel;
}
if (addCount <= 0)
{
return Task.FromResult(new EnhanceActiveSkillResponse());
}
// Deduct materials and gold for each level gained
for (int lvl = currentLevel; lvl < currentLevel + addCount; lvl++)
{
foreach (EntityMCostumeActiveSkillEnhancementMaterial mat in _masterDb.EntityMCostumeActiveSkillEnhancementMaterial)
{
if (mat.CostumeActiveSkillEnhancementMaterialId == enhanceMatId && mat.SkillLevel == lvl)
{
EntityIUserMaterial? userMat = FindUserMaterial(userDb, mat.MaterialId);
if (userMat != null)
{
int cost = mat.Count;
if (userMat.Count < cost)
{
cost = userMat.Count;
}
userMat.Count -= cost;
}
}
}
int goldCost = EvaluateNumericalFunction(rarityRow.ActiveSkillEnhancementCostNumericalFunctionId, lvl + 1);
SubtractGold(userDb, goldCost);
}
// Create the active skill record on first enhancement
if (skill == null)
{
skill = new EntityIUserCostumeActiveSkill
{
UserId = userId,
UserCostumeUuid = request.UserCostumeUuid,
AcquisitionDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
userDb.EntityIUserCostumeActiveSkill.Add(skill);
}
skill.Level = currentLevel + addCount;
return Task.FromResult(new EnhanceActiveSkillResponse());
}
/// <summary>
/// Stub for level bonus confirmation; returns empty response.
/// </summary>
/// <summary>
/// Acknowledges that the player has seen the level bonus notification. No server-side state change needed.
/// </summary>
public override Task<RegisterLevelBonusConfirmedResponse> RegisterLevelBonusConfirmed(RegisterLevelBonusConfirmedRequest request, ServerCallContext context)
{
return Task.FromResult(new RegisterLevelBonusConfirmedResponse());
}
/// <summary>
/// Stub for lottery effect slot unlock; returns empty response.
/// </summary>
/// <summary>
/// Unlocks a lottery effect slot on a costume. Not yet implemented.
/// </summary>
public override Task<UnlockLotteryEffectSlotResponse> UnlockLotteryEffectSlot(UnlockLotteryEffectSlotRequest request, ServerCallContext context)
{
return Task.FromResult(new UnlockLotteryEffectSlotResponse());
}
/// <summary>
/// Stub for lottery effect draw; returns empty response.
/// </summary>
/// <summary>
/// Draws a random lottery effect for a costume slot. Not yet implemented.
/// </summary>
public override Task<DrawLotteryEffectResponse> DrawLotteryEffect(DrawLotteryEffectRequest request, ServerCallContext context)
{
return Task.FromResult(new DrawLotteryEffectResponse());
}
/// <summary>
/// Stub for lottery effect confirmation; returns empty response.
/// </summary>
/// <summary>
/// Confirms and locks in a drawn lottery effect for a costume. Not yet implemented.
/// </summary>
public override Task<ConfirmLotteryEffectResponse> ConfirmLotteryEffect(ConfirmLotteryEffectRequest request, ServerCallContext context)
{
return Task.FromResult(new ConfirmLotteryEffectResponse());
}
/// <summary>
/// Applies stat increases from a costume awaken status-up effect.
/// </summary>
/// <summary>
/// Applies awakening stat bonuses (HP, ATK, VIT, AGI, CRIT, etc.) to the costume's awaken status record.
/// </summary>
private void ApplyAwakenStatusUp(DarkUserMemoryDatabase userDb, long userId, string userCostumeUuid, int statusUpGroupId)
{
foreach (EntityMCostumeAwakenStatusUpGroup row in _masterDb.EntityMCostumeAwakenStatusUpGroup)
{
if (row.CostumeAwakenStatusUpGroupId != statusUpGroupId)
{
continue;
}
EntityIUserCostumeAwakenStatusUp? state = null;
foreach (EntityIUserCostumeAwakenStatusUp s in userDb.EntityIUserCostumeAwakenStatusUp)
{
if (s.UserCostumeUuid == userCostumeUuid && s.StatusCalculationType == row.StatusCalculationType)
{
state = s;
break;
}
}
if (state == null)
{
state = new EntityIUserCostumeAwakenStatusUp
{
UserId = userId,
UserCostumeUuid = userCostumeUuid,
StatusCalculationType = row.StatusCalculationType,
};
userDb.EntityIUserCostumeAwakenStatusUp.Add(state);
}
switch (row.StatusKindType)
{
case StatusKindType.HP:
state.Hp += row.EffectValue;
break;
case StatusKindType.ATTACK:
state.Attack += row.EffectValue;
break;
case StatusKindType.VITALITY:
state.Vitality += row.EffectValue;
break;
case StatusKindType.AGILITY:
state.Agility += row.EffectValue;
break;
case StatusKindType.CRITICAL_RATIO:
state.CriticalRatio += row.EffectValue;
break;
case StatusKindType.CRITICAL_ATTACK:
state.CriticalAttack += row.EffectValue;
break;
}
}
}
/// <summary>
/// Grants a thought item from a costume awaken item-acquire effect.
/// </summary>
/// <summary>
/// Grants a thought item as an awakening reward, creating a new inventory entry if not already owned.
/// </summary>
private void ApplyAwakenItemAcquire(DarkUserMemoryDatabase userDb, long userId, int itemAcquireId)
{
EntityMCostumeAwakenItemAcquire? acq = null;
foreach (EntityMCostumeAwakenItemAcquire a in _masterDb.EntityMCostumeAwakenItemAcquire)
{
if (a.CostumeAwakenItemAcquireId == itemAcquireId)
{
acq = a;
break;
}
}
if (acq == null)
{
return;
}
string uuid = $"awaken-thought-{acq.PossessionId}";
foreach (EntityIUserThought t in userDb.EntityIUserThought)
{
if (t.UserThoughtUuid == uuid)
{
return;
}
}
userDb.EntityIUserThought.Add(new EntityIUserThought
{
UserId = userId,
UserThoughtUuid = uuid,
ThoughtId = acq.PossessionId,
AcquisitionDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
}
/// <summary>
/// Finds a user's material record by material ID.
/// </summary>
/// <summary>
/// Looks up a user's material inventory entry by material ID.
/// </summary>
private static EntityIUserMaterial? FindUserMaterial(DarkUserMemoryDatabase userDb, int materialId)
{
foreach (EntityIUserMaterial m in userDb.EntityIUserMaterial)
{
if (m.MaterialId == materialId)
{
return m;
}
}
return null;
}
/// <summary>
/// Deducts gold (consumable item ID 1) from the user's inventory.
/// </summary>
/// <summary>
/// Deducts gold (consumable item ID 1) from the user's inventory.
/// </summary>
private void SubtractGold(DarkUserMemoryDatabase userDb, int amount)
{
if (amount <= 0)
{
return;
}
foreach (EntityIUserConsumableItem ci in userDb.EntityIUserConsumableItem)
{
if (ci.ConsumableItemId == _gameConfig.ConsumableItemIdForGold)
{
ci.Count -= amount;
return;
}
}
}
}

View File

@@ -0,0 +1,58 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Configuration;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Proto.Data;
using Microsoft.Extensions.Options;
namespace MariesWonderland.Services;
public class DataService(IOptions<ServerOptions> options, UserDataStore userDataStore)
: MariesWonderland.Proto.Data.DataService.DataServiceBase
{
private readonly DataOptions _data = options.Value.Data;
/// <summary>Returns the latest master data version so the client can check if an asset update is needed.</summary>
public override Task<MasterDataGetLatestVersionResponse> GetLatestMasterDataVersion(Empty request, ServerCallContext context)
{
return Task.FromResult(new MasterDataGetLatestVersionResponse
{
LatestMasterDataVersion = _data.LatestMasterDataVersion
});
}
/// <summary>Returns the sorted list of user data table names used for diff-based synchronization.</summary>
public override Task<UserDataGetNameResponseV2> GetUserDataNameV2(Empty request, ServerCallContext context)
{
TableNameList tableNameList = new();
tableNameList.TableName.AddRange(UserDataDiffBuilder.TableNames.Order());
UserDataGetNameResponseV2 response = new();
response.TableNameList.Add(tableNameList);
return Task.FromResult(response);
}
/// <summary>Returns the full user state for the requested tables, serialized as JSON. Validates session before responding.</summary>
public override Task<UserDataGetResponse> GetUserData(UserDataGetRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
string sessionKey = context.GetSessionKey();
if (!userDataStore.TryResolveSession(sessionKey, out long resolvedUserId) || resolvedUserId != userId)
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid or expired session."));
}
DarkUserMemoryDatabase userDb = userDataStore.GetOrCreate(userId);
UserDataGetResponse response = new();
foreach (string tableName in request.TableName)
{
response.UserDataJson[tableName] = UserDataDiffBuilder.SerializeTable(userDb, tableName);
}
return Task.FromResult(response);
}
}

283
src/Services/DeckService.cs Normal file
View File

@@ -0,0 +1,283 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.Deck;
namespace MariesWonderland.Services;
public class DeckService(UserDataStore store) : MariesWonderland.Proto.Deck.DeckService.DeckServiceBase
{
private readonly UserDataStore _store = store;
/// <summary>Renames a deck.</summary>
public override Task<UpdateNameResponse> UpdateName(UpdateNameRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserDeck? deck = userDb.EntityIUserDeck.FirstOrDefault(d =>
d.DeckType == (DeckType)request.DeckType &&
d.UserDeckNumber == request.UserDeckNumber);
deck?.Name = request.Name;
return Task.FromResult(new UpdateNameResponse());
}
/// <summary>Replaces a deck's character lineup with new entries.</summary>
public override Task<ReplaceDeckResponse> ReplaceDeck(ReplaceDeckRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
if (request.Deck != null)
{
ApplyDeckReplacement(userDb, userId, (DeckType)request.DeckType, request.UserDeckNumber, request.Deck);
}
return Task.FromResult(new ReplaceDeckResponse());
}
/// <summary>Creates a deck character record from a DeckCharacter slot proto and returns the new UUID.</summary>
private static string CreateDeckCharacter(DarkUserMemoryDatabase db, long userId, DeckCharacter? slot)
{
if (slot is null || string.IsNullOrEmpty(slot.UserCostumeUuid))
{
return "";
}
string newUuid = Guid.NewGuid().ToString();
db.EntityIUserDeckCharacter.Add(new EntityIUserDeckCharacter
{
UserId = userId,
UserDeckCharacterUuid = newUuid,
UserCostumeUuid = slot.UserCostumeUuid,
MainUserWeaponUuid = slot.MainUserWeaponUuid,
UserCompanionUuid = slot.UserCompanionUuid,
UserThoughtUuid = slot.UserThoughtUuid,
Power = 0
});
if (slot.DressupCostumeId != 0)
{
db.EntityIUserDeckCharacterDressupCostume.Add(new EntityIUserDeckCharacterDressupCostume
{
UserId = userId,
UserDeckCharacterUuid = newUuid,
DressupCostumeId = slot.DressupCostumeId
});
}
for (int i = 0; i < slot.UserPartsUuid.Count; i++)
{
if (string.IsNullOrEmpty(slot.UserPartsUuid[i])) { continue; }
db.EntityIUserDeckPartsGroup.Add(new EntityIUserDeckPartsGroup
{
UserId = userId,
UserDeckCharacterUuid = newUuid,
UserPartsUuid = slot.UserPartsUuid[i],
SortOrder = i + 1
});
}
for (int i = 0; i < slot.SubUserWeaponUuid.Count; i++)
{
if (string.IsNullOrEmpty(slot.SubUserWeaponUuid[i])) { continue; }
db.EntityIUserDeckSubWeaponGroup.Add(new EntityIUserDeckSubWeaponGroup
{
UserId = userId,
UserDeckCharacterUuid = newUuid,
UserWeaponUuid = slot.SubUserWeaponUuid[i],
SortOrder = i + 1
});
}
return newUuid;
}
/// <summary>Stub for PvP defense deck; returns empty response.</summary>
public override Task<SetPvpDefenseDeckResponse> SetPvpDefenseDeck(SetPvpDefenseDeckRequest request, ServerCallContext context)
{
return Task.FromResult(new SetPvpDefenseDeckResponse());
}
/// <summary>Stub for deck copy; returns empty response.</summary>
public override Task<CopyDeckResponse> CopyDeck(CopyDeckRequest request, ServerCallContext context)
{
return Task.FromResult(new CopyDeckResponse());
}
/// <summary>Stub for deck removal; returns empty response.</summary>
public override Task<RemoveDeckResponse> RemoveDeck(RemoveDeckRequest request, ServerCallContext context)
{
return Task.FromResult(new RemoveDeckResponse());
}
/// <summary>Updates deck and character power values from the client.</summary>
public override Task<RefreshDeckPowerResponse> RefreshDeckPower(RefreshDeckPowerRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
if (request.DeckPower != null)
{
ApplyDeckPowerRefresh(userDb, userId, (DeckType)request.DeckType, request.UserDeckNumber, request.DeckPower);
}
return Task.FromResult(new RefreshDeckPowerResponse());
}
/// <summary>Stub for triple deck name update; returns empty response.</summary>
public override Task<UpdateTripleDeckNameResponse> UpdateTripleDeckName(UpdateTripleDeckNameRequest request, ServerCallContext context)
{
return Task.FromResult(new UpdateTripleDeckNameResponse());
}
/// <summary>Replaces up to three decks' character lineups in one operation.</summary>
public override Task<ReplaceTripleDeckResponse> ReplaceTripleDeck(ReplaceTripleDeckRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
foreach (DeckDetail? detail in new[] { request.DeckDetail01, request.DeckDetail02, request.DeckDetail03 })
{
if (detail?.Deck == null)
{
continue;
}
ApplyDeckReplacement(userDb, userId, (DeckType)detail.DeckType, detail.UserDeckNumber, detail.Deck);
}
return Task.FromResult(new ReplaceTripleDeckResponse());
}
/// <summary>Replaces multiple decks' character lineups in one operation.</summary>
public override Task<ReplaceMultiDeckResponse> ReplaceMultiDeck(ReplaceMultiDeckRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
foreach (DeckDetail detail in request.DeckDetail)
{
if (detail?.Deck == null)
{
continue;
}
ApplyDeckReplacement(userDb, userId, (DeckType)detail.DeckType, detail.UserDeckNumber, detail.Deck);
}
return Task.FromResult(new ReplaceMultiDeckResponse());
}
/// <summary>Updates power values for multiple decks in one operation.</summary>
public override Task<RefreshMultiDeckPowerResponse> RefreshMultiDeckPower(RefreshMultiDeckPowerRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
foreach (DeckPowerInfo info in request.DeckPowerInfo)
{
if (info.DeckPower == null)
{
continue;
}
ApplyDeckPowerRefresh(userDb, userId, (DeckType)info.DeckType, info.UserDeckNumber, info.DeckPower);
}
return Task.FromResult(new RefreshMultiDeckPowerResponse());
}
/// <summary>Removes old deck characters and creates new ones from the provided deck proto.</summary>
private static void ApplyDeckReplacement(DarkUserMemoryDatabase userDb, long userId, DeckType deckType, int deckNumber, Deck deck)
{
EntityIUserDeck? existing = userDb.EntityIUserDeck
.FirstOrDefault(d => d.DeckType == deckType && d.UserDeckNumber == deckNumber);
if (existing != null)
{
HashSet<string> oldUuids = [];
if (!string.IsNullOrEmpty(existing.UserDeckCharacterUuid01)) { oldUuids.Add(existing.UserDeckCharacterUuid01); }
if (!string.IsNullOrEmpty(existing.UserDeckCharacterUuid02)) { oldUuids.Add(existing.UserDeckCharacterUuid02); }
if (!string.IsNullOrEmpty(existing.UserDeckCharacterUuid03)) { oldUuids.Add(existing.UserDeckCharacterUuid03); }
userDb.EntityIUserDeckCharacter.RemoveAll(dc => oldUuids.Contains(dc.UserDeckCharacterUuid));
userDb.EntityIUserDeckCharacterDressupCostume.RemoveAll(dc => oldUuids.Contains(dc.UserDeckCharacterUuid));
userDb.EntityIUserDeckPartsGroup.RemoveAll(pg => oldUuids.Contains(pg.UserDeckCharacterUuid));
userDb.EntityIUserDeckSubWeaponGroup.RemoveAll(swg => oldUuids.Contains(swg.UserDeckCharacterUuid));
}
string uuid01 = CreateDeckCharacter(userDb, userId, deck.Character01);
string uuid02 = CreateDeckCharacter(userDb, userId, deck.Character02);
string uuid03 = CreateDeckCharacter(userDb, userId, deck.Character03);
if (existing == null)
{
existing = new EntityIUserDeck
{
UserId = userId,
DeckType = deckType,
UserDeckNumber = deckNumber,
Name = $"Loadout {deckNumber}",
Power = 0
};
userDb.EntityIUserDeck.Add(existing);
}
existing.UserDeckCharacterUuid01 = uuid01;
existing.UserDeckCharacterUuid02 = uuid02;
existing.UserDeckCharacterUuid03 = uuid03;
}
/// <summary>Updates deck and character power values and tracks max deck power per type.</summary>
private static void ApplyDeckPowerRefresh(DarkUserMemoryDatabase userDb, long userId, DeckType deckType, int deckNumber, DeckPower deckPower)
{
EntityIUserDeck? deck = userDb.EntityIUserDeck.FirstOrDefault(d =>
d.DeckType == deckType && d.UserDeckNumber == deckNumber);
if (deck != null)
{
deck.Power = deckPower.Power;
}
DeckCharacterPower?[] charPowers =
[
deckPower.DeckCharacterPower01,
deckPower.DeckCharacterPower02,
deckPower.DeckCharacterPower03,
];
foreach (DeckCharacterPower? cp in charPowers)
{
if (cp == null || string.IsNullOrEmpty(cp.UserDeckCharacterUuid))
{
continue;
}
EntityIUserDeckCharacter? dc = userDb.EntityIUserDeckCharacter.FirstOrDefault(c =>
c.UserDeckCharacterUuid == cp.UserDeckCharacterUuid);
if (dc != null)
{
dc.Power = cp.Power;
}
}
EntityIUserDeckTypeNote? note = userDb.EntityIUserDeckTypeNote.FirstOrDefault(n =>
n.DeckType == deckType);
if (note == null)
{
note = new EntityIUserDeckTypeNote { UserId = userId, DeckType = deckType };
userDb.EntityIUserDeckTypeNote.Add(note);
}
if (deckPower.Power > note.MaxDeckPower)
{
note.MaxDeckPower = deckPower.Power;
}
}
}

View File

@@ -0,0 +1,43 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.Dokan;
namespace MariesWonderland.Services;
public class DokanService(UserDataStore store) : MariesWonderland.Proto.Dokan.DokanService.DokanServiceBase
{
private readonly UserDataStore _store = store;
/// <summary>Marks one or more dokan story chapters as seen by the player, recording the display timestamp.</summary>
public override Task<RegisterDokanConfirmedResponse> RegisterDokanConfirmed(RegisterDokanConfirmedRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach (int dokanId in request.DokanId)
{
EntityIUserDokan? existing = userDb.EntityIUserDokan
.FirstOrDefault(d => d.DokanId == dokanId);
if (existing == null)
{
userDb.EntityIUserDokan.Add(new EntityIUserDokan
{
UserId = userId,
DokanId = dokanId,
DisplayDatetime = nowMs
});
}
else
{
existing.DisplayDatetime = nowMs;
}
}
return Task.FromResult(new RegisterDokanConfirmedResponse());
}
}

View File

@@ -0,0 +1,204 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.Explore;
namespace MariesWonderland.Services;
public class ExploreService(UserDataStore store, DarkMasterMemoryDatabase masterDb)
: MariesWonderland.Proto.Explore.ExploreService.ExploreServiceBase
{
private const int StaminaRecovery = 1000;
private const int RewardMaterialId = 100001;
private const int RewardBaseCount = 1;
private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
/// <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);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityMExplore? explore = _masterDb.EntityMExplore
.FirstOrDefault(e => e.ExploreId == request.ExploreId);
if (explore is null)
{
return Task.FromResult(new StartExploreResponse());
}
// Deduct consumable ticket if required
if (request.UseConsumableItemId > 0 && explore.ConsumeItemCount > 0)
{
EntityIUserConsumableItem? item = userDb.EntityIUserConsumableItem
.FirstOrDefault(i => i.ConsumableItemId == request.UseConsumableItemId);
if (item is not null)
{
item.Count -= explore.ConsumeItemCount;
}
}
// Record or update the active expedition state
EntityIUserExplore? userExplore = userDb.EntityIUserExplore
.FirstOrDefault(e => e.UserId == userId);
if (userExplore is null)
{
userDb.EntityIUserExplore.Add(new EntityIUserExplore
{
UserId = userId,
PlayingExploreId = request.ExploreId,
IsUseExploreTicket = false,
LatestPlayDatetime = nowMs
});
}
else
{
userExplore.PlayingExploreId = request.ExploreId;
userExplore.IsUseExploreTicket = false;
userExplore.LatestPlayDatetime = nowMs;
}
return Task.FromResult(new StartExploreResponse());
}
/// <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);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityMExplore? explore = _masterDb.EntityMExplore
.FirstOrDefault(e => e.ExploreId == request.ExploreId);
if (explore is null)
{
return Task.FromResult(new FinishExploreResponse());
}
int rewardCount = RewardBaseCount * explore.RewardLotteryCount;
// Update or create score
EntityIUserExploreScore? score = userDb.EntityIUserExploreScore
.FirstOrDefault(s => s.ExploreId == request.ExploreId);
if (score is null)
{
userDb.EntityIUserExploreScore.Add(new EntityIUserExploreScore
{
UserId = userId,
ExploreId = request.ExploreId,
MaxScore = request.Score,
MaxScoreUpdateDatetime = nowMs
});
}
else if (request.Score > score.MaxScore)
{
score.MaxScore = request.Score;
score.MaxScoreUpdateDatetime = nowMs;
}
// 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);
if (status is not null)
{
status.StaminaMilliValue += StaminaRecovery;
status.StaminaUpdateDatetime = nowMs;
}
// Grant material reward
EntityIUserMaterial? material = userDb.EntityIUserMaterial
.FirstOrDefault(m => m.MaterialId == RewardMaterialId);
if (material is not null)
{
material.Count += rewardCount;
}
else
{
userDb.EntityIUserMaterial.Add(new EntityIUserMaterial
{
UserId = userId,
MaterialId = RewardMaterialId,
Count = rewardCount,
FirstAcquisitionDatetime = nowMs
});
}
// Determine grade icon
int assetGradeIconId = GradeForScore(request.ExploreId, request.Score);
FinishExploreResponse response = new()
{
AcquireStaminaCount = StaminaRecovery,
AssetGradeIconId = assetGradeIconId
};
response.ExploreReward.Add(new ExploreReward
{
PossessionType = (int)Models.Type.PossessionType.MATERIAL,
PossessionId = RewardMaterialId,
Count = rewardCount
});
return Task.FromResult(response);
}
/// <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);
EntityIUserExplore? userExplore = userDb.EntityIUserExplore
.FirstOrDefault(e => e.UserId == userId);
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)
{
// Grade scores sorted descending by NecessaryScore; first match where score >= threshold wins
List<EntityMExploreGradeScore> gradeScores = [.. _masterDb.EntityMExploreGradeScore
.Where(gs => gs.ExploreId == exploreId)
.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 0;
}
}

View File

@@ -0,0 +1,80 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Proto.Friend;
namespace MariesWonderland.Services;
public class FriendService : MariesWonderland.Proto.Friend.FriendService.FriendServiceBase
{
/// <summary>Returns an empty response. Friend lookup not yet implemented.</summary>
public override Task<GetUserResponse> GetUser(GetUserRequest request, ServerCallContext context)
{
return Task.FromResult(new GetUserResponse());
}
/// <summary>Returns an empty response. Friend recommendations not yet implemented.</summary>
public override Task<SearchRecommendedUsersResponse> SearchRecommendedUsers(Empty request, ServerCallContext context)
{
return Task.FromResult(new SearchRecommendedUsersResponse());
}
/// <summary>Returns an empty response. Friend list not yet implemented.</summary>
public override Task<GetFriendListResponse> GetFriendList(GetFriendListRequest request, ServerCallContext context)
{
return Task.FromResult(new GetFriendListResponse());
}
/// <summary>Returns an empty response. Friend requests not yet implemented.</summary>
public override Task<GetFriendRequestListResponse> GetFriendRequestList(Empty request, ServerCallContext context)
{
return Task.FromResult(new GetFriendRequestListResponse());
}
/// <summary>Returns an empty response. Sending friend requests not yet implemented.</summary>
public override Task<SendFriendRequestResponse> SendFriendRequest(SendFriendRequestRequest request, ServerCallContext context)
{
return Task.FromResult(new SendFriendRequestResponse());
}
/// <summary>Returns an empty response. Accepting friend requests not yet implemented.</summary>
public override Task<AcceptFriendRequestResponse> AcceptFriendRequest(AcceptFriendRequestRequest request, ServerCallContext context)
{
return Task.FromResult(new AcceptFriendRequestResponse());
}
/// <summary>Returns an empty response. Declining friend requests not yet implemented.</summary>
public override Task<DeclineFriendRequestResponse> DeclineFriendRequest(DeclineFriendRequestRequest request, ServerCallContext context)
{
return Task.FromResult(new DeclineFriendRequestResponse());
}
/// <summary>Returns an empty response. Friend removal not yet implemented.</summary>
public override Task<DeleteFriendResponse> DeleteFriend(DeleteFriendRequest request, ServerCallContext context)
{
return Task.FromResult(new DeleteFriendResponse());
}
/// <summary>Returns an empty response. Cheering friends not yet implemented.</summary>
public override Task<CheerFriendResponse> CheerFriend(CheerFriendRequest request, ServerCallContext context)
{
return Task.FromResult(new CheerFriendResponse());
}
/// <summary>Returns an empty response. Bulk cheering not yet implemented.</summary>
public override Task<BulkCheerFriendResponse> BulkCheerFriend(Empty request, ServerCallContext context)
{
return Task.FromResult(new BulkCheerFriendResponse());
}
/// <summary>Returns an empty response. Receiving cheers not yet implemented.</summary>
public override Task<ReceiveCheerResponse> ReceiveCheer(ReceiveCheerRequest request, ServerCallContext context)
{
return Task.FromResult(new ReceiveCheerResponse());
}
/// <summary>Returns an empty response. Bulk receiving cheers not yet implemented.</summary>
public override Task<BulkReceiveCheerResponse> BulkReceiveCheer(Empty request, ServerCallContext context)
{
return Task.FromResult(new BulkReceiveCheerResponse());
}
}

1332
src/Services/GachaService.cs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
using Grpc.Core;
using MariesWonderland.Proto.GamePlay;
namespace MariesWonderland.Services;
public class GameplayService : MariesWonderland.Proto.GamePlay.GameplayService.GameplayServiceBase
{
/// <summary>Performs pre-battle validation. Returns no popups and an empty gacha badge list.</summary>
public override Task<CheckBeforeGamePlayResponse> CheckBeforeGamePlay(CheckBeforeGamePlayRequest request, ServerCallContext context)
{
return Task.FromResult(new CheckBeforeGamePlayResponse
{
IsExistUnreadPop = false
});
}
}

140
src/Services/GiftService.cs Normal file
View File

@@ -0,0 +1,140 @@
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.Gift;
namespace MariesWonderland.Services;
public class GiftService(UserDataStore store) : MariesWonderland.Proto.Gift.GiftService.GiftServiceBase
{
private readonly UserDataStore _store = store;
/// <summary>Claims inbox gifts by UUID, marking them as received with the current timestamp.</summary>
public override Task<ReceiveGiftResponse> ReceiveGift(ReceiveGiftRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
List<string> received = [];
foreach (EntitySUserGift gift in userDb.EntitySUserGift)
{
if (gift.UserId == userId
&& gift.ReceivedDatetime == 0
&& request.UserGiftUuid.Contains(gift.UserGiftUuid))
{
gift.ReceivedDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
received.Add(gift.UserGiftUuid);
}
}
ReceiveGiftResponse response = new();
response.ReceivedGiftUuid.AddRange(received);
return Task.FromResult(response);
}
/// <summary>Returns a paginated list of unclaimed gifts, sorted by expiration date.</summary>
public override Task<GetGiftListResponse> GetGiftList(GetGiftListRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
List<EntitySUserGift> unreceived = [.. userDb.EntitySUserGift
.Where(g => g.ReceivedDatetime == 0)];
if (request.IsAscendingSort)
{
unreceived.Sort((a, b) => a.ExpirationDatetime.CompareTo(b.ExpirationDatetime));
}
else
{
unreceived.Sort((a, b) => b.ExpirationDatetime.CompareTo(a.ExpirationDatetime));
}
IEnumerable<EntitySUserGift> page = unreceived;
if (request.GetCount > 0)
{
page = page.Take(request.GetCount);
}
GetGiftListResponse response = new()
{
TotalPageCount = PageCount(unreceived.Count, request.GetCount),
NextCursor = 0,
PreviousCursor = 0
};
foreach (EntitySUserGift gift in page)
{
NotReceivedGift item = new()
{
GiftCommon = ToProtoGiftCommon(gift),
UserGiftUuid = gift.UserGiftUuid
};
if (gift.ExpirationDatetime > 0)
{
item.ExpirationDatetime = Timestamp.FromDateTimeOffset(
DateTimeOffset.FromUnixTimeMilliseconds(gift.ExpirationDatetime));
}
response.Gift.Add(item);
}
return Task.FromResult(response);
}
/// <summary>Returns the history of previously claimed gifts.</summary>
public override Task<GetGiftReceiveHistoryListResponse> GetGiftReceiveHistoryList(Empty request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
GetGiftReceiveHistoryListResponse response = new();
foreach (EntitySUserGift gift in userDb.EntitySUserGift.Where(g => g.ReceivedDatetime != 0))
{
response.Gift.Add(new ReceivedGift
{
GiftCommon = ToProtoGiftCommon(gift),
ReceivedDatetime = Timestamp.FromDateTimeOffset(
DateTimeOffset.FromUnixTimeMilliseconds(gift.ReceivedDatetime))
});
}
return Task.FromResult(response);
}
/// <summary>Converts a server-side gift entity to the proto GiftCommon message.</summary>
private static GiftCommon ToProtoGiftCommon(EntitySUserGift gift)
{
GiftCommon common = new()
{
PossessionType = gift.PossessionType,
PossessionId = gift.PossessionId,
Count = gift.Count,
DescriptionGiftTextId = gift.DescriptionGiftTextId,
EquipmentData = gift.EquipmentData.Length > 0
? ByteString.CopyFrom(gift.EquipmentData)
: ByteString.Empty
};
if (gift.GrantDatetime > 0)
{
common.GrantDatetime = Timestamp.FromDateTimeOffset(
DateTimeOffset.FromUnixTimeMilliseconds(gift.GrantDatetime));
}
return common;
}
/// <summary>Calculates the total number of pages given item count and page size.</summary>
private static int PageCount(int total, int pageSize)
{
if (total == 0) return 0;
if (pageSize <= 0) return 1;
int pages = total / pageSize;
if (total % pageSize != 0) pages++;
return pages;
}
}

View File

@@ -0,0 +1,198 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.Gimmick;
namespace MariesWonderland.Services;
public class GimmickService(UserDataStore store, DarkMasterMemoryDatabase masterDb)
: MariesWonderland.Proto.Gimmick.GimmickService.GimmickServiceBase
{
private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
/// <summary>Records that the user has advanced to a gimmick sequence, preventing duplicate entries.</summary>
public override Task<UpdateSequenceResponse> UpdateSequence(UpdateSequenceRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
if (!userDb.EntityIUserGimmickSequence.Any(s =>
s.GimmickSequenceScheduleId == request.GimmickSequenceScheduleId &&
s.GimmickSequenceId == request.GimmickSequenceId))
{
userDb.EntityIUserGimmickSequence.Add(new EntityIUserGimmickSequence
{
UserId = userId,
GimmickSequenceScheduleId = request.GimmickSequenceScheduleId,
GimmickSequenceId = request.GimmickSequenceId,
});
}
return Task.FromResult(new UpdateSequenceResponse());
}
/// <summary>Updates progress on a field gimmick and its ornament interaction state.</summary>
public override Task<UpdateGimmickProgressResponse> UpdateGimmickProgress(UpdateGimmickProgressRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityIUserGimmick? gimmick = userDb.EntityIUserGimmick.FirstOrDefault(g =>
g.GimmickSequenceScheduleId == request.GimmickSequenceScheduleId &&
g.GimmickSequenceId == request.GimmickSequenceId &&
g.GimmickId == request.GimmickId);
if (gimmick is null)
{
gimmick = new EntityIUserGimmick
{
UserId = userId,
GimmickSequenceScheduleId = request.GimmickSequenceScheduleId,
GimmickSequenceId = request.GimmickSequenceId,
GimmickId = request.GimmickId,
};
userDb.EntityIUserGimmick.Add(gimmick);
}
gimmick.StartDatetime = nowMs;
EntityIUserGimmickOrnamentProgress? ornament = userDb.EntityIUserGimmickOrnamentProgress.FirstOrDefault(o =>
o.GimmickSequenceScheduleId == request.GimmickSequenceScheduleId &&
o.GimmickSequenceId == request.GimmickSequenceId &&
o.GimmickId == request.GimmickId &&
o.GimmickOrnamentIndex == request.GimmickOrnamentIndex);
if (ornament is null)
{
ornament = new EntityIUserGimmickOrnamentProgress
{
UserId = userId,
GimmickSequenceScheduleId = request.GimmickSequenceScheduleId,
GimmickSequenceId = request.GimmickSequenceId,
GimmickId = request.GimmickId,
GimmickOrnamentIndex = request.GimmickOrnamentIndex,
};
userDb.EntityIUserGimmickOrnamentProgress.Add(ornament);
}
ornament.ProgressValueBit = request.ProgressValueBit;
ornament.BaseDatetime = nowMs;
return Task.FromResult(new UpdateGimmickProgressResponse { IsSequenceCleared = false });
}
/// <summary>Initializes gimmick sequence schedules whose time windows are active and quest prerequisites are met.</summary>
public override Task<InitSequenceScheduleResponse> InitSequenceSchedule(Empty request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
Dictionary<int, long> conditionToQuestId = BuildConditionQuestMap();
// Collect all cleared quest IDs for prerequisite checks
HashSet<int> clearedQuests = [];
foreach (EntityIUserQuest quest in userDb.EntityIUserQuest)
{
if (quest.QuestStateType == (int)QuestStateType.CLEARED)
{
clearedQuests.Add(quest.QuestId);
}
}
// Activate each schedule whose time window includes now and whose quest prerequisite is met
foreach (EntityMGimmickSequenceSchedule schedule in _masterDb.EntityMGimmickSequenceSchedule)
{
if (nowMs < schedule.StartDatetime || nowMs > schedule.EndDatetime)
{
continue;
}
if (schedule.ReleaseEvaluateConditionId != 0)
{
if (conditionToQuestId.TryGetValue(schedule.ReleaseEvaluateConditionId, out long requiredQuestId)
&& !clearedQuests.Contains((int)requiredQuestId))
{
continue;
}
}
bool exists = userDb.EntityIUserGimmickSequence.Any(s =>
s.GimmickSequenceScheduleId == schedule.GimmickSequenceScheduleId &&
s.GimmickSequenceId == schedule.FirstGimmickSequenceId);
if (!exists)
{
userDb.EntityIUserGimmickSequence.Add(new EntityIUserGimmickSequence
{
UserId = userId,
GimmickSequenceScheduleId = schedule.GimmickSequenceScheduleId,
GimmickSequenceId = schedule.FirstGimmickSequenceId,
});
}
}
return Task.FromResult(new InitSequenceScheduleResponse());
}
/// <summary>Marks one or more gimmicks as unlocked so the player can interact with them in the field.</summary>
public override Task<UnlockResponse> Unlock(UnlockRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
foreach (GimmickKey key in request.GimmickKey)
{
EntityIUserGimmickUnlock? unlock = userDb.EntityIUserGimmickUnlock.FirstOrDefault(u =>
u.GimmickSequenceScheduleId == key.GimmickSequenceScheduleId &&
u.GimmickSequenceId == key.GimmickSequenceId &&
u.GimmickId == key.GimmickId);
if (unlock is null)
{
unlock = new EntityIUserGimmickUnlock
{
UserId = userId,
GimmickSequenceScheduleId = key.GimmickSequenceScheduleId,
GimmickSequenceId = key.GimmickSequenceId,
GimmickId = key.GimmickId,
};
userDb.EntityIUserGimmickUnlock.Add(unlock);
}
unlock.IsUnlocked = true;
}
return Task.FromResult(new UnlockResponse());
}
/// <summary>Builds a mapping from EvaluateConditionId to required quest ID for QUEST_CLEAR conditions.</summary>
private Dictionary<int, long> BuildConditionQuestMap() {
Dictionary<(int GroupId, int GroupIndex), long> vgByKey = [];
foreach (EntityMEvaluateConditionValueGroup vg in _masterDb.EntityMEvaluateConditionValueGroup)
{
vgByKey[(vg.EvaluateConditionValueGroupId, vg.GroupIndex)] = vg.Value;
}
Dictionary<int, long> conditionToQuestId = [];
foreach (EntityMEvaluateCondition cond in _masterDb.EntityMEvaluateCondition)
{
if (cond.EvaluateConditionFunctionType != EvaluateConditionFunctionType.QUEST_CLEAR)
{
continue;
}
if (cond.EvaluateConditionEvaluateType != EvaluateConditionEvaluateType.ID_CONTAIN)
{
continue;
}
if (vgByKey.TryGetValue((cond.EvaluateConditionValueGroupId, 1), out long questId))
{
conditionToQuestId[cond.EvaluateConditionId] = questId;
}
}
return conditionToQuestId;
}
}

View File

@@ -0,0 +1,14 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Proto.IndividualPop;
namespace MariesWonderland.Services;
public class IndividualPopService : MariesWonderland.Proto.IndividualPop.IndividualpopService.IndividualpopServiceBase
{
/// <summary>Returns an empty response. Individual pop-up notifications not yet implemented.</summary>
public override Task<GetUnreadPopResponse> GetUnreadPop(Empty request, ServerCallContext context)
{
return Task.FromResult(new GetUnreadPopResponse());
}
}

View File

@@ -0,0 +1,25 @@
using Grpc.Core;
using MariesWonderland.Proto.Labyrinth;
namespace MariesWonderland.Services;
public class LabyrinthService : MariesWonderland.Proto.Labyrinth.LabyrinthService.LabyrinthServiceBase
{
/// <summary>Returns an empty response. Labyrinth season data not yet implemented.</summary>
public override Task<UpdateSeasonDataResponse> UpdateSeasonData(UpdateSeasonDataRequest request, ServerCallContext context)
{
return Task.FromResult(new UpdateSeasonDataResponse());
}
/// <summary>Returns an empty response. Labyrinth stage clear rewards not yet implemented.</summary>
public override Task<ReceiveStageClearRewardResponse> ReceiveStageClearReward(ReceiveStageClearRewardRequest request, ServerCallContext context)
{
return Task.FromResult(new ReceiveStageClearRewardResponse());
}
/// <summary>Returns an empty response. Labyrinth accumulation rewards not yet implemented.</summary>
public override Task<ReceiveStageAccumulationRewardResponse> ReceiveStageAccumulationReward(ReceiveStageAccumulationRewardRequest request, ServerCallContext context)
{
return Task.FromResult(new ReceiveStageAccumulationRewardResponse());
}
}

View File

@@ -0,0 +1,72 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.LoginBonus;
namespace MariesWonderland.Services;
public class LoginBonusService(UserDataStore store, DarkMasterMemoryDatabase masterDb)
: MariesWonderland.Proto.LoginBonus.LoginbonusService.LoginbonusServiceBase
{
private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
/// <summary>Claims the daily login stamp reward and delivers it to the player's gift inbox.</summary>
public override Task<ReceiveStampResponse> ReceiveStamp(Empty request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
// Get or create login bonus tracker, defaulting to page 1 / stamp 0
EntityIUserLoginBonus loginBonus = userDb.EntityIUserLoginBonus.FirstOrDefault(lb => lb.UserId == userId)
?? AddEntity(userDb.EntityIUserLoginBonus, new EntityIUserLoginBonus
{
UserId = userId,
LoginBonusId = 1,
CurrentPageNumber = 1,
CurrentStampNumber = 0
});
int nextStamp = loginBonus.CurrentStampNumber + 1;
// Look up the reward for the next stamp from master data
EntityMLoginBonusStamp? stamp = _masterDb.EntityMLoginBonusStamp.FirstOrDefault(s =>
s.LoginBonusId == loginBonus.LoginBonusId
&& s.LowerPageNumber == loginBonus.CurrentPageNumber
&& s.StampNumber == nextStamp);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long expiryMs = nowMs + 30L * 24 * 60 * 60 * 1000;
// Deliver the stamp reward as a gift with 30-day expiry
if (stamp is not null)
{
userDb.EntitySUserGift.Add(new EntitySUserGift
{
UserId = userId,
UserGiftUuid = $"login-bonus-{userId}-{nextStamp}",
PossessionType = (int)stamp.RewardPossessionType,
PossessionId = stamp.RewardPossessionId,
Count = stamp.RewardCount,
GrantDatetime = nowMs,
ExpirationDatetime = expiryMs,
ReceivedDatetime = 0
});
}
loginBonus.CurrentStampNumber = nextStamp;
loginBonus.LatestRewardReceiveDatetime = nowMs;
return Task.FromResult(new ReceiveStampResponse());
}
/// <summary>Adds an entity to a list and returns it, enabling inline initialization with null-coalescing.</summary>
private static T AddEntity<T>(List<T> list, T entity)
{
list.Add(entity);
return entity;
}
}

View File

@@ -0,0 +1,99 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.Material;
namespace MariesWonderland.Services;
public class MaterialService(DarkMasterMemoryDatabase masterDb, UserDataStore store, GameConfig gameConfig)
: MariesWonderland.Proto.Material.MaterialService.MaterialServiceBase
{
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
private readonly UserDataStore _store = store;
private readonly GameConfig _gameConfig = gameConfig;
/// <summary>Sells materials for gold, removing depleted material entries from inventory.</summary>
public override Task<SellResponse> Sell(SellRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
int totalGold = 0;
foreach (SellPossession item in request.MaterialPossession)
{
// Look up the material's sell price from master data
EntityMMaterial? mat = null;
foreach (EntityMMaterial m in _masterDb.EntityMMaterial)
{
if (m.MaterialId == item.MaterialId)
{
mat = m;
break;
}
}
if (mat == null)
{
continue;
}
EntityIUserMaterial? userMat = null;
foreach (EntityIUserMaterial um in userDb.EntityIUserMaterial)
{
if (um.MaterialId == item.MaterialId)
{
userMat = um;
break;
}
}
if (userMat == null || userMat.Count < item.Count)
{
continue;
}
userMat.Count -= item.Count;
// If count reaches zero, remove the entry
if (userMat.Count <= 0)
{
userDb.EntityIUserMaterial.Remove(userMat);
}
totalGold += mat.SellPrice * item.Count;
}
// Credit the total gold earned to the user's consumable inventory
if (totalGold > 0)
{
EntityIUserConsumableItem? gold = null;
foreach (EntityIUserConsumableItem ci in userDb.EntityIUserConsumableItem)
{
if (ci.ConsumableItemId == _gameConfig.ConsumableItemIdForGold)
{
gold = ci;
break;
}
}
if (gold != null)
{
gold.Count += totalGold;
}
else
{
userDb.EntityIUserConsumableItem.Add(new EntityIUserConsumableItem
{
UserId = userId,
ConsumableItemId = _gameConfig.ConsumableItemIdForGold,
Count = totalGold,
FirstAcquisitionDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
}
}
return Task.FromResult(new SellResponse());
}
}

View File

@@ -0,0 +1,39 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Proto.Mission;
namespace MariesWonderland.Services;
public class MissionService(UserDataStore store)
: MariesWonderland.Proto.Mission.MissionService.MissionServiceBase
{
private readonly UserDataStore _store = store;
/// <summary>Returns an empty response. Mission progress tracking not yet implemented.</summary>
public override Task<UpdateMissionProgressResponse> UpdateMissionProgress(UpdateMissionProgressRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
_ = _store.GetOrCreate(userId);
return Task.FromResult(new UpdateMissionProgressResponse());
}
/// <summary>Returns an empty response. Mission reward claiming not yet implemented.</summary>
public override Task<ReceiveMissionRewardsResponse> ReceiveMissionRewardsById(ReceiveMissionRewardsByIdRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
_ = _store.GetOrCreate(userId);
return Task.FromResult(new ReceiveMissionRewardsResponse());
}
/// <summary>Returns an empty response. Mission pass rewards not yet implemented.</summary>
public override Task<ReceiveMissionPassRewardsResponse> ReceiveMissionPassRewards(ReceiveMissionPassRewardsRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
_ = _store.GetOrCreate(userId);
return Task.FromResult(new ReceiveMissionPassRewardsResponse());
}
}

View File

@@ -0,0 +1,35 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.Movie;
namespace MariesWonderland.Services;
public class MovieService(UserDataStore store) : MariesWonderland.Proto.Movie.MovieService.MovieServiceBase
{
private readonly UserDataStore _store = store;
/// <summary>Records one or more cutscenes as viewed, so the client can track which have been watched.</summary>
public override Task<SaveViewedMovieResponse> SaveViewedMovie(SaveViewedMovieRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach (int movieId in request.MovieId)
{
EntityIUserMovie? existing = userDb.EntityIUserMovie.FirstOrDefault(m => m.MovieId == movieId);
if (existing != null)
{
existing.LatestViewedDatetime = nowMs;
}
else
{
userDb.EntityIUserMovie.Add(new EntityIUserMovie { UserId = userId, MovieId = movieId, LatestViewedDatetime = nowMs });
}
}
return Task.FromResult(new SaveViewedMovieResponse());
}
}

View File

@@ -0,0 +1,40 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.NaviCutIn;
namespace MariesWonderland.Services;
public class NaviCutInService(UserDataStore store)
: MariesWonderland.Proto.NaviCutIn.NavicutinService.NavicutinServiceBase
{
private readonly UserDataStore _store = store;
/// <summary>Records that a navi cut-in animation has been played, so the client won't show it again.</summary>
public override Task<RegisterPlayedResponse> RegisterPlayed(RegisterPlayedRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityIUserNaviCutIn? record = userDb.EntityIUserNaviCutIn
.FirstOrDefault(n => n.NaviCutInId == request.NaviCutId);
if (record is null)
{
userDb.EntityIUserNaviCutIn.Add(new EntityIUserNaviCutIn
{
UserId = userId,
NaviCutInId = request.NaviCutId,
PlayDatetime = nowMs
});
}
else
{
record.PlayDatetime = nowMs;
}
return Task.FromResult(new RegisterPlayedResponse());
}
}

View File

@@ -0,0 +1,28 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Proto.Notification;
namespace MariesWonderland.Services;
public class NotificationService(UserDataStore store) : MariesWonderland.Proto.Notification.NotificationService.NotificationServiceBase
{
private readonly UserDataStore _store = store;
/// <summary>Returns notification badge counts for the header bar (unclaimed gifts, friend requests, unread info).</summary>
public override Task<GetHeaderNotificationResponse> GetHeaderNotification(Empty request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
int giftNotReceiveCount = userDb.EntitySUserGift.Count(g => g.ReceivedDatetime == 0);
return Task.FromResult(new GetHeaderNotificationResponse
{
GiftNotReceiveCount = giftNotReceiveCount,
FriendRequestReceiveCount = 0,
IsExistUnreadInformation = false
});
}
}

View File

@@ -0,0 +1,37 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.Omikuji;
namespace MariesWonderland.Services;
public class OmikujiService(UserDataStore store, DarkMasterMemoryDatabase masterDb) : MariesWonderland.Proto.Omikuji.OmikujiService.OmikujiServiceBase
{
private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
/// <summary>Draws a fortune (omikuji), records the draw timestamp, and returns the result asset ID.</summary>
public override Task<OmikujiDrawResponse> OmikujiDraw(OmikujiDrawRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
int omikujiId = request.OmikujiId;
EntityIUserOmikuji? existing = userDb.EntityIUserOmikuji.FirstOrDefault(o => o.OmikujiId == omikujiId);
if (existing != null)
{
existing.LatestDrawDatetime = nowMs;
}
else
{
userDb.EntityIUserOmikuji.Add(new EntityIUserOmikuji { UserId = userId, OmikujiId = omikujiId, LatestDrawDatetime = nowMs });
}
EntityMOmikuji? masterOmikuji = _masterDb.EntityMOmikuji.FirstOrDefault(o => o.OmikujiId == omikujiId);
OmikujiDrawResponse response = new() { OmikujiResultAssetId = masterOmikuji?.OmikujiAssetId ?? 0 };
return Task.FromResult(response);
}
}

View File

@@ -0,0 +1,571 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.Parts;
namespace MariesWonderland.Services;
public class PartsService(DarkMasterMemoryDatabase masterDb, UserDataStore store, GameConfig gameConfig)
: MariesWonderland.Proto.Parts.PartsService.PartsServiceBase
{
private const int PartsMaxLevel = 15;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
private readonly UserDataStore _store = store;
private readonly GameConfig _gameConfig = gameConfig;
/// <summary>
/// Sells one or more parts from the player's inventory, awarding gold based on each part's rarity and level.
/// Protected parts are silently skipped.
/// </summary>
public override Task<SellResponse> Sell(SellRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
int totalGold = 0;
foreach (string uuid in request.UserPartsUuid)
{
EntityIUserParts? part = null;
foreach (EntityIUserParts p in userDb.EntityIUserParts)
{
if (p.UserPartsUuid == uuid)
{
part = p;
break;
}
}
// Skip unknown or protected parts
if (part == null || part.IsProtected)
{
continue;
}
EntityMParts? partDef = null;
foreach (EntityMParts pd in _masterDb.EntityMParts)
{
if (pd.PartsId == part.PartsId)
{
partDef = pd;
break;
}
}
if (partDef == null)
{
continue;
}
// Look up sell price based on rarity
EntityMPartsRarity? rarityRow = null;
foreach (EntityMPartsRarity r in _masterDb.EntityMPartsRarity)
{
if (r.RarityType == partDef.RarityType)
{
rarityRow = r;
break;
}
}
if (rarityRow != null)
{
int gold = EvaluateNumericalFunction(rarityRow.SellPriceNumericalFunctionId, part.Level);
totalGold += gold;
}
userDb.EntityIUserParts.Remove(part);
}
// Award total gold earned from the sale
if (totalGold > 0)
{
AddGold(userDb, userId, totalGold);
}
return Task.FromResult(new SellResponse());
}
/// <summary>
/// Marks the specified parts as protected, preventing them from being accidentally sold.
/// </summary>
public override Task<ProtectResponse> Protect(ProtectRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
foreach (string uuid in request.UserPartsUuid)
{
foreach (EntityIUserParts p in userDb.EntityIUserParts)
{
if (p.UserPartsUuid == uuid)
{
p.IsProtected = true;
break;
}
}
}
return Task.FromResult(new ProtectResponse());
}
/// <summary>
/// Removes the protection flag from the specified parts, allowing them to be sold.
/// </summary>
public override Task<UnprotectResponse> Unprotect(UnprotectRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
foreach (string uuid in request.UserPartsUuid)
{
foreach (EntityIUserParts p in userDb.EntityIUserParts)
{
if (p.UserPartsUuid == uuid)
{
p.IsProtected = false;
break;
}
}
}
return Task.FromResult(new UnprotectResponse());
}
/// <summary>
/// Attempts to enhance a part by one level, deducting gold and performing a probability-based success roll.
/// Returns whether the level-up succeeded. Enhancement fails immediately if the part is at max level or gold is insufficient.
/// </summary>
public override Task<EnhanceResponse> Enhance(EnhanceRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserParts? part = null;
foreach (EntityIUserParts p in userDb.EntityIUserParts)
{
if (p.UserPartsUuid == request.UserPartsUuid)
{
part = p;
break;
}
}
// Reject if part not found or already at max level
if (part == null || part.Level >= PartsMaxLevel)
{
return Task.FromResult(new EnhanceResponse { IsSuccess = false });
}
EntityMParts? partDef = null;
foreach (EntityMParts pd in _masterDb.EntityMParts)
{
if (pd.PartsId == part.PartsId)
{
partDef = pd;
break;
}
}
if (partDef == null)
{
return Task.FromResult(new EnhanceResponse { IsSuccess = false });
}
EntityMPartsRarity? rarityRow = null;
foreach (EntityMPartsRarity r in _masterDb.EntityMPartsRarity)
{
if (r.RarityType == partDef.RarityType)
{
rarityRow = r;
break;
}
}
if (rarityRow == null)
{
return Task.FromResult(new EnhanceResponse { IsSuccess = false });
}
// Look up gold cost from price group
int goldCost = LookupPartsLevelUpPrice(rarityRow.PartsLevelUpPriceGroupId, part.Level);
EntityIUserConsumableItem? gold = FindConsumableItem(userDb, _gameConfig.ConsumableItemIdForGold);
if (gold == null || gold.Count < goldCost)
{
return Task.FromResult(new EnhanceResponse { IsSuccess = false });
}
// Deduct gold before rolling — cost applies even on failure
gold.Count -= goldCost;
// Look up success rate
int successRate = LookupPartsLevelUpRate(rarityRow.PartsLevelUpRateGroupId, part.Level);
// Roll for enhancement success (permil)
bool isSuccess = Random.Shared.Next(1000) < successRate;
if (isSuccess)
{
part.Level++;
}
return Task.FromResult(new EnhanceResponse { IsSuccess = isSuccess });
}
/// <summary>
/// Sets the display name of a parts preset if it exists.
/// </summary>
public override Task<UpdatePresetNameResponse> UpdatePresetName(UpdatePresetNameRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserPartsPreset? preset = null;
foreach (EntityIUserPartsPreset p in userDb.EntityIUserPartsPreset)
{
if (p.UserPartsPresetNumber == request.UserPartsPresetNumber)
{
preset = p;
break;
}
}
if (preset != null)
{
preset.Name = request.Name;
}
return Task.FromResult(new UpdatePresetNameResponse());
}
/// <summary>
/// Assigns a tag number to a parts preset if it exists.
/// </summary>
public override Task<UpdatePresetTagNumberResponse> UpdatePresetTagNumber(UpdatePresetTagNumberRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserPartsPreset? preset = null;
foreach (EntityIUserPartsPreset p in userDb.EntityIUserPartsPreset)
{
if (p.UserPartsPresetNumber == request.UserPartsPresetNumber)
{
preset = p;
break;
}
}
if (preset != null)
{
preset.UserPartsPresetTagNumber = request.UserPartsPresetTagNumber;
}
return Task.FromResult(new UpdatePresetTagNumberResponse());
}
/// <summary>
/// Sets the display name of a preset tag if it exists.
/// </summary>
public override Task<UpdatePresetTagNameResponse> UpdatePresetTagName(UpdatePresetTagNameRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserPartsPresetTag? tag = null;
foreach (EntityIUserPartsPresetTag t in userDb.EntityIUserPartsPresetTag)
{
if (t.UserPartsPresetTagNumber == request.UserPartsPresetTagNumber)
{
tag = t;
break;
}
}
if (tag != null)
{
tag.Name = request.Name;
}
return Task.FromResult(new UpdatePresetTagNameResponse());
}
/// <summary>
/// Overwrites the three parts slots of a preset with the specified part UUIDs, creating the preset if needed.
/// </summary>
public override Task<ReplacePresetResponse> ReplacePreset(ReplacePresetRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserPartsPreset? preset = null;
foreach (EntityIUserPartsPreset p in userDb.EntityIUserPartsPreset)
{
if (p.UserPartsPresetNumber == request.UserPartsPresetNumber)
{
preset = p;
break;
}
}
if (preset != null)
{
preset.UserPartsUuid01 = request.UserPartsUuid01;
preset.UserPartsUuid02 = request.UserPartsUuid02;
preset.UserPartsUuid03 = request.UserPartsUuid03;
}
else
{
userDb.EntityIUserPartsPreset.Add(new EntityIUserPartsPreset
{
UserId = userId,
UserPartsPresetNumber = request.UserPartsPresetNumber,
UserPartsUuid01 = request.UserPartsUuid01,
UserPartsUuid02 = request.UserPartsUuid02,
UserPartsUuid03 = request.UserPartsUuid03,
Name = string.Empty
});
}
return Task.FromResult(new ReplacePresetResponse());
}
/// <summary>
/// Copies the three parts slots from one preset to another, creating the destination preset if it does not yet exist.
/// The source preset must exist; if it doesn't, no changes are made.
/// </summary>
public override Task<CopyPresetResponse> CopyPreset(CopyPresetRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
// Find both source and destination presets in a single pass
EntityIUserPartsPreset? from = null;
EntityIUserPartsPreset? to = null;
foreach (EntityIUserPartsPreset p in userDb.EntityIUserPartsPreset)
{
if (p.UserPartsPresetNumber == request.FromUserPartsPresetNumber)
{
from = p;
}
if (p.UserPartsPresetNumber == request.ToUserPartsPresetNumber)
{
to = p;
}
}
if (from != null)
{
if (to != null)
{
to.UserPartsUuid01 = from.UserPartsUuid01;
to.UserPartsUuid02 = from.UserPartsUuid02;
to.UserPartsUuid03 = from.UserPartsUuid03;
}
else
{
userDb.EntityIUserPartsPreset.Add(new EntityIUserPartsPreset
{
UserId = userId,
UserPartsPresetNumber = request.ToUserPartsPresetNumber,
UserPartsUuid01 = from.UserPartsUuid01,
UserPartsUuid02 = from.UserPartsUuid02,
UserPartsUuid03 = from.UserPartsUuid03,
Name = string.Empty
});
}
}
return Task.FromResult(new CopyPresetResponse());
}
/// <summary>
/// Clears all three parts slots in a preset, emptying the loadout without deleting the preset record.
/// </summary>
public override Task<RemovePresetResponse> RemovePreset(RemovePresetRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserPartsPreset? preset = null;
foreach (EntityIUserPartsPreset p in userDb.EntityIUserPartsPreset)
{
if (p.UserPartsPresetNumber == request.UserPartsPresetNumber)
{
preset = p;
break;
}
}
if (preset != null)
{
preset.UserPartsUuid01 = string.Empty;
preset.UserPartsUuid02 = string.Empty;
preset.UserPartsUuid03 = string.Empty;
}
return Task.FromResult(new RemovePresetResponse());
}
/// <summary>
/// Returns the gold cost to enhance a part at the given level, using the rarity's price group.
/// Selects the row with the highest <c>LevelLowerLimit</c> that does not exceed the current level.
/// </summary>
private int LookupPartsLevelUpPrice(int priceGroupId, int level)
{
int price = 0;
foreach (EntityMPartsLevelUpPriceGroup row in _masterDb.EntityMPartsLevelUpPriceGroup)
{
if (row.PartsLevelUpPriceGroupId == priceGroupId && row.LevelLowerLimit <= level)
{
if (row.LevelLowerLimit >= (price > 0 ? price : 0))
{
price = row.Gold;
}
}
}
return price;
}
/// <summary>
/// Returns the enhancement success rate (permil) for a part at the given level, using the rarity's rate group.
/// Defaults to 1000 (100%) when no matching row is found.
/// </summary>
private int LookupPartsLevelUpRate(int rateGroupId, int level)
{
int rate = 1000; // Default 100% success
int bestLowerLimit = -1;
foreach (EntityMPartsLevelUpRateGroup row in _masterDb.EntityMPartsLevelUpRateGroup)
{
if (row.PartsLevelUpRateGroupId == rateGroupId && row.LevelLowerLimit <= level && row.LevelLowerLimit > bestLowerLimit)
{
bestLowerLimit = row.LevelLowerLimit;
rate = row.SuccessRatePermil;
}
}
return rate;
}
/// <summary>
/// Credits gold to the user's consumable inventory, creating the inventory entry if one does not yet exist.
/// </summary>
private void AddGold(DarkUserMemoryDatabase userDb, long userId, int amount)
{
EntityIUserConsumableItem? gold = null;
foreach (EntityIUserConsumableItem ci in userDb.EntityIUserConsumableItem)
{
if (ci.ConsumableItemId == _gameConfig.ConsumableItemIdForGold)
{
gold = ci;
break;
}
}
if (gold != null)
{
gold.Count += amount;
}
else
{
userDb.EntityIUserConsumableItem.Add(new EntityIUserConsumableItem
{
UserId = userId,
ConsumableItemId = _gameConfig.ConsumableItemIdForGold,
Count = amount,
FirstAcquisitionDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
}
}
/// <summary>
/// Returns the user's consumable item record for the given item ID, or <see langword="null"/> if not found.
/// </summary>
private static EntityIUserConsumableItem? FindConsumableItem(DarkUserMemoryDatabase userDb, int itemId)
{
foreach (EntityIUserConsumableItem ci in userDb.EntityIUserConsumableItem)
{
if (ci.ConsumableItemId == itemId)
{
return ci;
}
}
return null;
}
/// <summary>
/// Evaluates a master data numerical function against an input value (e.g., computes sell price for a given part level).
/// Returns 0 if the function definition is not found or the formula type is unrecognised.
/// </summary>
private int EvaluateNumericalFunction(int functionId, int value)
{
EntityMNumericalFunction? func = null;
foreach (EntityMNumericalFunction f in _masterDb.EntityMNumericalFunction)
{
if (f.NumericalFunctionId == functionId)
{
func = f;
break;
}
}
if (func == null)
{
return 0;
}
// Collect and sort parameters by index
List<(int Index, int Value)> paramEntries = [];
foreach (EntityMNumericalFunctionParameterGroup pg in _masterDb.EntityMNumericalFunctionParameterGroup)
{
if (pg.NumericalFunctionParameterGroupId == func.NumericalFunctionParameterGroupId)
{
paramEntries.Add((pg.ParameterIndex, pg.ParameterValue));
}
}
paramEntries.Sort((a, b) => a.Index.CompareTo(b.Index));
int[] p = new int[paramEntries.Count];
for (int i = 0; i < paramEntries.Count; i++)
{
p[i] = paramEntries[i].Value;
}
// Dispatch to the appropriate formula type
return func.NumericalFunctionType switch
{
NumericalFunctionType.LINEAR when p.Length >= 2 => p[1] + p[0] * value,
NumericalFunctionType.MONOMIAL when p.Length >= 2 => EvaluateMonomial(p, value),
NumericalFunctionType.LINEAR_PERMIL when p.Length >= 2 => p[0] * value / 1000 + p[1],
NumericalFunctionType.POLYNOMIAL_THIRD when p.Length >= 4 => p[3] + (p[2] + (p[1] + p[0] * value) * value) * value,
NumericalFunctionType.POLYNOMIAL_THIRD_PERMIL when p.Length >= 4 =>
p[0] * value * value * value / 1000 +
p[1] * value * value / 1000 +
p[2] * value / 1000 +
p[3],
_ => 0
};
}
/// <summary>
/// Computes <c>p[0] * (value - 1)^p[1]</c> using iterative multiplication.
/// </summary>
private static int EvaluateMonomial(int[] p, int value)
{
int v = value - 1;
int result = v;
int counter = p[1];
if (counter > 1)
{
counter--;
while (counter > 0)
{
counter--;
result *= v;
}
}
return result * p[0];
}
}

View File

@@ -0,0 +1,43 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.PortalCage;
namespace MariesWonderland.Services;
public class PortalCageService(UserDataStore store)
: MariesWonderland.Proto.PortalCage.PortalcageService.PortalcageServiceBase
{
private readonly UserDataStore _store = store;
/// <summary>Marks the portal cage scene as in-progress for the current user.</summary>
public override Task<UpdatePortalCageSceneProgressResponse> UpdatePortalCageSceneProgress(UpdatePortalCageSceneProgressRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserPortalCageStatus? status = userDb.EntityIUserPortalCageStatus
.FirstOrDefault(s => s.UserId == userId);
if (status is null)
{
status = new EntityIUserPortalCageStatus { UserId = userId };
userDb.EntityIUserPortalCageStatus.Add(status);
}
status.IsCurrentProgress = true;
return Task.FromResult(new UpdatePortalCageSceneProgressResponse());
}
/// <summary>Returns an empty response. Portal cage drop items not yet implemented.</summary>
public override Task<GetDropItemResponse> GetDropItem(Empty request, ServerCallContext context)
{
long userId = context.GetUserId();
_ = _store.GetOrCreate(userId);
return Task.FromResult(new GetDropItemResponse());
}
}

View File

@@ -0,0 +1,62 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Proto.Pvp;
namespace MariesWonderland.Services;
public class PvpService : MariesWonderland.Proto.Pvp.PvpService.PvpServiceBase
{
/// <summary>Returns an empty response. PvP top screen not yet implemented.</summary>
public override Task<GetTopDataResponse> GetTopData(Empty request, ServerCallContext context)
{
return Task.FromResult(new GetTopDataResponse());
}
/// <summary>Returns an empty response. PvP matchmaking not yet implemented.</summary>
public override Task<GetMatchingListResponse> GetMatchingList(Empty request, ServerCallContext context)
{
return Task.FromResult(new GetMatchingListResponse());
}
/// <summary>Returns an empty response. PvP matchmaking not yet implemented.</summary>
public override Task<UpdateMatchingListResponse> UpdateMatchingList(Empty request, ServerCallContext context)
{
return Task.FromResult(new UpdateMatchingListResponse());
}
/// <summary>Returns an empty response. PvP battles not yet implemented.</summary>
public override Task<StartBattleResponse> StartBattle(StartBattleRequest request, ServerCallContext context)
{
return Task.FromResult(new StartBattleResponse());
}
/// <summary>Returns an empty response. PvP battles not yet implemented.</summary>
public override Task<FinishBattleResponse> FinishBattle(FinishBattleRequest request, ServerCallContext context)
{
return Task.FromResult(new FinishBattleResponse());
}
/// <summary>Returns an empty response. PvP rankings not yet implemented.</summary>
public override Task<GetRankingResponse> GetRanking(GetRankingRequest request, ServerCallContext context)
{
return Task.FromResult(new GetRankingResponse());
}
/// <summary>Returns an empty response. PvP season results not yet implemented.</summary>
public override Task<GetSeasonResultResponse> GetSeasonResult(Empty request, ServerCallContext context)
{
return Task.FromResult(new GetSeasonResultResponse());
}
/// <summary>Returns an empty response. PvP attack logs not yet implemented.</summary>
public override Task<GetAttackLogListResponse> GetAttackLogList(Empty request, ServerCallContext context)
{
return Task.FromResult(new GetAttackLogListResponse());
}
/// <summary>Returns an empty response. PvP defense logs not yet implemented.</summary>
public override Task<GetDefenseLogListResponse> GetDefenseLogList(Empty request, ServerCallContext context)
{
return Task.FromResult(new GetDefenseLogListResponse());
}
}

1596
src/Services/QuestService.cs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,203 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Helpers;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.BigHunt;
using MariesWonderland.Proto.Reward;
namespace MariesWonderland.Services;
public class RewardService(UserDataStore store, DarkMasterMemoryDatabase masterDb)
: MariesWonderland.Proto.Reward.RewardService.RewardServiceBase
{
private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
/// <summary>
/// Collects the weekly score rewards for all bosses and grants any unclaimed ones to the user.
/// </summary>
public override Task<ReceiveBigHuntRewardResponse> ReceiveBigHuntReward(Empty request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long weeklyVersion = GetWeeklyVersion(nowMs);
EntityIUserBigHuntWeeklyStatus? ws = userDb.EntityIUserBigHuntWeeklyStatus
.FirstOrDefault(s => s.BigHuntWeeklyVersion == weeklyVersion);
bool isReceived = ws?.IsReceivedWeeklyReward ?? false;
List<WeeklyScoreResult> weeklyScoreResults = [];
List<BigHuntReward> weeklyRewards = [];
foreach (EntityMBigHuntBoss boss in _masterDb.EntityMBigHuntBoss)
{
EntityIUserBigHuntWeeklyMaxScore? wms = userDb.EntityIUserBigHuntWeeklyMaxScore
.FirstOrDefault(s => s.UserId == userId
&& s.BigHuntWeeklyVersion == weeklyVersion
&& s.AttributeType == boss.AttributeType);
long maxScore = wms?.MaxScore ?? 0;
int gradeIcon = ResolveGradeIconId(boss.BigHuntBossId, maxScore);
weeklyScoreResults.Add(new WeeklyScoreResult
{
AttributeType = (int)boss.AttributeType,
BeforeMaxScore = maxScore,
CurrentMaxScore = maxScore,
BeforeAssetGradeIconId = gradeIcon,
CurrentAssetGradeIconId = gradeIcon,
AfterMaxScore = maxScore,
AfterAssetGradeIconId = gradeIcon,
});
}
if (!isReceived)
{
foreach (EntityMBigHuntBoss boss in _masterDb.EntityMBigHuntBoss)
{
int rewardGroupId = ResolveActiveWeeklyRewardGroupId((int)boss.AttributeType, nowMs);
if (rewardGroupId == 0) { continue; }
EntityIUserBigHuntWeeklyMaxScore? wms = userDb.EntityIUserBigHuntWeeklyMaxScore
.FirstOrDefault(s => s.UserId == userId
&& s.BigHuntWeeklyVersion == weeklyVersion
&& s.AttributeType == boss.AttributeType);
long maxScore = wms?.MaxScore ?? 0;
List<EntityMBigHuntRewardGroup> items = CollectNewRewards(rewardGroupId, 0, maxScore);
foreach (EntityMBigHuntRewardGroup item in items)
{
PossessionHelper.Apply(userDb, userId, item.PossessionType, item.PossessionId, item.Count, _masterDb);
weeklyRewards.Add(new BigHuntReward
{
PossessionType = (int)item.PossessionType,
PossessionId = item.PossessionId,
Count = item.Count,
});
}
}
if (ws == null)
{
ws = new EntityIUserBigHuntWeeklyStatus
{
UserId = userId,
BigHuntWeeklyVersion = weeklyVersion,
};
userDb.EntityIUserBigHuntWeeklyStatus.Add(ws);
}
ws.IsReceivedWeeklyReward = true;
isReceived = true;
}
ReceiveBigHuntRewardResponse response = new()
{
IsReceivedWeeklyScoreReward = isReceived,
};
response.WeeklyScoreResult.AddRange(weeklyScoreResults);
response.WeeklyScoreReward.AddRange(weeklyRewards);
return Task.FromResult(response);
}
/// <summary>
/// Returns an empty PvP reward response (not yet implemented).
/// </summary>
public override Task<ReceivePvpRewardResponse> ReceivePvpReward(Empty request, ServerCallContext context)
{
return Task.FromResult(new ReceivePvpRewardResponse());
}
/// <summary>
/// Returns an empty labyrinth season reward response (not yet implemented).
/// </summary>
public override Task<ReceiveLabyrinthSeasonRewardResponse> ReceiveLabyrinthSeasonReward(Empty request, ServerCallContext context)
{
return Task.FromResult(new ReceiveLabyrinthSeasonRewardResponse());
}
/// <summary>
/// Returns an empty mission pass remaining reward response (not yet implemented).
/// </summary>
public override Task<ReceiveMissionPassRemainingRewardResponse> ReceiveMissionPassRemainingReward(Empty request, ServerCallContext context)
{
return Task.FromResult(new ReceiveMissionPassRemainingRewardResponse());
}
/// <summary>
/// Returns the Monday 00:00 UTC timestamp in milliseconds for the week containing the given timestamp.
/// </summary>
private static long GetWeeklyVersion(long millis)
{
DateTimeOffset dt = DateTimeOffset.FromUnixTimeMilliseconds(millis).ToUniversalTime();
int weekday = (int)dt.DayOfWeek;
if (weekday == 0) { weekday = 7; }
DateTimeOffset monday = new(dt.Year, dt.Month, dt.Day, 0, 0, 0, TimeSpan.Zero);
monday = monday.AddDays(-(weekday - 1));
return monday.ToUnixTimeMilliseconds();
}
private int ResolveGradeIconId(int bossId, long score)
{
EntityMBigHuntBoss? boss = _masterDb.EntityMBigHuntBoss.FirstOrDefault(b => b.BigHuntBossId == bossId);
if (boss == null) { return 0; }
List<EntityMBigHuntBossGradeGroup> thresholds = [.. _masterDb.EntityMBigHuntBossGradeGroup
.Where(g => g.BigHuntBossGradeGroupId == boss.BigHuntBossGradeGroupId)
.OrderBy(g => g.NecessaryScore)];
int iconId = 0;
foreach (EntityMBigHuntBossGradeGroup t in thresholds)
{
if (score >= t.NecessaryScore) { iconId = t.AssetGradeIconId; }
else { break; }
}
return iconId;
}
/// <summary>
/// Finds the active weekly reward group for a given attribute type at the specified time.
/// Uses ScheduleId=1 (the default schedule).
/// </summary>
private int ResolveActiveWeeklyRewardGroupId(int attributeType, long nowMs)
{
List<EntityMBigHuntWeeklyAttributeScoreRewardGroupSchedule> entries = [.. _masterDb
.EntityMBigHuntWeeklyAttributeScoreRewardGroupSchedule
.Where(e => e.AttributeType == (AttributeType)attributeType
&& e.BigHuntWeeklyAttributeScoreRewardGroupScheduleId == 1)
.OrderByDescending(e => e.StartDatetime)];
foreach (EntityMBigHuntWeeklyAttributeScoreRewardGroupSchedule e in entries)
{
if (nowMs >= e.StartDatetime) { return e.BigHuntScoreRewardGroupId; }
}
return entries.Count > 0 ? entries[^1].BigHuntScoreRewardGroupId : 0;
}
/// <summary>
/// Collects reward items for thresholds between oldMax (exclusive) and newMax (inclusive).
/// </summary>
private List<EntityMBigHuntRewardGroup> CollectNewRewards(int scoreRewardGroupId, long oldMax, long newMax)
{
List<EntityMBigHuntScoreRewardGroup> thresholds = [.. _masterDb.EntityMBigHuntScoreRewardGroup
.Where(t => t.BigHuntScoreRewardGroupId == scoreRewardGroupId)
.OrderBy(t => t.NecessaryScore)];
List<EntityMBigHuntRewardGroup> items = [];
foreach (EntityMBigHuntScoreRewardGroup t in thresholds)
{
if (t.NecessaryScore > oldMax && t.NecessaryScore <= newMax)
{
items.AddRange(_masterDb.EntityMBigHuntRewardGroup
.Where(r => r.BigHuntRewardGroupId == t.BigHuntRewardGroupId));
}
}
return items;
}
}

272
src/Services/ShopService.cs Normal file
View File

@@ -0,0 +1,272 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Helpers;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.Shop;
namespace MariesWonderland.Services;
public class ShopService(UserDataStore store, DarkMasterMemoryDatabase masterDb) : MariesWonderland.Proto.Shop.ShopService.ShopServiceBase
{
private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
/// <summary>Purchases shop items: deducts currency, grants item contents and effects, and updates purchase history.</summary>
public override Task<BuyResponse> Buy(BuyRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach ((int shopItemId, int qty) in request.ShopItems)
{
// Validate item exists in master data
EntityMShopItem? item = _masterDb.EntityMShopItem.FirstOrDefault(i => i.ShopItemId == shopItemId);
if (item == null) { continue; }
// Deduct the total price; skip this item if funds are insufficient
int totalPrice = item.Price * qty;
if (!DeductPrice(userDb, item.PriceType, item.PriceId, totalPrice)) { continue; }
// Grant all possession contents for this shop item
List<EntityMShopItemContentPossession> contents = [.. _masterDb.EntityMShopItemContentPossession
.Where(c => c.ShopItemId == shopItemId)];
foreach (EntityMShopItemContentPossession content in contents)
{
GrantShopPossession(userDb, userId, content.PossessionType, content.PossessionId, content.Count * qty);
}
// Apply side effects (e.g., stamina recovery)
ApplyContentEffects(userDb, shopItemId, qty, userId, nowMs);
// Track purchase count
EntityIUserShopItem? shopItem = userDb.EntityIUserShopItem.FirstOrDefault(s => s.ShopItemId == shopItemId);
if (shopItem == null)
{
shopItem = new EntityIUserShopItem { UserId = userId, ShopItemId = shopItemId };
userDb.EntityIUserShopItem.Add(shopItem);
}
shopItem.BoughtCount += qty;
shopItem.LatestBoughtCountChangedDatetime = nowMs;
}
return Task.FromResult(new BuyResponse());
}
/// <summary>
/// Returns the sorted item shop pool (item IDs) from the ITEM_SHOP group's cell layout.
/// </summary>
private List<int> GetItemShopPool()
{
EntityMShop? itemShop = _masterDb.EntityMShop.FirstOrDefault(s => s.ShopGroupType == ShopGroupType.ITEM_SHOP);
if (itemShop == null) { return []; }
Dictionary<int, int> cellIdToItemId = _masterDb.EntityMShopItemCell
.ToDictionary(c => c.ShopItemCellId, c => c.ShopItemId);
return [.. _masterDb.EntityMShopItemCellGroup
.Where(cg => cg.ShopItemCellGroupId == itemShop.ShopItemCellGroupId)
.OrderBy(cg => cg.SortOrder)
.Select(cg => cellIdToItemId.GetValueOrDefault(cg.ShopItemCellId))
.Where(id => id != 0)];
}
/// <summary>Returns an empty response. CESA age-rating spending limits not yet implemented.</summary>
public override Task<GetCesaLimitResponse> GetCesaLimit(Empty request, ServerCallContext context)
{
return Task.FromResult(new GetCesaLimitResponse());
}
/// <summary>Refreshes the replaceable item shop lineup. Initializes slots on first call; resets purchase counts when gems are spent to refresh.</summary>
public override Task<RefreshResponse> RefreshUserData(RefreshRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
List<int> itemShopPool = GetItemShopPool();
if (!userDb.EntityIUserShopReplaceableLineup.Any(l => l.UserId == userId) && itemShopPool.Count > 0)
{
for (int i = 0; i < itemShopPool.Count; i++)
{
int slot = i + 1;
userDb.EntityIUserShopReplaceableLineup.Add(new EntityIUserShopReplaceableLineup
{
UserId = userId,
SlotNumber = slot,
ShopItemId = itemShopPool[i],
});
}
}
if (request.IsGemUsed)
{
EntityIUserShopReplaceable? replaceable = userDb.EntityIUserShopReplaceable.FirstOrDefault(r => r.UserId == userId);
if (replaceable == null)
{
replaceable = new EntityIUserShopReplaceable { UserId = userId };
userDb.EntityIUserShopReplaceable.Add(replaceable);
}
replaceable.LineupUpdateCount++;
replaceable.LatestLineupUpdateDatetime = nowMs;
foreach (int itemId in itemShopPool)
{
EntityIUserShopItem? si = userDb.EntityIUserShopItem.FirstOrDefault(s => s.ShopItemId == itemId);
if (si != null)
{
si.BoughtCount = 0;
}
}
}
return Task.FromResult(new RefreshResponse());
}
/// <summary>Creates a purchase transaction for real-money IAP: deducts currency, grants contents and effects, and returns a transaction ID.</summary>
public override Task<CreatePurchaseTransactionResponse> CreatePurchaseTransaction(CreatePurchaseTransactionRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityMShopItem? item = _masterDb.EntityMShopItem.FirstOrDefault(i => i.ShopItemId == request.ShopItemId);
if (item != null)
{
DeductPrice(userDb, item.PriceType, item.PriceId, item.Price);
List<EntityMShopItemContentPossession> contents = [.. _masterDb.EntityMShopItemContentPossession
.Where(c => c.ShopItemId == request.ShopItemId)];
foreach (EntityMShopItemContentPossession content in contents)
{
GrantShopPossession(userDb, userId, content.PossessionType, content.PossessionId, content.Count);
}
ApplyContentEffects(userDb, request.ShopItemId, 1, userId, nowMs);
EntityIUserShopItem? shopItem = userDb.EntityIUserShopItem.FirstOrDefault(s => s.ShopItemId == request.ShopItemId);
if (shopItem == null)
{
shopItem = new EntityIUserShopItem { UserId = userId, ShopItemId = request.ShopItemId };
userDb.EntityIUserShopItem.Add(shopItem);
}
shopItem.BoughtCount++;
if (item.ShopItemLimitedStockId > 0)
{
EntityMShopItemLimitedStock? stock = _masterDb.EntityMShopItemLimitedStock
.FirstOrDefault(s => s.ShopItemLimitedStockId == item.ShopItemLimitedStockId);
if (stock != null && shopItem.BoughtCount >= stock.MaxCount)
{
shopItem.BoughtCount = 0;
}
}
shopItem.LatestBoughtCountChangedDatetime = nowMs;
}
string txId = $"tx_{userId}_{request.ShopItemId}_{nowMs}";
return Task.FromResult(new CreatePurchaseTransactionResponse
{
PurchaseTransactionId = txId,
});
}
/// <summary>Returns an empty response. Google Play Store IAP verification not yet implemented.</summary>
public override Task<PurchaseGooglePlayStoreProductResponse> PurchaseGooglePlayStoreProduct(PurchaseGooglePlayStoreProductRequest request, ServerCallContext context)
{
return Task.FromResult(new PurchaseGooglePlayStoreProductResponse());
}
/// <summary>
/// Deducts the given price from the user's balance. Returns false if insufficient funds.
/// GEM deducts free gems first, then paid gems. PAID_GEM deducts paid gems only.
/// CONSUMABLE_ITEM deducts from the consumable with PriceId.
/// </summary>
private static bool DeductPrice(DarkUserMemoryDatabase userDb, PriceType priceType, int priceId, int amount)
{
switch (priceType)
{
case PriceType.GEM:
{
EntityIUserGem? gem = userDb.EntityIUserGem.FirstOrDefault();
if (gem == null || gem.FreeGem + gem.PaidGem < amount) { return false; }
int fromFree = Math.Min(gem.FreeGem, amount);
gem.FreeGem -= fromFree;
gem.PaidGem -= amount - fromFree;
return true;
}
case PriceType.PAID_GEM:
{
EntityIUserGem? gem = userDb.EntityIUserGem.FirstOrDefault();
if (gem == null || gem.PaidGem < amount) { return false; }
gem.PaidGem -= amount;
return true;
}
case PriceType.CONSUMABLE_ITEM:
{
EntityIUserConsumableItem? item = userDb.EntityIUserConsumableItem.FirstOrDefault(c => c.ConsumableItemId == priceId);
if (item == null || item.Count < amount) { return false; }
item.Count -= amount;
return true;
}
default:
return true;
}
}
/// <summary>
/// Grants a shop possession to the user. For costumes already owned, grants duplication
/// exchange items instead. All other types delegate to PossessionHelper.Apply.
/// </summary>
private void GrantShopPossession(DarkUserMemoryDatabase userDb, long userId, PossessionType possessionType, int possessionId, int count)
{
if (possessionType is PossessionType.COSTUME or PossessionType.COSTUME_ENHANCED
&& userDb.EntityIUserCostume.Any(c => c.CostumeId == possessionId))
{
foreach (EntityMCostumeDuplicationExchangePossessionGroup exchange in _masterDb.EntityMCostumeDuplicationExchangePossessionGroup
.Where(e => e.CostumeId == possessionId))
{
PossessionHelper.Apply(userDb, userId, exchange.PossessionType, exchange.PossessionId, exchange.Count * count, _masterDb);
}
return;
}
PossessionHelper.Apply(userDb, userId, possessionType, possessionId, count, _masterDb);
}
/// <summary>Applies side-effect content (e.g., stamina recovery) from a shop item purchase.</summary>
private void ApplyContentEffects(DarkUserMemoryDatabase userDb, int shopItemId, int qty, long userId, long nowMs)
{
List<EntityMShopItemContentEffect> effects = [.. _masterDb.EntityMShopItemContentEffect
.Where(e => e.ShopItemId == shopItemId)];
EntityIUserStatus? userStatus = userDb.EntityIUserStatus.FirstOrDefault(s => s.UserId == userId);
if (userStatus == null) { return; }
EntityMUserLevel? levelData = _masterDb.EntityMUserLevel.FirstOrDefault(l => l.UserLevel == userStatus.Level);
int maxStaminaMillis = (levelData?.MaxStamina ?? 0) * 1000;
foreach (EntityMShopItemContentEffect effect in effects)
{
if (effect.EffectTargetType != EffectTargetType.STAMINA_RECOVERY) { continue; }
int effectMillis = effect.EffectValueType switch
{
EffectValueType.FIXED_VALUE => effect.EffectValue,
EffectValueType.PERMIL_VALUE => maxStaminaMillis > 0 ? effect.EffectValue * maxStaminaMillis / 1000 : 0,
_ => 0
};
userStatus.StaminaMilliValue = Math.Min(userStatus.StaminaMilliValue + effectMillis * qty, maxStaminaMillis);
userStatus.StaminaUpdateDatetime = nowMs;
}
}
}

View File

@@ -0,0 +1,90 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Models.Entities;
using MariesWonderland.Proto.SideStoryQuest;
namespace MariesWonderland.Services;
public class SideStoryQuestService(UserDataStore store, DarkMasterMemoryDatabase masterDb) : MariesWonderland.Proto.SideStoryQuest.SidestoryquestService.SidestoryquestServiceBase
{
private readonly UserDataStore _store = store;
private readonly DarkMasterMemoryDatabase _masterDb = masterDb;
/// <summary>Activates a side story quest: sets the current scene progress and creates the quest tracking record if new.</summary>
public override Task<MoveSideStoryQuestResponse> MoveSideStoryQuestProgress(MoveSideStoryQuestRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
int questId = request.SideStoryQuestId;
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
int firstSceneId = _masterDb.EntityMSideStoryQuestScene
.Where(s => s.SideStoryQuestId == questId)
.OrderBy(s => s.SortOrder)
.Select(s => s.SideStoryQuestSceneId)
.FirstOrDefault();
EntityIUserSideStoryQuest? existing = userDb.EntityIUserSideStoryQuest
.FirstOrDefault(s => s.SideStoryQuestId == questId);
int sceneId = (existing != null && existing.HeadSideStoryQuestSceneId > 0)
? existing.HeadSideStoryQuestSceneId
: firstSceneId;
EntityIUserSideStoryQuestSceneProgressStatus activeProgress = userDb.EntityIUserSideStoryQuestSceneProgressStatus
.FirstOrDefault(s => s.UserId == userId)
?? AddEntity(userDb.EntityIUserSideStoryQuestSceneProgressStatus, new EntityIUserSideStoryQuestSceneProgressStatus { UserId = userId });
activeProgress.CurrentSideStoryQuestId = questId;
activeProgress.CurrentSideStoryQuestSceneId = sceneId;
if (existing == null)
{
userDb.EntityIUserSideStoryQuest.Add(new EntityIUserSideStoryQuest
{
UserId = userId,
SideStoryQuestId = questId,
HeadSideStoryQuestSceneId = firstSceneId,
SideStoryQuestStateType = 1, // Active
});
}
return Task.FromResult(new MoveSideStoryQuestResponse());
}
/// <summary>Advances the player's current scene within a side story quest, updating the high-water mark.</summary>
public override Task<UpdateSideStoryQuestSceneProgressResponse> UpdateSideStoryQuestSceneProgress(UpdateSideStoryQuestSceneProgressRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
int questId = request.SideStoryQuestId;
int sceneId = request.SideStoryQuestSceneId;
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
EntityIUserSideStoryQuestSceneProgressStatus activeProgress = userDb.EntityIUserSideStoryQuestSceneProgressStatus
.FirstOrDefault(s => s.UserId == userId)
?? AddEntity(userDb.EntityIUserSideStoryQuestSceneProgressStatus, new EntityIUserSideStoryQuestSceneProgressStatus { UserId = userId });
activeProgress.CurrentSideStoryQuestSceneId = sceneId;
EntityIUserSideStoryQuest? progress = userDb.EntityIUserSideStoryQuest
.FirstOrDefault(s => s.SideStoryQuestId == questId);
if (progress != null)
{
if (sceneId > progress.HeadSideStoryQuestSceneId)
{
progress.HeadSideStoryQuestSceneId = sceneId;
}
}
return Task.FromResult(new UpdateSideStoryQuestSceneProgressResponse());
}
/// <summary>Adds an entity to a list and returns it, enabling inline initialization with null-coalescing.</summary>
private static T AddEntity<T>(List<T> list, T entity)
{
list.Add(entity);
return entity;
}
}

View File

@@ -0,0 +1,280 @@
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Helpers;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.Deck;
using MariesWonderland.Proto.Tutorial;
namespace MariesWonderland.Services;
public class TutorialService(UserDataStore store, DarkMasterMemoryDatabase masterDb) : MariesWonderland.Proto.Tutorial.TutorialService.TutorialServiceBase
{
/// <summary>Advances tutorial progress, triggers starter deck creation on menu tutorials, and returns tutorial rewards.</summary>
public override Task<SetTutorialProgressResponse> SetTutorialProgress(SetTutorialProgressRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = store.GetOrCreate(userId);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
UpsertTutorialProgress(userDb, userId, (TutorialType)request.TutorialType, request.ProgressPhase, request.ChoiceId);
if (request.TutorialType == (int)TutorialType.MENU_FIRST ||
request.TutorialType == (int)TutorialType.MENU_SECOND)
{
CreateStarterDeck(userDb, userId);
}
List<TutorialChoiceReward> rewards = ApplyTutorialRewards(userDb, userId, (TutorialType)request.TutorialType);
SetTutorialProgressResponse response = new();
response.TutorialChoiceReward.AddRange(rewards);
return Task.FromResult(response);
}
/// <summary>Advances tutorial progress and replaces the user's deck in one operation.</summary>
public override Task<SetTutorialProgressAndReplaceDeckResponse> SetTutorialProgressAndReplaceDeck(SetTutorialProgressAndReplaceDeckRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = store.GetOrCreate(userId);
UpsertTutorialProgress(userDb, userId, (TutorialType)request.TutorialType, request.ProgressPhase, choiceId: 0);
UpsertDeck(userDb, userId, request);
return Task.FromResult(new SetTutorialProgressAndReplaceDeckResponse());
}
/// <summary>Creates or updates a tutorial progress record, advancing to the specified phase.</summary>
private static void UpsertTutorialProgress(DarkUserMemoryDatabase db, long userId, TutorialType type, int phase, int choiceId)
{
EntityIUserTutorialProgress? existing = db.EntityIUserTutorialProgress
.FirstOrDefault(t => t.TutorialType == type);
if (existing is null)
{
db.EntityIUserTutorialProgress.Add(new EntityIUserTutorialProgress
{
UserId = userId,
TutorialType = type,
ProgressPhase = phase,
ChoiceId = choiceId
});
}
else
{
if (phase >= existing.ProgressPhase)
{
existing.ProgressPhase = phase;
existing.ChoiceId = choiceId;
}
}
}
/// <summary>
/// Creates a minimal starter deck (DeckType=QUEST, DeckNumber=1) using the player's first
/// owned costume/weapon/companion. Only runs when no deck 1 character slot exists yet.
/// Idempotent: safe to call multiple times.
/// </summary>
private static void CreateStarterDeck(DarkUserMemoryDatabase db, long userId)
{
if (db.EntityIUserCostume.Count == 0)
{
return;
}
EntityIUserDeck? existingDeck = db.EntityIUserDeck
.FirstOrDefault(d => d.DeckType == DeckType.QUEST && d.UserDeckNumber == 1);
if (existingDeck is not null && !string.IsNullOrEmpty(existingDeck.UserDeckCharacterUuid01))
{
return;
}
// Hardcoded to Rion & Everlasting Cardia
string costumeUuid = db.EntityIUserCostume
.Where(c => c.CostumeId == Constants.StartingDeckCostumeId)
.Select(c => c.UserCostumeUuid)
.Single();
string weaponUuid = db.EntityIUserWeapon
.Where(w => w.WeaponId == Constants.StartingDeckWeaponId)
.Select(w => w.UserWeaponUuid)
.Single();
string dcUuid = Guid.NewGuid().ToString();
db.EntityIUserDeckCharacter.Add(new EntityIUserDeckCharacter
{
UserId = userId,
UserDeckCharacterUuid = dcUuid,
UserCostumeUuid = costumeUuid,
MainUserWeaponUuid = weaponUuid,
Power = 0
});
if (existingDeck is null)
{
db.EntityIUserDeck.Add(new EntityIUserDeck
{
UserId = userId,
DeckType = DeckType.QUEST,
UserDeckNumber = 1,
UserDeckCharacterUuid01 = dcUuid,
Name = "Loadout 1",
Power = 0
});
}
else
{
existingDeck.UserDeckCharacterUuid01 = dcUuid;
}
bool hasDeckTypeNote = db.EntityIUserDeckTypeNote
.Any(n => n.DeckType == DeckType.QUEST);
if (!hasDeckTypeNote)
{
db.EntityIUserDeckTypeNote.Add(new EntityIUserDeckTypeNote
{
UserId = userId,
DeckType = DeckType.QUEST,
MaxDeckPower = 0
});
}
}
/// <summary>
/// Full replace of the deck's character lineup from a SetTutorialProgressAndReplaceDeck request.
/// Removes existing EntityIUserDeckCharacter records for the deck, creates fresh ones with new
/// UUIDs, and updates EntityIUserDeck to reference the newly generated character UUIDs.
/// </summary>
private static void UpsertDeck(DarkUserMemoryDatabase db, long userId, SetTutorialProgressAndReplaceDeckRequest request)
{
DeckType deckType = (DeckType)request.DeckType;
EntityIUserDeck? existing = db.EntityIUserDeck
.FirstOrDefault(d => d.DeckType == deckType && d.UserDeckNumber == request.UserDeckNumber);
if (existing is not null)
{
HashSet<string> oldUuids = [];
if (!string.IsNullOrEmpty(existing.UserDeckCharacterUuid01)) { oldUuids.Add(existing.UserDeckCharacterUuid01); }
if (!string.IsNullOrEmpty(existing.UserDeckCharacterUuid02)) { oldUuids.Add(existing.UserDeckCharacterUuid02); }
if (!string.IsNullOrEmpty(existing.UserDeckCharacterUuid03)) { oldUuids.Add(existing.UserDeckCharacterUuid03); }
db.EntityIUserDeckCharacter.RemoveAll(dc => oldUuids.Contains(dc.UserDeckCharacterUuid));
db.EntityIUserDeckCharacterDressupCostume.RemoveAll(dc => oldUuids.Contains(dc.UserDeckCharacterUuid));
db.EntityIUserDeckPartsGroup.RemoveAll(pg => oldUuids.Contains(pg.UserDeckCharacterUuid));
db.EntityIUserDeckSubWeaponGroup.RemoveAll(swg => oldUuids.Contains(swg.UserDeckCharacterUuid));
}
string uuid01 = CreateDeckCharacter(db, userId, request.Deck?.Character01);
string uuid02 = CreateDeckCharacter(db, userId, request.Deck?.Character02);
string uuid03 = CreateDeckCharacter(db, userId, request.Deck?.Character03);
if (existing is null)
{
db.EntityIUserDeck.Add(new EntityIUserDeck
{
UserId = userId,
DeckType = deckType,
UserDeckNumber = request.UserDeckNumber,
UserDeckCharacterUuid01 = uuid01,
UserDeckCharacterUuid02 = uuid02,
UserDeckCharacterUuid03 = uuid03,
Name = $"Loadout {request.UserDeckNumber}",
Power = 0
});
}
else
{
existing.UserDeckCharacterUuid01 = uuid01;
existing.UserDeckCharacterUuid02 = uuid02;
existing.UserDeckCharacterUuid03 = uuid03;
}
}
/// <summary>Creates a deck character record from a DeckCharacter slot proto and returns the new UUID.</summary>
private static string CreateDeckCharacter(DarkUserMemoryDatabase db, long userId, DeckCharacter? slot)
{
if (slot is null || string.IsNullOrEmpty(slot.UserCostumeUuid))
{
return "";
}
string newUuid = Guid.NewGuid().ToString();
db.EntityIUserDeckCharacter.Add(new EntityIUserDeckCharacter
{
UserId = userId,
UserDeckCharacterUuid = newUuid,
UserCostumeUuid = slot.UserCostumeUuid,
MainUserWeaponUuid = slot.MainUserWeaponUuid,
UserCompanionUuid = slot.UserCompanionUuid,
UserThoughtUuid = slot.UserThoughtUuid,
Power = 0
});
if (slot.DressupCostumeId != 0)
{
db.EntityIUserDeckCharacterDressupCostume.Add(new EntityIUserDeckCharacterDressupCostume
{
UserId = userId,
UserDeckCharacterUuid = newUuid,
DressupCostumeId = slot.DressupCostumeId
});
}
for (int i = 0; i < slot.UserPartsUuid.Count; i++)
{
if (string.IsNullOrEmpty(slot.UserPartsUuid[i])) { continue; }
db.EntityIUserDeckPartsGroup.Add(new EntityIUserDeckPartsGroup
{
UserId = userId,
UserDeckCharacterUuid = newUuid,
UserPartsUuid = slot.UserPartsUuid[i],
SortOrder = i + 1
});
}
for (int i = 0; i < slot.SubUserWeaponUuid.Count; i++)
{
if (string.IsNullOrEmpty(slot.SubUserWeaponUuid[i])) { continue; }
db.EntityIUserDeckSubWeaponGroup.Add(new EntityIUserDeckSubWeaponGroup
{
UserId = userId,
UserDeckCharacterUuid = newUuid,
UserWeaponUuid = slot.SubUserWeaponUuid[i],
SortOrder = i + 1
});
}
return newUuid;
}
/// <summary>
/// Grants tutorial rewards from master data keyed by tutorial type and returns the reward list
/// for the response. Each tutorial type may grant companions, gems, items, or other possessions.
/// </summary>
private List<TutorialChoiceReward> ApplyTutorialRewards(DarkUserMemoryDatabase db, long userId, TutorialType tutorialType)
{
List<EntityMTutorialConsumePossessionGroup> rewardRows = [.. masterDb.EntityMTutorialConsumePossessionGroup
.Where(r => r.TutorialType == tutorialType)];
List<TutorialChoiceReward> result = [];
foreach (EntityMTutorialConsumePossessionGroup row in rewardRows)
{
PossessionHelper.Apply(db, userId, row.PossessionType, row.PossessionId, row.Count, masterDb);
result.Add(new TutorialChoiceReward
{
PossessionType = (int)row.PossessionType,
PossessionId = row.PossessionId,
Count = row.Count
});
}
return result;
}
}

364
src/Services/UserService.cs Normal file
View File

@@ -0,0 +1,364 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MariesWonderland.Data;
using MariesWonderland.Extensions;
using MariesWonderland.Helpers;
using MariesWonderland.Models.Entities;
using MariesWonderland.Models.Type;
using MariesWonderland.Proto.User;
namespace MariesWonderland.Services;
public class UserService(UserDataStore store, UserDataSeeder seeder) : MariesWonderland.Proto.User.UserService.UserServiceBase
{
private readonly UserDataStore _store = store;
private readonly UserDataSeeder _seeder = seeder;
/// <summary>Returns Android-specific arguments (API key and nonce) for client initialization.</summary>
public override Task<GetAndroidArgsResponse> GetAndroidArgs(GetAndroidArgsRequest request, ServerCallContext context)
{
return Task.FromResult(new GetAndroidArgsResponse
{
ApiKey = "1234567890",
Nonce = "Mama"
});
}
/// <summary>Authenticates a user by UUID, creates/updates device records, and returns a session key.</summary>
public override Task<AuthUserResponse> Auth(AuthUserRequest request, ServerCallContext context)
{
var (userId, isNew) = _store.RegisterOrGetUser(request.Uuid);
var userDb = _store.GetOrCreate(userId);
var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var deviceRecord = userDb.EntitySUserDevice.FirstOrDefault(d => d.UserId == userId);
if (deviceRecord == null)
{
deviceRecord = new EntitySUserDevice { UserId = userId };
userDb.EntitySUserDevice.Add(deviceRecord);
}
deviceRecord.Uuid = request.Uuid;
deviceRecord.AdvertisingId = request.AdvertisingId;
deviceRecord.IsTrackingEnabled = request.IsTrackingEnabled;
deviceRecord.IdentifierForVendor = request.DeviceInherent?.IdentifierForVendor ?? "";
deviceRecord.DeviceToken = request.DeviceInherent?.DeviceToken ?? "";
deviceRecord.MacAddress = request.DeviceInherent?.MacAddress ?? "";
deviceRecord.RegistrationId = request.DeviceInherent?.RegistrationId ?? "";
deviceRecord.LastAuthAt = nowMs;
if (isNew) deviceRecord.RegisteredAt = nowMs;
var session = _store.CreateSession(userId, TimeSpan.FromHours(24));
var response = new AuthUserResponse
{
SessionKey = session.SessionKey,
ExpireDatetime = Timestamp.FromDateTime(session.ExpiresAt),
Signature = request.Signature,
UserId = userId
};
return Task.FromResult(response);
}
/// <summary>Stub for transfer setting check; returns empty response.</summary>
public override Task<CheckTransferSettingResponse> CheckTransferSetting(Empty request, ServerCallContext context)
{
return Task.FromResult(new CheckTransferSettingResponse());
}
/// <summary>Initializes the game session: sets game start time and ensures gem balance exists.</summary>
public override Task<GameStartResponse> GameStart(Empty request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUser user = userDb.EntityIUser.GetOrCreate(userId);
user.GameStartDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Initialize gem balance with 0/0 if not exists
if (!userDb.EntityIUserGem.Any(g => g.UserId == userId))
{
userDb.EntityIUserGem.Add(new EntityIUserGem { UserId = userId, PaidGem = 0, FreeGem = 0 });
}
// 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 });
// userDb.EntityIUserMainQuestProgressStatus.AddNew(new EntityIUserMainQuestProgressStatus { UserId = userId });
// userDb.EntityIUserMainQuestReplayFlowStatus.AddNew(new EntityIUserMainQuestReplayFlowStatus { UserId = userId });
// userDb.EntityIUserMainQuestSeasonRoute.AddNew(new EntityIUserMainQuestSeasonRoute { UserId = userId });
// userDb.EntityIUserPortalCageStatus.AddNew(new EntityIUserPortalCageStatus { UserId = userId });
// userDb.EntityIUserShopReplaceable.AddNew(new EntityIUserShopReplaceable { UserId = userId });
// userDb.EntityIUserSideStoryQuestSceneProgressStatus.AddNew(new EntityIUserSideStoryQuestSceneProgressStatus { UserId = userId });
// TODO: Initialize first mission record (missionId=1, IN_PROGRESS) at registration.
// userDb.EntityIUserMission.AddNew(new EntityIUserMission
// {
// UserId = userId,
// MissionId = 1,
// MissionProgressStatusType = MissionProgressStatusType.IN_PROGRESS
// });
return Task.FromResult(new GameStartResponse());
}
/// <summary>Returns the user's backup token for account recovery.</summary>
public override Task<GetBackupTokenResponse> GetBackupToken(GetBackupTokenRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntitySUser? sUser = userDb.EntitySUser.FirstOrDefault(u => u.UserId == userId);
string token = sUser?.BackupToken ?? "";
if (string.IsNullOrEmpty(token))
{
token = "mock-backup-token";
}
return Task.FromResult(new GetBackupTokenResponse
{
BackupToken = token
});
}
/// <summary>Returns the user's birth year and month.</summary>
public override Task<GetBirthYearMonthResponse> GetBirthYearMonth(Empty request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUser? user = userDb.EntityIUser.FirstOrDefault(u => u.UserId == userId);
return Task.FromResult(new GetBirthYearMonthResponse
{
BirthYear = user?.BirthYear ?? 2000,
BirthMonth = user?.BirthMonth ?? 1
});
}
/// <summary>Returns the user's charge money amount for the current month.</summary>
public override Task<GetChargeMoneyResponse> GetChargeMoney(Empty request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntitySUser? sUser = userDb.EntitySUser.FirstOrDefault(u => u.UserId == userId);
return Task.FromResult(new GetChargeMoneyResponse
{
ChargeMoneyThisMonth = sUser?.ChargeMoneyThisMonth ?? 0
});
}
/// <summary>Stub for game play note retrieval; returns empty response.</summary>
public override Task<GetUserGamePlayNoteResponse> GetUserGamePlayNote(GetUserGamePlayNoteRequest request, ServerCallContext context)
{
return Task.FromResult(new GetUserGamePlayNoteResponse());
}
/// <summary>Returns a player's profile including name, level, favorite costume, and lead deck character.</summary>
public override Task<GetUserProfileResponse> GetUserProfile(GetUserProfileRequest request, ServerCallContext context)
{
long userId = request.PlayerId != 0 ? request.PlayerId : context.GetUserId();
if (!_store.TryGet(userId, out DarkUserMemoryDatabase userDb))
{
return Task.FromResult(new GetUserProfileResponse
{
LatestUsedDeck = new ProfileDeck { Power = 100 },
PvpInfo = new ProfilePvpInfo(),
GamePlayHistory = new GamePlayHistory
{
HistoryItem = { },
HistoryCategoryGraphItem = { }
}
});
}
EntityIUserProfile? profile = userDb.EntityIUserProfile.FirstOrDefault(p => p.UserId == userId);
EntityIUserStatus? status = userDb.EntityIUserStatus.FirstOrDefault(s => s.UserId == userId);
List<ProfileDeckCharacter> deckCharacters = [];
EntityIUserDeck? deck = userDb.EntityIUserDeck.FirstOrDefault(d =>
d.DeckType == DeckType.QUEST && d.UserDeckNumber == 1);
if (deck != null && !string.IsNullOrEmpty(deck.UserDeckCharacterUuid01))
{
EntityIUserDeckCharacter? dc = userDb.EntityIUserDeckCharacter
.FirstOrDefault(c => c.UserDeckCharacterUuid == deck.UserDeckCharacterUuid01);
if (dc != null)
{
int costumeId = userDb.EntityIUserCostume
.FirstOrDefault(c => c.UserCostumeUuid == dc.UserCostumeUuid)?.CostumeId ?? 0;
EntityIUserWeapon? weapon = userDb.EntityIUserWeapon
.FirstOrDefault(w => w.UserWeaponUuid == dc.MainUserWeaponUuid);
deckCharacters.Add(new ProfileDeckCharacter
{
CostumeId = costumeId,
MainWeaponId = weapon?.WeaponId ?? 0,
MainWeaponLevel = weapon?.Level ?? 0
});
}
}
return Task.FromResult(new GetUserProfileResponse
{
Level = status?.Level ?? 0,
Name = profile?.Name ?? "",
FavoriteCostumeId = profile?.FavoriteCostumeId ?? 0,
Message = profile?.Message ?? "",
IsFriend = false,
LatestUsedDeck = new ProfileDeck
{
Power = 100,
DeckCharacter = { deckCharacters }
},
PvpInfo = new ProfilePvpInfo(),
GamePlayHistory = new GamePlayHistory
{
HistoryItem = { },
HistoryCategoryGraphItem = { }
}
});
}
/// <summary>Registers a new device UUID and assigns a permanent user ID.</summary>
public override Task<RegisterUserResponse> RegisterUser(RegisterUserRequest request, ServerCallContext context)
{
// RegisterUser is the very first API called on a fresh install. It registers the device UUID
// and assigns a permanent userId (random 19-digit number). Subsequent launches call Auth instead.
var (userId, _) = _store.RegisterOrGetUser(request.Uuid);
RegisterUserResponse response = new()
{
UserId = userId,
Signature = $"sig_{userId}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"
};
return Task.FromResult(response);
}
/// <summary>Stub for Apple account linking; returns empty response.</summary>
public override Task<SetAppleAccountResponse> SetAppleAccount(SetAppleAccountRequest request, ServerCallContext context)
{
return Task.FromResult(new SetAppleAccountResponse());
}
/// <summary>Updates the user's birth year and month.</summary>
public override Task<SetBirthYearMonthResponse> SetBirthYearMonth(SetBirthYearMonthRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUser user = userDb.EntityIUser.GetOrCreate(userId);
user.BirthYear = request.BirthYear;
user.BirthMonth = request.BirthMonth;
return Task.FromResult(new SetBirthYearMonthResponse());
}
/// <summary>Stub for Facebook account linking; returns empty response.</summary>
public override Task<SetFacebookAccountResponse> SetFacebookAccount(SetFacebookAccountRequest request, ServerCallContext context)
{
return Task.FromResult(new SetFacebookAccountResponse());
}
/// <summary>Updates the user's favorite costume displayed on their profile.</summary>
public override Task<SetUserFavoriteCostumeIdResponse> SetUserFavoriteCostumeId(SetUserFavoriteCostumeIdRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserProfile profile = userDb.EntityIUserProfile.GetOrCreate(userId);
profile.FavoriteCostumeId = request.FavoriteCostumeId;
profile.FavoriteCostumeIdUpdateDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return Task.FromResult(new SetUserFavoriteCostumeIdResponse());
}
/// <summary>Updates the user's profile message.</summary>
public override Task<SetUserMessageResponse> SetUserMessage(SetUserMessageRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserProfile profile = userDb.EntityIUserProfile.GetOrCreate(userId);
profile.Message = request.Message;
profile.MessageUpdateDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return Task.FromResult(new SetUserMessageResponse());
}
/// <summary>Updates the user's display name.</summary>
public override Task<SetUserNameResponse> SetUserName(SetUserNameRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserProfile profile = userDb.EntityIUserProfile.GetOrCreate(userId);
profile.Name = request.Name;
profile.NameUpdateDatetime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return Task.FromResult(new SetUserNameResponse());
}
/// <summary>Updates the user's notification preferences.</summary>
public override Task<SetUserSettingResponse> SetUserSetting(SetUserSettingRequest request, ServerCallContext context)
{
long userId = context.GetUserId();
DarkUserMemoryDatabase userDb = _store.GetOrCreate(userId);
EntityIUserSetting setting = userDb.EntityIUserSetting.GetOrCreate(userId);
setting.IsNotifyPurchaseAlert = request.IsNotifyPurchaseAlert;
return Task.FromResult(new SetUserSettingResponse());
}
/// <summary>Transfers account data from seed files, creating a new user with pre-seeded database.</summary>
public override Task<TransferUserResponse> TransferUser(TransferUserRequest request, ServerCallContext context)
{
DarkUserMemoryDatabase seededDb = _seeder.LoadFromFiles();
long userId = _store.SeedUserFromDatabase(request.Uuid, seededDb);
TransferUserResponse response = new()
{
UserId = userId,
Signature = $"sig_{userId}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"
};
return Task.FromResult(response);
}
/// <summary>Stub for Apple-based account transfer; returns empty response.</summary>
public override Task<TransferUserByAppleResponse> TransferUserByApple(TransferUserByAppleRequest request, ServerCallContext context)
{
return Task.FromResult(new TransferUserByAppleResponse());
}
/// <summary>Stub for Facebook-based account transfer; returns empty response.</summary>
public override Task<TransferUserByFacebookResponse> TransferUserByFacebook(TransferUserByFacebookRequest request, ServerCallContext context)
{
return Task.FromResult(new TransferUserByFacebookResponse());
}
/// <summary>Stub for unlinking Facebook account; returns empty response.</summary>
public override Task<UnsetFacebookAccountResponse> UnsetFacebookAccount(Empty request, ServerCallContext context)
{
return Task.FromResult(new UnsetFacebookAccountResponse());
}
}

File diff suppressed because it is too large Load Diff