diff --git a/laf-cloud/functions/activityConfig/tests/list.test.mjs b/laf-cloud/functions/activityConfig/tests/list.test.mjs index c9f52c8..99fb894 100644 --- a/laf-cloud/functions/activityConfig/tests/list.test.mjs +++ b/laf-cloud/functions/activityConfig/tests/list.test.mjs @@ -76,7 +76,7 @@ const hooks = registerHooks({ }, }); const { default: api, activityList } = await import('../list.ts'); -const { initial } = await import('../../pyramid/api.ts'); +const { initial, UNLOCK_LEVEL } = await import('../../pyramid/api.ts'); hooks.deregister(); const realNow = Date.now; beforeEach(() => { players.length = 0; pyramidConfigs.length = 0; pyramidPlayers.length = 0; unavailable = false; enabled = true; Date.now = () => NOW; }); @@ -203,7 +203,7 @@ function publishPyramid(config = pyramidConfig()) { test('public list includes pyramid schedule but does not create player progress', async () => { const row = publishPyramid(pyramidConfig(NOW + 5000, NOW + DAY)); let result = await api({}); assert.equal(result.code, 1); assert.deepEqual(result.data.activities, [{ - activityId: 'pyramid', periodId: row.config.periodId, configVersion: row.configVersion, startsAt: NOW + 5000, endsAt: NOW + DAY, unlockLevel: 50, phase: 'upcoming' + activityId: 'pyramid', periodId: row.config.periodId, configVersion: row.configVersion, startsAt: NOW + 5000, endsAt: NOW + DAY, unlockLevel: UNLOCK_LEVEL, phase: 'upcoming' }]); assert.equal(result.data.refreshAt, NOW + 5000); assert.equal(pyramidPlayers.length, 0); row.enabled = false; assert.deepEqual((await api({})).data.activities, []); @@ -211,6 +211,7 @@ test('public list includes pyramid schedule but does not create player progress' test('disabled pyramid is visible only to its existing participant identified by users._id', async () => { const user = personalUser(); players.push(user); const row = publishPyramid(); + user.levelAmount = UNLOCK_LEVEL; pyramidPlayers.push(initial(user._id, row.config)); row.enabled = false; assert.deepEqual((await api({})).data.activities, []); const own = await api({ body: { uid: user.onlyId, token: user.token } }); diff --git a/laf-cloud/functions/pyramid/README.md b/laf-cloud/functions/pyramid/README.md index dca8865..b1205aa 100644 --- a/laf-cloud/functions/pyramid/README.md +++ b/laf-cloud/functions/pyramid/README.md @@ -6,44 +6,57 @@ ## 配置 -### 每周自动开放(2026-10-15) +### 按天配置自动循环(2026-10-15) -正式规则为北京时间每周五 21:00(含)至周日 21:00(不含),持续 48 小时。 -在管理员发布的 `period` 中增加 `schedule: "weekly"` 即启用自动周循环;不带该字段的历史配置仍是一次性活动。 -服务器根据时间计算本期,不依赖定时器开期,也不需要每周复制发布配置。 +在管理员发布的 `period.schedule` 中配置首期开启时间、开期间隔天数和每期持续天数。 +服务器根据时间计算本期,不依赖定时器开期,也不需要每期复制发布配置。一天按 24 小时计算,目前支持北京时间 `Asia/Shanghai`。 +`intervalDays`、`durationDays` 均为正整数,持续天数不能超过间隔天数;相等时两期首尾衔接。 首次切换及以后修改配置均使用现有 `/pyramid/api` 管理接口: - `action: "admin"`、`operation: "publish"`,`adminToken` 使用服务端 `ADMIN_TOKEN` 对应凭证。 -- `period` 沿用已确认的正式奖励配置(包括 `assignmentMode`、`audience`、`offer` 或 `levels`),增加 `schedule: "weekly"`。 -- `period.startAt` 必须为未来某个北京时间周五 21:00,`period.endAt` 为该周日 21:00;两者表示此版本的首期。 -- `period.periodId` 为 `pyramid:YYYY-MM-DD`,日期取首期周五;`period.configVersion` 使用新且唯一的版本号。 -- `effectiveFrom` 省略或等于 `period.startAt`;`environment` 必须匹配部署环境。周循环配置在测试环境也不允许发布已开始的首期。 -- `enabled: true` 启用;发布未来周的新版本并设 `enabled: false` 可停用该周及之后的自动开放,直至下一份启用版本生效。 +- `period` 沿用已确认的正式奖励配置(包括 `assignmentMode`、`audience`、`offer` 或 `levels`),添加下方的 `schedule` 对象。 +- `schedule.anchorAt` 必须采用 `YYYY-MM-DDTHH:mm:ss+08:00` 格式,表示此配置版本的首期开启时间。 +- `period.periodId/startAt/endAt` 可以省略,服务端从排期推导并存储;显式填写时必须与推导值一致。`configVersion` 使用新且唯一的版本号。 +- `effectiveFrom` 省略或等于 `anchorAt` 转换后的毫秒时间戳;必须是未来时间,测试环境也不允许发布已开始的循环首期。`environment` 必须匹配部署环境。 +- `enabled: true` 启用;发布未来生效的新版本并设 `enabled: false` 可停用之后的自动开放,直至下一份启用版本生效。 例如从 2026-10-16 开始的排期字段(需合并进完整的正式 `period` 配置,以下不是完整发布请求): ```json { - "schedule": "weekly", - "periodId": "pyramid:2026-10-16", - "configVersion": "pyramid_weekly_20261016_1", - "environment": "production", - "startAt": 1792155600000, - "endAt": 1792328400000 + "schedule": { + "type": "recurring", + "timezone": "Asia/Shanghai", + "anchorAt": "2026-10-16T21:00:00+08:00", + "intervalDays": 7, + "durationDays": 2 + }, + "configVersion": "pyramid_recurring_20261016_1", + "environment": "production" } ``` -后续自动生成 `pyramid:2026-10-23`、`pyramid:2026-10-30` 等期次,奖励配置沿用所选版本。 -修改只从未来周的边界生效;已有玩家配置、订单和奖励快照不重写。首次切换必须在所有已发布单期结束之后,冲突返回 `PERIOD_OVERLAP`;切换后禁止再发布覆盖周循环时间范围的单期配置。 +此例为每周五 21:00(含)至周日 21:00(不含);首期 `startAt=1792155600000`、`endAt=1792328400000`。 +新期次 ID 为 `pyramid:<本期开启毫秒时间戳>`,例如 `pyramid:1792155600000`,不与旧日期格式混淆。 +改为 `intervalDays: 14, durationDays: 3` 即每隔 14 天开启、每期持续 3 天;首期可为任意日期及时间,不再限制周五。 + +选配置时在同一活动、部署环境的已发布版本中,筛选 `effectiveFrom <= 查询时刻` 且 `publishedAt <= 已知发布截止时刻`,按 `effectiveFrom` 降序、再按 `publishedAt` 降序选择第一条。两个时间都相同的不同版本视为冲突,不能靠版本名称决定优先级。 +普通玩法查询使用当前服务器时间;预告未来期时以未来开期时间选择已生效版本,但 `publishedAt` 截止于当前时间。最新选中版本停用时不回退旧版。 +例如 A 于 10 月 16 日生效、B 于 10 月 23 日生效,即使提前发布 B,10 月 16 日仍使用 A,10 月 23 日起使用 B。玩家已参与的期次先读取其快照,不被新配置替换。 + +修改只从未来首期生效;已有玩家配置、订单和奖励快照不重写。发布时检查前后版本的周期边界,禁止新规则截断旧期,也禁止插入的规则与已排定未来版本发生截断。 +首次切换必须在所有已发布单期结束之后,冲突返回 `PERIOD_OVERLAP`;切换后禁止再发布覆盖循环时间范围的单期配置。 已发布版本不可覆盖,旧期仍可通过玩家快照读取、结算、补发;迟到支付仍归原期,不转入新周。 +兼容性:旧 `schedule: "weekly"` 继续按每周五 21:00 开放两天,并沿用 `pyramid:YYYY-MM-DD`;不带 `schedule` 的历史配置仍为一次性活动。没有自动修改数据库中的历史规则。 + `pyramidActivities` 与玩法共用选期算法,关闭期间会提前返回未来 7 天内的下一期。 `activityConfig/list` 保持 `schemaVersion: 1` 和现有 `startsAt/endsAt` 字段,`info` 保持 `startAt/endAt`,当前客户端无需更新。 客户端只在登录、回首页、回前台时重新请求排期,并根据已缓存时间自动开闭;连续停留首页超过缓存覆盖范围仍需重新进入首页。 部署更新已有 `pyramid/api`(包含列表提供函数),无需新增云函数或更改结算定时器。 -随后发布一次完整周循环配置才会启用;仅部署代码不会擅自转换旧配置或启用测试草稿。发布前需核对线上现有期次结束时间及正式奖励版本。 +随后发布一次完整循环配置才会启用;仅部署代码不会擅自转换旧配置或启用测试草稿。发布前需核对线上现有期次结束时间及正式奖励版本。 `config/2026-09-28-test.json` 是已补齐奖励的测试期配置:`startAt:1790524800000`、`endAt:1790611200000`,即北京时间 2026-09-28 00:00(含)至 2026-09-29 00:00(不含)。前五层沿用 `pay_test`、`itemPrice:100` 分/次。每个普通格子为一百金币,每个宝箱为一百金币加一个冻结道具;第六层固定奖励为一百金币。 diff --git a/laf-cloud/functions/pyramid/api.ts b/laf-cloud/functions/pyramid/api.ts index b53b342..33562fc 100644 --- a/laf-cloud/functions/pyramid/api.ts +++ b/laf-cloud/functions/pyramid/api.ts @@ -23,6 +23,30 @@ export function weeklyPeriod(at = Date.now()) { startAt, endAt: startAt + 2 * PYRAMID_DAY, nextStartAt: startAt + PYRAMID_WEEK, }; } +/** A day is 24 hours; currently schedules use Beijing time only. */ +function recurrence(schedule: any) { + if (!schedule || typeof schedule !== "object" || schedule.type !== "recurring" + || schedule.timezone !== "Asia/Shanghai" || typeof schedule.anchorAt !== "string" + || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00$/.test(schedule.anchorAt) + || !Number.isSafeInteger(schedule.intervalDays) || schedule.intervalDays <= 0 + || !Number.isSafeInteger(schedule.durationDays) || schedule.durationDays <= 0 + || schedule.durationDays > schedule.intervalDays) fail("CONFIG_INVALID", "循环配置需要北京时间 anchorAt,及正整数 intervalDays/durationDays,持续天数不能超过间隔天数"); + const anchorAt = Date.parse(schedule.anchorAt), intervalMs = schedule.intervalDays * PYRAMID_DAY, durationMs = schedule.durationDays * PYRAMID_DAY; + if (!Number.isSafeInteger(anchorAt) || anchorAt <= 0 || !Number.isSafeInteger(intervalMs) + || !Number.isSafeInteger(anchorAt + intervalMs) || !Number.isSafeInteger(anchorAt + durationMs) + || new Date(anchorAt + 8 * 3600000).toISOString().slice(0, 19) + "+08:00" !== schedule.anchorAt) fail("CONFIG_INVALID"); + return { anchorAt, intervalMs, durationMs }; +} +/** Latest scheduled start, or the first start if the anchor is still in the future. */ +export function recurringPeriod(schedule: any, at = Date.now()) { + const { anchorAt, intervalMs, durationMs } = recurrence(schedule); + if (!Number.isSafeInteger(at) || at <= 0) fail("CONFIG_INVALID"); + const startAt = anchorAt + Math.max(0, Math.floor((at - anchorAt) / intervalMs)) * intervalMs; + return { periodId: "pyramid:" + startAt, startAt, endAt: startAt + durationMs, nextStartAt: startAt + intervalMs }; +} +function scheduledPeriod(config: any, at: number) { + return config.schedule === "weekly" ? weeklyPeriod(at) : recurringPeriod(config.schedule, at); +} export const FIELDS: Record = { "1001": "coinAmount", "2001": "freezeAmount", "2002": "hammerAmount", "2003": "magicAmount", "2005": "userPowerTime" }; export const key = (...parts: any[]) => createHash("sha256").update(JSON.stringify(parts)).digest("hex"); export const clone = (value: any) => JSON.parse(JSON.stringify(value)); @@ -67,14 +91,17 @@ export function fail(code: string, message = errorMessage(code)): never { throw export function id(value: any): string { if (typeof value !== "string" || !/^[\w:.-]{1,160}$/.test(value)) fail("INVALID_INPUT"); return value; } export function validPeriod(raw: any) { const p = clone(raw); - id(p.periodId); id(p.configVersion); // Missing schedule is the legacy, explicitly dated single period. if (p.schedule !== undefined) { - if (p.schedule !== "weekly") fail("CONFIG_INVALID"); - const dates = weeklyPeriod(p.startAt); + const dates = p.schedule === "weekly" ? weeklyPeriod(p.startAt) + : recurringPeriod(p.schedule, recurrence(p.schedule).anchorAt); + if (p.schedule !== "weekly") { + p.periodId ??= dates.periodId; p.startAt ??= dates.startAt; p.endAt ??= dates.endAt; + } if (p.periodId !== dates.periodId || p.startAt !== dates.startAt || p.endAt !== dates.endAt) - fail("CONFIG_INVALID", "周循环首期必须为北京时间周五21:00至周日21:00,periodId 为 pyramid:开期日期"); + fail("CONFIG_INVALID", "循环配置的 periodId/startAt/endAt 必须与首期排期一致"); } + id(p.periodId); id(p.configVersion); if (p.assignmentMode === "profile_auto") { validateOffer(p.offer); if (!Array.isArray(p.audience) || p.audience.some((uid: any) => typeof uid !== "string")) fail("CONFIG_INVALID"); @@ -234,7 +261,7 @@ export async function period(periodId?: string, at = Date.now(), publishedBefore // Preserve explicitly dated legacy periods. Historical player snapshots are // read by playerPeriod before this function, including after config changes. const legacy = await latestEnabled("pyramid", at, publishedBefore, { - ...extra, "config.periodId": periodId, "config.schedule": { $ne: "weekly" }, + ...extra, "config.periodId": periodId, "config.schedule": { $exists: false }, }); if (legacy && active(legacy.config, at)) return validPeriod({ ...legacy.config, configVersion: legacy.configVersion }); } @@ -242,8 +269,8 @@ export async function period(periodId?: string, at = Date.now(), publishedBefore const row = await latestEnabled("pyramid", at, publishedBefore, extra); if (!row) return null; let config = validPeriod({ ...row.config, configVersion: row.configVersion }); - if (config.schedule === "weekly") { - const dates = weeklyPeriod(at); + if (config.schedule !== undefined) { + const dates = scheduledPeriod(config, at); if (dates.startAt < config.startAt || row.effectiveFrom > dates.startAt) return null; config = { ...config, periodId: dates.periodId, startAt: dates.startAt, endAt: dates.endAt }; } @@ -272,9 +299,18 @@ export async function pyramidActivities(now: number, windowEndsAt: number, user: effectiveFrom: { $gt: now, $lte: windowEndsAt } }).toArray(); const starts = new Set(upcoming.map((row: any) => row.effectiveFrom)); - // Recurring versions need no new publication each week. Advertise the next + // Recurring versions need no new publication each period. Advertise the next // window before it opens so existing clients can switch using cached dates. - for (let at = weeklyPeriod(now).nextStartAt; at <= windowEndsAt; at += PYRAMID_WEEK) starts.add(at); + const selected = await latestEnabled("pyramid", now, now, { + "config.environment": process.env.PAYMENT_APP_ENV || "production", + }); + for (const row of [selected, ...upcoming]) { + if (!row || row.config.schedule === undefined) continue; + const config = validPeriod({ ...row.config, configVersion: row.configVersion }); + let dates = scheduledPeriod(config, Math.max(now, row.effectiveFrom)); + if (dates.startAt <= now) dates = scheduledPeriod(config, dates.nextStartAt); + for (; dates.startAt <= windowEndsAt; dates = scheduledPeriod(config, dates.nextStartAt)) starts.add(dates.startAt); + } for (const at of [...starts].sort((a, b) => a - b)) { const next = await period(undefined, at, now); if (next && next.periodId !== own?.periodId && !configs.some(p => p.periodId === next.periodId)) configs.push({ ...next, availableAt: at }); @@ -658,23 +694,36 @@ export async function administer(ctx: any) { || !/^[A-Za-z0-9_:-]{1,100}$/.test(p.configVersion) || typeof enabled !== "boolean" || !Number.isSafeInteger(effectiveFrom) || effectiveFrom < p.startAt || effectiveFrom >= p.endAt || p.endAt <= Date.now() || env !== "test" && effectiveFrom <= Date.now()) fail("CONFIG_INVALID"); - // A weekly version takes effect for a whole future week, never midway + // A recurring version takes effect for a whole future period, never midway // through a running period (including in test environments). - if (p.schedule === "weekly" && (effectiveFrom !== p.startAt || effectiveFrom <= Date.now())) - fail("CONFIG_INVALID", "周循环配置只能从未来某个周五21:00开始生效"); + if (p.schedule !== undefined && (effectiveFrom !== p.startAt || effectiveFrom <= Date.now())) + fail("CONFIG_INVALID", "循环配置 effectiveFrom 必须等于未来首期开启时间"); const versions = await records().find({ activityId: "pyramid", recordType: "version", status: "published", "config.environment": env }).toArray(); if (versions.some((old: any) => { const previous = old.config; - if (previous.schedule === "weekly" && p.schedule === "weekly") return false; + if (previous.schedule !== undefined && p.schedule !== undefined) return false; // A recurring schedule extends indefinitely; switch only after every // published legacy period finishes, without rewriting existing players. - if (p.schedule === "weekly") return previous.endAt > p.startAt; - if (previous.schedule === "weekly") return p.endAt > previous.startAt; + if (p.schedule !== undefined) return previous.endAt > p.startAt; + if (previous.schedule !== undefined) return p.endAt > previous.startAt; return previous.periodId !== p.periodId && previous.startAt < p.endAt && previous.endAt > p.startAt; })) fail("PERIOD_OVERLAP"); + if (p.schedule !== undefined) { + // Check both sides of an inserted future version. A new cadence must not + // truncate the last period of the preceding effective configuration. + const timeline = [...versions.filter((row: any) => row.config.schedule !== undefined + && row.effectiveFrom !== effectiveFrom), { config: p, effectiveFrom, enabled, publishedAt: Date.now() }] + .sort((a: any, b: any) => a.effectiveFrom - b.effectiveFrom || a.publishedAt - b.publishedAt); + for (let index = 1; index < timeline.length; index++) { + const previous: any = timeline[index - 1], next: any = timeline[index]; + if (previous.effectiveFrom === next.effectiveFrom || previous.enabled === false) continue; + if (scheduledPeriod(previous.config, next.effectiveFrom - 1).endAt > next.effectiveFrom) + fail("PERIOD_OVERLAP", "新循环规则不能截断上一版本正在开放的活动期"); + } + } if (versions.some((old: any) => old.config.periodId === p.periodId && (old.config.startAt !== p.startAt || old.config.endAt !== p.endAt))) fail("PERIOD_TIME_IMMUTABLE"); // Only activity parameters live in config. Existing players retain their complete snapshot. diff --git a/laf-cloud/functions/pyramid/tests/pyramid.test.mjs b/laf-cloud/functions/pyramid/tests/pyramid.test.mjs index 97d4098..3303648 100644 --- a/laf-cloud/functions/pyramid/tests/pyramid.test.mjs +++ b/laf-cloud/functions/pyramid/tests/pyramid.test.mjs @@ -268,9 +268,9 @@ test('test tier override requires both test deployment and test campaign', async test('test tier overrides retain audience, level and time requirements', async () => { const cfg = { ...offerConfig(), testTierOverrides: { u: 4 }, audience: ['another'] }; table(M.TABLES.periods).splice(0, 1, version(cfg)); assert.equal((await api('info')).errorCode, 'AUDIENCE_EXCLUDED'); - cfg.audience = []; table(M.TABLES.periods)[0] = version(cfg); table('users')[0].levelAmount = 49; + cfg.audience = []; table(M.TABLES.periods)[0] = version(cfg); table('users')[0].levelAmount = M.UNLOCK_LEVEL - 1; assert.equal((await api('info')).errorCode, 'LEVEL_LOCKED'); - table('users')[0].levelAmount = 50; now = cfg.endAt - 3600000; + table('users')[0].levelAmount = M.UNLOCK_LEVEL; now = cfg.endAt - 3600000; const result = await api('info'); assert.equal(result.code, 1); assert.equal(result.data.offerTier, 4); assert.equal(table(M.TABLES.players).length, 1); }); @@ -468,7 +468,7 @@ function version(p = config(), extra = {}) { function reset() { now = START + 1000; tables = {}; beforeWrite = null; paymentEvents = []; analyticsFailure = false; analyticsCallbackFailure = false; const p = config(); table(M.TABLES.periods).push(version(p)); table('users').push({ - _id: 'u', onlyId: 123, levelAmount: 50, token: 't', openid: 'openid', session_key: 'session', pay_user: false, vip_level: 0, register_time: START - 90 * DAY, + _id: 'u', onlyId: 123, levelAmount: M.UNLOCK_LEVEL, token: 't', openid: 'openid', session_key: 'session', pay_user: false, vip_level: 0, register_time: START - 90 * DAY, coinAmount: 100, hammerAmount: 0, freezeAmount: 0, magicAmount: 0, userPowerTime: 0 }); process.env.PAYMENT_APP_ENV = 'test'; process.env.WX_MIDAS_PAY_SIGN_KEY = 'synthetic-test-secret'; @@ -489,6 +489,103 @@ const publishWeekly = (p, extra = {}) => { return admin({ body: { action: 'publish', adminToken: 'weekly-admin', period: p, ...extra } }); }; +function recurringConfig(startAt = WEEKLY_START, intervalDays = 7, durationDays = 2, configVersion = 'recurring-v1') { + const { periodId, startAt: ignoredStart, endAt, ...base } = config(); + return M.validPeriod({ ...base, configVersion, schedule: { + type: 'recurring', timezone: 'Asia/Shanghai', + anchorAt: new Date(startAt + 8 * 3600000).toISOString().slice(0, 19) + '+08:00', intervalDays, durationDays, + } }); +} + +test('day-based recurrence derives first dates and supports arbitrary anchors and intervals', async () => { + const start = Date.parse('2026-10-20T15:30:00+08:00'), p = recurringConfig(start, 10, 3); + assert.equal(p.startAt, start); assert.equal(p.endAt, start + 3 * DAY); + assert.equal(p.periodId, 'pyramid:' + start); + assert.equal(M.recurringPeriod(p.schedule, start - 1).startAt, start); + assert.equal((await publishWeekly(p)).code, 1); + for (const index of [0, 1, 9, 40]) { + now = start + index * 10 * DAY; + const current = await S.period(); assert.equal(current.startAt, now); assert.equal(current.endAt, now + 3 * DAY); + assert.equal((await S.period(current.periodId)).periodId, current.periodId); + now = current.endAt - 1; assert.ok(await S.period()); + now++; assert.equal(await S.period(), null); + } +}); + +test('recurrence validates day units, Beijing dates and derived fields', () => { + const p = recurringConfig(); + for (const patch of [{ intervalDays: 0 }, { intervalDays: -1 }, { intervalDays: 1.5 }, { intervalDays: '7' }, + { durationDays: 0 }, { durationDays: 8 }, { durationDays: 0.5 }, { timezone: 'UTC' }, + { anchorAt: '2026-02-30T21:00:00+08:00' }, { anchorAt: '2026-10-16T21:00:00' }, { type: 'monthly' }]) { + assert.throws(() => M.validPeriod({ ...p, schedule: { ...p.schedule, ...patch } }), e => e.errorCode === 'CONFIG_INVALID'); + } + for (const patch of [{ startAt: p.startAt + 1 }, { endAt: p.endAt + 1 }, { periodId: 'wrong' }]) + assert.throws(() => M.validPeriod({ ...p, ...patch }), e => e.errorCode === 'CONFIG_INVALID'); + const continuous = recurringConfig(WEEKLY_START, 1, 1); + assert.equal(M.recurringPeriod(continuous.schedule, continuous.endAt).startAt, continuous.endAt); +}); + +test('day-based future list follows configured cadence, with no fixed weekday', async () => { + const p = recurringConfig(WEEKLY_START + 2 * DAY, 3, 1); + assert.equal((await publishWeekly(p)).code, 1); now = p.startAt - 1; + let list = await S.pyramidActivities(now, now + 7 * DAY, table('users')[0]); + assert.deepEqual(list.activities.map(p => p.startsAt), [p.startAt, p.startAt + 3 * DAY, p.startAt + 6 * DAY]); + assert.equal(table(M.TABLES.players).length, 0); + now = p.endAt; + list = await S.pyramidActivities(now, now + 7 * DAY, table('users')[0]); + assert.equal(list.activities[0].startsAt, p.startAt + 3 * DAY); + now = list.activities[0].startsAt; + const info = await S.info('u', { periodId: list.activities[0].periodId }); + assert.equal(info.startAt, now); assert.equal(info.endAt, now + DAY); +}); + +test('effectiveFrom selects the newest effective recurrence, keeps snapshots and respects disable', async () => { + const p = recurringConfig(); assert.equal((await publishWeekly(p)).code, 1); + now = p.startAt; await S.info('u', {}); + const next = recurringConfig(p.startAt + 7 * DAY, 14, 3, 'recurring-v2'); + next.levels[0].itemPrice = 200; + assert.equal((await publishWeekly(next)).code, 1); + assert.equal((await S.period()).configVersion, p.configVersion); + now = next.startAt - 1; assert.equal(await S.period(), null); + now++; assert.equal((await S.period()).configVersion, next.configVersion); + assert.equal((await S.info('u', { periodId: p.periodId })).levels[0].itemPrice, 100); + const off = recurringConfig(next.startAt + 14 * DAY, 14, 3, 'recurring-off'); + assert.equal((await publishWeekly(off, { enabled: false })).code, 1); + now = off.startAt; assert.equal(await S.period(), null); +}); + +test('same effectiveFrom uses latest publishedAt, not version name or future publication', async () => { + const p = recurringConfig(); assert.equal((await publishWeekly(p)).code, 1); + now++; + const newer = { ...copy(p), configVersion: 'aaa-newer' }; newer.levels[0].itemPrice = 200; + assert.equal((await publishWeekly(newer)).code, 1); + assert.equal((await S.period(undefined, p.startAt, now - 1)).configVersion, p.configVersion); + now = p.startAt; + assert.equal((await S.period()).configVersion, newer.configVersion); +}); + +test('cadence changes cannot truncate old periods, including already scheduled future versions', async () => { + const p = recurringConfig(); assert.equal((await publishWeekly(p)).code, 1); + assert.equal((await publishWeekly(recurringConfig(p.startAt + DAY, 7, 2, 'cutoff'))).errorCode, 'PERIOD_OVERLAP'); + const future = recurringConfig(p.startAt + 14 * DAY, 7, 2, 'future'); + assert.equal((await publishWeekly(future)).code, 1); + const inserted = recurringConfig(p.endAt, 10, 3, 'inserted'); + assert.equal((await publishWeekly(inserted)).errorCode, 'PERIOD_OVERLAP'); + const safe = recurringConfig(p.endAt, 7, 2, 'safe'); + assert.equal((await publishWeekly(safe)).code, 1); + assert.equal((await publishWeekly(recurringConfig(p.startAt + 28 * DAY, 7, 2, 'wrong-effective'), { effectiveFrom: p.startAt + 28 * DAY + 1 })).errorCode, 'CONFIG_INVALID'); +}); + +test('weekly legacy transitions to day-based recurrence without altering historical IDs', async () => { + const old = installWeekly(); now = old.startAt; + await S.info('u', { periodId: old.periodId }); + const next = recurringConfig(old.startAt + 7 * DAY); + assert.equal((await publishWeekly(next)).code, 1); + now = next.startAt; + assert.equal((await S.info('u', {})).periodId, next.periodId); + assert.equal((await S.info('u', { periodId: old.periodId })).periodId, old.periodId); +}); + test('weekly calendar uses Beijing Friday 21:00, including month/year/leap boundaries', () => { for (const date of ['2026-10-16', '2026-10-30', '2027-01-01', '2028-03-03']) { const start = Date.parse(date + 'T21:00:00+08:00'), p = M.weeklyPeriod(start); @@ -532,7 +629,7 @@ test('weekly list previews next opening without creating players and matches gam now = p.endAt; list = await S.pyramidActivities(now, now + 7 * DAY, table('users')[0]); assert.equal(list.activities.length, 1); assert.equal(list.activities[0].phase, 'upcoming'); - assert.equal((await S.pyramidActivities(now, now + 7 * DAY, { levelAmount: 49 })).activities.length, 0); + assert.equal((await S.pyramidActivities(now, now + 7 * DAY, { levelAmount: M.UNLOCK_LEVEL - 1 })).activities.length, 0); }); test('weekly progression resets while old snapshots and settlement remain addressable', async () => { @@ -603,7 +700,7 @@ test('weekly profile offers preserve audience and create a new assignment next w const p = { ...offerConfig(), ...weeklyConfig(), assignmentMode: 'profile_auto', audience: ['u'], testTierOverrides: { u: 2 } }; installWeekly(p); now = p.startAt; const first = await S.info('u', {}); assert.equal(first.offerTier, 2); assert.equal(first.drawCount, 1); - assert.equal((await S.pyramidActivities(now, now + 7 * DAY, { _id: 'other', levelAmount: 50 })).activities.length, 0); + assert.equal((await S.pyramidActivities(now, now + 7 * DAY, { _id: 'other', levelAmount: M.UNLOCK_LEVEL })).activities.length, 0); now += 7 * DAY; const second = await S.info('u', {}); assert.notEqual(second.periodId, first.periodId); assert.equal(second.offerTier, 2); assert.equal(second.drawCount, 1); @@ -674,7 +771,7 @@ test('settlement reads and claims require authentication and match the owner, pe await S.ensure('u', config()); M.grant(player(), ['l1a'], 'draw', now); now = END; await S.runJobs(); assert.equal((await api('settlement_read', { token: 'bad' })).errorCode, 'AUTH_INVALID'); assert.equal((await claimNotice(player(), { token: 'bad' })).errorCode, 'AUTH_INVALID'); - table('users').push({ _id: 'other', token: 'other-token', levelAmount: 50 }); + table('users').push({ _id: 'other', token: 'other-token', levelAmount: M.UNLOCK_LEVEL }); assert.equal((await api('settlement_read', { uid: 'other', token: 'other-token' })).data.notices.length, 0); for (const extra of [{ uid: 'other', token: 'other-token' }, { periodId: 'wrong' }, { noticeId: 'wrong' }]) assert.equal((await claimNotice(player(), extra)).errorCode, 'INVALID_INPUT'); assert.equal(player().settlementNotice.readAt, null); assert.equal(table('users')[0].coinAmount, 100); @@ -1074,10 +1171,10 @@ test('invalid receipt JSON blocks saving and confirmation without changing inven }); test('schedule is read only, unlocks after 50, and details create exactly one participation', async () => { - const user = table('users')[0]; user.levelAmount = 49; + const user = table('users')[0]; user.levelAmount = M.UNLOCK_LEVEL - 1; let r = await unifiedList({ body: { uid: 123, token: 't' } }); assert.deepEqual(r.data.activities, []); assert.equal((await api('info')).errorCode, 'LEVEL_LOCKED'); - user.levelAmount = 50; + user.levelAmount = M.UNLOCK_LEVEL; beforeWrite = () => { throw Error('schedule must not write'); }; r = await unifiedList({ body: { uid: 123, token: 't' } }); assert.equal(r.code, 1); assert.deepEqual(Object.keys(pyramidEntry(r)).sort(), ['activityId', 'periodId', 'configVersion', 'startsAt', 'endsAt', 'unlockLevel', 'phase'].sort()); @@ -1112,7 +1209,7 @@ test('only the authenticated owner can query historical details; schedule querie assert.equal(r.code, 1); assert.equal(r.data.activities.length, 0); assert.equal(player().settledAt, null); beforeWrite = null; assert.equal((await api('info')).data.status, 'ended'); assert.equal(player().settledAt, now); - table('users').push({ _id: 'other', onlyId: 124, token: 'other-token', levelAmount: 50 }); + table('users').push({ _id: 'other', onlyId: 124, token: 'other-token', levelAmount: M.UNLOCK_LEVEL }); const other = await api('info', { uid: 'other', token: 'other-token' }); assert.equal(other.code, 1); assert.equal(other.data, null); assert.equal(table(M.TABLES.players).length, 1); }); @@ -1150,7 +1247,7 @@ test('empty audience exposes upcoming schedules without creating players and pre assert.equal((await M.pyramidActivities(now, now + 7 * DAY, table('users')[0])).activities.length, 0); cfg.audience = []; cfg.startAt = START; tables.activityConfigs = [version(cfg, { enabled: false })]; assert.equal((await unifiedList({ body: { uid: 123, token: 't' } })).data.activities.length, 0); - tables.activityConfigs = [version(cfg)]; table('users')[0].levelAmount = 49; + tables.activityConfigs = [version(cfg)]; table('users')[0].levelAmount = M.UNLOCK_LEVEL - 1; assert.equal((await unifiedList({ body: { uid: 123, token: 't' } })).data.activities.length, 0); assert.equal(table(M.TABLES.players).length, 0); });