import test from 'node:test'; import assert from 'node:assert/strict'; import { isDeepStrictEqual } from 'node:util'; import { readFile } from 'node:fs/promises'; import { registerHooks } from 'node:module'; import * as Rules from '../rules.ts'; const stats = JSON.parse(await readFile(new URL('../data/cloudRiseLevelStats.json', import.meta.url))).levels; const profile = { username: '测试玩家', useravatar: '', useravatarIcon: 'icon_9' }; function runWith(scores = Array(99).fill(5)) { const stage = Rules.matchStage(1, 100, 1000, stats, {}, [profile], () => 0.5); stage.opponents = scores.map(success_num => ({ ...profile, start_level: 100, success_num })); return { id: 'run', periodId: 'p1', status: 'playing', stage: 1, startedAt: 1000, expiresAt: 1000 + Rules.DURATION, stages: [stage] }; } test('truncated discrete normal has half its mass within 100 at edge and interior centers', () => { for (const center of [1, 2, 50, 100, 101, 500, 1960, 3000]) { assert.ok(Math.abs(Rules.centralMass(center, Rules.sigmaFor(center)) - 0.5) < 1e-9); } assert.notEqual(Rules.sigmaFor(100), Rules.sigmaFor(1000)); }); test('start weights equal entry counts times Gaussian, keep zero weights and 1960 bound', () => { const weights = Rules.startWeights(100, stats), sigma = Rules.sigmaFor(100); assert.equal(weights.length, 1960); assert.ok(weights.slice(1920).every(x => x === 0)); const ratio = stats[98].enter_total / stats[99].enter_total * Math.exp(-1 / (2 * sigma * sigma)); assert.ok(Math.abs(weights[98] / weights[99] - ratio) < 1e-10); assert.equal(Rules.weightedIndex([0, 1, 0], () => 0), 1); }); test('histogram preserves duplicate sample probability; sample zero is not missing', () => { const histogram = [1, 0, 2, 0, 0, 1]; assert.deepEqual([0, 0.249, 0.25, 0.749, 0.75, 0.999].map(x => Rules.sampleScore(100, 5, histogram, stats, () => x)), [0, 0, 2, 2, 5, 5]); assert.equal(Rules.sampleScore(100, 5, [3, 0, 0, 0, 0, 0], stats, () => 0.9), 0); }); test('cold simulation stops at first failure and uses preceding non-null rate', () => { const rows = [{ level: 1, enter_total: 1, pass_rate: 0.5 }, { level: 2, enter_total: 1, pass_rate: null }]; const draws = [0.1, 0.2, 0.7, 0.1]; assert.equal(Rules.sampleScore(1, 5, undefined, rows, () => draws.shift()), 2); assert.equal(draws.length, 1); assert.equal(Rules.rateAt(1968, stats), 0.818); assert.equal(Rules.sampleScore(1960, 9, undefined, stats, () => 0), 9); }); test('one stage contains 99 pairs and has 100 initial survivors including zero-score opponents', () => { const stage = Rules.matchStage(2, 100, 0, stats, {}, [profile], () => 0.99999); assert.equal(stage.opponents.length, 99); assert.equal(stage.survivors, 100); for (const o of stage.opponents) { assert.ok(o.start_level >= 1 && o.start_level <= 1960); assert.ok(o.success_num >= 0 && o.success_num <= 7); } const exposed = Rules.publicRun(runWith()); assert.equal('success_num' in exposed.stages[0].opponents[0], false); assert.equal('start_level' in exposed.stages[0].opponents[0], false); }); test('win progression leaves reward to client; duplicate settlement and manual next stage', () => { const run = runWith([5, 5, ...Array(97).fill(0)]); for (let i = 1; i <= 5; i++) { Rules.beginAttempt(run, 'a' + i); assert.equal(Rules.finishAttempt(run, 'a' + i, 'win', 2000), 0); } assert.equal(run.status, 'waiting'); assert.equal(run.stage, 1); assert.equal(run.stages[0].survivors, 3); assert.equal(Rules.finishAttempt(run, 'a5', 'win', 3000), 0); assert.throws(() => Rules.beginAttempt(run, 'a6')); }); test('fail before ten ends whole run; expiration does not fabricate a lost sample', () => { const run = runWith(); Rules.beginAttempt(run, 'a1'); Rules.finishAttempt(run, 'a1', 'win', 2000); Rules.beginAttempt(run, 'a2'); Rules.finishAttempt(run, 'a2', 'interrupted', 3000); assert.equal(run.status, 'failed'); assert.equal(run.stages[0].success_num, 1); const timed = runWith(); Rules.beginAttempt(timed, 'a1'); assert.equal(Rules.expire(timed, timed.expiresAt), true); assert.equal(timed.stages[0].status, 'expired'); assert.equal(timed.stages[0].results.length, 0); }); test('a missing or non-string attempt identifier cannot settle an unstarted round', () => { for (const id of [undefined, null, '', 0, 1, {}, 'x'.repeat(101)]) { const run = runWith(); assert.throws(() => Rules.beginAttempt(run, id)); assert.throws(() => Rules.finishAttempt(run, id, 'win', 2000)); assert.equal(run.stages[0].success_num, 0); } }); const db = { users: new Map(), activityConfigs: new Map(), cloudRiseSamples: new Map(), cloudRiseRuns: new Map() }; // Fixtures use the real shared collection envelope; helpers expose only the activity payload for scenario edits. const configs = { get: id => db.activityConfigs.get(id)?.config, set(id, config) { db.activityConfigs.set(id,{_id:id,activityId:'cloudRise',recordType:'version',status:'published',configVersion:id, effectiveFrom:config.startsAt,publishedAt:Math.min(Date.now(),config.startsAt)-1,config:structuredClone(config)}); }, clear:()=>db.activityConfigs.clear(), values:()=>[...db.activityConfigs.values()].map(r=>r.config).values(), get size(){return db.activityConfigs.size;} }; let sampleFails = false, sampleAppendFails = 0; const profiles = [profile]; globalThis.__cloudRiseTestProfiles = profiles; const indexes = []; function field(doc, path) { const parts = path.split('.'); const read = (value, i) => i === parts.length ? value : Array.isArray(value) ? value.flatMap(item => read(item, i)) : read(value?.[parts[i]], i + 1); return read(doc, 0); } function matches(doc, query) { return Object.entries(query).every(([k, v]) => { if (k === '$or') return v.some(q => matches(doc, q)); const actual = field(doc, k); if (!v || typeof v !== 'object') return actual === v; if (!Object.keys(v).some(key => key.startsWith('$'))) return isDeepStrictEqual(actual, v); return Object.entries(v).every(([op, value]) => { if (op === '$exists') return (actual !== undefined) === value; if (op === '$elemMatch') return Array.isArray(actual) && actual.some(item => matches(item, value)); if (op === '$ne') return Array.isArray(actual) ? actual.every(item => item !== value) : actual !== value; const compare = item => { if (op === '$lte') return item <= value; if (op === '$gte') return item >= value; if (op === '$gt') return item > value; if (op === '$in') return value.includes(item); throw new Error('unsupported query operator: ' + op); }; return Array.isArray(actual) ? actual.some(compare) : compare(actual); }); }); } function expression(expr, doc) { if (typeof expr === 'string' && expr.startsWith('$')) return field(doc, expr.slice(1)); if (Array.isArray(expr)) return expr.map(v => expression(v, doc)); if (!expr || typeof expr !== 'object') return expr; if ('$isArray' in expr) return Array.isArray(expression(expr.$isArray, doc)); if ('$ifNull' in expr) return expression(expr.$ifNull[0], doc) ?? expression(expr.$ifNull[1], doc); if ('$cond' in expr) return expression(expr.$cond[expression(expr.$cond[0], doc) ? 1 : 2], doc); return Object.fromEntries(Object.entries(expr).map(([key, value]) => [key, expression(value, doc)])); } function aggregate(rows, pipeline) { for (const step of pipeline) { if (step.$match) rows = rows.filter(row => matches(row, step.$match)); else if (step.$project) rows = rows.map(row => Object.fromEntries(Object.entries(step.$project) .filter(([, value]) => value !== 0).map(([key, value]) => [key, value === 1 ? row[key] : expression(value, row)]))); else if (step.$unwind) { const path = step.$unwind.slice(1); rows = rows.flatMap(row => (field(row, path) || []).map(value => ({ ...row, [path]: value }))); } else if (step.$group) { const groups = new Map(); for (const row of rows) { const id = expression(step.$group._id, row), key = JSON.stringify(id); const group = groups.get(key) || { _id: id }; for (const [name, value] of Object.entries(step.$group)) { if (name === '_id') continue; if ('$first' in value && !(name in group)) group[name] = expression(value.$first, row); else if ('$sum' in value) group[name] = (group[name] || 0) + expression(value.$sum, row); } groups.set(key, group); } rows = [...groups.values()]; } else throw new Error('unsupported aggregation step'); } return rows; } globalThis.__cloudRiseCloud = { mongo: { db: { collection(name) { return { async insertOne(row) { if(db[name].has(row._id)) throw Error("duplicate key"); db[name].set(row._id,structuredClone(row)); return {insertedId:row._id}; }, async findOne(query, options) { const rows = [...db[name].values()].filter(d => matches(d, query)); if (options?.sort) rows.sort((a, b) => { for (const [k, direction] of Object.entries(options.sort)) { if (field(a,k) !== field(b,k)) return (field(a,k) < field(b,k) ? -1 : 1) * direction; } return 0; }); return structuredClone(rows[0] || null); }, aggregate(pipeline) { return { async toArray() { if (name === 'users') assert.fail('Matching must use the stats snapshot, not query the live users pool'); return aggregate([...db[name].values()], pipeline); } }; }, find(query) { let rows = [...db[name].values()].filter(row => matches(row, query)); return { sort(spec) { rows.sort((a,b)=>{for(const [key,direction] of Object.entries(spec)){if(field(a,key)!==field(b,key))return(field(a,key) matches(doc, query)); return { deletedCount: row && db[name].delete(row._id) ? 1 : 0 }; }, async updateOne(query, update, options) { if (name === 'cloudRiseSamples' && sampleFails) throw new Error('simulated sample write outage'); if (name === 'cloudRiseSamples' && update.$push && sampleAppendFails > 0) { sampleAppendFails--; throw Error('simulated append outage'); } const doc = [...db[name].values()].find(d => matches(d, query)); if (!doc && options?.upsert) { const id = query._id || 'generated-' + db[name].size; if (db[name].has(id)) throw new Error('duplicate key'); db[name].set(id, { _id: id, ...query, ...structuredClone(update.$setOnInsert || update.$set) }); return { modifiedCount: 0, upsertedCount: 1 }; } if (!doc) return { modifiedCount: 0, matchedCount: 0 }; Object.assign(doc, structuredClone(update.$set || {})); for (const [k, v] of Object.entries(update.$push || {})) (doc[k] ||= []).push(structuredClone(v)); for (const k of Object.keys(update.$unset || {})) { if (k === 'stages.$[].opponents') doc.stages.forEach(stage => { delete stage.opponents; }); else delete doc[k]; } for (const [k, v] of Object.entries(update.$inc || {})) doc[k] = (doc[k] || 0) + v; return { modifiedCount: 1, matchedCount: 1 }; }, async updateMany(query, update) { let count = 0; for (const row of [...db[name].values()]) if (matches(row, query)) { await this.updateOne({ _id: row._id }, update); count++; } return { matchedCount: count, modifiedCount: count }; }, listIndexes() { return { async toArray() { return indexes.filter(i => i.name === name).map(i => ({ key: i.spec, ...i.options })); } }; }, async createIndex(spec, options) { if (name === 'users' && spec.onlyId === 1 && options?.unique) { const ids = [...db.users.values()].map(user => user.onlyId).filter(id => typeof id === 'number'); if (new Set(ids).size !== ids.length) throw Object.assign(new Error('duplicate onlyId'), { code: 11000 }); } const existing = indexes.find(i => i.name === name && isDeepStrictEqual(i.spec, spec)); if (existing) { if (!isDeepStrictEqual(existing.options, options)) throw new Error('IndexOptionsConflict'); return 'test_index'; } indexes.push({ name, spec, options }); return 'test_index'; }, }; } } } }; globalThis.__cloudRiseCloud.database = () => ({ collection(name) { return { where(query) { return { async getOne() { return { data: await globalThis.__cloudRiseCloud.mongo.db.collection(name).findOne(query) }; }, async update(value) { return globalThis.__cloudRiseCloud.mongo.db.collection(name).updateOne(query, { $set: value }); }, }; } }; } }); const analyticsReports = []; let analyticsOffline = false, analyticsOnSend = null; globalThis.__cloudRiseAnalyticsSend = async events => { if (analyticsOffline) throw Error('simulated analytics outage'); analyticsReports.push(...structuredClone(events)); const hook = analyticsOnSend; analyticsOnSend = null; if (hook) await hook(); return events.map(event => event.id); }; const hooks = registerHooks({ resolve(specifier, context, next) { if (specifier === '@lafjs/cloud') return { url: 'cloudrise:cloud', shortCircuit: true }; if (specifier === '@/activityConfig/store') return {url:new URL('../../activityConfig/store.ts',import.meta.url).href,shortCircuit:true}; if (specifier === '@/Utils') return { url: 'cloudrise:utils', shortCircuit: true }; if (specifier === '@/cloudRise/analytics') return { url: 'cloudrise:analytics', shortCircuit: true }; if (specifier === '@/cloudRise/periods') return {url:new URL('../periods.ts',import.meta.url).href,shortCircuit:true}; if (specifier === '@/cloudRise/rules') return { url: new URL('../rules.ts', import.meta.url).href, shortCircuit: true }; if (specifier === '@/cloudRise/stats') return { url: 'cloudrise:stats', shortCircuit: true }; if (specifier === '@/cloudRise/index') return { url: new URL('../index.ts', import.meta.url).href, shortCircuit: true }; if (specifier === '@/jungleConfig') return { url: new URL('../../jungleConfig.ts', import.meta.url).href, shortCircuit: true }; return next(specifier, context); }, load(url, context, next) { if (url === 'cloudrise:cloud') return { format: 'module', source: 'export default globalThis.__cloudRiseCloud;', shortCircuit: true }; if (url === 'cloudrise:stats') return { format: 'module', shortCircuit: true, source: 'export {LEVEL_STATS} from ' + JSON.stringify(new URL('../stats.ts', import.meta.url).href) + '; export const OPPONENT_PROFILES = globalThis.__cloudRiseTestProfiles;' }; if (url === 'cloudrise:analytics') return { format: 'module', shortCircuit: true, source: 'export {collect} from ' + JSON.stringify(new URL('../analytics.ts', import.meta.url).href) + '; export const send = (...args) => globalThis.__cloudRiseAnalyticsSend(...args);' }; if (url === 'cloudrise:utils') return { format: 'module', source: 'export default {checkToken:(a,b)=>a===b};', shortCircuit: true }; return next(url, context); }, }); const { default: api, inactiveBefore, sampleHistogramPipeline, findOpenPeriod } = await import('../index.ts'); const { default: coinApi } = await import('../../userCoin.ts'); const { default: adminApi } = await import('../admin.ts'); const PersonalPeriods = await import('../periods.ts'); const realClock=Date.now; test.afterEach(()=>{Date.now=realClock;}); const openPeriod=(onlyId,now=Date.now())=>PersonalPeriods.openForUser(onlyId,now); function afterCooldown(){const at=PersonalPeriods.lastEndedAt(db.users.get('u'),Date.now())+PersonalPeriods.COOLDOWN_MS+1;Date.now=()=>at;} hooks.deregister(); function setup() { analyticsReports.length = 0; analyticsOffline = false; analyticsOnSend = null; Object.values(db).forEach(m => m.clear()); sampleFails = false; sampleAppendFails = 0; indexes.length = 0; profiles.splice(0, profiles.length, profile); db.users.set('u', { _id: 'u', onlyId: 1001, token: 'token', levelAmount: 99, coinAmount: 100 }); configs.set('current', { _id: 'current', periodId: 'p1', durationHours: 24, startsAt: Date.now() - 1000, endsAt: Date.now() + 600000, unlockLevel: 1 }); } // Existing gameplay scenarios use a deterministic personal-period fixture. Login behavior is tested below. const call = async body => { const user = db.users.get('u'); if (user && !user.cloudRisePeriod) { const period = await findOpenPeriod(Date.now()); if (period) user.cloudRisePeriod = structuredClone(period); } return api({ body: { uid: '1001', token: 'token', ...body } }); }; const saveCoins = body => coinApi({ body: { uid: 'u', token: 'token', action: 'save', ...body } }); function forceScores(score = 5) { const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState); state.run.stages.at(-1).opponents.forEach(o => { o.success_num = score; }); user.cloudRiseState = JSON.stringify(state); } async function winStage(run, stage, count) { for (let i = 1; i <= count; i++) { const attemptId = `s${stage}-${i}`; assert.equal((await call({ action: 'begin', runId: run.id, stage, attemptId })).code, 1); const result = await call({ action: 'finish', runId: run.id, stage, attemptId, outcome: 'win' }); assert.equal(result.code, 1); } } test('API atomic signup, fixed opponents and manual stage start after registration closes', async () => { setup(); const [a,b] = await Promise.all([call({ action: 'start', periodId: 'p1' }), call({ action: 'start', periodId: 'p1' })]); assert.equal(a.code, 1); assert.equal(b.code, 1); assert.deepEqual(a.data.run, b.data.run); forceScores(); await winStage(a.data.run, 1, 5); let result = await call({ action: 'status' }); assert.equal(result.data.run.status, 'waiting'); assert.equal(result.data.run.stages.length, 1); const expiresAt = result.data.run.expiresAt; configs.get('current').endsAt = Date.now() - 1; db.users.get('u').levelAmount = 110; result = await call({ action: 'start_stage', runId: a.data.run.id, stage: 2 }); assert.equal(result.code, 1); assert.equal(result.data.run.stages[1].start_level, 111); assert.equal(result.data.run.expiresAt, expiresAt); const duplicate = await call({ action: 'start_stage', runId: a.data.run.id, stage: 2 }); assert.deepEqual(duplicate.data.run, result.data.run); }); test('API concurrent finish records success without issuing coins; samples survive write failures', async () => { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); forceScores(); await winStage(data.run, 1, 4); await call({ action: 'begin', runId: data.run.id, stage: 1, attemptId: 'last' }); sampleFails = true; const body = { action: 'finish', runId: data.run.id, stage: 1, attemptId: 'last', outcome: 'win' }; const results = await Promise.all([call(body), call(body)]); assert.ok(results.every(r => r.code === 1 && r.data.sampleSyncPending)); assert.equal(db.users.get('u').coinAmount, 100); assert.equal(db.users.get('u').cloudRiseCoinTotal, undefined); sampleFails = false; await call({ action: 'status' }); await call(body); assert.equal(db.cloudRiseSamples.size, 1); assert.equal(db.users.get('u').coinAmount, 100); }); test('API interrupted first round consumes period, new period can start; expiry adds no sample', async () => { setup(); let result = await call({ action: 'start', periodId: 'p1' }); await call({ action: 'begin', runId: result.data.run.id, stage: 1, attemptId: 'first' }); await call({ action: 'finish', runId: result.data.run.id, stage: 1, attemptId: 'first', outcome: 'interrupted' }); assert.equal((await call({ action: 'start', periodId: 'p1' })).code, 0); assert.equal([...db.cloudRiseSamples.values()][0].samples[0].success_num, 0); configs.get('current').periodId = 'p2'; db.users.get('u').cloudRisePeriod.periodId = 'p2'; result = await call({ action: 'start', periodId: 'p2' }); assert.equal(result.code, 1); const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState); state.run.expiresAt = Date.now() - 1; user.cloudRiseState = JSON.stringify(state); result = await call({ action: 'status' }); assert.equal(result.data.run.status, 'expired'); assert.equal(db.cloudRiseSamples.size, 1); }); test('two-month inactive cutoff preserves dates and clamps at month-end', () => { assert.equal(new Date(inactiveBefore(Date.UTC(2026,4,31))).toISOString(), '2026-03-31T00:00:00.000Z'); assert.equal(new Date(inactiveBefore(Date.UTC(2026,3,30))).toISOString(), '2026-02-28T00:00:00.000Z'); }); test('all three stages complete in sequence with three independent samples and one grant each', async () => { setup(); let result = await call({ action: 'start', periodId: 'p1' }); const runId = result.data.run.id, deadline = result.data.run.expiresAt; for (let stage = 1; stage <= 3; stage++) { if (stage > 1) { result = await call({ action: 'start_stage', runId, stage }); assert.equal(result.code, 1); assert.equal(result.data.run.expiresAt, deadline); } forceScores([5, 7, 9][stage - 1]); await winStage(result.data.run, stage, [5, 7, 9][stage - 1]); result = await call({ action: 'status' }); const completed = result.data.run.stages.at(-1); const reward = Math.ceil(completed.pool / completed.survivors); result = await call({ action: 'save_reward', runId, stage, reward, coinAmount: db.users.get('u').coinAmount + reward }); assert.equal(result.code, 1); assert.equal(result.data.run.status, stage === 3 ? 'completed' : 'waiting'); assert.equal(db.cloudRiseSamples.size, 1); assert.equal(db.cloudRiseSamples.get(runId).samples.length, stage); } assert.equal(result.data.coinAmount, 550); assert.equal('cloudRiseCoinTotal' in result.data, false); assert.equal(db.cloudRiseRuns.size, 1); await call({ action: 'finish', runId, stage: 1, attemptId: 's1-5', outcome: 'win' }); assert.equal(db.users.get('u').coinAmount, 550); assert.equal((await call({ action: 'start', periodId: 'p1' })).code, 0); }); test('empty snapshot does not consume eligibility; matching excludes self and strips private profile fields', async () => { setup(); profiles.length = 0; assert.equal((await call({ action: 'start', periodId: 'p1' })).code, 0); assert.equal(db.users.get('u').cloudRiseState, undefined); profiles.push({ ...profile, onlyId: 1001, username: 'self' }, { ...profile, onlyId: 2002 }); const result = await call({ action: 'start', periodId: 'p1' }); assert.equal(result.code, 1); assert.equal(result.data.run.stages[0].opponents[0].useravatarIcon, 'icon_9'); for (const opponent of result.data.run.stages[0].opponents) { assert.equal(opponent.username, profile.username); assert.equal(opponent.onlyId, undefined); assert.equal(opponent.taskTime, undefined); } }); test('legacy coin save remains compatible before any award', async () => { setup(); const result = await saveCoins({ coinAmount: 42 }); assert.equal(result.code, 1); assert.equal(db.users.get('u').coinAmount, 42); }); test('admin requires configured secret, validates periods, and preserves enrolled deadline', async () => { setup(); delete process.env.ADMIN_TOKEN; assert.equal((await adminApi({ body: { action: 'save' } })).code, 0); process.env.ADMIN_TOKEN = 'test-only'; try { assert.equal((await adminApi({ body: { action: 'read', adminToken: 'wrong' } })).code, 0); assert.equal((await adminApi({ body: { action: 'save', adminToken: 'test-only', config: '{}' } })).code, 0); const { data } = await call({ action: 'start', periodId: 'p1' }); const config = { periodId: 'p2', configVersion: 'future-v1', startsAt: Date.now()+86400000, endsAt: Date.now()+2*86400000, unlockLevel: 1 }; assert.equal((await adminApi({ body: { action: 'save', adminToken: 'test-only', config: JSON.stringify(config) } })).code, 1); assert.equal((await call({ action: 'status' })).data.run.expiresAt, data.run.expiresAt); assert.equal((await adminApi({ body: { action: 'setup_indexes', adminToken: 'test-only' } })).code, 1); } finally { delete process.env.ADMIN_TOKEN; } }); test('index setup tolerates duplicate onlyId, prepares all indexes and can be retried', async () => { setup(); process.env.ADMIN_TOKEN = 'test-only'; db.users.set('duplicate', { _id: 'duplicate', onlyId: 1001, token: 'other-token' }); const before = structuredClone([...db.users]); const request = { body: { action: 'setup_indexes', adminToken: 'test-only' } }; try { assert.equal((await adminApi(request)).code, 1); assert.equal(indexes.length, 8); const lookup = indexes.find(i => i.name === 'users' && i.spec.onlyId === 1); assert(lookup && !lookup.options?.unique); assert(indexes.some(i => i.name === 'activityConfigs' && i.options?.unique)); assert(indexes.some(i => i.name === 'cloudRiseSamples' && i.options?.unique)); assert(indexes.some(i => i.name === 'cloudRiseRuns')); assert.equal((await adminApi(request)).code, 1); assert.equal(indexes.length, 8); assert.deepEqual([...db.users], before); } finally { delete process.env.ADMIN_TOKEN; } }); test('index setup preserves existing ordinary and unique onlyId indexes', async () => { for (const options of [undefined, { unique: true }, { unique: true, partialFilterExpression: { onlyId: { $type: 'number' } } }]) { setup(); process.env.ADMIN_TOKEN = 'test-only'; indexes.push({ name: 'users', spec: { onlyId: 1 }, options }); try { assert.equal((await adminApi({ body: { action: 'setup_indexes', adminToken: 'test-only' } })).code, 1); assert.equal(indexes.length, 8); assert.deepEqual(indexes.find(i => i.name === 'users' && i.spec.onlyId === 1).options, options); } finally { delete process.env.ADMIN_TOKEN; } } }); test('real client service talks to real activity API: manual stages, client awards, and offline win recovery', async () => { const { createRequire } = await import('node:module'); const { runInNewContext } = await import('node:vm'); const clientRoot = process.env.CLOUD_RISE_CLIENT_ROOT ? new URL(process.env.CLOUD_RISE_CLIENT_ROOT) : new URL('../../../../../MatchMaster/', import.meta.url); const requireClient = createRequire(new URL('package.json', clientRoot)); const ts = requireClient('typescript'); const storage = new Map(); const info = { uid: 'u', userId: 1001, token: 'token', coin: 100, level: 99, otherLevel: 0 }; const cc = { warn() {}, fx: { GameConfig: { GM_INFO: info }, StorageMessage: { getStorage: key => storage.has(key) ? JSON.parse(storage.get(key)) : null, setStorage: (key, value) => storage.set(key, JSON.stringify(value)), } } }; let offline = false, dropRewardReply = false; // Use the production encoder; passing JS objects directly misses form string coercion. cc.fx.GameTool = { getWechatGameVersion: () => '开发版' }; cc.loader = { getXMLHttpRequest: () => ({ open(method, url) { assert.equal(method, 'POST'); assert.equal(url, 'https://fixture.invalid/cloudRise/index'); }, setRequestHeader(name, value) { assert.equal(name, 'Content-Type'); assert.equal(value, 'application/x-www-form-urlencoded'); }, send(encoded) { const body = Object.fromEntries(new URLSearchParams(encoded)); assert.equal(body.uid, '1001'); if (body.action === 'save_reward') { assert.equal(typeof body.reward, 'string'); assert.equal(typeof body.coinAmount, 'string'); } if (offline) { this.onerror(); return; } call(body).then(result => { if (body.action === 'save_reward' && dropRewardReply) { dropRewardReply = false; this.onerror(); return; } this.status = 200; this.readyState = 4; this.responseText = JSON.stringify(result); this.onreadystatechange(); }); }, }) }; const utilsSource = ts.createSourceFile('Utils.ts', await readFile(new URL('assets/Script/module/Pay/Utils.ts', clientRoot), 'utf8'), ts.ScriptTarget.Latest, true); const postMethod = utilsSource.statements.find(ts.isClassDeclaration).members.find(n => n.name?.getText(utilsSource) === 'POST').getText(utilsSource); const baseUrlMethod = utilsSource.statements.find(ts.isClassDeclaration).members.find(n => n.name?.getText(utilsSource) === 'apiBaseUrl').getText(utilsSource); const postExports = {}; runInNewContext(ts.transpileModule(`export default class Utils { static httpip = 'https://fixture.invalid/'; static testHttpip = 'https://fixture.invalid/'; ${baseUrlMethod} ${postMethod} }`, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } }).outputText, { cc, exports: postExports, MiniGameSdk: { API: { showToast() {} } } }); const utils = { POST: postExports.default.POST.bind(postExports.default), setUserLevel() { throw new Error("CloudRise must not upload mainline progress"); }, }; async function load(name, imports) { const source = await readFile(new URL(name === 'CloudRiseRuntime' ? 'assets/cloud_rise_home/scripts/CloudRiseRuntime.ts' : 'assets/Script/module/Config/' + name + '.ts', clientRoot), 'utf8'); const exports = {}; runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } }).outputText, { cc, exports, console, Date, Math, require: path => { assert.ok(path in imports); return imports[path]; } }); return exports.default; } const boot = async () => load('CloudRiseRuntime', { '../../Script/module/Pay/Utils': { default: utils }, '../../Script/module/Config/CloudRiseService': { default: await load('CloudRiseService', { '../Pay/Utils': { default: utils } }) } }); setup(); configs.get('current').pools = [12345, 23456, 34567]; let client = await boot(); assert.equal(await client.prepareMatch('integration-cancel'), true); client.cancelMatch('integration-cancel'); await client.sync(); assert.equal(client.run(), null); assert.equal(client.data().available, true); assert.equal(await client.prepareMatch('integration-first'), true); assert.equal(await client.confirmMatch('integration-first'), true); const deadline = client.run().expiresAt; for (let stage = 1; stage <= 2; stage++) { if (stage > 1) { assert.equal(client.run().status, 'waiting'); const previous = db.users.get('u').cloudRiseState; client.prepareRound(() => {}); client.finish(true); await client.sync(); assert.equal(db.users.get('u').cloudRiseState, previous); info.level += 2; assert.equal(await client.prepareMatch('integration-second'), true); assert.equal(await client.confirmMatch('integration-second'), true); assert.equal(client.stage().start_level, info.level + 1); } forceScores([5,7][stage - 1]); for (let round = 0; round < [5,7][stage - 1]; round++) { assert.equal(client.prepareRound(() => {}), true); client.finish(true); info.level++; assert.equal(await client.sync(), true); } } assert.equal(client.run().status, 'waiting'); assert.equal(info.coin, 470); assert.equal(db.users.get('u').coinAmount, 470); assert.equal(client.run().expiresAt, deadline); assert.equal(db.cloudRiseSamples.size, 1); assert.equal([...db.cloudRiseSamples.values()][0].samples.length, 2); await client.startStage(); client.prepareRound(() => {}); await client.sync(); offline = true; client.finish(true); assert.equal(await client.sync(), false); client = await boot(); offline = false; assert.equal(await client.sync(), true); assert.equal(client.run().status, 'playing'); assert.equal(client.stage().success_num, 1); client.prepareRound(() => {}); client.finish(false); await client.sync(); assert.equal(client.run().status, 'failed'); assert.equal(info.coin, 470); assert.equal(db.cloudRiseSamples.size, 1); assert.equal([...db.cloudRiseSamples.values()][0].samples.length, 3); assert.equal(db.users.get('u').cloudRiseCoinTotal, undefined); // A committed reward with a lost response must still reach the local wallet after rollover. setup(); storage.clear(); info.coin = 100; client = await boot(); await client.startStage(); forceScores(); for (let round = 0; round < 4; round++) { client.prepareRound(() => {}); client.finish(true); await client.sync(); } dropRewardReply = true; client.prepareRound(() => {}); client.finish(true); assert.equal(await client.sync(), false); assert.equal(info.coin, 100); assert.equal(db.users.get('u').coinAmount, 200); const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState); state.run.expiresAt = Date.now() - 1; user.cloudRiseState = JSON.stringify(state); openNextPeriod(); assert.equal(await client.sync(), true); assert.equal(client.run(), null); assert.equal(client.unseenResult(), false); assert.equal(info.coin, 200); await client.sync(); assert.equal(info.coin, 200); assert.equal(await client.startStage(), true); assert.equal(client.run().periodId, 'p2'); }); test('onlyId identifies the player for queries, settlement and archives; legacy Mongo uid is rejected', async () => { setup(); db.users.set('1001', { _id: '1001', onlyId: 2002, token: 'other-token', coinAmount: 999 }); for (const uid of ['u', undefined, null, '', '01', '1e3', '1.5', {}, 0, -1, Number.MAX_SAFE_INTEGER + 1]) { assert.equal((await call({ uid })).code, 0); } assert.equal((await call({ uid: 1001 })).code, 1); assert.equal((await call({ uid: 1001, token: 'wrong' })).code, 0); const { data } = await call({ action: 'start', periodId: 'p1' }); await call({ action: 'begin', runId: data.run.id, stage: 1, attemptId: 'lost' }); assert.equal((await call({ action: 'finish', runId: data.run.id, stage: 1, attemptId: 'lost', outcome: 'lose' })).code, 1); assert.equal([...db.cloudRiseSamples.values()][0].uid, '1001'); assert.equal([...db.cloudRiseRuns.values()][0].uid, '1001'); assert.equal(db.users.get('1001').cloudRiseState, undefined); assert.equal(db.users.get('1001').coinAmount, 999); }); test('configuration selection and signup use time only, ignoring legacy enabled and status', async () => { setup(); const current = configs.get('current'); const active = { ...current, _id: 'other', periodId: 'p2', startsAt: current.startsAt + 1 }; configs.set('other', active); for (const status of [undefined, 'inactive', 'active']) { active.status = status; const result = await call({}); assert.equal(result.code, 1); assert.equal(result.data.period.periodId, 'p2'); assert.equal('status' in result.data.period, false); assert.equal(result.data.available, true); } active.enabled = false; const closed = await call({}); assert.equal(closed.code, 1); assert.equal(closed.data.period.periodId, 'p2'); assert.equal(closed.data.period.enabled, undefined); assert.equal(closed.data.available, true); assert.equal((await call({ action: 'start', periodId: 'p1' })).code, 0, 'must not fall back to an older period'); assert.equal(db.users.get('u').cloudRiseState, undefined); assert.equal((await call({ action: 'start', periodId: 'p2' })).code, 1); }); test('activity time boundaries are inclusive at startsAt and exclusive at endsAt', async () => { setup(); const config = configs.get('current'); const originalNow = Date.now; try { for (const [now, code] of [[config.startsAt - 1, 0], [config.startsAt, 1], [config.endsAt - 1, 1], [config.endsAt, 0]]) { Date.now = () => now; const result = await call({}); assert.equal(result.code, code); if (!code) assert.deepEqual(result, { code: 0, data: null, msg: '活动未开启' }); } } finally { Date.now = originalNow; } }); test('closing enrollment preserves the enrolled player deadline, progress and next stage', async () => { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); configs.get('current').endsAt = Date.now(); const closed = await call({}); assert.equal(closed.data.period.periodId, 'p1'); assert.equal(closed.data.available, false); assert.equal(closed.data.run.expiresAt, data.run.expiresAt); forceScores(); await winStage(data.run, 1, 5); assert.equal((await call({ action: 'save_reward', runId: data.run.id, stage: 1, reward: 100, coinAmount: 200 })).code, 1); const next = await call({ action: 'start_stage', runId: data.run.id, stage: 2 }); assert.equal(next.code, 1); assert.equal(next.data.run.stage, 2); assert.equal(next.data.run.expiresAt, data.run.expiresAt); }); test('admin publishes immutable future shared versions and refuses changing an opened period', async () => { setup(); process.env.ADMIN_TOKEN='test-only'; const admin=body=>adminApi({body:{adminToken:'test-only',...body}}); const future={periodId:'p2',startsAt:Date.now()+86400000,endsAt:Date.now()+2*86400000,unlockLevel:1}; for(const durationHours of [0,-1,1.5,'24']) assert.equal((await admin({action:'save',configVersion:'v2',config:{...future,durationHours}})).code,0); const saved=await admin({action:'save',configVersion:'v2',config:future});assert.equal(saved.code,1);assert.equal(saved.data.durationHours,24); assert.equal(configs.size,2);assert.equal((await admin({action:'read'})).data.periodId,'p1'); assert.equal((await admin({action:'read',periodId:'p2'})).data.configVersion,'v2'); assert.equal((await admin({action:'save',configVersion:'v2',config:{...future,unlockLevel:10}})).code,0); assert.equal((await admin({action:'save',configVersion:'opened-edit',config:configs.get('current')})).code,0); delete process.env.ADMIN_TOKEN; }); test('client reward reports persist the reported balance once, without server reward arithmetic', async () => { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); const report = { action: 'save_reward', runId: data.run.id, stage: 1, reward: 123, coinAmount: 223 }; assert.equal((await call(report)).code, 0); forceScores(); await winStage(data.run, 1, 5); assert.equal(db.users.get('u').coinAmount, 100); for (const patch of [{ reward: -1 }, { reward: 1.5 }, { coinAmount: NaN }, { coinAmount: 1 }]) { assert.equal((await call({ ...report, ...patch })).code, 0); } const replies = await Promise.all([call(report), call(report)]); assert.ok(replies.every(r => r.code === 1 && r.data.run.stages[0].rewardSaved)); assert.equal(db.users.get('u').coinAmount, 223); assert.equal(replies[0].data.run.stages[0].reward, 123); assert.equal((await saveCoins({ coinAmount: 80 })).code, 1); assert.equal((await call(report)).code, 1); assert.equal(db.users.get('u').coinAmount, 80); }); test('won rewards remain reportable after personal expiry and must be saved before a new period', async () => { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); forceScores(); await winStage(data.run, 1, 5); const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState); state.run.expiresAt = Date.now() - 1; user.cloudRiseState = JSON.stringify(state); configs.get('current').periodId = 'p2'; db.users.get('u').cloudRisePeriod.periodId = 'p2'; assert.equal((await call({})).data.available, false); assert.equal(db.cloudRiseRuns.size, 0); const result = await call({ action: 'save_reward', runId: data.run.id, stage: 1, reward: 100, coinAmount: 200 }); assert.equal(result.code, 1); assert.equal(result.data.available, true); assert.equal(db.cloudRiseRuns.size, 1); assert.equal([...db.cloudRiseRuns.values()][0].stages[0].rewardSaved, true); }); test('no open configuration returns only the closed response for unregistered players', async () => { setup(); configs.clear(); for (const body of [{}, { action: 'start', periodId: 'p1' }, { action: 'begin', runId: 'missing' }]) { assert.deepEqual(await call(body), { code: 0, data: null, msg: '活动未开启' }); } assert.equal(db.users.get('u').cloudRiseState, undefined); assert.equal(db.cloudRiseSamples.size, 0); }); test('registration closing still permits personal play, result recovery and reward retries', async () => { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); forceScores(); configs.clear(); const status = await call({}); assert.equal(status.code, 1); assert.equal(status.data.period.periodId, 'p1'); assert.deepEqual(status.data.targets, [5, 7, 9]); assert.deepEqual(status.data.pools, [10000, 15000, 20000]); assert.equal(status.data.run.id, data.run.id); await winStage(data.run, 1, 5); const report = { action: 'save_reward', runId: data.run.id, stage: 1, reward: 100, coinAmount: 200 }; assert.equal((await call(report)).code, 1); assert.equal((await call(report)).code, 1); assert.equal(db.users.get('u').coinAmount, 200); const state = JSON.parse(db.users.get('u').cloudRiseState); state.run.expiresAt = Date.now() - 1; db.users.get('u').cloudRiseState = JSON.stringify(state); assert.equal((await call({})).data.run.status, 'expired'); assert.equal((await call(report)).code, 1); }); function openNextPeriod() { const current = configs.get('current'); configs.set('next', { ...current, _id: 'next', periodId: 'p2', startsAt: Date.now() - 1 }); db.users.get('u').cloudRisePeriod = {...structuredClone(configs.get('next')),configVersion:'next'}; } test('new period status omits settled older runs while retaining durable history and eligibility', async () => { for (const status of ['expired', 'failed', 'completed']) { setup(); await call({ action: 'start', periodId: 'p1' }); const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState), old = state.run; old.status = status; Object.assign(old.stages[0], { status: status === 'completed' ? 'won' : status === 'failed' ? 'lost' : 'expired', rewardSaved: true, reward: status === 'completed' ? 100 : 0, endedAt: Date.now() }); user.cloudRiseState = JSON.stringify(state); openNextPeriod(); const result = await call({ action: 'status' }); assert.equal(result.data.period.periodId, 'p2'); assert.equal(result.data.available, true); assert.equal(result.data.run, null); assert.equal(result.data.previousRun.id, old.id); assert.equal(JSON.parse(user.cloudRiseState).run.id, old.id, 'status must not erase the idempotency source'); assert.equal(db.cloudRiseRuns.get(old.id).periodId, 'p1'); const joined = await call({ action: 'start', periodId: 'p2' }); assert.equal(joined.code, 1); assert.equal(joined.data.run.periodId, 'p2'); assert.equal(joined.data.run.stage, 1); assert.deepEqual(JSON.parse(user.cloudRiseState).playedPeriods, ['p1', 'p2']); assert.equal(db.cloudRiseRuns.get(old.id).periodId, 'p1'); } }); test('a start request can expire an older run and join the new period without a preceding status request', async () => { setup(); await call({ action: 'start', periodId: 'p1' }); const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState), oldId = state.run.id; state.run.expiresAt = Date.now() - 1; user.cloudRiseState = JSON.stringify(state); openNextPeriod(); const result = await call({ action: 'start', periodId: 'p2' }); assert.equal(result.code, 1); assert.equal(result.data.run.periodId, 'p2'); assert.equal(result.data.run.status, 'playing'); assert.equal(db.cloudRiseRuns.get(oldId).status, 'expired'); assert.equal(db.cloudRiseSamples.size, 0, 'expiry is not a loss sample'); }); test('new registration never hides an ongoing older personal run or its unsaved reward', async () => { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); openNextPeriod(); let result = await call({}); assert.equal(result.data.available, false); assert.equal(result.data.run.periodId, 'p1'); assert.equal((await call({ action: 'start', periodId: 'p2' })).code, 0); forceScores(); await winStage(data.run, 1, 5); result = await call({}); assert.equal(result.data.run.status, 'waiting'); const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState); state.run.expiresAt = Date.now() - 1; user.cloudRiseState = JSON.stringify(state); result = await call({}); assert.equal(result.data.run.status, 'expired'); assert.equal(result.data.available, false); assert.equal((await call({ action: 'start', periodId: 'p2' })).code, 0); const report = { action: 'save_reward', runId: data.run.id, stage: 1, reward: 100, coinAmount: 200 }; result = await call(report); assert.equal(result.data.run, null); assert.equal(result.data.available, true); result = await call(report); assert.equal(result.code, 1); assert.equal(result.data.coinAmount, 200); }); test('editing dates with the same periodId does not reset participation or hide its terminal result', async () => { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); await call({ action: 'begin', runId: data.run.id, stage: 1, attemptId: 'loss' }); await call({ action: 'finish', runId: data.run.id, stage: 1, attemptId: 'loss', outcome: 'lose' }); configs.get('current').endsAt += 86400000; const result = await call({}); assert.equal(result.data.run.status, 'failed'); assert.equal(result.data.available, false); assert.equal((await call({ action: 'start', periodId: 'p1' })).code, 0); }); test('form reward fields save as numbers and retries preserve a newer balance', async () => { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); forceScores(0); const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState); state.run.stages[0].opponents.slice(0, 49).forEach(o => o.success_num = 5); user.cloudRiseState = JSON.stringify(state); await winStage(data.run, 1, 5); assert.equal((await call({})).data.run.stages[0].survivors, 50); const report = { action: 'save_reward', runId: data.run.id, stage: '1', reward: '200', coinAmount: '300' }; const result = await call(report); assert.equal(result.code, 1); assert.equal(result.data.run.stages[0].reward, 200); assert.equal(user.coinAmount, 300); assert.equal(typeof user.coinAmount, 'number'); assert.equal(result.data.run.stages[0].rewardSaved, true); await saveCoins({ coinAmount: 80 }); await call(report); assert.equal(user.coinAmount, 80, 'a repeated receipt cannot credit twice or overwrite a newer balance'); }); test('invalid form or JSON reward integers leave both balance and receipt untouched', async () => { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); forceScores(); await winStage(data.run, 1, 5); const report = { action: 'save_reward', runId: data.run.id, stage: '1', reward: '100', coinAmount: '200' }; for (const field of ['reward', 'coinAmount']) { for (const value of [undefined, null, true, false, [], [200], {}, '', ' ', 'NaN', 'Infinity', '100x', '1.5', '1e3', '0x10', '-1', '0', '9007199254740992', 1.5, -1, NaN, Infinity, 9007199254740992]) { assert.equal((await call({ ...report, [field]: value })).code, 0, `${field}: ${String(value)}`); assert.equal(db.users.get('u').coinAmount, 100); assert.equal(!!JSON.parse(db.users.get('u').cloudRiseState).run.stages[0].rewardSaved, false); } } }); test('admin saves custom pools in activityConfigs and rejects invalid payloads',async()=>{ setup();process.env.ADMIN_TOKEN='test-only'; const config={periodId:'future',startsAt:Date.now()+86400000,endsAt:Date.now()+2*86400000,unlockLevel:1}; let revision=0; const save=config=>adminApi({body:{action:'save',adminToken:'test-only',configVersion:'v'+(++revision),config:JSON.stringify(config)}}); assert.deepEqual((await save(config)).data.pools,[10000,15000,20000]); const pools=[123,456,789];assert.deepEqual((await save({...config,pools})).data.pools,pools); const count=configs.size; for(const invalid of [[],[1,2],[0,1,2],[1,-1,3],[1,2,1.5],['1',2,3]])assert.equal((await save({...config,pools:invalid})).code,0); assert.equal(configs.size,count);delete process.env.ADMIN_TOKEN; }); test('signup freezes all three pools across config edits, closing and a new period; archives keep the snapshot', async () => { setup(); const pools = [12345, 23456, 34567], nextPools = [30000, 40000, 50000]; configs.get('current').pools = pools; let result = await call({ action: 'start', periodId: 'p1' }); const run = result.data.run; assert.deepEqual(run.pools, pools); assert.deepEqual(JSON.parse(db.users.get('u').cloudRiseState).run.pools, pools); configs.get('current').pools = nextPools; assert.deepEqual((await call({})).data.pools, pools); for (let stage = 1; stage <= 3; stage++) { if (stage === 2) configs.get('current').enabled = false; if (stage === 3) Object.assign(configs.get('current'), { enabled: true, periodId: 'p2' }); if (stage > 1) result = await call({ action: 'start_stage', runId: run.id, stage }); assert.equal(result.code, 1); assert.equal(result.data.run.stages.at(-1).pool, pools[stage - 1]); assert.deepEqual(result.data.pools, pools); if (stage === 2) { assert.equal(result.data.period.enabled, undefined); assert.equal(result.data.available, false); } if (stage === 3) assert.deepEqual(result.data.period.pools, pools); forceScores([5, 7, 9][stage - 1]); await winStage(run, stage, [5, 7, 9][stage - 1]); const reward = Math.ceil(pools[stage - 1] / 100); result = await call({ action: 'save_reward', runId: run.id, stage, reward, coinAmount: db.users.get('u').coinAmount + reward }); assert.equal(result.code, 1); } assert.equal(result.data.run.status, 'completed'); afterCooldown(); const nextPeriod = await openPeriod(1001); result = await call({action:'status'}); assert.equal(result.data.run, null); assert.deepEqual(result.data.pools, nextPools); assert.deepEqual([...db.cloudRiseRuns.values()][0].pools, pools); result = await call({ action: 'start', periodId: nextPeriod.periodId }); assert.equal(result.code, 1); assert.deepEqual(result.data.run.pools, nextPools); assert.equal(result.data.run.stages[0].pool, nextPools[0]); }); test('legacy configs and runs retain defaults without applying newly configured pools', async () => { setup(); assert.deepEqual((await call({})).data.pools, [10000, 15000, 20000]); const { data } = await call({ action: 'start', periodId: 'p1' }); const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState); delete state.run.pools; state.run.stages[0].pool = 11000; user.cloudRiseState = JSON.stringify(state); configs.get('current').pools = [30000, 40000, 50000]; assert.deepEqual((await call({})).data.pools, [11000, 15000, 20000]); forceScores(); await winStage(data.run, 1, 5); const result = await call({ action: 'start_stage', runId: data.run.id, stage: 2 }); assert.equal(result.code, 1); assert.equal(result.data.run.stages[1].pool, 15000); assert.deepEqual(JSON.parse(user.cloudRiseState).run.pools, [11000, 15000, 20000]); }); test('an opened period stays joinable when stored progress lags behind the client', async () => { for (const completed of [103, 109, 110]) { setup(); configs.get('current').unlockLevel = 110; db.users.get('u').levelAmount = completed; const status = await call({}); assert.equal(status.code, 1); assert.equal(status.data.period.unlockLevel, 110); assert.equal(status.data.available, true); const result = await call({ action: 'start', periodId: 'p1' }); assert.equal(result.code, 1); } }); const sample = (stage, success_num, extra = {}) => ({ stage, start_level: 100, success_num, outcome: 'lost', reason: 'lose', endedAt: 123, ...extra }); const sampleDoc = (runId, samples, extra = {}) => ({ _id: runId, schemaVersion: 2, uid: '1001', runId, periodId: 'p1', samples, ...extra }); const histogram = stage => aggregate([...db.cloudRiseSamples.values()], sampleHistogramPipeline(stage)); test('stage samples append once under concurrent retries and older saved snapshots preserve later stages', async () => { setup(); const result = await call({ action: 'start', periodId: 'p1' });const runId = result.data.run.id; forceScores(); await winStage(result.data.run, 1, 5); const saved = db.users.get('u').cloudRiseState; await call({ action: 'start_stage', runId, stage: 2 }); await call({ action: 'begin', runId, stage: 2, attemptId: 'lost' }); await Promise.all(Array.from({ length: 6 }, () => call({ action: 'finish', runId, stage: 2, attemptId: 'lost', outcome: 'lose' }))); assert.equal(db.cloudRiseSamples.size, 1); const doc = db.cloudRiseSamples.get(runId); assert.equal(doc.schemaVersion, 2);assert.equal(doc.uid, '1001');assert.equal(doc.periodId, 'p1'); assert.deepEqual(Object.keys(doc).sort(),['_id','schemaVersion','uid','runId','periodId','samples'].sort()); assert.equal(doc._id,runId);assert.equal(doc.runId,runId); for(const item of doc.samples)assert.deepEqual(Object.keys(item).sort(),['stage','start_level','success_num','outcome','reason','endedAt'].sort()); assert.deepEqual(doc.samples.map(s => s.stage).sort(), [1,2]); db.users.get('u').cloudRiseState = saved;await call({ action: 'status' }); assert.deepEqual(doc.samples.map(s => s.stage).sort(), [1,2]); assert.equal(doc.samples[1].success_num, 0); }); test('a failed append retries from user state without losing or duplicating the stage sample', async () => { setup();const { data } = await call({ action: 'start', periodId: 'p1' }); await call({ action: 'begin', runId: data.run.id, stage: 1, attemptId: 'lost' }); sampleAppendFails = 1; const failed = await call({ action: 'finish', runId: data.run.id, stage: 1, attemptId: 'lost', outcome: 'lose' }); assert.equal(failed.data.sampleSyncPending, true);assert.equal(db.cloudRiseSamples.get(data.run.id).samples.length, 0); assert.equal((await call({ action: 'status' })).data.sampleSyncPending, false); await call({ action: 'status' });assert.equal(db.cloudRiseSamples.get(data.run.id).samples.length, 1); }); test('array-only histogram counts equal scores across players and periods without mixing stages', () => { setup(); const rows = [ sampleDoc('r1',[sample(1,2),sample(2,7)]), sampleDoc('r2',[sample(1,2)],{periodId:'p2'}), sampleDoc('r3',[sample(1,0)],{uid:'1002'}), sampleDoc('r4',[sample(1,4),sample(2,6)],{uid:'1003'}), sampleDoc('invalid',[sample(1,1,{outcome:'expired'}),sample(1,9),sample(1,2,{start_level:1961})]), {_id:'flat',runId:'flat',uid:'1004',periodId:'p1',...sample(1,5)}, ]; for(const row of rows)db.cloudRiseSamples.set(row._id,row); assert.deepEqual(histogram(1),[{_id:{start:100,score:2},count:2},{_id:{start:100,score:0},count:1},{_id:{start:100,score:4},count:1}]); assert.deepEqual(histogram(2),[{_id:{start:100,score:7},count:1},{_id:{start:100,score:6},count:1}]); assert.deepEqual(histogram(3),[]); }); test('sample indexes support only nested scores and one document per player-period', async () => { setup();process.env.ADMIN_TOKEN='test-only';await adminApi({body:{action:'setup_indexes',adminToken:'test-only'}}); assert(!indexes.some(i=>i.name==='cloudRiseSamples'&&i.spec.stage===1)); assert(indexes.some(i=>i.name==='cloudRiseSamples'&&i.spec['samples.stage']===1&&i.spec['samples.start_level']===1&&i.spec['samples.success_num']===1)); assert(indexes.some(i=>i.name==='cloudRiseSamples'&&i.options?.unique&&i.spec.uid===1&&i.spec.periodId===1&&i.options.partialFilterExpression.schemaVersion===2)); delete process.env.ADMIN_TOKEN; }); test('durationHours is snapshotted at signup and later config edits preserve expiry through stage changes', async () => { setup(); const config = configs.get('current'); config.durationHours = 36; const realNow = Date.now, now = realNow(); Date.now = () => now; try { const { data } = await call({ action: 'start', periodId: 'p1' }); assert.equal(data.period.durationHours, 36); assert.equal(data.run.durationHours, 36); assert.equal(data.run.expiresAt, db.users.get('u').cloudRisePeriod.endsAt); config.durationHours = 1; config.endsAt = now; forceScores(); await winStage(data.run, 1, 5); await call({ action: 'save_reward', runId: data.run.id, stage: 1, reward: 100, coinAmount: 200 }); const next = await call({ action: 'start_stage', runId: data.run.id, stage: 2 }); assert.equal(next.code, 1); assert.equal(next.data.run.expiresAt, data.run.expiresAt); assert.equal(next.data.run.durationHours, 36); Date.now = () => data.run.expiresAt; assert.equal((await call({})).data.run.status, 'expired'); } finally { Date.now = realNow; } }); test('legacy configurations default to 24 hours and legacy runs keep their saved deadlines', async () => { setup(); delete configs.get('current').durationHours; const { data } = await call({ action: 'start', periodId: 'p1' }); assert.equal(data.run.durationHours,24); assert.equal(data.run.expiresAt,db.users.get('u').cloudRisePeriod.endsAt); const state = JSON.parse(db.users.get('u').cloudRiseState); delete state.run.durationHours; db.users.get('u').cloudRiseState = JSON.stringify(state); configs.get('current').durationHours = 48; assert.equal((await call({})).data.run.expiresAt, data.run.expiresAt); }); test('legacy migration action directs operators to the shared collection migration without writes',async()=>{ setup();process.env.ADMIN_TOKEN='test-only';const before=structuredClone([...db.activityConfigs]); assert.equal((await adminApi({body:{action:'migrate_config'}})).code,0); assert.equal((await adminApi({body:{action:'migrate_config',adminToken:'test-only'}})).code,0); assert.deepEqual([...db.activityConfigs],before);delete process.env.ADMIN_TOKEN; }); test('draft matching and cancellation never consume enrollment; retry confirms exactly once', async () => { setup(); const prepare = id => call({action:'prepare_match', matchId:id, stage:1, periodId:'p1'}); const first = await prepare('match-first'); assert.equal(first.code,1); assert.equal(first.data.available,true); assert.equal(first.data.run,null); assert.equal(first.data.matching.stage.opponents.length,99); assert.equal(db.cloudRiseRuns.size,0); assert.equal(db.cloudRiseSamples.size,0); assert.equal((await call({action:'cancel_match',matchId:'match-first'})).code,1); assert.equal((await call({action:'status'})).data.matching,null); assert.equal((await prepare('match-first')).data.matching,null,'late duplicate cannot resurrect cancelled preparation'); assert.equal((await call({action:'confirm_match',matchId:'match-first'})).code,0); assert.equal((await prepare('match-second')).code,1); const confirmed = await call({action:'confirm_match',matchId:'match-second'}); assert.equal(confirmed.code,1); assert.equal(confirmed.data.available,false); assert.equal(confirmed.data.run.status,'playing'); const runId=confirmed.data.run.id; await call({action:'begin',runId,stage:1,attemptId:'attempt-1'}); const again=await call({action:'confirm_match',matchId:'match-second'}); assert.equal(again.data.run.stages[0].attemptId,'attempt-1'); assert.deepEqual(JSON.parse(db.users.get('u').cloudRiseState).playedPeriods,['p1']); }); test('cancel arriving before or concurrently with prepare wins and does not cancel a new match', async()=>{ for(const concurrent of [false,true]){ setup(); const prepare=()=>call({action:'prepare_match',matchId:'race-old',stage:1,periodId:'p1'}); const cancel=()=>call({action:'cancel_match',matchId:'race-old'}); if(concurrent) await Promise.all([prepare(),cancel()]); else {await cancel(); await prepare();} const state=JSON.parse(db.users.get('u').cloudRiseState); assert.equal(state.pendingMatch,undefined);assert.deepEqual(state.playedPeriods,[]);assert.equal(state.run,null); await call({action:'prepare_match',matchId:'race-new',stage:1,periodId:'p1'}); assert.equal((await cancel()).data.matching.id,'race-new'); assert.equal((await call({action:'confirm_match',matchId:'race-new'})).code,1); } }); test('later-stage cancellation preserves wins, reward receipts and deadline; concurrent receipt survives confirmation',async()=>{ setup();const {data}=await call({action:'start',periodId:'p1'});forceScores();await winStage(data.run,1,5); const before=(await call({action:'status'})).data.run; const prepare=id=>call({action:'prepare_match',matchId:id,runId:before.id,stage:2}); await prepare('stage-two-cancel'); const cancelled=await call({action:'cancel_match',matchId:'stage-two-cancel'}); assert.deepEqual(cancelled.data.run,before); await prepare('stage-two-confirm'); await call({action:'save_reward',runId:before.id,stage:1,reward:100,coinAmount:200}); const confirmed=await call({action:'confirm_match',matchId:'stage-two-confirm'}); assert.equal(confirmed.code,1);assert.equal(confirmed.data.run.stage,2); assert.equal(confirmed.data.run.expiresAt,before.expiresAt); assert.equal(confirmed.data.run.stages[0].rewardSaved,true);assert.equal(confirmed.data.run.stages[0].reward,100); assert.equal(confirmed.data.coinAmount,200); }); test('registration closure before confirmation consumes no opportunity, and cancellation still succeeds',async()=>{ setup();await call({action:'prepare_match',matchId:'expired-match',stage:1,periodId:'p1'}); configs.get('current').endsAt=Date.now()-1; db.users.get('u').cloudRisePeriod.endsAt=Date.now()-1; assert.equal((await call({action:'confirm_match',matchId:'expired-match'})).code,0); assert.equal((await call({action:'cancel_match',matchId:'expired-match'})).code,1); const state=JSON.parse(db.users.get('u').cloudRiseState);assert.equal(state.run,null);assert.deepEqual(state.playedPeriods,[]); }); test('client level selects all three matching stages without modifying stored mainline progress', async () => { setup(); let runId; for (let stage = 1; stage <= 3; stage++) { const levelAmount = [103, 156, 210][stage - 1], matchId = 'client-level-' + stage; const body = { action: 'prepare_match', matchId, stage, levelAmount: String(levelAmount), ...(stage === 1 ? { periodId: 'p1' } : { runId }) }; const draft = await call(body); assert.equal(draft.code, 1); assert.equal(draft.data.matching.stage.start_level, levelAmount + 1); const retry = await call({ ...body, levelAmount: levelAmount + 50 }); assert.deepEqual(retry.data.matching, draft.data.matching, 'retry keeps the original matching snapshot'); const result = await call({ action: 'confirm_match', matchId }); assert.equal(result.code, 1); runId = result.data.run.id; assert.equal(result.data.run.stages.at(-1).start_level, levelAmount + 1); assert.equal(db.users.get('u').levelAmount, 99); if (stage < 3) { forceScores([5, 7, 9][stage - 1]); await winStage(result.data.run, stage, [5, 7, 9][stage - 1]); } } }); test('direct stage entry uses client level; cancelled matching may restart with newer local progress', async () => { setup(); let result = await call({ action: 'start', periodId: 'p1', levelAmount: 150 }); assert.equal(result.data.run.stages[0].start_level, 151); forceScores(); await winStage(result.data.run, 1, 5); result = await call({ action: 'start_stage', runId: result.data.run.id, stage: 2, levelAmount: '2000' }); assert.equal(result.data.run.stages[1].start_level, 2001, 'player progress is not capped to opponent sample range'); assert.equal(db.users.get('u').levelAmount, 99); setup(); await call({ action: 'prepare_match', matchId: 'cancel-level', stage: 1, periodId: 'p1', levelAmount: 103 }); await call({ action: 'cancel_match', matchId: 'cancel-level' }); result = await call({ action: 'prepare_match', matchId: 'restart-level', stage: 1, periodId: 'p1', levelAmount: 120 }); assert.equal(result.data.matching.stage.start_level, 121); assert.equal(result.data.available, true); }); test('matching level accepts zero and rejects malformed explicit values without consuming participation', async () => { for (const levelAmount of [-1, 1.5, '', '12x', null, true, Number.MAX_SAFE_INTEGER]) { setup(); const result = await call({ action: 'prepare_match', matchId: 'invalid-level', stage: 1, periodId: 'p1', levelAmount }); assert.equal(result.code, 0); assert.equal(result.msg, '当前关卡数无效'); assert.equal(db.users.get('u').cloudRiseState, undefined); } setup(); const result = await call({ action: 'prepare_match', matchId: 'zero-level', stage: 1, periodId: 'p1', levelAmount: '0' }); assert.equal(result.code, 1); assert.equal(result.data.matching.stage.start_level, 1); }); test('another device resumes the same matching snapshot without overwriting either mainline progress or opponents', async () => { setup(); const matchId = 'device-a-match'; const prepared = await call({ action: 'prepare_match', matchId, stage: 1, periodId: 'p1', levelAmount: 120 }); assert.equal(prepared.code, 1); const matching = structuredClone(prepared.data.matching); assert.equal(matching.stage.start_level, 121); // The account's normal mainline sync may change independently while another device opens the activity. db.users.get('u').levelAmount = 150; const restored = await call({ action: 'status', levelAmount: 103 }); assert.deepEqual(restored.data.matching, matching); const retried = await call({ action: 'prepare_match', matchId, stage: 1, periodId: 'p1', levelAmount: 103 }); assert.deepEqual(retried.data.matching, matching); const confirmed = await call({ action: 'confirm_match', matchId, levelAmount: 103 }); assert.equal(confirmed.code, 1); assert.equal(confirmed.data.run.stages[0].start_level, 121); assert.deepEqual(confirmed.data.run.stages[0].opponents, matching.stage.opponents); assert.equal(db.users.get('u').levelAmount, 150); const again = await call({ action: 'confirm_match', matchId, levelAmount: 180 }); assert.deepEqual(again.data.run, confirmed.data.run); assert.equal(db.users.get('u').levelAmount, 150); }); test('shared config lookup ignores gold miner and draft versions',async()=>{ setup();const original=structuredClone(db.activityConfigs.get('current')); db.activityConfigs.set('gold',{...original,_id:'gold',activityId:'goldMiner',config:{...original.config,periodId:'wrong'}}); db.activityConfigs.set('draft',{...original,_id:'draft',status:'draft',publishedAt:original.publishedAt+1,config:{...original.config,periodId:'draft'}}); const result=await call({action:'status'});assert.equal(result.code,1);assert.equal(result.data.period.periodId,'p1'); }); test('late publication is readable while the enrollment window and effective time remain enforced',async()=>{ setup();const now=Date.now(),row=db.activityConfigs.get('current'); row.publishedAt=now; assert.ok(row.publishedAt>row.config.startsAt); const status=await call({action:'status'}); assert.equal(status.code,1);assert.equal(status.data.period.periodId,'p1'); assert.equal(status.data.available,true);assert.equal(row.publishedAt,now); const startsAt=row.config.startsAt,endsAt=row.config.endsAt; row.effectiveFrom=now+1000; assert.equal(await findOpenPeriod(now),null); row.config.startsAt=now+1000; assert.equal(await findOpenPeriod(now),null); row.config.startsAt=startsAt;row.effectiveFrom=startsAt+1; assert.equal(await findOpenPeriod(now),null); row.effectiveFrom=startsAt;row.config.endsAt=now; assert.equal(await findOpenPeriod(now),null); row.config.endsAt=endsAt; db.activityConfigs.set('newer',{...structuredClone(row),_id:'newer',configVersion:'newer',publishedAt:now+1}); assert.equal((await findOpenPeriod(now+1)).configVersion,'newer'); }); test('cloudRise published example passes admin validation and creates the shared envelope',async()=>{ setup();process.env.ADMIN_TOKEN='test-only';const original=Date.now; Date.now=()=>Date.parse('2026-09-20T00:00:00+08:00'); try { const body=JSON.parse(await readFile(new URL('../cloudRise-config.publish.example.json',import.meta.url),'utf8')); body.adminToken='test-only';const result=await adminApi({body});assert.equal(result.code,1,JSON.stringify(result)); const row=[...db.activityConfigs.values()].find(r=>r.configVersion===body.configVersion); assert.equal(row.activityId,'cloudRise');assert.deepEqual(row.config.pools,[10000,15000,20000]); assert.equal(row.effectiveFrom,body.config.startsAt); } finally {Date.now=original;delete process.env.ADMIN_TOKEN;} }); test('compact drafts keep only their own opponents across all stages; confirmation appends once', async () => { setup(); let runId; const saved = () => JSON.parse(db.users.get('u').cloudRiseState); for (let stage = 1; stage <= 3; stage++) { const before = db.users.get('u').cloudRiseState ? saved().run : null; const matchId = 'compact-stage-' + stage; const body = { action: 'prepare_match', stage, matchId, ...(stage === 1 ? { periodId: 'p1' } : { runId }) }; assert.equal((await call(body)).code, 1); const pending = saved().pendingMatch; assert.equal(pending.run.stages.length, 1); assert.equal(pending.run.stages[0].stage, stage); assert.equal(pending.run.stages[0].opponents.length, 99); assert.deepEqual(saved().run, before, 'preparation must not change official stages'); await call(body); assert.deepEqual(saved().pendingMatch, pending, 'preparation retry must not reroll'); const confirmed = await call({ action: 'confirm_match', matchId }); assert.equal(confirmed.code, 1); runId = confirmed.data.run.id; const run = saved().run; assert.equal(run.stages.length, stage); assert.deepEqual(run.stages.slice(0, -1), before?.stages || []); assert.deepEqual(run.stages.at(-1).opponents, pending.run.stages[0].opponents); assert.ok(run.stages.every(s => s.opponents.length === 99)); await call({ action: 'confirm_match', matchId }); assert.deepEqual(saved().run, run); assert.equal(saved().pendingMatch, undefined); forceScores([5, 7, 9][stage - 1]); await winStage(run, stage, [5, 7, 9][stage - 1]); assert.equal((await call({ action: 'save_reward', runId, stage, reward: 100, coinAmount: 100 + 100 * stage })).code, 1); } const live = saved().run, archive = db.cloudRiseRuns.get(runId); assert.equal(live.status, 'completed'); assert.deepEqual(archive, { _id: runId, uid: '1001', ...live, stages: live.stages.map(({ opponents, ...summary }) => summary) }); assert.equal(live.stages.reduce((n, s) => n + s.opponents.length, 0), 297); const original = structuredClone(archive); await call({ action: 'status' }); await call({ action: 'save_reward', runId, stage: 3, reward: 100, coinAmount: 400 }); assert.deepEqual(db.cloudRiseRuns.get(runId), original, 'archive and reward retries are idempotent'); assert.equal(db.users.get('u').coinAmount, 400); }); test('legacy full-run drafts compact durably on status and keep matches and late reward receipts', async () => { for (const objectState of [false, true]) { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); forceScores(); await winStage(data.run, 1, 5); await call({ action: 'prepare_match', stage: 2, runId: data.run.id, matchId: 'legacy-draft' }); const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState); const ownStage = structuredClone(state.pendingMatch.run.stages[0]); state.pendingMatch.run.stages = [...structuredClone(state.run.stages), ownStage]; user.cloudRiseState = objectState ? state : JSON.stringify(state); const status = await call({ action: 'status' }); assert.equal(status.code, 1); const compact = JSON.parse(user.cloudRiseState); assert.deepEqual(compact.pendingMatch.run.stages, [ownStage]); assert.deepEqual(compact.run, state.run); assert.equal(status.data.matching.stage.opponents.length, 99); if (objectState) assert.equal(state.pendingMatch.run.stages.length, 2, 'CAS source object must not be mutated'); await call({ action: 'save_reward', runId: data.run.id, stage: 1, reward: 100, coinAmount: 200 }); const confirmed = await call({ action: 'confirm_match', matchId: 'legacy-draft' }); assert.equal(confirmed.code, 1); assert.equal(confirmed.data.run.stages[0].rewardSaved, true); assert.deepEqual(JSON.parse(user.cloudRiseState).run.stages[1].opponents, ownStage.opponents); } }); test('failed and expired archives omit opponent copies while status retains settlement avatars', async () => { for (const expired of [false, true]) { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); if (expired) { const state = JSON.parse(db.users.get('u').cloudRiseState); state.run.expiresAt = Date.now() - 1; db.users.get('u').cloudRiseState = JSON.stringify(state); } else { await call({ action: 'begin', runId: data.run.id, stage: 1, attemptId: 'lost-round' }); assert.equal((await call({ action: 'finish', runId: data.run.id, stage: 1, attemptId: 'lost-round', outcome: 'lose' })).code, 1); } const status = await call({ action: 'status' }); assert.equal(status.data.run.status, expired ? 'expired' : 'failed'); assert.equal(status.data.run.stages[0].opponents.length, 99); const archive = db.cloudRiseRuns.get(data.run.id); assert.equal('opponents' in archive.stages[0], false); assert.equal(archive.stages[0].survivors, status.data.run.stages[0].survivors); } }); test('archive compaction is authenticated, paginated and idempotent without touching live state', async () => { setup(); const oldToken = process.env.ADMIN_TOKEN; process.env.ADMIN_TOKEN = 'compact-test'; try { const { data } = await call({ action: 'start', periodId: 'p1' }); const userBefore = structuredClone(db.users.get('u')); const stage = JSON.parse(userBefore.cloudRiseState).run.stages[0]; for (const [id, status] of [['a', 'completed'], ['b', 'failed'], ['c', 'expired'], ['d', 'playing']]) { db.cloudRiseRuns.set(id, { _id: id, id, uid: '1001', status, stages: [structuredClone(stage), { stage: 2, reward: 120, rewardSaved: true }] }); } const originals = structuredClone([...db.cloudRiseRuns]); const runAdmin = body => adminApi({ body: { action: 'compact_archives', adminToken: 'compact-test', ...body } }); assert.equal((await runAdmin({ adminToken: 'wrong' })).code, 0); for (const limit of [0, 501, 1.5]) assert.equal((await runAdmin({ limit })).code, 0); assert.equal((await runAdmin({ afterId: 5 })).code, 0); assert.deepEqual([...db.cloudRiseRuns], originals); const first = await runAdmin({ limit: 2 }); assert.equal(first.code, 1); assert.deepEqual(first.data, { compacted: 2, nextCursor: 'b' }); const second = await runAdmin({ limit: 2, afterId: first.data.nextCursor }); assert.deepEqual(second.data, { compacted: 1, nextCursor: null }); assert.deepEqual((await runAdmin({})).data, { compacted: 0, nextCursor: null }); for (const [id, original] of originals) { const expected = id === 'd' ? original : { ...original, stages: original.stages.map(({ opponents, ...summary }) => summary) }; assert.deepEqual(db.cloudRiseRuns.get(id), expected); } assert.deepEqual(db.users.get('u'), userBefore); assert.equal((await call({ action: 'status' })).data.run.id, data.run.id); } finally { if (oldToken === undefined) delete process.env.ADMIN_TOKEN; else process.env.ADMIN_TOKEN = oldToken; } }); test('analytics start counts only finishing bots after confirmation and not cancelled preparations', async () => { setup(); await call({ action: 'prepare_match', stage: 1, periodId: 'p1', matchId: 'analytics-cancel' }); assert.equal(analyticsReports.length, 0); await call({ action: 'cancel_match', matchId: 'analytics-cancel' }); assert.equal(analyticsReports.length, 0); await call({ action: 'prepare_match', stage: 1, periodId: 'p1', matchId: 'analytics-start' }); const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState); state.pendingMatch.run.stages[0].opponents.forEach((o,i) => { o.success_num = i < 17 ? 5 : 4; }); user.cloudRiseState = JSON.stringify(state); assert.equal((await call({ action: 'confirm_match', matchId: 'analytics-start', isDebug: 'true' })).code, 1); assert.equal(analyticsReports.length, 1); assert.equal(analyticsReports[0].event, 'cloud_rise_start'); assert.equal(analyticsReports[0].appId, '95993f9ab6f1402a87abe5147827e5e0'); assert.deepEqual(analyticsReports[0].properties, { stage: 1, end_count: 17 }); await call({ action: 'confirm_match', matchId: 'analytics-start' }); await call({ action: 'status' }); assert.equal(analyticsReports.length, 1); assert.equal(JSON.parse(user.cloudRiseState).analyticsQueue, undefined); }); test('analytics reports all 5/7/9 steps and each saved stage award exactly once', async () => { setup(); const { data } = await call({ action: 'start', periodId: 'p1', isDebug: false }); for (let stage = 1; stage <= 3; stage++) { if (stage > 1) assert.equal((await call({ action: 'start_stage', runId: data.run.id, stage })).code, 1); const target = [5,7,9][stage-1]; forceScores(target); await winStage(data.run, stage, target); assert.equal(analyticsReports.filter(e => e.event === 'cloud_rise_succeed').length, stage - 1); assert.equal((await call({ action: 'save_reward', runId: data.run.id, stage, reward: 210, coinAmount: 100 + 210 * stage })).code, 1); await call({ action: 'save_reward', runId: data.run.id, stage, reward: 999, coinAmount: 999 }); await call({ action: 'finish', runId: data.run.id, stage, attemptId: 's' + stage + '-' + target, outcome: 'win' }); const steps = analyticsReports.filter(e => e.event === 'cloud_rise_step' && e.properties.stage === stage); assert.deepEqual(steps.map(e => e.properties), Array.from({length:target}, (_,i) => ({stage, step:i+1, result:'success'}))); } assert.equal(analyticsReports.filter(e => e.event === 'cloud_rise_start').length, 3); assert.deepEqual(analyticsReports.filter(e => e.event === 'cloud_rise_succeed').map(e => e.properties), [1,2,3].map(stage => ({ stage, coin_amount: 210 }))); assert.equal(analyticsReports.filter(e => e.event === 'cloud_rise_fail').length, 0); assert.ok(analyticsReports.every(e => e.appId === '40e3d5c5f2af49a4a074205564ce5dbb')); }); test('analytics loss sends failure step and extra fail event; interrupted rounds also fail once', async () => { for (const outcome of ['lose', 'interrupted']) { setup(); const { data } = await call({ action: 'start', periodId: 'p1' }); forceScores(); await winStage(data.run, 1, 2); await call({ action: 'begin', runId: data.run.id, stage: 1, attemptId: 'analytics-loss' }); const body = { action: 'finish', runId: data.run.id, stage: 1, attemptId: 'analytics-loss', outcome }; assert.equal((await call(body)).code, 1); await call(body); await call({ action: 'status' }); assert.deepEqual(analyticsReports.slice(-2).map(e => [e.event,e.properties]), [ ['cloud_rise_step', { stage:1, step:3, result:'failure' }], ['cloud_rise_fail', { stage:1, step:3 }], ]); assert.equal(analyticsReports.filter(e => e.event === 'cloud_rise_fail').length, 1); } }); test('analytics outage keeps events durable with original environment and IDs without failing play', async () => { setup(); analyticsOffline = true; const start = await call({ action: 'start', periodId: 'p1', isDebug: 'false' }); assert.equal(start.code, 1); const user = db.users.get('u'), queued = JSON.parse(user.cloudRiseState).analyticsQueue; assert.equal(queued.length, 1); assert.equal((await call({ action:'begin', runId:start.data.run.id, stage:1, attemptId:'analytics-offline' })).code, 1); analyticsOffline = false; await call({ action:'status', isDebug:'true' }); assert.deepEqual(analyticsReports, queued, 'retry must not change the originating project or event ID'); assert.equal(JSON.parse(user.cloudRiseState).analyticsQueue, undefined); await call({ action:'status' }); assert.equal(analyticsReports.length, 1); }); test('analytics acknowledgement cannot overwrite concurrent gameplay and retries share the same event ID', async () => { setup(); analyticsOffline = true; const { data } = await call({ action:'start', periodId:'p1' }); analyticsOffline = false; analyticsOnSend = async () => { assert.equal((await call({ action:'begin', runId:data.run.id, stage:1, attemptId:'analytics-race' })).code, 1); }; await call({ action:'status' }); const state = JSON.parse(db.users.get('u').cloudRiseState); assert.equal(state.run.stages[0].attemptId, 'analytics-race'); assert.equal(state.analyticsQueue, undefined); assert.equal(analyticsReports.length, 2, 'concurrent delivery is possible, deduplicated by the same firstCheckId'); assert.equal(analyticsReports[0].id, analyticsReports[1].id); }); test('analytics ignores failed requests and existing history; personal expiration emits only a stage failure', async () => { setup(); const { data } = await call({ action:'start', periodId:'p1' }); const count = analyticsReports.length; assert.equal((await call({ action:'finish', runId:data.run.id, stage:1, attemptId:'not-started', outcome:'win' })).code, 0); assert.equal(analyticsReports.length, count); await call({ action:'status' }); assert.equal(analyticsReports.length, count); const user = db.users.get('u'), state = JSON.parse(user.cloudRiseState); state.run.expiresAt = Date.now()-1; user.cloudRiseState = JSON.stringify(state); assert.equal((await call({ action:'status' })).code, 1); assert.deepEqual(analyticsReports.at(-1).properties, { stage:1, step:1 }); assert.equal(analyticsReports.at(-1).event, 'cloud_rise_fail'); assert.equal(analyticsReports.filter(e => e.event === 'cloud_rise_step').length, 0); await call({ action:'status' }); assert.equal(analyticsReports.length, count+1); }); test('analytics concurrent matching and finish retries use one event identity per committed result', async () => { setup(); const starts = await Promise.all([call({action:'start',periodId:'p1'}),call({action:'start',periodId:'p1'})]); assert.ok(starts.every(r => r.code === 1)); const runId=starts[0].data.run.id; assert.equal(new Set(analyticsReports.filter(e=>e.event==='cloud_rise_start').map(e=>e.id)).size,1); await call({action:'begin',runId,stage:1,attemptId:'analytics-concurrent'}); const results = await Promise.all([0,1].map(()=>call({action:'finish',runId,stage:1,attemptId:'analytics-concurrent',outcome:'win'}))); assert.ok(results.every(r => r.code === 1)); const steps=analyticsReports.filter(e=>e.event==='cloud_rise_step'); assert.ok(steps.length>0);assert.equal(new Set(steps.map(e=>e.id)).size,1); assert.deepEqual(steps[0].properties,{stage:1,step:1,result:'success'}); assert.equal(JSON.parse(db.users.get('u').cloudRiseState).run.stages[0].success_num,1); }); test('analytics outage cannot undo a saved award or turn a retry amount into a second success event', async () => { setup();const {data}=await call({action:'start',periodId:'p1'});forceScores();await winStage(data.run,1,5); analyticsOffline=true; assert.equal((await call({action:'save_reward',runId:data.run.id,stage:1,reward:210,coinAmount:310})).code,1); assert.equal(db.users.get('u').coinAmount,310); const queue=JSON.parse(db.users.get('u').cloudRiseState).analyticsQueue; assert.equal(queue.length,1);assert.deepEqual(queue[0].properties,{stage:1,coin_amount:210}); analyticsOffline=false; assert.equal((await call({action:'save_reward',runId:data.run.id,stage:1,reward:990,coinAmount:1090})).code,1); assert.equal(db.users.get('u').coinAmount,310); assert.deepEqual(analyticsReports.filter(e=>e.event==='cloud_rise_succeed'),queue); }); test('personal Home opening creates one period without enrollment and repeated/concurrent logins preserve it', async()=>{ setup();const now=Date.now(); const [a,b]=await Promise.all([openPeriod(1001,now),openPeriod(1001,now)]); assert.deepEqual(a,b);assert.equal(a.startsAt,now);assert.equal(a.endsAt-now,86400000); assert.equal(db.users.get('u').cloudRiseState,undefined); assert.deepEqual(await openPeriod(1001,now+1000),a); assert.equal(db.cloudRiseRuns.size,0);assert.equal(db.cloudRiseSamples.size,0);assert.equal(analyticsReports.length,0); }); test('Home request owns the unlock decision; the server only deduplicates active periods', async()=>{ setup();configs.get('current').unlockLevel=100; const period=await openPeriod(1001); assert.equal(period.unlockLevel,100); db.users.get('u').levelAmount=101; const next=await openPeriod(1001,period.endsAt+PersonalPeriods.COOLDOWN_MS+1); assert.notEqual(next.periodId,period.periodId); }); test('explicit requests may reopen ended periods without a server cooldown',async()=>{ for(const status of ['failed','completed']){ setup();const now=Date.now(),period=await openPeriod(1001,now),user=db.users.get('u'); const endedAt=now+1000;user.cloudRiseState=JSON.stringify({run:{periodId:period.periodId,status,expiresAt:period.endsAt,stages:[{endedAt}]}}); assert.equal(PersonalPeriods.lastEndedAt(user,endedAt),endedAt); const [a,b]=await Promise.all([openPeriod(1001,endedAt+1),openPeriod(1001,endedAt+1)]); assert.deepEqual(a,b);assert.notEqual(a.periodId,period.periodId); } }); test('Home opening action authenticates without requiring progress or matching',async()=>{ setup();configs.get('current').unlockLevel=100; const request=extra=>api({body:{uid:1001,token:'token',action:'open_period',...extra}}); assert.equal((await request({token:'bad'})).code,0); assert.equal(db.users.get('u').cloudRisePeriod,undefined); const a=await request({}),b=await request({levelAmount:99}); assert.equal(a.code,1);assert.equal(b.code,1);assert.equal(a.data.period.periodId,b.data.period.periodId); assert.equal(db.users.get('u').levelAmount,99);assert.equal(db.users.get('u').cloudRiseState,undefined); assert.equal(db.cloudRiseRuns.size,0);assert.equal(analyticsReports.length,0); }); test('natural expiry cooldown starts at the deadline, not a delayed status request',async()=>{ setup();const now=Date.now(),period=await openPeriod(1001,now),user=db.users.get('u'); assert.equal(PersonalPeriods.lastEndedAt(user,period.endsAt),period.endsAt); user.cloudRiseState=JSON.stringify({run:{periodId:period.periodId,status:'expired',expiresAt:period.endsAt,stages:[{endedAt:period.endsAt+3600000}]}}); assert.equal(PersonalPeriods.lastEndedAt(user,period.endsAt+3600000),period.endsAt); assert.ok(await openPeriod(1001,period.endsAt)); }); test('login and level reports contain no automatic CloudRise opener',async()=>{ for(const file of ['../../login.ts','../../userLevel.ts']){ const source=await readFile(new URL(file,import.meta.url),'utf8'); assert.doesNotMatch(source,/CloudRisePeriods|ensureOnLogin|ensureOnLevelProgress|openForUser/); } }); test('personal matching uses personal deadline and never restarts its countdown',async()=>{ setup();const now=Date.now();db.activityConfigs.get('current').effectiveFrom=now-120000; db.activityConfigs.get('current').publishedAt=now-120001; const period=await openPeriod(1001,now-60000); const prepared=await call({action:'prepare_match',periodId:period.periodId,stage:1,matchId:'personal-match'}); assert.equal(prepared.code,1);assert.equal(prepared.data.run,null); const result=await call({action:'confirm_match',matchId:'personal-match'}); assert.equal(result.code,1);assert.equal(result.data.run.expiresAt,period.endsAt); assert.ok(result.data.run.expiresAt-result.data.run.startedAt<86400000); assert.deepEqual(await openPeriod(1001,now+1000),period); }); test('personal period templates ignore old global end times and freeze per-player settings',async()=>{ setup();const now=Date.now(),row=db.activityConfigs.get('current'); row.config.endsAt=now-1; const first=await openPeriod(1001,now); assert.equal(first.endsAt,now+86400000); row.config.durationHours=48;row.config.pools=[30000,40000,50000]; assert.deepEqual(await openPeriod(1001,now+1000),first); const second=await openPeriod(1001,first.endsAt+PersonalPeriods.COOLDOWN_MS+1); assert.notEqual(second.periodId,first.periodId);assert.equal(second.endsAt-second.startsAt,48*3600000); assert.deepEqual(second.pools,[30000,40000,50000]); }); test('personal ended challenges reopen only by request after cooldown',async()=>{ for(const status of ['failed','completed','expired']){ setup();const period=await openPeriod(1001); const state={version:1,playedPeriods:[period.periodId],run:{id:'old',periodId:period.periodId,status,stage:1,pools:period.pools, startedAt:period.startsAt,expiresAt:period.endsAt,stages:[]},analyticsQueue:[{id:'pending'}]}; db.users.get('u').cloudRiseState=JSON.stringify(state); assert.equal(PersonalPeriods.openPeriodForUser(db.users.get('u'),Date.now()),null); const result=await call({action:'status'});assert.equal(result.code,1);assert.equal(result.data.available,false); assert.equal(db.users.get('u').cloudRisePeriod.periodId,period.periodId); const preserved=db.users.get('u').cloudRiseState; afterCooldown();const next=await openPeriod(1001); assert.notEqual(next.periodId,period.periodId);assert.equal(db.users.get('u').cloudRiseState,preserved); assert.deepEqual(await openPeriod(1001),next); } }); test('personal Home opening keeps pending reward receipts and active legacy challenges intact',async()=>{ setup();const period=await openPeriod(1001); const start=await call({action:'start',periodId:period.periodId});forceScores();await winStage(start.data.run,1,5); const before=db.users.get('u').cloudRiseState; assert.deepEqual(await openPeriod(1001),period);assert.equal(db.users.get('u').cloudRiseState,before); const state=JSON.parse(before);state.run.status='failed';db.users.get('u').cloudRiseState=JSON.stringify(state); afterCooldown();const unsaved=db.users.get('u').cloudRiseState,next=await openPeriod(1001); assert.notEqual(next.periodId,period.periodId);assert.equal(db.users.get('u').cloudRiseState,unsaved); assert.equal((await call({action:'save_reward',runId:start.data.run.id,stage:1,reward:210,coinAmount:310})).code,1); assert.equal((await call({action:'start',periodId:next.periodId})).code,1); setup();const legacy=(await call({action:'start',periodId:'p1'})).data.run; delete db.users.get('u').cloudRisePeriod; const saved=db.users.get('u').cloudRiseState,recovered=await openPeriod(1001); assert.equal(recovered.periodId,legacy.periodId);assert.equal(recovered.endsAt,legacy.expiresAt); assert.equal(db.users.get('u').cloudRisePeriod,undefined);assert.equal(db.users.get('u').cloudRiseState,saved); }); test('personal Home opening does not invent a template, rejects bad settings and isolates players',async()=>{ setup();db.users.get('u').cloudRiseState=''; assert.ok(await openPeriod(1001),'empty legacy state still allows login opening'); setup();configs.clear();assert.equal(await openPeriod(1001),null); assert.equal(db.users.get('u').cloudRisePeriod,undefined); setup();configs.get('current').durationHours=0;await assert.rejects(openPeriod(1001)); setup();db.users.set('other',{_id:'other',onlyId:1002,token:'other-token',levelAmount:1}); const a=await openPeriod(1001),b=await openPeriod(1002); assert.notEqual(a.periodId,b.periodId);assert.equal(b.unlockLevel,1); }); test('test-only fractional duration works through Home opening, matching, expiry and reopening',async()=>{ const oldEnv=process.env.PAYMENT_APP_ENV,realNow=Date.now;let now=realNow();Date.now=()=>now; try { process.env.PAYMENT_APP_ENV='test';setup();configs.get('current').durationHours=0.1; const period=await openPeriod(1001); assert.equal(period.endsAt-period.startsAt,360000); const draft=await call({action:'prepare_match',stage:1,periodId:period.periodId,matchId:'short-test-match'}); assert.equal(draft.code,1);assert.equal(draft.data.period.durationHours,0.1); const joined=await call({action:'confirm_match',matchId:'short-test-match'}); assert.equal(joined.code,1);assert.equal(joined.data.run.expiresAt,period.endsAt); now=period.endsAt-1;assert.equal((await call({action:'status'})).data.run.status,'playing'); now=period.endsAt;assert.equal((await call({action:'status'})).data.run.status,'expired'); const next=await openPeriod(1001); assert.notEqual(next.periodId,period.periodId);assert.equal(next.endsAt-Date.now(),360000); } finally { Date.now=realNow;if(oldEnv===undefined)delete process.env.PAYMENT_APP_ENV;else process.env.PAYMENT_APP_ENV=oldEnv; } }); test('fractional duration is rejected outside server test mode and invalid durations stay rejected',async()=>{ const oldEnv=process.env.PAYMENT_APP_ENV; try { for(const env of [undefined,'production','test']) { if(env===undefined)delete process.env.PAYMENT_APP_ENV;else process.env.PAYMENT_APP_ENV=env; assert.equal(PersonalPeriods.validDurationHours(0.1),env==='test'); assert.equal(PersonalPeriods.validDurationHours(1.5),env==='test'); assert.equal(PersonalPeriods.validDurationHours(1),true); for(const bad of [0,-0.1,NaN,Infinity,'0.1',null,0.00000001,Number.MAX_SAFE_INTEGER]) assert.equal(PersonalPeriods.validDurationHours(bad),false,String(bad)); setup();configs.get('current').durationHours=0.1; if(env!=='test') { await assert.rejects(openPeriod(1001)); assert.equal((await call({action:'start',periodId:'p1',isDebug:false})).code,0); } } } finally {if(oldEnv===undefined)delete process.env.PAYMENT_APP_ENV;else process.env.PAYMENT_APP_ENV=oldEnv;} }); test('admin accepts fractional hours only in test mode and active personal deadlines remain frozen',async()=>{ const oldEnv=process.env.PAYMENT_APP_ENV,oldToken=process.env.ADMIN_TOKEN; try { setup();process.env.ADMIN_TOKEN='short-test-admin';process.env.PAYMENT_APP_ENV='production'; const config={...configs.get('current'),periodId:'short-admin',startsAt:Date.now()+60000,endsAt:Date.now()+120000,durationHours:0.1}; const body={action:'save',adminToken:'short-test-admin',configVersion:'short-v1',config}; assert.equal((await adminApi({body})).code,0); process.env.PAYMENT_APP_ENV='test';assert.equal((await adminApi({body})).code,1); const first=await openPeriod(1001); configs.get('current').durationHours=0.1; assert.deepEqual(await openPeriod(1001),first); } finally { if(oldEnv===undefined)delete process.env.PAYMENT_APP_ENV;else process.env.PAYMENT_APP_ENV=oldEnv; if(oldToken===undefined)delete process.env.ADMIN_TOKEN;else process.env.ADMIN_TOKEN=oldToken; } }); test('expiry advances opponents one round for all stages, including never-played and final-step challenges',()=>{ for(const [number,target] of [[1,5],[2,7],[3,9]]) for(const score of [0,target-1]) { const run=runWith([...Array(40).fill(target),...Array(59).fill(score)]),stage=run.stages[0]; run.stage=stage.stage=number;stage.target=target;stage.success_num=stage.round=score; const results=structuredClone(stage.results); assert.equal(Rules.expire(run,run.expiresAt),true); assert.equal(stage.round,score+1);assert.equal(stage.success_num,score);assert.equal(stage.survivors,40); assert.equal(Rules.publicRun(run).stages[0].opponents.filter(o=>o.alive).length,40); assert.deepEqual(stage.results,results);assert.equal(stage.reward,0); const saved=JSON.stringify(run);assert.equal(Rules.expire(run,run.expiresAt+1000),false);assert.equal(JSON.stringify(run),saved); } }); test('expiry between stages preserves the completed stage and its reward count',()=>{ const run=runWith(),stage=run.stages[0];run.status='waiting';stage.status='won';stage.success_num=stage.round=5; stage.rewardSaved=true;stage.reward=210; const before=structuredClone(stage);Rules.expire(run,run.expiresAt); assert.deepEqual(stage,before);assert.equal(run.status,'expired'); }); test('enabled switch selects latest effective published version without fallback and defaults legacy to true',async()=>{ setup();const now=Date.now(); assert.ok(await PersonalPeriods.templateAt(now),'legacy enabled defaults true'); const old=db.activityConfigs.get('current');old.enabled=true; const latest={...structuredClone(old),_id:'disabled',configVersion:'disabled',effectiveFrom:now-100,publishedAt:now-101,enabled:false}; db.activityConfigs.set(latest._id,latest); db.activityConfigs.set('future',{...structuredClone(latest),_id:'future',configVersion:'future',effectiveFrom:now+1000,enabled:true}); db.activityConfigs.set('draft',{...structuredClone(latest),_id:'draft',status:'draft',effectiveFrom:now-1,enabled:true}); assert.equal(await PersonalPeriods.templateAt(now),null); assert.equal(await openPeriod(1001,now),null); assert.equal(db.users.get('u').cloudRisePeriod,undefined); const reopened=await openPeriod(1001,now+1000); assert.equal(reopened.configVersion,'future'); }); test('enabled switch preserves existing personal enrollment gameplay and reward settlement',async()=>{ setup();const now=Date.now(),period=await openPeriod(1001,now); db.activityConfigs.get('current').enabled=false; assert.deepEqual(await openPeriod(1001,now+1),period); assert.deepEqual(PersonalPeriods.openPeriodForUser(db.users.get('u'),now+1),period); assert.equal((await call({action:'status'})).data.available,true); const draft=await call({action:'prepare_match',stage:1,periodId:period.periodId,matchId:'enabled-existing'}); assert.equal(draft.code,1); const start=await call({action:'confirm_match',matchId:'enabled-existing'}); assert.equal(start.code,1);forceScores();await winStage(start.data.run,1,5); assert.equal((await call({action:'save_reward',runId:start.data.run.id,stage:1,reward:100,coinAmount:200})).code,1); assert.equal((await call({action:'start_stage',runId:start.data.run.id,stage:2})).code,1); }); test('enabled switch blocks reopening ended or expired personal periods without changing their state',async()=>{ for(const status of ['failed','completed','expired','timeout']) { setup();const now=Date.now(),period=await openPeriod(1001,now),user=db.users.get('u'); user.cloudRiseState=JSON.stringify({version:1,playedPeriods:[period.periodId],run:{periodId:period.periodId,status,stages:[]}}); db.activityConfigs.get('current').enabled=false; const before=structuredClone(user),at=status==='timeout'?period.endsAt:now+1; assert.equal(await openPeriod(1001,at),null); assert.deepEqual(user,before); db.activityConfigs.get('current').enabled=true; const next=await openPeriod(1001,period.endsAt+PersonalPeriods.COOLDOWN_MS+1); assert.notEqual(next.periodId,period.periodId);assert.equal(user.cloudRiseState,before.cloudRiseState); } }); test('admin enabled switch persists at version root validates booleans and preserves publish idempotency',async()=>{ const oldToken=process.env.ADMIN_TOKEN; try { setup();process.env.ADMIN_TOKEN='enabled-admin'; const config={...configs.get('current'),periodId:'enabled-admin',startsAt:Date.now()+60000,endsAt:Date.now()+3600000}; const body={action:'save',adminToken:'enabled-admin',configVersion:'enabled-v1',config,enabled:false}; for(const enabled of ['false',0,null]) assert.equal((await adminApi({body:{...body,enabled}})).code,0); const saved=await adminApi({body});assert.equal(saved.code,1);assert.equal(saved.data.enabled,false); const row=[...db.activityConfigs.values()].find(r=>r.configVersion==='enabled-v1'); assert.equal(row.enabled,false);assert.equal('enabled' in row.config,false); assert.equal((await adminApi({body})).code,1); assert.equal((await adminApi({body:{...body,enabled:true}})).code,0); assert.equal((await adminApi({body:{action:'read',adminToken:'enabled-admin',periodId:config.periodId}})).data.enabled,false); const defaults=await adminApi({body:{...body,configVersion:'enabled-default',enabled:undefined}}); assert.equal(defaults.code,1);assert.equal(defaults.data.enabled,true); } finally {if(oldToken===undefined)delete process.env.ADMIN_TOKEN;else process.env.ADMIN_TOKEN=oldToken;} });