server/laf-cloud/functions/cloudRise/tests/cloud-rise.test.mjs

1633 lines
98 KiB
JavaScript

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)<field(b,key)?-1:1)*direction;}return 0;});return this; },
limit(n) { rows = rows.slice(0, n); return this; },
async toArray() { return structuredClone(rows); },
};
},
async deleteOne(query) {
const row = [...db[name].values()].find(doc => 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;}
});