Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/constants/actionName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export const ACTION_NAME = {
LEADERBOARDS_SET_SCORE: 'leaderboards_set_score',
LEADERBOARDS_GET_ENTRIES: 'leaderboards_get_entries',
LEADERBOARDS_SHOW_NATIVE_POPUP: 'leaderboards_show_native_popup',
DAILY_REWARDS_CLAIM: 'daily_rewards_claim',
DAILY_REWARDS_RESET: 'daily_rewards_reset',
GET_PURCHASES: 'get_purchases',
GET_CATALOG: 'get_catalog',
PURCHASE: 'purchase',
Expand Down
17 changes: 15 additions & 2 deletions src/modules/daily-rewards/DailyRewardsModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,20 +79,33 @@ class DailyRewardsModule extends ModuleBase<DailyRewardsBridgeContract> {
return false
}

state.lastClaimEpochDay = await this.#getTodayEpochDay()
const claimedDay = state.day
const todayEpochDay = await this.#getTodayEpochDay()
// Re-check after await: a concurrent claim may have already stamped today
if (state.lastClaimEpochDay === todayEpochDay) {
return false
}
state.lastClaimEpochDay = todayEpochDay
state.day = this.#cycle ? (state.day + 1) % this.#rewards.length : state.day + 1

await this.#persist()
this._platformBridge.dailyRewardsClaimed?.({
day: claimedDay,
reward: this.#rewards[claimedDay],
})
return true
}

async #refresh(): Promise<DailyRewardsState> {
const state = await this.#load()
if (this.#resetOnMiss && state.lastClaimEpochDay !== null && state.day > 0) {
const todayEpochDay = await this.#getTodayEpochDay()
if (todayEpochDay - state.lastClaimEpochDay >= 2) {
// Re-check after await: a concurrent #refresh may have already reset the state
if (state.day > 0 && todayEpochDay - state.lastClaimEpochDay >= 2) {
const missedDay = state.day
state.day = 0
await this.#persist()
this._platformBridge.dailyRewardsReset?.({ day: missedDay })
}
}
return state
Expand Down
4 changes: 4 additions & 0 deletions src/modules/daily-rewards/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,8 @@ export interface DailyRewardsBridgeContract extends PlatformBridgeLike {
platformId: PlatformId
options?: AnyRecord
getServerTime(): Promise<number>
// Optional observability hooks; implemented by bridges that surface daily
// rewards activity to an external tool (e.g. QA Tool).
dailyRewardsClaimed?(options: { day: number, reward: string }): void
dailyRewardsReset?(options: { day: number }): void
}
16 changes: 16 additions & 0 deletions src/platform-bridges/QaToolPlatformBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,22 @@ class QaToolPlatformBridge extends PlatformBridgeBase {
return this.#serverTimeCache.getServerTime()
}

dailyRewardsClaimed(options: { day: number, reward: string }): void {
this.#sendMessage({
type: MODULE_NAME.DAILY_REWARDS,
action: ACTION_NAME.DAILY_REWARDS_CLAIM,
options,
})
}

dailyRewardsReset(options: { day: number }): void {
this.#sendMessage({
type: MODULE_NAME.DAILY_REWARDS,
action: ACTION_NAME.DAILY_REWARDS_RESET,
options,
})
}

async getDataFromStorage(keys: string[]): Promise<Record<string, unknown>> {
const result: Record<string, unknown> = {}
await Promise.all(keys.map(async (key) => {
Expand Down
107 changes: 107 additions & 0 deletions tests/src/modules/dailyRewardsModule.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
describe, test, expect, vi, beforeEach,
} from 'vitest'
import DailyRewardsModule from '../../../src/modules/daily-rewards/DailyRewardsModule'
import bridgeConfig from '../../../src/lib/bridge-config'
import { MS_PER_DAY } from '../../../src/modules/daily-rewards/constants'
import type { DailyRewardsBridgeContract } from '../../../src/modules/daily-rewards/types'

const { store } = vi.hoisted(() => ({ store: new Map<string, unknown>() }))

vi.mock('../../../src/modules/storage', () => ({
default: {
get: vi.fn((key: string) => Promise.resolve(
store.has(key) ? JSON.parse(JSON.stringify(store.get(key))) : null,
)),
set: vi.fn((key: string, value: unknown) => {
store.set(key, JSON.parse(JSON.stringify(value)))
return Promise.resolve()
}),
},
}))

vi.mock('../../../src/lib/bridge-config', () => ({
default: {
getValues: vi.fn(() => ({})),
},
}))

const BASE_MS = 100 * MS_PER_DAY

// A bridge with a movable server clock and spied notification hooks.
function createBridge() {
const clock = { ms: BASE_MS }
return {
platformId: 'mock',
getServerTime: vi.fn(() => Promise.resolve(clock.ms)),
dailyRewardsClaimed: vi.fn(),
dailyRewardsReset: vi.fn(),
clock,
}
}

function createModule(bridge: ReturnType<typeof createBridge>) {
return new DailyRewardsModule().initialize(bridge as unknown as DailyRewardsBridgeContract)
}

describe('DailyRewardsModule platform notifications', () => {
beforeEach(() => {
store.clear()
vi.mocked(bridgeConfig.getValues).mockReturnValue({
dailyRewards: { rewards: ['a', 'b', 'c'] },
})
})

test('successful claim notifies the platform bridge', async () => {
const bridge = createBridge()
const module = createModule(bridge)

expect(await module.claimCurrentReward()).toBe(true)
expect(bridge.dailyRewardsClaimed).toHaveBeenCalledWith({ day: 0, reward: 'a' })
})

test('rejected claim does not notify', async () => {
const bridge = createBridge()
const module = createModule(bridge)

await module.claimCurrentReward()
expect(await module.claimCurrentReward()).toBe(false)
expect(bridge.dailyRewardsClaimed).toHaveBeenCalledTimes(1)
})

test('missed-day reset notifies the platform bridge', async () => {
const bridge = createBridge()
await createModule(bridge).claimCurrentReward()

bridge.clock.ms += 3 * MS_PER_DAY
const module = createModule(bridge)

expect(await module.getCurrentDay()).toBe(0)
expect(bridge.dailyRewardsReset).toHaveBeenCalledWith({ day: 1 })
})

test('concurrent claims grant a single reward', async () => {
const bridge = createBridge()
const module = createModule(bridge)

const results = await Promise.all([
module.claimCurrentReward(),
module.claimCurrentReward(),
])

expect(results.filter(Boolean)).toHaveLength(1)
expect(bridge.dailyRewardsClaimed).toHaveBeenCalledTimes(1)
expect(await module.getCurrentDay()).toBe(1)
})

test('concurrent refreshes send a single reset notification', async () => {
const bridge = createBridge()
await createModule(bridge).claimCurrentReward()

bridge.clock.ms += 3 * MS_PER_DAY
const module = createModule(bridge)

await Promise.all([module.getCurrentDay(), module.getCurrentReward()])
expect(bridge.dailyRewardsReset).toHaveBeenCalledTimes(1)
})
})
Loading