import { EDITOR } from 'cc/env'; import { sys } from 'cc'; import { MiniGamePf } from '../../mx/tools/MiniGameHostInfo'; import { IUserInfo } from '../game/GameController'; import { TableNames } from '../game/ConfigTableData'; import { RankDataManager } from './RankDataManager'; import { DailyWinStats } from './DailyWinStats'; import { HeadIconManager } from './HeadIconManager'; import { OtherDataType } from '../game/GameDataEnums'; import { ProvinceLocator } from './ProvinceLocator'; /** 排行榜类型:省榜 / 总榜 / 每日胜场榜 */ export enum RankBoardScope { Province = 1, Total = 2, DailyWins = 3, } /** 单条排行榜展示数据 */ export type RankBoardEntry = { rank: number; score: number; nickname: string; avatarUrl: string; region: string; userId: string; isSelf?: boolean; }; export type RankBoardResult = { list: RankBoardEntry[]; self: RankBoardEntry | null; }; const RANK_TOTAL_CATEGORY = '总榜'; const RANK_DAILY_WINS_CATEGORY = '每日胜场榜'; const RANK_TOP_COUNT = 100; const RANK_PRESET = 'today'; /** 1=永久榜 2=日榜 */ const RANK_LIST_TYPE_PERMANENT = 1; const RANK_LIST_TYPE_DAILY = 2; /** 未授权玩家展示昵称 */ const RANK_NICK_ANONYMOUS = '匿名'; /** 未授权玩家展示地区 */ const RANK_REGION_UNKNOWN = '未知'; /** 排行榜用户信息授权选项 */ export type RankUserAuthOpts = { /** 授权失败时不弹 Toast */ silent?: boolean; }; type BoardCacheItem = { data: RankBoardResult; at: number; }; export class RankBoardManager { private static _selfUserId: string | null = null; private static _authPromise: Promise | null = null; /** 日榜上传世代:跨天或新请求作废未完成的旧上传 */ private static _dailyWinsUploadGen = 0; /** 三榜预拉取缓存(进大厅静默写入,打开面板直接读) */ private static _boardCache = new Map(); private static _fetchInflight = new Map>(); private static _warmupInflight: Promise | null = null; private static _locateInflight: Promise | null = null; /** 首次进入排行榜后标记红点已读 */ static markRankEntered(): void { if (gg.data.getkeyKeyValue(OtherDataType.RedDotState, 'rank') === '1') return; gg.data.setkeyKeyValue(OtherDataType.RedDotState, 'rank', '1'); gg.data.saveLocal(); } static hasRankProfile(): boolean { const name = String(gg.data?.doc?.userName ?? '').trim(); const avatar = String(gg.data?.doc?.userAvatar ?? '').trim(); return !!name && !!avatar; } static getSavedNickname(): string { return this._decodeNick(String(gg.data?.doc?.userName ?? '').trim()); } static getSavedAvatar(): string { return String(gg.data?.doc?.userAvatar ?? '').trim(); } static getSavedProvince(): string { return String(gg.data?.doc?.userProvince ?? '').trim(); } /** 是否已弹过排行榜首次用户信息授权 */ static isRankAuthPrompted(): boolean { return Math.floor(Number(gg.data?.doc?.rankUserAuthPrompted) || 0) === 1; } /** 标记排行榜首次用户信息授权已弹过 */ static markRankAuthPrompted(): void { if (this.isRankAuthPrompted()) return; gg.data.doc.rankUserAuthPrompted = 1; gg.data.saveLocal(); } /** * 首次打开排行榜时弹一次用户信息授权;之后打开不再判断授权。 * 须在 onInit 等同步路径调用(不能先 await),否则平台不弹授权窗。 * 授权成功后清预拉取缓存,面板侧可异步刷新「我」的资料。 */ static tryPromptRankAuthOnFirstOpen(): Promise { if (this.isRankAuthPrompted()) return Promise.resolve(this.hasRankProfile()); this.markRankAuthPrompted(); if (this.hasRankProfile()) return Promise.resolve(true); return this.ensureRankUserAuth({ silent: true }).then((ok) => { if (ok) this.invalidateBoardCache(); return ok; }); } /** * 拉取平台用户信息授权并写入存档,成功后同步榜单。 * 须在点击回调里同步调用(不能先 await),否则平台不弹授权窗。 */ static ensureRankUserAuth(opts?: RankUserAuthOpts): Promise { if (this._authPromise) return this._authPromise; if (!this._needPlatformAuth()) { this._authPromise = Promise.all([ this.syncSelfScore(), this.syncSelfDailyWins(), ]).then(() => true).finally(() => { this._authPromise = null; }); return this._authPromise; } if (this.hasRankProfile()) { // 只做用户资料同步;定位授权由打开排行榜 / 点省榜单独触发 this._authPromise = (async () => { await this.syncSelfScore(); await this.syncSelfDailyWins(); return true; })().finally(() => { this._authPromise = null; }); return this._authPromise; } const infoP = this._pullRankUserInfo(); this._authPromise = this._finishUserInfoAuth(infoP, opts).finally(() => { this._authPromise = null; }); return this._authPromise; } /** @deprecated 使用 ensureRankUserAuth */ static ensureAuthToEnter(): Promise { return this.ensureRankUserAuth(); } private static _pullRankUserInfo(): Promise<{ userInfo?: Record } | null> { return new Promise((resolve) => { gg.sdk.pullRankUserInfo((info) => resolve(info ?? null)); }); } /** 调试用:拉取结果摘要(昵称/头像截断) */ private static _describeInfo(info: { userInfo?: Record } | null): string { if (!info?.userInfo) return 'null(未拉到)'; const u = info.userInfo; const nick = String(u.nickName ?? u.nickname ?? '').trim(); const avatar = String(u.avatarUrl ?? '').trim(); const nickShow = nick ? `"${nick.length > 20 ? nick.slice(0, 20) + '…' : nick}"` : '(空)'; const avatarShow = avatar ? `"${avatar.slice(0, 40)}${avatar.length > 40 ? '…' : ''}"` : '(空)'; return `nick=${nickShow} avatar=${avatarShow}`; } static saveUserProfile(userInfo: Record | null | undefined): boolean { return this._saveUserFromInfo(userInfo ? { userInfo } : null); } private static async _finishUserInfoAuth( infoP: Promise<{ userInfo?: Record } | null>, opts?: RankUserAuthOpts, ): Promise { let info = await infoP; console.warn(`[RankDebug] 首拉用户信息结果=${this._describeInfo(info)}`); if (!this._saveUserFromInfo(info)) { if (opts?.silent) return false; // 先查授权状态:已授权(可能拉取抖动/中途取消)不弹设置直接重拉;未授权才打开设置 const granted = await this._isScopeGranted('scope.userInfo'); console.warn(`[RankDebug] authSetting.scope.userInfo=${granted} → ${granted ? '已授权,不开设置直接重拉' : '未授权,打开设置'}`); if (!granted) { await this._openSetting(); } info = await this._pullRankUserInfo(); console.warn(`[RankDebug] 重拉用户信息结果=${this._describeInfo(info)}`); if (!this._saveUserFromInfo(info)) { console.warn('[RankDebug] 重拉仍无有效资料,toast 提示'); gg.ui.showToast( gg.lang.get('需要授权用户信息才能查看省榜') || '需要授权用户信息才能查看省榜', false, ); return false; } } console.warn(`[RankDebug] 资料就绪 userName="${this.getSavedNickname()}" province="${this.getSavedProvince()}"`); gg.data.saveToServer(); await this._syncAllBoardsAfterProfileUpdate(); this.invalidateBoardCache(); return this.hasRankProfile(); } private static _saveUserFromInfo(info: { userInfo?: Record } | null): boolean { const user = info?.userInfo; const nick = String(user?.nickName ?? user?.nickname ?? '').trim(); const avatar = String(user?.avatarUrl ?? '').trim(); if (!nick || !avatar) return false; const encoded = this._encodeNick(nick); if (!encoded) { console.warn('[RankBoardManager] 昵称 Base64 编码失败,未写入存档'); return false; } gg.data.doc.userName = encoded; gg.data.doc.userAvatar = avatar; gg.data.saveLocal(); return true; } /** * 同步星数到省榜 + 总榜。 * 首次进榜上传一次;之后仅星数或省份变化时再上传。 */ static async syncSelfScore(force = false): Promise { await RankDataManager.ensureTable(); await gg.data.table.ensureTableLoaded(TableNames.Headicon); const score = RankDataManager.getRankStars(); // 只读存档省份;定位授权不在对局结算/同步路径触发 const province = this.getSavedProvince(); const profile = this._getUploadProfile(); const region = province || profile.region; if (!force && !this._needUploadRank(score, province, profile, region)) return; const avatar = profile.avatar; const nick = profile.nickname; try { if (province) { this._setRankingData(score, province, avatar, nick, region, RANK_LIST_TYPE_PERMANENT); } this._setRankingData(score, RANK_TOTAL_CATEGORY, avatar, nick, region, RANK_LIST_TYPE_PERMANENT); this._markRankUploaded(score, province, nick, avatar, region); } catch (e) { console.log('[RankBoardManager] syncSelfScore', String(e)); } } /** * 同步今日胜场到每日胜场榜(日榜 rankingtype=2)。 * 胜场变化或跨天后重新上传;await 之后再按当天日期取值,禁止把昨日场次传到新日榜。 */ static async syncSelfDailyWins(force = false): Promise { const uploadGen = ++this._dailyWinsUploadGen; await gg.data.table.ensureTableLoaded(TableNames.Headicon); if (uploadGen !== this._dailyWinsUploadGen) return; // 只读存档省份;定位授权不在对局结算/同步路径触发 const province = this.getSavedProvince(); DailyWinStats.ensureToday(); const today = DailyWinStats.todayKey(); const wins = DailyWinStats.getTodayWins(); const profile = this._getUploadProfile(); const region = province || profile.region; if (!force && !this._needUploadDailyWins(wins, today, profile, region)) return; const avatar = profile.avatar; const nick = profile.nickname; const expiredate = DailyWinStats.expireDateToday(); try { this._setRankingData(wins, RANK_DAILY_WINS_CATEGORY, avatar, nick, region, RANK_LIST_TYPE_DAILY, expiredate); this._markDailyWinsUploaded(wins, today, nick, avatar, region); } catch (e) { console.log('[RankBoardManager] syncSelfDailyWins', String(e)); } } /** 首次、胜场变化、或上次上传不是今天则再传每日胜场榜 */ private static _needUploadDailyWins( wins: number, today: string, profile: { nickname: string; avatar: string; region: string }, region: string, ): boolean { const doc = gg.data?.doc; if (!doc) return false; const lastDate = String(doc.rankLastSyncDailyWinsDate ?? '').trim(); if (lastDate !== today) return true; const last = Number(doc.rankLastSyncDailyWins); if (last !== wins) return true; return this._needUploadProfile(profile, region); } private static _markDailyWinsUploaded( wins: number, today: string, nick: string, avatar: string, region: string, ): void { const doc = gg.data?.doc; if (!doc) return; doc.rankLastSyncDailyWins = wins; doc.rankLastSyncDailyWinsDate = today; doc.rankLastSyncNick = nick; doc.rankLastSyncAvatar = avatar; doc.rankLastSyncRegion = region; gg.data.saveLocal(); } /** score, category, avatar, nick 保持原逻辑;region=存档省份;日榜须带当日 expiredate */ private static _setRankingData( score: number, category: string, avatar: string, nick: string, region: string, rankingtype: number, expiredate = '', ): void { ZYSDK.ZYSDK.setRankingData(score, category, avatar, nick, region, expiredate, rankingtype); } /** 授权或资料变更后清除上传缓存,强制重传省榜/总榜/每日胜场榜 */ private static _invalidateRankUploadCache(): void { const doc = gg.data?.doc; if (!doc) return; doc.rankLastSyncStars = -1; doc.rankLastSyncDailyWins = -1; doc.rankLastSyncDailyWinsDate = ''; doc.rankLastSyncProvince = ''; doc.rankLastSyncNick = ''; doc.rankLastSyncAvatar = ''; doc.rankLastSyncRegion = ''; gg.data.saveLocal(); } /** 授权成功后强制同步三榜资料 */ private static async _syncAllBoardsAfterProfileUpdate(): Promise { this._invalidateRankUploadCache(); await this.syncSelfScore(true); await this.syncSelfDailyWins(true); } /** 昵称 / 头像 / 地区变化时需重传 */ private static _needUploadProfile( profile: { nickname: string; avatar: string; region: string }, region: string, ): boolean { const doc = gg.data?.doc; if (!doc) return false; const syncRegion = region || profile.region; if (String(doc.rankLastSyncNick ?? '') !== profile.nickname) return true; if (String(doc.rankLastSyncAvatar ?? '') !== profile.avatar) return true; return String(doc.rankLastSyncRegion ?? '') !== syncRegion; } /** 首次进榜必传;已传过后星数 / 省份 / 资料变化再传 */ private static _needUploadRank( score: number, province: string, profile: { nickname: string; avatar: string; region: string }, region: string, ): boolean { const doc = gg.data?.doc; if (!doc?.rankSyncedOnce) return true; const last = Number(doc.rankLastSyncStars); if (last !== score) return true; const lastProv = String(doc.rankLastSyncProvince ?? ''); if (!!province && province !== lastProv) return true; return this._needUploadProfile(profile, region); } private static _markRankUploaded( score: number, province: string, nick: string, avatar: string, region: string, ): void { const doc = gg.data?.doc; if (!doc) return; doc.rankSyncedOnce = 1; doc.rankLastSyncStars = score; if (province) doc.rankLastSyncProvince = province; doc.rankLastSyncNick = nick; doc.rankLastSyncAvatar = avatar; doc.rankLastSyncRegion = region; gg.data.saveLocal(); } /** * 进入首页/大厅静默预拉取不依赖定位的榜(总榜/日榜)并缓存。 * 不申请定位权限——定位统一放到打开排行榜之后。 */ static warmupOnHallEnter(): void { if (this._warmupInflight) return; this._warmupInflight = this._doWarmup().finally(() => { this._warmupInflight = null; }); } private static async _doWarmup(): Promise { try { this._boardCache.clear(); await Promise.all([ this.getBoard(RankBoardScope.Total), this.getBoard(RankBoardScope.DailyWins), ]); } catch (e) { console.log('[RankBoardManager] warmupOnHallEnter', String(e)); } } static getBoardCache(scope: RankBoardScope): RankBoardResult | null { return this._boardCache.get(scope)?.data ?? null; } static invalidateBoardCache(scope?: RankBoardScope): void { if (scope == null) { this._boardCache.clear(); return; } this._boardCache.delete(scope); } /** * 取榜单:优先预拉取缓存;无缓存则复用进行中的请求。 * @param opts.force 忽略缓存重新拉取(授权成功后刷新等场景) */ static getBoard(scope: RankBoardScope, opts?: { force?: boolean }): Promise { if (!opts?.force) { const inflight = this._fetchInflight.get(scope); if (inflight) return inflight; const cached = this._boardCache.get(scope); if (cached) return Promise.resolve(cached.data); } const p = this.fetchBoard(scope) .then((data) => { if (this._fetchInflight.get(scope) === p) { this._boardCache.set(scope, { data, at: Date.now() }); } return data; }) .finally(() => { if (this._fetchInflight.get(scope) === p) { this._fetchInflight.delete(scope); } }); this._fetchInflight.set(scope, p); return p; } static async fetchBoard(scope: RankBoardScope): Promise { if (this._authPromise) { await this._authPromise; } await RankDataManager.ensureTable(); await gg.data.table.ensureTableLoaded(TableNames.Headicon); if (scope === RankBoardScope.DailyWins) { await this.syncSelfDailyWins(); } else { await this.syncSelfScore(); } const user = await this._resolveUserInfo(); const selfUserId = await this._resolveSelfUserId(); // 只读存档省份;拉榜副作用不得申请定位 const province = this.getSavedProvince(); let parsed: RankBoardResult; if (this._shouldUseMockBoard()) { parsed = scope === RankBoardScope.DailyWins ? this._buildMockDailyWinsBoard(user, selfUserId) : this._buildMockBoard(user, selfUserId); parsed = this._applyScope(parsed, scope, province, selfUserId, user); return parsed; } const category = this._categoryOfScope(scope, province); if (scope === RankBoardScope.Province && !category) { return { list: [], self: this._buildSelfFallback(scope, user, selfUserId), }; } const listType = scope === RankBoardScope.DailyWins ? RANK_LIST_TYPE_DAILY : RANK_LIST_TYPE_PERMANENT; let raw: unknown = null; try { raw = await this._requestRankingList(category, listType); } catch (e) { console.log('[RankBoardManager] fetchBoard failed', String(e)); } parsed = this._parseResponse(raw, selfUserId); if (scope !== RankBoardScope.DailyWins) { parsed = this._clampStarBoardScores(parsed); } if (!parsed.list.length && this._shouldUseMockBoard()) { parsed = scope === RankBoardScope.DailyWins ? this._buildMockDailyWinsBoard(user, selfUserId) : this._buildMockBoard(user, selfUserId); } if (!parsed.self) { parsed.self = this._buildSelfFallback(scope, user, selfUserId); } parsed = this._applyScope(parsed, scope, province, selfUserId, user); return parsed; } private static _categoryOfScope(scope: RankBoardScope, province: string): string { if (scope === RankBoardScope.Province) return province; if (scope === RankBoardScope.DailyWins) return RANK_DAILY_WINS_CATEGORY; return RANK_TOTAL_CATEGORY; } private static _needPlatformAuth(): boolean { if (EDITOR) return false; if (MiniGamePf.id()) return true; return sys.platform === sys.Platform.WECHAT_GAME || sys.platform === sys.Platform.BYTEDANCE_MINI_GAME; } /** * 定位授权拿到省份后写入存档,已有省份则不再变更。 * 失败会打开系统设置页,返回后以 authSetting 为准。 * @returns 短省名;拒绝授权或定位失败返回空串 */ static async ensureProvinceWithAuth(): Promise { const inflight = this._locateInflight; if (inflight) { const got = await inflight; if (got) return got; // 打开排行榜时的低权限请求可能仍在收尾;失败后再允许打开设置 if (this._locateInflight) return this._locateInflight; } return this._ensureProvinceSaved({ allowOpenSetting: true }); } /** * 打开排行榜后申请定位省份(进首页不申请)。 * 只弹授权框,不打开设置页;设置页留给点省榜时的 ensureProvinceWithAuth。 * @returns 短省名;拒绝授权或定位失败返回空串 */ static ensureProvinceOnRankOpen(): Promise { return this._ensureProvinceSaved({ allowOpenSetting: false }); } /** * 定位授权拿到省份后写入存档,已有省份则不再变更。 */ private static _ensureProvinceSaved(opts?: { allowOpenSetting?: boolean }): Promise { if (this._locateInflight) return this._locateInflight; this._locateInflight = this._doEnsureProvinceSaved(opts).finally(() => { this._locateInflight = null; }); return this._locateInflight; } private static async _doEnsureProvinceSaved(opts?: { allowOpenSetting?: boolean }): Promise { const saved = this.getSavedProvince(); if (saved) { console.warn(`[RankDebug] 定位跳过:已有存档省份="${saved}"`); return saved; } const located = await this._requestProvince(opts); console.warn(`[RankDebug] 拉取定位省份结果="${located || '(空)'}"`); if (located) { gg.data.doc.userProvince = located; gg.data.saveLocal(); // 省份就绪后补传省榜,并作废省榜缓存 this.invalidateBoardCache(RankBoardScope.Province); void this.syncSelfScore(); void this.syncSelfDailyWins(); } return located; } private static async _requestProvince(opts?: { allowOpenSetting?: boolean }): Promise { if (!this._needPlatformAuth()) { return this._shortProvince(await ProvinceLocator.getProvince({ allowEmptyCacheRetry: true, force: true, })); } const granted = await this._authorizeLocation(!!opts?.allowOpenSetting); if (!granted) { // 拒绝定位则不进省榜,勿再调 getLocation(会直接 fail) return ''; } // 授权成功后必须 force,否则历史失败空缓存会挡住 getLocation const located = await ProvinceLocator.getProvince({ allowEmptyCacheRetry: true, force: true, }); return this._shortProvince(located); } private static _shortProvince(name: string): string { const n = String(name ?? '').trim(); if (!n) return ''; // 长后缀优先,避免「广西壮族自治区」被「自治区」截成「广西壮族」 return n.replace( /(省|市|特别行政区|壮族自治区|回族自治区|维吾尔自治区|自治区)$/, '', ) || n; } private static _getSetting(): Promise<{ authSetting?: Record } | null> { return new Promise((resolve) => { gg.sdk.getSetting((res) => resolve(res ?? null)); }); } private static _openSetting(): Promise { return new Promise((resolve) => { gg.sdk.openSetting((ok) => resolve(!!ok)); }); } /** 设置页中该 scope 是否已授权 */ private static async _isScopeGranted(scope: string): Promise { const setting = await this._getSetting(); return !!setting?.authSetting?.[scope]; } /** * 申请定位授权;可选失败后打开设置,返回后以 authSetting 为准。 * @param allowOpenSetting 授权失败是否打开系统设置页 */ private static async _authorizeLocation(allowOpenSetting: boolean): Promise { if (await this._isScopeGranted('scope.userLocation')) return true; const granted = await new Promise((resolve) => { gg.sdk.authorizeUserLocation((ok) => resolve(!!ok)); }); if (granted) return true; if (!allowOpenSetting) return false; await this._openSetting(); return this._isScopeGranted('scope.userLocation'); } private static _applyScope( parsed: RankBoardResult, scope: RankBoardScope, province: string, selfUserId: string, user: IUserInfo | null, ): RankBoardResult { let result: RankBoardResult; if (scope === RankBoardScope.Province && this._shouldUseMockBoard() && province) { const filtered = this._filterProvince(parsed, province, selfUserId); if (filtered.list.length > 0) { result = filtered; } else { const mock = this._buildMockBoard(user, selfUserId); const filteredMock = this._filterProvince(mock, province, selfUserId); result = filteredMock.list.length > 0 ? filteredMock : mock; } } else { const list = parsed.list.map((row, i) => ({ ...row, rank: i + 1 })); let self = parsed.self; if (self?.userId) { const idx = list.findIndex((r) => r.userId === self!.userId); self = idx >= 0 ? { ...list[idx], isSelf: true } : { ...self, rank: 0, isSelf: true }; } result = { list, self }; } if (scope !== RankBoardScope.DailyWins) { result = this._clampStarBoardScores(result); } return this._patchSelfProfileOnBoard(result, province, user, selfUserId); } /** 省榜/总榜条目星数不低于 1 */ private static _clampStarBoardScores(result: RankBoardResult): RankBoardResult { const clamp = (entry: RankBoardEntry): RankBoardEntry => ({ ...entry, score: RankDataManager.clampRankStars(entry.score), }); return { list: result.list.map(clamp), self: result.self ? clamp(result.self) : null, }; } /** 自己排名(含列表内「我」)用本地存档资料覆盖昵称 / 头像 / 地区 */ private static _patchSelfProfileOnBoard( result: RankBoardResult, province: string, user: IUserInfo | null, selfUserId: string, ): RankBoardResult { const patch = (entry: RankBoardEntry): RankBoardEntry => { const isSelfRow = entry.isSelf || (!!selfUserId && entry.userId === selfUserId); if (!isSelfRow) return entry; const display = this._getSelfDisplayProfile(user); return { ...entry, nickname: display.nickname, avatarUrl: display.avatarUrl, region: display.region || province || entry.region, }; }; return { list: result.list.map(patch), self: result.self ? patch(result.self) : null, }; } /** 上传榜单用的昵称 / 头像 / 地区 */ private static _getUploadProfile(): { nickname: string; avatar: string; region: string } { if (this.hasRankProfile()) { return { nickname: this.getSavedNickname(), avatar: this.getSavedAvatar(), region: this.getSavedProvince(), }; } const headId = HeadIconManager.getPlayerHeadIconId(); if (headId <= 0) { console.log('[RankBoardManager] 未授权且无有效 headIconId'); } return { nickname: RANK_NICK_ANONYMOUS, avatar: headId > 0 ? String(headId) : '', region: RANK_REGION_UNKNOWN, }; } /** 自己条目展示用的昵称 / 头像 / 地区 */ private static _getSelfDisplayProfile(user: IUserInfo | null): { nickname: string; avatarUrl: string; region: string; } { if (this.hasRankProfile()) { return { nickname: this.getSavedNickname() || user?.nickName || '我', avatarUrl: this.getSavedAvatar() || user?.avatarUrl || '', region: this.getSavedProvince() || this._regionOf(user), }; } const headId = HeadIconManager.getPlayerHeadIconId(); return { nickname: RANK_NICK_ANONYMOUS, avatarUrl: headId > 0 ? String(headId) : '', region: RANK_REGION_UNKNOWN, }; } /** 浏览器 / 编辑器 / 非小游戏环境:直接用本地 mock,避免 SDK 登录等待 */ private static _shouldUseMockBoard(): boolean { if (EDITOR || sys.isBrowser) return true; if (MiniGamePf.id()) return false; return !(sys.platform === sys.Platform.WECHAT_GAME || sys.platform === sys.Platform.BYTEDANCE_MINI_GAME); } private static _requestRankingList(category: string, rankingtype: number): Promise { return new Promise((resolve, reject) => { ZYSDK.ZYSDK.getRankingList( category, RANK_TOP_COUNT, RANK_PRESET, rankingtype, (rep) => { console.log('[RankBoardManager] getRankingList ok', category); resolve(rep); }, (err) => { console.log('[RankBoardManager] getRankingList fail', category, String(err)); reject(err); }, ); }); } private static _parseResponse(raw: unknown, selfUserId: string): RankBoardResult { const root = this._unwrap(raw); const listRaw = this._pickArray(root, ['toplist', 'list', 'ranklist', 'rankinglist', 'ranking_list', 'data_list', 'users']); const selfRaw = this._pickObject(root, ['self', 'my', 'myinfo', 'myrank', 'my_rank', 'userdata']); const list: RankBoardEntry[] = []; for (let i = 0; i < listRaw.length; i++) { const row = this._normalizeEntry(listRaw[i], i + 1, selfUserId); if (row) list.push(row); } let self = selfRaw ? this._normalizeEntry(selfRaw, 0, selfUserId) : null; if (!self && selfUserId) { const hit = list.find((r) => r.userId === selfUserId); if (hit) self = { ...hit, isSelf: true }; } if (self) { self.isSelf = true; if (!self.rank && selfUserId) { const hit = list.find((r) => r.userId === selfUserId); if (hit) self.rank = hit.rank; } } return { list, self }; } private static _filterProvince(result: RankBoardResult, province: string, selfUserId: string): RankBoardResult { const key = province.trim().toLowerCase(); const filtered = result.list.filter((row) => { const region = (row.region ?? '').trim().toLowerCase(); return region && (region === key || region.includes(key) || key.includes(region)); }); const list = filtered.map((row, i) => ({ ...row, rank: i + 1 })); let self: RankBoardEntry | null = null; if (selfUserId) { const hit = list.find((r) => r.userId === selfUserId); if (hit) { self = { ...hit, isSelf: true }; } else if (result.self) { self = { ...result.self, rank: 0, isSelf: true }; } } return { list, self }; } private static _normalizeEntry(raw: unknown, fallbackRank: number, selfUserId: string): RankBoardEntry | null { if (!raw || typeof raw !== 'object') return null; const o = raw as Record; const userId = String(o.userid ?? o.userId ?? o.uid ?? o.openid ?? o.openId ?? ''); const score = Math.max(0, Math.floor(Number(o.score ?? o.value ?? o.soul ?? o.point ?? 0) || 0)); const rankNum = Math.floor(Number(o.rank ?? o.ranking ?? o.idx ?? o.index ?? fallbackRank) || 0); return { rank: rankNum > 0 ? rankNum : fallbackRank, score, nickname: this._decodeNick(String(o.nickname ?? o.nickName ?? o.name ?? '玩家').trim()) || '玩家', avatarUrl: String(o.imgurl ?? o.avatarUrl ?? o.avatar ?? o.head ?? o.icon ?? ''), region: String(o.region ?? o.province ?? o.area ?? ''), userId, isSelf: !!(selfUserId && userId && userId === selfUserId), }; } private static _unwrap(raw: unknown): Record { if (!raw) return {}; if (typeof raw === 'string') { try { return JSON.parse(raw) as Record; } catch { return {}; } } if (typeof raw !== 'object') return {}; const o = raw as Record; if (o.data && typeof o.data === 'object') { return o.data as Record; } return o; } private static _pickArray(root: Record, keys: string[]): unknown[] { for (const k of keys) { const v = root[k]; if (Array.isArray(v)) return v; } return []; } private static _pickObject(root: Record, keys: string[]): Record | null { for (const k of keys) { const v = root[k]; if (v && typeof v === 'object' && !Array.isArray(v)) { return v as Record; } } return null; } private static async _resolveSelfUserId(): Promise { if (this._selfUserId) return this._selfUserId; try { const uid = await ZYSDK.ZYSDK.getUserId(); this._selfUserId = uid != null ? String(uid) : ''; } catch { this._selfUserId = ''; } return this._selfUserId ?? ''; } private static async _resolveUserInfo(): Promise { const nick = this.getSavedNickname(); const avatar = this.getSavedAvatar(); const province = this.getSavedProvince(); if (nick || avatar) { return { nickName: nick || '我', avatarUrl: avatar, gender: '', country: '', province, city: '', language: '', }; } if (gg.game?.UserInfo) return gg.game.UserInfo; return null; } private static _regionOf(user: IUserInfo | null): string { return this.getSavedProvince() || String(user?.province ?? user?.city ?? '').trim(); } /** 编辑器 / 浏览器 / 接口无数据时的本地占位 */ private static _buildMockBoard(user: IUserInfo | null, selfUserId: string): RankBoardResult { const selfScore = RankDataManager.getRankStars(); const display = this._getSelfDisplayProfile(user); const self: RankBoardEntry = { rank: 8, score: selfScore, nickname: display.nickname, avatarUrl: display.avatarUrl, region: display.region, userId: selfUserId || 'self', isSelf: true, }; const list: RankBoardEntry[] = [ { rank: 1, score: selfScore + 120, nickname: '棋圣张三', avatarUrl: '', region: '广东', userId: 'm1' }, { rank: 2, score: selfScore + 90, nickname: '百战李四', avatarUrl: '', region: '北京', userId: 'm2' }, { rank: 3, score: selfScore + 60, nickname: '落子王五', avatarUrl: '', region: '上海', userId: 'm3' }, { rank: 4, score: selfScore + 40, nickname: '快棋手赵六', avatarUrl: '', region: '浙江', userId: 'm4' }, { rank: 5, score: selfScore + 25, nickname: '守擂者钱七', avatarUrl: '', region: '江苏', userId: 'm5' }, { rank: 6, score: selfScore + 15, nickname: '挑战者孙八', avatarUrl: '', region: '四川', userId: 'm6' }, { rank: 7, score: selfScore + 5, nickname: '修行者周九', avatarUrl: '', region: '湖北', userId: 'm7' }, { ...self }, { rank: 9, score: RankDataManager.clampRankStars(selfScore - 5), nickname: '新手吴十', avatarUrl: '', region: '广东', userId: 'm9' }, { rank: 10, score: RankDataManager.clampRankStars(selfScore - 12), nickname: '学徒郑一', avatarUrl: '', region: '广东', userId: 'm10' }, ]; return { list, self: { ...self } }; } /** 每日胜场榜 mock */ private static _buildMockDailyWinsBoard(user: IUserInfo | null, selfUserId: string): RankBoardResult { const selfWins = DailyWinStats.getTodayWins(); const display = this._getSelfDisplayProfile(user); const self: RankBoardEntry = { rank: 6, score: selfWins, nickname: display.nickname, avatarUrl: display.avatarUrl, region: display.region, userId: selfUserId || 'self', isSelf: true, }; const list: RankBoardEntry[] = [ { rank: 1, score: selfWins + 8, nickname: '常胜张三', avatarUrl: '', region: '广东', userId: 'd1' }, { rank: 2, score: selfWins + 6, nickname: '连战李四', avatarUrl: '', region: '北京', userId: 'd2' }, { rank: 3, score: selfWins + 4, nickname: '捷报王五', avatarUrl: '', region: '上海', userId: 'd3' }, { rank: 4, score: selfWins + 3, nickname: '快赢赵六', avatarUrl: '', region: '浙江', userId: 'd4' }, { rank: 5, score: selfWins + 1, nickname: '守擂钱七', avatarUrl: '', region: '江苏', userId: 'd5' }, { ...self }, { rank: 7, score: Math.max(0, selfWins - 1), nickname: '新手吴十', avatarUrl: '', region: '四川', userId: 'd7' }, { rank: 8, score: Math.max(0, selfWins - 2), nickname: '学徒郑一', avatarUrl: '', region: '湖北', userId: 'd8' }, ]; return { list, self: { ...self } }; } private static _buildSelfFallback(scope: RankBoardScope, user: IUserInfo | null, selfUserId: string): RankBoardEntry { const score = scope === RankBoardScope.DailyWins ? DailyWinStats.getTodayWins() : RankDataManager.getRankStars(); const display = this._getSelfDisplayProfile(user); return { rank: 0, score, nickname: display.nickname, avatarUrl: display.avatarUrl, region: display.region, userId: selfUserId || 'self', isSelf: true, }; } /** * 授权昵称转 UTF-8 Base64,避免特殊字符破坏存档。 * 不依赖 btoa/atob(抖音等小游戏环境没有),统一走自实现。 * @returns 编码结果;失败返回空串 */ private static _encodeNick(nick: string): string { if (!nick) return ''; try { return this._base64Encode(this._utf8ToBinary(nick)); } catch (e) { console.warn('[RankBoardManager] 昵称 Base64 编码异常', e); return ''; } } /** * 存档/榜单昵称解码。旧档明文(非 Base64)原样返回。 * 纯英文词(如 liweimin)恰好符合 Base64 形态会被误判,解码失败/乱码时按明文原样返回。 */ private static _decodeNick(raw: string): string { if (!raw) return ''; if (!this._isBase64Text(raw)) return raw; try { const binary = this._base64Decode(raw); if (!binary) return raw; const text = this._binaryToUtf8(binary); // 解码出控制字符/替换符说明不是有效编码昵称(误判的明文),按明文返回 if (/[\u0000-\u001F\u007F\uFFFD]/.test(text)) return raw; return text; } catch { // 误判明文的解码失败属预期,静默按明文返回 return raw; } } /** latin1 字符串(每字符码 0-255)转 Base64;纯手写实现,不依赖 btoa */ private static _base64Encode(binary: string): string { const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; let out = ''; let i = 0; const len = binary.length; for (; i + 2 < len; i += 3) { const n = ((binary.charCodeAt(i) & 255) << 16) | ((binary.charCodeAt(i + 1) & 255) << 8) | (binary.charCodeAt(i + 2) & 255); out += CHARS.charAt((n >> 18) & 63) + CHARS.charAt((n >> 12) & 63) + CHARS.charAt((n >> 6) & 63) + CHARS.charAt(n & 63); } const remain = len - i; if (remain === 1) { const n = (binary.charCodeAt(i) & 255) << 16; out += CHARS.charAt((n >> 18) & 63) + CHARS.charAt((n >> 12) & 63) + '=='; } else if (remain === 2) { const n = ((binary.charCodeAt(i) & 255) << 16) | ((binary.charCodeAt(i + 1) & 255) << 8); out += CHARS.charAt((n >> 18) & 63) + CHARS.charAt((n >> 12) & 63) + CHARS.charAt((n >> 6) & 63) + '='; } return out; } /** Base64 转 latin1 字符串;纯手写实现,不依赖 atob。非法输入返回空串 */ private static _base64Decode(b64: string): string { const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const lookup: Record = {}; for (let i = 0; i < CHARS.length; i++) lookup[CHARS.charAt(i)] = i; const clean = b64.replace(/=+$/, ''); let out = ''; let buffer = 0; let bits = 0; for (let i = 0; i < clean.length; i++) { const v = lookup[clean.charAt(i)]; if (v === undefined) return ''; buffer = (buffer << 6) | v; bits += 6; if (bits >= 8) { bits -= 8; out += String.fromCharCode((buffer >> bits) & 255); } } return out; } /** 是否为标准 Base64 文本(含 padding) */ private static _isBase64Text(raw: string): boolean { if (raw.length < 4 || raw.length % 4 !== 0) return false; for (let i = 0; i < raw.length; i++) { const c = raw.charCodeAt(i); const isAZ = c >= 65 && c <= 90; const isaz = c >= 97 && c <= 122; const is09 = c >= 48 && c <= 57; const isPlus = c === 43; const isSlash = c === 47; const isPad = c === 61; if (isPad) { if (i < raw.length - 2) return false; continue; } if (!(isAZ || isaz || is09 || isPlus || isSlash)) return false; } return true; } /** UTF-8 字符串转 latin1 二进制(供 _base64Encode) */ private static _utf8ToBinary(text: string): string { const encoded = encodeURIComponent(text); let binary = ''; for (let i = 0; i < encoded.length; i++) { const ch = encoded.charAt(i); if (ch === '%' && i + 2 < encoded.length) { binary += String.fromCharCode(parseInt(encoded.slice(i + 1, i + 3), 16)); i += 2; } else { binary += ch; } } return binary; } /** latin1 二进制转 UTF-8 字符串(供 _base64Decode 结果) */ private static _binaryToUtf8(binary: string): string { let pct = ''; for (let i = 0; i < binary.length; i++) { const hex = binary.charCodeAt(i).toString(16); pct += '%' + (hex.length < 2 ? '0' + hex : hex); } return decodeURIComponent(pct); } }