diff --git a/assets/Script/GameManager.ts b/assets/Script/GameManager.ts index 2be08fb..8f32594 100644 --- a/assets/Script/GameManager.ts +++ b/assets/Script/GameManager.ts @@ -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) { diff --git a/assets/Script/module/Config/GameConfig.ts b/assets/Script/module/Config/GameConfig.ts index 0586f48..29b180e 100644 --- a/assets/Script/module/Config/GameConfig.ts +++ b/assets/Script/module/Config/GameConfig.ts @@ -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 diff --git a/server/laf-cloud/functions/abConfigAdmin.ts b/server/laf-cloud/functions/abConfigAdmin.ts index 1b9c77f..50414ab 100644 --- a/server/laf-cloud/functions/abConfigAdmin.ts +++ b/server/laf-cloud/functions/abConfigAdmin.ts @@ -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; +} diff --git a/server/laf-cloud/functions/login.ts b/server/laf-cloud/functions/login.ts index 5b04815..4e1aa5d 100644 --- a/server/laf-cloud/functions/login.ts +++ b/server/laf-cloud/functions/login.ts @@ -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()}`; } diff --git a/server/laf-cloud/tests/ab-config-admin.test.mjs b/server/laf-cloud/tests/ab-config-admin.test.mjs new file mode 100644 index 0000000..54cdaed --- /dev/null +++ b/server/laf-cloud/tests/ab-config-admin.test.mjs @@ -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.*系统保留/); +}); diff --git a/server/laf-cloud/tests/login-wucai-state.test.mjs b/server/laf-cloud/tests/login-wucai-state.test.mjs index 0c883d9..76cd611 100644 --- a/server/laf-cloud/tests/login-wucai-state.test.mjs +++ b/server/laf-cloud/tests/login-wucai-state.test.mjs @@ -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); diff --git a/server/laf-cloud/tests/wucai-client.test.mjs b/server/laf-cloud/tests/wucai-client.test.mjs index 1f17e7f..a8809ae 100644 --- a/server/laf-cloud/tests/wucai-client.test.mjs +++ b/server/laf-cloud/tests/wucai-client.test.mjs @@ -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"), diff --git a/server/xxwzUI.html b/server/xxwz.html similarity index 96% rename from server/xxwzUI.html rename to server/xxwz.html index ce90add..698b2eb 100644 --- a/server/xxwzUI.html +++ b/server/xxwz.html @@ -3,7 +3,7 @@
-