From 00977f49b9cfb2881f6553b7f7142571658ffcfd Mon Sep 17 00:00:00 2001 From: TsFreddie Date: Fri, 17 Jan 2025 14:08:19 +0800 Subject: [PATCH] player map stats --- src/lib/components/Modal.svelte | 2 +- src/lib/components/TeeRender.svelte | 5 +- src/lib/ddnet/searches.ts | 62 +++++ src/lib/server/bots/handlers/maps.ts | 49 +--- src/routes/ddnet/maps/+page.svelte | 68 +---- src/routes/ddnet/players/+page.svelte | 17 +- .../ddnet/players/[name]/+page.server.ts | 10 +- src/routes/ddnet/players/[name]/+page.svelte | 244 ++++++++++++++++-- src/routes/ddnet/players/[name]/+page.ts | 11 +- 9 files changed, 315 insertions(+), 153 deletions(-) create mode 100644 src/lib/ddnet/searches.ts diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index 8e071ac..367fd24 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -12,7 +12,7 @@ (show = false)} onclick={(e) => { diff --git a/src/lib/components/TeeRender.svelte b/src/lib/components/TeeRender.svelte index 8f01368..31eae75 100644 --- a/src/lib/components/TeeRender.svelte +++ b/src/lib/components/TeeRender.svelte @@ -111,8 +111,8 @@ loadingSkin = 'default'; } } else { - skin = useDefault ? DEFAULT_SKIN : X_SPEC_SKIN; - loadingSkin = 'default'; + skin = X_SPEC_SKIN; + loadingSkin = 'cancelled'; } }; @@ -157,6 +157,7 @@ }); $effect(() => { + url; name; requestAnimationFrame(updateSkin); }); diff --git a/src/lib/ddnet/searches.ts b/src/lib/ddnet/searches.ts new file mode 100644 index 0000000..a01b0e3 --- /dev/null +++ b/src/lib/ddnet/searches.ts @@ -0,0 +1,62 @@ +export const checkMapName = (name: string, search: string) => { + if (!search) { + return true; + } + + let mapInitial = ''; + let mapNameNoSeparator = ''; + let prevIsUpper = false; + let prevIsSeparator = true; + for (let i = 0; i < name.length; i++) { + const char = name[i]; + const isUpper = !!char.match(/[A-Z]/); + const isLetter = isUpper || char.match(/[a-z]/); + const isSeparator = char == '-' || char == '_' || char == ' '; + const isNumber = char.match(/[0-9]/); + if (isUpper) { + if (!prevIsUpper || prevIsSeparator) { + mapInitial += char; + } + } else if (isLetter) { + if (prevIsSeparator) { + mapInitial += char; + } + } else if (isNumber) { + mapInitial += char; + } + prevIsUpper = isUpper; + prevIsSeparator = isSeparator; + if (!isSeparator) { + mapNameNoSeparator += char; + } + } + + const mapName = name.toLowerCase(); + const searchTextLower = search.toLowerCase(); + return ( + mapInitial.toLowerCase() == searchTextLower || + mapNameNoSeparator.toLowerCase().includes(searchTextLower) || + mapName.includes(searchTextLower) + ); +}; + +export const checkMapper = (mapper: string, search: string) => { + if (!search) { + return true; + } + + const mapperString = mapper || '不详'; + + if (search.startsWith('"') && search.endsWith('"')) { + // exact match + const mappers = (mapperString as string) + .split(',') + .flatMap((mapper) => mapper.split('&')) + .map((mapper) => mapper.trim()); + + search = search.slice(1, -1).toLowerCase(); + return mappers.some((mapper) => mapper.toLowerCase() == search); + } + + return mapperString.toLowerCase().includes(search.toLowerCase()); +}; diff --git a/src/lib/server/bots/handlers/maps.ts b/src/lib/server/bots/handlers/maps.ts index 9aa6ca1..70e9f7c 100644 --- a/src/lib/server/bots/handlers/maps.ts +++ b/src/lib/server/bots/handlers/maps.ts @@ -1,50 +1,9 @@ import { mapType, numberToStars } from '$lib/ddnet/helpers'; +import { checkMapName } from '$lib/ddnet/searches'; import { encodeAsciiURIComponent } from '$lib/link'; import { maps, type MapList } from '$lib/server/fetches/maps'; import type { Handler } from '../protocol/types'; -const checkMapName = (map: any, search: string) => { - if (!search) { - return true; - } - - let mapInitial = ''; - let mapNameNoSeparator = ''; - let prevIsUpper = false; - let prevIsSeparator = true; - for (let i = 0; i < map.name.length; i++) { - const char = map.name[i]; - const isUpper = char.match(/[A-Z]/); - const isLetter = isUpper || char.match(/[a-z]/); - const isSeparator = char == '-' || char == '_' || char == ' '; - const isNumber = char.match(/[0-9]/); - if (isUpper) { - if (!prevIsUpper || prevIsSeparator) { - mapInitial += char; - } - } else if (isLetter) { - if (prevIsSeparator) { - mapInitial += char; - } - } else if (isNumber) { - mapInitial += char; - } - prevIsUpper = isUpper; - prevIsSeparator = isSeparator; - if (!isSeparator) { - mapNameNoSeparator += char; - } - } - - const mapName = map.name.toLowerCase(); - const searchTextLower = search.toLowerCase(); - return ( - mapInitial.toLowerCase() == searchTextLower || - mapNameNoSeparator.toLowerCase().includes(searchTextLower) || - mapName.includes(searchTextLower) - ); -}; - export const handleMaps: Handler = async ({ reply, fetch, args }) => { const mapName = args.trim(); if (!mapName) { @@ -55,10 +14,10 @@ export const handleMaps: Handler = async ({ reply, fetch, args }) => { }); } - const mapData: any[] = await maps.fetch(); + const mapData = await maps.fetch(); - const filteredMaps = mapData.filter((map: any) => { - return checkMapName(map, mapName); + const filteredMaps = mapData.filter((map: (typeof mapData)[0]) => { + return checkMapName(map.name, mapName); }); if (filteredMaps.length == 0) { diff --git a/src/routes/ddnet/maps/+page.svelte b/src/routes/ddnet/maps/+page.svelte index e6f3282..01aaf3a 100644 --- a/src/routes/ddnet/maps/+page.svelte +++ b/src/routes/ddnet/maps/+page.svelte @@ -9,6 +9,7 @@ import { ddnetDate, mapType, numberToStars } from '$lib/ddnet/helpers'; import { browser } from '$app/environment'; import { tippy } from '$lib/tippy'; + import { checkMapName, checkMapper } from '$lib/ddnet/searches'; let maps: MapList = $state([]); let error = $state(); @@ -49,74 +50,11 @@ let paginatedMaps = $state([]); let totalPages = $state(1); - const checkMapName = (map: any, search: string) => { - if (!search) { - return true; - } - - let mapInitial = ''; - let mapNameNoSeparator = ''; - let prevIsUpper = false; - let prevIsSeparator = true; - for (let i = 0; i < map.name.length; i++) { - const char = map.name[i]; - const isUpper = char.match(/[A-Z]/); - const isLetter = isUpper || char.match(/[a-z]/); - const isSeparator = char == '-' || char == '_' || char == ' '; - const isNumber = char.match(/[0-9]/); - if (isUpper) { - if (!prevIsUpper || prevIsSeparator) { - mapInitial += char; - } - } else if (isLetter) { - if (prevIsSeparator) { - mapInitial += char; - } - } else if (isNumber) { - mapInitial += char; - } - prevIsUpper = isUpper; - prevIsSeparator = isSeparator; - if (!isSeparator) { - mapNameNoSeparator += char; - } - } - - const mapName = map.name.toLowerCase(); - const searchTextLower = search.toLowerCase(); - return ( - mapInitial.toLowerCase() == searchTextLower || - mapNameNoSeparator.toLowerCase().includes(searchTextLower) || - mapName.includes(searchTextLower) - ); - }; - - const checkMapper = (map: any, search: string) => { - if (!search) { - return true; - } - - const mapperString = map.mapper || '不详'; - - if (search.startsWith('"') && search.endsWith('"')) { - // exact match - const mappers = (mapperString as string) - .split(',') - .flatMap((mapper) => mapper.split('&')) - .map((mapper) => mapper.trim()); - - search = search.slice(1, -1).toLowerCase(); - return mappers.some((mapper) => mapper.toLowerCase() == search); - } - - return mapperString.toLowerCase().includes(search.toLowerCase()); - }; - $effect(() => { if (!Array.isArray(maps)) return; - const filteredMaps = maps.filter((map: any) => { - return checkMapName(map, searchName) && checkMapper(map, searchMapper); + const filteredMaps = maps.filter((map: (typeof maps)[0]) => { + return checkMapName(map.name, searchName) && checkMapper(map.mapper, searchMapper); }); totalPages = Math.ceil(filteredMaps.length / pageSize); diff --git a/src/routes/ddnet/players/+page.svelte b/src/routes/ddnet/players/+page.svelte index 330694e..de079ce 100644 --- a/src/routes/ddnet/players/+page.svelte +++ b/src/routes/ddnet/players/+page.svelte @@ -123,18 +123,11 @@ ]} /> - - -
+
{ if (ev.key == 'Enter') { @@ -149,6 +142,12 @@ > 查询玩家 +
diff --git a/src/routes/ddnet/players/[name]/+page.server.ts b/src/routes/ddnet/players/[name]/+page.server.ts index 599ed04..4bb5a7f 100644 --- a/src/routes/ddnet/players/[name]/+page.server.ts +++ b/src/routes/ddnet/players/[name]/+page.server.ts @@ -32,7 +32,6 @@ interface MapData { pending?: boolean; }; }; - pending_points?: number; } export const load = (async ({ fetch, parent, params, setHeaders }) => { @@ -145,20 +144,25 @@ export const load = (async ({ fetch, parent, params, setHeaders }) => { // don't count already finished maps if (targetMap.first_finish) continue; + const mapFinishInfo = player.last_finishes.find((finish) => finish.map == map.name); + if (!mapFinishInfo) continue; + targetMap.finishes = 1; + targetMap.first_finish = mapFinishInfo.timestamp; targetMap.pending = true; // find the first finish time from the last finish list - const time = player.last_finishes.find((finish) => finish.map == map.name)?.time; + const time = mapFinishInfo.time; targetMap.time = time ?? undefined; const points = targetMap.points; if (points) { player.pending_points = (player.pending_points || 0) + points; - type.pending_points = (type.pending_points || 0) + points; } + // if player has played more than 10 maps in the last 24 hours, + // we can't garantee the estimated points are accurate. const lastFinish = player.last_finishes[player.last_finishes.length - 1]; if (lastFinish && Date.now() / 1000 - lastFinish.timestamp < 24 * 60 * 60) { player.pending_unknown = true; diff --git a/src/routes/ddnet/players/[name]/+page.svelte b/src/routes/ddnet/players/[name]/+page.svelte index 6875ef3..8b2f759 100644 --- a/src/routes/ddnet/players/[name]/+page.svelte +++ b/src/routes/ddnet/players/[name]/+page.svelte @@ -9,18 +9,73 @@ import TeeRender from '$lib/components/TeeRender.svelte'; import { secondsToDate } from '$lib/date'; import { mapType } from '$lib/ddnet/helpers'; + import { checkMapName } from '$lib/ddnet/searches.js'; import { secondsToTime } from '$lib/helpers'; import { encodeAsciiURIComponent } from '$lib/link.js'; import { share } from '$lib/share'; import { tippy } from '$lib/tippy'; - import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; + import { faMap, faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; import { Chart } from 'chart.js/auto'; + import { onMount } from 'svelte'; import Fa from 'svelte-fa'; + import VirtualScroll from 'svelte-virtual-scroll-list'; let { data } = $props(); let explaination = $state(false); - let showModal = $state(false); + let pointModal = $state(false); + let mapModal = $state(false); + let searchMap = $state(''); + let filterType = $state('all'); + let sortType = $state('finish'); + + let filteredMaps = $derived(() => { + if (!searchMap) { + let maps = [...data.maps]; + if (filterType != 'all') { + maps = maps.filter((map) => map.type.toLowerCase().startsWith(filterType)); + } + if (sortType == 'unfinished') { + maps = maps.filter((map) => !map.map.first_finish); + } + if (sortType == 'rank') { + maps.sort((a, b) => { + const aRank = Math.min(a.map.team_rank || Infinity, a.map.rank || Infinity); + const bRank = Math.min(b.map.team_rank || Infinity, b.map.rank || Infinity); + if (aRank == bRank) { + if (a.map.first_finish == b.map.first_finish) { + return a.name.localeCompare(b.name); + } + return (b.map.first_finish || 0) - (a.map.first_finish || 0); + } + return aRank - bRank; + }); + } else if (sortType == 'point') { + maps.sort((a, b) => { + const aPoint = a.map.first_finish ? a.map.points : 0; + const bPoint = b.map.first_finish ? b.map.points : 0; + if (aPoint == bPoint) { + if (a.map.first_finish == b.map.first_finish) { + return a.name.localeCompare(b.name); + } + return (b.map.first_finish || 0) - (a.map.first_finish || 0); + } + return bPoint - aPoint; + }); + } else if (sortType == 'name') { + maps.sort((a, b) => a.name.localeCompare(b.name)); + } else if (sortType == 'finish') { + maps.sort((a, b) => { + if (a.map.first_finish == b.map.first_finish) { + return a.name.localeCompare(b.name); + } + return (b.map.first_finish || 0) - (a.map.first_finish || 0); + }); + } + return maps; + } + return data.maps.filter((map) => checkMapName(map.name, searchMap)); + }); const hoursToColor = (value: number) => { const weight = value / 24; @@ -38,12 +93,16 @@ title: data.player.player, desc: `玩家信息:${data.player.points.points}pts` }); + + filterType = 'all'; + sortType = 'finish'; + searchMap = ''; }); - $effect(() => { - data.growth; + let chart: Chart | null = null; - const chart = new Chart(document.getElementById('growth-chart') as HTMLCanvasElement, { + onMount(() => { + chart = new Chart(document.getElementById('growth-chart') as HTMLCanvasElement, { type: 'line', options: { maintainAspectRatio: false, @@ -72,19 +131,9 @@ } }, data: { - labels: data.growth.map((_, index) => { - return new Date((data.endOfDay - (364 - index) * 24 * 60 * 60) * 1000).toLocaleDateString( - 'zh-CN', - { - dateStyle: 'short' - } - ); - }), datasets: [ { - data: data.growth.map((point, index) => { - return [index, point]; - }), + data: [], segment: { borderDash: (ctx) => (ctx.p1.parsed.y == 0 ? [5, 5] : undefined) }, @@ -98,6 +147,25 @@ } }); }); + + $effect(() => { + data.growth; + + if (chart) { + chart.data.labels = data.growth.map((_, index) => { + return new Date((data.endOfDay - (364 - index) * 24 * 60 * 60) * 1000).toLocaleDateString( + 'zh-CN', + { + dateStyle: 'short' + } + ); + }); + chart.data.datasets[0].data = data.growth.map((point, index) => { + return [index, point]; + }); + chart.update(); + } + });
+ 分数说明
-
+

玩家信息 {/if}

-
+

玩家数据

{#each data.statsCols as col, i} @@ -311,7 +385,7 @@ {/each}
-
+

玩家活跃

@@ -379,6 +453,132 @@
- + + + +
+

过图数据

+
+
+ + + +
+
+ +
+ 地图 + 分数 + 记录 + 完成 +
+
+ +
到底了
+ {@const isTeamTop10 = data.map.team_rank && data.map.team_rank <= 10} + {@const isRankTop10 = data.map.rank && data.map.rank <= 10} +
+ {data.name} + + {data.map.points} + {#if data.map.pending} + + + {:else} + + + {/if} + {data.map.time ? secondsToTime(data.map.time) : ''} + + {data.map.first_finish + ? new Date(data.map.first_finish * 1000).toLocaleString('zh-CN', { + dateStyle: 'short', + timeStyle: 'short' + }) + : ''} +
+
+
+
+
+
+
diff --git a/src/routes/ddnet/players/[name]/+page.ts b/src/routes/ddnet/players/[name]/+page.ts index e6b4937..6ca2ab6 100644 --- a/src/routes/ddnet/players/[name]/+page.ts +++ b/src/routes/ddnet/players/[name]/+page.ts @@ -106,11 +106,9 @@ export const load = (async ({ data, parent }) => { // setup growth const maps = Object.keys(player.types) .flatMap((type) => - Object.values(player.types[type].maps) - .filter((map) => map.first_finish && map.points) - .map((map) => ({ p: map.points, t: map.first_finish! })) + Object.entries(player.types[type].maps).map(([name, map]) => ({ name, type, map })) ) - .sort((a, b) => b.t - a.t); + .sort((a, b) => (b.map.first_finish || 0) - (a.map.first_finish || 0)); // points of last 365 days let currentPoints = player.points.points; @@ -121,8 +119,8 @@ export const load = (async ({ data, parent }) => { const growth: number[] = []; for (let i = 0; i < 365; i++) { - while (maps[mapIndex] && maps[mapIndex].t >= currentDate) { - currentPoints -= maps[mapIndex].p; + while (maps[mapIndex] && (maps[mapIndex].map.first_finish || 0) >= currentDate) { + currentPoints -= maps[mapIndex].map.points; mapIndex++; } growth.push(currentPoints); @@ -151,6 +149,7 @@ export const load = (async ({ data, parent }) => { ranks, growth, endOfDay, + maps, ...(await parent()) }; }) satisfies PageLoad;