import { LitElement, PropertyValues, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { PlayerStatsLeaf, PlayerStatsTree } from "../../../../core/ApiSchemas"; import { Difficulty, GameMode, GameType, RankedType, isDifficulty, isGameMode, isGameType, } from "../../../../core/game/Game"; import { PlayerStats } from "../../../../core/StatsSchemas"; import { renderNumber, translateText } from "../../../Utils"; import "./PlayerStatsGrid"; import "./PlayerStatsTable"; @customElement("player-stats-tree-view") export class PlayerStatsTreeView extends LitElement { @property({ type: Object }) statsTree?: PlayerStatsTree; @state() selectedType: GameType | "Ranked" = GameType.Public; @state() selectedMode: GameMode = GameMode.FFA; @state() selectedDifficulty: Difficulty = Difficulty.Medium; @state() selectedRankedType: RankedType = RankedType.OneVOne; private get typeNode() { if (this.selectedType === "Ranked") return undefined; return this.statsTree?.[this.selectedType]; } private get modeNode() { return this.typeNode?.[this.selectedMode]; } private get shouldMergeDifficulties() { return this.selectedType === GameType.Public; } private get availableTypes(): (GameType | "Ranked")[] { if (!this.statsTree) return []; const types: (GameType | "Ranked")[] = Object.keys(this.statsTree).filter( (k): k is GameType => isGameType(k) && Object.keys(this.statsTree![k as GameType] ?? {}).length > 0, ); if ( this.statsTree.Ranked && Object.keys(this.statsTree.Ranked).length > 0 ) { types.push("Ranked"); } return types; } private get availableModes(): GameMode[] { if (!this.typeNode) return []; return Object.keys(this.typeNode).filter(isGameMode); } private get availableRankedTypes(): RankedType[] { if (!this.statsTree?.Ranked) return []; return Object.keys(this.statsTree.Ranked).filter((k): k is RankedType => Object.values(RankedType).includes(k as RankedType), ); } private get availableDifficulties(): Difficulty[] { if (!this.modeNode) return []; return Object.keys(this.modeNode).filter(isDifficulty); } private labelForMode(m: GameMode) { return m === GameMode.FFA ? translateText("game_mode.ffa") : translateText("game_mode.teams"); } private labelForRankedType(r: RankedType) { switch (r) { case RankedType.OneVOne: return translateText("player_stats_tree.ranked_1v1"); } } createRenderRoot() { return this; } private getSelectedLeaf(): PlayerStatsLeaf | null { if (this.selectedType === "Ranked") { return this.statsTree?.Ranked?.[this.selectedRankedType] ?? null; } const modeNode = this.modeNode; if (!modeNode) return null; if (!this.shouldMergeDifficulties) { return modeNode[this.selectedDifficulty] ?? null; } const diffKeys = Object.keys(modeNode).filter(isDifficulty); if (!diffKeys.length) return null; return diffKeys.reduce((merged, diffKey) => { const leaf = modeNode[diffKey]; if (!leaf) return merged; if (!merged) { return { wins: leaf.wins, losses: leaf.losses, total: leaf.total, stats: this.cloneStats(leaf.stats), }; } return { wins: merged.wins + leaf.wins, losses: merged.losses + leaf.losses, total: merged.total + leaf.total, stats: this.mergeStats(merged.stats, leaf.stats), }; }, null); } private syncSelection(): void { const types = this.availableTypes; if (types.length && !types.includes(this.selectedType as GameType)) { this.selectedType = types[0]; } if (this.selectedType === "Ranked") { const rankedTypes = this.availableRankedTypes; if ( rankedTypes.length && !rankedTypes.includes(this.selectedRankedType) ) { this.selectedRankedType = rankedTypes[0]; } return; } const modes = this.availableModes; if (modes.length && !modes.includes(this.selectedMode)) { this.selectedMode = modes[0]; } const diffs = this.availableDifficulties; if ( !this.shouldMergeDifficulties && diffs.length && !diffs.includes(this.selectedDifficulty) ) { this.selectedDifficulty = diffs[0]; } } protected willUpdate(changedProperties: PropertyValues) { if ( changedProperties.has("statsTree") || changedProperties.has("selectedType") || changedProperties.has("selectedMode") || changedProperties.has("selectedDifficulty") || changedProperties.has("selectedRankedType") ) { this.syncSelection(); } } private setGameType(t: GameType | "Ranked") { if (this.selectedType === t) return; this.selectedType = t; this.requestUpdate(); } private setMode(m: GameMode) { if (this.selectedMode === m) return; this.selectedMode = m; this.requestUpdate(); } private setRankedType(r: RankedType) { if (this.selectedRankedType === r) return; this.selectedRankedType = r; this.requestUpdate(); } private setDifficulty(d: Difficulty) { if (this.selectedDifficulty === d) return; this.selectedDifficulty = d; this.requestUpdate(); } private mergeStats( base: PlayerStats | undefined, next: PlayerStats | undefined, ): PlayerStats | undefined { if (!base && !next) return undefined; if (!base) return this.cloneStats(next); if (!next) return this.cloneStats(base); return { attacks: this.mergeStatArrays(base.attacks, next.attacks), betrayals: this.mergeStatValue(base.betrayals, next.betrayals), killedAt: this.mergeStatValue(base.killedAt, next.killedAt), conquests: this.mergeStatArrays(base.conquests, next.conquests), boats: this.mergeStatRecord(base.boats, next.boats), bombs: this.mergeStatRecord(base.bombs, next.bombs), gold: this.mergeStatArrays(base.gold, next.gold), units: this.mergeStatRecord(base.units, next.units), }; } private mergeStatValue( base: bigint | undefined, next: bigint | undefined, ): bigint | undefined { if (base === undefined && next === undefined) return undefined; return (base ?? 0n) + (next ?? 0n); } private mergeStatArrays( base: bigint[] | undefined, next: bigint[] | undefined, ): bigint[] | undefined { if (!base && !next) return undefined; const maxLen = Math.max(base?.length ?? 0, next?.length ?? 0); const merged: bigint[] = []; for (let i = 0; i < maxLen; i += 1) { merged[i] = (base?.[i] ?? 0n) + (next?.[i] ?? 0n); } return merged; } private mergeStatRecord( base: Partial> | undefined, next: Partial> | undefined, ): Partial> | undefined { if (!base && !next) return undefined; const merged: Partial> = {}; const keys = new Set([ ...Object.keys(base ?? {}), ...Object.keys(next ?? {}), ]) as Set; keys.forEach((key) => { const mergedArray = this.mergeStatArrays(base?.[key], next?.[key]); if (mergedArray) { merged[key] = mergedArray; } }); return Object.keys(merged).length ? merged : undefined; } private cloneStats(stats: PlayerStats | undefined): PlayerStats | undefined { if (!stats) return undefined; return { attacks: stats.attacks ? [...stats.attacks] : undefined, betrayals: stats.betrayals, killedAt: stats.killedAt, conquests: stats.conquests ? [...stats.conquests] : undefined, boats: stats.boats ? { ...stats.boats } : undefined, bombs: stats.bombs ? { ...stats.bombs } : undefined, gold: stats.gold ? [...stats.gold] : undefined, units: stats.units ? { ...stats.units } : undefined, }; } render() { const types = this.availableTypes; const modes = this.availableModes; const diffs = this.availableDifficulties; const rankedTypes = this.availableRankedTypes; const leaf = this.getSelectedLeaf(); const wlr = leaf ? leaf.losses === 0n ? Number(leaf.wins) : Number(leaf.wins) / Number(leaf.losses) : 0; return html`
${types.map( (t) => html` `, )}
${this.selectedType === "Ranked" && rankedTypes.length ? html`
${rankedTypes.map( (r) => html` `, )}
` : html``} ${modes.length ? html`
${modes.map( (m) => html` `, )}
` : html``} ${!this.shouldMergeDifficulties && diffs.length ? html`
${diffs.map( (d) => html` `, )}
` : html``}
${leaf ? html`
` : html`
${translateText("player_stats_tree.no_stats")}
`}
`; } }