feat: 百人赛匹配时从固定玩家池中匹配玩家信息,减轻服务器压力

This commit is contained in:
guanchao 2026-09-24 20:27:16 +08:00
parent 4c9bf507a7
commit c5b68757db
9 changed files with 40191 additions and 22 deletions

View File

@ -45,7 +45,7 @@ cloudRise/
`rules` 仅供内部导入常量与规则函数,没有默认请求处理函数,YAML 使用 `methods: []`。它与统计模块都需发布以支持内部导入,但不作为 HTTP 接口开放;对外玩家接口为 `cloudRise/index`,运营接口为校验管理密钥的 `cloudRise/admin`。
`stats` 只导出 `LEVEL_STATS`,没有默认请求处理函数,YAML 使用 `methods: []` 关闭 HTTP 调用;仍需发布该模块供 `@/cloudRise/stats` 内部导入。生成器不会再生成对外处理函数,`--check` 校验导出与数据内容并允许格式化。若线上已发布旧版本,需要同步更新其 HTTP 方法配置。
`stats` 导出 `LEVEL_STATS` 和 `OPPONENT_PROFILES`,没有默认请求处理函数,YAML 使用 `methods: []` 关闭 HTTP 调用;仍需发布该模块供 `@/cloudRise/stats` 内部导入。生成器同时校验关卡统计和玩家资料池,`--check` 校验导出与数据内容并允许格式化。若线上已发布旧版本,需要同步更新其 HTTP 方法配置。
共用的 `login`、`userCoin`、`jungleTreasure` 保留在原目录,继续使用原有登录、金币保存与丛林宝藏结算逻辑。数据库集合和存档格式不因本次目录整理而改变。此次仅修改本地文件,没有部署或删除线上旧接口。
@ -178,8 +178,19 @@ save 不再覆盖当前期,必须指定新的 configVersion 发布未来配置
## 匹配与兜底
- 资料候选池:`users.taskTime` 大于 0 且早于当前时间减三个日历月,排除玩家本人,随机取最多 500 条。月底日期向目标月月末收敛。
- 资料候选池:使用 `stats.OPPONENT_PROFILES` 中固定的 5,000 名玩家,不再在匹配时扫描线上 `users`。按 2026-09-24 19:05:38(北京时间)筛选 `0 < taskTime < 2026-07-24 19:05:38`,沿用两个日历月及月底收敛规则;按 onlyId 去重,4,000 名 HTTPS 远程头像、1,000 名游戏内头像。比例指整个资料池,每场仍随机抽取,不保证每场严格 8:2。
- 原始全量导出不入库;`data/cloudRiseProfiles.json` 仅保存 onlyId、taskTime、username、useravatar、useravatarIcon 和筛选汇总、源文件 SHA256。stats 不导出 taskTime,匹配时排除玩家本人,并在发送对手信息前移除 onlyId;没有登录凭据或资产字段。资料池是筛选时的快照,不随用户之后登录自动刷新。
- 有候选资料但不足 99 条时可重复使用;头像昵称抽取与成绩抽取独立。头像保留 `useravatarIcon` 字符串,兼容 `icon_9` 和远程头像。
重新采样(JSON-lines 用户导出;第二个参数为明确的筛选时间):
```powershell
node laf-cloud/functions/cloudRise/tools/select-cloud-rise-profiles.mjs <users.json路径> 2026-09-24T11:05:38.000Z
node laf-cloud/functions/cloudRise/tools/generate-cloud-rise-stats.mjs
node laf-cloud/functions/cloudRise/tools/generate-cloud-rise-stats.mjs --check
```
采样优先满足 4,000/1,000;某类不足时从另一类补足 5,000,总人数不足则报错且不写入快照。发布时先更新 `cloudRise/stats`,再更新 `cloudRise/index`,无需上传全量用户文件或创建集合。
- 候选池为空时返回匹配失败,首次报名不消耗资格;已生成名单永不通过重试重抽。
- 起点权重为进入人数乘离散正态权重,先在正整数上确定截断分布标准差,再对 1~1960 范围的乘积归一化。
- 有真实样本时按样本次数抽取;没有样本时逐关 Bernoulli 模拟,首次失败结束。

