Forráskód Böngészése

CREATE INDEX IDX_AU_MSISDN ON ACCOUNT_USER(MSISDN) ONLINE;
CREATE INDEX IDX_CL_MSISDN_TIME ON CHARGE_LOG(MSISDN, CHARGE_TIME) ONLINE;

-Thua hoặc hết giờ ở một màn. Khi đó game đóng phiên, dù người chơi quay về màn chọn level.
-Bấm thoát game.
-Tải lại trang, tắt app rồi vào lại, hoặc bấm Play trên thiết bị khác.

student 1 hónapja
szülő
commit
63cf9a786d

+ 111 - 12
Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/Business/Game/GameBusinessImpl.cs

@@ -660,6 +660,13 @@ namespace Kitty_Fall.Apis.Business.Game
                     .OrderByDescending(x => x.Id)
                     .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
                     .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.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;
                 await dbContext.SaveChangesAsync();
+                await startTransaction.CommitAsync();
                 var totalTurn = await GetAvailableTurnAsync(msisdn, now);
 
                 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.
                 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();
                     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()
                     .Where(x => x.ModeId == mode.ModeId && x.LevelNo == levelNo.Value && x.Status != false)
                     .FirstOrDefaultAsync();
@@ -1234,6 +1301,34 @@ namespace Kitty_Fall.Apis.Business.Game
                 .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)
         {
             // 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)
         {
             // 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)
                 .OrderBy(x => x.ExpireTime ?? DateTime.MaxValue)
                 .ThenBy(x => x.InsertTime ?? DateTime.MaxValue)
                 .ThenBy(x => x.Id)
                 .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)

+ 9 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/Language/Lang.Designer.cs

@@ -312,6 +312,15 @@ namespace Kitty_Fall.Apis.Language {
             }
         }
         
+        /// <summary>
+        ///   Looks up a localized string similar to Your game session has ended. Please press Play to start a new turn..
+        /// </summary>
+        public static string sessionClosed {
+            get {
+                return ResourceManager.GetString("sessionClosed", resourceCulture);
+            }
+        }
+
         /// <summary>
         ///   Looks up a localized string similar to You have subscribed to the package.
         /// </summary>

+ 3 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/Language/Lang.resx

@@ -168,6 +168,9 @@
   <data name="overTurn" xml:space="preserve">
     <value>Turn over</value>
   </data>
+  <data name="sessionClosed" xml:space="preserve">
+    <value>Your game session has ended. Please press Play to start a new turn.</value>
+  </data>
   <data name="packageAlready" xml:space="preserve">
     <value>You have subscribed to the package</value>
   </data>

+ 14 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/appsettings.json

@@ -11,6 +11,20 @@
   "wsPass": "KittyFall@123A",
   "REVENUE_MIN": "200000",
 
+  // Thoi gian toi da giua 2 lan luu ket qua trong 1 phien (giay). Qua thoi gian -> dong phien,
+  // choi tiep phai bam Play (tru luot moi). Key = GAME_MODE.MODE_CODE (Medium = NORMAL).
+  "GameSession": {
+    "LevelTimeoutEnabled": true,
+    "LevelTimeLimitSeconds": {
+      "EASY": 150,
+      "NORMAL": 100,
+      "HARD": 60
+    },
+    "GraceSeconds": 5,
+    // Cong them cho level dau tien cua phien: thoi gian tai game trong iframe sau StartSession.
+    "FirstLevelExtraSeconds": 30
+  },
+
   "DefaultOtp": {
     "Code": "111111",
     "Msisdns": [

+ 3 - 1
Kitty_Fall/Kitty_Fall/Kitty_Fall.Website/Controllers/GameController.cs

@@ -92,7 +92,9 @@ namespace Kitty_Fall.Website.Controllers
                         rewardType = apiResult.data.rewardType,
                         rewardValue = apiResult.data.rewardValue,
                         rewardUnit = apiResult.data.rewardUnit,
-                        success = apiResult.errorCode == "0"
+                        success = apiResult.errorCode == "0",
+                        reason = apiResult.data.reason ?? "",
+                        message = apiResult.message ?? ""
                     });
                 }