歡迎來到第十九天!我們的門市遊戲平台包含「放置經營」與「離線點數累積」機制:當顧客離開門市、關閉 LINE WebView 後,其大廳內的設施與豆花怪依然會隨著時間推移持續產生收益(如離線點數與金幣)。
今天我們要解決兩個經典的系統設計問題:
離線收益的計算絕不能由手機客戶端計算並直接回傳(否則玩家只要修改手機系統時間就能「飛躍時間」刷爆點數)。我們採用 Server-Authoritative(伺服器權威)時間戳機制:
\[\text{有效離線秒數 } \Delta T = \min\left(T_{\text{now}} - T_{\text{last_harvest_time}}, T_{\text{max_offline_cap}}\right)\]
\[\text{獲得總點數} = \lfloor \Delta T \times \text{單位每秒點數產出 Rate} \rfloor\]
// OfflineProgressService.kt (Kotlin Ktor 後端)
object OfflineProgressService {
private const val MAX_OFFLINE_SECONDS = 28800L // 上限 8 小時 (28800 秒 / 480 分鐘)
private const val BASE_POINTS_PER_SECOND = 0.166L // 基礎產出速率
fun calculateAndApplyOfflineEarnings(userId: UUID): OfflineRewardDTO {
return transaction {
val state = GameStateTable.select { GameStateTable.userId eq userId }.single()
val currentTime = System.currentTimeMillis() / 1000L // 取得伺服器 NTP 標準時間戳 (秒)
val lastHarvestTime = state[GameStateTable.lastHarvestTime] / 1000L
// 1. 計算總離線秒數
val rawOfflineSeconds = currentTime - lastHarvestTime
if (rawOfflineSeconds < 60) {
return@transaction OfflineRewardDTO(earnedPoints = 0, offlineSeconds = 0) // 未滿 1 分鐘不發放
}
// 2. 扣除超過上限的時間 (Cap Barrier: 480 分鐘 / 8 小時)
val effectiveOfflineSeconds = Math.min(rawOfflineSeconds, MAX_OFFLINE_SECONDS)
// 3. 根據家具/房間等級加成計算最終收益
val hotelLevel = state[GameStateTable.hotelLevel]
val totalEarnedPoints = (effectiveOfflineSeconds * BASE_POINTS_PER_SECOND * hotelLevel).toLong()
// 4. 更新資料庫 atomically
GameStateTable.update({ GameStateTable.userId eq userId }) {
it[coins] = coins + totalEarnedPoints
it[GameStateTable.lastHarvestTime] = currentTime * 1000L // 更新最後收成時間戳
}
OfflineRewardDTO(
earnedPoints = totalEarnedPoints,
offlineSeconds = effectiveOfflineSeconds
)
}
}
}
為了在線上發送即時活動通知或進行雙向狀態同步,我們在 Ktor 中建立了 WebSocket 路由:
// WebSocketRoutes.kt
fun Route.gameWebSocketRouting() {
webSocket("/ws/game") {
val session = this
println("WebSocket 存取建立: ${session.hashCode()}")
try {
for (frame in incoming) {
when (frame) {
is Frame.Text -> {
val text = frame.readText()
if (text == "PING") {
// 收到客戶端心跳包,即時回傳 PONG
outgoing.send(Frame.Text("PONG"))
} else {
handleClientMessage(session, text)
}
}
else -> {}
}
}
} catch (e: Exception) {
println("WebSocket 異常中斷: ${e.localizedMessage}")
} finally {
println("WebSocket Session 釋放")
}
}
}
當手機螢幕關閉或網路切換時,WebSocket 會中斷。我們在前端實作了 Exponential Backoff(指數退避演算法) 重連機制:
// WebSocketClient.ts
export class WebSocketClient {
private ws: WebSocket | null = null;
private retryCount: number = 0;
private maxRetryCount: number = 5;
public connect(url: string) {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.retryCount = 0; // 重置重連次數
this.startHeartbeat();
};
this.ws.onclose = () => {
this.scheduleReconnect(url);
};
}
private scheduleReconnect(url: string) {
if (this.retryCount < this.maxRetryCount) {
this.retryCount++;
// 指數退避延遲:1s, 2s, 4s, 8s, 16s + 隨機抖動 (Jitter)
const delay = Math.pow(2, this.retryCount) * 1000 + Math.random() * 500;
setTimeout(() => this.connect(url), delay);
}
}
}
Tech Tip:單機遊戲常被玩家透過修改手機時間刷收益。在我們的 Server-Authoritative 架構中,客戶端傳來的系統時間只作為參考,甚至完全不採信!所有離線計算、任務冷卻與簽到判定,一律以 Ktor 伺服器的 UNIX 時間戳(由內網 NTP 伺服器校時)為唯一真理。就算玩家將手機時間調到 2030 年,伺服器計算出的離線秒數依然是精準的 0 秒!