diff --git a/.gitignore b/.gitignore index 4334987..e215ae4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ research/ .playwright-mcp/ cache/ +star-raid/data/ diff --git a/AGENTS.md b/AGENTS.md index 1ccb2b3..7b1f7da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,21 +4,24 @@ ## 项目概述 -- 纯 HTML5 Canvas + 原生 JavaScript,**无构建、无依赖、无框架** +- 客户端:纯 HTML5 Canvas + 原生 JavaScript,**无构建、无依赖、无框架** +- 服务端:**零 npm 依赖** Node 原生(`node:http` + `node:sqlite`,要求 Node≥22.5),SQLite 存储 - 240×320 像素画布,竖版弹幕射击(主武器/副武器/僚机/Boss 战/Boss Rush 模式) -- 游戏本体在 `star-raid/` 子目录 +- 账号系统:注册/登陆(用户名+邮箱+密码)、大厅排行榜 Top100、图鉴收集点亮同步 +- 游戏本体在 `star-raid/` 子目录,后端在 `star-raid/server/`,部署见 `star-raid/DEPLOY.md` ## 常用命令 ```bash cd star-raid -# 语法检查(每次改完 JS 必须跑) -for f in js/*.js; do node --check "$f" || echo "FAIL $f"; done +# 语法检查(每次改完 JS 必须跑;server/ 也要查) +for f in js/*.js server/*.js; do node --check "$f" || echo "FAIL $f"; done -# 本地运行(用户试玩用) -python3 -m http.server 8643 # 然后访问 http://localhost:8643 -# 注意:8642 端口有浏览器缓存污染,固定用 8643 +# 启动完整版(游戏+后端,Node≥22.5) +node server/server.js # 或 npm start;访问 http://localhost:8643 +# 注意:8643 现由 node server 占用;旧 python http.server 试玩方式已废弃 +# 数据库:star-raid/data/starraid.db(git 忽略,删除重启自动重建空库) # Git(仓库根 = 工作区根目录,游戏在子目录) git add -A && git commit -m "提交信息" @@ -26,9 +29,9 @@ git add -A && git commit -m "提交信息" ## 验证规则(最高优先级,违反会被用户骂) -1. **禁止运行游戏测试视觉效果**——包括 playwright 截图、浏览器数据断言。视觉和手感全部由用户亲自验收。Agent 只做:`node --check` 语法检查 + git 提交。 -2. 交付时必须诚实标注验证状态:"仅语法检查通过,未运行验证"。 -3. 完成了操作 ≠ 任务完成,未经验证不得说"修复了/完成了"。 +1. **游戏视觉/手感禁止 Agent 验收**——用户亲自验收。但**账号/大厅/排行榜/结算上报等功能流允许 playwright 测试**(用户 2026-08-06 明确授权):注册/登陆/数据断言可跑,只是不许用截图评判游戏画面效果。 +2. 交付时必须诚实标注验证状态。 +3. 完成了操作 ≠ 任务完成,未经验证不得说“修复了/完成了”。 ## 项目结构 @@ -36,18 +39,29 @@ git add -A && git commit -m "提交信息" star-raid/ ├── index.html # 入口 + 全部覆盖层 UI 结构 ├── css/style.css # 全部样式(像素风 UI 变量配色) +├── server/ # 后端(零依赖 node:http+node:sqlite) +│ ├── server.js # 入口:静态白名单+/api路由+安全头 +│ ├── db.js # SQLite schema(users/sessions/user_stats/codex_unlocks/runs) +│ ├── auth.js # scrypt密码哈希+服务端session +│ ├── api.js # API处理器;CODEX_KEYS 白名单(新增装备必须同步!) +│ └── util.js # body解析/限流/输入校验 +├── data/ # SQLite 数据库(git 忽略) ├── js/ │ ├── config.js # BAL 平衡参数、常量 │ ├── data.js # WEAPONS/SUBS/DRONES 装备数据表 │ ├── core.js # 游戏状态 G、发射逻辑、explodeFx 爆炸特效库 │ ├── combat.js # 子弹运动/碰撞/光束判定/zones 区域效果 │ ├── enemies.js # ENEMIES 杂兵表、BOSSES 表、bossMain/bossMinor 攻击库 -│ ├── render.js # 世界渲染(drawBoss 分支调度、血条、子弹、zones) +│ ├── render.js # 世界渲染(battleUI 门控只在战斗界面绘制游戏对象) │ ├── boss_art.js # Boss 美术库(drawProgenitor/drawAssault/drawPiano/drawUsurper/drawSaucer) │ ├── sprites.js # 像素字符画 bake 系统、SPR 精灵表、BHALO 弹光晕 │ ├── audio.js # SFX 音效(WebAudio 合成) -│ └── ui.js # 菜单/图鉴/rush 选择/老虎机/结算 -├── demo/ # 动作设计 demo(如 tentacle-sweep.html 触手扫击) +│ ├── net.js # NET:API 客户端(fetch 封装/图鉴同步/submitRun) +│ ├── auth.js # 认证流程(登陆/注册/登出 + boot 引导) +│ ├── home.js # 大厅:排行榜 + 图鉴展柜背景(drawHomeShowcase) +│ └── ui.js # 菜单/图鉴/rush 选择/老虎机/结算(submitRun 上报) +├── demo/ # 动作设计 demo(不在静态白名单,公网不可见) +├── DEPLOY.md # 部署指南(公网/HTTPS/安全边界) └── research/ # Boss 参考图与调研(不进 git) ``` @@ -76,3 +90,12 @@ star-raid/ - 大改动先讨论方案再动手;视觉效果由用户验收后再迭代 - 每完成一个逻辑单元立即 git 提交(提交信息写清改了什么+为什么) - 项目记忆(`.astrion/memory/`)沉淀重要约定,发现新约定主动更新 + +## 后端约定(2026-08-06 后端化) + +- 客户端流程:boot → `/api/me` → 未登录 o-auth(登陆/注册)→ 已登录 o-home(大厅:用户面板+排行榜)→ o-title(基地菜单) +- 结算上报:`submitRun()`(ui.js)在 endRun 与 rush 胜利(enemies.js killBoss)两处调用;`G.submitted` 防重 +- 图鉴:服务端 `codex_unlocks` 为准,登陆时与本地 SAVE.codex 并集合并;未收集=剪影+???不可演示 +- 背景规则:render() 只在 battleUI()(play/demoSim/pause/cards/over/slot)绘制游戏对象;大厅 UI="home" 时 drawHomeShowcase()(30s 重抽);其余=星空 +- 新增装备时必须同步 `server/api.js` 的 CODEX_KEYS 白名单(现 24+17+13+8=62) +- 星币/机库/机体仍存 localStorage 不同步(v1 边界,见 DEPLOY.md) diff --git a/star-raid/DEPLOY.md b/star-raid/DEPLOY.md new file mode 100644 index 0000000..343e7c7 --- /dev/null +++ b/star-raid/DEPLOY.md @@ -0,0 +1,61 @@ +# Star Raid 部署指南 + +## 运行要求 + +- **Node.js ≥ 22.5**(使用内置 `node:sqlite`,零 npm 依赖,无需 `npm install`) +- SQLite 数据库文件自动创建于 `star-raid/data/starraid.db`(已在 .gitignore) + +## 启动 + +```bash +cd star-raid +npm start # = node server/server.js +# 或指定环境变量 +PORT=8643 HOST=0.0.0.0 node server/server.js +``` + +| 环境变量 | 默认 | 说明 | +|---|---|---| +| `PORT` | `8643` | 监听端口 | +| `HOST` | `0.0.0.0` | 监听地址 | +| `COOKIE_SECURE` | 空 | **HTTPS 部署时必须设为 `1`**(Cookie 加 Secure) | +| `DATA_DIR` | `star-raid/data` | 数据库目录 | + +## 公网部署(nginx + HTTPS 推荐) + +```nginx +server { + listen 443 ssl; + server_name your-domain.com; + # ssl_certificate / certbot 申请 + location / { + proxy_pass http://127.0.0.1:8643; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; # 限流按真实 IP + } +} +``` + +启动时 `COOKIE_SECURE=1 PORT=8643 node server/server.js`,用 systemd/supervisor 守护进程。 + +## 已实现的安全措施 + +- 密码:scrypt + 每用户随机盐 + 时序安全比较;登陆失败统一报错(不泄露账号是否存在),不存在账号也跑一次哈希防时序探测 +- 会话:32 字节随机 token 存服务端 SQLite,HttpOnly + SameSite=Lax,7 天滑动续期,可吊销 +- SQL 注入:全部 prepared statement;图鉴 key 服务端白名单(`server/api.js` 的 `CODEX_KEYS`,**新增装备时必须同步**) +- 输入:用户名/邮箱/密码白名单校验;请求体 ≤16KB;POST 强制 `application/json`(配合 SameSite 防 CSRF) +- 限流:注册 10 次/5 分钟/IP,登陆 20 次/5 分钟/IP,成绩上报 20 次/5 分钟/账号 +- 静态资源白名单:只放行 `/`、`/css/`、`/js/`(`server/`、`data/`、`demo/` 不可访问),防目录穿越 +- 安全头:CSP / X-Frame-Options / X-Content-Type-Options / Referrer-Policy + +## v1 边界(明确不做) + +- 邮箱仅作唯一标识,**无验证邮件、无找回密码**(需 SMTP 服务,留 v2) +- **反作弊仅为基础校验**(数值范围/限流):纯客户端游戏无法杜绝作弊,有志者改内存照样能伪造高分。如需严肃竞技,要加服务端回放校验(大工程) +- 星币/机库强化/机体解锁仍存浏览器 localStorage,**不随账号同步**(跨设备冲突解决复杂,留 v2) +- 排行榜 = 全服单榜,无分页/赛季 + +## 数据维护 + +- 备份:拷贝 `data/starraid.db`(WAL 模式,建议先 `PRAGMA wal_checkpoint` 或直接停服拷贝) +- 重置排行榜:停服后删除 `data/` 下文件,重启自动重建空库 diff --git a/star-raid/css/style.css b/star-raid/css/style.css index 111984e..33ae25b 100644 --- a/star-raid/css/style.css +++ b/star-raid/css/style.css @@ -116,3 +116,27 @@ h1.logo{font-size:clamp(20px,5.4vw,44px);color:#fff;letter-spacing:4px;line-heig .cx-item.lock{opacity:.35;cursor:default} .cx-item.lock:hover{transform:none;border-color:var(--line)} .cx-sec{font-size:clamp(8px,1.5vw,10px);color:var(--cyan);margin:10px 0 4px;letter-spacing:3px} + +/* ---------- 登陆/注册 ---------- */ +.auth-f{display:flex;flex-direction:column;gap:8px;margin-top:12px} +.auth-f.hide{display:none} +.auth-f input{font:inherit;font-size:clamp(8px,1.6vw,11px);background:var(--panel2);border:3px solid var(--line); + color:var(--ink);padding:10px 12px;letter-spacing:1px;width:100%;outline:none} +.auth-f input:focus{border-color:var(--cyan)} +.auth-f input::placeholder{color:var(--dim)} +.auth-f .btn{width:100%;min-width:0;margin-top:2px} +/* ---------- 飞行员大厅 ---------- */ +#o-home{background:rgba(4,7,16,.42);justify-content:flex-start;gap:8px;overflow-y:auto} +#o-home .lb{width:min(94%,430px);background:var(--panel);border:3px solid var(--line);padding:10px 10px 8px;margin-top:auto;margin-bottom:2%} +.lb-tabs{display:flex;gap:6px;justify-content:center;margin:6px 0 8px} +.lb-tabs button{font:inherit;font-size:clamp(7px,1.3vw,9px);background:var(--panel2);color:var(--dim); + border:2px solid var(--line);padding:6px 10px;cursor:pointer;letter-spacing:1px} +.lb-tabs button.on{border-color:var(--cyan);color:var(--cyan)} +.lb-row{display:grid;grid-template-columns:30px 1fr 48px 58px 70px;gap:4px;align-items:center; + font-size:clamp(7px,1.3vw,9px);padding:4px 4px;letter-spacing:1px;text-align:left} +.lb-row span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.lb-row span:first-child{color:var(--dim)} +.lb-row.lb-head{color:var(--dim);border-bottom:2px solid var(--line)} +.lb-row.me{background:rgba(65,230,255,.12);outline:1px solid rgba(65,230,255,.35)} +.lb-list{max-height:34%;min-height:60px;overflow-y:auto} +.lb-empty{color:var(--dim);font-size:clamp(7px,1.3vw,9px);padding:14px 0;letter-spacing:2px} diff --git a/star-raid/index.html b/star-raid/index.html index 0bcf207..3ad634d 100644 --- a/star-raid/index.html +++ b/star-raid/index.html @@ -3,6 +3,7 @@ + 星尘突击队 STAR RAID @@ -13,17 +14,66 @@
+ +
+
PIXEL ROGUELIKE SHMUP
+

