MatchMaster/assets/Script/BattlePassSettlement.ts
2026-10-10 11:54:48 +08:00

275 lines
14 KiB
TypeScript

import Utils from './module/Pay/Utils';
import Art from './BattlePassArt';
import NumberToImage from './NumberToImage';
import { settlementRewards, settlementSteps } from './module/Config/BattlePassSettlementModel';
import { MiniGameSdk } from './Sdk/MiniGameSdk';
import BattlePassSettlementCheck from './module/Config/BattlePassSettlementCheck';
const { ccclass } = cc._decorator;
@ccclass
export default class BattlePassSettlement extends cc.Component {
private root: cc.Node;
private panel: cc.Node;
private confirm: cc.Node;
private rows: any[] = [];
private row: any;
private latest = 0;
private serverTime = 0;
private mode = '';
private busy = false;
private offerToken = '';
private finished: () => void;
private iosOrder = '';
private recoveredRewards: any[] = [];
private check: BattlePassSettlementCheck;
private readVersion = 0;
private checkedThrough = 0;
private retryDelay = 5;
private back = (e: cc.Event.EventKeyboard) => {
if (e.keyCode === cc.macro.KEY.escape || e.keyCode === 6) this.close();
};
private resume = () => { if (this.iosOrder) this.checkIos(); };
async run(done: () => void) {
this.finished = done;
try {
// Resolve paid entitlements before selecting frozen rewards. Other login
// orders remain in the home queue until this entire sequence is closed.
const gm = cc.fx.GameConfig.GM_INFO;
this.check = BattlePassSettlementCheck.forPlayer(gm);
// Usually observed by the existing homepage read. Also cover failed
// or locked entry reads so the login cache never lacks a deadline.
if (!this.check.hasSchedule()) {
const period: any = await new Promise((resolve, reject) => Utils.POSTJSON('passCheck', {
uid: gm.uid || Utils.uid, action: 'read'
}, res => res.code === 1 ? resolve(res) : reject(res)));
this.check.observeStart(Number(period.data.time));
if (!this.check.hasSchedule()) throw { msg: '战令周期时间不可用' };
}
if (!this.isCurrentRun()) return;
for (const order of (gm.allOutTradeNo || []).slice()) {
if (!/^battlepass(?:_30|_12)?$/.test(order.itemid)) continue;
if (this.check.reviewOrders.has(order.outTradeNo)) continue;
try {
const paid = await Utils.completePassOrder(order.outTradeNo);
if (!this.isCurrentRun()) return;
if (!paid.data.alreadyGranted) {
if (paid.data.postSeason) this.recoveredRewards.push(...(paid.data.rewards || []));
else if (paid.data.bonus) this.recoveredRewards.push({ type: 'coin', count: 3000 }, { type: 'pass_key', count: 20 });
}
gm.allOutTradeNo = gm.allOutTradeNo.filter(o => o.outTradeNo !== order.outTradeNo);
} catch (e) {
if (e.code !== 410) throw e;
// Paid duplicates/obsolete orders remain available for manual review.
this.check.reviewOrders.add(order.outTradeNo);
cc.warn('战令订单需要人工处理', order.outTradeNo, e.msg);
}
}
this.readVersion = this.check.version;
const readStarted = Date.now();
const result = await Utils.settlementRequest('read');
if (!this.isCurrentRun()) return;
this.serverTime = result.data.serverTime;
this.check.syncClock(this.serverTime);
// A request spanning the cutoff may contain a pre-cutoff snapshot.
this.checkedThrough = this.serverTime - Math.max(0, Date.now() - readStarted);
this.latest = Math.max(0, ...result.data.rows.map(r => r.end));
this.rows = settlementSteps(result.data.rows, this.latest, this.serverTime);
if (!this.rows.length && !this.recoveredRewards.length) { this.finish(); return; }
await Art.load();
if (!this.isCurrentRun()) return;
this.root = cc.instantiate(Art.settlementPrefab); this.root.active = false; this.root.parent = this.node; this.root.zIndex = 1000;
this.panel = this.root.getChildByName('panel');
const fit = Math.min(this.node.width / this.root.width, this.node.height / this.root.height);
this.panel.scaleX *= fit; this.panel.scaleY *= fit;
for (const mode of ['offer', 'empty', 'claim']) this.bindButton(mode + '/closeButton', () => this.close());
this.bindButton('claim/claimButton', () => this.claim());
this.bindButton('offer/buyButton', () => this.purchase());
this.bindButton('confirmation/thinkAgainButton', () => this.hideConfirmation());
this.bindButton('confirmation/abandonButton', () => this.decline());
cc.systemEvent.on(cc.SystemEvent.EventType.KEY_DOWN, this.back, this);
// @ts-ignore
if (typeof wx !== 'undefined') wx.onShow(this.resume);
this.next();
} catch (e) {
if (!this.isCurrentRun()) return;
this.error(e);
if (this.root && cc.isValid(this.root)) this.root.destroy();
cc.systemEvent.off(cc.SystemEvent.EventType.KEY_DOWN, this.back, this);
// @ts-ignore
if (typeof wx !== 'undefined') wx.offShow(this.resume);
// Keep the queue reserved until the settlement check can complete.
this.scheduleOnce(() => this.run(done), this.retryDelay);
this.retryDelay = Math.min(this.retryDelay * 2, 60);
}
}
private isCurrentRun(): boolean {
return cc.isValid(this, true) && cc.isValid(this.node, true) &&
this.check === BattlePassSettlementCheck.forPlayer(cc.fx.GameConfig.GM_INFO);
}
private finish() {
// Cache only after every popup and reward animation has completed. A
// destroyed scene or failed request must leave the login check pending.
if (!this.isCurrentRun()) return;
this.check.complete(this.checkedThrough, this.readVersion);
if (this.root && cc.isValid(this.root)) this.root.destroy();
this.destroy();
this.finished();
}
private error(e: any) {
cc.log(e.msg || e.message || '网络异常,请重试');
// MiniGameSdk.API.showToast(e.msg || e.message || '网络异常,请重试');
}
private next() {
this.row = this.rows.shift();
if (!this.row) {
if (this.recoveredRewards.length) {
const rewards = this.recoveredRewards.splice(0);
this.playRewards(rewards, () => this.next());
return;
}
this.finish(); return;
}
if (!this.row.claimed && !this.row.emptySeen) {
const rewards = settlementRewards(this.row, cc.fx.GameConfig.PASS_CHECK);
if (rewards.length) this.show('claim', rewards);
else {
this.show('empty');
this.busy = true;
// Mark only after the successfully constructed view has reached a rendered frame.
this.scheduleOnce(() => this.markEmpty(), 0);
}
} else this.offerOrNext();
}
private async markEmpty() {
try { const r = await Utils.settlementRequest('empty_seen', { end: this.row.end }); this.row = r.data.row; this.busy = false; }
catch (e) { this.busy = false; this.error(e); }
}
private offerOrNext() {
if (this.row.end !== this.latest || this.row.end <= this.serverTime - 30 * 86400000 || this.row.stage.activate || this.row.stage.tier || this.row.offer !== 'unused' || this.row.purchase) { this.next(); return; }
this.offerToken = Date.now().toString(36) + '_' + Math.random().toString(36).slice(2) + '_' + Math.random().toString(36).slice(2);
// A stale snapshot may pass the local check but be rejected by the server.
// Keep the prefab (including its default offer and shade) hidden until accepted.
this.root.active = false;
this.busy = true;
this.markOffer();
}
private async markOffer() {
try {
const r = await Utils.settlementRequest('offer_shown', { end: this.row.end, offerToken: this.offerToken });
if (!cc.isValid(this.node) || !cc.isValid(this.root)) return;
this.row = r.data.row;
this.show('offer', settlementRewards(this.row, cc.fx.GameConfig.PASS_CHECK, true));
this.busy = false;
}
catch (e) {
if (!cc.isValid(this.node) || !cc.isValid(this.root)) return;
this.busy = false;
if (e.code === 410) { this.next(); return; }
this.error(e);
// No button is visible yet. Retry with the same token so a lost response
// does not consume the opportunity twice or leave the home queue stuck.
this.busy = true;
this.scheduleOnce(() => this.markOffer(), 5);
}
}
private bindButton(path: string, click: () => void) {
cc.find(path, this.panel).on('click', () => {
if (!this.busy) {
cc.fx.AudioManager._instance.playEffect("anniu_little", null);
click();
}
}, this);
}
private show(mode: string, rewards: any[] = []) {
this.mode = mode; this.hideConfirmation();
for (const name of ['offer', 'empty', 'claim']) this.panel.getChildByName(name).active = name === mode;
if (mode !== 'empty') this.preview(rewards);
this.root.active = true;
}
private preview(rewards: any[]) {
const group = cc.find(this.mode + '/rewards', this.panel);
const slots = group.getChildByName('topRow').children.concat(group.getChildByName('bottomRow').children);
slots.forEach((cell, i) => {
const r = rewards[i]; cell.active = !!r; if (!r) return;
const item = cell.getChildByName('item');
const icon = item.getChildByName('icon'); for (const child of icon.children) child.active = child.name === r.type;
const label = item.getChildByName('amount');
if (r.type === 'infinite_health') NumberToImage.durationToImageNodes(r.count, label);
else NumberToImage.numberToImageNodesNumber(r.count, 45, 5, 'big_', label, true);
});
}
private hideConfirmation() {
const confirmation = this.confirm || this.panel.getChildByName('confirmation');
if (confirmation) confirmation.active = false;
this.confirm = null;
}
private close() {
if (this.busy) return;
if (this.confirm) { this.hideConfirmation(); return; }
if (this.mode === 'claim') { this.claim(); return; }
if (this.mode === 'empty') { if (!this.row.emptySeen) { this.busy = true; this.markEmpty(); } else this.offerOrNext(); return; }
if (this.mode !== 'offer') return;
this.confirm = this.panel.getChildByName('confirmation');
this.confirm.active = true;
}
private async decline() {
this.busy = true;
try { await Utils.settlementRequest('decline', { end: this.row.end }); this.busy = false; this.next(); }
catch (e) { this.busy = false; this.error(e); }
}
private async claim() {
if (this.busy) return; this.busy = true;
try {
const r = await Utils.commitPassSettlement('claim', this.row);
if (!cc.isValid(this.root)) return;
this.row = r.data.row;
this.playRewards(r.data.rewards, () => { this.busy = false; this.offerOrNext(); });
} catch (e) { this.busy = false; this.error(e); }
}
private playRewards(rewards: any[], done: () => void) {
this.root.active = true;
this.mode = 'animation'; this.panel.active = false;
const home = this.node.getComponent('JiaZai'); home.updateCoin(); home.updatePower();
if (rewards?.length) home.openRewardWindow(rewards, undefined, () => { this.panel.active = true; done(); });
else { this.panel.active = true; done(); }
}
private async purchase() {
if (this.busy) return;
if (this.row.offer !== 'shown') { this.busy = true; await this.markOffer(); return; }
this.busy = true;
let system = 'Android';
// @ts-ignore
if (typeof wx !== 'undefined' && wx.getSystemInfoSync().platform === 'ios') system = 'ios';
if (system === 'ios' && !cc.fx.GameConfig.GM_INFO.iosCanPay) { this.oldIos(); return; }
Utils.buyProp('battlepass', 1, 1800, system, 'battlepass', res => {
if (!res || res.err) { if (res?.errCode === 16) { this.oldIos(); return; } this.busy = false; this.error(res || {}); return; }
Utils.getPayInfo(data => {
if (data?.data?.pay_state === 2) this.fulfill(res.outTradeNo);
else { this.busy = false; this.error({ msg: '支付未完成,请重试' }); }
}, res.outTradeNo);
}, String(this.row.end), undefined, this.offerToken);
}
private oldIos() {
Utils.GoKEFu({ payment_name: 'battlepass', payment_count: 1, price: 1800, passEnd: String(this.row.end), passOfferToken: this.offerToken },
() => { this.busy = false; }, order => { this.iosOrder = order; });
}
private checkIos() {
this.busy = true;
Utils.getIosPayInfo(this.iosOrder, res => { if (res.code === 1) this.fulfill(this.iosOrder); else this.busy = false; });
}
private async fulfill(order: string) {
try {
const result = await Utils.completePassOrder(order); this.iosOrder = ''; cc.fx.GameConfig.GM_INFO.iosPassCheckOrder = null;
if (!cc.isValid(this.root)) return;
this.playRewards(result.data.rewards, () => { this.busy = false; this.next(); });
} catch (e) { this.busy = false; this.error(e); }
}
onDestroy() {
cc.systemEvent.off(cc.SystemEvent.EventType.KEY_DOWN, this.back, this);
// @ts-ignore
if (typeof wx !== 'undefined') wx.offShow(this.resume);
if (this.root && cc.isValid(this.root)) this.root.destroy();
}
}