click the squares?!?!?!?!!?!?!?
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
import JSZip from 'jszip';
|
||||
import db from './sql.mjs';
|
||||
import { MAX_NOTES, MAX_HOLD_MS, cleanNotes, cleanTitle, insertMap } from './square_clicker.mjs';
|
||||
|
||||
// Beatmap import for Square Clicker: .osz sets (zip: audio, background, one .osu text file per
|
||||
// difficulty) and loose .osu files, converted to Square Clicker notes { t, x, y, d, p? }.
|
||||
//
|
||||
// Standard and catch: circle / fruit -> tap, slider / juice stream -> slider (curve sampled, repeats
|
||||
// become a path that goes back and forth, duration from length, timing and slider velocity),
|
||||
// spinner / banana shower -> hold in the middle.
|
||||
// Taiko: every object sits in the middle of the playfield, so positions are generated: a path that
|
||||
// flows over the screen (don turns gently, kat turns sharply, distance grows with the time gap).
|
||||
// Drumroll and denden -> hold.
|
||||
// Mania: columns become lanes side by side, long notes holds; chords collapse to one note (one cursor).
|
||||
|
||||
const PF_W = 512, PF_H = 384; // playfield in osu pixels
|
||||
// playfield -> viewport fractions, leaving room for the top bar and the edges
|
||||
const toX = (x) => Math.max(0.02, Math.min(0.98, 0.1 + (x / PF_W) * 0.8));
|
||||
const toY = (y) => Math.max(0.02, Math.min(0.98, 0.14 + (y / PF_H) * 0.74));
|
||||
const MAX_OSU_BYTES = 4 * 1024 * 1024; // one .osu file
|
||||
const MAX_DIFFS = 40;
|
||||
|
||||
// ── .osu text -> sections ─────────────────────────────────────────────────────
|
||||
export const parseOsu = (text) => {
|
||||
const out = { General: {}, Metadata: {}, Difficulty: {}, Events: [], TimingPoints: [], HitObjects: [] };
|
||||
let sec = null;
|
||||
for (let line of String(text).replace(/^/, '').split(/\r?\n/)) {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith('//')) continue;
|
||||
const m = /^\[(\w+)\]$/.exec(line);
|
||||
if (m) { sec = m[1]; continue; }
|
||||
if (!sec) continue;
|
||||
if (sec === 'General' || sec === 'Metadata' || sec === 'Difficulty') {
|
||||
const i = line.indexOf(':');
|
||||
if (i > 0) out[sec][line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
} else if (Array.isArray(out[sec])) {
|
||||
out[sec].push(line);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// ── curves ────────────────────────────────────────────────────────────────────
|
||||
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
const lerp = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
|
||||
const bezier = (pts) => {
|
||||
let len = 0;
|
||||
for (let i = 1; i < pts.length; i++) len += dist(pts[i - 1], pts[i]);
|
||||
const n = Math.max(2, Math.min(200, Math.ceil(len / 4)));
|
||||
const out = [];
|
||||
for (let s = 0; s <= n; s++) {
|
||||
let q = pts.slice();
|
||||
const t = s / n;
|
||||
while (q.length > 1) q = q.slice(1).map((p, i) => lerp(q[i], p, t)); // de Casteljau
|
||||
out.push(q[0]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const catmull = (pts) => {
|
||||
const out = [];
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
|
||||
for (let s = 0; s < 16; s++) {
|
||||
const t = s / 16, t2 = t * t, t3 = t2 * t;
|
||||
out.push([0, 1].map(k => 0.5 * (2 * p1[k] + (-p0[k] + p2[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (-p0[k] + 3 * p1[k] - 3 * p2[k] + p3[k]) * t3)));
|
||||
}
|
||||
}
|
||||
out.push(pts[pts.length - 1]);
|
||||
return out;
|
||||
};
|
||||
|
||||
// Perfect circle through three points; null when they are (nearly) on a line
|
||||
const arc = (a, b, c) => {
|
||||
const d = 2 * (a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1]));
|
||||
if (Math.abs(d) < 1e-3) return null;
|
||||
const sq = (p) => p[0] * p[0] + p[1] * p[1];
|
||||
const cx = (sq(a) * (b[1] - c[1]) + sq(b) * (c[1] - a[1]) + sq(c) * (a[1] - b[1])) / d;
|
||||
const cy = (sq(a) * (c[0] - b[0]) + sq(b) * (a[0] - c[0]) + sq(c) * (b[0] - a[0])) / d;
|
||||
const r = Math.hypot(a[0] - cx, a[1] - cy);
|
||||
const a0 = Math.atan2(a[1] - cy, a[0] - cx);
|
||||
let a2 = Math.atan2(c[1] - cy, c[0] - cx);
|
||||
// direction: the way that passes through b (sign of the turn a -> b -> c)
|
||||
const ccw = ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) > 0;
|
||||
if (ccw) { while (a2 < a0) a2 += 2 * Math.PI; } else { while (a2 > a0) a2 -= 2 * Math.PI; }
|
||||
// sample well past the end point: the slider length decides where it stops
|
||||
const span = (a2 - a0) * 2;
|
||||
const n = Math.max(8, Math.min(200, Math.ceil(Math.abs(span) * r / 4)));
|
||||
const out = [];
|
||||
for (let s = 0; s <= n; s++) { const t = a0 + span * s / n; out.push([cx + r * Math.cos(t), cy + r * Math.sin(t)]); }
|
||||
return out;
|
||||
};
|
||||
|
||||
// Slider curve as a polyline (osu pixels), before cutting to the slider length
|
||||
const curve = (type, pts) => {
|
||||
if (type === 'P' && pts.length === 3) {
|
||||
const c = arc(pts[0], pts[1], pts[2]);
|
||||
if (c) return c;
|
||||
return pts;
|
||||
}
|
||||
if (type === 'L') return pts;
|
||||
if (type === 'C') return catmull(pts);
|
||||
// Bezier (also the fallback): a repeated point starts a new segment
|
||||
const out = [];
|
||||
let seg = [pts[0]];
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
if (pts[i][0] === pts[i - 1][0] && pts[i][1] === pts[i - 1][1]) { out.push(...bezier(seg)); seg = [pts[i]]; }
|
||||
else seg.push(pts[i]);
|
||||
}
|
||||
out.push(...bezier(seg));
|
||||
return out;
|
||||
};
|
||||
|
||||
// Cut / extend a polyline to exactly `length`, then resample it to k points evenly along it
|
||||
const fitLength = (poly, length, k) => {
|
||||
const cum = [0];
|
||||
for (let i = 1; i < poly.length; i++) cum.push(cum[i - 1] + dist(poly[i - 1], poly[i]));
|
||||
const total = cum[cum.length - 1];
|
||||
const at = (s) => {
|
||||
if (poly.length < 2) return poly[0];
|
||||
if (s >= total) { // past the end: go on along the last direction
|
||||
const a = poly[poly.length - 2], b = poly[poly.length - 1], l = dist(a, b) || 1;
|
||||
return [b[0] + (b[0] - a[0]) / l * (s - total), b[1] + (b[1] - a[1]) / l * (s - total)];
|
||||
}
|
||||
let i = 1;
|
||||
while (i < cum.length - 1 && cum[i] < s) i++;
|
||||
const seg = cum[i] - cum[i - 1] || 1;
|
||||
return lerp(poly[i - 1], poly[i], (s - cum[i - 1]) / seg);
|
||||
};
|
||||
return Array.from({ length: k }, (_, i) => at(length * i / (k - 1)));
|
||||
};
|
||||
|
||||
// ── timing ────────────────────────────────────────────────────────────────────
|
||||
// -> sorted [{ time, beatLength, sv }]: the beat length and slider velocity in effect from `time` on
|
||||
const timingOf = (lines) => {
|
||||
const out = [];
|
||||
let beat = 500, sv = 1;
|
||||
const pts = lines.map(l => l.split(',')).filter(p => p.length >= 2)
|
||||
.map(p => ({ time: +p[0], bl: +p[1], unin: p.length > 6 ? p[6] === '1' : +p[1] > 0 }))
|
||||
.filter(p => isFinite(p.time) && isFinite(p.bl))
|
||||
.sort((a, z) => a.time - z.time || (z.unin - a.unin)); // uninherited first at the same time
|
||||
for (const p of pts) {
|
||||
if (p.unin && p.bl > 0) { beat = p.bl; sv = 1; }
|
||||
else if (!p.unin && p.bl < 0) sv = Math.max(0.1, Math.min(10, -100 / p.bl));
|
||||
out.push({ time: p.time, beatLength: beat, sv });
|
||||
}
|
||||
if (!out.length) out.push({ time: 0, beatLength: 500, sv: 1 });
|
||||
return out;
|
||||
};
|
||||
const timingAt = (tps, t) => {
|
||||
let cur = tps[0];
|
||||
for (const p of tps) { if (p.time <= t + 1) cur = p; else break; }
|
||||
return cur;
|
||||
};
|
||||
|
||||
// ── generated positions (taiko) ───────────────────────────────────────────────
|
||||
// A path that wanders over the screen: every note turns it a little (don) or a lot (kat), notes further
|
||||
// apart in time are further apart on screen, and a spot that would cover a note still on screen (or leave
|
||||
// the play area) is avoided by turning further until the spot is free
|
||||
const flowPlace = (list) => {
|
||||
const ASPECT = 1.6; // x/y are fractions of a wide viewport: a y step this much larger is the same distance
|
||||
const ON_SCREEN_MS = 1200; // notes this close in time are visible together
|
||||
const FREE_X = 0.065, FREE_Y = 0.12; // about a note and a bit, as viewport fractions
|
||||
const inside = (x, y) => x >= 0.15 && x <= 0.85 && y >= 0.2 && y <= 0.82;
|
||||
const out = [];
|
||||
let x = 0.5, y = 0.5, ang = -Math.PI / 2, prevEnd = null;
|
||||
for (const n of list) {
|
||||
if (prevEnd !== null) {
|
||||
const want = ang + (n.kat ? 2.2 : 0.3);
|
||||
const step = Math.max(0.11, Math.min(0.3, (n.t - prevEnd) / 1000 * 0.35));
|
||||
const recent = out.filter(o => n.t - o.t < ON_SCREEN_MS);
|
||||
const free = (px, py) => inside(px, py) && !recent.some(o => Math.abs(o.x - px) < FREE_X && Math.abs(o.y - py) < FREE_Y);
|
||||
let best = null;
|
||||
for (let k = 0; k < 24 && !best; k++) { // want, want+0.26, want-0.26, want+0.52, ...
|
||||
const a = want + (k % 2 ? 1 : -1) * Math.ceil(k / 2) * 0.26;
|
||||
const px = x + Math.cos(a) * step, py = y + Math.sin(a) * step * ASPECT;
|
||||
if (free(px, py)) best = [a, px, py];
|
||||
}
|
||||
if (!best) { // boxed in: the farthest in-bounds spot from the recent notes
|
||||
let far = -1;
|
||||
for (let k = 0; k < 24; k++) {
|
||||
const a = want + k * Math.PI / 12;
|
||||
const px = Math.max(0.15, Math.min(0.85, x + Math.cos(a) * step)), py = Math.max(0.2, Math.min(0.82, y + Math.sin(a) * step * ASPECT));
|
||||
const dmin = recent.reduce((m, o) => Math.min(m, Math.hypot(o.x - px, (o.y - py) / ASPECT)), Infinity);
|
||||
if (dmin > far) { far = dmin; best = [a, px, py]; }
|
||||
}
|
||||
}
|
||||
[ang, x, y] = best;
|
||||
}
|
||||
prevEnd = n.t + (n.d || 0); // after a hold the gap counts from its end
|
||||
out.push({ t: n.t, x: +x.toFixed(4), y: +y.toFixed(4), d: n.d || 0 });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// ── one difficulty -> notes ───────────────────────────────────────────────────
|
||||
const MODES = ['standard', 'taiko', 'catch', 'mania'];
|
||||
// -> { version, creator, notes, mode, error? }
|
||||
export const convertOsu = (text) => {
|
||||
const o = parseOsu(text);
|
||||
const mode = parseInt(o.General.Mode || '0', 10) || 0;
|
||||
const version = o.Metadata.Version || 'Normal';
|
||||
const res = { version, creator: o.Metadata.Creator || '', mode, meta: o.Metadata, audioFile: o.General.AudioFilename || '', notes: [] };
|
||||
if (!MODES[mode]) { res.error = 'unknown game mode ' + mode; return res; }
|
||||
const tps = timingOf(o.TimingPoints);
|
||||
const mult = parseFloat(o.Difficulty.SliderMultiplier) || 1.4;
|
||||
const keys = Math.max(1, Math.min(18, Math.round(parseFloat(o.Difficulty.CircleSize) || 4))); // mania columns
|
||||
const holdUntil = (t, end) => Math.max(0, Math.min(MAX_HOLD_MS, Math.round(end) - t));
|
||||
const sliderMs = (t, p) => {
|
||||
const tp = timingAt(tps, t);
|
||||
const slides = Math.max(1, Math.min(50, parseInt(p[6], 10) || 1));
|
||||
return (Math.max(1, +p[7] || 0) / (mult * 100 * tp.sv) * tp.beatLength) * slides;
|
||||
};
|
||||
let notes = [];
|
||||
const flow = []; // taiko: { t, d, kat } placed afterwards
|
||||
for (const line of o.HitObjects) {
|
||||
const p = line.split(',');
|
||||
if (p.length < 4) continue;
|
||||
const x = +p[0], y = +p[1], t = Math.round(+p[2]), type = +p[3];
|
||||
if (!isFinite(x) || !isFinite(y) || !isFinite(t) || t < 0) continue;
|
||||
if (mode === 1) { // taiko: hitSound whistle (2) or clap (8) = kat
|
||||
if (type & 2) flow.push({ t, d: holdUntil(t, t + sliderMs(t, p)) }); // drumroll
|
||||
else if (type & 8) flow.push({ t, d: holdUntil(t, +p[5] || t) }); // denden
|
||||
else if (type & 1) flow.push({ t, d: 0, kat: !!((+p[4] || 0) & 10) });
|
||||
continue;
|
||||
}
|
||||
if (mode === 3) { // mania: x picks the column; type 128 = long note, end time in the extras
|
||||
const col = Math.max(0, Math.min(keys - 1, Math.floor(x * keys / PF_W)));
|
||||
const d = type & 128 ? holdUntil(t, parseInt(String(p[5] || '').split(':')[0], 10) || t) : 0;
|
||||
notes.push({ t, x: +(0.15 + (col + 0.5) / keys * 0.7).toFixed(4), y: 0.62, d });
|
||||
continue;
|
||||
}
|
||||
if (type & 2) { // slider: x,y,time,type,hitSound,curve,slides,length,...
|
||||
const [ctype, ...cps] = String(p[5] || '').split('|');
|
||||
const ctrl = [[x, y], ...cps.map(s => s.split(':').map(Number)).filter(q => q.length === 2 && q.every(isFinite))];
|
||||
const slides = Math.max(1, Math.min(50, parseInt(p[6], 10) || 1));
|
||||
const length = Math.max(1, +p[7] || 0);
|
||||
const tp = timingAt(tps, t);
|
||||
const perSlide = length / (mult * 100 * tp.sv) * tp.beatLength;
|
||||
const d = Math.round(perSlide * slides);
|
||||
if (!isFinite(d) || d <= 0 || ctrl.length < 2) { notes.push({ t, x: toX(x), y: toY(y), d: 0 }); continue; }
|
||||
const k = Math.max(2, Math.min(Math.ceil(length / 12) + 1, 40, Math.floor(900 / slides)));
|
||||
const leg = fitLength(curve(ctype, ctrl), length, k);
|
||||
const path = [];
|
||||
for (let s = 0; s < slides; s++) {
|
||||
const pts = s % 2 ? leg.slice().reverse() : leg;
|
||||
pts.forEach((q, i) => {
|
||||
if (s > 0 && i === 0) return; // the turn point is already there
|
||||
path.push([Math.round(perSlide * (s + i / (k - 1))), +toX(q[0]).toFixed(4), +toY(q[1]).toFixed(4)]);
|
||||
});
|
||||
}
|
||||
notes.push({ t, x: toX(x), y: toY(y), d: Math.min(MAX_HOLD_MS, d), p: path });
|
||||
} else if (type & 8) { // spinner: x,y,time,type,hitSound,endTime -> hold in the middle
|
||||
const end = Math.round(+p[5] || t);
|
||||
notes.push({ t, x: 0.5, y: toY(PF_H / 2), d: Math.max(0, Math.min(MAX_HOLD_MS, end - t)) });
|
||||
} else if (type & 1) {
|
||||
notes.push({ t, x: toX(x), y: toY(y), d: 0 });
|
||||
}
|
||||
}
|
||||
if (mode === 1) notes = flowPlace(flow.sort((a, z) => a.t - z.t));
|
||||
notes.sort((a, z) => a.t - z.t);
|
||||
// one cursor: notes at the same moment (mania chords) collapse to the first
|
||||
notes = notes.filter((n, i) => i === 0 || n.t - notes[i - 1].t > 1);
|
||||
if (!notes.length) res.error = 'no hit objects';
|
||||
else if (notes.length > MAX_NOTES) res.error = 'too many notes (' + notes.length + ', max ' + MAX_NOTES + ')';
|
||||
res.notes = notes;
|
||||
return res;
|
||||
};
|
||||
|
||||
// Title of an imported map: "Insane (mapper)", other modes marked: "ONI (mapper, taiko)"
|
||||
export const importedTitle = (diff) => {
|
||||
const extra = [diff.creator, diff.mode ? MODES[diff.mode] : ''].filter(Boolean).join(', ');
|
||||
return diff.version + (extra ? ' (' + extra + ')' : '');
|
||||
};
|
||||
|
||||
// ── .osz -> { meta, audio: { name, data }, background: { name, data } | null, diffs, skipped } ──
|
||||
// null when the zip has no .osu files (then it is a normal archive)
|
||||
export const readOsz = async (buffer) => {
|
||||
let zip;
|
||||
try { zip = await JSZip.loadAsync(buffer); } catch (_) { return null; }
|
||||
const files = Object.values(zip.files).filter(f => !f.dir);
|
||||
const osuFiles = files.filter(f => /\.osu$/i.test(f.name)).slice(0, MAX_DIFFS);
|
||||
if (!osuFiles.length) return null;
|
||||
const byName = (name) => {
|
||||
if (!name) return null;
|
||||
const want = name.replace(/\\/g, '/').toLowerCase();
|
||||
return files.find(f => f.name.toLowerCase() === want) || files.find(f => f.name.toLowerCase().endsWith('/' + want)) || null;
|
||||
};
|
||||
const diffs = [], skipped = [];
|
||||
let meta = null, audioName = null, bgName = null;
|
||||
for (const f of osuFiles) {
|
||||
const text = await f.async('string');
|
||||
if (text.length > MAX_OSU_BYTES) { skipped.push({ version: f.name, reason: 'file too large' }); continue; }
|
||||
const d = convertOsu(text);
|
||||
if (d.error) { skipped.push({ version: d.version, reason: d.error }); continue; }
|
||||
diffs.push(d);
|
||||
if (!meta) meta = d.meta;
|
||||
if (!audioName) audioName = d.audioFile;
|
||||
if (!bgName) {
|
||||
const ev = parseOsu(text).Events.find(l => /^0\s*,\s*0\s*,/.test(l));
|
||||
const m = ev && /^0\s*,\s*0\s*,\s*"?([^",]+)"?/.exec(ev);
|
||||
if (m) bgName = m[1].trim();
|
||||
}
|
||||
}
|
||||
// audio: the file the difficulties name, or any audio file in the set
|
||||
const audioFile = byName(audioName) || files.find(f => /\.(mp3|ogg|wav|flac|m4a)$/i.test(f.name));
|
||||
const bgFile = byName(bgName) || files.find(f => /\.(jpe?g|png)$/i.test(f.name));
|
||||
diffs.sort((a, z) => a.notes.length - z.notes.length); // easiest first
|
||||
return {
|
||||
meta: meta || {},
|
||||
audio: audioFile ? { name: audioFile.name, data: await audioFile.async('nodebuffer') } : null,
|
||||
background: bgFile ? { name: bgFile.name, data: await bgFile.async('nodebuffer') } : null,
|
||||
diffs,
|
||||
skipped,
|
||||
};
|
||||
};
|
||||
|
||||
// "Artist - Title" for the item title
|
||||
export const setTitle = (meta) => [meta.Artist || meta.ArtistUnicode, meta.Title || meta.TitleUnicode].filter(Boolean).join(' - ');
|
||||
|
||||
// Store converted difficulties as maps of an item (or album entry). A difficulty that is already
|
||||
// there (same title and note count) is skipped, so importing a set twice adds nothing.
|
||||
// -> { ids, skipped: [{ version, reason }] }
|
||||
export const saveDiffs = async ({ itemId, albumItemId = null, userId, diffs, durationMs = 0 }) => {
|
||||
const ids = [], skipped = [];
|
||||
for (const d of diffs) {
|
||||
const v = cleanNotes(d.notes, 0);
|
||||
if (v.error) { skipped.push({ version: d.version, reason: v.error }); continue; }
|
||||
const title = cleanTitle(importedTitle(d));
|
||||
const [dupe] = await db`
|
||||
SELECT id FROM sqc_maps
|
||||
WHERE item_id = ${itemId} AND album_item_id IS NOT DISTINCT FROM ${albumItemId}::int
|
||||
AND title = ${title} AND note_count = ${v.notes.length}
|
||||
LIMIT 1
|
||||
`;
|
||||
if (dupe) { skipped.push({ version: d.version, reason: 'already imported' }); continue; }
|
||||
const last = v.notes[v.notes.length - 1];
|
||||
ids.push(await insertMap({ itemId, albumItemId, userId, title, notes: v.notes, durationMs: durationMs || last.t + last.d + 2000 }));
|
||||
}
|
||||
return { ids, skipped };
|
||||
};
|
||||
@@ -299,6 +299,7 @@
|
||||
"focus_comment": "Kommentarfeld fokussieren",
|
||||
"send_comment": "Kommentar senden",
|
||||
"flash_yank": "Flash-Yank ein-/ausschalten",
|
||||
"square_clicker": "Square Clicker (Audio- / Video-Posts)",
|
||||
"open_settings": "Einstellungen öffnen/schließen",
|
||||
"tag_exclude": "Tag-Ausschluss öffnen",
|
||||
"tag_input": "Tag-Eingabe öffnen",
|
||||
|
||||
@@ -299,6 +299,7 @@
|
||||
"focus_comment": "focus comment input",
|
||||
"send_comment": "send comment",
|
||||
"flash_yank": "enable/disable flash yank",
|
||||
"square_clicker": "Square Clicker (audio / video items)",
|
||||
"open_settings": "open/close settings",
|
||||
"tag_exclude": "open tag exclude",
|
||||
"tag_input": "open tag input",
|
||||
|
||||
@@ -297,6 +297,7 @@
|
||||
"focus_comment": "focus op commentaarinvoer",
|
||||
"send_comment": "opmerking verzenden",
|
||||
"flash_yank": "flash yank in/uitschakelen",
|
||||
"square_clicker": "Square Clicker (audio- / video-posts)",
|
||||
"open_settings": "instellingen openen/sluiten",
|
||||
"tag_exclude": "open tag uitsluiten",
|
||||
"tag_input": "open tag invoer",
|
||||
|
||||
@@ -295,6 +295,7 @@
|
||||
"focus_comment": "Kommentareingabe fokussieren",
|
||||
"send_comment": "Kommentar senden",
|
||||
"flash_yank": "Blitz-Rucken aktivieren/deaktivieren",
|
||||
"square_clicker": "Square Clicker (Audio- / Video-Posts)",
|
||||
"open_settings": "Einstellungen auf-/zuklappen",
|
||||
"tag_exclude": "Etiketten-Ausschluss öffnen",
|
||||
"tag_input": "Etiketten-Eingabe öffnen",
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import audit from '../../audit.mjs';
|
||||
import { isSquareClickerActive, cleanNotes, cleanTitle, canSeeItem, resolveTarget, insertMap } from '../../square_clicker.mjs';
|
||||
import { convertOsu, saveDiffs } from '../../beatmap.mjs';
|
||||
|
||||
// Square Clicker API: user-made beatmaps for audio and video items and their scores.
|
||||
// 404 when the feature is switched off (websrv.square_clicker_enabled: false, see inc/square_clicker.mjs).
|
||||
// Maps of an album belong to one of its entries (album_item_id), since every entry is its own track.
|
||||
const notFound = (res) => res.json({ success: false, msg: 'Not found' }, 404);
|
||||
const bodyOf = (req) => req.body || req.post || {};
|
||||
|
||||
export default router => {
|
||||
// List the maps of an item
|
||||
router.get(/^\/api\/v2\/sqc\/maps\/?$/, async (req, res) => {
|
||||
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
|
||||
const itemId = parseInt(req.url.qs?.item_id, 10);
|
||||
if (!itemId) return res.json({ success: false, msg: 'item_id required' }, 400);
|
||||
const subId = parseInt(req.url.qs?.album_item_id, 10) || null;
|
||||
const [item] = await db`SELECT id, username, COALESCE(visibility, 0) AS visibility FROM items WHERE id = ${itemId} AND active = true AND is_deleted = false LIMIT 1`;
|
||||
if (!item || !canSeeItem(item, req.session)) return notFound(res);
|
||||
const maps = await db`
|
||||
SELECT m.id, m.title, m.note_count, m.duration_ms, m.plays, m.created_at, m.user_id, m.album_item_id,
|
||||
u.user AS username,
|
||||
(SELECT MAX(s.score) FROM sqc_scores s WHERE s.map_id = m.id) AS best
|
||||
FROM sqc_maps m
|
||||
LEFT JOIN "user" u ON u.id = m.user_id
|
||||
WHERE m.item_id = ${itemId} AND m.album_item_id IS NOT DISTINCT FROM ${subId}::int
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
return res.json({ success: true, maps });
|
||||
});
|
||||
|
||||
// One map with its notes and top 10 scores
|
||||
router.get(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/?$/, async (req, res) => {
|
||||
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const [map] = await db`
|
||||
SELECT m.id, m.item_id, m.album_item_id, m.title, m.notes, m.note_count, m.duration_ms, m.plays, m.created_at, m.user_id,
|
||||
u.user AS username, i.username AS item_owner, COALESCE(i.visibility, 0) AS visibility
|
||||
FROM sqc_maps m
|
||||
JOIN items i ON i.id = m.item_id AND i.active = true AND i.is_deleted = false
|
||||
LEFT JOIN "user" u ON u.id = m.user_id
|
||||
WHERE m.id = ${id}
|
||||
LIMIT 1
|
||||
`;
|
||||
if (!map || !canSeeItem({ visibility: map.visibility, username: map.item_owner }, req.session)) return notFound(res);
|
||||
const scores = await db`
|
||||
SELECT s.score, s.accuracy, s.max_combo, s.created_at, u.user AS username
|
||||
FROM sqc_scores s
|
||||
LEFT JOIN "user" u ON u.id = s.user_id
|
||||
WHERE s.map_id = ${id}
|
||||
ORDER BY s.score DESC, s.created_at ASC
|
||||
LIMIT 10
|
||||
`;
|
||||
delete map.item_owner;
|
||||
delete map.visibility;
|
||||
return res.json({ success: true, map, scores });
|
||||
});
|
||||
|
||||
// Create a map (logged in; audio and video items)
|
||||
router.post(/^\/api\/v2\/sqc\/maps\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
|
||||
const b = bodyOf(req);
|
||||
const target = await resolveTarget(b.item_id, b.album_item_id, req.session);
|
||||
if (target.error) return res.json({ success: false, msg: target.error }, target.code);
|
||||
const duration = Math.max(0, Math.min(6 * 3600 * 1000, parseInt(b.duration_ms, 10) || 0));
|
||||
const v = cleanNotes(b.notes, duration);
|
||||
if (v.error) return res.json({ success: false, msg: v.error }, 400);
|
||||
const id = await insertMap({ itemId: target.item.id, albumItemId: target.albumItemId, userId: req.session.id, title: b.title, notes: v.notes, durationMs: duration });
|
||||
return res.json({ success: true, id });
|
||||
});
|
||||
|
||||
// Import beatmap difficulties (.osu texts; the browser unpacks .osz sets and sends only these) as maps
|
||||
// of an item or album entry. Standard mode only; other modes and invalid files are reported as skipped.
|
||||
router.post(/^\/api\/v2\/sqc\/import\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
|
||||
const b = bodyOf(req);
|
||||
const target = await resolveTarget(b.item_id, b.album_item_id, req.session);
|
||||
if (target.error) return res.json({ success: false, msg: target.error }, target.code);
|
||||
const files = Array.isArray(b.files) ? b.files.slice(0, 40) : [];
|
||||
if (!files.length) return res.json({ success: false, msg: 'No .osu files' }, 400);
|
||||
if (files.reduce((sum, f) => sum + String((f && f.text) || '').length, 0) > 16 * 1024 * 1024) {
|
||||
return res.json({ success: false, msg: 'Too large' }, 413);
|
||||
}
|
||||
const diffs = [], skipped = [];
|
||||
for (const f of files) {
|
||||
const d = convertOsu(String((f && f.text) || ''));
|
||||
if (d.error) skipped.push({ version: d.version || String((f && f.name) || '?'), reason: d.error });
|
||||
else diffs.push(d);
|
||||
}
|
||||
diffs.sort((a, z) => a.notes.length - z.notes.length);
|
||||
const duration = Math.max(0, parseInt(b.duration_ms, 10) || 0);
|
||||
const r = await saveDiffs({ itemId: target.item.id, albumItemId: target.albumItemId, userId: req.session.id, diffs, durationMs: duration });
|
||||
return res.json({ success: true, imported: r.ids.length, ids: r.ids, skipped: [...skipped, ...r.skipped] });
|
||||
});
|
||||
|
||||
// Edit a map (its creator, or staff: audited). Title and/or notes; changed notes clear the old scores,
|
||||
// which were made on a different map.
|
||||
router.put(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const [map] = await db`SELECT id, item_id, user_id, title, duration_ms FROM sqc_maps WHERE id = ${id} LIMIT 1`;
|
||||
if (!map) return notFound(res);
|
||||
const isOwner = map.user_id === req.session.id;
|
||||
const isStaff = !!(req.session.admin || req.session.is_moderator);
|
||||
if (!isOwner && !isStaff) return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||
const b = bodyOf(req);
|
||||
const upd = {};
|
||||
if (b.title !== undefined) upd.title = cleanTitle(b.title);
|
||||
let notesChanged = false;
|
||||
if (b.notes !== undefined) {
|
||||
const v = cleanNotes(b.notes, map.duration_ms);
|
||||
if (v.error) return res.json({ success: false, msg: v.error }, 400);
|
||||
upd.notes = db.json(v.notes);
|
||||
upd.note_count = v.notes.length;
|
||||
notesChanged = true;
|
||||
}
|
||||
if (!Object.keys(upd).length) return res.json({ success: false, msg: 'Nothing to change' }, 400);
|
||||
await db`UPDATE sqc_maps SET ${db(upd)} WHERE id = ${id}`;
|
||||
let scoresCleared = 0;
|
||||
if (notesChanged) {
|
||||
const r = await db`DELETE FROM sqc_scores WHERE map_id = ${id}`;
|
||||
scoresCleared = r.count || 0;
|
||||
await db`UPDATE sqc_maps SET plays = 0 WHERE id = ${id}`;
|
||||
}
|
||||
if (!isOwner) {
|
||||
audit.log(req.session.id, 'sqc_map_edit', 'sqc_map', id, { item_id: map.item_id, title: upd.title || map.title, notes_changed: notesChanged }).catch(() => {});
|
||||
}
|
||||
return res.json({ success: true, id, scores_cleared: scoresCleared });
|
||||
});
|
||||
|
||||
// Submit a score (logged in). Plausibility-checked against the map, not trusted blindly.
|
||||
router.post(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/scores\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const [map] = await db`SELECT id, note_count FROM sqc_maps WHERE id = ${id} LIMIT 1`;
|
||||
if (!map) return notFound(res);
|
||||
const b = bodyOf(req);
|
||||
const hits = b.hits && typeof b.hits === 'object' ? b.hits : {};
|
||||
const h = {
|
||||
300: Math.max(0, parseInt(hits[300], 10) || 0),
|
||||
100: Math.max(0, parseInt(hits[100], 10) || 0),
|
||||
50: Math.max(0, parseInt(hits[50], 10) || 0),
|
||||
miss: Math.max(0, parseInt(hits.miss, 10) || 0),
|
||||
};
|
||||
if (h[300] + h[100] + h[50] + h.miss !== map.note_count) {
|
||||
return res.json({ success: false, msg: 'Score does not match the map' }, 400);
|
||||
}
|
||||
const maxCombo = Math.max(0, Math.min(map.note_count, parseInt(b.max_combo, 10) || 0));
|
||||
const accuracy = map.note_count ? +(((h[300] * 300 + h[100] * 100 + h[50] * 50) / (map.note_count * 300)) * 100).toFixed(2) : 0;
|
||||
// Upper bound: every note a 300 at full combo multiplier
|
||||
const maxScore = map.note_count * 300 * (1 + map.note_count / 25);
|
||||
const score = Math.max(0, Math.min(Math.round(maxScore), parseInt(b.score, 10) || 0));
|
||||
|
||||
await db`
|
||||
INSERT INTO sqc_scores ${db({
|
||||
map_id: id,
|
||||
user_id: req.session.id,
|
||||
score,
|
||||
accuracy,
|
||||
max_combo: maxCombo,
|
||||
hits: db.json(h),
|
||||
created_at: ~~(Date.now() / 1e3),
|
||||
})}
|
||||
`;
|
||||
await db`UPDATE sqc_maps SET plays = plays + 1 WHERE id = ${id}`;
|
||||
return res.json({ success: true, score, accuracy });
|
||||
});
|
||||
|
||||
// Delete a map: its creator, or staff (audited)
|
||||
router.delete(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const [map] = await db`SELECT id, item_id, user_id, title FROM sqc_maps WHERE id = ${id} LIMIT 1`;
|
||||
if (!map) return notFound(res);
|
||||
const isOwner = map.user_id === req.session.id;
|
||||
const isStaff = !!(req.session.admin || req.session.is_moderator);
|
||||
if (!isOwner && !isStaff) return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||
await db`DELETE FROM sqc_maps WHERE id = ${id}`;
|
||||
if (!isOwner) {
|
||||
audit.log(req.session.id, 'sqc_map_delete', 'sqc_map', id, { item_id: map.item_id, title: map.title }).catch(() => {});
|
||||
}
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import cfg from './config.mjs';
|
||||
import db from './sql.mjs';
|
||||
|
||||
// Square Clicker: audio / video items become playable rhythm-game maps. For everyone, on unless
|
||||
// websrv.square_clicker_enabled is false. Callers may pass (date, session); both are ignored.
|
||||
export const isSquareClickerActive = () => cfg.websrv?.square_clicker_enabled !== false;
|
||||
|
||||
export const MAX_NOTES = 3000;
|
||||
export const MAX_HOLD_MS = 60000;
|
||||
const MAX_TITLE = 80;
|
||||
const MAX_PATH_POINTS = 1000; // per slider
|
||||
const MAX_MAP_PATH_POINTS = 50000; // per map
|
||||
|
||||
const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0));
|
||||
export const cleanTitle = (t) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TITLE) || 'Untitled';
|
||||
|
||||
// Validate + normalise a notes array (used by create, edit and imports). -> { notes } or { error }
|
||||
// Slider path: [[dt, x, y], ...] inside the hold (0..d), sorted by time; dropped if under 2 points
|
||||
export const cleanNotes = (raw, duration) => {
|
||||
if (!Array.isArray(raw) || raw.length < 1) return { error: 'A map needs at least one note' };
|
||||
if (raw.length > MAX_NOTES) return { error: 'Too many notes (max ' + MAX_NOTES + ')' };
|
||||
const cleanPath = (p, d) => {
|
||||
if (!Array.isArray(p) || !d) return null;
|
||||
const pts = p.slice(0, MAX_PATH_POINTS)
|
||||
.filter(q => Array.isArray(q) && q.length >= 3)
|
||||
.map(q => [Math.max(0, Math.min(d, Math.round(Number(q[0]) || 0))), +clamp01(q[1]).toFixed(4), +clamp01(q[2]).toFixed(4)])
|
||||
.sort((a, z) => a[0] - z[0]);
|
||||
return pts.length >= 2 ? pts : null;
|
||||
};
|
||||
const notes = raw
|
||||
.map(n => {
|
||||
const d = Math.max(0, Math.min(MAX_HOLD_MS, Math.round(Number(n && n.d) || 0)));
|
||||
const note = { t: Math.max(0, Math.round(Number(n && n.t) || 0)), x: +clamp01(n && n.x).toFixed(4), y: +clamp01(n && n.y).toFixed(4), d };
|
||||
const path = cleanPath(n && n.p, d);
|
||||
if (path) note.p = path;
|
||||
return note;
|
||||
})
|
||||
.filter(n => !duration || n.t <= duration + 1000)
|
||||
.sort((a, z) => a.t - z.t);
|
||||
if (!notes.length) return { error: 'No valid notes' };
|
||||
if (notes.reduce((sum, n) => sum + (n.p ? n.p.length : 0), 0) > MAX_MAP_PATH_POINTS) return { error: 'Slider paths too long' };
|
||||
return { notes };
|
||||
};
|
||||
|
||||
// Can the session see this item (private items only for their owner and staff)
|
||||
export const canSeeItem = (item, session) => {
|
||||
if ((item.visibility || 0) !== 2) return true;
|
||||
if (!session) return false;
|
||||
return session.admin || session.is_moderator || (item.username || '').toLowerCase() === (session.user || '').toLowerCase();
|
||||
};
|
||||
|
||||
// Audio and video files; YouTube items are iframes the game can't time or control
|
||||
const playableMime = (mime) => {
|
||||
mime = String(mime || '');
|
||||
return (mime.startsWith('audio/') || mime.startsWith('video/')) && mime !== 'video/youtube';
|
||||
};
|
||||
|
||||
// What a map belongs to: an item, or one entry of an album item (every entry is its own track).
|
||||
// -> { item, albumItemId } or { error, code }. albumItemId is null for normal items.
|
||||
export const resolveTarget = async (itemId, albumItemId, session) => {
|
||||
itemId = parseInt(itemId, 10) || 0;
|
||||
albumItemId = parseInt(albumItemId, 10) || null;
|
||||
const [item] = await db`SELECT id, mime, username, COALESCE(visibility, 0) AS visibility, is_album FROM items WHERE id = ${itemId} AND active = true AND is_deleted = false LIMIT 1`;
|
||||
if (!item || !canSeeItem(item, session)) return { error: 'Not found', code: 404 };
|
||||
if (albumItemId) {
|
||||
const [sub] = await db`SELECT id, mime FROM album_items WHERE id = ${albumItemId} AND item_id = ${itemId} LIMIT 1`;
|
||||
if (!sub) return { error: 'Not found', code: 404 };
|
||||
if (!playableMime(sub.mime)) return { error: 'Maps are only for audio and video', code: 400 };
|
||||
return { item, albumItemId: sub.id };
|
||||
}
|
||||
if (!playableMime(item.mime)) return { error: 'Maps are only for audio and video items', code: 400 };
|
||||
return { item, albumItemId: null };
|
||||
};
|
||||
|
||||
// Store a map (notes already validated with cleanNotes). -> id
|
||||
export const insertMap = async ({ itemId, albumItemId = null, userId, title, notes, durationMs = 0 }) => {
|
||||
const [row] = await db`
|
||||
INSERT INTO sqc_maps ${db({
|
||||
item_id: itemId,
|
||||
album_item_id: albumItemId,
|
||||
user_id: userId,
|
||||
title: cleanTitle(title),
|
||||
notes: db.json(notes),
|
||||
note_count: notes.length,
|
||||
duration_ms: Math.max(0, Math.min(6 * 3600 * 1000, Math.round(durationMs) || 0)),
|
||||
created_at: ~~(Date.now() / 1e3),
|
||||
})}
|
||||
RETURNING id
|
||||
`;
|
||||
return row.id;
|
||||
};
|
||||
Reference in New Issue
Block a user