From 47995a0ba076fe87acdbc2ef54467cff76fddeff Mon Sep 17 00:00:00 2001 From: guanchao <18612113337@163.com> Date: Thu, 22 Oct 2026 17:17:52 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=99=BE=E4=BA=BA=E8=B5=9B=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E3=80=81=E6=B4=BB=E5=8A=A8=E8=BF=9B=E5=BA=A6=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=A4=84=E7=90=86=EF=BC=8C=E9=BB=84=E9=87=91=E7=9F=BF?= =?UTF-8?q?=E5=B7=A5=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/Script/PyramidView.ts | 13 +- assets/Script/coin_madness/CoinMadnessHost.ts | 14 +- .../Script/coin_madness/GoldMinerService.ts | 16 +- .../Script/module/Config/CloudRiseService.ts | 12 +- assets/Script/module/Config/GameConfig.ts | 2 +- assets/Script/module/Config/StarterPack.ts | 6 +- .../cloud_rise_home/scripts/CloudRisePanel.ts | 237 ++++++++-------- .../scripts/CloudRiseRuntime.ts | 47 +++- assets/coin_madness/prefab/CoinMadness.prefab | 2 +- .../coin_madness/script/CoinMadnessDigits.ts | 2 +- .../coin_madness/script/CoinMadnessPanel.ts | 3 +- docs/cloudRise-development.md | 15 +- packages/activity-bundles/runtime.js | 21 +- tools/coin-madness/check-number-layout.cjs | 73 ++++- tools/test-cloud-rise-animations.cjs | 261 +++++++++--------- tools/test-cloud-rise-bundles.cjs | 31 ++- tools/test-cloud-rise.cjs | 129 ++++++++- tools/test-starter-pack.cjs | 41 ++- 18 files changed, 575 insertions(+), 350 deletions(-) diff --git a/assets/Script/PyramidView.ts b/assets/Script/PyramidView.ts index a69e7506..bcff8f87 100644 --- a/assets/Script/PyramidView.ts +++ b/assets/Script/PyramidView.ts @@ -130,20 +130,11 @@ export default class PyramidView extends cc.Component { }); } private async open(reminder?: PyramidDailyReminder): Promise { - console.log("openPyramid"); - console.log("open1"); - console.log(this.node); const alive = () => cc.isValid(this, true) && cc.isValid(this.node, true); - console.log(alive()); - console.log(this.overlay); - console.log(this.opening); - console.log(this.settlementQueued); if (!alive() || this.overlay || this.opening || this.settlementQueued) return; this.opening = true; // Prepare automatic reminders silently; only an explicit click shows Loading. - console.log("openLoading"); const stopLoading = reminder ? () => { } : this.startLoading(); try { - console.log("try"); const release = await Service.acquireResources(); if (!alive()) { release(); return; } this.releaseResources = release; @@ -166,14 +157,12 @@ export default class PyramidView extends cc.Component { if (reminder) ActivityScheduleService.markPyramidReminderShown(reminder); } } catch (error) { - console.log("catch"); - console.log(error); + console.error("[Pyramid] 界面加载失败", error); if (alive()) { cc.warn("[Pyramid] 界面加载失败", error); this.closeOverlay(); } } finally { - console.log("finally"); stopLoading(); if (alive()) { this.opening = false; this.syncPaymentLoading(); diff --git a/assets/Script/coin_madness/CoinMadnessHost.ts b/assets/Script/coin_madness/CoinMadnessHost.ts index 1ee548e0..039f376e 100644 --- a/assets/Script/coin_madness/CoinMadnessHost.ts +++ b/assets/Script/coin_madness/CoinMadnessHost.ts @@ -5,10 +5,11 @@ import Utils from '../module/Pay/Utils'; import ActivityBundleLoader from '../module/Config/ActivityBundleLoader'; const { ccclass } = cc._decorator; const BUNDLE = 'coin_madness'; -function traceLoad(stage: string, requestId: number, details: any = {}, error?: any) { +function traceLoad(stage: string, requestId: number, details: any, error: any) { + if (!error) return; const record = { bundle: BUNDLE, requestId, ...details, - ...(error ? { error: { message: error.message || String(error), errMsg: error.errMsg, stack: error.stack } } : {}) }; - console[error ? 'error' : 'log']('[GoldMinerLoad] ' + stage + ' ' + JSON.stringify(record)); + error: { message: error.message || String(error), errMsg: error.errMsg, stack: error.stack } }; + console.error('[GoldMinerLoad] ' + stage + ' ' + JSON.stringify(record)); } /** Resident bridge only: never import the bundle's UI class or preload its assets. */ @@ -257,7 +258,6 @@ export default class CoinMadnessHost extends cc.Component { this.loading = true; this.setEntryLoading(true); const requestId = ++this.requestId; - traceLoad('start', requestId, { reminder: !!reminder }); const service = this.service; // Fetch the first usable snapshot alongside assets, before mounting the panel. const ready = service ? service.readForDisplay().then(data => ({ data, error: null }), error => ({ data: null, error })) : Promise.resolve({ data: null, error: null }); @@ -304,7 +304,6 @@ export default class CoinMadnessHost extends cc.Component { openSettlement: data => this.openSettlement(data), toast: (message: string) => this.toast(message), }); - traceLoad('panel.ready', requestId); if (reminder && this.panel && this.dailyReminderCurrent(service, reminder)) service.markDailyReminderShown(reminder); if (recovery) recovery.then(result => { if (!this.alive || requestId !== this.requestId || this.service !== service) return; @@ -397,9 +396,7 @@ export default class CoinMadnessHost extends cc.Component { } private loadBundle(done: (error: Error, bundle?: cc.AssetManager.Bundle) => void): void { const requestId = this.requestId, started = Date.now(); - traceLoad('bundle.acquire', requestId, { runtimeInstalled: typeof window !== 'undefined' && !!(window as any).__activityBundles }); ActivityBundleLoader.acquireBundle(BUNDLE).then(lease => { - traceLoad('bundle.ready', requestId, { elapsedMs: Date.now() - started }); // Keep ownership in closures: Cocos clears component fields on destruction, // but an outstanding prefab callback must still be able to release its lease. let pending = 0, closed = false, queued = false; @@ -423,9 +420,8 @@ export default class CoinMadnessHost extends cc.Component { done: (error: Error, prefab: cc.Prefab) => void | Promise): void { const finish = this.resourceState.begin(); const requestId = this.requestId, started = Date.now(); - traceLoad('prefab.load', requestId, { path }); bundle.load(path, cc.Prefab, (error, prefab: cc.Prefab) => { - traceLoad(error ? 'prefab.failed' : 'prefab.ready', requestId, { path, elapsedMs: Date.now() - started }, error); + if (error) traceLoad('prefab.failed', requestId, { path, elapsedMs: Date.now() - started }, error); Promise.resolve().then(() => done(error, prefab)).then(finish, error => { traceLoad('prefab.callback.failed', requestId, { path }, error); finish(); this.close(); cc.warn(error); diff --git a/assets/Script/coin_madness/GoldMinerService.ts b/assets/Script/coin_madness/GoldMinerService.ts index 17686dba..abc13329 100644 --- a/assets/Script/coin_madness/GoldMinerService.ts +++ b/assets/Script/coin_madness/GoldMinerService.ts @@ -70,16 +70,11 @@ export default class GoldMinerService { const startedAt = Date.now(), url = Utils.serverUrl() + path; let response: any; const request = authenticated ? { ...body, uid: this.uid } : body; - this.iosPaymentLog('request', { url, request }); try { response = await new Promise(resolve => Utils.POSTJSON(path, request, resolve, authenticated)); } catch (error) { this.iosPaymentLog('request exception', { url, code: error.code, errCode: error.errCode, message: error.message }); throw error; } - this.iosPaymentLog('response', { - url, action: body && body.action, durationMs: Date.now() - startedAt, - code: response && response.code, errorCode: response && response.errorCode, response - }); if (!this.active()) throw new Error('账号已切换,请重新打开活动'); if (!response || response.code !== 1) { const error: any = new Error(response && (response.msg || response.message || response.errorCode) || '网络请求失败,请重试'); @@ -89,6 +84,10 @@ export default class GoldMinerService { && response && response.code === 0 && response.data && response.data.pay_state === 1 && response.msg === '充值未成功') error.code = 'PAYMENT_PENDING'; if (error.code === 'PAYMENT_PENDING') error.paymentPending = true; + if (!error.paymentPending) this.iosPaymentLog('response failed', { + url, action: body && body.action, durationMs: Date.now() - startedAt, + code: response && response.code, errorCode: response && response.errorCode, response + }); throw error; } return response.data; @@ -98,7 +97,7 @@ export default class GoldMinerService { try { if (typeof wx === 'undefined' || wx.getSystemInfoSync().platform !== 'ios') return; const entry = '[GoldMiner iOS] ' + event + ' ' + JSON.stringify(this.testSafe({ time: new Date().toISOString(), ...detail }), null, 2); - console.log(entry); + console.error(entry); } catch (_) { /* Diagnostics must never interrupt payment. */ } } private testSafe(value: any): any { @@ -273,7 +272,6 @@ export default class GoldMinerService { price: this.info.priceFen, productId: this.info.productId, channel: ios && (fallbackToCustomerService || !cc.fx.GameConfig.GM_INFO.iosCanPay) ? 'legacy_ios' : 'native', ios } as any; - this.iosPaymentLog('purchase route', { periodId, ...order, iosCanPay: cc.fx.GameConfig.GM_INFO.iosCanPay, fallbackToCustomerService }); // Keep the native order recoverable until the server accepts the channel switch. if (!fallbackToCustomerService) { this.ledger.orders[periodId] = order; this.persist(); } const body = { @@ -306,10 +304,9 @@ export default class GoldMinerService { await new Promise((resolve, reject) => { let done = false; const api = order.channel === 'legacy_ios' ? 'openCustomerServiceConversation' : 'requestMidasPaymentGameItem'; - this.iosPaymentLog('wx call', { api, outTradeNo: order.outTradeNo }); const finish = (ok: boolean, detail: any = {}) => { if (done) return; done = true; clearTimeout(timer); - this.iosPaymentLog('wx callback', { api, outTradeNo: order.outTradeNo, success: ok, errCode: detail.errCode, response: detail }); + if (!ok) this.iosPaymentLog('wx callback failed', { api, outTradeNo: order.outTradeNo, errCode: detail.errCode, response: detail }); if (ok) resolve(); else { const cancelled = /cancel/i.test(detail.errMsg || ''); @@ -346,7 +343,6 @@ export default class GoldMinerService { (result, detail) => result === 'success' ? finish(true, { result }) : fail(detail || { errMsg: '客服会话打开失败' }), outTradeNo => { order.outTradeNo = outTradeNo; this.persist(); - this.iosPaymentLog('customer order created', { outTradeNo, productId: this.info.productId, priceFen: this.info.priceFen }); }); } else { wx.requestMidasPaymentGameItem({ diff --git a/assets/Script/module/Config/CloudRiseService.ts b/assets/Script/module/Config/CloudRiseService.ts index 605300d3..baf889c6 100644 --- a/assets/Script/module/Config/CloudRiseService.ts +++ b/assets/Script/module/Config/CloudRiseService.ts @@ -1,9 +1,10 @@ import Utils from "../Pay/Utils"; import ActivityBundleLoader from "./ActivityBundleLoader"; -function traceResources(event: string, details: any, error?: any) { - console[error ? 'error' : 'log']('[CloudRiseFlow] ' + event + ' ' + JSON.stringify({ ...details, - ...(error ? { error: { message: error.message || String(error), errMsg: error.errMsg, stack: error.stack } } : {}) })); +function traceResources(event: string, details: any, error: any) { + if (!error) return; + console.error('[CloudRiseFlow] ' + event + ' ' + JSON.stringify({ ...details, + error: { message: error.message || String(error), errMsg: error.errMsg, stack: error.stack } })); } /** The home bundle is a local subpackage; matching/stages load independently. */ @@ -128,19 +129,15 @@ export default class CloudRiseService { static async pageFragment(page: "matching" | "stage", stage = 1): Promise { if (page === "stage" && ![1, 2, 3].includes(stage)) throw new Error("无效的百人赛阶段"); const name = page === "matching" ? "matching" : "stage" + stage; - traceResources('fragment.request', { name, page, stage }); const release = this.retainResources(); let phase = 'home-and-manifest'; try { await Promise.all([this.bundle(), this.prepareActivityVersion()]); phase = 'art'; - traceResources('fragment.art', { name }); await ActivityBundleLoader.ensureBundle("art"); phase = 'page-bundle'; - traceResources('fragment.bundle', { name }); const bundle = await ActivityBundleLoader.ensureBundle(name); phase = 'prefab'; - traceResources('fragment.prefab', { name, path: 'prefab/CloudRise', cached: !!this.pageTasks[name] && this.pageTasks[name].bundle === bundle }); if (!this.pageTasks[name] || this.pageTasks[name].bundle !== bundle) { const entry = { bundle, task: null as Promise }; entry.task = new Promise((resolve, reject) => { @@ -149,7 +146,6 @@ export default class CloudRiseService { this.pageTasks[name] = entry; } const prefab = await this.pageTasks[name].task; - traceResources('fragment.ready', { name }); return prefab; } catch (error) { traceResources('fragment.failed', { name, phase }, error); throw error; } finally { release(); } diff --git a/assets/Script/module/Config/GameConfig.ts b/assets/Script/module/Config/GameConfig.ts index e88cf4f4..b23557e8 100644 --- a/assets/Script/module/Config/GameConfig.ts +++ b/assets/Script/module/Config/GameConfig.ts @@ -779,7 +779,7 @@ export class GameConfig { vibrateOpen: true, //震动 coinnum: 0, //每局的金币数 paid_user: false, //是否是付费用户 - version: "1.0.92", //版本号 + version: "1.0.93", //版本号 shushu_DistinctId: "", //数数访客ID shushu_AccountId: "", //数数账号ID uid: "", //用户和后端唯一id diff --git a/assets/Script/module/Config/StarterPack.ts b/assets/Script/module/Config/StarterPack.ts index 0fe83ed6..b43a201a 100644 --- a/assets/Script/module/Config/StarterPack.ts +++ b/assets/Script/module/Config/StarterPack.ts @@ -67,7 +67,11 @@ export function starterPackFailureKey(): string { export function starterPackRecordResult(won: boolean): number { const key = starterPackFailureKey(); - const count = won ? 0 : Math.min(4, Math.max(0, Number(cc.fx.StorageMessage.getStorage(key)) || 0) + 1); + if (won) { + cc.fx.StorageMessage.removeStorage(key); + return 0; + } + const count = Math.min(4, Math.max(0, Number(cc.fx.StorageMessage.getStorage(key)) || 0) + 1); cc.fx.StorageMessage.setStorage(key, count); return count; } diff --git a/assets/cloud_rise_home/scripts/CloudRisePanel.ts b/assets/cloud_rise_home/scripts/CloudRisePanel.ts index 75af25e1..50d33b4b 100644 --- a/assets/cloud_rise_home/scripts/CloudRisePanel.ts +++ b/assets/cloud_rise_home/scripts/CloudRisePanel.ts @@ -5,15 +5,19 @@ import CloudRisePage from "./CloudRisePage"; import CloudRiseCounter from "./CloudRiseCounter"; import { MiniGameSdk } from "../../Script/Sdk/MiniGameSdk"; const { ccclass, property } = cc._decorator; -function traceMatch(event: string, details: any, error?: any) { - console[error ? 'error' : 'log']('[CloudRiseFlow] ' + event + ' ' + JSON.stringify({ ...details, - ...(error ? { error: { message: error.message || String(error), errMsg: error.errMsg, stack: error.stack } } : {}) })); +function traceMatch(event: string, details: any, error: any) { + if (!error) return; + console.error('[CloudRiseFlow] ' + event + ' ' + JSON.stringify({ ...details, + error: { message: error.message || String(error), errMsg: error.errMsg, stack: error.stack } })); } // Shorten avatar motion and launch delays together, preserving the staggered trajectories. const AVATAR_MOTION_TIME_SCALE = 0.7; const PROGRESS_AVATAR_SCALE = 0.9; -// 1.2 times the original stage starting stack's visible bounds (341.07 x 201.33). -const AVATAR_AREA_WIDTH = 409.28, AVATAR_AREA_HEIGHT = 241.60; +const VISIBLE_AVATARS = 15; +const MATCHING_FIRST_WAVE_DELAY = 0.18; +const MATCHING_WAVE_INTERVAL = 0.22; +const MATCHING_AVATAR_DURATION = 0.3; +const MATCHING_DURATION = MATCHING_FIRST_WAVE_DELAY + Math.floor((VISIBLE_AVATARS - 2) / 5) * MATCHING_WAVE_INTERVAL + MATCHING_AVATAR_DURATION; const STARTING_STACK_SPREAD = 1.45024; declare const wx: any; @@ -47,6 +51,7 @@ export default class CloudRisePanel extends cc.Component { @property(CloudRiseNumberToImage) matchingReward: CloudRiseNumberToImage = null; @property([cc.Node]) matchingSlots: cc.Node[] = []; private matchingAvatars: cc.Node[] = []; + private matchingAvatarPool: cc.Node[] = []; private matchingLayout: Array<{ x: number; y: number; scale: number }> = []; @property([cc.Node]) avatarTemplates: cc.Node[] = []; private loadedStage = 0; @@ -57,6 +62,8 @@ export default class CloudRisePanel extends cc.Component { @property(cc.Node) progressRoster: cc.Node = null; @property(cc.Node) progressStart: cc.Node = null; private progressSlots: Array<{ node: cc.Node; index: number }> = []; + private progressAvatarPool: cc.Node[] = []; + private survivorCounterMotion = { value: 0 }; private progressRosterKey = ""; private animationWaits: Array<(completed: boolean) => void> = []; @property(cc.Node) design: cc.Node = null; @@ -119,11 +126,12 @@ export default class CloudRisePanel extends cc.Component { private restingAvatars = new Map(); update(dt: number): void { - if (this.mode === "matching" && ["preparing", "submitting"].includes(this.matchPhase)) { + if (this.mode === "matching" && this.matchPhase === "animating") { this.matchingElapsed += dt; - while (this.matchingElapsed >= 0.4 && this.matchingDisplayedCount < 99) { - this.matchingElapsed -= 0.4; - this.addSimulatedMatch(); + if (this.matchingDisplayedCount < 100) { + this.matchingDisplayedCount = Math.max(this.matchingDisplayedCount, + Math.min(99, 1 + Math.floor(this.matchingElapsed / MATCHING_DURATION * 99))); + if (this.matchingCount) this.matchingCount.string = this.matchingDisplayedCount + "/100"; } } if (this.mode === "result" && this.victoryPage && this.victoryPage.active) { @@ -436,12 +444,11 @@ export default class CloudRisePanel extends cc.Component { const matching = mode === "matching", stage = stageNumber || (Service.stage() && Service.stage().stage) || 1; if (matching ? !!this.matchingPage : this.loadedStage === stage) return; const key = matching ? "matching" : "stage" + stage; - traceMatch('page.prepare', { key, reusedTask: !!this.pageLoads[key] }); // Cocos clears component fields on destruction; keep the pending-load map alive for cleanup. const loads = this.pageLoads; if (!loads[key]) loads[key] = (async () => { const prefab = await Bridge.pageFragment(matching ? "matching" : "stage", stage); - if (!cc.isValid(this.node, true)) { traceMatch('page.cancelled', { key, reason: 'node-destroyed' }); return; } + if (!cc.isValid(this.node, true)) return; const page = cc.instantiate(prefab), bindings = page.getComponent(CloudRisePage); if (!matching && this.stagePage) { this.stopVictorySparkles(); @@ -456,7 +463,6 @@ export default class CloudRisePanel extends cc.Component { if (matching) this.matchingPage = page; else { this.stagePage = page; this.loadedStage = stage; } this.bindButtons(); - traceMatch('page.ready', { key }); })().then(() => { delete loads[key]; }, error => { traceMatch('page.failed', { key }, error); delete loads[key]; throw error; }); return loads[key]; } @@ -520,21 +526,18 @@ export default class CloudRisePanel extends cc.Component { } private overviewClick(): void { const run = Service.run(), data = Service.data(); - traceMatch('start.click', { mode: this.mode, busy: this.busy, matchPhase: this.matchPhase, - runStatus: run && run.status, available: data && data.available, active: Bridge.challengeActive(run), registrationOpen: !!data && Bridge.registrationOpen(data) }); if (Bridge.challengeActive(run) && run.status === "playing") this.show("progress"); else if (Bridge.challengeActive(run) && run.status === "waiting" || data && data.available && Bridge.registrationOpen(data)) void this.match(); - else { traceMatch('start.blocked', { reason: 'not-eligible' }); this.closePage(); } + else this.closePage(); } async match(): Promise { - if (this.busy) { traceMatch('match.blocked', { reason: 'busy', matchPhase: this.matchPhase }); return false; } + if (this.busy) return false; const request = ++this.matchRequest; const matchId = this.matchId = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2); const active = () => cc.isValid(this.node, true) && this.matchRequest === request; this.busy = true; this.matchPhase = "preparing"; const current = Service.run(), nextStage = current && current.status === "waiting" ? current.stage + 1 : 1; - traceMatch('match.start', { request, matchId, nextStage }); let resourceError = false; try { // Start both downloads, but only matching readiness gates the first page switch. @@ -542,7 +545,6 @@ export default class CloudRisePanel extends cc.Component { const matchingReady = this.preparePage("matching"); const stageReady = this.preparePage("progress", nextStage).then(() => ({ error: null }), error => ({ error })); await matchingReady; - traceMatch('match.matching-ready', { request, active: active() }); if (!active()) return false; this.show("matching"); this.matchingElapsed = 0; this.matchingDisplayedCount = 1; @@ -551,42 +553,32 @@ export default class CloudRisePanel extends cc.Component { const data = Service.data(); this.matchingReward.setValue((current && current.pools || data && data.pools || [10000, 15000, 20000])[nextStage - 1]); this.resetMatchingAvatars(); - this.addSimulatedMatch(); this.showMatchingPrompt(); } const prepared = await stageReady; - traceMatch('match.stage-ready', { request, nextStage, active: active() }, prepared.error); + if (prepared.error) traceMatch('match.stage-ready', { request, nextStage, active: active() }, prepared.error); if (!active()) return false; if (prepared.error) throw prepared.error; // The server prepares a draft roster without consuming participation. this.matchPhase = "submitting"; if (this.buttons[0]) this.buttons[0].active = false; - traceMatch('match.prepare-request', { request, matchId }); const matched = await Service.prepareMatch(matchId); - traceMatch('match.prepare-result', { request, matchId, matched, active: active(), message: Service.error, retryExhausted: Service.matchRetryExhausted }); if (!active()) return false; const matching = Service.data()?.matching; if (matched && matching?.id === matchId) { this.matchPhase = "animating"; - traceMatch('match.animation-start', { request, matchId }); await this.playMatching(matching.stage, request); - traceMatch('match.animation-ready', { request, active: active() }); if (!active()) return false; this.matchPhase = "complete"; - traceMatch('match.confirm-request', { request, matchId }); const confirmed = await Service.confirmMatch(matchId); - traceMatch('match.confirm-result', { request, matchId, confirmed, message: Service.error }); if (!confirmed) { if (active()) MiniGameSdk.API.showToast("匹配失败请重试"); if (active() && Service.matchRetryExhausted) this.returnHomeAfterMatchFailure(); return false; } if (!active()) return false; - if (this.matchingCount) this.matchingCount.string = "100/100"; - traceMatch('match.wait-continue', { request }); if (!active() || !await this.waitForContinue()) return false; - traceMatch('match.continued', { request }); } else { MiniGameSdk.API.showToast("匹配失败请重试"); if (Service.matchRetryExhausted) this.returnHomeAfterMatchFailure(); @@ -603,7 +595,6 @@ export default class CloudRisePanel extends cc.Component { return false; } finally { - traceMatch('match.finished', { request, matchId, matchPhase: this.matchPhase, active: active(), runStatus: Service.run() && Service.run().status }); // An older cancelled attempt must never reset a newer attempt's UI or busy state. if (active()) { this.matchPhase = "idle"; this.busy = false; @@ -841,22 +832,19 @@ export default class CloudRisePanel extends cc.Component { private stackPosition(base: { x: number; y: number }, order: number, spread = STARTING_STACK_SPREAD, count = 100): { x: number; y: number } { if (order === 0) return { x: base.x, y: base.y }; const density = Math.min(1, spread / STARTING_STACK_SPREAD); - // All pages use fixed avatar sizes. Only row count and overlap change with population/cloud size. - if (order <= 2) return { x: base.x + (order === 1 ? -54 : 54) * density, y: base.y + 8 * density }; - const remaining = Math.max(1, count - 3); - const columns = Math.min(10, Math.ceil(Math.sqrt(remaining * 1.1))); - const rows = Math.ceil(remaining / columns), row = Math.floor((order - 3) / columns); - const inRow = Math.min(columns, remaining - row * columns), col = (order - 3) % columns; - const stepX = Math.min(44 * density, (AVATAR_AREA_WIDTH * density - 140 * 0.52 * PROGRESS_AVATAR_SCALE) / Math.max(1, columns - 1)); - const stepY = Math.min(36 * density, (AVATAR_AREA_HEIGHT * density - 140 * (0.65 + 0.52) * PROGRESS_AVATAR_SCALE / 2 - 24 * density) / Math.max(1, rows - 1)); - return { - x: base.x + (col - (inRow - 1) / 2) * stepX, - y: base.y + 24 * density + row * stepY - }; + // Staggered depth rows (1 + 2 + 4 + 5 + 3), with the player in front. + if (order <= 2) return { x: base.x + (order === 1 ? -60 : 60) * density, y: base.y + 18 * density }; + const row = order < 7 ? { start: 3, count: 4, step: 66, y: 62 } + : order < 12 ? { start: 7, count: 5, step: 64, y: 108 } + : { start: 12, count: 3, step: 66, y: 154 }; + const columns = Math.min(row.count, Math.min(VISIBLE_AVATARS, count) - row.start); + return { x: base.x + (order - row.start - (columns - 1) / 2) * row.step * density, + y: base.y + row.y * density }; } private clearProgressRoster(): void { this.restingAvatars.clear(); - this.progressSlots.forEach(({ node }) => { cc.Tween.stopAllByTarget(node); node.destroy(); }); + this.progressAvatarPool.forEach(node => { cc.Tween.stopAllByTarget(node); node.destroy(); }); + this.progressAvatarPool = []; this.progressSlots = []; this.progressRosterKey = ""; } // Cloud size controls stack spacing only; avatar sizes stay fixed across all landings. @@ -869,15 +857,29 @@ export default class CloudRisePanel extends cc.Component { private prepareProgressRoster(stage: any, indices: number[], includeSelf = true): void { this.resetCloudFloat(); const rosterCloud = this.cloudForScore(stage, stage.success_num); - this.clearProgressRoster(); + this.progressRosterKey = ""; + this.restingAvatars.clear(); + this.progressAvatarPool.forEach(node => { cc.Tween.stopAllByTarget(node); node.active = false; }); + const selected = indices.slice(0, VISIBLE_AVATARS - (includeSelf ? 1 : 0)); + const identities = includeSelf ? [-1, ...selected] : selected; + const previous = new Map(this.progressSlots.map(slot => [slot.index, slot.node] as [number, cc.Node])); + const reserved = new Set(identities.map(index => previous.get(index)).filter(node => !!node)); + const spare = this.progressAvatarPool.filter(node => !reserved.has(node)); const base = this.cloudPosition(stage, stage.success_num); const size = this.cloudSpread(rosterCloud); const spread = size * (0.85 + 0.15 * size); - this.progressSlots = (includeSelf ? [-1, ...indices] : indices).map((index, order) => { - const slot = cc.instantiate(this.avatarTemplates[index < 0 ? 2 : index % 2]); - this.progressRoster.addChild(slot); slot.active = true; slot.opacity = 255; + this.progressSlots = identities.map((index, order) => { + // The player uses its own authored frame; never recycle it for an opponent. + const spareIndex = spare.findIndex(node => (node as any).cloudRiseSelf === (index < 0)); + let slot = previous.get(index) || (spareIndex >= 0 ? spare.splice(spareIndex, 1)[0] : null); + if (!slot) { + slot = cc.instantiate(this.avatarTemplates[index < 0 ? 2 : index % 2]); + (slot as any).cloudRiseSelf = index < 0; + this.progressRoster.addChild(slot); this.progressAvatarPool.push(slot); + } + slot.active = true; slot.opacity = 255; slot.scale = (index < 0 ? 0.65 : 0.52) * PROGRESS_AVATAR_SCALE; slot.angle = 0; - const pose = this.stackPosition(base, order, spread, indices.length + (includeSelf ? 1 : 0)); slot.setPosition(pose.x, pose.y); + const pose = this.stackPosition(base, order, spread, identities.length); slot.setPosition(pose.x, pose.y); if (rosterCloud) this.restingAvatars.set(slot, { cloud: rosterCloud, offsetY: pose.y - rosterCloud.y }); slot.zIndex = 100 - order; this.avatar(slot, index < 0 ? { ...cc.fx.GameConfig.GM_INFO, username: "我" } : stage.opponents[index]); @@ -955,13 +957,11 @@ export default class CloudRisePanel extends cc.Component { } } private resetMatchingAvatars(): void { - this.matchingAvatars.forEach(slot => { - cc.Tween.stopAllByTarget(slot); - if (!this.matchingSlots.includes(slot)) slot.destroy(); - }); + this.matchingAvatarPool.forEach(slot => { cc.Tween.stopAllByTarget(slot); slot.active = false; }); this.matchingSlots.forEach(slot => { cc.Tween.stopAllByTarget(slot); slot.active = false; }); this.matchingAvatars = [this.matchingSlots[2]]; - // Reserve the full roster before any arrival; filling a slot must not move earlier avatars. + this.matchingElapsed = 0; this.matchingDisplayedCount = 1; + // Reserve representative positions once; arrival waves never move earlier avatars. this.matchingLayout = this.matchingPositions(); const front = this.matchingSlots[2]; this.popMatchingAvatar(front, { ...cc.fx.GameConfig.GM_INFO, username: "我" }, @@ -970,32 +970,43 @@ export default class CloudRisePanel extends cc.Component { } /** Same bounds, sizes and ordering as every stage's initial cloud. */ private matchingPositions(): Array<{ x: number; y: number; scale: number }> { - return Array.from({ length: 99 }, (_, i) => ({ - ...this.stackPosition({ x: 0, y: -760 }, i + 1, STARTING_STACK_SPREAD, 100), scale: 0.52 * PROGRESS_AVATAR_SCALE + return Array.from({ length: VISIBLE_AVATARS - 1 }, (_, i) => ({ + ...this.stackPosition({ x: 0, y: -760 }, i + 1, STARTING_STACK_SPREAD, VISIBLE_AVATARS), scale: 0.52 * PROGRESS_AVATAR_SCALE })); } - private addSimulatedMatch(): void { - if (this.matchingDisplayedCount >= 99) return; - const icon = String(Math.floor(Math.random() * 17)); - this.appendMatchingAvatar({ username: "玩家", useravatar: icon, useravatarIcon: icon }); + private matchingProfiles(opponents: any[]): any[] { + const realCount = 8 + Math.floor(Math.random() * 5); + const players = opponents.filter(player => /^https:\/\//.test(String(player.useravatarIcon || "")) + || /^https:\/\//.test(String(player.useravatar || ""))).slice(0, realCount); + // Only presentation is sampled; the complete competition roster stays unchanged. + while (players.length < VISIBLE_AVATARS - 1) { + const icon = String(Math.floor(Math.random() * 17)); + players.push({ username: "玩家", useravatar: icon, useravatarIcon: icon }); + } + for (let i = players.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [players[i], players[j]] = [players[j], players[i]]; + } + return players; } private appendMatchingAvatar(player: any, delay = 0): void { const i = this.matchingAvatars.length - 1; const pose = this.matchingLayout[i]; if (!pose) return; - const slot = i < 2 ? this.matchingSlots[i] : cc.instantiate(this.matchingSlots[i % 2]); - if (i >= 2) this.matchingSlots[0].parent.addChild(slot); + let slot = i < 2 ? this.matchingSlots[i] : this.matchingAvatarPool[i - 2]; + if (!slot) { + slot = cc.instantiate(this.matchingSlots[i % 2]); + this.matchingSlots[0].parent.addChild(slot); this.matchingAvatarPool.push(slot); + } this.matchingAvatars.push(slot); this.popMatchingAvatar(slot, player, pose, delay); slot.zIndex = 99 - i; - this.matchingDisplayedCount = Math.min(99, this.matchingAvatars.length); - this.matchingCount.string = this.matchingDisplayedCount + "/100"; } private popMatchingAvatar(slot: cc.Node, player: any, pose: { x: number; y: number; scale: number }, delay: number, done?: () => void): void { slot.active = true; slot.scale = 0; slot.opacity = 0; slot.setPosition(pose.x, pose.y - 12); slot.zIndex = Math.round(1000 - pose.y); this.avatar(slot, player); - cc.tween(slot).delay(delay).to(0.3, { y: pose.y, scale: pose.scale, opacity: 255 }, { easing: "backOut" }) + cc.tween(slot).delay(delay).to(MATCHING_AVATAR_DURATION, { y: pose.y, scale: pose.scale, opacity: 255 }, { easing: "backOut" }) .call(() => { if (done) done(); }).start(); } private async playMatching(stage: any, request = this.matchRequest): Promise { @@ -1003,15 +1014,18 @@ export default class CloudRisePanel extends cc.Component { this.showMatchingPrompt(); if (!this.matchingAvatars.length) this.resetMatchingAvatars(); this.matchingReward.setValue(stage.pool); - const remaining = 100 - this.matchingAvatars.length; - const players = stage.opponents.slice(0, remaining); - if (players.length !== remaining) throw new Error("匹配人数不足"); - for (const player of players) { - if (request !== this.matchRequest || !cc.isValid(this.node, true)) return; - this.appendMatchingAvatar(player); - if (!await this.waitAnimation(0.04)) return; + if (!stage.opponents || stage.opponents.length !== 99) throw new Error("匹配人数不足"); + if (request !== this.matchRequest || !cc.isValid(this.node, true)) return; + // Start opponent waves only after the API returns; never replace an arrived avatar. + const players = this.matchingProfiles(stage.opponents); + this.matchingElapsed = 0; + for (let i = this.matchingAvatars.length - 1; i < VISIBLE_AVATARS - 1; i++) { + this.appendMatchingAvatar(players[i], MATCHING_FIRST_WAVE_DELAY + Math.floor(i / 5) * MATCHING_WAVE_INTERVAL); } - await this.waitAnimation(0.35); + if (!await this.waitAnimation(MATCHING_DURATION)) return; + if (request !== this.matchRequest || !cc.isValid(this.node, true)) return; + this.matchingDisplayedCount = 100; + this.matchingCount.string = "100/100"; } private showFailureMessage(failed: boolean): void { this.progressStatsContents.forEach(node => node.active = !failed); @@ -1034,60 +1048,64 @@ export default class CloudRisePanel extends cc.Component { const alive = (before.opponents || []).map((p, i) => p.alive ? i : -1).filter(i => i >= 0); const survivors = alive.filter(i => after.opponents[i] && after.opponents[i].alive); const eliminated = alive.filter(i => after.opponents[i] && !after.opponents[i].alive); - this.prepareProgressRoster(before, alive); + // Keep stable leading survivors and at most three real eliminated representatives. + // Small rosters show every participant, including every elimination. + const shownDrops = alive.length < VISIBLE_AVATARS ? eliminated : eliminated.slice(0, 3); + const shownSurvivors = survivors.slice(0, VISIBLE_AVATARS - 1 - shownDrops.length); + const shownBefore = alive.length < VISIBLE_AVATARS ? alive : [...shownSurvivors, ...shownDrops]; + this.prepareProgressRoster(before, shownBefore); this.updateClocks(); - if (!await this.waitAnimation(0.25)) return false; + if (!await this.waitAnimation(0.12)) return false; const destinationScore = Math.min(after.target, Number(after.round) || before.success_num + 1); const destinationCloud = this.cloudForScore(after, destinationScore); const size = this.cloudSpread(destinationCloud); const spread = size * (0.85 + 0.15 * size); const destinationBase = this.cloudPosition(after, destinationScore); - // Plan every landing once from the final population, relative to the floating cloud. - const landingOffsets = Array.from({ length: after.survivors }, (_, order) => - this.stackPosition({ x: destinationBase.x - destinationCloud.x, y: destinationBase.y - destinationCloud.y }, - order, spread, after.survivors)); - const landingPosition = (order: number) => ({ - x: destinationCloud.x + landingOffsets[order].x, - y: destinationCloud.y + landingOffsets[order].y - }); + const finalIndices = survivors.slice(0, VISIBLE_AVATARS - (won ? 1 : 0)); + const finalCount = finalIndices.length + (won ? 1 : 0); + const landingPosition = (order: number) => this.stackPosition(destinationBase, order, spread, finalCount); const self = this.progressSlots[0].node; const jumping = this.progressSlots.filter(s => s.index >= 0 && survivors.includes(s.index)); const falling = this.progressSlots.filter(s => s.index >= 0 && eliminated.includes(s.index)); - let remaining = before.survivors; + let duration = 0; if (won) { - const duration = this.jumpAvatar(self, landingPosition(0), 0, destinationCloud); - if (!await this.waitAnimation(duration + 0.05)) return false; + duration = this.jumpAvatar(self, landingPosition(0), 0, destinationCloud); this.progressCount.string = after.success_num + "/" + after.target; } - let jumpDuration = 0; - jumping.forEach(({ node }, i) => { - node.zIndex = 99 - i; - // Overlapping waves vary launch order, arc and tilt without changing the final roster positions. - const delay = Math.floor(i / Math.max(4, Math.ceil(jumping.length / 5))) * 0.26 + [0, 0.19, 0.07, 0.29][i % 4]; - jumpDuration = Math.max(jumpDuration, this.jumpAvatar(node, - landingPosition(i + (won ? 1 : 0)), - delay, destinationCloud, i)); + jumping.forEach(({ node, index }, i) => { + // Two overlapping waves start while the player is still in flight. + duration = Math.max(duration, this.jumpAvatar(node, + landingPosition(finalIndices.indexOf(index) + (won ? 1 : 0)), + (0.14 + Math.floor(i / 7) * 0.12) / AVATAR_MOTION_TIME_SCALE, destinationCloud, i)); }); - if (jumping.length && !await this.waitAnimation(jumpDuration + 0.05)) return false; - if (!won) { - const duration = this.dropAvatar(self, 0, () => { this.survivorCount.string = (--remaining) + "/100"; }); - if (!await this.waitAnimation(duration + 0.05)) return false; - } - const beforeDrops = remaining; - let dropDuration = 0, dropped = 0; + const dropStart = won || jumping.length ? 0.5 : 0; + if (!won) duration = Math.max(duration, this.dropAvatar(self, dropStart / AVATAR_MOTION_TIME_SCALE, () => {})); falling.forEach(({ node }, i) => { - const delay = Math.floor(i / Math.max(4, Math.ceil(falling.length / 5))) * 0.22 + [0, 0.16, 0.05, 0.25][i % 4]; - dropDuration = Math.max(dropDuration, this.dropAvatar(node, delay, () => { - // Different flight times can finish out of order; count completions, not avatar indices. - remaining = beforeDrops - Math.round(eliminated.length * ++dropped / falling.length); - this.survivorCount.string = remaining + "/100"; - }, i)); + duration = Math.max(duration, this.dropAvatar(node, + (dropStart + 0.06 + Math.floor(i / 7) * 0.08) / AVATAR_MOTION_TIME_SCALE, () => {}, i)); }); - if (falling.length && !await this.waitAnimation(dropDuration + 0.05)) return false; + // Population changes are independent of the number and completion order of visible drops. + cc.Tween.stopAllByTarget(this.survivorCounterMotion); + this.survivorCounterMotion.value = before.survivors; + cc.tween(this.survivorCounterMotion).delay(dropStart).to(0.6, { value: after.survivors }, { + onUpdate: (counter: { value: number }) => { this.survivorCount.string = Math.round(counter.value) + "/100"; } + }).start(); + duration = Math.max(duration, dropStart + 0.6); + if (!await this.waitAnimation(duration + 0.03)) return false; this.survivorCount.string = after.survivors + "/100"; this.progressCount.string = after.success_num + "/" + after.target; + const landed = new Set(jumping.map(slot => slot.index)); + if (won) landed.add(-1); + // Refill the representative group only with survivors. Reuse landed nodes and their frames. + this.prepareProgressRoster({ ...after, success_num: destinationScore }, finalIndices, won); + this.progressSlots.forEach(({ node, index }) => { + if (!landed.has(index)) { + node.opacity = 0; + cc.tween(node).to(0.18, { opacity: 255 }).start(); + } + }); this.keepEliminationRoster = !won; - return this.waitAnimation(0.55); + return this.waitAnimation(0.22); } private tick = (): void => { const data = Service.data(), run = Service.run(); @@ -1155,7 +1173,8 @@ export default class CloudRisePanel extends cc.Component { this.node.off("touchend", this.onResultTouchEnd, this, true); this.node.off("touchcancel", this.onResultTouchCancel, this, true); if (this.unsubscribe) this.unsubscribe(); - this.progressSlots.map(slot => slot.node).concat(this.matchingAvatars, this.matchingSlots).forEach(slot => cc.Tween.stopAllByTarget(slot)); + cc.Tween.stopAllByTarget(this.survivorCounterMotion); + this.progressAvatarPool.concat(this.matchingAvatarPool, this.matchingAvatars, this.matchingSlots).forEach(slot => cc.Tween.stopAllByTarget(slot)); this.victoryGroups.forEach(group => cc.Tween.stopAllByTarget(group)); this.animationWaits.splice(0).forEach(resolve => resolve(false)); } diff --git a/assets/cloud_rise_home/scripts/CloudRiseRuntime.ts b/assets/cloud_rise_home/scripts/CloudRiseRuntime.ts index a6f1471f..ca83e48b 100644 --- a/assets/cloud_rise_home/scripts/CloudRiseRuntime.ts +++ b/assets/cloud_rise_home/scripts/CloudRiseRuntime.ts @@ -90,10 +90,8 @@ export default class CloudRiseRuntime { return { code: 0, msg: "账号已切换" }; if (body.matchId && this.cancelledMatches.has(body.matchId) && body.action !== "cancel_match") return { code: 0, msg: "匹配已取消" }; - const started = Date.now(); // Log operation/result metadata only, never account data or the opponent roster. const diagnostic = { action: body.action, matchId: body.matchId, retry, retryNetwork }; - console.log('[CloudRiseFlow] api.request ' + JSON.stringify(diagnostic)); const response: any = await new Promise(resolve => { try { Utils.POST("cloudRise/index", { uid, ...body }, resolve); } catch (error) { @@ -104,10 +102,6 @@ export default class CloudRiseRuntime { resolve({ code: 0, msg: "活动暂时无法连接,请重试", networkError: true }); } }); - console.log('[CloudRiseFlow] api.response ' + JSON.stringify({ - ...diagnostic, elapsedMs: Date.now() - started, - code: response && response.code, message: response && response.msg, networkError: response && response.networkError - })); if (!retryNetwork || !response?.networkError) return response; if (retry >= 5) { this.matchRetryExhausted = true; @@ -185,6 +179,32 @@ export default class CloudRiseRuntime { this.error = res.msg; this.save(); this.emit(); return true; } + /** Reconcile against a fresh server snapshot, preserving any recorded result before recovery. */ + private static reconcileAttempt(data: any, recoverOrphan: boolean): "ready" | "queued" | "blocked" { + const run = data.run, stage = run && run.stages.find(item => item.stage === run.stage); + if (!run || run.status !== "playing" || !stage?.attemptId) return "ready"; + const matches = (item: Attempt) => item && item.runId === run.id + && item.stage === run.stage && item.attemptId === stage.attemptId; + const index = this.journal.queue.findIndex(item => item.action === "finish" && matches(item)); + if (index >= 0) { + if (index === 0) return "ready"; + // A legacy conflicting begin may be ahead of the result that unlocks it. + this.journal.queue.unshift(this.journal.queue.splice(index, 1)[0]); + } else if (matches(this.journal.attempt)) { + if (!this.journal.attempt.outcome) return "ready"; // The current board is still running. + this.journal.queue.unshift({ action: "finish", ...this.journal.attempt }); + } else { + // Only cold-start recovery may infer interruption. A live conflict could be another device. + if (!recoverOrphan) return "blocked"; + this.journal.queue.unshift({ + action: "finish", runId: run.id, stage: run.stage, + attemptId: stage.attemptId, outcome: "interrupted" + }); + } + this.save(); + return "queued"; + } + static async sync(retryNetwork = false): Promise { this.ensure(); if (!this.uid) return false; @@ -223,6 +243,12 @@ export default class CloudRiseRuntime { if (!await this.accept(check.data)) return false; this.journal.queue.shift(); this.journal.attempt = null; this.save(); continue; } + if ((item.action === "begin" || item.action === "finish") + && current?.id === item.runId && current.stage === item.stage) { + const recovery = this.reconcileAttempt(check.data, !this.initialized); + if (!await this.accept(check.data)) return false; + if (recovery === "queued") continue; + } } this.error = res && res.msg || "活动进度待同步,请联网重试"; this.emit(); return false; } @@ -243,7 +269,11 @@ export default class CloudRiseRuntime { if (this.uid !== uid) return false; if (this.acceptClosed(res)) return false; if (!res || res.code !== 1) { this.error = res && res.msg || "活动暂时无法连接"; this.emit(); return false; } + const recovery = this.reconcileAttempt(res.data, !this.initialized); if (!await this.accept(res.data)) return false; this.initialized = true; + if (recovery === "blocked") { + this.error = "上一关尚未结算,请先完成原关卡"; this.emit(); return false; + } // A board result can arrive while the final status request is in flight. if (!this.journal.queue.length) return true; } @@ -324,8 +354,9 @@ export default class CloudRiseRuntime { // The pending records remain ordered and are reconciled by the normal sync queue. const locallyFinished = finishes.some(item => item.outcome !== "win") || stage && stage.success_num + finishes.length >= stage.target; - if (run && run.status === "playing" && this.now() < run.expiresAt && !locallyFinished) this.createAttempt(run); - if ((!this.initialized || this.pending()) && Date.now() >= this.nextPlaySyncAt) { + const unresolvedAttempt = stage?.attemptId && !finishes.some(item => item.attemptId === stage.attemptId); + if (run && run.status === "playing" && this.now() < run.expiresAt && !locallyFinished && !unresolvedAttempt) this.createAttempt(run); + if ((!this.initialized || this.pending() || unresolvedAttempt) && Date.now() >= this.nextPlaySyncAt) { this.nextPlaySyncAt = Date.now() + 5000; void this.sync().catch(error => { this.error = String(error); cc.warn("百人赛后台同步失败", error); }); } diff --git a/assets/coin_madness/prefab/CoinMadness.prefab b/assets/coin_madness/prefab/CoinMadness.prefab index 9d4d47a7..15401b17 100644 --- a/assets/coin_madness/prefab/CoinMadness.prefab +++ b/assets/coin_madness/prefab/CoinMadness.prefab @@ -7182,7 +7182,7 @@ "__type__": "TypedArray", "ctor": "Float64Array", "array": [ - -30, + -24, 22, 0, 0, diff --git a/assets/coin_madness/script/CoinMadnessDigits.ts b/assets/coin_madness/script/CoinMadnessDigits.ts index d23b5fe9..09be0efd 100644 --- a/assets/coin_madness/script/CoinMadnessDigits.ts +++ b/assets/coin_madness/script/CoinMadnessDigits.ts @@ -33,7 +33,7 @@ export default class CoinMadnessDigits extends cc.Component { slot.active = i < glyphs.length; if (!slot.active) return; const glyph = glyphs[i]; - // slot.x = x + glyph.width * scale / 2; + slot.x = x + glyph.width * scale / 2; const image = slot.getChildByName('Image'); image.active = glyph.char !== ':'; if (glyph.char !== ':') { diff --git a/assets/coin_madness/script/CoinMadnessPanel.ts b/assets/coin_madness/script/CoinMadnessPanel.ts index 80765c43..a497bcec 100644 --- a/assets/coin_madness/script/CoinMadnessPanel.ts +++ b/assets/coin_madness/script/CoinMadnessPanel.ts @@ -211,7 +211,8 @@ export default class CoinMadnessPanel extends cc.Component { } else if (!lock.getNumberOfRunningActions()) lock.active = false; this.paintProgress(row, i, expired); child('Reward').getComponent(CoinMadnessDigits).setText(String(stage.coins), 170); - ['Task', 'CompletedTask'].forEach(name => child(name).getChildByName('Target').getComponent(CoinMadnessDigits).setText(String(stage.wins))); + // Keep three-digit goals inside the blank between “通过” and “关”. + ['Task', 'CompletedTask'].forEach(name => child(name).getChildByName('Target').getComponent(CoinMadnessDigits).setText(String(stage.wins), 88)); }); this.focusGrowingTask(); this.tick(); diff --git a/docs/cloudRise-development.md b/docs/cloudRise-development.md index 3f24717a..c997ea8b 100644 --- a/docs/cloudRise-development.md +++ b/docs/cloudRise-development.md @@ -23,7 +23,7 @@ stage1/2 成功后状态为 waiting。主线结算“下一关”通过 `CloudRi 同一期重置复测:服务端 run.id 由玩家和 periodId 派生。同一期参与记录被管理员清空后重新报名,会复用 run.id;旧 journal.seenResults 可使前两阶段已保存奖励却不展示动画。现仅在 start 报名成功时清除该 run.id 的旧展示确认,start_stage、状态轮询、重登不清除,发奖回执不变。该情景已先复现再修复,运行时 21 项测试通过;实际 Cocos 引擎中连跑两轮 5/7/9 关(第二轮复用同一期和 run.id),六次阶段结束均显示全部四组奖励元素。此为本地隔离接口验证,尚未取得用户设备的存档以确认其具体触发原因。 首次有效棋盘操作登记本关尝试。使用可消除方块的锤子/魔法棒也接入登记;无效目标不登记。通关只结算一次,复活窗口本身不判负;确认放弃、有效重开、最终失败才结算失败。 -切后台与网络错误没有独立判负逻辑。新实例读取未完成尝试时补传 interrupted;已经保存的胜利优先重试上传,不能改判中断。 +切后台与网络错误没有独立判负逻辑。新实例读取未完成尝试时补传 interrupted;已经保存的胜利优先重试上传,不能改判中断。冷启动同步也核对服务端 attemptId:本地记录丢失但服务端仍有未结算尝试时,持久化并补传原尝试的 interrupted。若旧队列中的新 begin 挡住上一关已保存的结果,先补传上一关结果再继续;运行中发现来源不明的其他尝试时保留状态,不直接判负。开始新尝试前检查快照中的未结算标记,已有待补传结果仍按原队列离线推进。服务端保留不同 attemptId 的冲突校验,并返回 errorCode=ATTEMPT_PENDING。 ## 运行检查 @@ -151,3 +151,16 @@ home 继续放主包。新增项目构建扩展 packages/cloud-rise-package, 实际产物 game.json 已指向 subpackages/cloud_rise,但磁盘仍为四个独立目录。Creator 构建日志的 platform 是 mini-game,actualPlatform 才是 wechatgame;扩展原有 platform 判断会跳过合并。改为优先 actualPlatform,同时模板清单恢复四个中间目录,仅在合并成功后由扩展写入统一目录,避免未执行扩展时产生无效 root。 新增真实 Creator 平台参数、其他平台跳过及模板到最终清单的回归验证,相关 29 项测试通过。已备份当前构建入口/配置到 temp/cloudrise-before-root-fix-o81IeD,再直接修复 build/wechatgame,确认全部 17 个分包 root 和 game.js 均存在、四个 Bundle 版本配置存在,home 保留主包。未运行微信开发者工具编译;后续构建需重启 Creator 以加载修正后的扩展。 + + +## 2026-10-22 匹配与晋级头像展示优化 + +比赛名单和人数继续保留完整 100 人,匹配及云台最多展示 15 个代表头像。匹配头像参照提供的效果图采用 1+2+4+5+3 的错位纵深排列,主角居中靠前,后排层级降低;三个阶段沿用同一排列规则,按云台可用宽度收紧间距。 + +匹配分三波批量入场。资源/准备请求未完成时保持 1/100,只显示本人;接口返回后人数与对手入场同时开始,按最后一波入场结束时间计算总时长(当前 0.18 + 2 × 0.22 + 0.3 = 0.92 秒),结束时同步显示 100/100。取消确认后额外的 0.2 秒轻弹,确认接口仍在入场完成后执行,成功后才开放“点击继续”,失败则沿用原有提示及返回逻辑。网络等待不计入动画时长。 + +晋级采用主角领先、两波队友跟随与掉落交叠播放,人数变化独立于掉落头像数与完成顺序。大队伍使用最多三个实际淘汰者代表,小队伍完整展示实际角色;结束时仅补入存活者,15 人及以下展示全部存活者。匹配和云台头像复用节点,同阶段优先复用存活者节点,切换阶段释放原云台节点。编排测试覆盖三阶段、胜利/失败、0/1/8/14/15/48/99 个存活对手,均不超过 2 秒。 + +验证:动画与匹配/分包流程 66 项测试通过,包含上述 42 种结果组合、节点复用、取消/销毁及服务器确认门槛。另有 5 项现存失败(主线续关测试夹具、缺失旧公共字体路径),已在修改前版本中复现,定向运行时排除;全项目类型检查仍有 20 项其他文件错误,CloudRisePanel 无类型错误。已用运行时坐标做静态排布检查;本地 Cocos 浏览器预览连接失败,未完成微信真机视觉验证或重新构建发布包。 + +2026-10-22 匹配头像来源补充:等待匹配接口时只显示本人,返回完整名单后才创建对手头像。14 个对手展示位置随机取 8~12 个带 HTTPS 头像地址的返回资料(支持 useravatar 与 useravatarIcon),其余使用本地默认头像;真实头像不足时按可用数量展示并用默认头像补齐。仅调整展示样本,不改写比赛名单。对手入场计时从接口返回后重新开始,慢接口仍保留三波节奏,不等待头像图片下载完成,也不替换已经入场的头像。17 项匹配相关测试通过,包含 8/10/12 个真实头像、慢接口、默认头像补齐、重复播放和服务器确认门槛。 diff --git a/packages/activity-bundles/runtime.js b/packages/activity-bundles/runtime.js index 6d23ee13..947551ea 100644 --- a/packages/activity-bundles/runtime.js +++ b/packages/activity-bundles/runtime.js @@ -14,11 +14,11 @@ for (const id of Object.keys(config.groups || {})) { let cycle = 0, manifestTask = null, sequence = 0; // Snapshot diagnostics so the developer tools console preserves the failure context. function trace(event, detail, error) { + if (!error) return; const record = Object.assign({ cycle }, detail); - if (error) record.error = { message: error.message || String(error), errMsg: error.errMsg, stack: error.stack }; - console[error ? 'error' : 'log']('[ActivityBundles] ' + event + ' ' + JSON.stringify(record)); + record.error = { message: error.message || String(error), errMsg: error.errMsg, stack: error.stack }; + console.error('[ActivityBundles] ' + event + ' ' + JSON.stringify(record)); } -trace('init', { diagnostics: 'load-debug-v1', baseUrl, bundles: config.bundles, groups: config.groups }); const digest = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); const safePath = value => typeof value === 'string' && /^[a-zA-Z0-9_./+@=-]+$/.test(value) && !value.split('/').some(p => !p || p === '.' || p === '..'); @@ -60,14 +60,12 @@ function validateMetadata(entry, name) { function manifest() { if (!manifestTask) manifestTask = new Promise((resolve, reject) => { const url = baseUrl + 'manifest.json?_t=' + Date.now() + '-' + cycle; - trace('manifest.request', { url }); wx.request({ url, timeout: 8000, header: { 'Cache-Control': 'no-cache' }, success: response => { try { if (response.statusCode !== 200) throw Error('活动资源清单请求失败:' + response.statusCode); const data = validate(typeof response.data === 'string' ? JSON.parse(response.data) : response.data); - trace('manifest.ready', { url, statusCode: response.statusCode, bundles: Object.keys(data.bundles) }); resolve(data); } catch (error) { trace('manifest.failed', { url, statusCode: response.statusCode }, error); reject(error); } }, fail: error => { trace('manifest.failed', { url }, error); reject(error); } }); @@ -158,13 +156,11 @@ function checkArchive(bytes) { } async function directory(name, entry, cacheOnly = false) { const previous = cached(name); - trace('cache.check', { name, cacheOnly, hasCache: !!previous, cachedHash: previous && previous.hash, expectedHash: entry && entry.hash }); if (previous && digest(previous.hash) && (!entry || previous.hash === entry.hash) && digest(previous.metadataHash) && owned(previous.dir, name, previous.hash)) { try { const { metadata } = await readMetadata(previous.dir, name, previous.metadataHash); await complete(previous.dir, name, metadata); - trace('cache.hit', { name, dir: previous.dir }); return { dir: previous.dir, metadata }; } catch (error) { trace('cache.invalid', { name, dir: previous.dir, willRepair: !!entry && !cacheOnly }, error); @@ -180,17 +176,14 @@ async function directory(name, entry, cacheOnly = false) { try { await call('mkdir', { dirPath: dir, recursive: true }); phase = 'download'; - trace('zip.download', { name, url, expectedSize: entry.size, expectedHash: entry.hash }); await new Promise((resolve, reject) => wx.downloadFile({ url, filePath: zipFile, timeout: 60000, success: response => { - trace('zip.response', { name, url, statusCode: response.statusCode }); return response.statusCode === 200 ? resolve(response) : reject(Error('活动分包下载失败:' + response.statusCode)); }, fail: reject })); phase = 'read-zip'; const { data } = await call('readFile', { filePath: zipFile }); phase = 'verify-zip'; const actualHash = sha256(data); - trace('zip.verify', { name, actualSize: data.byteLength, expectedSize: entry.size, actualHash, expectedHash: entry.hash }); if (data.byteLength !== entry.size || actualHash !== entry.hash) throw Error('活动分包 SHA-256 校验失败'); phase = 'archive-index'; const archivePaths = checkArchive(new Uint8Array(data)); @@ -204,7 +197,6 @@ async function directory(name, entry, cacheOnly = false) { for (const relative of directories) await call('mkdir', { dirPath: dir + '/' + relative, recursive: true }); phase = 'unzip'; - trace('zip.unzip', { name, zipFile, dir }); await call('unzip', { zipFilePath: zipFile, targetPath: dir }); phase = 'metadata'; const { metadata, metadataHash } = await readMetadata(dir, name); @@ -216,7 +208,6 @@ async function directory(name, entry, cacheOnly = false) { phase = 'save-cache'; wx.setStorageSync(key(name), { hash: entry.hash, dir, metadataHash }); try { await call('unlink', { filePath: zipFile }); } catch (_) {} - trace('directory.ready', { name, dir, files: metadata.files.length, version: metadata.version }); return { dir, metadata }; } catch (error) { trace('directory.failed', { name, phase, url, dir }, error); await discard(stage); throw error; } } @@ -228,10 +219,8 @@ async function activate(name, entry, dir) { // Preserve the previous Bundle object until the replacement config actually loads. if (old) manager.bundles.remove(name); let bundle; - trace('engine.load', { name, dir, version: entry.version }); try { bundle = await new Promise((resolve, reject) => engineLoad.call(manager, dir, { version: entry.version }, (error, result) => error ? reject(error) : resolve(result))); } catch (error) { trace('engine.failed', { name, dir, version: entry.version }, error); if (old) manager.bundles.add(name, old); throw error; } - trace('engine.ready', { name, dir }); if (old && old !== bundle) old.releaseAll(); loaded.set(name, { bundle, dir }); retiredDirs.delete(name); @@ -240,7 +229,6 @@ async function activate(name, entry, dir) { return bundle; } function ensureBundle(name) { - trace('bundle.request', { name, baseUrl, configured: config.bundles.includes(name), reusedTask: tasks.has(name) }); if (!config.bundles.includes(name)) { const error = Error('活动尚未接入公共分包:' + name); trace('bundle.failed', { name, configuredBundles: config.bundles }, error); @@ -258,7 +246,6 @@ function ensureBundle(name) { const [{ data, offline }] = await Promise.all([selectedManifest, preceding && preceding.catch(() => {})]); if (!group && selectedCycle !== cycle) throw Error('活动资源检查周期已更新'); const entry = data && data.bundles[name]; - trace('bundle.selection', { name, selectedCycle, offline, availableBundles: data && Object.keys(data.bundles), entry }); if (data && !entry) throw Error('公共清单未配置活动资源:' + name); const { dir, metadata } = await directory(name, entry, offline); if (!group && selectedCycle !== cycle) throw Error('活动资源检查周期已更新'); @@ -271,7 +258,7 @@ function ensureBundle(name) { return task; } window.__activityBundles = { - beginCycle() { cycle++; manifestTask = null; tasks.clear(); trace('cycle.begin', {}); }, + beginCycle() { cycle++; manifestTask = null; tasks.clear(); }, ensureBundle, retainGroup, async prepareGroup(id) { diff --git a/tools/coin-madness/check-number-layout.cjs b/tools/coin-madness/check-number-layout.cjs index abc48aa8..88d201cb 100644 --- a/tools/coin-madness/check-number-layout.cjs +++ b/tools/coin-madness/check-number-layout.cjs @@ -3,6 +3,17 @@ const fs = require('node:fs'); const ts = require('typescript'); const { chromium } = require('playwright'); +// Snapshot of goldMiner/vip-tiers.example.json: all six configured recharge tiers. +const tiers = [ + [3, [1,2,3,5,10,15,20,30,40,50], [100,150,200,300,400,500,600,750,1000,2000]], + [6, [2,3,4,6,12,18,25,35,50,60], [100,150,200,300,400,500,600,750,1000,2000]], + [12, [2,3,5,7,14,20,30,45,65,80], [150,200,250,400,550,650,800,1000,1350,2650]], + [30, [2,4,6,9,16,24,35,55,85,100], [250,400,500,750,1000,1250,1500,1850,2500,5000]], + [68, [3,4,7,10,18,27,45,70,115,150], [450,700,950,1400,1850,2350,2800,3500,4650,9350]], + [128, [3,5,8,12,20,30,55,90,150,200], [750,1150,1500,2250,3000,3750,4500,5600,7500,15000]] +].map(([price, wins, coins], i) => ({ price, productId: 'gold_miner_' + i, + stages: wins.map((wins, j) => ({ wins, coins: coins[j] })) })); + const output = ts.transpileModule(fs.readFileSync('assets/Script/coin_madness/GoldMinerService.ts', 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS } }).outputText; @@ -26,8 +37,9 @@ assert.equal(service.hasBadge, true); (async () => { const browser = await chromium.launch({ headless: true, channel: 'msedge' }); try { - const page = await browser.newPage({ viewport: { width: 450, height: 800 } }); - page.on('pageerror', error => console.error('Preview:', error.message)); + const page = await browser.newPage({ viewport: { width: 450, height: 800 }, deviceScaleFactor: 2 }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); await page.goto('http://127.0.0.1:' + (process.env.PORT || 8879)); await page.waitForFunction(() => window.demo && demo.ready); await page.evaluate(() => { @@ -42,35 +54,68 @@ assert.equal(service.hasBadge, true); }); await page.evaluate(() => { demo.open(); }); await page.waitForFunction(() => cc.director.getScene().children[0].getChildByName('CoinMadness')?.getComponent('CoinMadnessPanel')?.data); - for (const price of [3, 68, 128, 3]) { - const result = await page.evaluate(price => { + for (const tier of [...tiers, tiers[0], { price: 128, + stages: Array.from({ length: 5 }, (_, i) => ({ wins: 200, coins: i ? 9999 : 45000 })) }]) { + for (const paid of [false, true]) { + const result = await page.evaluate(({ tier, paid }) => { const panel = demo.panel(), data = JSON.parse(JSON.stringify(panel.data)); - data.state.config.priceCents = price * 100; - data.state.config.stages.forEach((s, i) => { s.coins = i ? 9999 : 45000; }); - data.state.paidAt = 0; data.status = 'purchasable'; + data.state.config.priceCents = tier.price * 100; + data.state.config.productId = tier.productId; + data.state.config.stages = tier.stages; + data.state.claimed = tier.stages.map(() => paid); + data.state.paidAt = paid ? 1 : 0; data.status = paid ? 'unlocked' : 'purchasable'; panel.render(data); function bounds(digits) { const boxes = digits.node.children.filter(n => n.active).map(n => { const image = n.getChildByName('Image'); return { left: n.x - image.width / 2, right: n.x + image.width / 2 }; }); - return { left: boxes[0].left, right: boxes[boxes.length - 1].right, - separated: boxes.every((b, i) => !i || b.left >= boxes[i - 1].right) }; + return { text: digits.text, left: boxes[0].left, right: boxes[boxes.length - 1].right, + separated: boxes.every((b, i) => !i || b.left >= boxes[i - 1].right - .001) }; } const p = bounds(panel.priceDigits), total = bounds(panel.totalDigits); - return { total, price: p, priceRight: panel.priceDigits.node.x + p.right, + return { total, price: p, priceLeft: panel.priceDigits.node.x + p.left, + priceRight: panel.priceDigits.node.x + p.right, currencyLeft: panel.currency.x - panel.currency.width / 2, currencyRight: panel.currency.x + panel.currency.width / 2, buttonRight: panel.purchaseButton.width / 2, - rewards: panel.content.children.filter(n => n.active).map(n => bounds(n.getChildByName('Reward').getComponent('CoinMadnessDigits'))) }; - }, price); + discount: { visible: panel.discountDigits.node.parent.active, text: panel.discountDigits.text }, + rewards: panel.content.children.filter(n => n.active).map(n => bounds(n.getChildByName('Reward').getComponent('CoinMadnessDigits'))), + targets: panel.content.children.filter(n => n.active).flatMap(n => ['Task', 'CompletedTask'].map(name => + bounds(n.getChildByName(name).getChildByName('Target').getComponent('CoinMadnessDigits')))) }; + }, { tier, paid }); assert(result.total.separated && result.total.right - result.total.left <= 164.01); assert(result.price.separated && result.priceRight < result.currencyLeft); assert(result.currencyRight < result.buttonRight); assert(result.rewards.every(r => r.separated && r.right - r.left <= 170.01)); + assert.equal(result.price.text, String(tier.price)); + if (tier.productId) { + assert(result.discount.visible, 'configured tiers must show the discount badge'); + assert.equal(result.discount.text, String(Number(tier.productId.slice(-1)) + 1)); + } + assert.equal(result.total.text, String(tier.stages.reduce((sum, s) => sum + s.coins, 0))); + assert.deepEqual(result.rewards.map(r => r.text), tier.stages.map(s => String(s.coins))); + assert.deepEqual(result.targets.map(r => r.text), tier.stages.flatMap(s => [String(s.wins), String(s.wins)])); + assert(result.targets.every(r => r.separated && r.right - r.left <= 88.01)); + assert([result.price, result.total, ...result.rewards, ...result.targets] + .every(r => Math.abs(r.left + r.right) < .01), 'glyph groups must stay centered after every refresh'); + assert(Math.abs(result.priceLeft + result.currencyRight) < .01, + 'price and currency must be centered together'); fs.mkdirSync('.codex_tmp/number-layout', { recursive: true }); - await page.screenshot({ path: '.codex_tmp/number-layout/price-' + price + '.png' }); + if (tier.productId) { + for (const position of ['top', 'bottom']) { + await page.evaluate(position => { + const scroll = demo.panel().content.parent.getComponent(cc.ScrollView); + scroll.stopAutoScroll(); + if (position === 'top') scroll.scrollToTop(0); else scroll.scrollToBottom(0); + }, position); + await page.waitForTimeout(350); + await page.screenshot({ path: `.codex_tmp/number-layout/price-${tier.price}-${paid ? 'claimed' : 'unpaid'}-${position}.png` }); + } + } + } } - console.log('PASS: 4/5-digit rewards, 1/2/3-digit prices, repeated layout and unpaid completion badge'); + assert.deepEqual(errors, []); + console.log('PASS: all 6 tiers / 60 tasks, unpaid and claimed layouts, centered 1/2/3-digit goals, bounded rewards and totals, tier switching, repeated refresh, stress amounts and unpaid completion badge'); } finally { await browser.close(); } })().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tools/test-cloud-rise-animations.cjs b/tools/test-cloud-rise-animations.cjs index 0fa29330..8a6e8ef7 100644 --- a/tools/test-cloud-rise-animations.cjs +++ b/tools/test-cloud-rise-animations.cjs @@ -4,9 +4,9 @@ const fs = require('node:fs'); const vm = require('node:vm'); const ts = require('typescript'); -function fixture() { - const scheduled = [], tweens = []; - class Component { scheduleOnce(fn) { scheduled.push(fn); } } +function fixture(random = Math.random) { + const scheduled = [], tweens = [], waits = []; + class Component { scheduleOnce(fn, seconds) { scheduled.push(fn); waits.push(seconds); } } class Node { constructor(name = '') { this.name = name; this.children = []; this.scale = 1; this.opacity = 255; this.active = true; } addChild(node) { this.children.push(node); node.parent=this; } @@ -33,7 +33,7 @@ function fixture() { const exports={}; vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/cloud_rise_home/scripts/CloudRisePanel.ts','utf8'), {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017,experimentalDecorators:true}}).outputText, - {exports,cc,console,require:()=>({default:class{}})}); + {exports,cc,console,Math:Object.assign(Object.create(Math),{random}),require:()=>({default:class{}})}); const panel=new exports.default();panel.node=new Node();panel.progressRoster=new Node();panel.progressStart={x:310,y:-950}; panel.rowTemplate={children:[new Node()]};panel.avatar=(slot,player)=>slot.player=player; panel.matchingSlots=[new Node('left'),new Node('right'),new Node('front')]; @@ -55,47 +55,51 @@ function fixture() { if(a.props) Object.assign(t.target,a.props); if(a.fn) a.fn(); } }; const advance=async()=>{flushTweens();const fn=scheduled.shift();if(fn)fn();await Promise.resolve();await Promise.resolve();}; - return {panel,stage,tweens,scheduled,advance,flushTweens,cc}; + return {panel,stage,tweens,scheduled,waits,advance,flushTweens,cc}; } -test('matching retains simulated avatars and appends exactly the remaining real profiles',async()=>{ - const f=fixture();const stage=f.stage(99);stage.pool=23456; - f.panel.resetMatchingAvatars();f.flushTweens(); - const self=f.panel.matchingAvatars[0];assert.equal(self.opacity,255); - f.panel.addSimulatedMatch();f.panel.mode='matching';f.panel.matchPhase='submitting'; - f.panel.update(1.61);f.flushTweens(); - assert.equal(f.panel.matchingCount.string,'6/100'); - const simulated=f.panel.matchingAvatars.slice(); - const simulatedPoses=simulated.map(n=>({x:n.x,y:n.y,scale:n.scale,z:n.zIndex})); - assert(simulated.slice(1).every(n=>Number(n.player.useravatarIcon)>=0&&Number(n.player.useravatarIcon)<=16)); - f.panel.matchPhase='animating'; - const task=f.panel.playMatching(stage); - for(let i=0;i<100;i++)await f.advance();await task; - assert.equal(f.panel.matchingAvatars.length,100);assert.equal(f.panel.pool,23456); - assert.equal(new Set(f.panel.matchingAvatars).size,100); - assert.equal(self.opacity,255,'already visible self is not hidden when opponents arrive'); - const bindings=f.panel.matchingAvatars.map(n=>n.player); - await f.advance();assert.equal(f.panel.matchingCount.string,'99/100'); - assert.equal(f.panel.matchingPrompt.active,true); - assert.equal(f.panel.matchingTitle.node.active,false); - assert(f.panel.matchingAvatars.every(slot=>slot.opacity===255&&slot.active)); - assert.equal(self.scale,0.65*0.9); - const [left,right]=f.panel.matchingAvatars.slice(1,3); - assert(left.xself.x,'avatars fill both sides of the player'); - assert(Math.abs(left.y-self.y)<15&&Math.abs(right.y-self.y)<15,'side avatars occupy the front row'); - assert(f.panel.matchingAvatars.slice(1).every(n=>n.zIndexn.scale({x:n.x,y:n.y,size:n.scale*140})); - assert.equal(new Set(boxes.map(b=>b.x+':'+b.y)).size,100,'each arrival has its own position'); - assert(Math.max(...boxes.map(b=>b.x+b.size/2))-Math.min(...boxes.map(b=>b.x-b.size/2))>350); - assert(boxes.every(b=>Math.abs(b.x)+b.size/2<284),'stack stays within the cloud width'); - assert.deepEqual(f.panel.matchingAvatars.slice(0,6),simulated); - assert.deepEqual(simulated.map(n=>({x:n.x,y:n.y,scale:n.scale,z:n.zIndex})),simulatedPoses, - 'switching to real profiles must not reposition simulated arrivals'); - for(let i=6;i<100;i++)assert.equal(bindings[i],stage.opponents[i-6]); - await f.advance();await task; - assert.deepEqual(f.panel.matchingAvatars.map(n=>n.player),bindings,'no replacement after more arrivals'); - assert(f.panel.matchingAvatars.every(slot=>slot.opacity===255),'all remain until matching leaves'); +test('matching waits for the API then batches 8 to 12 returned photos with defaults without replacement',async()=>{ + for(const [random,expected] of [[0,8],[.5,10],[.999,12]])for(const elapsed of [0,60]){ + const f=fixture(()=>random),p=f.panel,stage=f.stage(99); + stage.opponents.forEach((player,i)=>{ + player.useravatar='';player.useravatarIcon='0'; + if(i>=30&&i<50)player[i%2?'useravatarIcon':'useravatar']='https://avatar.example/'+i+'.png'; + }); + const roster=JSON.stringify(stage.opponents);p.resetMatchingAvatars();f.flushTweens(); + const self=p.matchingAvatars[0],bind=p.avatar,bindings=new Set([self]); + p.avatar=(slot,player)=>{assert(!bindings.has(slot),'each avatar is bound only once');bindings.add(slot);bind(slot,player);}; + p.mode='matching';p.matchPhase='submitting';p.update(elapsed); + assert.equal(p.matchingAvatars.length,1,'no placeholder opponents before the API response'); + assert.equal(p.matchingCount.string,'1/100','count waits for the API too'); + p.matchPhase='animating';const task=p.playMatching(stage); + assert.equal(p.matchingAvatars.length,15);assert.equal(p.matchingAvatars[0],self); + const starts=f.tweens.map(t=>t.actions.find(a=>'delay' in a)?.delay); + assert.deepEqual([...new Set(starts)],[.18,.4,.62],'slow API must not collapse arrival waves'); + const real=p.matchingAvatars.slice(1).filter(n=>/^https:/.test(n.player.useravatar)||/^https:/.test(n.player.useravatarIcon)); + assert.equal(real.length,expected);assert(real.every(n=>stage.opponents.includes(n.player))); + const defaults=p.matchingAvatars.slice(1).filter(n=>!real.includes(n)); + assert.equal(defaults.length,14-expected);assert(defaults.every(n=>/^(?:[0-9]|1[0-6])$/.test(n.player.useravatarIcon))); + const duration=Math.max(...f.tweens.map(t=>t.actions.reduce((sum,a)=>sum+(a.delay||a.duration||0),0))); + assert.equal(f.waits[0],duration,'counter finishes with the final avatar wave'); + assert.equal(p.matchingCount.string,'1/100'); + p.update(duration/2);assert.equal(p.matchingCount.string,'50/100'); + p.update(duration/2-.001);assert.equal(p.matchingCount.string,'99/100'); + await f.advance();await task;assert.equal(p.matchingCount.string,'100/100'); + p.update(60);assert.equal(p.matchingCount.string,'100/100'); + assert(p.matchingAvatars.every(n=>n.active&&n.opacity===255)); + const profiles=p.matchingAvatars.map(n=>n.player);const repeat=p.playMatching(stage);await f.advance();await repeat; + assert.deepEqual(p.matchingAvatars.map(n=>n.player),profiles);assert.equal(JSON.stringify(stage.opponents),roster); + } +}); + +test('matching uses available returned photos and fills shortages with defaults',()=>{ + for(const available of [0,5,8]){ + const f=fixture(()=>.999),stage=f.stage(99); + stage.opponents.forEach((p,i)=>{p.useravatar=i/^https:/.test(p.useravatar)).length,available); + assert(profiles.filter(p=>!/^https:/.test(p.useravatar)).every(p=>p.useravatarIcon==='16')); + } }); function floatingFixture() { @@ -109,7 +113,7 @@ function floatingFixture() { return {...f,page}; } -test('matching and all three starting clouds use identical 100-avatar geometry within the 1.2x bounds',()=>{ +test('matching and all three starting clouds use identical 15-avatar geometry within the 1.2x bounds',()=>{ const f=fixture(),p=f.panel;p.design={width:1080};p.resetMatchingAvatars(); for(let i=0;i<99;i++)p.appendMatchingAvatar({username:'player'+i});f.flushTweens(); const matched=p.matchingAvatars.map(n=>({x:n.x,y:n.y+760,scale:n.scale,z:n.zIndex})); @@ -122,7 +126,7 @@ test('matching and all three starting clouds use identical 100-avatar geometry w [p.progressStart.x,p.progressStart.y]=start._trs.array; const stage={...f.stage(99),stage:stageNumber}; p.prepareProgressRoster(stage,Array.from({length:99},(_,i)=>i)); - assert.equal(p.progressSlots.length,100); + assert.equal(p.progressSlots.length,15); p.progressSlots.forEach(({node:n},i)=>{ const m=matched[i];assert(Math.abs(n.x-p.progressStart.x-m.x)<0.001);assert(Math.abs(n.y-p.progressStart.y-m.y)<0.001); assert.equal(n.scale,m.scale);assert.equal(n.zIndex,m.z); @@ -139,12 +143,12 @@ test('matching and all three starting clouds use identical 100-avatar geometry w } }); -test('each matching slot is reserved once for 100 players and new arrivals never move settled avatars',()=>{ +test('each matching slot is reserved once for 15 representatives and new arrivals never move settled avatars',()=>{ const f=fixture(),p=f.panel; const calculate=p.matchingPositions.bind(p);let calculated=0; p.matchingPositions=()=>{calculated++;return calculate();}; - p.resetMatchingAvatars();f.flushTweens();assert.equal(p.matchingLayout.length,99); - for(let i=0;i<99;i++){ + p.resetMatchingAvatars();f.flushTweens();assert.equal(p.matchingLayout.length,14); + for(let i=0;i<14;i++){ const existing=p.matchingAvatars.map(n=>({x:n.x,y:n.y,scale:n.scale,z:n.zIndex})); p.appendMatchingAvatar({username:'player'+i}); assert(f.tweens.every(t=>t.target===p.matchingAvatars.at(-1)), 'only the arriving avatar may animate'); @@ -194,19 +198,12 @@ test('cloud floating continues during elimination and restores positions on page p.update(0.5);p.onDestroy();assert.equal(cloud.y,restY); }); -test('during elimination, landed avatars follow the next floating cloud and falling avatars detach',async()=>{ - const f=floatingFixture(),p=f.panel;p.mode='elimination'; - const before=f.stage(79,1),after=f.stage(49,2); - const task=p.playElimination({before,after}); - const from=p.tracks[0].children[0],to=p.tracks[0].children[1],self=p.progressSlots[0].node; - p.update(0.3);assert.equal(self.y,from.y+60); - await f.advance();assert.equal(p.restingAvatars.has(self),false,'jump is driven by its tween'); - p.update(0.3);await f.advance();p.update(0.3); - assert.equal(self.y,to.y+60,'landed self follows the destination while other players jump'); - await f.advance(); - const falling=p.progressSlots.find(s=>s.index>=49).node; - assert.equal(p.restingAvatars.has(falling),false); - await f.advance();await f.advance();assert.equal(await task,true); +test('during elimination, landed avatars follow the destination and falling avatars detach',async()=>{ + const f=floatingFixture(),p=f.panel;p.mode='elimination';const task=p.playElimination({before:f.stage(79,1),after:f.stage(49,2)}); + const from=p.tracks[0].children[0],to=p.tracks[0].children[1],self=p.progressSlots[0].node; + p.update(.3);assert.equal(self.y,from.y+60);await f.advance();assert.equal(p.restingAvatars.has(self),false); + const falling=p.progressSlots.find(s=>s.index>=49).node;assert.equal(p.restingAvatars.has(falling),false); + await f.advance();p.update(.3);assert.equal(self.y,to.y+60);await f.advance();assert.equal(await task,true); }); test('switching stage floats only the newly active Step nodes',()=>{ @@ -218,76 +215,54 @@ test('switching stage floats only the newly active Step nodes',()=>{ p.onDisable();assert.deepEqual(p.tracks[1].children.map(n=>n.y),next); }); -test('starting another match disposes the previous roster only at the start of that match',async()=>{ - const f=fixture();const task=f.panel.playMatching(f.stage(99));for(let i=0;i<101;i++)await f.advance();await task; - const clones=f.panel.matchingAvatars.slice(3);assert(clones.every(n=>!n.destroyed)); - f.panel.resetMatchingAvatars();assert(clones.every(n=>n.destroyed)); - assert.equal(f.panel.matchingAvatars.length,1);assert.equal(f.panel.matchingSlots[0].active,false); +test('starting another match reuses its bounded avatar pool',async()=>{ + const f=fixture(),p=f.panel;let task=p.playMatching(f.stage(99));await f.advance();await task; + const pool=p.matchingAvatars.slice();p.resetMatchingAvatars();assert.equal(p.matchingAvatars.length,1); + assert(pool.slice(1).every(n=>!n.active&&!n.destroyed)); + task=p.playMatching(f.stage(99));await f.advance();await task;assert.deepEqual(p.matchingAvatars,pool); }); test('destroying a matching page cancels the avatar tweens and stops later waves',async()=>{ const f=fixture();const task=f.panel.playMatching(f.stage(99)); f.panel.node.destroy();f.panel.onDestroy();await task; - assert(f.tweens.every(t=>t.stopped));assert.equal(f.panel.matchingCount.string,'2/100'); + assert(f.tweens.every(t=>t.stopped));assert.equal(f.panel.matchingCount.string,'1/100'); }); -test('simulated arrivals add five per two seconds and never finish without the server',()=>{ - const f=fixture(),p=f.panel;p.resetMatchingAvatars();p.addSimulatedMatch(); - p.mode='matching';p.matchPhase='submitting'; - assert.equal(p.matchingCount.string,'2/100'); - p.update(2.001);assert.equal(p.matchingCount.string,'7/100'); - p.update(100);assert.equal(p.matchingCount.string,'99/100');assert.equal(p.matchingAvatars.length,99); - p.update(100);assert.equal(p.matchingAvatars.length,99); - p.matchPhase='idle';p.update(10);assert.equal(p.matchingAvatars.length,99); +test('waiting shows only the player and holds the count until the roster arrives',()=>{ + const f=fixture(),p=f.panel;p.resetMatchingAvatars();p.mode='matching';p.matchPhase='submitting';f.flushTweens(); + const nodes=p.matchingAvatars.slice(); + for(const phase of ['preparing','submitting'])for(const dt of [.17,.22,1,60]){ + p.matchPhase=phase;p.update(dt);assert.equal(p.matchingAvatars.length,1);assert.equal(p.matchingCount.string,'1/100'); + } + assert.deepEqual(p.matchingAvatars,nodes); + assert.equal(f.tweens.length,0,'waiting does not start opponent animations or avatar loads'); + p.matchPhase='complete';p.matchingDisplayedCount=100;p.matchingCount.string='100/100';p.update(60);assert.equal(p.matchingCount.string,'100/100'); }); -test('success: self jumps first, then all survivors jump, then all eliminated players fall',async()=>{ - const f=fixture(),before=f.stage(79,1),after=f.stage(49,2); - const task=f.panel.playElimination({before,after}); - assert.equal(f.panel.progressSlots.length,80);assert.equal(f.panel.survivorCount.string,'80/100'); - assert(f.panel.progressSlots.every(s=>s.index<80),'earlier eliminated players are absent'); - const self=f.panel.progressSlots[0].node; - const [left,right]=f.panel.progressSlots.slice(1,3).map(s=>s.node); - assert(left.xself.x&&left.y>self.y&&left.y-self.y<=8&&right.y===left.y); - assert(f.panel.progressSlots.slice(1).every(s=>s.node.zIndext.target),[self]); - await f.advance();assert.equal(f.panel.progressCount.string,'2/5'); - assert(f.tweens.every(t=>f.panel.progressSlots.find(s=>s.node===t.target).index<49)); - assert(f.tweens.every(t=>!t.actions.some(a=>a.props?.opacity===0))); - const launches=f.tweens.map(t=>t.actions.find(a=>'delay' in a).delay); - assert(launches[2]{const target={};t.actions.find(a=>a.props).options.onUpdate(target,.5);return target.angle;}); - assert(tilts.some(n=>n<0)&&tilts.some(n=>n>0),'jump arcs lean in both directions'); - assert(Math.max(...tilts.map(Math.abs))>=14,'staggered jumps retain different tilts'); - await f.advance();assert(f.tweens.every(t=>t.actions.some(a=>a.props?.opacity===0))); - const drops=f.tweens.map(t=>t.actions.find(a=>a.props?.opacity===0).props); - assert(drops.some(p=>p.angle>0)&&drops.some(p=>p.angle<0),'eliminations tumble in both directions'); - const counts=[];let displayed=f.panel.survivorCount.string; - Object.defineProperty(f.panel.survivorCount,'string',{get:()=>displayed,set:value=>{displayed=value;counts.push(Number(value.split('/')[0]));}}); - f.tweens.reverse(); // Slower avatars may finish after those which launched later. - assert.equal(self.opacity,255); - await f.advance();assert.equal(f.panel.survivorCount.string,'50/100'); - assert(counts.every((n,i)=>!i||n<=counts[i-1]),'out-of-order drops never increase remaining players'); - assert(f.panel.progressSlots.filter(s=>s.index>=49).every(s=>!s.node.active)); - assert(f.panel.progressSlots.filter(s=>s.index<49).every(s=>s.node.opacity===255)); - assert(f.panel.progressSlots.filter(s=>s.index<49).every(s=>s.node.angle===0),'survivors straighten after landing'); - await f.advance();assert.equal(await task,true); +test('success overlaps jumps and drops while counting the full population',async()=>{ + const f=fixture(),p=f.panel,before=f.stage(79,1),after=f.stage(49,2),task=p.playElimination({before,after}); + assert.equal(p.progressSlots.length,15);const self=p.progressSlots[0].node;assert.equal(p.survivorCount.string,'80/100'); + assert.equal(p.progressSlots.filter(s=>s.index>=49).length,3);await f.advance(); + const selfTween=f.tweens.find(t=>t.target===self),jumpTime=selfTween.actions.filter(a=>a.duration).reduce((n,a)=>n+a.duration,0); + const others=f.tweens.filter(t=>t.target!==self&&t.target!==p.survivorCounterMotion); + assert(others.some(t=>t.actions[0].delayt.target===p.survivorCounterMotion).actions.find(a=>a.options); + counter.options.onUpdate({value:65.1});assert.equal(p.survivorCount.string,'65/100'); + f.tweens.reverse();await f.advance();assert.equal(p.survivorCount.string,'50/100'); + assert(p.progressSlots.every(s=>s.index<49));assert.equal(p.progressSlots.length,15); + await f.advance();assert.equal(await task,true);assert.equal(p.progressCount.string,'2/5'); + assert(f.waits.reduce((a,b)=>a+b,0)<=2);assert.equal(before.opponents.length,99);assert.equal(after.opponents.length,99); }); -test('failure: survivors jump first, self falls second, other eliminated players fall last',async()=>{ - const f=fixture(),before=f.stage(79,2),after=f.stage(39,2);after.status='lost';after.survivors=39;after.round=3; - const task=f.panel.playElimination({before,after}),self=f.panel.progressSlots[0].node; - await f.advance();assert(f.tweens.length>0);assert(f.tweens.every(t=>t.target!==self)); - assert(f.tweens.every(t=>!t.actions.some(a=>a.props?.opacity===0))); - await f.advance();assert.deepEqual(f.tweens.map(t=>t.target),[self]); - assert.equal(f.panel.survivorCount.string,'80/100'); - await f.advance();assert.equal(self.opacity,0);assert.equal(f.panel.survivorCount.string,'79/100'); - assert(f.tweens.every(t=>t.target!==self&&t.actions.some(a=>a.props?.opacity===0))); - await f.advance();assert.equal(f.panel.survivorCount.string,'39/100'); - assert.equal(f.panel.progressCount.string,'2/5','failure does not advance the player score'); - const destination=f.panel.cloudPosition(after,3); - assert(f.panel.progressSlots.filter(s=>s.index>=0&&s.index<39).every(s=>s.node.y>=destination.y)); - await f.advance();assert.equal(await task,true); +test('failure drops the player, preserves the score and retains only living representatives',async()=>{ + const f=fixture(),p=f.panel,before=f.stage(79,2),after={...f.stage(39,2),status:'lost',survivors:39,round:3}; + const task=p.playElimination({before,after}),self=p.progressSlots[0].node;await f.advance(); + assert(f.tweens.find(t=>t.target===self).actions.some(a=>a.props?.opacity===0)); + for(let i=0;i<3;i++)await f.advance();assert.equal(await task,true);assert.equal(self.active,false); + assert.equal(p.survivorCount.string,'39/100');assert.equal(p.progressCount.string,'2/5'); + assert.equal(p.progressSlots.length,15);assert(p.progressSlots.every(s=>s.index>=0&&s.index<39)); + const destination=p.cloudForScore(after,3);assert(p.progressSlots.every(s=>p.restingAvatars.get(s.node).cloud===destination)); + assert(f.waits.reduce((a,b)=>a+b,0)<=2); }); test('final win with no eliminations still moves the stack to the reward cloud',async()=>{ @@ -303,10 +278,9 @@ test('each stage final round sends survivors to the chest cloud on both win and const f=fixture(),p=f.panel,prize=p.pages[2].getChildByName('PrizeCloud'); const before={...f.stage(20,target-1),stage,target,status:'playing'}; const after={...f.stage(10,won?target:target-1),stage,target,round:target,status:won?'won':'lost',survivors:won?11:10}; - const task=p.playElimination({before,after}); + const task=p.playElimination({before,after}),self=p.progressSlots[0].node; for(let i=0;i<10;i++)await f.advance(); assert.equal(await task,true); - const self=p.progressSlots[0].node; assert.equal(self.active,won); const landed=p.progressSlots.filter(s=>s.node.active); assert(landed.length>0); @@ -365,13 +339,11 @@ test('every stage locks close from the first frame through final-win, loss and e } }); -test('failure with no surviving opponents drops self first and finishes at zero',async()=>{ - const f=fixture(),before=f.stage(2,0),after=f.stage(0,0);after.status='lost';after.survivors=0; - const task=f.panel.playElimination({before,after}),self=f.panel.progressSlots[0].node; - await f.advance();assert.deepEqual(f.tweens.map(t=>t.target),[self]); - await f.advance();assert.equal(f.panel.survivorCount.string,'2/100'); - await f.advance();assert.equal(f.panel.survivorCount.string,'0/100'); - await f.advance();assert.equal(await task,true);assert(f.panel.progressSlots.every(s=>!s.node.active)); +test('failure without survivors drops self and finishes at zero within two seconds',async()=>{ + const f=fixture(),p=f.panel,after={...f.stage(0,0),status:'lost',survivors:0}; + const task=p.playElimination({before:f.stage(2,0),after}),self=p.progressSlots[0].node; + for(let i=0;i<4;i++)await f.advance();assert.equal(await task,true);assert.equal(p.survivorCount.string,'0/100'); + assert.equal(self.active,false);assert.equal(p.progressSlots.length,0);assert(f.waits.reduce((a,b)=>a+b,0)<=2); }); test('all stage timeouts move survivors to the next cloud and drop self plus eliminated opponents',async()=>{ @@ -385,7 +357,7 @@ test('all stage timeouts move survivors to the next cloud and drop self plus eli const cloud=f.panel.cloudForScore(after,score+1); const survivors=f.panel.progressSlots.filter(s=>s.index>=0&&s.index<49); const eliminated=f.panel.progressSlots.filter(s=>s.index>=49); - assert(survivors.length>0&&eliminated.length>0); + assert(survivors.length>0&&eliminated.length===0); assert(survivors.every(s=>s.node.opacity===255));assert(eliminated.every(s=>s.node.opacity===0)); assert(survivors.every(s=>f.panel.restingAvatars.get(s.node)?.cloud===cloud)); } @@ -416,7 +388,7 @@ test('loss and expiry show failure text immediately while avatar motion still co for(let i=0;i<8;i++)await f.advance(); assert.equal(await task,true);assert.equal(p.failureMessage.active,failed); assert.equal(p.keepEliminationRoster,failed); - if(failed)assert.equal(p.progressSlots[0].node.active,false,'the player still falls off the cloud'); + if(failed)assert(p.progressSlots.every(s=>s.index>=0)); } }); @@ -736,3 +708,30 @@ test('promotion follows one smooth curve bounded just above its destination and assert(speeds[0]<1&&speeds.at(-1)<1,'soft takeoff and landing'); } }); + + +test('sparse rosters show all survivors, never resurrect opponents, and finish within two seconds',async()=>{ + for(const number of [1,2,3])for(const remaining of [0,1,8,14,15,48,99])for(const won of [true,false]){ + const f=fixture(),p=f.panel,before={...f.stage(99,1),stage:number}; + const after={...f.stage(99,won?2:1),stage:number,round:2,status:won?'playing':'lost',survivors:remaining+(won?1:0)}; + after.opponents.forEach((o,i)=>o.alive=i>=99-remaining); + const task=p.playElimination({before,after});assert(p.progressSlots.length<=15); + for(let i=0;i<4;i++){await f.advance();assert(p.progressSlots.filter(s=>s.node.active).length<=15);} + assert.equal(await task,true);assert.equal(p.survivorCount.string,after.survivors+'/100'); + assert.equal(p.progressSlots.length,Math.min(15,after.survivors)); + assert(p.progressSlots.every(s=>s.index===-1?won:after.opponents[s.index].alive)); + assert(f.waits.reduce((a,b)=>a+b,0)<=2); + if(after.survivors<=15)assert.deepEqual(Array.from(p.progressSlots.filter(s=>s.index>=0),s=>s.index),after.opponents.flatMap((o,i)=>o.alive?[i]:[])); + } +}); + +test('progress reuses survivor nodes and keeps the pool bounded across changing samples',()=>{ + const f=fixture(),p=f.panel,stage=f.stage(99);let previous=new Map(); + for(let first=0;first<85;first++){ + p.prepareProgressRoster(stage,Array.from({length:99-first},(_,i)=>i+first)); + for(const slot of p.progressSlots)if(previous.has(slot.index))assert.equal(slot.node,previous.get(slot.index)); + previous=new Map(p.progressSlots.map(s=>[s.index,s.node])); + assert.equal(p.progressAvatarPool.length,15);assert.equal(p.progressSlots.length,15); + } + p.clearProgressRoster();assert.equal(p.progressAvatarPool.length,0);assert.equal(p.progressSlots.length,0); +}); diff --git a/tools/test-cloud-rise-bundles.cjs b/tools/test-cloud-rise-bundles.cjs index 968af4d9..dd6624ff 100644 --- a/tools/test-cloud-rise-bundles.cjs +++ b/tools/test-cloud-rise-bundles.cjs @@ -109,11 +109,11 @@ function panelFixture(){ const exports={};vm.runInNewContext(ts.transpileModule(fs.readFileSync('assets/cloud_rise_home/scripts/CloudRisePanel.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2018,experimentalDecorators:true}}).outputText, {exports,cc,require:n=>({MiniGameSdk:{API:{showToast:msg=>notices.push(msg)}},default:n.endsWith('CloudRiseRuntime')?service:n.endsWith('CloudRiseService')?bridge:class{}})}); const panel=new exports.default();panel.openPopup=()=>{};panel.node=new Node();panel.design=new Node();panel.errorLabel={node:{}}; + cc.fx={AudioManager:{_instance:{playEffect(){}}}}; panel.buttons=[new Node()];panel.bindButtons();panel.mode='overview'; panel.show=mode=>{panel.mode=mode;panel.buttons[0].active=mode!=='matching'&&(!panel.busy||panel.matchPhase==='preparing');}; panel.matchingCount={string:''};panel.matchingPrompt={active:false};panel.matchingTitle={node:{active:false},string:''};panel.matchingReward={setValue(){}}; - panel.waitAnimation=async()=>true;panel.waitForContinue=async()=>true;panel.resetMatchingAvatars=()=>{};panel.playMatching=async()=>{panel.matchingCount.string='100/100';}; - panel.addSimulatedMatch=()=>{if(panel.matchingDisplayedCount<99)panel.matchingCount.string=(++panel.matchingDisplayedCount)+'/100';}; + panel.waitAnimation=async()=>true;panel.waitForContinue=async()=>true;panel.resetMatchingAvatars=()=>{panel.matchingAvatars=[];panel.matchingLayout=Array.from({length:14},()=>({y:0}));};panel.playMatching=async()=>{panel.matchingCount.string='100/100';}; return {panel,pending,attached,cancellations,notices,service,cc,starts:()=>starts,commits:()=>commits,holdConfirmation:()=>new Promise(resolve=>{confirm=()=>new Promise(done=>resolve(done));})}; } test('closing while a page downloads does not attach stale nodes or throw during promise cleanup',async()=>{ @@ -151,9 +151,9 @@ test('failed hidden page preparation leaves enrollment untouched and manual star test('matching is visible while the next stage loads; confirmation starts only after stage readiness',async()=>{ const f=panelFixture(),p=f.panel,confirmation=f.holdConfirmation(),task=p.match(); f.pending[0].resolve({});await settle(); - assert.equal(p.mode,'matching');assert.equal(p.matchingCount.string,'2/100');assert.equal(p.buttons[0].active,false);assert.equal(f.starts(),0); + assert.equal(p.mode,'matching');assert.equal(p.matchingCount.string,'1/100');assert.equal(p.buttons[0].active,false);assert.equal(f.starts(),0); f.pending[1].resolve({});const confirm=await confirmation; - assert.equal(p.matchPhase,'submitting');assert.equal(p.buttons[0].active,false);assert.equal(p.matchingCount.string,'2/100'); + assert.equal(p.matchPhase,'submitting');assert.equal(p.buttons[0].active,false);assert.equal(p.matchingCount.string,'1/100'); assert.equal(f.commits(),0); assert.equal(await p.match(),false);assert.equal(f.starts(),1); confirm(true);assert.equal(await task,true);assert.equal(p.mode,'progress');assert.equal(p.matchingCount.string,'100/100'); @@ -188,7 +188,7 @@ test('destroying the panel during preparation never submits enrollment',async()= test('failed server confirmation never reaches 100 and leaves the button retryable',async()=>{ const f=panelFixture(),p=f.panel,confirmation=f.holdConfirmation(),task=p.match(); f.pending[0].resolve({});f.pending[1].resolve({});const confirm=await confirmation;confirm(false); - assert.equal(await task,false);assert.equal(p.mode,'overview');assert.equal(p.busy,false);assert.equal(p.matchingCount.string,'2/100'); + assert.equal(await task,false);assert.equal(p.mode,'overview');assert.equal(p.busy,false);assert.equal(p.matchingCount.string,'1/100'); }); test('shared art loads before pages, coalesces across stages and retries without loading pages after failure',async()=>{ @@ -262,12 +262,13 @@ test('cancel during avatar animation stops that match; 100/100 instead waits for }); -test('matching simulates immediately while resources or API wait and a rejected match shows a toast',async()=>{ +test('matching count waits for resources and the API and a rejected match shows a toast',async()=>{ const f=panelFixture(),p=f.panel,reply=f.holdConfirmation(),task=p.match(); - f.pending[0].resolve({});await settle();assert.equal(p.matchingCount.string,'2/100'); - p.update(2.001);assert.equal(p.matchingCount.string,'7/100'); - p.update(60);assert.equal(p.matchingCount.string,'99/100');assert.equal(f.starts(),0); - f.pending[1].resolve({});const resolve=await reply;resolve(false);assert.equal(await task,false); + f.pending[0].resolve({});await settle();assert.equal(p.matchingCount.string,'1/100'); + p.update(2.001);assert.equal(p.matchingCount.string,'1/100'); + p.update(60);assert.equal(p.matchingCount.string,'1/100');assert.equal(f.starts(),0); + f.pending[1].resolve({});const resolve=await reply; + p.update(60);assert.equal(p.matchingCount.string,'1/100');resolve(false);assert.equal(await task,false); assert.deepEqual(f.notices,['匹配失败请重试']);assert.equal(p.mode,'overview');assert.equal(f.commits(),0); }); @@ -283,14 +284,16 @@ test('overview layers the shared chest below its sparkles without depending on r assert(!fs.existsSync('assets/cloud_rise/art/images/chest.png'),'keep only one chest texture'); }); -test('matching waits for confirmation before displaying 100 and reports confirmation failure',async()=>{ +test('matching displays 100 after animation but waits for confirmation before continuing and reports failure',async()=>{ for(const success of [false,true]){ const f=panelFixture(),p=f.panel;let resolve;f.service.confirmMatch=()=>new Promise(done=>resolve=done); - p.playMatching=async()=>{p.matchingCount.string='99/100';}; + let continued=0;p.waitForContinue=async()=>{continued++;return true;}; + p.playMatching=async()=>{p.matchingDisplayedCount=100;p.matchingCount.string='100/100';}; const task=p.match();f.pending[0].resolve({});f.pending[1].resolve({});await settle(); - assert.equal(p.matchingCount.string,'99/100');assert(resolve); + assert.equal(p.matchingCount.string,'100/100');assert(resolve);assert.equal(continued,0); + p.update(60);assert.equal(p.matchingCount.string,'100/100'); resolve(success);await task; - assert.equal(p.matchingCount.string,success?'100/100':'99/100'); + assert.equal(p.matchingCount.string,'100/100');assert.equal(continued,success?1:0); assert.deepEqual(f.notices,success?[]:['匹配失败请重试']); } }); diff --git a/tools/test-cloud-rise.cjs b/tools/test-cloud-rise.cjs index edf154f1..a71a6f34 100644 --- a/tools/test-cloud-rise.cjs +++ b/tools/test-cloud-rise.cjs @@ -11,11 +11,12 @@ function fixture() { const storage = new Map(), calls = [], callbacks = [], networkFailures = {}, retryDelays = []; const info = { uid: 'u', userId: 1001, coin: 50, level: 99, otherLevel: 0 }; const cc = { warn() {}, fx: { GameConfig: { GM_INFO: info }, GameTool: { - coinEvents: [], recordActivityCoin(amount) { this.coinEvents.push(amount); } + coinEvents: [], recordActivityCoin(amount) { this.coinEvents.push(amount); }, shushu_Track() {} }, StorageMessage: { getStorage: key => clone(storage.get(key)), setStorage: (key, value) => storage.set(key, clone(value)), } } }; - let offline = false, holdStatus = false, rewardMode = '', matchMode = ''; const rewardCallbacks = [], matchCallbacks = [], cancelled = new Set(); + let offline = false, holdStatus = false, rewardMode = '', matchMode = '', dropFinishReply = false; + const rewardCallbacks = [], matchCallbacks = [], cancelled = new Set(); const data = { serverNow: Date.now(), available: true, period: { periodId: 'p1', enabled: true, startsAt: Date.now()-1000, endsAt: Date.now()+86400000 }, run: null, coinAmount: 50 }; function newStage(stage, levelAmount) { return { stage, status: 'playing', target: [5, 7, 9][stage - 1], start_level: levelAmount + 1, @@ -55,18 +56,27 @@ function fixture() { data.run = { id: 'r1', periodId: body.periodId, stage: 1, status: 'playing', expiresAt: Date.now() + 86400000, stages: [newStage(1, body.levelAmount)] }; } else if (body.action === 'start_stage' && run.status === 'waiting') { run.stage++; run.status = 'playing'; run.stages.push(newStage(run.stage, body.levelAmount)); - } else if (body.action === 'begin' && run.status === 'playing') stage.attemptId = body.attemptId; - else if (body.action === 'finish' && !stage.results.includes(body.attemptId)) { - if (stage.attemptId !== body.attemptId) code = 0; + } else if (body.action === 'begin' || body.action === 'finish') { + const requested = run?.stages.find(s => s.stage === body.stage); + if (!run || run.id !== body.runId || !requested) code = 0; + else if (requested.results.some(r => r.id === body.attemptId) || run.status === 'expired' + || run.status === 'failed' && requested.reason === 'interrupted') {} + else if (run.status !== 'playing' || run.stage !== body.stage) code = 0; + else if (body.action === 'begin') { + if (stage.attemptId && stage.attemptId !== body.attemptId) { + done({code:0, msg:'上一关尚未结算', errorCode:'ATTEMPT_PENDING'}); return; + } + stage.attemptId = body.attemptId; + } else if (stage.attemptId !== body.attemptId) code = 0; else { - stage.results.push(body.attemptId); stage.attemptId = null; + stage.results.push({id:body.attemptId, outcome:body.outcome}); stage.attemptId = null; if (body.outcome === 'win') { stage.success_num++; if (stage.success_num === stage.target) { stage.status = 'won'; run.status = run.stage === 3 ? 'completed' : 'waiting'; } - } else { stage.status = 'lost'; run.status = 'failed'; } + } else { stage.status = 'lost'; stage.reason = body.outcome; run.status = 'failed'; } } } if (body.action === 'save_reward' && rewardMode === 'fail') { rewardMode = ''; done({ code: 0, msg: 'save failed' }); return; } @@ -77,6 +87,7 @@ function fixture() { } } const response = { code, data: clone(data) }; + if (body.action === 'finish' && dropFinishReply) { dropFinishReply = false; done({code:0,msg:'response lost'}); return; } if(body.action==='prepare_match'&&matchMode==='hold'){matchMode='';matchCallbacks.push(()=>done(response));return;} if(body.action==='confirm_match'&&matchMode==='lost'){matchMode='';done({code:0,msg:'response lost'});return;} if (body.action === 'save_reward' && rewardMode === 'lost') { rewardMode = ''; done({ code: 0, msg: 'response lost' }); return; } @@ -109,7 +120,7 @@ function fixture() { return bridge; } return { storage, calls, callbacks, info, data, boot, bootBridge, cc, rewardCallbacks, matchCallbacks, networkFailures, retryDelays, matchMode(value){matchMode=value;}, rewardMode(value) { rewardMode = value; }, - offline(value) { offline = value; }, holdStatus() { holdStatus = true; } }; + offline(value) { offline = value; }, holdStatus() { holdStatus = true; }, dropFinishReply() { dropFinishReply = true; } }; } async function enrolled(f) { const service = f.boot(); assert.equal(await service.startStage(), true); return service; } async function win(service) { assert.equal(service.prepareRound(() => {}), true); service.finish(true); await service.sync(); } @@ -121,7 +132,8 @@ test('POST marks transport failures for matching retries and invokes the callbac const methods=cls.members.filter(n=>n.name&&['POST','apiBaseUrl'].includes(n.name.getText(ast))).map(n=>n.getText(ast)).join('\n'); const exports={},xhr={open(){},setRequestHeader(){},send(){}}; const cc={loader:{getXMLHttpRequest:()=>xhr},fx:{GameConfig:{GM_INFO:{}},GameTool:{getWechatGameVersion:()=> '开发版'}}}; - vm.runInNewContext(ts.transpileModule('export default class Utils {'+methods+'}',{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,{exports,cc}); + vm.runInNewContext(ts.transpileModule('export default class Utils {'+methods+'}',{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,{exports,cc,ServerEnvironment:{current:()=> 'test'}}); + exports.default.trackWucaiRequest=(_url,_body,done)=>done; exports.default.testHttpip='https://test.invalid/';const responses=[]; exports.default.POST('cloudRise/index',{},r=>responses.push(r)); xhr.ontimeout();xhr.onerror();xhr.readyState=4;xhr.status=0;xhr.onreadystatechange(); @@ -275,6 +287,105 @@ test('unplayed home restart has no interruption; other modes do not create an at assert.equal(service.run().status, 'playing'); }); +test('cold restart recovers a server attempt after the local activity cache is deleted', async () => { + const f = fixture(), original = await enrolled(f); + original.prepareRound(); await original.sync(); + const id = f.data.run.stages[0].attemptId; + f.storage.delete('cloudRise:1001'); + const restored = f.boot(); + assert.equal(await restored.sync(), true); + assert.equal(restored.run().status, 'failed'); + assert.equal(restored.stage().reason, 'interrupted'); + assert.deepEqual(f.data.run.stages[0].results, [{id, outcome:'interrupted'}]); + restored.prepareRound(); await restored.sync(); + assert.equal(f.calls.filter(c => c.action === 'begin').length, 1); + assert.equal(f.calls.filter(c => c.action === 'finish').length, 1); + assert.equal(restored.pending(), false); +}); + +test('cold recovery drains a legacy conflicting begin without crediting its later win', async () => { + const f = fixture(), original = await enrolled(f); + original.prepareRound(); await original.sync(); + const stage = f.data.run.stages[0], id = stage.attemptId; + const next = {runId:f.data.run.id, stage:1, attemptId:'legacy-B', outcome:'win'}; + f.storage.set('cloudRise:1001', {snapshot:clone(f.data), attempt:next, seenResults:[], + queue:[{action:'begin', ...next}, {action:'finish', ...next}]}); + const restored = f.boot(); + assert.equal(await restored.sync(), true); + assert.equal(restored.run().status, 'failed'); + assert.equal(stage.success_num, 0); + assert.deepEqual(stage.results, [{id, outcome:'interrupted'}]); + assert.equal(restored.pending(), false); +}); + +test('a cached orphan blocks new activity attempts before startup sync and recovery retries durably', async () => { + const f = fixture(), original = await enrolled(f); + original.prepareRound(); await original.sync(); + const saved = f.storage.get('cloudRise:1001'), id = saved.attempt.attemptId; + saved.attempt = null; f.storage.set('cloudRise:1001', saved); + f.networkFailures.finish = 1; + const restored = f.boot(); + restored.prepareRound(); assert.equal(await restored.sync(), false); + assert.equal(f.calls.filter(c => c.action === 'begin').length, 1); + assert.deepEqual(f.storage.get('cloudRise:1001').queue, [ + {action:'finish', runId:f.data.run.id, stage:1, attemptId:id, outcome:'interrupted'} + ]); + assert.equal(await restored.sync(), true); + assert.equal(restored.pending(), false); + assert.deepEqual(f.data.run.stages[0].results, [{id, outcome:'interrupted'}]); +}); + +test('conflict recovery uploads an existing victory ahead of a blocked begin', async () => { + const f = fixture(), service = await enrolled(f); + service.prepareRound(); await service.sync(); + const first = clone(f.storage.get('cloudRise:1001').attempt); + f.offline(true); service.finish(true); await service.sync(); + service.prepareRound(); service.finish(true); await service.sync(); + // Simulate an old outbox with B ahead of the still-durable result for A. + const queue = service.journal.queue; + queue.unshift(queue.splice(queue.findIndex(x => x.action === 'begin'), 1)[0]); + f.offline(false); + assert.equal(await service.sync(), true); + assert.equal(service.run().status, 'playing'); + assert.equal(service.stage().success_num, 2); + assert.deepEqual(f.data.run.stages[0].results.map(r => r.outcome), ['win','win']); + assert.equal(f.calls.some(c => c.outcome === 'interrupted'), false); + assert.equal(f.data.run.stages[0].results[0].id, first.attemptId); + assert.equal(service.pending(), false); +}); + +test('a lost victory response is retried after reboot without turning it into an interruption', async () => { + const f = fixture(), service = await enrolled(f); + service.prepareRound(); await service.sync(); f.dropFinishReply(); + service.finish(true); assert.equal(await service.sync(), false); + const restored = f.boot(); + assert.equal(await restored.sync(), true); + assert.equal(restored.stage().success_num, 1); + assert.equal(restored.stage().results.length, 1); + assert.equal(f.calls.some(c => c.outcome === 'interrupted'), false); + assert.equal(restored.pending(), false); +}); + +test('status polling preserves a live local attempt and never creates another attempt', async () => { + const f = fixture(), service = await enrolled(f); + service.prepareRound(); await service.sync(); + const id = service.stage().attemptId; + assert.equal(await service.sync(), true); service.prepareRound(); + assert.equal(service.stage().attemptId, id); + assert.equal(f.calls.filter(c => c.action === 'begin').length, 1); + assert.equal(f.calls.some(c => c.action === 'finish'), false); +}); + +test('a foreign attempt discovered during live play is not overwritten or automatically failed', async () => { + const f = fixture(), service = await enrolled(f); + f.data.run.stages[0].attemptId = 'other-device-A'; + assert.equal(await service.sync(), false); + service.prepareRound(); await service.sync(); + assert.equal(service.run().status, 'playing'); + assert.equal(service.stage().attemptId, 'other-device-A'); + assert.equal(f.calls.some(c => c.action === 'begin' || c.action === 'finish'), false); +}); + test('a result queued during final status is flushed before sync resolves', async () => { const f = fixture(), service = await enrolled(f); service.prepareRound(() => {}); await service.sync(); diff --git a/tools/test-starter-pack.cjs b/tools/test-starter-pack.cjs index 62e440b4..9f54f91c 100644 --- a/tools/test-starter-pack.cjs +++ b/tools/test-starter-pack.cjs @@ -45,7 +45,7 @@ function fixture() { _decorator: { ccclass: x => x, property: () => () => {} }, isValid: value => !!value && !value.destroyed, fx: { GameConfig: { GM_INFO: info, isGameplayEntryPreparing: () => false }, - StorageMessage: { getStorage: k => storage.get(k), setStorage: (k,v) => storage.set(k,v) }, + StorageMessage: { getStorage: k => storage.get(k), setStorage: (k,v) => storage.set(k,v), removeStorage: k => storage.delete(k) }, GameTool: { shushu_Track() {}, getWechatGameVersion: () => '体验版', tryReactivateStarterPack() {}, recordStarterPackShown() {} } }, find: (name, node) => node ? name.split('/').reduce((n, part) => n?.children.find(c => c.name === part), node) : null, @@ -581,7 +581,7 @@ test('four failed challenges on one level trigger once per attempt; revive failu const tool=gameToolMethods(f,['recordStarterPackLevelResult']); tool.tryReactivateStarterPack=reason=>requests.push(reason); f.cc.fx.GameTool.recordStarterPackLevelResult=tool.recordStarterPackLevelResult.bind(tool); - const Map=methods('assets/Script/Map.ts',['failLevel'],f.globals); + const Map=methods('assets/Script/Map.ts',['failLevel'],{...f.globals,DailyTaskProgressTracker:{resetWinStreak(){}}}); for(let i=0;i<4;i++) { const map=new Map(); Object.assign(map,{blocks:[1],blockNum:1,pause:false, isMapRuntimeAlive:()=>true,canCompleteLevel:()=>false,stopBoom(){},stopTimeCutDown(){},openLosePanel(){}}); @@ -589,11 +589,46 @@ test('four failed challenges on one level trigger once per attempt; revive failu map.gameOver=false; map.failLevel('time'); f.timers.shift()(); } assert.deepEqual(requests,['four_failures']); assert.equal(f.storage.get(f.rules.starterPackFailureKey()),4); - tool.recordStarterPackLevelResult(true); assert.equal(f.storage.get(f.rules.starterPackFailureKey()),0); + tool.recordStarterPackLevelResult(true); assert.equal(f.storage.has(f.rules.starterPackFailureKey()),false); tool.recordStarterPackLevelResult(false); assert.equal(f.storage.get(f.rules.starterPackFailureKey()),1); f.info.level++; assert.equal(f.rules.starterPackRecordResult(false),1); f.info.uid='another'; assert.equal(f.rules.starterPackRecordResult(false),1); }); +test('winning removes only the current account and level failure record in the active server namespace', () => { + for (const [version, environment] of [['develop','test'],['release','production']]) { + const f=fixture(), raw=new Map(); + f.info.uid='6a96b56b22ce438c510643d5'; f.info.level=404; f.info.otherLevel=0; + f.cc.sys.localStorage={getItem:key=>raw.get(key)??null,setItem:(key,value)=>raw.set(key,value),removeItem:key=>raw.delete(key)}; + const globals={...f.globals,wx:{getAccountInfoSync:()=>({miniProgram:{envVersion:version}})}}; + const env=moduleFrom('assets/Script/module/Config/ServerEnvironment.ts',globals); + f.cc.fx.StorageMessage=moduleFrom('assets/Script/module/Storage/Storage.ts',globals,{'../Config/ServerEnvironment':env}).StorageMessage; + const key=f.rules.starterPackFailureKey(),prefix='matchmaster:server:v1:'+environment+':'; + const untouched=[ + 'matchmaster:server:v1:'+(environment==='test'?'production':'test')+':'+key, + prefix+'StarterPackFailures_another_404',prefix+'StarterPackFailures_'+f.info.uid+'_405', + prefix+'StarterPackLastShown_'+f.info.uid, + ]; + raw.set(prefix+key,'4');untouched.forEach(k=>raw.set(k,'3')); + const tool=gameToolMethods(f,['recordStarterPackLevelResult']); + tool.recordStarterPackLevelResult(true); + assert.equal(raw.has(prefix+key),false,'the key itself is deleted, not retained with zero'); + assert.equal(f.cc.fx.StorageMessage.getStorage(key),null); + untouched.forEach(k=>assert.equal(raw.get(k),'3')); + tool.recordStarterPackLevelResult(true);assert.equal(raw.has(prefix+key),false,'repeated win does not recreate an empty key'); + f.info.level++;assert.equal(f.cc.fx.StorageMessage.getStorage(f.rules.starterPackFailureKey()),3,'next-level progress was untouched'); + } +}); + +test('helping another player does not clear main-level failures and a clean win creates no cache', () => { + const f=fixture(),tool=gameToolMethods(f,['recordStarterPackLevelResult']),key=f.rules.starterPackFailureKey(); + f.info.otherLevel=0;tool.recordStarterPackLevelResult(true);assert.equal(f.storage.has(key),false); + f.storage.set(key,4);f.info.otherLevel=88;tool.recordStarterPackLevelResult(true);assert.equal(f.storage.get(key),4); + f.info.otherLevel=0;tool.recordStarterPackLevelResult(true);assert.equal(f.storage.has(key),false); + assert.equal(f.rules.starterPackRecordResult(false),1); + for(let i=0;i<10;i++)f.rules.starterPackRecordResult(false); + assert.equal(f.storage.get(key),4,'failure counting retains its existing cap'); +}); + test('coin trigger waits for successful server coin save', () => { const f=fixture(); f.globals.wx={}; f.info.coin=499; const tool=gameToolMethods(f,['setUserCoin']); const reasons=[]; let complete;