Set gold miner unlock to cleared level 50

This commit is contained in:
COMPUTER\EDY 2026-10-09 20:15:02 +08:00
parent 00735b4c5e
commit 4ee546591e
8 changed files with 166 additions and 26 deletions

View File

@ -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,
});
}

View File

@ -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']);

View File

@ -3,7 +3,7 @@
"status": "draft",
"effectiveFromPeriodId": null,
"publishedAt": null,
"unlockPassedLevel": 41,
"unlockPassedLevel": 50,
"timezone": "Asia/Shanghai",
"productId": "gold_miner",
"priceFen": null,

View File

@ -5,7 +5,7 @@
"config": {
"configVersion": "goldMinerFortnightV1",
"effectiveFromPeriodId": "goldMiner:2026-10-01",
"unlockPassedLevel": 41,
"unlockPassedLevel": 50,
"timezone": "Asia/Shanghai",
"productId": "gold_miner",
"priceFen": 100,

View File

@ -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.

View File

@ -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);

View File

@ -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');

View File

@ -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);
});