|
@@ -660,6 +660,13 @@ namespace Kitty_Fall.Apis.Business.Game
|
|
|
.OrderByDescending(x => x.Id)
|
|
.OrderByDescending(x => x.Id)
|
|
|
.FirstOrDefaultAsync();
|
|
.FirstOrDefaultAsync();
|
|
|
|
|
|
|
|
|
|
+ // Serialize StartSession per subscriber (same lock as UserSaveLevelResult) so
|
|
|
|
|
+ // parallel requests cannot open several runs while spending only one turn.
|
|
|
|
|
+ await using var startTransaction = await dbContext.Database.BeginTransactionAsync();
|
|
|
|
|
+ if (!await LockRewardSubscriberAsync(msisdn))
|
|
|
|
|
+ return CommonLogic.BuildResponse(url, json, CommonErrorCode.Error, Lang.errorOccurred, new { });
|
|
|
|
|
+ now = MyanmarClock.Now;
|
|
|
|
|
+
|
|
|
var session = await dbContext.GamePlaySessions
|
|
var session = await dbContext.GamePlaySessions
|
|
|
.FirstOrDefaultAsync(x => x.RootTransId == request.transId && x.Msisdn == msisdn);
|
|
.FirstOrDefaultAsync(x => x.RootTransId == request.transId && x.Msisdn == msisdn);
|
|
|
|
|
|
|
@@ -723,9 +730,29 @@ namespace Kitty_Fall.Apis.Business.Game
|
|
|
session.TurnRefId = turn.Id.ToString(CultureInfo.InvariantCulture);
|
|
session.TurnRefId = turn.Id.ToString(CultureInfo.InvariantCulture);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- session.LastActionTime = now;
|
|
|
|
|
|
|
+ // Only one active run per subscriber: a reload, app restart or another device
|
|
|
|
|
+ // starts a new run (new turn) and the previous run can no longer submit results.
|
|
|
|
|
+ var otherActiveSessions = await dbContext.GamePlaySessions
|
|
|
|
|
+ .Where(x => x.Msisdn == msisdn && x.Status == 1 && x.RootTransId != request.transId)
|
|
|
|
|
+ .ToListAsync();
|
|
|
|
|
+ foreach (var otherSession in otherActiveSessions)
|
|
|
|
|
+ {
|
|
|
|
|
+ otherSession.Status = 3;
|
|
|
|
|
+ otherSession.EndTime = now;
|
|
|
|
|
+ otherSession.FailReason = "REPLACED";
|
|
|
|
|
+ otherSession.UpdatedTime = now;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (otherActiveSessions.Count > 0)
|
|
|
|
|
+ {
|
|
|
|
|
+ log.Info($"StartSession closed previous active runs. msisdn={msisdn}, transId={request.transId}, closedSessionIds={string.Join(",", otherActiveSessions.Select(x => x.SessionId))}");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // LastActionTime is set only when the run is created (initializer above). A repeated
|
|
|
|
|
+ // StartSession on an active run must not refresh the level timer used by
|
|
|
|
|
+ // UserSaveLevelResult, otherwise a client could keep a run alive forever.
|
|
|
session.UpdatedTime = now;
|
|
session.UpdatedTime = now;
|
|
|
await dbContext.SaveChangesAsync();
|
|
await dbContext.SaveChangesAsync();
|
|
|
|
|
+ await startTransaction.CommitAsync();
|
|
|
var totalTurn = await GetAvailableTurnAsync(msisdn, now);
|
|
var totalTurn = await GetAvailableTurnAsync(msisdn, now);
|
|
|
|
|
|
|
|
return CommonLogic.BuildResponse(
|
|
return CommonLogic.BuildResponse(
|
|
@@ -804,11 +831,15 @@ namespace Kitty_Fall.Apis.Business.Game
|
|
|
// Thua game hoac thoat game chi dong phien, khong ghi diem va khong phat thuong.
|
|
// Thua game hoac thoat game chi dong phien, khong ghi diem va khong phat thuong.
|
|
|
if (request.gameOver != 0)
|
|
if (request.gameOver != 0)
|
|
|
{
|
|
{
|
|
|
- session.Status = 3;
|
|
|
|
|
- session.EndTime = now;
|
|
|
|
|
- session.LastActionTime = now;
|
|
|
|
|
- session.UpdatedTime = now;
|
|
|
|
|
- await dbContext.SaveChangesAsync();
|
|
|
|
|
|
|
+ // Closing an already closed run (double exit) is a no-op.
|
|
|
|
|
+ if (session.Status == 1)
|
|
|
|
|
+ {
|
|
|
|
|
+ session.Status = 3;
|
|
|
|
|
+ session.EndTime = now;
|
|
|
|
|
+ session.LastActionTime = now;
|
|
|
|
|
+ session.UpdatedTime = now;
|
|
|
|
|
+ await dbContext.SaveChangesAsync();
|
|
|
|
|
+ }
|
|
|
await rewardTransaction.CommitAsync();
|
|
await rewardTransaction.CommitAsync();
|
|
|
var totalTurn = await GetAvailableTurnAsync(msisdn, now);
|
|
var totalTurn = await GetAvailableTurnAsync(msisdn, now);
|
|
|
|
|
|
|
@@ -829,6 +860,42 @@ namespace Kitty_Fall.Apis.Business.Game
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // A run closed by game over, exit or a newer StartSession must not earn
|
|
|
|
|
+ // anything more; the player has to press Play again (new turn).
|
|
|
|
|
+ if (session.Status != 1)
|
|
|
|
|
+ {
|
|
|
|
|
+ log.Warn($"SaveLevelResult rejected on closed run. msisdn={msisdn}, transId={request.transId}, sessionId={session.SessionId}, status={session.Status}, failReason={session.FailReason}, level={levelNo}");
|
|
|
|
|
+ return CommonLogic.BuildResponse(
|
|
|
|
|
+ url,
|
|
|
|
|
+ json,
|
|
|
|
|
+ CommonErrorCode.Error,
|
|
|
|
|
+ Lang.sessionClosed,
|
|
|
|
|
+ new UserGameLevelResultData { reason = "SESSION_CLOSED", transId = request.transId });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Level time limit per mode (+ grace). Measured on the server from the previous
|
|
|
|
|
+ // action of this run, so losing/timing out and replaying from the level menu
|
|
|
|
|
+ // without pressing Play again (new turn) cannot earn anything.
|
|
|
|
|
+ var allowedSeconds = await GetAllowedLevelSecondsAsync(session);
|
|
|
|
|
+ var elapsedSeconds = (now - session.LastActionTime).TotalSeconds;
|
|
|
|
|
+ if (allowedSeconds > 0 && elapsedSeconds > allowedSeconds)
|
|
|
|
|
+ {
|
|
|
|
|
+ session.Status = 3;
|
|
|
|
|
+ session.EndTime = now;
|
|
|
|
|
+ session.FailReason = "TIMEOUT";
|
|
|
|
|
+ session.UpdatedTime = now;
|
|
|
|
|
+ await dbContext.SaveChangesAsync();
|
|
|
|
|
+ await rewardTransaction.CommitAsync();
|
|
|
|
|
+
|
|
|
|
|
+ log.Warn($"SaveLevelResult rejected, level time exceeded. msisdn={msisdn}, transId={request.transId}, sessionId={session.SessionId}, level={levelNo}, elapsedSeconds={elapsedSeconds:0}, allowedSeconds={allowedSeconds}");
|
|
|
|
|
+ return CommonLogic.BuildResponse(
|
|
|
|
|
+ url,
|
|
|
|
|
+ json,
|
|
|
|
|
+ CommonErrorCode.Error,
|
|
|
|
|
+ Lang.sessionClosed,
|
|
|
|
|
+ new UserGameLevelResultData { reason = "SESSION_CLOSED", transId = request.transId });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
var levelConfig = await dbContext.GameLevelConfigs.AsNoTracking()
|
|
var levelConfig = await dbContext.GameLevelConfigs.AsNoTracking()
|
|
|
.Where(x => x.ModeId == mode.ModeId && x.LevelNo == levelNo.Value && x.Status != false)
|
|
.Where(x => x.ModeId == mode.ModeId && x.LevelNo == levelNo.Value && x.Status != false)
|
|
|
.FirstOrDefaultAsync();
|
|
.FirstOrDefaultAsync();
|
|
@@ -1234,6 +1301,34 @@ namespace Kitty_Fall.Apis.Business.Game
|
|
|
.FirstOrDefaultAsync();
|
|
.FirstOrDefaultAsync();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Max seconds allowed between two actions of a run, 0 = no limit.
|
|
|
|
|
+ // Limit per mode comes from GameSession:LevelTimeLimitSeconds:{MODE_CODE}
|
|
|
|
|
+ // (fallback GAME_MODE.TIME_LIMIT_SECONDS); the run's own mode is used, not the
|
|
|
|
|
+ // gameType sent by the client.
|
|
|
|
|
+ private async Task<int> GetAllowedLevelSecondsAsync(GamePlaySession session)
|
|
|
|
|
+ {
|
|
|
|
|
+ if (!configuration.GetValue("GameSession:LevelTimeoutEnabled", false)) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ var mode = await dbContext.GameModes.AsNoTracking()
|
|
|
|
|
+ .Where(x => x.ModeId == session.ModeId)
|
|
|
|
|
+ .Select(x => new { x.ModeCode, x.TimeLimitSeconds })
|
|
|
|
|
+ .FirstOrDefaultAsync();
|
|
|
|
|
+ if (mode == null) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ var limit = configuration.GetValue<int?>($"GameSession:LevelTimeLimitSeconds:{mode.ModeCode.Trim().ToUpperInvariant()}")
|
|
|
|
|
+ ?? mode.TimeLimitSeconds;
|
|
|
|
|
+ if (limit <= 0) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ var allowed = limit + configuration.GetValue("GameSession:GraceSeconds", 5);
|
|
|
|
|
+
|
|
|
|
|
+ // The first level also includes loading the game in the iframe after StartSession.
|
|
|
|
|
+ var isFirstLevel = !await dbContext.GameLevelAttempts.AsNoTracking()
|
|
|
|
|
+ .AnyAsync(x => x.SessionId == session.SessionId);
|
|
|
|
|
+ if (isFirstLevel) allowed += configuration.GetValue("GameSession:FirstLevelExtraSeconds", 0);
|
|
|
|
|
+
|
|
|
|
|
+ return allowed;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private async Task<bool> IsConfiguredLevelAsync(decimal modeId, byte levelNo)
|
|
private async Task<bool> IsConfiguredLevelAsync(decimal modeId, byte levelNo)
|
|
|
{
|
|
{
|
|
|
// Moi loai game co so level rieng, lay theo GAME_LEVEL_CONFIG dang active.
|
|
// Moi loai game co so level rieng, lay theo GAME_LEVEL_CONFIG dang active.
|
|
@@ -1254,19 +1349,23 @@ namespace Kitty_Fall.Apis.Business.Game
|
|
|
private async Task<LuckySpin?> DeductOneTurnAsync(string msisdn, DateTime now)
|
|
private async Task<LuckySpin?> DeductOneTurnAsync(string msisdn, DateTime now)
|
|
|
{
|
|
{
|
|
|
// Tru 1 luot tu goi con han cu nhat cho moi phien choi.
|
|
// Tru 1 luot tu goi con han cu nhat cho moi phien choi.
|
|
|
- var turns = await dbContext.LuckySpins
|
|
|
|
|
|
|
+ var turns = await dbContext.LuckySpins.AsNoTracking()
|
|
|
.Where(x => x.Msisdn == msisdn)
|
|
.Where(x => x.Msisdn == msisdn)
|
|
|
.OrderBy(x => x.ExpireTime ?? DateTime.MaxValue)
|
|
.OrderBy(x => x.ExpireTime ?? DateTime.MaxValue)
|
|
|
.ThenBy(x => x.InsertTime ?? DateTime.MaxValue)
|
|
.ThenBy(x => x.InsertTime ?? DateTime.MaxValue)
|
|
|
.ThenBy(x => x.Id)
|
|
.ThenBy(x => x.Id)
|
|
|
.ToListAsync();
|
|
.ToListAsync();
|
|
|
|
|
|
|
|
- var turn = turns.FirstOrDefault(x => (x.Added ?? 0) - (x.Used ?? 0) > 0);
|
|
|
|
|
- if (turn == null) return null;
|
|
|
|
|
|
|
+ foreach (var turn in turns.Where(x => (x.Added ?? 0) - (x.Used ?? 0) > 0))
|
|
|
|
|
+ {
|
|
|
|
|
+ // Conditional update in the database: never spends more turns than ADDED,
|
|
|
|
|
+ // even if another writer changed USED after the read above.
|
|
|
|
|
+ var updated = await dbContext.Database.ExecuteSqlInterpolatedAsync(
|
|
|
|
|
+ $"UPDATE LUCKY_SPIN SET USED = NVL(USED, 0) + 1, LAST_UPDATE = {now} WHERE ID = {turn.Id} AND NVL(ADDED, 0) - NVL(USED, 0) > 0");
|
|
|
|
|
+ if (updated == 1) return turn;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- turn.Used = (turn.Used ?? 0) + 1;
|
|
|
|
|
- turn.LastUpdate = now;
|
|
|
|
|
- return turn;
|
|
|
|
|
|
|
+ return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private async Task UpsertMonthlyRankingAsync(string msisdn, decimal? userId, decimal modeId, long scoreDelta, byte maxLevelReached, DateTime now)
|
|
private async Task UpsertMonthlyRankingAsync(string msisdn, decimal? userId, decimal modeId, long scoreDelta, byte maxLevelReached, DateTime now)
|