File diff suppressed because it is too large Load Diff

View File

@ -92,12 +92,9 @@ export function inactiveBefore(now: number): number {
}
async function makeStage(stage: number, start: number, onlyId: number, now: number, rewardPool: number): Promise<any> {
const profiles = await users().aggregate([
{ $match: { onlyId: { $ne: onlyId }, taskTime: { $gt: 0, $lt: inactiveBefore(now) } } },
{ $sample: { size: 500 } },
{ $project: { _id: 0, username: 1, useravatar: 1, useravatarIcon: 1 } },
]).toArray();
const pool = profiles.map((p: any) => ({
// Frozen inactive-user snapshot; no live users scan during matching.
// Keep account identifiers internal and still exclude the current player.
const pool = Stats.OPPONENT_PROFILES.filter((p: any) => p.onlyId !== onlyId).map((p: any) => ({
username: String(p.username || "玩家"),
useravatar: typeof p.useravatar === "string" ? p.useravatar : "",
useravatarIcon: String(p.useravatarIcon || "icon_0")

File diff suppressed because one or more lines are too long

View File

@ -94,7 +94,9 @@ const configs = {
values:()=>[...db.activityConfigs.values()].map(r=>r.config).values(),
get size(){return db.activityConfigs.size;}
};
let sampleFails = false, sampleAppendFails = 0, profiles = [profile];
let sampleFails = false, sampleAppendFails = 0;
const profiles = [profile];
globalThis.__cloudRiseTestProfiles = profiles;
const indexes = [];
function field(doc, path) {
const parts = path.split('.');
@ -170,7 +172,7 @@ globalThis.__cloudRiseCloud = { mongo: { db: { collection(name) { return {
return structuredClone(rows[0] || null);
},
aggregate(pipeline) { return { async toArray() {
if (name === 'users') { assert.deepEqual(pipeline[0].$match.onlyId, { $ne: 1001 }); return profiles; }
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) {
@ -249,13 +251,16 @@ const hooks = registerHooks({
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: new URL('../stats.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);' };
@ -274,7 +279,7 @@ function afterCooldown(){const at=PersonalPeriods.lastEndedAt(db.users.get('u'),
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 = [profile];
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 });
@ -376,13 +381,19 @@ test('all three stages complete in sequence with three independent samples and o
assert.equal((await call({ action: 'start', periodId: 'p1' })).code, 0);
});
test('empty inactive pool does not consume eligibility; profile icon survives matching', async () => {
setup(); profiles = [];
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 = [profile]; const result = await call({ action: 'start', periodId: 'p1' });
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);
}
});

View File

@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { OPPONENT_PROFILES, LEVEL_STATS } from '../stats.ts';
const snapshot = JSON.parse(await readFile(new URL('../data/cloudRiseProfiles.json', import.meta.url), 'utf8'));
test('snapshot has 5,000 unique inactive players with a 4,000/1,000 avatar split', () => {
assert.equal(snapshot.profiles.length, 5000);
assert.equal(new Set(snapshot.profiles.map(row => row.onlyId)).size, 5000);
const sampled = new Date(snapshot.sampledAt), cutoff = new Date(snapshot.inactiveBefore);
assert.equal(sampled.getUTCFullYear(), 2026);
assert.equal(cutoff.toISOString(), '2026-07-24T11:05:38.000Z');
let real = 0;
for (const row of snapshot.profiles) {
assert(Number.isSafeInteger(row.onlyId) && row.onlyId > 0);
assert(row.taskTime > 0 && row.taskTime < cutoff.getTime());
assert.deepEqual(Object.keys(row).sort(), ['onlyId', 'taskTime', 'useravatar', 'useravatarIcon', 'username']);
const avatar = /^https:\/\//.test(row.useravatarIcon) ? row.useravatarIcon : row.useravatar;
if (/^https:\/\//.test(avatar)) { assert(new URL(avatar).hostname); real++; }
else assert.match(row.useravatar, /^(?:icon_)?\d+$/);
}
assert.equal(real, 4000);
assert.deepEqual(snapshot.selected, { real: 4000, game: 1000 });
});
test('stats preserves level data and contains only the selected display identities', async () => {
const levels = JSON.parse(await readFile(new URL('../data/cloudRiseLevelStats.json', import.meta.url), 'utf8'));
assert.deepEqual(LEVEL_STATS, levels.levels);
assert.deepEqual(OPPONENT_PROFILES, snapshot.profiles.map(({ taskTime, ...profile }) => profile));
assert(OPPONENT_PROFILES.every(row => Object.keys(row).sort().join(',') === 'onlyId,useravatar,useravatarIcon,username'));
});

View File

@ -5,15 +5,38 @@ for (const [index, row] of source.levels.entries()) {
if (row.level !== index + 1 || !Number.isSafeInteger(row.enter_total) || row.enter_total < 0
|| (row.pass_rate !== null && !(row.pass_rate >= 0 && row.pass_rate <= 1))) throw new Error('Invalid row ' + row.level);
}
const text = '// Generated by tools/generate-cloud-rise-stats.mjs; edit data/cloudRiseLevelStats.json instead.\n'
const profileSource = JSON.parse(await readFile(new URL('../data/cloudRiseProfiles.json', import.meta.url), 'utf8'));
const cutoff = Date.parse(profileSource.inactiveBefore), seen = new Set();
const counts = { real: 0, game: 0 };
if (!Number.isFinite(cutoff) || profileSource.profiles.length !== 5000) throw new Error('Invalid profile snapshot');
const profiles = profileSource.profiles.map(row => {
if (!Number.isSafeInteger(row.onlyId) || row.onlyId <= 0 || seen.has(row.onlyId)
|| !Number.isSafeInteger(row.taskTime) || row.taskTime <= 0 || row.taskTime >= cutoff
|| typeof row.username !== 'string' || !row.username.trim()
|| typeof row.useravatar !== 'string' || typeof row.useravatarIcon !== 'string'
|| Object.keys(row).sort().join(',') !== 'onlyId,taskTime,useravatar,useravatarIcon,username') {
throw new Error('Invalid or duplicate profile');
}
seen.add(row.onlyId);
const displayed = /^https:\/\//.test(row.useravatarIcon) ? row.useravatarIcon : row.useravatar;
if (/^https:\/\//.test(displayed)) counts.real++;
else if (/^(?:icon_)?\d+$/.test(row.useravatar)) counts.game++;
else throw new Error('Unknown avatar type');
return { onlyId: row.onlyId, username: row.username, useravatar: row.useravatar, useravatarIcon: row.useravatarIcon };
});
if (counts.real !== profileSource.selected.real || counts.game !== profileSource.selected.game) throw new Error('Avatar counts do not match');
const text = '// Generated by tools/generate-cloud-rise-stats.mjs; edit data/cloudRiseLevelStats.json and data/cloudRiseProfiles.json instead.\n'
+ '// Source SHA256: ' + source.source_sha256 + '\n'
+ 'export const LEVEL_STATS = ' + JSON.stringify(source.levels) + ';\n';
+ 'export const LEVEL_STATS = ' + JSON.stringify(source.levels) + ';\n'
+ '// Profile snapshot: ' + profileSource.sampledAt + '; inactive before ' + profileSource.inactiveBefore + '\n'
+ 'export const OPPONENT_PROFILES = [\n' + profiles.map(row => JSON.stringify(row)).join(',\n') + '\n];\n';
const target = new URL('../stats.ts', import.meta.url);
if (process.argv.includes('--check')) {
// Formatting is editable; validate the data and the internal-only export contract.
const generated = await import(target.href);
if (Object.keys(generated).length !== 1 || !('LEVEL_STATS' in generated)
|| JSON.stringify(generated.LEVEL_STATS) !== JSON.stringify(source.levels)) {
if (Object.keys(generated).sort().join(',') !== 'LEVEL_STATS,OPPONENT_PROFILES'
|| JSON.stringify(generated.LEVEL_STATS) !== JSON.stringify(source.levels)
|| JSON.stringify(generated.OPPONENT_PROFILES) !== JSON.stringify(profiles)) {
throw new Error('Generated stats are stale or expose unexpected exports');
}
} else await writeFile(target, text);

View File

@ -0,0 +1,72 @@
import { createReadStream } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { createInterface } from 'node:readline';
import { createHash } from 'node:crypto';
// Input is a Mongo JSON-lines export. Never copy credentials or account assets.
const [input, asOf] = process.argv.slice(2);
if (!input || !asOf || !Number.isFinite(Date.parse(asOf))) {
throw new Error('Usage: node select-cloud-rise-profiles.mjs <users.json> <ISO sampling time>');
}
const cutoff = new Date(asOf), day = cutoff.getUTCDate();
cutoff.setUTCDate(1); cutoff.setUTCMonth(cutoff.getUTCMonth() - 2);
cutoff.setUTCDate(Math.min(day, new Date(Date.UTC(cutoff.getUTCFullYear(), cutoff.getUTCMonth() + 1, 0)).getUTCDate()));
const groups = { real: { count: 0, rows: [] }, game: { count: 0, rows: [] } };
const seen = new Set(), hash = createHash('sha256');
let total = 0, inactive = 0, invalidProfile = 0, duplicates = 0, seed = 20260924;
function random() {
seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
return (seed >>> 0) / 4294967296;
}
function avatarKind(row) {
const icon = String(row.useravatarIcon || ''), avatar = String(row.useravatar || '');
// Match CloudRisePanel.avatar: an HTTPS icon takes precedence over useravatar.
const displayed = /^https:\/\//.test(icon) ? icon : avatar;
if (/^https:\/\//.test(displayed)) {
try { if (new URL(displayed).hostname) return 'real'; } catch {}
}
if (/^(?:icon_)?\d+$/.test(avatar) && !/^https?:/.test(icon)) return 'game';
return null;
}
const stream = createReadStream(input);
stream.on('data', chunk => hash.update(chunk));
for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) {
if (!line.trim()) continue;
const row = JSON.parse(line.replace(/^\uFEFF/, '')); total++;
if (!Number.isSafeInteger(row.taskTime) || row.taskTime <= 0 || row.taskTime >= cutoff.getTime()) continue;
inactive++;
const kind = avatarKind(row);
if (!kind || !Number.isSafeInteger(row.onlyId) || row.onlyId <= 0
|| typeof row.username !== 'string' || !row.username.trim()) { invalidProfile++; continue; }
if (seen.has(row.onlyId)) { duplicates++; continue; }
seen.add(row.onlyId);
const profile = { onlyId: row.onlyId, taskTime: row.taskTime, username: row.username,
useravatar: String(row.useravatar || ''), useravatarIcon: String(row.useravatarIcon || 'icon_0') };
const group = groups[kind]; group.count++;
// Keep enough unbiased candidates to fill all 5,000 slots if one category is scarce.
if (group.rows.length < 5000) group.rows.push(profile);
else {
const index = Math.floor(random() * group.count);
if (index < 5000) group.rows[index] = profile;
}
}
if (groups.real.count + groups.game.count < 5000) throw new Error('Fewer than 5,000 eligible unique players');
// Shuffle the reservoirs before taking a smaller sample from either category.
for (const group of Object.values(groups)) {
for (let i = group.rows.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[group.rows[i], group.rows[j]] = [group.rows[j], group.rows[i]];
}
}
let realCount = Math.min(4000, groups.real.count), gameCount = Math.min(1000, groups.game.count);
realCount += Math.min(5000 - realCount - gameCount, groups.real.count - realCount);
gameCount += 5000 - realCount - gameCount;
const profiles = [...groups.real.rows.slice(0, realCount), ...groups.game.rows.slice(0, gameCount)]
.sort((a, b) => a.onlyId - b.onlyId);
const metadata = { sampledAt: new Date(asOf).toISOString(), inactiveBefore: cutoff.toISOString(),
sourceSha256: hash.digest('hex'), total, inactive, invalidProfile, duplicates,
eligible: { real: groups.real.count, game: groups.game.count },
selected: { real: realCount, game: gameCount }, seed: 20260924 };
await writeFile(new URL('../data/cloudRiseProfiles.json', import.meta.url),
JSON.stringify({ ...metadata, profiles }, null, 2) + '\n');
console.log(JSON.stringify(metadata, null, 2));

View File

@ -102,7 +102,7 @@ main 中不存在百人赛模块,按本次首次上线场景无需运行配置
4. 正式环境 `PAYMENT_APP_ENV` 应为 `production` 或保持未设置的生产默认行为,不用 `test`;正式模板 `durationHours` 必须为正整数小时。现有依赖 `thinkingdata-node` 需支持 `trackFirst/flush/close`,登录继续依赖 `ip2region`。实际生产安装状态需在控制台确认。
5. 对 `cloudRise/admin` 发送 `save`,参考 `functions/cloudRise/cloudRise-config.publish.example.json`:替换 adminToken、configVersion、periodId、startsAt、endsAt,核对正式门槛、时长和三个奖池。示例不是自动发布的生产配置;`startsAt` 必须晚于实际保存时间,版本标识不可覆盖。`enabled: true` 在请求顶层,不放进 config。
6. 模板生效后成功登录才创建个人期。`endsAt` 不会让模板自动停止以后登录的新开期;个人结束时间是登录开期时间加 durationHours。停止新开期时,将最新已生效版本记录的外层 `enabled` 设为 `false`,不要删除模板。已有个人期、挑战和待保存奖励继续有效;这是暂停新开期,不是立即清空所有活动。
7. 匹配资料池按用户确认保留 **超过 2 个自然月未活跃** 的现有规则:`users.onlyId != 当前玩家`、`0 < taskTime < inactiveBefore(now)`。没有符合条件的玩家资料时匹配会失败;`cloudRiseSamples` 可以为空,用户头像昵称资料池不可为空。旧客户端 PRD 中“三个月”的文字应以本次确认为准。
7. 匹配资料池已从用户导出中筛选并内嵌到 `cloudRise/stats`:5,000 名玩家(4,000 远程头像、1,000 游戏内头像),筛选时 `taskTime` 距 2026-09-24 19:05:38 北京时间严格超过两个日历月。匹配排除玩家本人,不再依赖线上 `users` 资料池扫描;`cloudRiseSamples` 仍可为空。更新时先发布带 `OPPONENT_PROFILES` 的 `cloudRise/stats`,再发布 `cloudRise/index`,不创建新集合。
8. 达到门槛的专用账号执行:登录 → 携带 `users.onlyId` 和 token 查 `activityConfig/list` → `cloudRise/index` 的 status → prepare_match → cancel_match → 重新 prepare_match/confirm_match → begin/finish → save_reward → 下一阶段。检查仅有百人赛入口、99 个对手、同一期截止时间不延长、重复奖励上报不重复入账、失败/到期/三阶段完成后列表隐藏、下次登录才重新开期。
9. 检查普通登录和普通订单补发保持正常;确认没有黄金矿工查询、订单创建或定时器被新增。核对样本、归档、金币余额和真实埋点。匿名活动列表为空是预期行为,不能用空请求判断百人赛配置是否正常。