后端化:账号系统+排行榜+图鉴收集同步+飞行员大厅(零依赖Node+SQLite)
- server/:node:http+node:sqlite 零依赖后端(users/sessions/user_stats/codex_unlocks/runs 五表) - 安全:scrypt密码哈希/服务端session/全prepared statement/输入白名单/限流/CSP等安全头/静态白名单防穿越 - 前端:o-auth登陆注册 + o-home飞行员大厅(排行榜Top100三排序+图鉴展柜背景:随机3主3副2僚机+刷怪巡游) - 图鉴改为收集点亮制(未收集=剪影+???),服务端codex_unlocks与本地并集同步 - 修复:回标题/大厅后背景卡在上场残影——render按battleUI门控+离场G=null;空闲=星空 - 结算上报:endRun/rush胜利两处submitRun,Boss击杀计数入排行榜 - playwright全流程验证通过(注册/登陆/游戏/结算/图鉴/安全注入/限流);游戏视觉未验收(用户亲自)
This commit is contained in:
parent
93e1f4699d
commit
ab403f4cc7
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@
|
||||
research/
|
||||
.playwright-mcp/
|
||||
cache/
|
||||
star-raid/data/
|
||||
|
||||
49
AGENTS.md
49
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)
|
||||
|
||||
61
star-raid/DEPLOY.md
Normal file
61
star-raid/DEPLOY.md
Normal file
@ -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/` 下文件,重启自动重建空库
|
||||
@ -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}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
<link rel="icon" href="data:,">
|
||||
<title>星尘突击队 STAR RAID</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
|
||||
@ -13,17 +14,66 @@
|
||||
<canvas id="game" width="240" height="320"></canvas>
|
||||
<div id="scan"></div><div id="vig"></div>
|
||||
|
||||
<!-- 登陆/注册 -->
|
||||
<div class="ovl" id="o-auth">
|
||||
<div class="tag">PIXEL ROGUELIKE SHMUP</div>
|
||||
<h1 class="logo">STAR<br>RAID</h1>
|
||||
<div class="logo-cn">星尘突击队</div>
|
||||
<div class="panel" style="width:min(92%,340px)">
|
||||
<div class="row" style="gap:6px">
|
||||
<button class="btn primary" id="tab-login" style="min-width:0;flex:1">登 陆</button>
|
||||
<button class="btn" id="tab-reg" style="min-width:0;flex:1">注 册</button>
|
||||
</div>
|
||||
<form class="auth-f" id="f-login">
|
||||
<input id="li-id" placeholder="用户名 / 邮箱" maxlength="64" autocomplete="username" required>
|
||||
<input id="li-pw" type="password" placeholder="密码" maxlength="72" autocomplete="current-password" required>
|
||||
<button class="btn primary" type="submit">进入星域 ▶</button>
|
||||
</form>
|
||||
<form class="auth-f hide" id="f-reg">
|
||||
<input id="rg-name" placeholder="用户名(2-16位)" maxlength="16" autocomplete="off" required>
|
||||
<input id="rg-email" type="email" placeholder="邮箱" maxlength="64" autocomplete="off" required>
|
||||
<input id="rg-pw" type="password" placeholder="密码(至少8位)" maxlength="72" autocomplete="new-password" required>
|
||||
<input id="rg-pw2" type="password" placeholder="确认密码" maxlength="72" autocomplete="new-password" required>
|
||||
<button class="btn primary" type="submit">创建飞行员档案</button>
|
||||
</form>
|
||||
<div class="h3" id="auth-err" style="color:var(--red)"> </div>
|
||||
</div>
|
||||
<div class="small">账号数据保存在服务器 · 排行榜与全服飞行员同台竞技</div>
|
||||
</div>
|
||||
|
||||
<!-- 飞行员大厅(排行榜主页) -->
|
||||
<div class="ovl hide" id="o-home">
|
||||
<div class="h2" style="margin-top:2%">— 飞行员大厅 —</div>
|
||||
<div class="h3" id="home-stats"> </div>
|
||||
<div class="row">
|
||||
<button class="btn primary" id="b-base" style="min-width:min(56vw,200px)">▶ 进入基地</button>
|
||||
<button class="btn" id="b-logout" style="min-width:0">登出</button>
|
||||
</div>
|
||||
<div class="lb">
|
||||
<div class="h3" style="color:var(--cyan);letter-spacing:3px">— 全服排行榜 TOP 100 —</div>
|
||||
<div class="lb-tabs">
|
||||
<button data-s="endless" class="on">无尽最高分</button>
|
||||
<button data-s="boss">击坠Boss</button>
|
||||
<button data-s="codex">图鉴收集</button>
|
||||
</div>
|
||||
<div class="lb-row lb-head"><span>#</span><span>飞行员</span><span>击坠</span><span>图鉴</span><span>无尽最高</span></div>
|
||||
<div class="lb-list" id="lb-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="ovl" id="o-title">
|
||||
<div class="ovl hide" id="o-title">
|
||||
<div class="tag">PIXEL ROGUELIKE SHMUP</div>
|
||||
<h1 class="logo">STAR<br>RAID</h1>
|
||||
<div class="logo-cn">星尘突击队</div>
|
||||
<div class="h3" id="best-line">BEST -- </div>
|
||||
<div class="h3" id="pilot-line" style="color:var(--cyan)"> </div>
|
||||
<div class="col" style="margin-top:6px">
|
||||
<button class="btn primary" id="b-go">▶ 出击</button>
|
||||
<button class="btn gold" id="b-hangar">✦ 机库强化</button>
|
||||
<button class="btn" id="b-codex">❖ 军械图鉴</button>
|
||||
<button class="btn danger" id="b-rush">☠ BOSS 挑战</button>
|
||||
<button class="btn" id="b-lobby" style="min-width:min(48vw,180px)">⌂ 飞行员大厅</button>
|
||||
</div>
|
||||
<div class="small" style="margin-top:8px">键盘 WASD/方向键 移动 · 自动射击 · 超频充满自动暴走<br>SHIFT 精密移动 · P 暂停 · M 音乐</div>
|
||||
</div>
|
||||
@ -133,6 +183,9 @@
|
||||
<script src="js/combat.js"></script>
|
||||
<script src="js/render.js"></script>
|
||||
<script src="js/boss_art.js"></script>
|
||||
<script src="js/net.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/home.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
63
star-raid/js/auth.js
Normal file
63
star-raid/js/auth.js
Normal file
@ -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");
|
||||
}
|
||||
})();
|
||||
})();
|
||||
@ -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();
|
||||
|
||||
@ -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<best){SAVE.rush[b.idx]=ct;persist();}
|
||||
if(typeof submitRun==="function")submitRun(true); // rush胜利也上报(击坠计入排行榜)
|
||||
setTimeout(()=>{if(!G||G.lives<=0)return;buildRush();UI="rush";show("o-rush");},1500);
|
||||
}
|
||||
else if(G.mode==="stage"){
|
||||
|
||||
121
star-raid/js/home.js
Normal file
121
star-raid/js/home.js
Normal file
@ -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 = '<div class="lb-empty">载入中...</div>';
|
||||
const r = await NET.api("/api/leaderboard?sort=" + sort, "GET");
|
||||
if (!r.ok) { list.innerHTML = '<div class="lb-empty">载入失败</div>'; return; }
|
||||
const rows = r.data.rows || [];
|
||||
list.innerHTML = "";
|
||||
if (!rows.length) { list.innerHTML = '<div class="lb-empty">虚位以待</div>'; 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);
|
||||
}
|
||||
76
star-raid/js/net.js
Normal file
76
star-raid/js/net.js
Normal file
@ -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(() => { /* 网络失败静默:本地存档仍在 */ });
|
||||
},
|
||||
};
|
||||
@ -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);}
|
||||
// 敌机
|
||||
|
||||
@ -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",`<div class="nm">${def.name}</div>`);
|
||||
d.onclick=()=>{SFX.pick();startDemoSim(kind,k);};
|
||||
d.appendChild(iconCanvas(img,28,!owned));
|
||||
d.insertAdjacentHTML("beforeend",`<div class="nm">${owned?def.name:"???"}</div>`);
|
||||
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";
|
||||
|
||||
12
star-raid/package.json
Normal file
12
star-raid/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
142
star-raid/server/api.js
Normal file
142
star-raid/server/api.js
Normal file
@ -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 };
|
||||
69
star-raid/server/auth.js
Normal file
69
star-raid/server/auth.js
Normal file
@ -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 };
|
||||
63
star-raid/server/db.js
Normal file
63
star-raid/server/db.js
Normal file
@ -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<?").run(Date.now()); } catch (e) { /* 忽略 */ }
|
||||
}, 3600000).unref();
|
||||
|
||||
module.exports = db;
|
||||
106
star-raid/server/server.js
Normal file
106
star-raid/server/server.js
Normal file
@ -0,0 +1,106 @@
|
||||
"use strict";
|
||||
/* ================= Star Raid 服务端入口 =================
|
||||
零依赖:node:http + node:sqlite。静态资源白名单 + /api 路由 + 安全头 */
|
||||
const http = require("node:http");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const db = require("./db");
|
||||
const auth = require("./auth");
|
||||
const { ROUTES } = require("./api");
|
||||
const { readJson, parseCookies, clientIp } = require("./util");
|
||||
|
||||
const PORT = Number(process.env.PORT || 8643);
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
const SECURE = process.env.COOKIE_SECURE === "1"; // HTTPS 反代部署时置 1
|
||||
const ROOT = path.join(__dirname, ".."); // star-raid/
|
||||
|
||||
/* 静态资源白名单:只允许入口页与 css/js(server/、data/、demo/ 一律不放行) */
|
||||
const STATIC_PREFIXES = ["/css/", "/js/"];
|
||||
const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".ico": "image/x-icon" };
|
||||
|
||||
function secHeaders(res) {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Referrer-Policy", "no-referrer");
|
||||
res.setHeader("Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'");
|
||||
}
|
||||
function sendJson(res, status, obj, setCookie) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
...(setCookie ? { "Set-Cookie": setCookie } : {}),
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
/* 请求上下文:统一成功/失败出口 */
|
||||
function makeCtx(req, res) {
|
||||
return {
|
||||
ip: clientIp(req),
|
||||
secure: SECURE,
|
||||
token: parseCookies(req)[auth.COOKIE_NAME],
|
||||
user: null,
|
||||
setCookie: null,
|
||||
ok(data) { sendJson(res, 200, data, this.setCookie); return true; },
|
||||
fail(status, error) { sendJson(res, status, { error }, this.setCookie); return false; },
|
||||
};
|
||||
}
|
||||
|
||||
async function handleApi(req, res, pathname, query) {
|
||||
const ctx = makeCtx(req, res);
|
||||
const route = ROUTES[req.method + " " + pathname];
|
||||
if (!route) return ctx.fail(404, "not found");
|
||||
if (route.auth) {
|
||||
const u = auth.getSessionUser(db, ctx.token);
|
||||
if (!u) return ctx.fail(401, "未登录或会话已过期");
|
||||
ctx.user = u;
|
||||
}
|
||||
try {
|
||||
let body = null;
|
||||
/* logout 等无 body 的 POST 不强制 JSON;有 body 的必须 application/json */
|
||||
if (req.method === "POST" && Number(req.headers["content-length"] || 0) > 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})`);
|
||||
});
|
||||
80
star-raid/server/util.js
Normal file
80
star-raid/server/util.js
Normal file
@ -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 };
|
||||
Loading…
Reference in New Issue
Block a user