feat(pyramid): configure recurring schedules in days
This commit is contained in:
@@ -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 } });
|
||||
|
||||
@@ -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` 分/次。每个普通格子为一百金币,每个宝箱为一百金币加一个冻结道具;第六层固定奖励为一百金币。
|
||||
|
||||
|
||||
@@ -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<string, string> = { "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<number>(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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user