mirror of
https://github.com/BillyCool/MariesWonderland.git
synced 2026-05-10 06:23:39 +02:00
Initial commit
This commit is contained in:
114
src/Services/BannerService.cs
Normal file
114
src/Services/BannerService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
69
src/Services/BattleService.cs
Normal file
69
src/Services/BattleService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
772
src/Services/BigHuntService.cs
Normal file
772
src/Services/BigHuntService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
94
src/Services/CageOrnamentService.cs
Normal file
94
src/Services/CageOrnamentService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
362
src/Services/CharacterBoardService.cs
Normal file
362
src/Services/CharacterBoardService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
135
src/Services/CharacterService.cs
Normal file
135
src/Services/CharacterService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
113
src/Services/CharacterViewerService.cs
Normal file
113
src/Services/CharacterViewerService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
191
src/Services/CompanionService.cs
Normal file
191
src/Services/CompanionService.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
14
src/Services/ConfigService.cs
Normal file
14
src/Services/ConfigService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
99
src/Services/ConsumableItemService.cs
Normal file
99
src/Services/ConsumableItemService.cs
Normal 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()
|
||||
});
|
||||
}
|
||||
}
|
||||
40
src/Services/ContentsStoryService.cs
Normal file
40
src/Services/ContentsStoryService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
806
src/Services/CostumeService.cs
Normal file
806
src/Services/CostumeService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/Services/DataService.cs
Normal file
58
src/Services/DataService.cs
Normal 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
283
src/Services/DeckService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/Services/DokanService.cs
Normal file
43
src/Services/DokanService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
204
src/Services/ExploreService.cs
Normal file
204
src/Services/ExploreService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
80
src/Services/FriendService.cs
Normal file
80
src/Services/FriendService.cs
Normal 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
1332
src/Services/GachaService.cs
Normal file
File diff suppressed because it is too large
Load Diff
16
src/Services/GameplayService.cs
Normal file
16
src/Services/GameplayService.cs
Normal 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
140
src/Services/GiftService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
198
src/Services/GimmickService.cs
Normal file
198
src/Services/GimmickService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
14
src/Services/IndividualPopService.cs
Normal file
14
src/Services/IndividualPopService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
25
src/Services/LabyrinthService.cs
Normal file
25
src/Services/LabyrinthService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
72
src/Services/LoginBonusService.cs
Normal file
72
src/Services/LoginBonusService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
99
src/Services/MaterialService.cs
Normal file
99
src/Services/MaterialService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
39
src/Services/MissionService.cs
Normal file
39
src/Services/MissionService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
35
src/Services/MovieService.cs
Normal file
35
src/Services/MovieService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
40
src/Services/NaviCutInService.cs
Normal file
40
src/Services/NaviCutInService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
28
src/Services/NotificationService.cs
Normal file
28
src/Services/NotificationService.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
37
src/Services/OmikujiService.cs
Normal file
37
src/Services/OmikujiService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
571
src/Services/PartsService.cs
Normal file
571
src/Services/PartsService.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
43
src/Services/PortalCageService.cs
Normal file
43
src/Services/PortalCageService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
62
src/Services/PvpService.cs
Normal file
62
src/Services/PvpService.cs
Normal 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
1596
src/Services/QuestService.cs
Normal file
File diff suppressed because it is too large
Load Diff
203
src/Services/RewardService.cs
Normal file
203
src/Services/RewardService.cs
Normal 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
272
src/Services/ShopService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/Services/SideStoryQuestService.cs
Normal file
90
src/Services/SideStoryQuestService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
280
src/Services/TutorialService.cs
Normal file
280
src/Services/TutorialService.cs
Normal 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
364
src/Services/UserService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
1193
src/Services/WeaponService.cs
Normal file
1193
src/Services/WeaponService.cs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user