From 4ee546591e89e483a8ac595ccd184a3fa3bbc21e Mon Sep 17 00:00:00 2001 From: "COMPUTER\\EDY" <249929363@qq.com> Date: Fri, 9 Oct 2026 20:15:02 +0800 Subject: [PATCH] Set gold miner unlock to cleared level 50 --- laf-cloud/functions/activityConfig/list.ts | 6 +- .../activityConfig/tests/list.test.mjs | 2 +- .../functions/goldMiner/config.draft.json | 2 +- .../goldMiner/config.publish.example.json | 2 +- laf-cloud/functions/goldMiner/config.ts | 1 + laf-cloud/functions/goldMiner/service.ts | 8 +- .../goldMiner/tests/gold-miner.test.mjs | 32 ++-- .../tests/activity-unlock-levels.test.mjs | 139 ++++++++++++++++++ 8 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 laf-cloud/tests/activity-unlock-levels.test.mjs diff --git a/laf-cloud/functions/activityConfig/list.ts b/laf-cloud/functions/activityConfig/list.ts index e5bf5f6..2f90ea1 100644 --- a/laf-cloud/functions/activityConfig/list.ts +++ b/laf-cloud/functions/activityConfig/list.ts @@ -2,7 +2,7 @@ import cloud from "@lafjs/cloud"; import Utils from "@/Utils"; import { openPeriodForUser, openingRuleForUser } from "@/cloudRise/periods"; -import { calendar } from "@/goldMiner/config"; +import { calendar, UNLOCK_PASSED_LEVEL } from "@/goldMiner/config"; import { periodById, periodForPlayer } from "@/goldMiner/service"; import { testPeriods } from "@/goldMiner/testPeriods"; @@ -39,7 +39,7 @@ async function goldMinerActivities(now: number, windowEndsAt: number, user: any) if (!period) continue; result.push({ activityId: "goldMiner", periodId: period.periodId, configVersion: period.configVersion, - startsAt: period.startsAt, endsAt: period.endsAt, unlockLevel: period.configSnapshot.unlockPassedLevel, + startsAt: period.startsAt, endsAt: period.endsAt, unlockLevel: UNLOCK_PASSED_LEVEL, phase: period.startsAt <= now ? "active" : "upcoming", purchaseEnabled: period.purchaseEnabled }); } @@ -52,7 +52,7 @@ async function goldMinerActivities(now: number, windowEndsAt: number, user: any) result.push({ activityId: "goldMiner", periodId: period.periodId, configVersion: period.configVersion, startsAt: period.startsAt, endsAt: period.endsAt, - unlockLevel: period.configSnapshot.unlockPassedLevel, + unlockLevel: UNLOCK_PASSED_LEVEL, phase: period.startsAt <= now ? "active" : "upcoming", purchaseEnabled: period.purchaseEnabled, }); } diff --git a/laf-cloud/functions/activityConfig/tests/list.test.mjs b/laf-cloud/functions/activityConfig/tests/list.test.mjs index 78e2b90..5910422 100644 --- a/laf-cloud/functions/activityConfig/tests/list.test.mjs +++ b/laf-cloud/functions/activityConfig/tests/list.test.mjs @@ -194,7 +194,7 @@ test('registered gold miner is public and coexists with personal CloudRise witho assert.deepEqual(anonymous.data.activities, [{ activityId: 'goldMiner', periodId: 'goldMiner:2026-09-17', configVersion: 'goldV1', startsAt: G.calendar(NOW).startsAt, endsAt: G.calendar(NOW).endsAt, - unlockLevel: 41, phase: 'active', purchaseEnabled: true + unlockLevel: 50, phase: 'active', purchaseEnabled: true }]); const loggedIn = await api({ body: { uid: 1001, token: user.token } }); assert.deepEqual(loggedIn.data.activities.map(a => a.activityId), ['cloudRise', 'goldMiner']); diff --git a/laf-cloud/functions/goldMiner/config.draft.json b/laf-cloud/functions/goldMiner/config.draft.json index 46309b3..2de4d85 100644 --- a/laf-cloud/functions/goldMiner/config.draft.json +++ b/laf-cloud/functions/goldMiner/config.draft.json @@ -3,7 +3,7 @@ "status": "draft", "effectiveFromPeriodId": null, "publishedAt": null, - "unlockPassedLevel": 41, + "unlockPassedLevel": 50, "timezone": "Asia/Shanghai", "productId": "gold_miner", "priceFen": null, diff --git a/laf-cloud/functions/goldMiner/config.publish.example.json b/laf-cloud/functions/goldMiner/config.publish.example.json index aea0304..7d4d8bd 100644 --- a/laf-cloud/functions/goldMiner/config.publish.example.json +++ b/laf-cloud/functions/goldMiner/config.publish.example.json @@ -5,7 +5,7 @@ "config": { "configVersion": "goldMinerFortnightV1", "effectiveFromPeriodId": "goldMiner:2026-10-01", - "unlockPassedLevel": 41, + "unlockPassedLevel": 50, "timezone": "Asia/Shanghai", "productId": "gold_miner", "priceFen": 100, diff --git a/laf-cloud/functions/goldMiner/config.ts b/laf-cloud/functions/goldMiner/config.ts index d2dc7ec..44f145d 100644 --- a/laf-cloud/functions/goldMiner/config.ts +++ b/laf-cloud/functions/goldMiner/config.ts @@ -1,6 +1,7 @@ import { createHash } from "crypto"; export const DAY = 86400000; +export const UNLOCK_PASSED_LEVEL = 50; // 通关第50关后解锁,即进入第51关时可参与。 // Beijing 2026-10-01 starts the fixed fortnightly schedule. Earlier periods keep their historical dates. export const FORTNIGHT_START = Date.parse("2026-10-01T00:00:00+08:00"); export const MAX_TARGET = 1000; // Bounds the embedded dedup document, not a live activity value. diff --git a/laf-cloud/functions/goldMiner/service.ts b/laf-cloud/functions/goldMiner/service.ts index db80712..65df7ea 100644 --- a/laf-cloud/functions/goldMiner/service.ts +++ b/laf-cloud/functions/goldMiner/service.ts @@ -1,7 +1,7 @@ import * as ConfigStore from "@/activityConfig/store"; import cloud from "@lafjs/cloud"; import Utils from "@/Utils"; -import { TABLES as T, calendar, validatePeriodId, isTestPeriod, testPeriodsEnabled, validateConfig, selectVipConfig, paymentProductId, key, reject, open, integer, identifier, parseObject } from "@/goldMiner/config"; +import { TABLES as T, UNLOCK_PASSED_LEVEL, calendar, validatePeriodId, isTestPeriod, testPeriodsEnabled, validateConfig, selectVipConfig, paymentProductId, key, reject, open, integer, identifier, parseObject } from "@/goldMiner/config"; import { resolvePeriodDates, testPeriods } from "@/goldMiner/testPeriods"; export const collection = (name: string): any => (cloud.mongo.db as any).collection(name); @@ -68,7 +68,7 @@ export async function ensurePlayer(uid: string, user: any, period: any) { let stored = await collection(T.players).findOne({ _id: id }); if (stored?.tasks) return refreshPlayerOffer(id, user); if (!ConfigStore.isEnabled(period) && (!stored?.entitlement || stored.entitlement.state === "manual_review")) reject("PERIOD_DISABLED"); - if (Number(user?.levelAmount) < period.configSnapshot.unlockPassedLevel || !Number.isFinite(Number(user?.levelAmount))) reject("NOT_QUALIFIED"); + if (Number(user?.levelAmount) < UNLOCK_PASSED_LEVEL || !Number.isFinite(Number(user?.levelAmount))) reject("NOT_QUALIFIED"); const now = Date.now(); stored = stored || await insertOnce(T.players, { _id: id, uid, accountScope: "users", periodId: period.periodId, @@ -172,7 +172,7 @@ export async function recordProgress(uid: string, user: any, raw: any) { if (!period || period.periodId !== input.periodId) reject("PERIOD_ENDED"); if (input.mode === "main") { if (sequence !== Number(user.levelAmount)) reject("INVALID_INPUT", "通关号与本次保存的主线进度不一致"); - if (sequence <= period.configSnapshot.unlockPassedLevel) return { activityCounted: false, reason: "qualification_level", progressWins: row?.progressWins || 0 }; + if (sequence <= UNLOCK_PASSED_LEVEL) return { activityCounted: false, reason: "qualification_level", progressWins: row?.progressWins || 0 }; } row = await ensurePlayer(uid, user, period); await ensureEntitlement(uid, period.periodId); @@ -428,7 +428,7 @@ export async function activityInfo(uid: string, user: any) { serverTime: Date.now(), status: "unavailable", periodId: dates.periodId, startsAt: dates.startsAt, endsAt: dates.endsAt, tasks: [] }; - if (Number(user.levelAmount || 0) < period.configSnapshot.unlockPassedLevel) return { + if (Number(user.levelAmount || 0) < UNLOCK_PASSED_LEVEL) return { serverTime: Date.now(), status: "locked", periodId: period.periodId, tasks: [] }; await ensurePlayer(uid, user, period); diff --git a/laf-cloud/functions/goldMiner/tests/gold-miner.test.mjs b/laf-cloud/functions/goldMiner/tests/gold-miner.test.mjs index cba1c33..6e3a5b1 100644 --- a/laf-cloud/functions/goldMiner/tests/gold-miner.test.mjs +++ b/laf-cloud/functions/goldMiner/tests/gold-miner.test.mjs @@ -149,7 +149,7 @@ const embeddedGrants = () => table(R.TABLES.players).flatMap(p => (p.tasks || [] const control = () => { let c = table(R.TABLES.configs).find(c => c.recordType === 'control'); if (!c) { c = { _id: S.controlId('goldMiner:2026-09-17'), activityId: 'goldMiner', recordType: 'control', purchaseEnabled: true }; table(R.TABLES.configs).push(c); } return c; }; function reset(overrides = {}) { now = START + 1000; tables = {}; counter = 0; fault = null; beforeWrite = null; - table('users').push({ _id: 'u', token: 'token', openid: 'openid', session_key: 'session', levelAmount: 41, endLevelNum: 1, coinAmount: 500, isWhite: true, ...overrides }); + table('users').push({ _id: 'u', token: 'token', openid: 'openid', session_key: 'session', levelAmount: 50, endLevelNum: 1, coinAmount: 500, isWhite: true, ...overrides }); table(R.TABLES.configs).push(version(config)); process.env.PAYMENT_APP_ENV = 'production'; process.env.WX_MIDAS_PAY_SIGN_KEY = 'synthetic-key'; process.env.GOLD_MINER_ADMIN_TOKEN = 'admin-test'; @@ -163,10 +163,10 @@ const ctx = body => ({ body, headers: {}, socket: { remoteAddress: '127.0.0.1' } const call = (action, more = {}) => api(ctx({ action, uid: 'u', token: 'token', ...more })); const event = (i, mode = 'main', more = {}) => ({ periodId: R.calendar(now).periodId, eventId: 'event' + i, mode, outcome: 'win', - ...(mode === 'main' ? { clearedMainLevel: 41 + i } : { endlessSequence: i }), ...more + ...(mode === 'main' ? { clearedMainLevel: 50 + i } : { endlessSequence: i }), ...more }); async function win(i, mode = 'main', more = {}) { - if (mode === 'main') table('users')[0].levelAmount = 41 + i; + if (mode === 'main') table('users')[0].levelAmount = 50 + i; return S.recordProgress('u', user(), event(i, mode, more)); } const player = periodId => table(R.TABLES.players).find(p => p.periodId === (periodId || 'goldMiner:2026-09-17')); @@ -203,12 +203,12 @@ test('configuration rejects unsupported rewards, gaps, duplicate goals, invalid const c = copy(config); change(c); assert.throws(() => R.validateConfig(c)); } }); -test('authentication fails closed, account scope is fixed, and level 40 is locked', async () => { - reset({ levelAmount: 40 }); assert.equal((await call('info')).data.status, 'locked'); +test('authentication fails closed, account scope is fixed, and level 49 is locked', async () => { + reset({ levelAmount: 49 }); assert.equal((await call('info')).data.status, 'locked'); for (const extra of [{ token: '' }, { token: 'bad' }, { uid: 'other' }, { gameName: 'iaa' }]) assert.equal((await call('info', extra)).errorCode, 'UNAUTHORIZED'); delete table('users')[0].token; assert.equal((await call('info')).errorCode, 'UNAUTHORIZED'); }); -test('level 41 only unlocks; first main win creates a record without info or payment', async () => { +test('level 50 only unlocks; first main win creates a record without info or payment', async () => { reset(); const zero = await S.recordProgress('u', user(), event(0)); assert.equal(zero.activityCounted, false); assert.equal((await win(1)).progressWins, 1); assert.equal(player().entitlementStatus, 'none'); assert.equal(player().configSnapshot.tasks.length, 3); assert.equal(table('users')[0].coinAmount, 500); @@ -231,7 +231,7 @@ test('cutoff rejects new events, preserves previous success on retry, and never reset(); const original = event(1); await win(1); now = END; assert.equal((await S.recordProgress('u', user(), original)).duplicate, true); await assert.rejects(() => win(2), e => e.errorCode === 'PERIOD_ENDED'); now = NEXT; - await assert.rejects(() => S.recordProgress('u', user(), { ...original, eventId: 'late', clearedMainLevel: 43 }), e => e.errorCode === 'PERIOD_ENDED'); + await assert.rejects(() => S.recordProgress('u', user(), { ...original, eventId: 'late', clearedMainLevel: 52 }), e => e.errorCode === 'PERIOD_ENDED'); assert.equal(player().progressWins, 1); }); test('database time prevents an in-flight write after cutoff; no recovery record is created', async () => { @@ -363,10 +363,10 @@ test('legacy customer-service payment rejects calls without an authenticated pre assert.equal(r.errorCode, 'INVALID_INPUT'); assert.equal(table('order').length, 0); }); test('userLevel integration accepts form-encoded events and keeps ordinary save success on activity failure', async () => { - reset(); const e = event(1); const r = await saveLevel(ctx({ action: 'save', uid: 'u', token: 'token', levelAmount: '42', goldMiner: JSON.stringify(e) })); - assert.equal(r.code, 1); assert.equal(r.data.goldMiner.progressWins, 1); assert.equal(table('users')[0].levelAmount, 42); - now = END; const late = await saveLevel(ctx({ action: 'save', uid: 'u', token: 'token', levelAmount: 43, goldMiner: JSON.stringify(event(2)) })); - assert.equal(late.code, 1); assert.equal(late.data.goldMiner.errorCode, 'PERIOD_ENDED'); assert.equal(table('users')[0].levelAmount, 43); + reset(); const e = event(1); const r = await saveLevel(ctx({ action: 'save', uid: 'u', token: 'token', levelAmount: '51', goldMiner: JSON.stringify(e) })); + assert.equal(r.code, 1); assert.equal(r.data.goldMiner.progressWins, 1); assert.equal(table('users')[0].levelAmount, 51); + now = END; const late = await saveLevel(ctx({ action: 'save', uid: 'u', token: 'token', levelAmount: 52, goldMiner: JSON.stringify(event(2)) })); + assert.equal(late.code, 1); assert.equal(late.data.goldMiner.errorCode, 'PERIOD_ENDED'); assert.equal(table('users')[0].levelAmount, 52); }); test('admin is protected and can only publish immutable future configurations', async () => { reset(); assert.equal((await admin(ctx({ action: 'setup_indexes' }))).errorCode, 'UNAUTHORIZED'); @@ -683,13 +683,13 @@ test('career counter excludes the first 15 main levels for every assignment with test('unified career rule preserves independent main and endless gold miner progress', async () => { reset({ addLevel: 12, abTests: { layer_1: { name: 'new1_50', enabled: true, group: 'A' } } }); - const main = { action: 'save', uid: 'u', token: 'token', levelAmount: 42, goldMiner: event(1) }; + const main = { action: 'save', uid: 'u', token: 'token', levelAmount: 51, goldMiner: event(1) }; let r = await saveLevel(ctx(main)); assert.equal(r.data.goldMiner.progressWins, 1); assert.equal(user().addLevel, 13); r = await saveLevel(ctx(main)); assert.equal(r.data.goldMiner.duplicate, true); assert.equal(player().progressWins, 1); assert.equal(user().addLevel, 13); r = await saveLevel(ctx({ ...main, isWuXian: 'true', goldMiner: event(2, 'endless') })); - assert.equal(r.data.goldMiner.progressWins, 2); assert.equal(user().addLevel, 14); assert.equal(user().levelAmount, 42); + assert.equal(r.data.goldMiner.progressWins, 2); assert.equal(user().addLevel, 14); assert.equal(user().levelAmount, 51); }); @@ -702,7 +702,7 @@ test('info combines activity states and removes legacy flags from every response } reset(); table(R.TABLES.configs)[0].status = 'draft'; assert.deepEqual((await expectState('unavailable')).tasks, []); reset(); now = END; assert.deepEqual((await expectState('unavailable')).tasks, []); - reset({ levelAmount: 40 }); assert.deepEqual((await expectState('locked')).tasks, []); + reset({ levelAmount: 49 }); assert.deepEqual((await expectState('locked')).tasks, []); reset(); await win(1); await expectState('purchasable'); control().purchaseEnabled = false; let info = await expectState('purchase_disabled'); assert.equal(info.progressWins, 1); assert.equal(info.tasks[0].claimStatus, 'pending_unlock'); @@ -988,9 +988,9 @@ test('Monday temporary period uses its exact boundaries for info, main/endless p now = TEMP_START; const info = (await call('info')).data; assert.equal(info.periodId, TEMP); assert.equal(info.startsAt, TEMP_START); assert.equal(info.endsAt, TEMP_END); assert.equal(info.status, 'purchasable'); - const main = await saveLevel(ctx({ action: 'save', uid: 'u', token: 'token', levelAmount: 42, goldMiner: event(1, 'main', { periodId: TEMP }) })); + const main = await saveLevel(ctx({ action: 'save', uid: 'u', token: 'token', levelAmount: 51, goldMiner: event(1, 'main', { periodId: TEMP }) })); assert.equal(main.data.goldMiner.activityCounted, true); - const endless = await saveLevel(ctx({ action: 'save', uid: 'u', token: 'token', levelAmount: 42, isWuXian: 'true', goldMiner: event(2, 'endless', { periodId: TEMP }) })); + const endless = await saveLevel(ctx({ action: 'save', uid: 'u', token: 'token', levelAmount: 51, isWuXian: 'true', goldMiner: event(2, 'endless', { periodId: TEMP }) })); assert.equal(endless.data.goldMiner.activityCounted, true); assert.equal(player(TEMP).progressWins, 2); const order = await buy(); assert.equal(order.goldMiner.periodId, TEMP); assert.equal(order.goldMiner.endsAt, TEMP_END); await pay(order); assert.equal((await call('info')).data.status, 'unlocked'); diff --git a/laf-cloud/tests/activity-unlock-levels.test.mjs b/laf-cloud/tests/activity-unlock-levels.test.mjs new file mode 100644 index 0000000..6ff846a --- /dev/null +++ b/laf-cloud/tests/activity-unlock-levels.test.mjs @@ -0,0 +1,139 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +const functionsUrl = new URL('../functions/', import.meta.url).href; +const moduleUrl = name => new URL(name + '.ts', functionsUrl).href; +const R = await import(moduleUrl('goldMiner/config')); +const NOW = Date.parse('2026-10-02T00:00:00+08:00'); +const clone = value => structuredClone(value); +const players = new Map(); +let temporary = [], writes = 0; +const legacyConfig = { + configVersion: 'legacy41', effectiveFromPeriodId: 'goldMiner:2026-10-01', publishedAt: NOW - 2 * R.DAY, + unlockPassedLevel: 41, timezone: 'Asia/Shanghai', currency: 'CNY', productId: 'gold_miner', priceFen: 100, + tasks: [{ taskId: 'task1', sequence: 1, targetWins: 10, items: [{ type: 'coin', count: 100 }] }] +}; +const legacyVersion = { enabled: true, configVersion: legacyConfig.configVersion, + publishedAt: legacyConfig.publishedAt, config: legacyConfig }; +const mocks = { + cloud: { mongo: { db: { collection(name) { + assert.ok(['activityConfigs', R.TABLES.players].includes(name), 'unlock checks must not touch orders or balances'); + return { + async findOne(query) { return name === R.TABLES.players ? clone(players.get(query._id) || null) : null; }, + async updateOne(query, update, options = {}) { + assert.equal(name, R.TABLES.players); + let row = players.get(query._id); + if (!row && options.upsert) { + row = { _id: query._id, ...clone(update.$setOnInsert) }; + players.set(query._id, row); writes++; + return { modifiedCount: 0, upsertedCount: 1 }; + } + if (!row || (query.revision !== undefined && row.revision !== query.revision)) return { modifiedCount: 0 }; + if (query.$expr && !(Date.now() < query.$expr.$lt[1].getTime())) return { modifiedCount: 0 }; + Object.assign(row, clone(update.$set || {})); + for (const [key, value] of Object.entries(update.$inc || {})) row[key] = (row[key] || 0) + value; + writes++; return { modifiedCount: 1 }; + } + }; + } } } }, + store: { latest: async () => clone(legacyVersion), isEnabled: value => !!value && value.enabled !== false }, + periods: { + testPeriods: async () => clone(temporary), + resolvePeriodDates: async id => R.isTestPeriod(id) ? clone(temporary.find(p => p.periodId === id)) : R.periodDates(id) + } +}; +globalThis.__activityUnlockMocks = mocks; +// The existing missing paymentProductId export otherwise prevents service.ts from loading. +// Only provide a fail-fast placeholder: these cases do not exercise payment offer refreshes. +const configShim = `export * from ${JSON.stringify(moduleUrl('goldMiner/config'))};` + + ('paymentProductId' in R ? '' : 'export function paymentProductId() { throw Error("payment offer refresh is outside unlock tests"); }'); +const modules = { + '@lafjs/cloud': 'export default globalThis.__activityUnlockMocks.cloud;', + '@/Utils': 'export default { checkToken: (a, b) => a === b };', + '@/goldMiner/config': configShim, + '@/activityConfig/store': 'export const { latest, isEnabled } = globalThis.__activityUnlockMocks.store;', + '@/goldMiner/testPeriods': 'export const { testPeriods, resolvePeriodDates } = globalThis.__activityUnlockMocks.periods;', + '@/cloudRise/periods': 'export const openPeriodForUser = () => null, openingRuleForUser = async () => ({});' +}; +const hooks = registerHooks({ resolve(specifier, context, next) { + if (modules[specifier]) return { url: 'data:text/javascript,' + encodeURIComponent(modules[specifier]), shortCircuit: true }; + if (specifier.startsWith('@/')) return { url: moduleUrl(specifier.slice(2)), shortCircuit: true }; + return next(specifier, context); +} }); +const S = await import(moduleUrl('goldMiner/service')); +const { activityList } = await import(moduleUrl('activityConfig/list')); +hooks.deregister(); +const realNow = Date.now; +test.beforeEach(() => { players.clear(); temporary = []; writes = 0; Date.now = () => NOW; }); +test.afterEach(() => { Date.now = realNow; }); +test.after(() => { delete globalThis.__activityUnlockMocks; }); +const user = levelAmount => ({ _id: 'unlock-user', levelAmount }); +const period = () => S.periodById(R.calendar(NOW).periodId); +const victory = level => ({ periodId: R.calendar(NOW).periodId, eventId: 'win-' + level, + mode: 'main', outcome: 'win', clearedMainLevel: level }); + +function historicalPlayer() { + const configSnapshot = R.validateConfig(legacyConfig), dates = R.calendar(NOW); + const row = { + _id: S.playerId('unlock-user', dates.periodId), uid: 'unlock-user', ...dates, revision: 7, + configSnapshot, configVersion: configSnapshot.configVersion, configHash: R.key(configSnapshot), + qualifiedPassedLevel: 41, progressWins: 7, progressDedup: { entries: [] }, + entitlementStatus: 'unlocked', paidOrderNo: 'existing-paid-order', claimedThrough: 1, + tasks: [{ taskId: 'task1', targetWins: 10, itemsSnapshot: [{ type: 'coin', count: 100 }], claimStatus: 'claimed', grantId: 'existing-grant' }], + settlementState: 'client_saved', settlementAcknowledgedAt: NOW - 1000 + }; + players.set(row._id, row); return row; +} + +test('49 cleared levels remain locked despite a legacy 41-level configuration', async () => { + const before = clone(legacyVersion); + assert.equal((await S.activityInfo('unlock-user', user(49))).status, 'locked'); + await assert.rejects(S.ensurePlayer('unlock-user', user(49), await period()), { errorCode: 'NOT_QUALIFIED' }); + assert.equal(players.size, 0); assert.equal(writes, 0); assert.deepEqual(legacyVersion, before); +}); + +test('50 cleared levels allow joining without rewriting legacy reward snapshots', async () => { + const info = await S.activityInfo('unlock-user', user(50)); + assert.equal(info.status, 'purchasable'); assert.equal(info.progressWins, 0); + const row = [...players.values()][0]; + assert.equal(row.qualifiedPassedLevel, 50); + assert.deepEqual(row.configSnapshot, R.validateConfig(legacyConfig)); + assert.equal(row.configSnapshot.unlockPassedLevel, 41); + assert.equal(row.configHash, R.key(row.configSnapshot)); +}); + +test('qualification win 50 is excluded and win 51 is the first activity win', async () => { + assert.deepEqual(await S.recordProgress('unlock-user', user(50), victory(50)), + { activityCounted: false, reason: 'qualification_level', progressWins: 0 }); + assert.equal(players.size, 0); assert.equal(writes, 0); + assert.deepEqual(await S.recordProgress('unlock-user', user(51), victory(51)), + { activityCounted: true, duplicate: false, progressWins: 1 }); + const row = [...players.values()][0]; + assert.equal(row.configSnapshot.unlockPassedLevel, 41); + assert.equal(row.progressDedup.entries[0].businessKey, 'main:51'); +}); + +test('regular and temporary lists advertise 50 without modifying configurations or history', async () => { + historicalPlayer(); + for (const isTemporary of [false, true]) { + temporary = isTemporary ? [{ periodId: 'goldMiner:test:unlock', startsAt: NOW - 1000, + endsAt: NOW + R.DAY, enabled: true, configVersion: 'legacy41', configSnapshot: clone(legacyConfig) }] : []; + const before = clone({ legacyVersion, temporary, players: [...players.values()] }); + const result = await activityList(NOW, user(49)); + assert.equal(result.activities.length, 1); + assert.equal(result.activities[0].periodId, isTemporary ? temporary[0].periodId : R.calendar(NOW).periodId); + assert.equal(result.activities[0].unlockLevel, 50); + assert.deepEqual({ legacyVersion, temporary, players: [...players.values()] }, before); + assert.equal(writes, 0); + } +}); + +test('existing paid progress, claims and snapshots survive the new unlock threshold', async () => { + const row = historicalPlayer(), before = clone(row); + assert.equal((await S.activityInfo('unlock-user', user(49))).status, 'locked'); + const info = await S.activityInfo('unlock-user', user(50)); + assert.equal(info.status, 'unlocked'); assert.equal(info.progressWins, 7); + assert.equal(info.tasks[0].claimStatus, 'claimed'); + assert.deepEqual(players.get(row._id), before); assert.equal(writes, 0); +});