From f52cc515a13f0aa1eddfd18f3ef0890890ded7a2 Mon Sep 17 00:00:00 2001 From: zk Date: Mon, 18 May 2026 19:31:54 +0100 Subject: [PATCH] emerg: scoreID must use legacyChartID to maintain stability --- Dockerfile.dev | 2 +- typescript/common/src/types/documents.ts | 2 + .../server/src/lib/db-formats/chart.test.ts | 1 + typescript/server/src/lib/db-formats/chart.ts | 2 + .../src/lib/orphan-queue/deorphan-bms-pg.ts | 2 +- .../src/lib/orphan-queue/orphan-queue.ts | 2 +- .../__snapshots__/score-id.test.ts.snap | 274 ++++++++++++++ .../score-importing/score-id-fixtures.ts | 221 +++++++++++ .../score-importing/score-id.test.ts | 67 +++- .../framework/score-importing/score-id.ts | 22 +- .../score-importing/score-importing.ts | 2 +- .../api/myt-chunithm/converter.test.ts | 2 +- .../import-types/ir/beatoraja/converter.ts | 2 + .../ir/fervidex/converter.test.ts | 2 +- .../import-types/ir/fervidex/converter.ts | 2 +- .../src/scripts/compare-score-id-feb1.ts | 353 ++++++++++++++++++ typescript/server/src/test-utils/test-data.ts | 21 ++ 17 files changed, 952 insertions(+), 27 deletions(-) create mode 100644 typescript/server/src/lib/score-import/framework/score-importing/__snapshots__/score-id.test.ts.snap create mode 100644 typescript/server/src/lib/score-import/framework/score-importing/score-id-fixtures.ts create mode 100644 typescript/server/src/scripts/compare-score-id-feb1.ts diff --git a/Dockerfile.dev b/Dockerfile.dev index 5875aca38..7ec677817 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -25,7 +25,7 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update \ # postgres client tools (psql, pg_dump, pg_restore, pg_isready, etc.) postgresql-client-18 \ # nice to haves - gh fish just fzf curl wget parallel neovim fd-find bat rsync \ + gh fish just fzf curl wget parallel neovim fd-find bat ripgrep rsync \ # MinIO CLI (binary is `minio-client`, not Midnight Commander's `mc`) minio-client \ # uninstall lynx so people don't get a CLI browser that can't load anything when they do `gh auth login` diff --git a/typescript/common/src/types/documents.ts b/typescript/common/src/types/documents.ts index 1d35bb267..9db952902 100644 --- a/typescript/common/src/types/documents.ts +++ b/typescript/common/src/types/documents.ts @@ -353,6 +353,8 @@ export interface ChartTierlistInfo { export interface ChartDocument { game: TGame; chartID: string; + /** Mongo-era 40-character chart identifier; used for score deduplication. */ + legacyChartID: string; level: string; levelNum: number; isPrimary: boolean; diff --git a/typescript/server/src/lib/db-formats/chart.test.ts b/typescript/server/src/lib/db-formats/chart.test.ts index f29ed7e3f..93ddaa80e 100644 --- a/typescript/server/src/lib/db-formats/chart.test.ts +++ b/typescript/server/src/lib/db-formats/chart.test.ts @@ -61,6 +61,7 @@ describe("GetChartById", () => { expect(c).toBeDefined(); expect(c!.game).toBe("iidx-sp"); expect(c!.chartID).toBe(CHART_ID); + expect(c!.legacyChartID).toBe(CHART_LEGACY_ID); expect(c!.song.id).toBe(SONG_PG_ID); expect(c!.versions).toContain("27"); }); diff --git a/typescript/server/src/lib/db-formats/chart.ts b/typescript/server/src/lib/db-formats/chart.ts index 916742800..b011ad328 100644 --- a/typescript/server/src/lib/db-formats/chart.ts +++ b/typescript/server/src/lib/db-formats/chart.ts @@ -16,6 +16,7 @@ import { SELECT_SONG_DOCUMENT } from "./song"; export const SELECT_CHART = [ "chart.id as chart_id", + "chart.legacy_id as chart_legacy_id", "chart.game as chart_game", "chart.level as chart_level", "chart.level_num as chart_level_num", @@ -33,6 +34,7 @@ export function ToChartDocument(row: ChartRow): ChartDocument { return { game: row.chart_game, chartID: row.chart_id, + legacyChartID: row.chart_legacy_id, song: { altTitles: row.song_alt_titles, artist: row.song_artist, diff --git a/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts b/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts index 46aa99a21..6782bb969 100644 --- a/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts +++ b/typescript/server/src/lib/orphan-queue/deorphan-bms-pg.ts @@ -68,7 +68,7 @@ export async function DeorphanBmsIfInOrphanChartPg( .insertInto("chart") .values({ id: chartDoc.chartID, - legacy_id: chartDoc.chartID, + legacy_id: chartDoc.legacyChartID, game, song_id: songNewID, level: chartDoc.level, diff --git a/typescript/server/src/lib/orphan-queue/orphan-queue.ts b/typescript/server/src/lib/orphan-queue/orphan-queue.ts index ae369e34a..c4a205703 100644 --- a/typescript/server/src/lib/orphan-queue/orphan-queue.ts +++ b/typescript/server/src/lib/orphan-queue/orphan-queue.ts @@ -94,7 +94,7 @@ async function writeBmsPmsSongAndChart( .insertInto("chart") .values({ id: chartDoc.chartID, - legacy_id: chartDoc.chartID, + legacy_id: chartDoc.legacyChartID, game: v3Game, song_id: songNewID, level: chartDoc.level, diff --git a/typescript/server/src/lib/score-import/framework/score-importing/__snapshots__/score-id.test.ts.snap b/typescript/server/src/lib/score-import/framework/score-importing/__snapshots__/score-id.test.ts.snap new file mode 100644 index 000000000..e9812bb40 --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/score-importing/__snapshots__/score-id.test.ts.snap @@ -0,0 +1,274 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CreateScoreID static output > arcaea produces stable score ids 1`] = ` +{ + "baseline": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "chartID": "Tc855e4d5df609a3ac6aceccd0aaf75454aecd8091513bde9978a270ae26b40f8", + "optional.fast.ignored": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "provided.lamp": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "provided.score": "T77c242b448574d58745a9b5c36bf67e769ee420c2f23c3f0837877cdefe37fda", + "userID": "T542bc5112a0a0342be56856bb38c80ebfa14286a7e951578680bc78ff957a134", +} +`; + +exports[`CreateScoreID static output > bms-7k produces stable score ids 1`] = ` +{ + "baseline": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "chartID": "Tc855e4d5df609a3ac6aceccd0aaf75454aecd8091513bde9978a270ae26b40f8", + "optional.fast.ignored": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "provided.lamp": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "provided.score": "T77c242b448574d58745a9b5c36bf67e769ee420c2f23c3f0837877cdefe37fda", + "userID": "T542bc5112a0a0342be56856bb38c80ebfa14286a7e951578680bc78ff957a134", +} +`; + +exports[`CreateScoreID static output > bms-14k produces stable score ids 1`] = ` +{ + "baseline": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "chartID": "Tc855e4d5df609a3ac6aceccd0aaf75454aecd8091513bde9978a270ae26b40f8", + "optional.fast.ignored": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "provided.lamp": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "provided.score": "T77c242b448574d58745a9b5c36bf67e769ee420c2f23c3f0837877cdefe37fda", + "userID": "T542bc5112a0a0342be56856bb38c80ebfa14286a7e951578680bc78ff957a134", +} +`; + +exports[`CreateScoreID static output > chunithm produces stable score ids 1`] = ` +{ + "baseline": "T34db52d521109b3a8889ab3b5354fef08f9b39ab6da62eb6240c868035affe93", + "chartID": "T343ef4bf7d6bd59590247a5c7c51b2fc99a45be5666a99c282d17232a7a50ed7", + "optional.fast.ignored": "T34db52d521109b3a8889ab3b5354fef08f9b39ab6da62eb6240c868035affe93", + "provided.clearLamp": "T725933011ed88177e9a6d6785ec1f05789e2a50490216b3bd362f9a148266a87", + "provided.noteLamp": "Tdb1dfc3587098c967a9831fde32da01fd2a7e54af9819d1cb51f01d0da55c0cd", + "provided.score": "T082194b0a2b91ad226d6aa1705e6dd1def4485e6b63bb57030bc8f9b05657ae6", + "userID": "T05f3250757ccea76b266f478c51dadf3a704234a3006dbe24362468648ac6a14", +} +`; + +exports[`CreateScoreID static output > ddr-dp produces stable score ids 1`] = ` +{ + "baseline": "Tefde7e5b2f221fb4522c8d02033e0d7e7c4f14e47c18978e9054d4412d743ebd", + "chartID": "T475d196f662d46fca9bc65e29e495cead9d19fedfc541e67a7841027169ee505", + "optional.exScore.null": "T17a4b7a38a565b747be4a838bbe1cb996a817b61f3397d7befd9f4422026a110", + "optional.exScore.set": "Tefde7e5b2f221fb4522c8d02033e0d7e7c4f14e47c18978e9054d4412d743ebd", + "optional.exScore.unset": "T17a4b7a38a565b747be4a838bbe1cb996a817b61f3397d7befd9f4422026a110", + "optional.flare.ignored": "Tefde7e5b2f221fb4522c8d02033e0d7e7c4f14e47c18978e9054d4412d743ebd", + "provided.lamp": "T80cbc715b7acc9a7b3a029b2912e4c0f6e020109be46780a42c877654ef1ece8", + "provided.score": "T4283ad151d4659d35ef56ef18ae4b5be2f8b2d094c7105666ef92b02d78e0d4f", + "userID": "Tde9d8c058751a41e5e278be598968e52d9ce1c74c28b5f426b6390b76e15acf7", +} +`; + +exports[`CreateScoreID static output > ddr-sp produces stable score ids 1`] = ` +{ + "baseline": "Tefde7e5b2f221fb4522c8d02033e0d7e7c4f14e47c18978e9054d4412d743ebd", + "chartID": "T475d196f662d46fca9bc65e29e495cead9d19fedfc541e67a7841027169ee505", + "optional.exScore.null": "T17a4b7a38a565b747be4a838bbe1cb996a817b61f3397d7befd9f4422026a110", + "optional.exScore.set": "Tefde7e5b2f221fb4522c8d02033e0d7e7c4f14e47c18978e9054d4412d743ebd", + "optional.exScore.unset": "T17a4b7a38a565b747be4a838bbe1cb996a817b61f3397d7befd9f4422026a110", + "optional.flare.ignored": "Tefde7e5b2f221fb4522c8d02033e0d7e7c4f14e47c18978e9054d4412d743ebd", + "provided.lamp": "T80cbc715b7acc9a7b3a029b2912e4c0f6e020109be46780a42c877654ef1ece8", + "provided.score": "T4283ad151d4659d35ef56ef18ae4b5be2f8b2d094c7105666ef92b02d78e0d4f", + "userID": "Tde9d8c058751a41e5e278be598968e52d9ce1c74c28b5f426b6390b76e15acf7", +} +`; + +exports[`CreateScoreID static output > gitadora-dora produces stable score ids 1`] = ` +{ + "baseline": "T5db96789ad7a73096158cb1eb57381d423bf8dd5ad4fd0c0bf4e63883e3d8ac0", + "chartID": "T9561c3da01a13f78fdb402026c8424d5a754159e894927bb3118a12064e37b17", + "optional.fast.ignored": "T5db96789ad7a73096158cb1eb57381d423bf8dd5ad4fd0c0bf4e63883e3d8ac0", + "provided.lamp": "Te6a1f9342cefc623314a5ae8f7c4b37f77ce236023540dd94743cdc959189179", + "provided.percent": "T809dbd0b965b67e6b99fdda40d96290100b8f41876f79eeb0d68ed240cfb0f42", + "userID": "Te2fecd654e16a3ca7754f7493d0f186f5cfaa7d4df1504558e41fcd2a65233e5", +} +`; + +exports[`CreateScoreID static output > gitadora-gita produces stable score ids 1`] = ` +{ + "baseline": "T5db96789ad7a73096158cb1eb57381d423bf8dd5ad4fd0c0bf4e63883e3d8ac0", + "chartID": "T9561c3da01a13f78fdb402026c8424d5a754159e894927bb3118a12064e37b17", + "optional.fast.ignored": "T5db96789ad7a73096158cb1eb57381d423bf8dd5ad4fd0c0bf4e63883e3d8ac0", + "provided.lamp": "Te6a1f9342cefc623314a5ae8f7c4b37f77ce236023540dd94743cdc959189179", + "provided.percent": "T809dbd0b965b67e6b99fdda40d96290100b8f41876f79eeb0d68ed240cfb0f42", + "userID": "Te2fecd654e16a3ca7754f7493d0f186f5cfaa7d4df1504558e41fcd2a65233e5", +} +`; + +exports[`CreateScoreID static output > iidx-dp produces stable score ids 1`] = ` +{ + "baseline": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "chartID": "Tc855e4d5df609a3ac6aceccd0aaf75454aecd8091513bde9978a270ae26b40f8", + "optional.fast.ignored": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "provided.lamp": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "provided.score": "T77c242b448574d58745a9b5c36bf67e769ee420c2f23c3f0837877cdefe37fda", + "userID": "T542bc5112a0a0342be56856bb38c80ebfa14286a7e951578680bc78ff957a134", +} +`; + +exports[`CreateScoreID static output > iidx-sp produces stable score ids 1`] = ` +{ + "baseline": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "chartID": "Tc855e4d5df609a3ac6aceccd0aaf75454aecd8091513bde9978a270ae26b40f8", + "optional.fast.ignored": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "provided.lamp": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "provided.score": "T77c242b448574d58745a9b5c36bf67e769ee420c2f23c3f0837877cdefe37fda", + "userID": "T542bc5112a0a0342be56856bb38c80ebfa14286a7e951578680bc78ff957a134", +} +`; + +exports[`CreateScoreID static output > itg-stamina produces stable score ids 1`] = ` +{ + "baseline": "Tdadb283e70ef31c8702bb89f837bc94436ae6ee7f244ab70ea2a39ae1fe9f7ec", + "chartID": "T6ca6f39f038396ecb849664b2c1835a5be822e6192e90b60214d5cd2002723ba", + "optional.lifebarHistory.ignored": "Tdadb283e70ef31c8702bb89f837bc94436ae6ee7f244ab70ea2a39ae1fe9f7ec", + "provided.lamp": "T2ab2790e85a14fc39a38e8061c80a3ef8a0d67bfe40a9433a4a0ba8f2680ce3d", + "provided.scorePercent": "T79c2380cac5b3d602354e86f961bf954e77366fa2d7143ac125a8781adf4b734", + "provided.survivedPercent": "Tbc57ec37a8ab4469b5c1fefab2a86434e8bc72518302fc6a2cfaff572f1bd820", + "userID": "Tca59e8a903c600ec370819d96cfde63aaa549704c8606109bee8d76a6ef0d20d", +} +`; + +exports[`CreateScoreID static output > jubeat produces stable score ids 1`] = ` +{ + "baseline": "T83e29d2893291c0cae7aefba2d9405c1d2c60ee98ee6ab1adf2c5cbcebb7550e", + "chartID": "T692dd9a1a68dcddb58c19415797dfabf1095d483a0f33367df299ae668c8cdf5", + "optional.fast.ignored": "T83e29d2893291c0cae7aefba2d9405c1d2c60ee98ee6ab1adf2c5cbcebb7550e", + "provided.lamp": "T97861ca816081d7071e0cfb8acaaa229deb0d11facd73c68380743594812c4b2", + "provided.musicRate": "T470a07c210911b79f8fa3b11fb4613d10fd9b5e8020e37c10e47c29669cc0541", + "provided.score": "Tb21103a4c8034291988f0fe83e3d4b653e40b027994e43d23855c7b8a32739b8", + "userID": "Te92e20c8d9e7463bb8a79367281420bdc146d4c09e666f7056a89bd681481088", +} +`; + +exports[`CreateScoreID static output > maimai produces stable score ids 1`] = ` +{ + "baseline": "T5db96789ad7a73096158cb1eb57381d423bf8dd5ad4fd0c0bf4e63883e3d8ac0", + "chartID": "T9561c3da01a13f78fdb402026c8424d5a754159e894927bb3118a12064e37b17", + "optional.fast.ignored": "T5db96789ad7a73096158cb1eb57381d423bf8dd5ad4fd0c0bf4e63883e3d8ac0", + "provided.lamp": "Te6a1f9342cefc623314a5ae8f7c4b37f77ce236023540dd94743cdc959189179", + "provided.percent": "T809dbd0b965b67e6b99fdda40d96290100b8f41876f79eeb0d68ed240cfb0f42", + "userID": "Te2fecd654e16a3ca7754f7493d0f186f5cfaa7d4df1504558e41fcd2a65233e5", +} +`; + +exports[`CreateScoreID static output > maimaidx produces stable score ids 1`] = ` +{ + "baseline": "T5db96789ad7a73096158cb1eb57381d423bf8dd5ad4fd0c0bf4e63883e3d8ac0", + "chartID": "T9561c3da01a13f78fdb402026c8424d5a754159e894927bb3118a12064e37b17", + "optional.fast.ignored": "T5db96789ad7a73096158cb1eb57381d423bf8dd5ad4fd0c0bf4e63883e3d8ac0", + "provided.lamp": "Te6a1f9342cefc623314a5ae8f7c4b37f77ce236023540dd94743cdc959189179", + "provided.percent": "T809dbd0b965b67e6b99fdda40d96290100b8f41876f79eeb0d68ed240cfb0f42", + "userID": "Te2fecd654e16a3ca7754f7493d0f186f5cfaa7d4df1504558e41fcd2a65233e5", +} +`; + +exports[`CreateScoreID static output > museca produces stable score ids 1`] = ` +{ + "baseline": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "chartID": "T17a81c58b451236c6f80c80848bef7d1e4dbaff88e4fac35638e966f686f5602", + "optional.fast.ignored": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "provided.lamp": "Te570c06f4431c29ff6493869c56749e259dc83fa19e9cfd7e02899a772152d9c", + "provided.score": "Tfd40722a31de045b97b7ae23b0d1613ebc9442ab27cdbdfe5252ef823a0ad94f", + "userID": "Tc18bba02eca24e8fa646f70f8d2d447caf9315b0b16efb9ac90ae8a798118848", +} +`; + +exports[`CreateScoreID static output > ongeki produces stable score ids 1`] = ` +{ + "baseline": "T14a20b47ff8adaa78a3c8fcc587996cccf9e69ff9e94d28ffc2a99a43b2e1173", + "chartID": "T1a31b6d75fa427ee4154981c5212abf0494db270b2f1d5d8472ff7cc3671ca1e", + "optional.bellCount.null": "T093f6dc7537e576dba449a2699246efa947ed8fa9f697cd3e4dcfeefc3ffe784", + "optional.bellCount.set": "T14a20b47ff8adaa78a3c8fcc587996cccf9e69ff9e94d28ffc2a99a43b2e1173", + "optional.bellCount.unset": "T093f6dc7537e576dba449a2699246efa947ed8fa9f697cd3e4dcfeefc3ffe784", + "optional.damage.null": "T66c94a56bb6d00a1ce8700b64adf5d6bcd7da022c6fc27ba98b80236e9d7fd1a", + "optional.damage.set": "T14a20b47ff8adaa78a3c8fcc587996cccf9e69ff9e94d28ffc2a99a43b2e1173", + "optional.damage.unset": "T66c94a56bb6d00a1ce8700b64adf5d6bcd7da022c6fc27ba98b80236e9d7fd1a", + "optional.fast.ignored": "T14a20b47ff8adaa78a3c8fcc587996cccf9e69ff9e94d28ffc2a99a43b2e1173", + "provided.bellLamp": "Tca16675649b342a148d57cfc2a966489f753bce05c721d22a331fe5b1848ac0a", + "provided.noteLamp": "Taa7a7095805bc712a33997530f0594c32aa67a4aad31d8040d52246d9aecd0b2", + "provided.platinumScore": "T2ada4cd09159bbc5affcb2ff1f13df6353e784382a1181a9d38b3178621496e9", + "provided.score": "T207b7cf7f0423818cc4a69f8b7ce111559b82ee71ab6cf64d7ad8afb41ce7d65", + "userID": "T8910462fc86dc4dd87878884784fb2aa5a63934cf121dcf0fe2832c9b0ce576a", +} +`; + +exports[`CreateScoreID static output > pms-controller produces stable score ids 1`] = ` +{ + "baseline": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "chartID": "Tc855e4d5df609a3ac6aceccd0aaf75454aecd8091513bde9978a270ae26b40f8", + "optional.fast.ignored": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "provided.lamp": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "provided.score": "T77c242b448574d58745a9b5c36bf67e769ee420c2f23c3f0837877cdefe37fda", + "userID": "T542bc5112a0a0342be56856bb38c80ebfa14286a7e951578680bc78ff957a134", +} +`; + +exports[`CreateScoreID static output > pms-keyboard produces stable score ids 1`] = ` +{ + "baseline": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "chartID": "Tc855e4d5df609a3ac6aceccd0aaf75454aecd8091513bde9978a270ae26b40f8", + "optional.fast.ignored": "T611ff4451dd7ff905e9468d128a73f05fa106bc80c75a86e132b51d819f9dd00", + "provided.lamp": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "provided.score": "T77c242b448574d58745a9b5c36bf67e769ee420c2f23c3f0837877cdefe37fda", + "userID": "T542bc5112a0a0342be56856bb38c80ebfa14286a7e951578680bc78ff957a134", +} +`; + +exports[`CreateScoreID static output > popn produces stable score ids 1`] = ` +{ + "baseline": "Tb4ad84adf0b4e18f32d696e3567887cd4607db61550d6fbd9fac0231b9f8e7bb", + "chartID": "T77807c70a4da0d1b5210b5ce8d18c09444555ffacd28d590509acb961c9de6c9", + "optional.fast.ignored": "Tb4ad84adf0b4e18f32d696e3567887cd4607db61550d6fbd9fac0231b9f8e7bb", + "provided.clearMedal": "T1e9d1165a2ccc2c1082d9bc266030a48fa66e140e86853670e292d3397446d27", + "provided.score": "T265b8102af7d36a80b44e33a10a8989b608e4014120da5148aa23aff427230a4", + "userID": "T9137d13aa0eb07438184dd49dd296ab8485b3a62737416547f98d52adc578ad4", +} +`; + +exports[`CreateScoreID static output > sdvx produces stable score ids 1`] = ` +{ + "baseline": "Tefde7e5b2f221fb4522c8d02033e0d7e7c4f14e47c18978e9054d4412d743ebd", + "chartID": "T475d196f662d46fca9bc65e29e495cead9d19fedfc541e67a7841027169ee505", + "optional.exScore.null": "T17a4b7a38a565b747be4a838bbe1cb996a817b61f3397d7befd9f4422026a110", + "optional.exScore.set": "Tefde7e5b2f221fb4522c8d02033e0d7e7c4f14e47c18978e9054d4412d743ebd", + "optional.exScore.unset": "T17a4b7a38a565b747be4a838bbe1cb996a817b61f3397d7befd9f4422026a110", + "optional.fast.ignored": "Tefde7e5b2f221fb4522c8d02033e0d7e7c4f14e47c18978e9054d4412d743ebd", + "provided.lamp": "Td12f6eb659d72412c12316a69570e6e488c525f211b311467810d5c324ed355d", + "provided.score": "T4283ad151d4659d35ef56ef18ae4b5be2f8b2d094c7105666ef92b02d78e0d4f", + "userID": "Tde9d8c058751a41e5e278be598968e52d9ce1c74c28b5f426b6390b76e15acf7", +} +`; + +exports[`CreateScoreID static output > usc-controller produces stable score ids 1`] = ` +{ + "baseline": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "chartID": "T17a81c58b451236c6f80c80848bef7d1e4dbaff88e4fac35638e966f686f5602", + "optional.fast.ignored": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "provided.lamp": "T810246f082ed7f9722ec63cba06f3f773eb4bcca671ad9ee9ce64be0ff2c492e", + "provided.score": "Tfd40722a31de045b97b7ae23b0d1613ebc9442ab27cdbdfe5252ef823a0ad94f", + "userID": "Tc18bba02eca24e8fa646f70f8d2d447caf9315b0b16efb9ac90ae8a798118848", +} +`; + +exports[`CreateScoreID static output > usc-keyboard produces stable score ids 1`] = ` +{ + "baseline": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "chartID": "T17a81c58b451236c6f80c80848bef7d1e4dbaff88e4fac35638e966f686f5602", + "optional.fast.ignored": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "provided.lamp": "T810246f082ed7f9722ec63cba06f3f773eb4bcca671ad9ee9ce64be0ff2c492e", + "provided.score": "Tfd40722a31de045b97b7ae23b0d1613ebc9442ab27cdbdfe5252ef823a0ad94f", + "userID": "Tc18bba02eca24e8fa646f70f8d2d447caf9315b0b16efb9ac90ae8a798118848", +} +`; + +exports[`CreateScoreID static output > wacca produces stable score ids 1`] = ` +{ + "baseline": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "chartID": "T17a81c58b451236c6f80c80848bef7d1e4dbaff88e4fac35638e966f686f5602", + "optional.fast.ignored": "T87515e8d5a9921435c1c449d605d185a2d534f949f32cc03367c00c5787f5d78", + "provided.lamp": "Tc6ea52eaea944f67bedd0b513ffb87215210ea3b22a19f54e96a19f892ab17f8", + "provided.score": "Tfd40722a31de045b97b7ae23b0d1613ebc9442ab27cdbdfe5252ef823a0ad94f", + "userID": "Tc18bba02eca24e8fa646f70f8d2d447caf9315b0b16efb9ac90ae8a798118848", +} +`; diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-id-fixtures.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-id-fixtures.ts new file mode 100644 index 000000000..d68667c79 --- /dev/null +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-id-fixtures.ts @@ -0,0 +1,221 @@ +import { dmf } from "#test-utils/misc"; + +import { ALL_GAMES, GetGameConfig, type ConfScoreMetric, type V3Game } from "tachi-common"; + +import type { DryScore } from "../common/types"; + +import { CreateScoreID } from "./score-id"; + +/** Stable inputs shared by every snapshot case. */ +export const SCORE_ID_CANONICAL_USER_ID = 1; +export const SCORE_ID_ALT_USER_ID = 424_242; +export const SCORE_ID_CANONICAL_CHART_ID = "c2311194e3897ddb5745b1760d2c0141f933e683"; +export const SCORE_ID_ALT_CHART_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +function isEnumMetric( + conf: ConfScoreMetric, +): conf is ConfScoreMetric & { type: "ENUM"; values: Array } { + return conf.type === "ENUM"; +} + +function baselineProvidedValue(conf: ConfScoreMetric): number | string { + switch (conf.type) { + case "INTEGER": + return 500_000; + case "DECIMAL": + return 97.53; + case "ENUM": { + if (isEnumMetric(conf) && conf.minimumRelevantValue) { + return conf.minimumRelevantValue; + } + + return isEnumMetric(conf) ? conf.values[Math.min(2, conf.values.length - 1)]! : "CLEAR"; + } + default: + throw new Error(`Unsupported provided metric type: ${(conf as ConfScoreMetric).type}`); + } +} + +function alternateProvidedValue(conf: ConfScoreMetric, baseline: number | string): number | string { + switch (conf.type) { + case "INTEGER": + return baseline === 500_000 ? 123_456 : 500_000; + case "DECIMAL": + return baseline === 97.53 ? 88.88 : 97.53; + case "ENUM": { + if (!isEnumMetric(conf)) { + throw new Error("Expected enum metric"); + } + + const idx = conf.values.indexOf(baseline as string); + + return conf.values[(idx + 1) % conf.values.length]!; + } + default: + throw new Error(`Unsupported provided metric type: ${(conf as ConfScoreMetric).type}`); + } +} + +function baselineOptionalPartOfScoreIdValue(conf: ConfScoreMetric): number | string { + switch (conf.type) { + case "INTEGER": + return 777; + case "DECIMAL": + return 12.34; + case "ENUM": { + if (isEnumMetric(conf) && conf.minimumRelevantValue) { + return conf.minimumRelevantValue; + } + + return isEnumMetric(conf) ? conf.values[0]! : "0"; + } + default: + throw new Error( + `Unsupported partOfScoreID optional metric type: ${(conf as ConfScoreMetric).type}`, + ); + } +} + +/** + * Builds a minimal DryScore with every provided metric and every + * partOfScoreID optional metric populated with stable values. + */ +export function buildCanonicalDryScore(game: V3Game): DryScore { + const gameConfig = GetGameConfig(game); + + const scoreData: Record = { + judgements: {}, + optional: {}, + }; + + for (const [metric, conf] of Object.entries(gameConfig.providedMetrics)) { + scoreData[metric] = baselineProvidedValue(conf); + } + + for (const [metric, conf] of Object.entries(gameConfig.optionalMetrics)) { + if (conf.partOfScoreID) { + (scoreData.optional as Record)[metric] = + baselineOptionalPartOfScoreIdValue(conf); + } + } + + return { + service: "score-id-fixture", + game, + scoreData: scoreData as DryScore["scoreData"], + scoreMeta: {}, + timeAchieved: null, + comment: null, + importType: "file/batch-manual", + }; +} + +/** + * Returns every score-id variant we care about locking for a game: + * baseline identity inputs, user/chart permutations, each provided metric, + * and each partOfScoreID optional (set vs null). + */ +export function collectScoreIdVariants(game: V3Game): Record { + const dryScore = buildCanonicalDryScore(game); + const gameConfig = GetGameConfig(game); + + const variants: Record = { + baseline: CreateScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dryScore, + SCORE_ID_CANONICAL_CHART_ID, + ), + userID: CreateScoreID(game, SCORE_ID_ALT_USER_ID, dryScore, SCORE_ID_CANONICAL_CHART_ID), + chartID: CreateScoreID(game, SCORE_ID_CANONICAL_USER_ID, dryScore, SCORE_ID_ALT_CHART_ID), + }; + + for (const [metric, conf] of Object.entries(gameConfig.providedMetrics)) { + const baseline = baselineProvidedValue(conf); + const alternate = alternateProvidedValue(conf, baseline); + + variants[`provided.${metric}`] = CreateScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dmf(dryScore, { + scoreData: { + [metric]: alternate, + }, + }), + SCORE_ID_CANONICAL_CHART_ID, + ); + } + + for (const [metric, conf] of Object.entries(gameConfig.optionalMetrics)) { + if (!conf.partOfScoreID) { + continue; + } + + const setValue = baselineOptionalPartOfScoreIdValue(conf); + + variants[`optional.${metric}.set`] = CreateScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dmf(dryScore, { + scoreData: { + optional: { + [metric]: setValue, + }, + }, + }), + SCORE_ID_CANONICAL_CHART_ID, + ); + + variants[`optional.${metric}.null`] = CreateScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dmf(dryScore, { + scoreData: { + optional: { + [metric]: null, + }, + }, + }), + SCORE_ID_CANONICAL_CHART_ID, + ); + + variants[`optional.${metric}.unset`] = CreateScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dmf(dryScore, { + scoreData: { + optional: { + [metric]: undefined, + }, + }, + }), + SCORE_ID_CANONICAL_CHART_ID, + ); + } + + // Non-partOfScoreID optionals must not affect the hash. + const firstNonScoreIdOptional = Object.entries(gameConfig.optionalMetrics).find( + ([, conf]) => !conf.partOfScoreID, + ); + + if (firstNonScoreIdOptional) { + const [metric] = firstNonScoreIdOptional; + + variants[`optional.${metric}.ignored`] = CreateScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dmf(dryScore, { + scoreData: { + optional: { + [metric]: 99_999, + }, + }, + }), + SCORE_ID_CANONICAL_CHART_ID, + ); + } + + return variants; +} + +export const SCORE_ID_SNAPSHOT_GAMES = ALL_GAMES; diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts index e7d51e00a..0d582d8dd 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-id.test.ts @@ -12,28 +12,43 @@ import { CreateScoreID } from "./score-id"; describe("CreateScoreID", () => { it("returns a T-prefixed 40-hex score id", () => { - const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + const scoreID = CreateScoreID( + "iidx-sp", + 1, + TestingIIDXSPDryScore, + Testing511SPA.legacyChartID, + ); expect(scoreID).toMatch(/^T[0-9a-f]{40}/u); }); it("varies with user id", () => { - const a = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); - const b = CreateScoreID("iidx-sp", 2, TestingIIDXSPDryScore, Testing511SPA.chartID); + const a = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.legacyChartID); + const b = CreateScoreID("iidx-sp", 2, TestingIIDXSPDryScore, Testing511SPA.legacyChartID); expect(a).not.toBe(b); }); it("is stable for the same inputs", () => { - const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + const scoreID = CreateScoreID( + "iidx-sp", + 1, + TestingIIDXSPDryScore, + Testing511SPA.legacyChartID, + ); expect(scoreID).toBe( - CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID), + CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.legacyChartID), ); }); it("only incorporates score metrics that affect the checksum", () => { - const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + const scoreID = CreateScoreID( + "iidx-sp", + 1, + TestingIIDXSPDryScore, + Testing511SPA.legacyChartID, + ); expect(scoreID).toBe( CreateScoreID( @@ -49,13 +64,18 @@ describe("CreateScoreID", () => { }, } as DryScoreData<"iidx-sp">, }), - Testing511SPA.chartID, + Testing511SPA.legacyChartID, ), ); }); it("changes when a provided metric changes", () => { - const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + const scoreID = CreateScoreID( + "iidx-sp", + 1, + TestingIIDXSPDryScore, + Testing511SPA.legacyChartID, + ); expect(scoreID).not.toBe( CreateScoreID( @@ -66,7 +86,7 @@ describe("CreateScoreID", () => { score: 0, } as DryScoreData<"iidx-sp">, }), - Testing511SPA.chartID, + Testing511SPA.legacyChartID, ), ); }); @@ -76,7 +96,7 @@ describe("CreateScoreID", () => { "sdvx", 1, TestingSDVXSingleDryScore, - Testing511SPA.chartID, + Testing511SPA.legacyChartID, ); expect(sdvxScoreID).not.toBe( @@ -86,7 +106,7 @@ describe("CreateScoreID", () => { dmf(TestingSDVXSingleDryScore, { scoreData: { optional: { exScore: 1 } }, }), - Testing511SPA.chartID, + Testing511SPA.legacyChartID, ), ); @@ -97,7 +117,7 @@ describe("CreateScoreID", () => { dmf(TestingSDVXSingleDryScore, { scoreData: { optional: { exScore: 1 } }, }), - Testing511SPA.chartID, + Testing511SPA.legacyChartID, ), ).not.toBe( CreateScoreID( @@ -106,7 +126,7 @@ describe("CreateScoreID", () => { dmf(TestingSDVXSingleDryScore, { scoreData: { optional: { exScore: 100 } }, }), - Testing511SPA.chartID, + Testing511SPA.legacyChartID, ), ); }); @@ -119,7 +139,7 @@ describe("CreateScoreID", () => { dmf(TestingSDVXSingleDryScore, { scoreData: { optional: { exScore: 1, fast: 18 } }, }), - Testing511SPA.chartID, + Testing511SPA.legacyChartID, ), ).toBe( CreateScoreID( @@ -128,7 +148,7 @@ describe("CreateScoreID", () => { dmf(TestingSDVXSingleDryScore, { scoreData: { optional: { exScore: 1 } }, }), - Testing511SPA.chartID, + Testing511SPA.legacyChartID, ), ); }); @@ -141,7 +161,7 @@ describe("CreateScoreID", () => { dmf(TestingSDVXSingleDryScore, { scoreData: { optional: { exScore: undefined } }, }), - Testing511SPA.chartID, + Testing511SPA.legacyChartID, ), ).toBe( CreateScoreID( @@ -150,14 +170,25 @@ describe("CreateScoreID", () => { dmf(TestingSDVXSingleDryScore, { scoreData: { optional: { exScore: null } }, }), - Testing511SPA.chartID, + Testing511SPA.legacyChartID, ), ); }); it("is deterministic (canary - changing the algorithm is a breaking change)", () => { - const scoreID = CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, Testing511SPA.chartID); + const scoreID = CreateScoreID( + "iidx-sp", + 1, + TestingIIDXSPDryScore, + Testing511SPA.legacyChartID, + ); expect(scoreID).toBe("T5d669c4d5d6ca80761e87698acd77c51d2bed95b64ab76e65952dbca7c26bc81"); }); + + it("throws when legacyChartID is not exactly 40 characters", () => { + expect(() => + CreateScoreID("iidx-sp", 1, TestingIIDXSPDryScore, "not-a-legacy-chart-id"), + ).toThrow(/legacyChartID must be exactly 40 characters/u); + }); }); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts index 802385be2..2dd6ae53a 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts @@ -11,6 +11,16 @@ import { import type { DryScore } from "../common/types"; +export const LEGACY_CHART_ID_LENGTH = 40; + +export function assertLegacyChartIDForScoreID(legacyChartID: string): void { + if (legacyChartID.length !== LEGACY_CHART_ID_LENGTH) { + throw new Error( + `legacyChartID must be exactly ${LEGACY_CHART_ID_LENGTH} characters for score deduplication, got ${legacyChartID.length}`, + ); + } +} + /** * Creates an identifier for this score. * This is used to deduplicate repeated scores. @@ -19,10 +29,18 @@ export function CreateScoreID( game: V3Game, userID: integer, dryScore: DryScore, - chartID: string, + legacyChartID: string, logger?: KtLogger, ) { - const elements: Record = { userID, chartID }; + // scoreIDs CANNOT CHANGE. and we changed the chartID format for v3. So, we _have_ to use + // the legacyID here instead of the pretty ID. + // + // TODO(zk): migrate all scoreIDs over to the new format at some point, this is not that bad + // if we have on update cascade :3c + assertLegacyChartIDForScoreID(legacyChartID); + + // Score IDs were historically keyed on the mongo-era chartID (40-char SHA1 hex). + const elements: Record = { userID, chartID: legacyChartID }; const gameConfig = GetGameConfig(game); diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts index 28d0fffe7..1b684fb44 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts @@ -355,7 +355,7 @@ async function HydrateCheckAndInsertScore( ): Promise { const game = dryScore.game; - const scoreID = CreateScoreID(game, userID, dryScore, chart.chartID, importLog); + const scoreID = CreateScoreID(game, userID, dryScore, chart.legacyChartID, importLog); // sub-context thelog so the below logs are more accurate const log = AppendLogCtx(scoreID, importLog); diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.test.ts b/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.test.ts index ef4d0a7f5..2b0bc5f46 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.test.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/converter.test.ts @@ -74,7 +74,7 @@ async function seedSongs() { await DB.insertInto("chart") .values({ id: chart.chartID, - legacy_id: index, + legacy_id: chart.legacyChartID, game: "chunithm", song_id: chart.song.id, difficulty: chart.difficulty, diff --git a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts index dc30fbcfe..a37485a3e 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts @@ -11,6 +11,7 @@ import { } from "#lib/score-import/framework/common/converter-failures"; import { DeorphanScores } from "#lib/score-import/framework/orphans/orphans"; import { ServerConfig, TachiConfig } from "#lib/setup/config"; +import { Random20Hex } from "#utils/misc"; import { FindChartOnSHA256, FindChartOnSHA256Playtype } from "#utils/queries/charts"; import { FindSongOnID } from "#utils/queries/songs"; import { @@ -297,6 +298,7 @@ function ConvertBeatorajaChartToTachi(chart: BeatorajaChart, game: BMSGames) { const chartDoc: ChartDocument = { game, chartID, + legacyChartID: Random20Hex(), difficulty: "CHART", isPrimary: true, level: "?", diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts index f0aecf153..b576e7cfa 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts @@ -303,7 +303,7 @@ describe("ConverterIRFervidex", () => { log, ); - const scoreID = CreateScoreID("iidx-sp", userId, dryScore, chart.chartID); + const scoreID = CreateScoreID("iidx-sp", userId, dryScore, chart.legacyChartID); const hydrated = HydrateScore(userId, dryScore, chart, song, scoreID, log); const { data, derived, judgements } = mongoScoreDataToPg("iidx-sp", hydrated.scoreData); const now = new Date().toISOString(); diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.ts index 7529acebf..cc42a754e 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex/converter.ts @@ -262,7 +262,7 @@ export const ConverterIRFervidex: ConverterFunction { const scoreRow = await trx diff --git a/typescript/server/src/scripts/compare-score-id-feb1.ts b/typescript/server/src/scripts/compare-score-id-feb1.ts new file mode 100644 index 000000000..56d8c3755 --- /dev/null +++ b/typescript/server/src/scripts/compare-score-id-feb1.ts @@ -0,0 +1,353 @@ +/** + * Compare CreateScoreID output at HEAD vs eed374649 (2026-02-01). + * + * Usage: bun run src/scripts/compare-score-id-feb1.ts + */ +import type { DryScore } from "#lib/score-import/framework/common/types"; +import { + buildCanonicalDryScore, + SCORE_ID_ALT_CHART_ID, + SCORE_ID_ALT_USER_ID, + SCORE_ID_CANONICAL_CHART_ID, + SCORE_ID_CANONICAL_USER_ID, + SCORE_ID_SNAPSHOT_GAMES, +} from "#lib/score-import/framework/score-importing/score-id-fixtures"; +import { collectScoreIdVariants } from "#lib/score-import/framework/score-importing/score-id-fixtures"; +import { dmf } from "#test-utils/misc"; +import fjsh from "fast-json-stable-hash"; +import { GetGPTConfig } from "/tmp/tachi-feb1/common/src/config/config.ts"; +import { + GetGameConfig, + LEGACY_GameToGPTString, + type ConfScoreMetric, + type V3Game, +} from "tachi-common"; + +const LEGACY_COMMIT = "eed37464970d6863454508ea5064e627b8fd0a33"; + +type ScoreIdElements = Record; + +function isEnumMetric( + conf: ConfScoreMetric, +): conf is ConfScoreMetric & { type: "ENUM"; values: Array } { + return conf.type === "ENUM"; +} + +function baselineProvidedValue(conf: ConfScoreMetric): number | string { + switch (conf.type) { + case "INTEGER": + return 500_000; + case "DECIMAL": + return 97.53; + case "ENUM": { + if (isEnumMetric(conf) && conf.minimumRelevantValue) { + return conf.minimumRelevantValue; + } + + return isEnumMetric(conf) ? conf.values[Math.min(2, conf.values.length - 1)]! : "CLEAR"; + } + default: + throw new Error(`Unsupported provided metric type: ${(conf as ConfScoreMetric).type}`); + } +} + +function alternateProvidedValue(conf: ConfScoreMetric, baseline: number | string): number | string { + switch (conf.type) { + case "INTEGER": + return baseline === 500_000 ? 123_456 : 500_000; + case "DECIMAL": + return baseline === 97.53 ? 88.88 : 97.53; + case "ENUM": { + if (!isEnumMetric(conf)) { + throw new Error("Expected enum metric"); + } + + const idx = conf.values.indexOf(baseline as string); + + return conf.values[(idx + 1) % conf.values.length]!; + } + default: + throw new Error(`Unsupported provided metric type: ${(conf as ConfScoreMetric).type}`); + } +} + +function baselineOptionalPartOfScoreIdValue(conf: ConfScoreMetric): number | string { + switch (conf.type) { + case "INTEGER": + return 777; + case "DECIMAL": + return 12.34; + case "ENUM": { + if (isEnumMetric(conf) && conf.minimumRelevantValue) { + return conf.minimumRelevantValue; + } + + return isEnumMetric(conf) ? conf.values[0]! : "0"; + } + default: + throw new Error( + `Unsupported partOfScoreID optional metric type: ${(conf as ConfScoreMetric).type}`, + ); + } +} + +function buildLegacyElements( + game: V3Game, + userID: number, + dryScore: DryScore, + chartID: string, +): ScoreIdElements { + const gptString = LEGACY_GameToGPTString(game); + const gptConfig = GetGPTConfig(gptString); + const elements: ScoreIdElements = { userID, chartID }; + + for (const metric of Object.keys(gptConfig.providedMetrics)) { + elements[metric] = dryScore.scoreData[metric as keyof typeof dryScore.scoreData] as + | number + | string; + } + + for (const [metric, conf] of Object.entries(gptConfig.optionalMetrics)) { + if (conf.partOfScoreID) { + elements[`optional.${metric}`] = dryScore.scoreData.optional[metric] ?? null; + } + } + + return elements; +} + +function createLegacyScoreID( + game: V3Game, + userID: number, + dryScore: DryScore, + chartID: string, +): string { + const elements = buildLegacyElements(game, userID, dryScore, chartID); + + return `T${fjsh.hash(elements, "sha256")}`; +} + +function buildCurrentElements( + game: V3Game, + userID: number, + dryScore: DryScore, + legacyChartID: string, +): ScoreIdElements { + const gameConfig = GetGameConfig(game); + const elements: ScoreIdElements = { userID, chartID: legacyChartID }; + + for (const metric of Object.keys(gameConfig.providedMetrics)) { + elements[metric] = dryScore.scoreData[metric as keyof typeof dryScore.scoreData] as + | number + | string; + } + + for (const [metric, conf] of Object.entries(gameConfig.optionalMetrics)) { + if (conf.partOfScoreID) { + elements[`optional.${metric}`] = dryScore.scoreData.optional[metric] ?? null; + } + } + + return elements; +} + +function resolveVariantInputs( + game: V3Game, + variant: string, +): { dryScore: DryScore; userID: number; chartID: string } { + const dryScore = buildCanonicalDryScore(game); + const gameConfig = GetGameConfig(game); + + if (variant === "baseline") { + return { + dryScore, + userID: SCORE_ID_CANONICAL_USER_ID, + chartID: SCORE_ID_CANONICAL_CHART_ID, + }; + } + + if (variant === "userID") { + return { dryScore, userID: SCORE_ID_ALT_USER_ID, chartID: SCORE_ID_CANONICAL_CHART_ID }; + } + + if (variant === "chartID") { + return { dryScore, userID: SCORE_ID_CANONICAL_USER_ID, chartID: SCORE_ID_ALT_CHART_ID }; + } + + if (variant.startsWith("provided.")) { + const metric = variant.slice("provided.".length); + const conf = gameConfig.providedMetrics[metric]!; + const alternate = alternateProvidedValue(conf, baselineProvidedValue(conf)); + + return { + dryScore: dmf(dryScore, { scoreData: { [metric]: alternate } }), + userID: SCORE_ID_CANONICAL_USER_ID, + chartID: SCORE_ID_CANONICAL_CHART_ID, + }; + } + + if (variant.startsWith("optional.")) { + const rest = variant.slice("optional.".length); + const dot = rest.lastIndexOf("."); + const metric = rest.slice(0, dot); + const mode = rest.slice(dot + 1); + const conf = gameConfig.optionalMetrics[metric]!; + + let value: number | string | null | undefined; + + if (mode === "set") { + value = baselineOptionalPartOfScoreIdValue(conf); + } else if (mode === "null") { + value = null; + } else if (mode === "unset") { + value = undefined; + } else if (mode === "ignored") { + value = 99_999; + } else { + throw new Error(`Unknown optional variant ${variant}`); + } + + return { + dryScore: dmf(dryScore, { scoreData: { optional: { [metric]: value } } }), + userID: SCORE_ID_CANONICAL_USER_ID, + chartID: SCORE_ID_CANONICAL_CHART_ID, + }; + } + + throw new Error(`Unknown variant ${variant}`); +} + +function collectLegacyScoreIdVariants(game: V3Game): Record { + const dryScore = buildCanonicalDryScore(game); + const gameConfig = GetGameConfig(game); + + const variants: Record = { + baseline: createLegacyScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dryScore, + SCORE_ID_CANONICAL_CHART_ID, + ), + userID: createLegacyScoreID( + game, + SCORE_ID_ALT_USER_ID, + dryScore, + SCORE_ID_CANONICAL_CHART_ID, + ), + chartID: createLegacyScoreID(game, SCORE_ID_CANONICAL_USER_ID, dryScore, SCORE_ID_ALT_CHART_ID), + }; + + for (const [metric, conf] of Object.entries(gameConfig.providedMetrics)) { + const alternate = alternateProvidedValue(conf, baselineProvidedValue(conf)); + + variants[`provided.${metric}`] = createLegacyScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dmf(dryScore, { scoreData: { [metric]: alternate } }), + SCORE_ID_CANONICAL_CHART_ID, + ); + } + + for (const [metric, conf] of Object.entries(gameConfig.optionalMetrics)) { + if (!conf.partOfScoreID) { + continue; + } + + const setValue = baselineOptionalPartOfScoreIdValue(conf); + + variants[`optional.${metric}.set`] = createLegacyScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dmf(dryScore, { scoreData: { optional: { [metric]: setValue } } }), + SCORE_ID_CANONICAL_CHART_ID, + ); + + variants[`optional.${metric}.null`] = createLegacyScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dmf(dryScore, { scoreData: { optional: { [metric]: null } } }), + SCORE_ID_CANONICAL_CHART_ID, + ); + + variants[`optional.${metric}.unset`] = createLegacyScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dmf(dryScore, { scoreData: { optional: { [metric]: undefined } } }), + SCORE_ID_CANONICAL_CHART_ID, + ); + } + + const firstNonScoreIdOptional = Object.entries(gameConfig.optionalMetrics).find( + ([, conf]) => !conf.partOfScoreID, + ); + + if (firstNonScoreIdOptional) { + const [metric] = firstNonScoreIdOptional; + + variants[`optional.${metric}.ignored`] = createLegacyScoreID( + game, + SCORE_ID_CANONICAL_USER_ID, + dmf(dryScore, { scoreData: { optional: { [metric]: 99_999 } } }), + SCORE_ID_CANONICAL_CHART_ID, + ); + } + + return variants; +} + +function main() { + let total = 0; + let matched = 0; + const mismatches: Array = []; + const configDrift: Array = []; + + for (const game of SCORE_ID_SNAPSHOT_GAMES) { + const currentVariants = collectScoreIdVariants(game); + const legacyVariants = collectLegacyScoreIdVariants(game); + + for (const [variant, currentId] of Object.entries(currentVariants)) { + total++; + const legacyId = legacyVariants[variant]; + + if (currentId === legacyId) { + matched++; + continue; + } + + mismatches.push(`${game} ${variant}: current=${currentId} legacy=${legacyId}`); + + const { dryScore, userID, chartID } = resolveVariantInputs(game, variant); + const currentElements = buildCurrentElements(game, userID, dryScore, chartID); + const legacyElements = buildLegacyElements(game, userID, dryScore, chartID); + + if (JSON.stringify(currentElements) === JSON.stringify(legacyElements)) { + configDrift.push(`${game} ${variant}: hash inputs match but ids differ (algorithm drift!)`); + } else { + configDrift.push(`${game} ${variant}: hash inputs differ (likely config drift)`); + } + } + } + + console.log(`CreateScoreID comparison vs ${LEGACY_COMMIT} (2026-02-01)`); + console.log(`Games: ${SCORE_ID_SNAPSHOT_GAMES.length}, variants checked: ${total}`); + console.log(`Matched: ${matched}/${total}`); + + if (mismatches.length === 0) { + console.log("All inputs produce identical score ids."); + process.exit(0); + } + + console.log("\nMismatches:"); + for (const line of mismatches) { + console.log(` ${line}`); + } + + console.log("\nDiagnosis:"); + for (const line of configDrift) { + console.log(` ${line}`); + } + + process.exit(1); +} + +main(); diff --git a/typescript/server/src/test-utils/test-data.ts b/typescript/server/src/test-utils/test-data.ts index 856a81d48..d43ea391f 100644 --- a/typescript/server/src/test-utils/test-data.ts +++ b/typescript/server/src/test-utils/test-data.ts @@ -328,6 +328,7 @@ export const TestingSDVXAlbidaSong: SongDocument<"sdvx"> = { export const TestingSDVXAlbidaChart: ChartDocument<"sdvx"> = { game: "sdvx", chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", + legacyChartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", difficulty: "ADV", song: TestingSDVXAlbidaSong, levelNum: 10, @@ -444,6 +445,7 @@ export const TestingJubeatSong: SongDocument<"jubeat"> = { export const TestingJubeatChart: ChartDocument<"jubeat"> = { game: "jubeat", chartID: "b90a319f18d1a746b330b8f4cd6f74874f664421", + legacyChartID: "b90a319f18d1a746b330b8f4cd6f74874f664421", song: TestingJubeatSong, level: "6", levelNum: 6, @@ -469,6 +471,7 @@ export const TestingGitadoraSong: SongDocument<"gitadora"> = { export const TestingGitadoraChart: ChartDocument<"gitadora-dora"> = { game: "gitadora-dora", chartID: "bf62dd6c78afaf6b4f95cce70b5b08af193c0c57", + legacyChartID: "bf62dd6c78afaf6b4f95cce70b5b08af193c0c57", data: { inGameID: 0, }, @@ -496,6 +499,7 @@ export const TestingMaimaiSong: SongDocument<"maimai"> = { export const TestingMaimaiChart: ChartDocument<"maimai"> = { game: "maimai", chartID: "6e087e5f58519a2697374b9bff776a004046e952", + legacyChartID: "6e087e5f58519a2697374b9bff776a004046e952", data: { inGameID: 834, inGameStrID: "pandora_paradoxxx", @@ -524,6 +528,7 @@ export const TestingMaimaiDXSong: SongDocument<"maimaidx"> = { export const TestingMaimaiDXChart: ChartDocument<"maimaidx"> = { game: "maimaidx", chartID: "fbde73d543f93cbad9ec67052a7165700c5c8df6", + legacyChartID: "fbde73d543f93cbad9ec67052a7165700c5c8df6", data: { displayVersion: "maimai", inGameID: 1, @@ -552,6 +557,7 @@ export const TestingMusecaSong: SongDocument<"museca"> = { export const TestingMusecaChart: ChartDocument<"museca"> = { game: "museca", chartID: "4f3a7bf907f7be54a0c531dd9077e58fd7b6bb9c", + legacyChartID: "4f3a7bf907f7be54a0c531dd9077e58fd7b6bb9c", data: { inGameID: 1, }, @@ -579,6 +585,7 @@ export const TestingPopnSong: SongDocument<"popn"> = { export const TestingPopnChart: ChartDocument<"popn"> = { game: "popn", chartID: "7489166ddfb6ec780249823d5bda255611fbc528", + legacyChartID: "7489166ddfb6ec780249823d5bda255611fbc528", data: { hashSHA256: "83287260cb32c5c54b06b76967ba8620dd3735063959d9fbaf2cffc0dba8a049", inGameID: 0, @@ -606,6 +613,7 @@ export const Testing511Song: SongDocument<"iidx"> = { export const Testing511SPA: ChartDocument<"iidx-sp"> = { game: "iidx-sp", chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", + legacyChartID: "c2311194e3897ddb5745b1760d2c0141f933e683", difficulty: "ANOTHER", song: Testing511Song, levelNum: 10, @@ -653,6 +661,7 @@ export const TestingDDRSong: SongDocument<"ddr"> = { export const TestingDDRSP: ChartDocument<"ddr-sp"> = { game: "ddr-sp", chartID: "2fc2e0cfdda42addb7840b58be1df1f545310d66", + legacyChartID: "2fc2e0cfdda42addb7840b58be1df1f545310d66", data: { inGameID: 10, }, @@ -667,6 +676,7 @@ export const TestingDDRSP: ChartDocument<"ddr-sp"> = { export const TestingAlbidaADV: ChartDocument<"sdvx"> = { game: "sdvx", chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", + legacyChartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", difficulty: "ADV", song: TestingSDVXAlbidaSong, levelNum: 10, @@ -697,6 +707,7 @@ export const BMSGazerChart: ChartDocument<"bms-7k"> = { game: "bms-7k", song: BMSGazerSong, chartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", + legacyChartID: "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", data: { aiLevel: "0", notecount: 2256, @@ -731,6 +742,7 @@ export const CHUNITHMBBKKSong: SongDocument<"chunithm"> = { export const CHUNITHMBBKKChart: ChartDocument<"chunithm"> = { game: "chunithm", chartID: "192b96bdb6150f80ba6412ce02df1249e16c0cb0", + legacyChartID: "192b96bdb6150f80ba6412ce02df1249e16c0cb0", difficulty: "BASIC", song: CHUNITHMBBKKSong, levelNum: 3, @@ -780,6 +792,7 @@ export const TestingDoraChart: ChartDocument<"gitadora-dora"> = { game: "gitadora-dora", song: TestingGitadoraSong, chartID: "29f0bfab357ba54e3fd0176fb3cbc578c9ec8df5", + legacyChartID: "29f0bfab357ba54e3fd0176fb3cbc578c9ec8df5", difficulty: "BASIC", levelNum: 1.6, level: "1.60", @@ -806,6 +819,7 @@ export const TestingWaccaPupaExp: ChartDocument<"wacca"> = { game: "wacca", song: TestingWaccaPupaSong, chartID: "2abcf2da86f3d968dbe07695ab7ebf6b79cb0841", + legacyChartID: "2abcf2da86f3d968dbe07695ab7ebf6b79cb0841", difficulty: "EXPERT", levelNum: 13.7, level: "13+", @@ -1046,6 +1060,7 @@ export const TestingUSCSong: SongDocument<"usc"> = { export const TestingUSCChart: ChartDocument<"usc-keyboard"> = { game: "usc-keyboard", chartID: "USC_CHART_ID", + legacyChartID: "0000000000000000000000000000000000000001", difficulty: "NOV", song: TestingUSCSong, levelNum: 1, @@ -1454,6 +1469,7 @@ export const TestingArcaeaSheriruthSong: SongDocument<"arcaea"> = { export const TestingArcaeaSheriruthFTR: ChartDocument<"arcaea"> = { game: "arcaea", chartID: "48de13e0be17a282eed6ef97e6e1478eb59bc55e", + legacyChartID: "48de13e0be17a282eed6ef97e6e1478eb59bc55e", data: { inGameStrID: "sheriruth", notecount: 1151, @@ -1481,6 +1497,7 @@ export const TestingOngekiSong: SongDocument<"ongeki"> = { export const TestingOngekiChart: ChartDocument<"ongeki"> = { game: "ongeki", chartID: "213796bdb6150f80ba6412ce69df1249e16c0cb0", + legacyChartID: "213796bdb6150f80ba6412ce69df1249e16c0cb0", data: { displayVersion: "オンゲキ bright MEMORY Act.3", inGameID: 2137, @@ -1534,6 +1551,7 @@ export const TestingOngekiScorePB: PBScoreDocument<"ongeki"> = { export const TestingOngekiChartConverter: ChartDocument<"ongeki"> = { game: "ongeki", chartID: "e5e4ee3d4feb233c399751b3ba3daf8ba149c9e6", + legacyChartID: "e5e4ee3d4feb233c399751b3ba3daf8ba149c9e6", data: { displayVersion: "オンゲキ bright", inGameID: 678, @@ -1564,6 +1582,7 @@ export const TestingChunithmSongConverter: SongDocument<"chunithm"> = { export const TestingChunithmChartConverter: ChartDocument<"chunithm"> = { game: "chunithm", chartID: "fbba26a6a223b1dd0bf8ef139cfee83114b91086", + legacyChartID: "fbba26a6a223b1dd0bf8ef139cfee83114b91086", data: { inGameID: 956, }, @@ -1595,6 +1614,7 @@ export const TestingChunithmWEChartConverter: ChartDocument<"chunithm"> = { }, difficulty: "割☆4", chartID: "C19e10e7a402af3865b8", + legacyChartID: "e5e4ee3d4feb233c399751b3ba3daf8ba149c9e6", isPrimary: true, level: "", levelNum: 0, @@ -1616,6 +1636,7 @@ export const TestingMaimaiDXSongConverter: SongDocument<"maimaidx"> = { export const TestingMaimaiDXChartConverter: ChartDocument<"maimaidx"> = { game: "maimaidx", chartID: "fab3d632610b9b98ee1e4f68e9ecf0161f9cb8cd", + legacyChartID: "fab3d632610b9b98ee1e4f68e9ecf0161f9cb8cd", data: { displayVersion: "maimaiでらっくす UNiVERSE", inGameID: 11294,