rhythia-api 243.0.0 → 244.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from "./response";
2
- import { ZodObject } from "zod";
2
+ import { ZodObject, ZodTypeAny } from "zod";
3
3
  import { getUserBySession } from "./getUserBySession";
4
4
  import { supabase } from "./supabase";
5
5
 
@@ -61,7 +61,7 @@ interface Props<
61
61
  T = ZodObject<any>,
62
62
  > {
63
63
  request: Request;
64
- schema: { input: T; output: T };
64
+ schema: { input: T; output: ZodTypeAny };
65
65
  authorization?: Function;
66
66
  activity: K;
67
67
  }
@@ -0,0 +1,107 @@
1
+ const GeneralScientificLowerExponentDotNet = -5;
2
+ const DecimalTieTolerance = Number.EPSILON * 4;
3
+
4
+ export function formatSingle(value: number) {
5
+ value = Math.fround(value);
6
+
7
+ for (let precision = 1; precision <= 9; precision++) {
8
+ const rounded = Number(value.toPrecision(precision));
9
+ const formatted = formatGeneralDotNet(
10
+ breakDecimalTieDotNet(value, rounded, precision)
11
+ );
12
+
13
+ if (Math.fround(Number(formatted)) === value) {
14
+ return formatted;
15
+ }
16
+ }
17
+
18
+ return formatGeneralDotNet(value);
19
+ }
20
+
21
+ function formatGeneralDotNet(value: number) {
22
+ if (value === 0) {
23
+ return "0";
24
+ }
25
+
26
+ const formatted = value.toString();
27
+ const exponent = getDecimalExponent(value);
28
+
29
+ if (
30
+ formatted.includes("e") ||
31
+ exponent <= GeneralScientificLowerExponentDotNet
32
+ ) {
33
+ return formatExponential(value, getSignificantDigitCount(formatted));
34
+ }
35
+
36
+ return formatted;
37
+ }
38
+
39
+ function getDecimalExponent(value: number) {
40
+ return Math.floor(Math.log10(Math.abs(value)));
41
+ }
42
+
43
+ function getSignificantDigitCount(value: string) {
44
+ const [mantissa] = value.toLowerCase().split("e");
45
+ const digits = mantissa!.replace("-", "").replace(".", "").replace(/^0+/, "");
46
+ return Math.max(digits.length, 1);
47
+ }
48
+
49
+ function formatExponential(value: number, significantDigitCount: number) {
50
+ const [mantissa, exponent] = value
51
+ .toExponential(significantDigitCount - 1)
52
+ .split("e");
53
+ const exponentValue = Number(exponent);
54
+ const exponentSign = exponentValue < 0 ? "-" : "+";
55
+ const exponentDigits = Math.abs(exponentValue).toString().padStart(2, "0");
56
+ const normalizedMantissa = mantissa!
57
+ .replace(/(\.\d*?)0+$/, "$1")
58
+ .replace(/\.$/, "");
59
+
60
+ return `${normalizedMantissa}E${exponentSign}${exponentDigits}`;
61
+ }
62
+
63
+ function breakDecimalTieDotNet(
64
+ value: number,
65
+ rounded: number,
66
+ precision: number
67
+ ) {
68
+ if (rounded === value || Math.fround(rounded) !== value) {
69
+ return rounded;
70
+ }
71
+
72
+ const exponent = getDecimalExponent(rounded);
73
+ const step = 10 ** (exponent - precision + 1);
74
+ const alternate = Number(
75
+ (rounded > value ? rounded - step : rounded + step).toPrecision(precision)
76
+ );
77
+
78
+ if (alternate === rounded || Math.fround(alternate) !== value) {
79
+ return rounded;
80
+ }
81
+
82
+ const roundedDistance = Math.abs(value - rounded);
83
+ const alternateDistance = Math.abs(value - alternate);
84
+ const tolerance =
85
+ DecimalTieTolerance *
86
+ Math.max(1, Math.abs(value), Math.abs(rounded), Math.abs(alternate));
87
+
88
+ if (alternateDistance + tolerance < roundedDistance) {
89
+ return alternate;
90
+ }
91
+
92
+ if (roundedDistance + tolerance < alternateDistance) {
93
+ return rounded;
94
+ }
95
+
96
+ return hasEvenLastSignificantDigit(alternate, precision)
97
+ ? alternate
98
+ : rounded;
99
+ }
100
+
101
+ function hasEvenLastSignificantDigit(value: number, precision: number) {
102
+ const [mantissa] = Math.abs(value).toPrecision(precision).split("e");
103
+ const digits = mantissa!.replace(".", "").replace(/^0+/, "");
104
+ const lastDigit = Number(digits.at(-1));
105
+
106
+ return lastDigit % 2 === 0;
107
+ }
@@ -0,0 +1,214 @@
1
+ import { Buffer } from "buffer";
2
+ import { createHash } from "node:crypto";
3
+ import pako from "pako";
4
+ import { formatSingle } from "./formatSingle.ts";
5
+ import type { SSPMParsedMap } from "./sspmParser.ts";
6
+
7
+ const LocalFileHeaderSignature = 0x04034b50;
8
+ const CentralDirectorySignature = 0x02014b50;
9
+ const EndOfCentralDirectorySignature = 0x06054b50;
10
+
11
+ interface ZipEntry {
12
+ name: string;
13
+ method: number;
14
+ compressedSize: number;
15
+ localHeaderOffset: number;
16
+ }
17
+
18
+ interface RHMNote {
19
+ Time: number;
20
+ X: number;
21
+ Y: number;
22
+ }
23
+
24
+ export interface RHMMap {
25
+ OnlineId?: number;
26
+ OnlineStatus?: string;
27
+ LegacyId?: string;
28
+ SongName: string;
29
+ Mappers: string[];
30
+ Title: string;
31
+ Duration: number;
32
+ Difficulty: number;
33
+ CustomDifficultyName?: string;
34
+ StarRating?: number;
35
+ Notes: RHMNote[];
36
+ AudioFileName?: string;
37
+ ImagePath?: string;
38
+ }
39
+
40
+ export interface RHMParsedMap {
41
+ map: RHMMap;
42
+ audio?: Buffer;
43
+ cover?: Buffer;
44
+ }
45
+
46
+ export function isRHM(buffer: Buffer) {
47
+ return (
48
+ buffer.length >= 4 && buffer.readUInt32LE(0) === LocalFileHeaderSignature
49
+ );
50
+ }
51
+
52
+ export function parseRHM(buffer: Buffer): RHMParsedMap {
53
+ const entries = readZipEntries(buffer);
54
+ const mapEntry = entries.get("map");
55
+
56
+ if (!mapEntry) {
57
+ throw new Error("Missing RHM map entry.");
58
+ }
59
+
60
+ return {
61
+ map: JSON.parse(mapEntry.toString("utf8")),
62
+ audio: entries.get("audio"),
63
+ cover: entries.get("cover"),
64
+ };
65
+ }
66
+
67
+ export function rhmToSSPMParsedMap({ map, audio, cover }: RHMParsedMap) {
68
+ const notes = [...map.Notes].sort((a, b) => a.Time - b.Time);
69
+ const fields: SSPMParsedMap["customData"]["fields"] = [];
70
+
71
+ if (map.CustomDifficultyName) {
72
+ fields.push({
73
+ id: "difficulty_name",
74
+ type: 0x09,
75
+ value: map.CustomDifficultyName,
76
+ });
77
+ }
78
+
79
+ return {
80
+ header: {
81
+ signature: Buffer.from("RHM\0"),
82
+ version: 0,
83
+ reserved: Buffer.from([]),
84
+ },
85
+ metadata: {
86
+ sha1: Buffer.from([]),
87
+ lastMarkerPos: map.Duration,
88
+ noteCount: notes.length,
89
+ markerCount: notes.length,
90
+ difficulty: map.Difficulty,
91
+ rating: 0,
92
+ hasAudio: !!audio,
93
+ hasCover: !!cover,
94
+ requiresMod: false,
95
+ },
96
+ pointers: {
97
+ customDataOffset: 0,
98
+ customDataLength: 0,
99
+ audioOffset: 0,
100
+ audioLength: audio?.length || 0,
101
+ coverOffset: 0,
102
+ coverLength: cover?.length || 0,
103
+ markerDefinitionsOffset: 0,
104
+ markerDefinitionsLength: 0,
105
+ markerOffset: 0,
106
+ markerLength: 0,
107
+ },
108
+ strings: {
109
+ mapID: map.LegacyId || computeRhythiaMapHash(map),
110
+ mapName: map.Title,
111
+ songName: map.SongName,
112
+ mappers: map.Mappers,
113
+ },
114
+ customData: { fields },
115
+ audio,
116
+ cover,
117
+ markerDefinitions: [{ id: "ssp_note", values: [0x07] }],
118
+ markers: notes.map((note) => {
119
+ const position = { x: note.X, y: note.Y, type: "quantum" as const };
120
+
121
+ return {
122
+ position: note.Time,
123
+ type: 0,
124
+ data: {
125
+ field0: position,
126
+ position,
127
+ },
128
+ };
129
+ }),
130
+ } satisfies SSPMParsedMap;
131
+ }
132
+
133
+ export function computeRhythiaMapHash(map: RHMMap) {
134
+ const hashString =
135
+ map.Title +
136
+ map.SongName +
137
+ map.Mappers.join(",") +
138
+ map.Duration +
139
+ map.Notes.map(
140
+ (x) => x.Time + formatSingle(x.X) + formatSingle(x.Y)
141
+ ).join(",") +
142
+ map.Difficulty +
143
+ (map.CustomDifficultyName ?? "");
144
+
145
+ return createHash("sha256").update(hashString, "utf16le").digest("hex");
146
+ }
147
+
148
+ function readZipEntries(buffer: Buffer) {
149
+ const eocdOffset = findEndOfCentralDirectory(buffer);
150
+ const entryCount = buffer.readUInt16LE(eocdOffset + 10);
151
+ let offset = buffer.readUInt32LE(eocdOffset + 16);
152
+ const entries = new Map<string, Buffer>();
153
+
154
+ for (let i = 0; i < entryCount; i++) {
155
+ const entry = readCentralDirectoryEntry(buffer, offset);
156
+ entries.set(entry.name, readZipEntry(buffer, entry));
157
+
158
+ offset +=
159
+ 46 +
160
+ buffer.readUInt16LE(offset + 28) +
161
+ buffer.readUInt16LE(offset + 30) +
162
+ buffer.readUInt16LE(offset + 32);
163
+ }
164
+
165
+ return entries;
166
+ }
167
+
168
+ function findEndOfCentralDirectory(buffer: Buffer) {
169
+ for (let i = buffer.length - 22; i >= 0; i--) {
170
+ if (buffer.readUInt32LE(i) === EndOfCentralDirectorySignature) {
171
+ return i;
172
+ }
173
+ }
174
+
175
+ throw new Error("Invalid RHM zip directory.");
176
+ }
177
+
178
+ function readCentralDirectoryEntry(buffer: Buffer, offset: number): ZipEntry {
179
+ if (buffer.readUInt32LE(offset) !== CentralDirectorySignature) {
180
+ throw new Error("Invalid RHM central directory.");
181
+ }
182
+
183
+ const nameLength = buffer.readUInt16LE(offset + 28);
184
+
185
+ return {
186
+ name: buffer.subarray(offset + 46, offset + 46 + nameLength).toString(),
187
+ method: buffer.readUInt16LE(offset + 10),
188
+ compressedSize: buffer.readUInt32LE(offset + 20),
189
+ localHeaderOffset: buffer.readUInt32LE(offset + 42),
190
+ };
191
+ }
192
+
193
+ function readZipEntry(buffer: Buffer, entry: ZipEntry) {
194
+ const localHeaderOffset = entry.localHeaderOffset;
195
+
196
+ if (buffer.readUInt32LE(localHeaderOffset) !== LocalFileHeaderSignature) {
197
+ throw new Error(`Invalid RHM entry header: ${entry.name}`);
198
+ }
199
+
200
+ const nameLength = buffer.readUInt16LE(localHeaderOffset + 26);
201
+ const extraLength = buffer.readUInt16LE(localHeaderOffset + 28);
202
+ const dataStart = localHeaderOffset + 30 + nameLength + extraLength;
203
+ const data = buffer.subarray(dataStart, dataStart + entry.compressedSize);
204
+
205
+ if (entry.method === 0) {
206
+ return data;
207
+ }
208
+
209
+ if (entry.method === 8) {
210
+ return Buffer.from(pako.inflateRaw(data));
211
+ }
212
+
213
+ throw new Error(`Unsupported RHM zip compression: ${entry.method}`);
214
+ }