更新注册事件以及abtest后端

This commit is contained in:
COMPUTER\EDY 2026-08-21 18:09:01 +08:00
parent 1363e62101
commit 6351520e35
8 changed files with 633 additions and 85 deletions

View File

@ -516,6 +516,11 @@ export default class GameManager extends cc.Component {
// this.newReadData();
}
if (data.data.isFirst === true) {
console.log("服务器中严格的新用户,发送注册事件和公共属性");
this.setFirstInfo();
}
if (data.data.forcedUpdate === true
|| cc.fx.GameConfig.GM_INFO.wucai_type === 1) {
const healthAmount = Number(data.data.healthAmount);
@ -830,16 +835,9 @@ export default class GameManager extends cc.Component {
getUserDataToServer(data) {
const timestamp = Date.now();
if (data) {
if (data.isFirst == true) {
if (data.isFirst === true) {
cc.fx.GameConfig.GM_INFO.first = true;
console.log("服务器中严格的新用户", data.isFirst, "发送注册事件和公共属性");
// 新用户,走一遍 track注册事件
this.setFirstInfo();
}
//无论是不是新用户,以防新用户第一次退出了 没有isFirst但是没发送setonce注册,统一走一遍
this.load3 = true;
this.load4 = true;
if (data.levelAmount == null || data.levelAmount == undefined) {

View File

@ -348,7 +348,7 @@ export class GameConfig {
vibrateOpen: true, //震动
coinnum: 0, //每局的金币数
paid_user: false, //是否是付费用户
version: "1.0.34", //版本号
version: "1.0.35", //版本号
shushu_DistinctId: "", //数数访客ID
shushu_AccountId: "", //数数账号ID
uid: "", //用户和后端唯一id

View File

@ -71,9 +71,10 @@ async function saveConfig(config: any) {
// from the submitted array. An empty array therefore clears all configs.
const oldRet = await db.collection(COLLECTION).get();
const oldList = Array.isArray(oldRet.data) ? oldRet.data : [];
const oldByLayer = pickLatestConfigByLayer(oldList);
const list = [];
for (let i = 0; i < config.length; i++) {
const ret = await saveOneConfig(config[i]);
const ret = await saveOneConfig(config[i], oldByLayer[String(config[i].layer)] || null);
if (ret.code !== 1) {
return ret;
}
@ -95,19 +96,25 @@ async function saveConfig(config: any) {
return await saveOneConfig(config);
}
async function saveOneConfig(config: any) {
async function saveOneConfig(config: any, previousConfig?: any) {
const error = validateConfig(config);
if (error) {
return { code: 0, data: null, msg: error };
}
const now = Date.now();
const layer = String(config.layer || "");
if (previousConfig === undefined) {
const previous = await db.collection(COLLECTION).where({ layer: layer }).getOne();
previousConfig = previous.data || null;
}
const data = {
experimentId: config.experimentId,
layer: String(config.layer || ""),
layer: layer,
name: config.name || config.experimentId,
enabled: config.enabled !== false,
resetAssignments: config.resetAssignments === true,
resetVersion: getResetVersion(config, previousConfig, now),
startTime: Number(config.startTime || 0),
endTime: Number(config.endTime || 0),
variants: normalizeVariants(config.variants),
@ -128,10 +135,14 @@ async function setEnabled(experimentId: string, enabled: boolean) {
if (!experimentId) {
return { code: 0, data: null, msg: "experimentId required" };
}
const ret = await db.collection(COLLECTION).where({ experimentId: experimentId }).update({
const data: any = {
enabled: enabled,
updatedAt: Date.now(),
});
};
if (!enabled) {
data.resetVersion = 0;
}
const ret = await db.collection(COLLECTION).where({ experimentId: experimentId }).update(data);
return { code: 1, data: ret, msg: "ok" };
}
@ -161,13 +172,17 @@ function validateConfig(config: any) {
const groups = {};
for (let i = 0; i < config.variants.length; i++) {
const variant = config.variants[i];
if (!variant.group) {
const group = String(variant.group || "").trim();
if (!group) {
return "variant.group required";
}
if (groups[variant.group]) {
if (group.toLowerCase() === "default") {
return "variant.group Default is reserved";
}
if (groups[group]) {
return "variant.group duplicated";
}
groups[variant.group] = true;
groups[group] = true;
const ratio = Number(variant.ratio);
if (!(ratio > 0)) {
return "variant.ratio must be positive";
@ -185,7 +200,36 @@ function validateConfig(config: any) {
function normalizeVariants(variants: any[]) {
return variants.map(variant => ({
group: variant.group,
group: String(variant.group).trim(),
ratio: Number(variant.ratio),
}));
}
function pickLatestConfigByLayer(configs: any[]) {
const byLayer = {};
for (const config of configs) {
const layer = String(config?.layer || "");
const old = byLayer[layer];
if (layer && (!old || Number(config.updatedAt || config.createdAt || 0) >= Number(old.updatedAt || old.createdAt || 0))) {
byLayer[layer] = config;
}
}
return byLayer;
}
function getResetVersion(config: any, previousConfig: any, now: number) {
const previousVersion = Number(previousConfig?.resetVersion || 0);
if (config.enabled === false) {
return 0;
}
if (config.resetAssignments !== true) {
return previousVersion;
}
const hasPreviousVersion = previousConfig
&& Object.prototype.hasOwnProperty.call(previousConfig, "resetVersion");
if (!previousConfig || previousConfig.resetAssignments !== true || !hasPreviousVersion) {
return Math.max(now, previousVersion + 1);
}
return previousVersion;
}

View File

@ -22,6 +22,8 @@ const query = new Ip2region();
const canIos = false;
const version = "9.9.99";
const AB_CONFIG_COLLECTION = "ab_config";
const MANAGED_AB_LAYERS = ["1", "2", "3"];
const DEFAULT_AB_GROUP = "Default";
export default async function (ctx: FunctionContext) {
const body = ctx.body || {};
let code = body.code;
@ -56,10 +58,13 @@ export default async function (ctx: FunctionContext) {
return { code: 0, data: "账号封禁", msg: "账号封禁" };
}
let token = await stringToHash(Date.now() + openid);
let abTests = {};
let abTests: any = null;
let shouldPersistABTests = false;
let wucaiPreview = null;
if (!userData) {
abTests = await getActiveABAssignments(openid, null);
if (dbname === "users") {
abTests = await getActiveABAssignments(openid, null, true);
}
let migratedUser = null;
if (from === "wucai") {
const source = await fetchWucaiUser(unionid);
@ -113,7 +118,7 @@ export default async function (ctx: FunctionContext) {
headArr: migratedUser?.headArr ?? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
catArr: migratedUser?.catArr ?? [1, 2, 3],
token: token,
abTests: abTests
...(dbname === "users" ? { abTests: abTests } : {})
});
} else {
const migrationUpdate: any = {};
@ -162,7 +167,10 @@ export default async function (ctx: FunctionContext) {
}
}
abTests = normalizeABTests(userData.abTests);
if (dbname === "users") {
abTests = await getActiveABAssignments(openid, userData.abTests, false);
shouldPersistABTests = JSON.stringify(userData.abTests) !== JSON.stringify(abTests);
}
let taskTime = userData.taskTime || 0;
let task = userData.task;
if (task) {
@ -219,6 +227,16 @@ export default async function (ctx: FunctionContext) {
await db.collection(dbname)
.where({ _id: userData._id })
.update(loginUpdate);
if (shouldPersistABTests) {
try {
await db.collection(dbname)
.where({ _id: userData._id })
.update({ abTests: abTests });
} catch (e) {
console.log("AB测试分组写回失败", e);
}
}
}
await delayExecution(0.1);
userData = await getUserData(dbname, openid);
@ -251,7 +269,9 @@ export default async function (ctx: FunctionContext) {
userData.shareLv = [];
}
userData.careerAmount = userData.careerAmount || 0;
userData.abTests = normalizeABTests(abTests);
if (dbname === "users") {
userData.abTests = abTests;
}
let rebornGiftTime = userData?.rebornGiftTime || 0;
let times = getTodayStartTimestamp();
if (rebornGiftTime < times) {
@ -532,56 +552,108 @@ async function sign(outTradeNo, nonceStr, timestamp) {
return authorization;
}
async function getActiveABAssignments(openid: string, oldAbTests: any) {
let abTests = normalizeABTests(oldAbTests);
async function getActiveABAssignments(openid: string, oldAbTests: any, isNewUser: boolean) {
const now = Date.now();
const abTests = normalizeABTests(oldAbTests, now);
let configs = [];
try {
let ret = await db.collection(AB_CONFIG_COLLECTION).get();
// console.log("AB config count:", ret.data?.length || 0, ret.data);
const ret = await db.collection(AB_CONFIG_COLLECTION).get();
configs = pickConfigByLayer(ret.data || []);
} catch (e) {
// console.log("AB配置读取失败", e);
return abTests;
return ensureManagedABLayers(abTests, now);
}
const now = Date.now();
const nextAbTests: any = {};
for (let i = 0; i < configs.length; i++) {
const experiment = configs[i];
if (!isValidExperiment(experiment)) {
const configByLayer: any = {};
for (const config of configs) {
configByLayer[getABLayerKey(config.layer)] = config;
}
const nextAbTests: any = { ...abTests };
for (const layer of MANAGED_AB_LAYERS) {
const layerKey = getABLayerKey(layer);
const experiment = configByLayer[layerKey];
const oldAssignment = abTests[layerKey];
if (!experiment) {
nextAbTests[layerKey] = buildDefaultABAssignment(layer, null, oldAssignment, now, 0);
continue;
}
const resetVersion = getABResetVersion(experiment.resetVersion);
if (experiment.enabled === false) {
nextAbTests[layerKey] = buildDefaultABAssignment(
layer,
experiment,
oldAssignment,
now,
resetVersion
);
continue;
}
const layerKey = getABLayerKey(experiment.layer);
if (abTests[layerKey] && experiment.resetAssignments !== true) {
const shouldReset = experiment.resetAssignments === true
&& resetVersion > 0
&& getABResetVersion(oldAssignment?.appliedResetVersion) !== resetVersion;
if (isNewUser || shouldReset) {
const variant = pickABVariant(openid, experiment);
nextAbTests[layerKey] = variant
? buildABAssignment(layer, experiment, variant.group, now, resetVersion)
: buildDefaultABAssignment(layer, experiment, oldAssignment, now, resetVersion);
continue;
}
if (oldAssignment) {
nextAbTests[layerKey] = {
...abTests[layerKey],
experimentId: experiment.experimentId,
layer: String(experiment.layer),
name: experiment.name || abTests[layerKey].name || "",
enabled: experiment.enabled !== false,
...oldAssignment,
experimentId: getABText(experiment.experimentId),
layer: layer,
name: getABText(experiment.name),
enabled: true,
};
continue;
} else {
nextAbTests[layerKey] = buildDefaultABAssignment(layer, experiment, null, now, 0);
}
const variant = pickABVariant(openid, experiment);
if (!variant) {
continue;
}
nextAbTests[layerKey] = {
experimentId: experiment.experimentId,
layer: String(experiment.layer),
name: experiment.name || "",
enabled: experiment.enabled !== false,
group: variant.group,
assignedAt: now,
};
}
return nextAbTests;
}
function normalizeABTests(abTests: any) {
function ensureManagedABLayers(abTests: any, now: number) {
const completed: any = { ...abTests };
for (const layer of MANAGED_AB_LAYERS) {
const layerKey = getABLayerKey(layer);
if (!completed[layerKey]) {
completed[layerKey] = buildDefaultABAssignment(layer, null, null, now, 0);
}
}
return completed;
}
function buildDefaultABAssignment(layer: string, experiment: any, oldAssignment: any, now: number, resetVersion: number) {
const wasDefault = normalizeABGroup(oldAssignment?.group) === DEFAULT_AB_GROUP;
return {
experimentId: experiment ? getABText(experiment.experimentId) : null,
layer: layer,
name: experiment ? getABText(experiment.name) : null,
enabled: !!experiment && experiment.enabled !== false,
group: DEFAULT_AB_GROUP,
assignedAt: wasDefault ? oldAssignment.assignedAt : now,
appliedResetVersion: resetVersion,
};
}
function buildABAssignment(layer: string, experiment: any, group: any, assignedAt: number, resetVersion: number) {
return {
experimentId: getABText(experiment.experimentId),
layer: layer,
name: getABText(experiment.name),
enabled: true,
group: normalizeABGroup(group),
assignedAt: assignedAt,
appliedResetVersion: resetVersion,
};
}
function normalizeABTests(abTests: any, now = Date.now()) {
if (!abTests) {
return {};
}
@ -592,24 +664,49 @@ function normalizeABTests(abTests: any) {
return {};
}
}
if (typeof abTests !== "object" || Array.isArray(abTests)) {
return {};
}
const normalized: any = {};
for (const key in abTests) {
const item = abTests[key];
if (!item || !item.layer || key !== getABLayerKey(item.layer)) {
const layer = String(item?.layer || "").trim();
const group = normalizeABGroup(item?.group);
if (!layer || !group || key !== getABLayerKey(layer)) {
continue;
}
const assignedAt = Number(item.assignedAt);
normalized[key] = {
experimentId: item.experimentId || "",
layer: String(item.layer),
name: item.name || "",
experimentId: getABText(item.experimentId),
layer: layer,
name: getABText(item.name),
enabled: item.enabled !== false,
group: item.group,
assignedAt: item.assignedAt || Date.now(),
group: group,
assignedAt: Number.isFinite(assignedAt) && assignedAt > 0 ? assignedAt : now,
appliedResetVersion: getABResetVersion(item.appliedResetVersion),
};
}
return normalized;
}
function normalizeABGroup(group: any) {
const normalized = String(group || "").trim();
return normalized.toLowerCase() === "default" ? DEFAULT_AB_GROUP : normalized;
}
function getABText(value: any) {
if (value === null || value === undefined || value === "") {
return null;
}
return String(value);
}
function getABResetVersion(value: any) {
const version = Number(value);
return Number.isFinite(version) && version > 0 ? version : 0;
}
function getABLayerKey(layer: any) {
return `layer_${String(layer || "").trim()}`;
}

View File

@ -0,0 +1,173 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { registerHooks } from "node:module";
const functionUrl = new URL("../functions/abConfigAdmin.ts", import.meta.url);
const state = {
configs: [],
nextId: 1,
};
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function matches(document, query) {
return Object.keys(query).every(key => document[key] === query[key]);
}
function collection(name) {
assert.equal(name, "ab_config");
return {
async get() {
return { data: clone(state.configs) };
},
where(query) {
return {
async getOne() {
return { data: clone(state.configs.find(item => matches(item, query)) || null) };
},
async update(payload) {
const matched = state.configs.filter(item => matches(item, query));
matched.forEach(item => Object.assign(item, clone(payload)));
return { updated: matched.length };
},
async remove() {
const oldLength = state.configs.length;
state.configs = state.configs.filter(item => !matches(item, query));
return { deleted: oldLength - state.configs.length };
},
};
},
async add(payload) {
const document = { _id: `config-${state.nextId++}`, ...clone(payload) };
state.configs.push(document);
return { id: document._id };
},
};
}
globalThis.__abConfigAdminCloudMock = {
database() {
return { collection };
},
};
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "@lafjs/cloud") {
return {
url: "data:text/javascript,export default globalThis.__abConfigAdminCloudMock",
shortCircuit: true,
};
}
return nextResolve(specifier, context);
},
});
const { default: abConfigAdmin } = await import(functionUrl);
function reset() {
state.configs = [];
state.nextId = 1;
}
function config(extra = {}) {
return {
experimentId: "exp_layer_1_001",
layer: "1",
name: "Layer 1",
enabled: true,
resetAssignments: false,
variants: [{ group: "A", ratio: 50 }, { group: "B", ratio: 50 }],
...extra,
};
}
async function save(items) {
return await abConfigAdmin({
body: {
action: "save",
adminToken: "abtest-admin-token",
config: items,
},
});
}
test("resetVersion changes only on an enabled false-to-true transition", async () => {
reset();
await save([config()]);
assert.equal(state.configs[0].resetVersion, 0);
await save([config({ resetAssignments: true })]);
const firstVersion = state.configs[0].resetVersion;
assert.ok(firstVersion > 0);
await save([config({
experimentId: "exp_layer_1_002",
resetAssignments: true,
variants: [{ group: "A", ratio: 80 }, { group: "B", ratio: 20 }],
})]);
assert.equal(state.configs.length, 1);
assert.equal(state.configs[0].experimentId, "exp_layer_1_002");
assert.equal(state.configs[0].resetVersion, firstVersion);
await save([config({ experimentId: "exp_layer_1_002", resetAssignments: false })]);
assert.equal(state.configs[0].resetVersion, firstVersion);
await save([config({ experimentId: "exp_layer_1_002", resetAssignments: true })]);
assert.ok(state.configs[0].resetVersion > firstVersion);
});
test("a disabled layer does not create a reset version", async () => {
reset();
await save([config({ enabled: false })]);
await save([config({ enabled: false, resetAssignments: true })]);
assert.equal(state.configs[0].resetVersion, 0);
await save([config({ enabled: true, resetAssignments: true })]);
assert.equal(state.configs[0].resetVersion, 0);
await save([config({ enabled: true, resetAssignments: false })]);
await save([config({ enabled: true, resetAssignments: true })]);
assert.ok(state.configs[0].resetVersion > 0);
await save([config({ enabled: false, resetAssignments: true })]);
assert.equal(state.configs[0].resetVersion, 0);
await save([config({ enabled: true, resetAssignments: true })]);
assert.equal(state.configs[0].resetVersion, 0);
});
test("an existing enabled reset without a version is initialized on publish", async () => {
reset();
state.configs.push({ _id: "legacy", ...config({ resetAssignments: true }) });
const result = await save([config({ resetAssignments: true })]);
assert.equal(result.code, 1);
assert.ok(state.configs[0].resetVersion > 0);
});
test("Default is a case-insensitive reserved group name", async () => {
reset();
const result = await save([config({
variants: [{ group: "DEFAULT", ratio: 50 }, { group: "B", ratio: 50 }],
})]);
assert.equal(result.code, 0);
assert.match(result.msg, /Default.*reserved/i);
assert.equal(state.configs.length, 0);
});
test("the admin page explains edge-triggered reset behavior", async () => {
const ui = await readFile(new URL("../../xxwzUI.html", import.meta.url), "utf8");
assert.match(ui, /关闭→开启并发布/);
assert.match(ui, /关闭后未登录用户不补做/);
assert.match(ui, /Default.*系统保留/);
});

View File

@ -16,6 +16,7 @@ const state = {
source: null,
sourceFails: false,
sourceCalls: 0,
failABUpdate: false,
};
function clone(value) {
@ -49,6 +50,9 @@ function collection(name) {
return { data: clone(documents(name).find(item => matches(item, query)) || null) };
},
async update(payload) {
if (state.failABUpdate && Object.keys(payload).length === 1 && payload.abTests) {
throw new Error("abTests update failed");
}
const matched = documents(name).filter(item => matches(item, query));
matched.forEach(item => Object.assign(item, clone(payload)));
return { updated: matched.length };
@ -134,6 +138,20 @@ function reset(users = [], source = null) {
state.source = source;
state.sourceFails = false;
state.sourceCalls = 0;
state.failABUpdate = false;
}
function abConfig(layer, extra = {}) {
return {
experimentId: `exp_layer_${layer}_001`,
layer: String(layer),
name: `Layer ${layer}`,
enabled: true,
resetAssignments: false,
resetVersion: 0,
variants: [{ group: "A", ratio: 50 }, { group: "B", ratio: 50 }],
...extra,
};
}
function existingUser(extra = {}) {
@ -182,30 +200,213 @@ test("ordinary legacy login initializes wucai_type -1 without reading Wucai", as
assert.equal("isWuCai" in state.users[0], false);
});
test("new users skip a disabled layer without affecting enabled layers", async () => {
test("new users receive all managed layers without affecting enabled assignments", async () => {
reset();
state.abConfigs = [
{
experimentId: "exp_layer_1_001",
layer: "1",
enabled: false,
variants: [{ group: "A", ratio: 50 }, { group: "B", ratio: 50 }],
},
{
experimentId: "exp_layer_2_001",
layer: "2",
enabled: true,
variants: [{ group: "A", ratio: 50 }, { group: "B", ratio: 50 }],
},
abConfig(1, { enabled: false }),
abConfig(2),
];
const result = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
assert.equal(result.code, 1);
assert.equal(result.data.abTests.layer_1, undefined);
assert.equal(state.users[0].abTests.layer_1, undefined);
assert.deepEqual(Object.keys(result.data.abTests).sort(), ["layer_1", "layer_2", "layer_3"]);
assert.equal(result.data.abTests.layer_1.experimentId, "exp_layer_1_001");
assert.equal(result.data.abTests.layer_1.enabled, false);
assert.equal(result.data.abTests.layer_1.group, "Default");
assert.equal(result.data.abTests.layer_2.experimentId, "exp_layer_2_001");
assert.match(result.data.abTests.layer_2.group, /^[AB]$/);
assert.equal(result.data.abTests.layer_3.experimentId, null);
assert.equal(result.data.abTests.layer_3.name, null);
assert.equal(result.data.abTests.layer_3.enabled, false);
assert.equal(result.data.abTests.layer_3.group, "Default");
assert.equal(result.data.abTests.layer_3.appliedResetVersion, 0);
assert.deepEqual(state.users[0].abTests, result.data.abTests);
});
test("existing users keep groups per layer while disabled and missing layers become Default", async () => {
reset([existingUser({
wucai_type: -1,
abTests: {
layer_1: {
experimentId: "exp_layer_1_001",
layer: "1",
name: "Old Layer 1",
enabled: true,
group: "B",
assignedAt: 100,
appliedResetVersion: 7,
},
layer_2: {
experimentId: "exp_layer_2_001",
layer: "2",
name: "Old Layer 2",
enabled: true,
group: "A",
assignedAt: 200,
appliedResetVersion: 0,
},
layer_3: {
experimentId: "exp_layer_3_001",
layer: "3",
name: "Removed Layer",
enabled: true,
group: "C",
assignedAt: 250,
appliedResetVersion: 0,
},
layer_4: {
experimentId: "exp_layer_4_001",
layer: "4",
name: "Future Layer",
enabled: true,
group: "H",
assignedAt: 300,
appliedResetVersion: 0,
},
},
})]);
state.abConfigs = [
abConfig(1, { experimentId: "exp_layer_1_002", name: "New Layer 1" }),
abConfig(2, { enabled: false, resetAssignments: true, resetVersion: 101 }),
];
const result = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
assert.equal(result.code, 1);
assert.equal(result.data.abTests.layer_1.group, "B");
assert.equal(result.data.abTests.layer_1.experimentId, "exp_layer_1_002");
assert.equal(result.data.abTests.layer_1.name, "New Layer 1");
assert.equal(result.data.abTests.layer_1.assignedAt, 100);
assert.equal(result.data.abTests.layer_1.appliedResetVersion, 7);
assert.equal(result.data.abTests.layer_2.group, "Default");
assert.equal(result.data.abTests.layer_2.enabled, false);
assert.notEqual(result.data.abTests.layer_2.assignedAt, 200);
assert.equal(result.data.abTests.layer_3.group, "Default");
assert.equal(result.data.abTests.layer_3.experimentId, null);
assert.notEqual(result.data.abTests.layer_3.assignedAt, 250);
assert.equal(result.data.abTests.layer_4.group, "H");
assert.equal(result.data.abTests.layer_4.assignedAt, 300);
assert.deepEqual(state.users[0].abTests, result.data.abTests);
});
test("an active reset version reassigns each existing user only once", async () => {
reset([existingUser({
wucai_type: -1,
abTests: {
layer_1: {
experimentId: "exp_layer_1_001",
layer: "1",
name: "Layer 1",
enabled: true,
group: "B",
assignedAt: 100,
appliedResetVersion: 0,
},
layer_2: {
experimentId: "exp_layer_2_001",
layer: "2",
name: "Layer 2",
enabled: true,
group: "B",
assignedAt: 200,
appliedResetVersion: 0,
},
},
})]);
state.abConfigs = [
abConfig(1, {
resetAssignments: true,
resetVersion: 101,
variants: [{ group: "A", ratio: 95 }, { group: "B", ratio: 5 }],
}),
abConfig(2),
];
const first = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
const assignedAt = first.data.abTests.layer_1.assignedAt;
const second = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
assert.equal(first.data.abTests.layer_1.group, "A");
assert.equal(first.data.abTests.layer_1.appliedResetVersion, 101);
assert.notEqual(assignedAt, 100);
assert.equal(second.data.abTests.layer_1.group, "A");
assert.equal(second.data.abTests.layer_1.assignedAt, assignedAt);
assert.equal(second.data.abTests.layer_1.appliedResetVersion, 101);
assert.equal(second.data.abTests.layer_2.group, "B");
assert.equal(second.data.abTests.layer_2.assignedAt, 200);
});
test("users who miss a reset window keep their old group after the switch closes", async () => {
reset([existingUser({
wucai_type: -1,
abTests: {
layer_1: {
experimentId: "exp_layer_1_001",
layer: "1",
name: "Layer 1",
enabled: true,
group: "B",
assignedAt: 100,
appliedResetVersion: 0,
},
},
})]);
state.abConfigs = [abConfig(1, {
resetAssignments: false,
resetVersion: 101,
variants: [{ group: "A", ratio: 95 }, { group: "B", ratio: 5 }],
})];
const result = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
assert.equal(result.data.abTests.layer_1.group, "B");
assert.equal(result.data.abTests.layer_1.assignedAt, 100);
assert.equal(result.data.abTests.layer_1.appliedResetVersion, 0);
});
test("Default remains sticky until an active reset version is available", async () => {
reset([existingUser({
wucai_type: -1,
abTests: {
layer_1: {
experimentId: "exp_layer_1_001",
layer: "1",
name: "Layer 1",
enabled: true,
group: "DEFAULT",
assignedAt: 100,
appliedResetVersion: 0,
},
},
})]);
state.abConfigs = [abConfig(1, {
resetAssignments: false,
resetVersion: 101,
variants: [{ group: "A", ratio: 95 }, { group: "B", ratio: 5 }],
})];
const beforeReset = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
state.abConfigs[0].resetAssignments = true;
const afterReset = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
assert.equal(beforeReset.data.abTests.layer_1.group, "Default");
assert.equal(beforeReset.data.abTests.layer_1.assignedAt, 100);
assert.equal(afterReset.data.abTests.layer_1.group, "A");
assert.equal(afterReset.data.abTests.layer_1.appliedResetVersion, 101);
assert.notEqual(afterReset.data.abTests.layer_1.assignedAt, 100);
});
test("broken AB data is repaired without blocking login when persistence fails", async () => {
reset([existingUser({ wucai_type: -1, abTests: "not-json" })]);
state.failABUpdate = true;
const result = await login({ body: { code: "code-1" }, headers: {}, socket: {} });
assert.equal(result.code, 1);
assert.deepEqual(Object.keys(result.data.abTests).sort(), ["layer_1", "layer_2", "layer_3"]);
assert.equal(result.data.abTests.layer_1.group, "Default");
assert.equal(state.users[0].abTests, "not-json");
});
test("ordinary usersAd login does not create migration fields", async () => {
@ -219,6 +420,8 @@ test("ordinary usersAd login does not create migration fields", async () => {
assert.equal(result.code, 1);
assert.equal(state.usersAd.length, 1);
assert.equal("wucai_type" in state.usersAd[0], false);
assert.equal("abTests" in state.usersAd[0], false);
assert.equal("abTests" in result.data, false);
assert.equal(state.sourceCalls, 0);
});
@ -238,6 +441,7 @@ test("new Wucai account migrates source data and returns type 0", async () => {
});
assert.equal(result.code, 1);
assert.equal(result.data.isFirst, true);
assert.equal(state.sourceCalls, 1);
assert.equal(state.users[0].wucai_type, 0);
assert.equal(state.users[0].coinAmount, 100);

View File

@ -55,6 +55,28 @@ test("Wucai transfer analytics report the final choice and both archive levels",
assert.match(utils, /shushu_userSetTransferredFromWucai\(true\)/);
});
test("new-user registration is independent of local cache and does not wait for analytics login", async () => {
const gameManager = await readFile(
new URL("../../../assets/Script/GameManager.ts", import.meta.url),
"utf8"
);
assert.doesNotMatch(gameManager, /pendingRegisterEvent/);
assert.match(gameManager, /if \(data\.data\.isFirst === true\)\s*\{[\s\S]*?this\.setFirstInfo\(\);\s*\}/);
assert.equal((gameManager.match(/this\.setFirstInfo\(\);/g) || []).length, 1);
const analyticsInitIndex = gameManager.indexOf("MiniGameSdk.API.shushu_Init();");
const loginRequestIndex = gameManager.indexOf("Utils.getUserInfo(", analyticsInitIndex);
const cacheBranchIndex = gameManager.indexOf("this.getUserDataToLocal(data.data);", loginRequestIndex);
const registerIndex = gameManager.indexOf("this.setFirstInfo();", cacheBranchIndex);
const loginMethodIndex = gameManager.indexOf("private loginShushuOnce()");
assert.ok(analyticsInitIndex >= 0);
assert.ok(loginRequestIndex > analyticsInitIndex);
assert.ok(cacheBranchIndex > loginRequestIndex);
assert.ok(registerIndex > cacheBranchIndex);
assert.ok(loginMethodIndex > registerIndex);
});
test("a completed progress choice shows the Home reward popup once after reboot", async () => {
const [transfer, home, gameManager] = await Promise.all([
readFile(new URL("../../../assets/Transfer/script/Transfer.ts", import.meta.url), "utf8"),

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AB Test Config</title>
<title>AB Test Config v1.0</title>
<style>
:root {
--bg: #f5f7fb;
@ -276,6 +276,12 @@
padding-top: 18px;
border-top: 1px solid var(--line);
}
.reset-help {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.group-count {
width: 84px;
}
@ -394,7 +400,7 @@
</head>
<body>
<header>
<h1>AB Test Config</h1>
<h1>AB Test Config v1.0</h1>
<div class="action-panel">
<div class="top-actions">
<button id="readBtn" class="secondary">读取配置</button>
@ -409,7 +415,7 @@
<main>
<div class="side-panel">
<section class="settings">
<label for="apiUrl">abConfigAdmin 云函数: URL</label>
<label for="apiUrl">abConfigAdmin 云函数v1.0: URL</label>
<input id="apiUrl" placeholder="https://q6rvwvtnga.sealoshzh.site/abConfigAdmin/">
<label for="adminToken">Admin Token</label>
@ -636,6 +642,7 @@
let total = 0;
for (const variant of item.variants) {
if (!variant.group) return `Layer ${item.layer} 存在空组名`;
if (variant.group.trim().toLowerCase() === "default") return `Layer ${item.layer} 的 Default 是系统保留组名`;
if (groupSet[variant.group]) return `Layer ${item.layer} 组名 ${variant.group} 重复`;
groupSet[variant.group] = true;
if (!(variant.ratio > 0)) return `Layer ${item.layer} 的比例必须大于 0`;
@ -715,12 +722,15 @@
</div>
</div>
<div class="group-head">
<div class="group-tools">
<label class="switch" title="开启后,这个 Layer 的老用户也会按当前配置重新计算分组">
彻底重新分组
<input data-field="resetAssignments" data-index="${index}" type="checkbox" ${layer.resetAssignments ? "checked" : ""}>
<span></span>
</label>
<div>
<div class="group-tools">
<label class="switch" title="关闭→开启并发布才创建新一轮;禁用 Layer 时不生效">
彻底重新分组
<input data-field="resetAssignments" data-index="${index}" type="checkbox" ${layer.resetAssignments ? "checked" : ""}>
<span></span>
</label>
</div>
<div class="reset-help">关闭→开启并发布才会创建新一轮;每名老用户在开启期间最多处理一次。关闭后未登录用户不补做,状态不变再次发布也不会创建新一轮;禁用 Layer 时不生效。</div>
</div>
</div>
<div class="group-head">