You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
AdLike2048/assets/script/manager/RankBoardManager.ts

1125 lines
44 KiB

3 weeks ago
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<boolean> | null = null;
/** 日榜上传世代:跨天或新请求作废未完成的旧上传 */
private static _dailyWinsUploadGen = 0;
/** 三榜预拉取缓存(进大厅静默写入,打开面板直接读) */
private static _boardCache = new Map<RankBoardScope, BoardCacheItem>();
private static _fetchInflight = new Map<RankBoardScope, Promise<RankBoardResult>>();
private static _warmupInflight: Promise<void> | null = null;
private static _locateInflight: Promise<string> | 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<boolean> {
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<boolean> {
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<boolean> {
return this.ensureRankUserAuth();
}
private static _pullRankUserInfo(): Promise<{ userInfo?: Record<string, unknown> } | null> {
return new Promise((resolve) => {
gg.sdk.pullRankUserInfo((info) => resolve(info ?? null));
});
}
/** 调试用:拉取结果摘要(昵称/头像截断) */
private static _describeInfo(info: { userInfo?: Record<string, unknown> } | 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<string, unknown> | null | undefined): boolean {
return this._saveUserFromInfo(userInfo ? { userInfo } : null);
}
private static async _finishUserInfoAuth(
infoP: Promise<{ userInfo?: Record<string, unknown> } | null>,
opts?: RankUserAuthOpts,
): Promise<boolean> {
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<string, unknown> } | 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<void> {
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<void> {
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<void> {
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<void> {
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<RankBoardResult> {
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<RankBoardResult> {
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<string> {
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<string> {
return this._ensureProvinceSaved({ allowOpenSetting: false });
}
/**
* 定位授权拿到省份后写入存档,已有省份则不再变更。
*/
private static _ensureProvinceSaved(opts?: { allowOpenSetting?: boolean }): Promise<string> {
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<string> {
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<string> {
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<string, unknown> } | null> {
return new Promise((resolve) => {
gg.sdk.getSetting((res) => resolve(res ?? null));
});
}
private static _openSetting(): Promise<boolean> {
return new Promise((resolve) => {
gg.sdk.openSetting((ok) => resolve(!!ok));
});
}
/** 设置页中该 scope 是否已授权 */
private static async _isScopeGranted(scope: string): Promise<boolean> {
const setting = await this._getSetting();
return !!setting?.authSetting?.[scope];
}
/**
* 申请定位授权;可选失败后打开设置,返回后以 authSetting 为准。
* @param allowOpenSetting 授权失败是否打开系统设置页
*/
private static async _authorizeLocation(allowOpenSetting: boolean): Promise<boolean> {
if (await this._isScopeGranted('scope.userLocation')) return true;
const granted = await new Promise<boolean>((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<unknown> {
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<string, unknown>;
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<string, unknown> {
if (!raw) return {};
if (typeof raw === 'string') {
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
}
if (typeof raw !== 'object') return {};
const o = raw as Record<string, unknown>;
if (o.data && typeof o.data === 'object') {
return o.data as Record<string, unknown>;
}
return o;
}
private static _pickArray(root: Record<string, unknown>, keys: string[]): unknown[] {
for (const k of keys) {
const v = root[k];
if (Array.isArray(v)) return v;
}
return [];
}
private static _pickObject(root: Record<string, unknown>, keys: string[]): Record<string, unknown> | null {
for (const k of keys) {
const v = root[k];
if (v && typeof v === 'object' && !Array.isArray(v)) {
return v as Record<string, unknown>;
}
}
return null;
}
private static async _resolveSelfUserId(): Promise<string> {
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<IUserInfo | null> {
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(抖音等小游戏环境没有),统一走自实现。
3 weeks ago
* @returns 编码结果;失败返回空串
*/
private static _encodeNick(nick: string): string {
if (!nick) return '';
try {
return this._base64Encode(this._utf8ToBinary(nick));
3 weeks ago
} 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);
3 weeks ago
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 */
3 weeks ago
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。非法输入返回空串 */
3 weeks ago
private static _base64Decode(b64: string): string {
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const lookup: Record<string, number> = {};
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) */
3 weeks ago
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 结果) */
3 weeks ago
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);
}
}