starraid/star-raid/server/api.js
JOJO 3ffb51098b 新增海盗、植物两大种族敌人各15种
- 海盗族15种:5杂兵+6小兵+4精英,锈红/金/骨白配色
- 植物族15种:5杂兵+6小兵+4精英,叶绿/深绿/花粉粉配色
- 各族子弹配色严格≤3色:海盗锈钉/炮弹/燃烧瓶,植物荆棘/种子/孢子
- 敌表新增 race/tier 标签,波次生成按层级池抽取
- 更新 CODEX_KEYS 与客户端图鉴总数
2026-08-06 21:20:39 +08:00

272 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
/* ================= API 处理器 =================
所有 SQL 均为 prepared statement禁止字符串拼接用户输入 */
const db = require("./db");
const auth = require("./auth");
const { vUsername, vEmail, vPassword, vInt, rateLimit, clientIp } = require("./util");
/* 图鉴合法 key 白名单(与 js/data.js 保持同步27主武+15副武+10僚机+8被动+45敌人=105 */
const CODEX_KEYS = new Set([
"vulcan", "shotgun", "needle", "flame", "shotgun2", "tesla", "railgun", "deathray",
"crescent", "fission", "frost", "swarm", "bubble", "starfall", "boomerang", "singularity",
"wavecannon", "holy", "thundercall", "arcshu", "volt", "dawn", "proto", "nebula", "loiter",
"beegrenade", "chlorophyte",
"broad", "sweep", "leech", "orbit", "sats", "pd", "slowfield", "flares", "seeker",
"frostnova", "thorns", "sticky", "magnetar", "executor", "airstrike",
"gunner", "gunner2", "sniperd", "beamer", "bomber", "rammer",
"healer", "shieldd", "missileD", "bladeD",
"fire", "crit", "shield", "nano", "mag", "bounty", "odcore", "ghost",
"scout", "weaver", "kami", "splitter", "mine", "gunship", "sniper", "rock", "mini",
"gaunt", "sporeT", "ripper", "boomer", "spitter", "spinegun",
"p_scav", "p_raid", "p_board", "p_drift", "p_swab", "p_cut", "p_blund", "p_keg", "p_harp", "p_cannon", "p_flint",
"p_mara", "p_bucc", "p_frig", "p_dread",
"f_spore", "f_seed", "f_sprout", "f_thorn", "f_mote", "f_vine", "f_shoot", "f_archer", "f_bomb", "f_pollen", "f_bud",
"f_nettle", "f_over", "f_mother", "f_treant"
]);
const CODEX_TOTAL = CODEX_KEYS.size;
const RUN_MODES = new Set(["stage", "endless", "rush"]);
/* 存档白名单(与 js/data.js SHIPS、js/ui.js META 保持同步) */
const SHIP_KEYS = new Set(["falcon", "ghost", "bastion", "valkyrie", "asura"]);
const PASSIVE_KEYS = new Set(["fire", "crit", "shield", "nano", "mag", "bounty", "odcore", "ghost"]);
const META_MAX = { weapon: 2, armor: 2, hpup: 2, luck: 2, wallet: 3, mag2: 2, odstart: 1 };
const DEFAULT_STATE = { coins: 0, ships: ["falcon"], ship: "falcon", m: {}, best: { s0: 0, s1: 0, e0: 0, e1: 0, stage: 0 }, rush: {}, mute: false, music: true };
const qInsUser = db.prepare("INSERT INTO users(username,email,pass_hash,created_at) VALUES(?,?,?,?)");
const qInsStats = db.prepare("INSERT OR IGNORE INTO user_stats(user_id) VALUES(?)");
const qByName = db.prepare("SELECT * FROM users WHERE username=?");
const qByEmail = db.prepare("SELECT * FROM users WHERE email=?");
const qCodex = db.prepare("SELECT entry_key FROM codex_unlocks WHERE user_id=?");
const qInsCodex = db.prepare("INSERT OR IGNORE INTO codex_unlocks(user_id,entry_key,unlocked_at) VALUES(?,?,?)");
const qStats = db.prepare("SELECT * FROM user_stats WHERE user_id=?");
const qInsRun = db.prepare("INSERT INTO runs(user_id,mode,diff,score,wave,stage,time_sec,boss_kills,win,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)");
const qUpdStats = db.prepare(`INSERT INTO user_stats(user_id,boss_kills,endless_high,runs,updated_at) VALUES(?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
boss_kills=boss_kills+excluded.boss_kills,
endless_high=MAX(endless_high,excluded.endless_high),
runs=runs+1,
updated_at=excluded.updated_at`);
const qGetState = db.prepare("SELECT state FROM player_state WHERE user_id=?");
const qPutState = db.prepare(`INSERT INTO player_state(user_id,state,updated_at) VALUES(?,?,?)
ON CONFLICT(user_id) DO UPDATE SET state=excluded.state, updated_at=excluded.updated_at`);
const qGetSave = db.prepare("SELECT payload, updated_at FROM run_saves WHERE user_id=?");
const qPutSave = db.prepare(`INSERT INTO run_saves(user_id,payload,updated_at) VALUES(?,?,?)
ON CONFLICT(user_id) DO UPDATE SET payload=excluded.payload, updated_at=excluded.updated_at`);
const qDelSave = db.prepare("DELETE FROM run_saves WHERE user_id=?");
function statsOf(userId) {
const s = qStats.get(userId) || { boss_kills: 0, endless_high: 0, runs: 0 };
return { bossKills: s.boss_kills, endlessHigh: s.endless_high, runs: s.runs };
}
function codexOf(userId) {
return qCodex.all(userId).map((r) => r.entry_key);
}
function stateOf(userId) {
const r = qGetState.get(userId);
if (!r) return null; // null=从未同步(老账号首次登陆:客户端用本地存档初始化上传)
try { return JSON.parse(r.state); } catch (e) { return null; }
}
/* 存档白名单校验:只留合法字段并 clamp任何字段非法则整体拒收 */
function vState(s) {
if (!s || typeof s !== "object" || Array.isArray(s)) return null;
if (!vInt(s.coins, 0, 1e7)) return null;
if (!Array.isArray(s.ships)) return null;
const ships = [...new Set(s.ships.filter((k) => SHIP_KEYS.has(k)))].slice(0, 8);
if (!ships.includes("falcon")) ships.unshift("falcon");
const ship = SHIP_KEYS.has(s.ship) ? s.ship : "falcon";
const m = {};
if (s.m && typeof s.m === "object") for (const k of Object.keys(META_MAX))
if (vInt(s.m[k], 0, META_MAX[k])) m[k] = s.m[k];
const best = { s0: 0, s1: 0, e0: 0, e1: 0, stage: 0 };
if (s.best && typeof s.best === "object") for (const k of Object.keys(best))
if (vInt(s.best[k], 0, 1e8)) best[k] = s.best[k];
const rush = {};
if (s.rush && typeof s.rush === "object") for (const k of Object.keys(s.rush).slice(0, 32))
if (/^\d{1,2}$/.test(k) && vInt(s.rush[k], 0, 8640000)) rush[k] = s.rush[k];
/* 音频开关:缺省=不静音/音乐开(老存档无此字段) */
const mute = s.mute === true;
const music = s.music !== false;
return { coins: s.coins, ships, ship, m, best, rush, mute, music };
}
/* 无尽模式断点存档校验(波次检查点快照;装备 key 复用图鉴白名单) */
function vEquipMap(o) {
if (!o || typeof o !== "object" || Array.isArray(o)) return null;
const out = {};
for (const k of Object.keys(o).slice(0, 8))
if (CODEX_KEYS.has(k) && vInt(o[k], 1, 5)) out[k] = o[k];
return out;
}
function vLoadout(a, max, src) {
if (!Array.isArray(a)) return null;
return [...new Set(a.filter((k) => typeof k === "string" && src[k]))].slice(0, max);
}
function vRunSave(s) {
if (!s || typeof s !== "object" || Array.isArray(s)) return null;
if (s.mode !== "endless") return null; // 断点续玩仅限无尽模式
if (!SHIP_KEYS.has(s.ship)) return null;
if (!vInt(s.diff, 0, 1)) return null;
if (!vInt(s.wave, 1, 9999) || !vInt(s.score, 0, 1e8) || !vInt(s.coins, 0, 1e7)) return null;
if (!vInt(s.kills, 0, 1e6) || !vInt(s.level, 1, 999) || !vInt(s.xp, 0, 1e6) || !vInt(s.xpNeed, 1, 1e9)) return null;
if (!vInt(s.lives, 0, 99) || !vInt(s.maxHp, 1, 9999) || !vInt(s.hp, 0, 9999)) return null;
if (!vInt(s.shield, 0, 9) || !vInt(s.od, 0, 100)) return null;
if (!vInt(s.bossN, 0, 9999) || !vInt(s.bossKills, 0, 60) || !vInt(s.time, 0, 5184000)) return null;
const weapons = vEquipMap(s.weapons), subs = vEquipMap(s.subs), drones = vEquipMap(s.drones);
if (!weapons || !subs || !drones) return null;
const loadout = vLoadout(s.loadout, 5, weapons);
const subLoadout = vLoadout(s.subLoadout, 5, subs);
const droneLoadout = vLoadout(s.droneLoadout, 3, drones);
if (!loadout || !loadout.length || !subLoadout || !droneLoadout) return null;
const u = {};
if (s.u && typeof s.u === "object") for (const k of PASSIVE_KEYS)
if (vInt(s.u[k], 0, 5)) u[k] = s.u[k];
const luck = vInt(s.luck, 0, 2) ? s.luck : 0;
return { v: 1, mode: "endless", diff: s.diff, ship: s.ship, wave: s.wave, score: s.score,
coins: s.coins, kills: s.kills, level: s.level, xp: s.xp, xpNeed: s.xpNeed, luck,
weapons, loadout, subs, subLoadout, drones, droneLoadout, u,
lives: s.lives, maxHp: s.maxHp, hp: Math.min(s.hp, s.maxHp),
shield: s.shield, od: s.od, bossN: s.bossN, bossKills: s.bossKills, time: s.time };
}
/* ---------- GET /api/run_save无档返回 save:null ---------- */
function getRunSave(req, res, ctx) {
const r = qGetSave.get(ctx.user.id);
if (!r) return ctx.ok({ save: null });
let save = null;
try { save = JSON.parse(r.payload); } catch (e) { /* 损坏按无档处理 */ }
return ctx.ok({ save, updatedAt: r.updated_at });
}
/* ---------- POST /api/run_save每账号1栏位新档覆盖旧档 ---------- */
function putRunSave(req, res, ctx, body) {
if (!rateLimit("rsave:" + ctx.user.id, 40, 5 * 60000)) return ctx.fail(429, "同步太频繁");
const save = vRunSave(body && body.save);
if (!save) return ctx.fail(400, "非法对局存档");
qPutSave.run(ctx.user.id, JSON.stringify(save), Date.now());
return ctx.ok({ saved: true });
}
/* ---------- DELETE /api/run_save结算/死亡后清除) ---------- */
function delRunSave(req, res, ctx) {
qDelSave.run(ctx.user.id);
return ctx.ok({ deleted: true });
}
/* ---------- POST /api/register ---------- */
function register(req, res, ctx, body) {
if (!rateLimit("reg:" + ctx.ip, 10, 5 * 60000)) return ctx.fail(429, "操作太频繁,请稍后再试");
const username = body && body.username, email = body && body.email, password = body && body.password;
if (!vUsername(username)) return ctx.fail(400, "用户名需为 2-16 位(中文/英文/数字/下划线)");
if (!vEmail(email)) return ctx.fail(400, "邮箱格式不正确");
if (!vPassword(password)) return ctx.fail(400, "密码需为 8-72 位");
if (qByName.get(username)) return ctx.fail(409, "用户名已被注册");
if (qByEmail.get(email)) return ctx.fail(409, "邮箱已被注册");
const info = qInsUser.run(username.trim(), email.trim().toLowerCase(), auth.hashPassword(password), Date.now());
const uid = Number(info.lastInsertRowid);
qInsStats.run(uid);
const token = auth.createSession(db, uid);
ctx.setCookie = auth.sessionCookie(token, ctx.secure);
return ctx.ok({ user: { username, email: email.trim().toLowerCase() }, stats: statsOf(uid), codex: [], codexTotal: CODEX_TOTAL, state: DEFAULT_STATE });
}
/* ---------- POST /api/login ---------- */
function login(req, res, ctx, body) {
if (!rateLimit("login:" + ctx.ip, 20, 5 * 60000)) return ctx.fail(429, "尝试太频繁,请稍后再试");
const id = body && body.id, password = body && body.password;
if (typeof id !== "string" || typeof password !== "string" || id.length > 64 || password.length > 72)
return ctx.fail(401, "用户名或密码错误");
const u = id.includes("@") ? qByEmail.get(id.trim().toLowerCase()) : qByName.get(id.trim());
/* 统一错误信息,不泄露账号是否存在;对不存在账号也跑一次哈希,防时序探测 */
const hash = u ? u.pass_hash : "0".repeat(32) + ":" + "0".repeat(128);
if (!auth.verifyPassword(password, hash) || !u) return ctx.fail(401, "用户名或密码错误");
qInsStats.run(u.id);
const token = auth.createSession(db, u.id);
ctx.setCookie = auth.sessionCookie(token, ctx.secure);
return ctx.ok({ user: { username: u.username, email: u.email }, stats: statsOf(u.id), codex: codexOf(u.id), codexTotal: CODEX_TOTAL, state: stateOf(u.id) });
}
/* ---------- POST /api/logout ---------- */
function logout(req, res, ctx) {
auth.destroySession(db, ctx.token);
ctx.setCookie = auth.clearCookie(ctx.secure);
return ctx.ok({ bye: true });
}
/* ---------- GET /api/me未登录返回 user:null不产生 401 控制台噪音) ---------- */
function me(req, res, ctx) {
const u = ctx.user;
if (!u) return ctx.ok({ user: null });
return ctx.ok({ user: { username: u.username, email: u.email, createdAt: u.created_at }, stats: statsOf(u.id), codex: codexOf(u.id), codexTotal: CODEX_TOTAL, state: stateOf(u.id) });
}
/* ---------- GET /api/leaderboard?sort=endless|boss|codex ---------- */
const LB_COLS = { endless: "endless_high", boss: "boss_kills", codex: "codexN" };
const BOSS_TOTAL = 11; // 与 js/enemies.js BOSSES 数量同步
function leaderboard(req, res, ctx, _body, query) {
const sort = (query && query.get("sort")) || "";
const bossSort = sort === "boss";
/* Boss 列一律统计 BOSS 挑战(rush)击败过的不同 Boss 数去重stage 0~10不再出现累计击坠数 */
const bossSub = `(SELECT COUNT(DISTINCT r.stage) FROM runs r
WHERE r.user_id=u.id AND r.mode='rush' AND r.win=1 AND r.stage BETWEEN 0 AND 10)`;
const rows = db.prepare(`
SELECT u.username AS username, ${bossSub} AS bossKills, s.endless_high AS endlessHigh,
(SELECT COUNT(*) FROM codex_unlocks c WHERE c.user_id=u.id) AS codexN
FROM users u JOIN user_stats s ON s.user_id=u.id
ORDER BY ${bossSort ? "bossKills" : (LB_COLS[sort] || "endless_high")} DESC, u.id ASC LIMIT 100`).all();
return ctx.ok({ rows, codexTotal: CODEX_TOTAL, bossTotal: BOSS_TOTAL, sort });
}
/* ---------- POST /api/run ----------
结算上报:基础合理性校验(客户端游戏无法杜绝作弊,只挡低级伪造) */
function run(req, res, ctx, body) {
if (!rateLimit("run:" + ctx.user.id, 20, 5 * 60000)) return ctx.fail(429, "上报太频繁");
const b = body || {};
if (!RUN_MODES.has(b.mode)) return ctx.fail(400, "非法模式");
if (!vInt(b.diff, 0, 1)) return ctx.fail(400, "非法难度");
if (!vInt(b.score, 0, 1e8)) return ctx.fail(400, "非法分数");
if (!vInt(b.wave, 0, 9999) || !vInt(b.stage, 0, 99)) return ctx.fail(400, "非法进度");
if (!vInt(b.timeSec, 0, 86400)) return ctx.fail(400, "非法时长");
if (!vInt(b.bossKills, 0, 60)) return ctx.fail(400, "非法击坠数");
const keys = Array.isArray(b.codex) ? b.codex.slice(0, 100).filter((k) => CODEX_KEYS.has(k)) : [];
const uid = ctx.user.id, now = Date.now();
/* endless_high 只认本账号实际上报的无尽对局分数(不收 localStorage 历史值——浏览器级共享会串号,已踩坑) */
const endlessCand = b.mode === "endless" ? b.score : 0;
db.exec("BEGIN");
try {
qInsRun.run(uid, b.mode, b.diff, b.score, b.wave, b.stage, b.timeSec, b.bossKills, b.win ? 1 : 0, now);
qUpdStats.run(uid, b.bossKills, endlessCand, 1, now);
for (const k of keys) qInsCodex.run(uid, k, now);
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
return ctx.ok({ stats: statsOf(uid), codex: codexOf(uid), codexTotal: CODEX_TOTAL });
}
/* ---------- POST /api/state ----------
存档云同步:星币/机体/选中机体/永久强化/本地纪录(防抖由客户端负责) */
function saveState(req, res, ctx, body) {
if (!rateLimit("state:" + ctx.user.id, 60, 5 * 60000)) return ctx.fail(429, "同步太频繁");
const st = vState(body && body.state);
if (!st) return ctx.fail(400, "非法存档数据");
qPutState.run(ctx.user.id, JSON.stringify(st), Date.now());
return ctx.ok({ saved: true });
}
const ROUTES = {
"POST /api/register": { fn: register, auth: false },
"POST /api/login": { fn: login, auth: false },
"POST /api/logout": { fn: logout, auth: true },
"GET /api/me": { fn: me, auth: false },
"GET /api/leaderboard": { fn: leaderboard, auth: true },
"POST /api/run": { fn: run, auth: true },
"POST /api/state": { fn: saveState, auth: true },
"GET /api/run_save": { fn: getRunSave, auth: true },
"POST /api/run_save": { fn: putRunSave, auth: true },
"DELETE /api/run_save": { fn: delRunSave, auth: true },
};
module.exports = { ROUTES, CODEX_TOTAL };