STAR
RAID

+
星尘突击队
+
+
+ + +
+
+ + + +
+
+ + + + + +
+
 
+
+
账号数据保存在服务器 · 排行榜与全服飞行员同台竞技
+
+ + +
+
— 飞行员大厅 —
+
 
+
+ + +
+
+
— 全服排行榜 TOP 100 —
+
+ + + +
+
#飞行员击坠图鉴无尽最高
+
+
+
+ -
+
PIXEL ROGUELIKE SHMUP

STAR
RAID

星尘突击队
BEST --
+
 
+
键盘 WASD/方向键 移动 · 自动射击 · 超频充满自动暴走
SHIFT 精密移动 · P 暂停 · M 音乐
@@ -133,6 +183,9 @@ + + + diff --git a/star-raid/js/auth.js b/star-raid/js/auth.js new file mode 100644 index 0000000..2d2ce9b --- /dev/null +++ b/star-raid/js/auth.js @@ -0,0 +1,63 @@ +"use strict"; +/* ================= 认证流程:登陆/注册/登出 + 启动引导 ================= */ +(function () { + const err = (t) => { $("auth-err").textContent = t || " "; }; + + function tab(login) { + $("tab-login").classList.toggle("primary", login); + $("tab-reg").classList.toggle("primary", !login); + $("f-login").classList.toggle("hide", !login); + $("f-reg").classList.toggle("hide", login); + err(); + } + + $("tab-login").onclick = () => tab(true); + $("tab-reg").onclick = () => tab(false); + + $("f-login").addEventListener("submit", async (e) => { + e.preventDefault(); + err("登陆中..."); + const msg = await NET.login($("li-id").value.trim(), $("li-pw").value); + if (msg) { err(msg); SFX.hurt(); return; } + err(); SFX.level(); enterHome(); + }); + + $("f-reg").addEventListener("submit", async (e) => { + e.preventDefault(); + const name = $("rg-name").value.trim(), email = $("rg-email").value.trim(); + const pw = $("rg-pw").value, pw2 = $("rg-pw2").value; + if (pw !== pw2) { err("两次输入的密码不一致"); SFX.hurt(); return; } + err("注册中..."); + const msg = await NET.register(name, email, pw); + if (msg) { err(msg); SFX.hurt(); return; } + err(); SFX.level(); enterHome(); + }); + + $("b-logout").onclick = async () => { + SFX.pick(); + await NET.logout(); + G = null; UI = "auth"; + $("li-pw").value = ""; + show("o-auth"); + }; + + /* 进入基地(游戏主菜单) */ + $("b-base").onclick = () => { + SFX.pick(); + $("pilot-line").textContent = NET.user ? ("飞行员 " + NET.user.username) : " "; + bestLine(); UI = "title"; show("o-title"); + }; + $("b-lobby").onclick = () => { SFX.pick(); enterHome(); }; + + /* 启动引导:恢复会话 → 大厅;否则登陆页(网络故障给出提示) */ + (async function boot() { + UI = "auth"; + show("o-auth"); + const r = await NET.api("/api/me", "GET"); + if (r.ok) { NET._apply(r.data); enterHome(); } + else { + if (r.status === 0) err("无法连接服务器,请稍后刷新重试"); + show("o-auth"); + } + })(); +})(); diff --git a/star-raid/js/core.js b/star-raid/js/core.js index f6b2d9c..558b90f 100644 --- a/star-raid/js/core.js +++ b/star-raid/js/core.js @@ -9,7 +9,7 @@ function newRun(mode,shipKey,diff){ G={mode,diff,ship:shipKey,stage:1,wave:0,waveT:0,betweenT:60,spawning:[], score:0,coins:0,kills:0,combo:0,comboT:0,gems:0,level:1,xp:0,xpNeed:BAL.xpNeed(1), luck:(meta.luck||0),mutator:null,mutList:[], - time:0,boss:null,bossN:0,shake:0,hitstop:0,flash:0,slowmo:0, + time:0,boss:null,bossN:0,bossKills:0,shake:0,hitstop:0,flash:0,slowmo:0, weapons:{vulcan:1},loadout:["vulcan"],subs:{},subLoadout:[],drones:{},droneLoadout:[], wt:{},wst:{},sst:{},dst:{},zones:[],zaps:[],novas:[],flares:[],holies:[],shocks:[],bbeams:[],funnels:[], u:{fire:0,crit:0,shield:0,nano:0,mag:0,bounty:0,odcore:0,ghost:0}, @@ -49,6 +49,7 @@ function pCoinMul(){let c=G.stat.coinMul*(1+0.3*G.u.bounty); if(G.diff===1)c*=BA const KEYS={}; const GAMEKEYS=["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"," ","KeyW","KeyA","KeyS","KeyD","KeyX","KeyC","KeyZ","Shift","KeyP","Escape","KeyM","Digit1","Digit2","Digit3"]; addEventListener("keydown",e=>{ + if(e.target&&(e.target.tagName==="INPUT"||e.target.tagName==="TEXTAREA"))return; // 登陆/注册表单打字不拦截 if(GAMEKEYS.includes(e.code))e.preventDefault(); KEYS[e.code]=true; AU.init(); if(AU.ctx&&AU.ctx.state==="suspended")AU.ctx.resume(); diff --git a/star-raid/js/enemies.js b/star-raid/js/enemies.js index 92c6d49..c052795 100644 --- a/star-raid/js/enemies.js +++ b/star-raid/js/enemies.js @@ -542,6 +542,7 @@ function hurtBoss(dmg){ } function killBoss(){ const b=G.boss; + G.bossKills=(G.bossKills||0)+1; // 账号累计击坠Boss数(结算上报) SFX.boom(true);G.shake=16;G.flash=20; for(let i=0;i<40;i++)spawnPart(b.x+rnd(-24,24),b.y+rnd(-14,14),pick(["#ffcf5c","#ff5566","#fff","#ff9a3d"]),3); addScore(5000*(1+G.bossN*0.5),b.x,b.y); @@ -558,6 +559,7 @@ function killBoss(){ SAVE.rush=SAVE.rush||{}; const best=SAVE.rush[b.idx]; if(!best||ct{if(!G||G.lives<=0)return;buildRush();UI="rush";show("o-rush");},1500); } else if(G.mode==="stage"){ diff --git a/star-raid/js/home.js b/star-raid/js/home.js new file mode 100644 index 0000000..c87bfc5 --- /dev/null +++ b/star-raid/js/home.js @@ -0,0 +1,121 @@ +"use strict"; +/* ================= 飞行员大厅(主页):排行榜 + 图鉴展柜背景 ================= */ +let LB_SORT = "endless"; +let HOME_SHOW = null; + +/* 图鉴已收集数(只统计合法 key) */ +function codexCount() { + let n = 0; + for (const k in SAVE.codex) + if (SAVE.codex[k] && (WEAPONS[k] || SUBS[k] || DRONES[k] || PASSIVES[k])) n++; + return n; +} + +async function enterHome() { + G = null; + UI = "home"; + rollHomeShow(); + show("o-home"); + /* 刷新账号数据(stats/codex),再渲染面板与排行榜 */ + const r = await NET.api("/api/me", "GET"); + if (r.ok) NET._apply(r.data); + else if (r.status === 401) { NET.user = null; UI = "auth"; show("o-auth"); return; } + buildHomeStats(); + buildLeaderboard(LB_SORT); +} + +function buildHomeStats() { + const s = NET.stats || { bossKills: 0, endlessHigh: 0 }; + const nm = NET.user ? NET.user.username : "--"; + $("home-stats").textContent = + `${nm} · 击坠Boss ${s.bossKills} · 图鉴 ${codexCount()}/${NET.codexTotal} · 无尽最高 ${s.endlessHigh}`; +} + +/* ---------- 排行榜 ---------- */ +document.querySelectorAll(".lb-tabs button").forEach((b) => { + b.onclick = () => { + LB_SORT = b.dataset.s; + document.querySelectorAll(".lb-tabs button").forEach((x) => x.classList.toggle("on", x === b)); + SFX.pick(); + buildLeaderboard(LB_SORT); + }; +}); + +async function buildLeaderboard(sort) { + const list = $("lb-list"); + list.innerHTML = '
载入中...
'; + const r = await NET.api("/api/leaderboard?sort=" + sort, "GET"); + if (!r.ok) { list.innerHTML = '
载入失败
'; return; } + const rows = r.data.rows || []; + list.innerHTML = ""; + if (!rows.length) { list.innerHTML = '
虚位以待
'; return; } + const meName = NET.user && NET.user.username; + rows.forEach((row, i) => { + const d = document.createElement("div"); + d.className = "lb-row" + (meName && row.username === meName ? " me" : ""); + const cells = [String(i + 1), row.username, String(row.bossKills), row.codexN + "/" + r.data.codexTotal, String(row.endlessHigh)]; + for (const c of cells) { + const s = document.createElement("span"); + s.textContent = c; // textContent 防注入 + d.appendChild(s); + } + list.appendChild(d); + }); +} + +/* ---------- 大厅背景:随机军械展柜 + 随机刷怪巡游 ---------- */ +const HOME_MOB_TYPES = ["scout", "weaver", "kami", "splitter", "mine", "gunship", "sniper"]; + +function rollHomeShow() { + HOME_SHOW = { + m: shuffle(Object.keys(WEAPONS)).slice(0, 3), + s: shuffle(Object.keys(SUBS)).slice(0, 3), + d: shuffle(Object.keys(DRONES)).slice(0, 2), + t: 0, spawnT: 20, mobs: [], + }; +} + +/* 由 render() 在 UI==="home" 时每帧调用(dt=1) */ +function drawHomeShowcase(dt) { + if (!HOME_SHOW || HOME_SHOW.t > 1800) rollHomeShow(); // 30 秒重抽一轮 + const hs = HOME_SHOW; + hs.t += dt; + /* 展柜:3主武+3副武+2僚机,两排漂浮 */ + const items = [ + ...hs.m.map((k) => ({ d: WEAPONS[k] })), + ...hs.s.map((k) => ({ d: SUBS[k] })), + ...hs.d.map((k) => ({ d: DRONES[k] })), + ]; + ctx.textAlign = "center"; + items.forEach((it, i) => { + const col = i % 4, row = (i / 4) | 0; + const x = W / 2 + (col - 1.5) * 56; + const y = 118 + row * 52 + Math.sin(hs.t * 0.03 + i * 1.7) * 2.5; + const img = it.d.icon ? ICONS[it.d.icon] : SPR[it.d.spr]; + if (img) { + ctx.globalAlpha = 0.85; + ctx.drawImage(img, x - img.width * 1.5, y - img.height * 1.5, img.width * 3, img.height * 3); + ctx.globalAlpha = 1; + } + ctx.font = "6px 'Press Start 2P',monospace"; + ctx.fillStyle = "#7f8cb8"; + ctx.fillText(it.d.name, x, y + 22); + }); + /* 随机刷怪:从顶部飘入,正弦摆动 */ + hs.spawnT -= dt; + if (hs.spawnT <= 0 && hs.mobs.length < 8) { + hs.spawnT = rndi(50, 110); + const ty = pick(HOME_MOB_TYPES), elite = Math.random() < 0.2; + const spr = SPR[(elite ? "e_" : "") + ty] || SPR[ty] || SPR.scout; + hs.mobs.push({ x: rnd(20, W - 20), y: -16, vy: rnd(0.25, 0.6), ph: rnd(0, TAU), amp: rnd(6, 26), spr, sc: elite ? 1.8 : 1.3, t: 0 }); + } + for (const m of hs.mobs) { + m.t += dt; + m.y += m.vy * dt; + const mx = m.x + Math.sin(m.t * 0.02 + m.ph) * m.amp; + ctx.globalAlpha = 0.8; + ctx.drawImage(m.spr, mx - (m.spr.width * m.sc) / 2, m.y - (m.spr.height * m.sc) / 2, m.spr.width * m.sc, m.spr.height * m.sc); + ctx.globalAlpha = 1; + } + hs.mobs = hs.mobs.filter((m) => m.y < H + 24); +} diff --git a/star-raid/js/net.js b/star-raid/js/net.js new file mode 100644 index 0000000..2d7dc06 --- /dev/null +++ b/star-raid/js/net.js @@ -0,0 +1,76 @@ +"use strict"; +/* ================= 网络层:服务端 API 客户端 ================= */ +const NET = { + user: null, // {username,email} + stats: null, // {bossKills,endlessHigh,runs} + codexTotal: 62, + online: false, + + async api(path, method, body) { + let res; + try { + res = await fetch(path, { + method: method || "GET", + credentials: "same-origin", + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (e) { + return { ok: false, status: 0, data: { error: "无法连接服务器" } }; + } + let data = {}; + try { data = await res.json(); } catch (e) { /* 空响应 */ } + return { ok: res.ok, status: res.status, data }; + }, + + /* 启动:尝试恢复会话。true=已登录 */ + async boot() { + const r = await this.api("/api/me", "GET"); + if (r.ok) { this._apply(r.data); return true; } + return false; + }, + + async login(id, password) { + const r = await this.api("/api/login", "POST", { id, password }); + if (r.ok) { this._apply(r.data); return null; } + return r.data.error || "登陆失败"; + }, + + async register(username, email, password) { + const r = await this.api("/api/register", "POST", { username, email, password }); + if (r.ok) { this._apply(r.data); return null; } + return r.data.error || "注册失败"; + }, + + async logout() { + await this.api("/api/logout", "POST"); + this.user = null; this.stats = null; this.online = false; + }, + + /* 应用服务端返回的用户数据:同步图鉴解锁到本地存档(并集) */ + _apply(d) { + this.user = d.user; + this.stats = d.stats; + this.codexTotal = d.codexTotal || 62; + this.online = true; + if (Array.isArray(d.codex)) { + SAVE.codex = SAVE.codex || {}; + for (const k of d.codex) SAVE.codex[k] = true; + persist(); + } + }, + + /* 结算上报(fire-and-forget):成功后回写最新统计 */ + submitRun(payload) { + if (!this.user) return; + this.api("/api/run", "POST", payload).then((r) => { + if (r.ok) { + this.stats = r.data.stats; + if (Array.isArray(r.data.codex)) { + for (const k of r.data.codex) SAVE.codex[k] = true; + persist(); + } + } + }).catch(() => { /* 网络失败静默:本地存档仍在 */ }); + }, +}; diff --git a/star-raid/js/render.js b/star-raid/js/render.js index c283cc0..d99e788 100644 --- a/star-raid/js/render.js +++ b/star-raid/js/render.js @@ -232,11 +232,14 @@ function drawSeg(x1,y1,x2,y2,w,col,alpha){ ctx.fillRect(Math.round(lerp(x1,x2,t2)-w/2),Math.round(lerp(y1,y2,t2)-w/2),w,w);} ctx.globalAlpha=1; } +/* 战斗相关界面才绘制游戏对象;大厅/标题/登陆页只画星空(修复:回基地后背景卡在上场残影) */ +function battleUI(){return UI==="play"||UI==="demoSim"||UI==="pause"||UI==="cards"||UI==="over"||UI==="slot";} function render(){ ctx.save(); if(G&&G.shake>0){ctx.translate(rnd(-G.shake,G.shake)*0.5,rnd(-G.shake,G.shake)*0.5);} drawBG(1); - if(G){ + if(UI==="home"&&typeof drawHomeShowcase==="function")drawHomeShowcase(1); + if(G&&battleUI()){ // 拾取物 for(const k of G.picks){const bob=Math.sin(k.t*0.1)*1.5;drawSprite(SPR[k.spr],k.x,k.y+bob);} // 敌机 diff --git a/star-raid/js/ui.js b/star-raid/js/ui.js index 0dd5d9a..6fc7ffa 100644 --- a/star-raid/js/ui.js +++ b/star-raid/js/ui.js @@ -1,6 +1,6 @@ "use strict"; /* ================= 界面流转 ================= */ -const OVLS=["o-title","o-setup","o-hangar","o-cards","o-pause","o-over","o-codex","o-demo","o-rush","o-slot"]; +const OVLS=["o-auth","o-home","o-title","o-setup","o-hangar","o-cards","o-pause","o-over","o-codex","o-demo","o-rush","o-slot"]; function show(id){for(const o of OVLS)$(o).classList.toggle("hide",o!==id);} const SETUP={mode:"stage",ship:"falcon",diff:0}; function bestLine(){ @@ -22,7 +22,7 @@ $("b-resume").onclick=()=>togglePause(); $("b-restart").onclick=()=>{show("");if(G&&G.mode==="rush")startRush(G.rushIdx);else startGame();}; // Boss挑战重开仍在挑战模式 $("b-quit").onclick=()=>{bank();G=null;MUS.stop();UI="title";bestLine();show("o-title");}; $("b-again").onclick=()=>startGame(); -$("b-home").onclick=()=>{UI="title";bestLine();show("o-title");}; +$("b-home").onclick=()=>{G=null;UI="title";bestLine();show("o-title");}; $("mode-pick").addEventListener("click",e=>{ const c=e.target.closest(".pcard");if(!c)return; SETUP.mode=c.dataset.m; @@ -123,29 +123,30 @@ function buildLoadoutUI(){ mk("僚机",DRONES,G.drones,G.droneLoadout,3); } /* ================= 图鉴 ================= */ -function iconCanvas(img,size){ +function iconCanvas(img,size,sil){ const c=document.createElement("canvas");c.width=c.height=size; const g=c.getContext("2d");g.imageSmoothingEnabled=false; const sc=Math.min(size/img.width,size/img.height); g.drawImage(img,(size-img.width*sc)/2,(size-img.height*sc)/2,img.width*sc,img.height*sc); + if(sil){g.globalCompositeOperation="source-atop";g.fillStyle="#232f52";g.fillRect(0,0,size,size);g.globalCompositeOperation="source-over";} return c; } function buildCodex(){ const box=$("codex");box.innerHTML=""; const total=MAINS.length+SUB_KEYS.length+DRONE_KEYS.length+PAS_KEYS.length; $("codex-t").textContent=total; - $("codex-n").textContent=Object.keys(SAVE.codex).length; + $("codex-n").textContent=codexCount(); const mk=(title,keys,dict,kind)=>{ const h=document.createElement("div");h.className="cx-sec";h.textContent=title;box.appendChild(h); const grid=document.createElement("div");grid.className="cx-grid"; for(const k of keys){ - const def=dict[k]; + const def=dict[k],owned=!!SAVE.codex[k]; const d=document.createElement("div"); - d.className="cx-item"; + d.className="cx-item"+(owned?"":" lock"); // 未收集:剪影+???,不可演示 const img=def.icon?ICONS[def.icon]:SPR[def.spr]; - d.appendChild(iconCanvas(img,28)); - d.insertAdjacentHTML("beforeend",`
${def.name}
`); - d.onclick=()=>{SFX.pick();startDemoSim(kind,k);}; + d.appendChild(iconCanvas(img,28,!owned)); + d.insertAdjacentHTML("beforeend",`
${owned?def.name:"???"}
`); + if(owned)d.onclick=()=>{SFX.pick();startDemoSim(kind,k);}; grid.appendChild(d); } box.appendChild(grid); @@ -154,7 +155,6 @@ function buildCodex(){ mk("副武器",SUB_KEYS,SUBS,"s"); mk("僚机",DRONE_KEYS,DRONES,"d"); mk("被动强化",PAS_KEYS,PASSIVES,"p"); - $("codex-n").textContent=total; // 图鉴全量直接展示,无需收集 } document.addEventListener("visibilitychange",()=>{if(document.hidden&&UI==="play")togglePause();}); function bank(){ @@ -166,6 +166,7 @@ function bank(){ } /* ---- Boss 挑战模式 ---- */ function buildRush(){ + G=null; // 清掉上一局残留(rush胜利后G仍在),防背景卡残影 SAVE.rush=SAVE.rush||{}; const ra=$("rush-anim"); // 老虎机开关(持久化) if(ra){ra.checked=!!SAVE.rushAnim;ra.onchange=()=>{SAVE.rushAnim=ra.checked?1:0;persist();};} @@ -227,9 +228,20 @@ function playSlot(build,cb){ },700+idx*280); }); } +/* 结算上报服务端(每局一次,含rush胜利;图鉴演示除外) */ +function submitRun(win){ + if(!G||G.submitted||G.demoSim)return; + G.submitted=true; + if(typeof NET==="undefined"||!NET.user)return; + NET.submitRun({mode:G.mode,diff:G.diff,score:G.score,wave:G.wave,stage:G.stage, + timeSec:Math.round(G.time/60),bossKills:G.bossKills||0,win:!!win, + bestEndless:Math.max(SAVE.best.e0||0,SAVE.best.e1||0), + codex:Object.keys(SAVE.codex)}); +} function endRun(win){ if(UI==="over")return; bank();MUS.stop(); + submitRun(win); UI="over"; $("over-title").textContent=win?"★ 任务完成 ★":"任务失败"; $("over-title").style.color=win?"#ffcf5c":"#ff5566"; diff --git a/star-raid/package.json b/star-raid/package.json new file mode 100644 index 0000000..82b949a --- /dev/null +++ b/star-raid/package.json @@ -0,0 +1,12 @@ +{ + "name": "star-raid", + "version": "1.0.0", + "private": true, + "description": "星尘突击队 STAR RAID —— 像素肉鸽竖版射击(含服务端:账号/排行榜/图鉴同步)", + "scripts": { + "start": "node server/server.js" + }, + "engines": { + "node": ">=22.5.0" + } +} diff --git a/star-raid/server/api.js b/star-raid/server/api.js new file mode 100644 index 0000000..9fbaab3 --- /dev/null +++ b/star-raid/server/api.js @@ -0,0 +1,142 @@ +"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 保持同步:24主武+17副武+13僚机+8被动=62) */ +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", + "broad", "sweep", "leech", "orbit", "sats", "pd", "slowfield", "flares", "seeker", + "beamdrone", "frostnova", "thorns", "sticky", "magnetar", "executor", "airstrike", "repair", + "gunner", "gunner2", "sniperd", "beamer", "bomber", "frostd", "teslaD", "rammer", + "healer", "shieldd", "missileD", "bladeD", "sweeper", + "fire", "crit", "shield", "nano", "mag", "bounty", "odcore", "ghost", +]); +const CODEX_TOTAL = CODEX_KEYS.size; +const RUN_MODES = new Set(["stage", "endless", "rush"]); + +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`); + +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); +} + +/* ---------- 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 }); +} + +/* ---------- 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 }); +} + +/* ---------- 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 ---------- */ +function me(req, res, ctx) { + const u = ctx.user; + 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 }); +} + +/* ---------- GET /api/leaderboard?sort=endless|boss|codex ---------- */ +const LB_COLS = { endless: "endless_high", boss: "boss_kills", codex: "codex_n" }; +function leaderboard(req, res, ctx, _body, query) { + const col = LB_COLS[(query && query.get("sort")) || ""] || "endless_high"; + /* col 来自上方白名单映射,非用户输入直接拼接 */ + const rows = db.prepare(` + SELECT u.username AS username, s.boss_kills 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 ${col} DESC, u.id ASC LIMIT 100`).all(); + return ctx.ok({ rows, codexTotal: CODEX_TOTAL, sort: col }); +} + +/* ---------- 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, "非法击坠数"); + if (!vInt(b.bestEndless === undefined ? 0 : b.bestEndless, 0, 1e8)) 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(); + const endlessCand = Math.max(b.mode === "endless" ? b.score : 0, b.bestEndless || 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 }); +} + +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: true }, + "GET /api/leaderboard": { fn: leaderboard, auth: true }, + "POST /api/run": { fn: run, auth: true }, +}; + +module.exports = { ROUTES, CODEX_TOTAL }; diff --git a/star-raid/server/auth.js b/star-raid/server/auth.js new file mode 100644 index 0000000..6ab38d4 --- /dev/null +++ b/star-raid/server/auth.js @@ -0,0 +1,69 @@ +"use strict"; +/* ================= 认证:scrypt 密码哈希 + 服务端会话 ================= */ +const crypto = require("node:crypto"); + +const SCRYPT_N = 16384, SCRYPT_R = 8, SCRYPT_P = 1, KEYLEN = 64; +const SESSION_TTL = 7 * 24 * 3600 * 1000; // 7 天 +const COOKIE_NAME = "sr_sid"; + +function hashPassword(pw) { + const salt = crypto.randomBytes(16); + const hash = crypto.scryptSync(pw, salt, KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }); + return salt.toString("hex") + ":" + hash.toString("hex"); +} + +function verifyPassword(pw, stored) { + if (typeof stored !== "string") return false; + const i = stored.indexOf(":"); + if (i < 0) return false; + const salt = Buffer.from(stored.slice(0, i), "hex"); + const expect = Buffer.from(stored.slice(i + 1), "hex"); + if (salt.length !== 16 || expect.length !== KEYLEN) return false; + const got = crypto.scryptSync(pw, salt, KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }); + return crypto.timingSafeEqual(got, expect); +} + +function createSession(db, userId) { + const token = crypto.randomBytes(32).toString("hex"); + const now = Date.now(); + db.prepare("INSERT INTO sessions(token,user_id,created_at,expires_at) VALUES(?,?,?,?)") + .run(token, userId, now, now + SESSION_TTL); + return token; +} + +/* 查 session(含滑动续期:剩余不足一半时顺延) */ +function getSessionUser(db, token) { + if (!token || typeof token !== "string" || token.length > 128) return null; + const row = db.prepare( + "SELECT s.token,s.expires_at,u.id,u.username,u.email,u.created_at FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token=?" + ).get(token); + if (!row) return null; + const now = Date.now(); + if (row.expires_at < now) { + db.prepare("DELETE FROM sessions WHERE token=?").run(token); + return null; + } + if (row.expires_at - now < SESSION_TTL / 2) + db.prepare("UPDATE sessions SET expires_at=? WHERE token=?").run(now + SESSION_TTL, token); + return row; +} + +function destroySession(db, token) { + if (token) db.prepare("DELETE FROM sessions WHERE token=?").run(token); +} + +/* Set-Cookie 字符串;secure=true 用于 HTTPS 部署(环境变量 COOKIE_SECURE=1) */ +function sessionCookie(token, secure) { + const parts = [ + COOKIE_NAME + "=" + token, + "Path=/", "HttpOnly", "SameSite=Lax", + "Max-Age=" + Math.floor(SESSION_TTL / 1000), + ]; + if (secure) parts.push("Secure"); + return parts.join("; "); +} +function clearCookie(secure) { + return COOKIE_NAME + "=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + (secure ? "; Secure" : ""); +} + +module.exports = { hashPassword, verifyPassword, createSession, getSessionUser, destroySession, sessionCookie, clearCookie, COOKIE_NAME }; diff --git a/star-raid/server/db.js b/star-raid/server/db.js new file mode 100644 index 0000000..db64505 --- /dev/null +++ b/star-raid/server/db.js @@ -0,0 +1,63 @@ +"use strict"; +/* ================= SQLite(node:sqlite 内置驱动,零依赖) ================= */ +const { DatabaseSync } = require("node:sqlite"); +const path = require("node:path"); +const fs = require("node:fs"); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "..", "data"); +fs.mkdirSync(DATA_DIR, { recursive: true }); + +const db = new DatabaseSync(path.join(DATA_DIR, "starraid.db")); +db.exec("PRAGMA journal_mode=WAL;"); +db.exec("PRAGMA foreign_keys=ON;"); + +db.exec(` +CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + pass_hash TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE TABLE IF NOT EXISTS user_stats( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + boss_kills INTEGER NOT NULL DEFAULT 0, + endless_high INTEGER NOT NULL DEFAULT 0, + runs INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS codex_unlocks( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entry_key TEXT NOT NULL, + unlocked_at INTEGER NOT NULL, + PRIMARY KEY(user_id, entry_key) +); +CREATE TABLE IF NOT EXISTS runs( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mode TEXT NOT NULL, + diff INTEGER NOT NULL, + score INTEGER NOT NULL, + wave INTEGER NOT NULL, + stage INTEGER NOT NULL, + time_sec INTEGER NOT NULL, + boss_kills INTEGER NOT NULL, + win INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_runs_user ON runs(user_id); +`); + +/* 定期清理过期 session */ +setInterval(() => { + try { db.prepare("DELETE FROM sessions WHERE expires_at 0) body = await readJson(req); + return route.fn(req, res, ctx, body, query); + } catch (e) { + const status = e && e.status && Number.isInteger(e.status) ? e.status : 500; + if (status === 500) console.error("[api]", pathname, e); + return ctx.fail(status, status === 500 ? "服务器开小差了" : e.message); + } +} + +function handleStatic(req, res, pathname) { + if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405); return res.end(); } + let p = pathname; + if (p === "/") p = "/index.html"; + const allowed = p === "/index.html" || STATIC_PREFIXES.some((pre) => p.startsWith(pre)); + if (!allowed || p.includes("..")) { res.writeHead(404); return res.end("not found"); } + const full = path.normalize(path.join(ROOT, p)); + if (!full.startsWith(ROOT + path.sep)) { res.writeHead(404); return res.end("not found"); } + const ext = path.extname(full).toLowerCase(); + const mime = MIME[ext]; + if (!mime) { res.writeHead(404); return res.end("not found"); } + fs.readFile(full, (err, data) => { + if (err) { res.writeHead(404); return res.end("not found"); } + res.writeHead(200, { "Content-Type": mime, "Cache-Control": "no-cache" }); + if (req.method === "HEAD") return res.end(); + res.end(data); + }); +} + +const server = http.createServer((req, res) => { + secHeaders(res); + let url; + try { url = new URL(req.url, "http://x"); } catch (e) { res.writeHead(400); return res.end(); } + const pathname = url.pathname; + if (pathname.startsWith("/api/")) { + if (pathname === "/api/health" && req.method === "GET") return sendJson(res, 200, { ok: true, t: Date.now() }); + handleApi(req, res, pathname, url.searchParams); + } else { + handleStatic(req, res, pathname); + } +}); + +server.listen(PORT, HOST, () => { + console.log(`[star-raid] server listening on http://${HOST}:${PORT} (secure-cookie=${SECURE})`); +}); diff --git a/star-raid/server/util.js b/star-raid/server/util.js new file mode 100644 index 0000000..29dede2 --- /dev/null +++ b/star-raid/server/util.js @@ -0,0 +1,80 @@ +"use strict"; +/* ================= 工具:请求体 / 限流 / 输入校验 ================= */ + +/* 读取请求体(带大小上限,超限即毁连接) */ +function readBody(req, limit = 16384) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + req.on("data", (c) => { + size += c.length; + if (size > limit) { + reject(Object.assign(new Error("body too large"), { status: 413 })); + req.destroy(); + } else chunks.push(c); + }); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +/* 读取 JSON:强制 content-type(配合 SameSite=Lax 防 CSRF),限长 */ +async function readJson(req, limit) { + const ct = String(req.headers["content-type"] || ""); + if (!ct.includes("application/json")) + throw Object.assign(new Error("Content-Type must be application/json"), { status: 415 }); + const buf = await readBody(req, limit); + try { + return JSON.parse(buf.toString("utf8")); + } catch (e) { + throw Object.assign(new Error("invalid JSON"), { status: 400 }); + } +} + +/* ---------------- 限流(内存桶,按 key) ---------------- */ +const buckets = new Map(); +/** 返回 true=放行;false=超限 */ +function rateLimit(key, max, winMs) { + const now = Date.now(); + let b = buckets.get(key); + if (!b || now > b.reset) { + b = { count: 0, reset: now + winMs }; + buckets.set(key, b); + } + b.count++; + return b.count <= max; +} +setInterval(() => { + const now = Date.now(); + for (const [k, b] of buckets) if (now > b.reset) buckets.delete(k); +}, 60000).unref(); + +/* ---------------- 输入校验(白名单式) ---------------- */ +const RE_USER = /^[A-Za-z0-9_一-龥]{2,16}$/; +const RE_EMAIL = /^[^\s@]{1,40}@[^\s@]{1,40}\.[^\s@]{1,20}$/; +const vUsername = (u) => typeof u === "string" && RE_USER.test(u); +const vEmail = (e) => typeof e === "string" && e.length <= 64 && RE_EMAIL.test(e); +const vPassword = (p) => typeof p === "string" && p.length >= 8 && p.length <= 72; +const vInt = (n, min, max) => Number.isInteger(n) && n >= min && n <= max; + +/* 客户端 IP(反代后取 X-Forwarded-For 首段,仅用于限流) */ +function clientIp(req) { + const xf = req.headers["x-forwarded-for"]; + if (xf) return String(xf).split(",")[0].trim().slice(0, 64); + return (req.socket && req.socket.remoteAddress) || "unknown"; +} + +/* 解析 Cookie 头 */ +function parseCookies(req) { + const out = {}; + const raw = req.headers.cookie; + if (!raw) return out; + for (const part of String(raw).split(";")) { + const i = part.indexOf("="); + if (i < 0) continue; + out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); + } + return out; +} + +module.exports = { readJson, rateLimit, vUsername, vEmail, vPassword, vInt, clientIp, parseCookies };