Files
pixelheros/put/server/index.js
panFD 5d398d0a29 chore(server): 添加 HOST 配置支持容器部署
在示例环境配置、README 和服务代码中添加 HOST 配置项,默认监听 0.0.0.0 以适配容器和局域网部署场景,同时更新服务启动日志显示正确的监听地址。
2026-10-03 15:37:24 +08:00

562 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 英雄配置工具服务(双模式)
* - 项目开发版(MODE=local):全功能,meta 从游戏工程 TS 编译,可写 heroSet.ts / 导出 CSV / 推送快照
* - 线上设计版(MODE=remote):meta 从 data/meta.json 快照加载,仅英雄/预设的增删改查;
* 库中 schemaVersion 高于本服务内置版本时进入只读(保存接口返回 409),需更新部署后才能修改
*/
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import esbuild from 'esbuild'
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import * as store from './db.js'
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const PROJECT_ROOT = path.resolve(__dirname, '..', '..')
const CONFIG_DIR = path.join(PROJECT_ROOT, 'assets/script/game/common/config')
const HEROSET_PATH = path.join(CONFIG_DIR, 'heroSet.ts')
const CSV_PATH = path.join(CONFIG_DIR, '英雄设计表.csv')
// heroSet.ts 中英雄条目区域标记(怪物配置在 end 标记之后,不受替换影响)
const HEROES_BEGIN = '// @hero-setup:heroes-begin'
const HEROES_END = '// @hero-setup:heroes-end'
const DATA_DIR = path.join(__dirname, 'data')
const META_SNAPSHOT_PATH = path.join(DATA_DIR, 'meta.json')
const MODE = process.env.MODE || 'local'
/** 数据结构版本:英雄/底座结构有破坏性变更时 +1,并随 push-meta 推送到库中 */
const SCHEMA_VERSION = Number(process.env.SCHEMA_VERSION || 1)
const app = express()
app.use(cors())
app.use(express.json({ limit: '5mb' }))
/** 编译配置 TS 文件为可执行 CJS,返回模块 exports */
async function loadConfigModule(file) {
const result = await esbuild.build({
entryPoints: [path.join(CONFIG_DIR, file)],
bundle: true,
write: false,
format: 'cjs',
platform: 'node',
target: 'node18',
external: ['cc'],
logLevel: 'silent',
})
const code = result.outputFiles[0].text
const module = { exports: {} }
// 屏蔽对 cc 的引用(v3 等)
const fn = new Function('module', 'exports', 'require', code)
fn(module, module.exports, () => ({ v3: () => ({}) }))
return module.exports
}
let cache = null
async function buildCache() {
// 线上设计版:从快照文件加载(快照由开发版 scripts/push-meta.js 生成并随部署发布)
if (MODE === 'remote') {
if (!fs.existsSync(META_SNAPSHOT_PATH)) {
throw new Error('缺少 data/meta.json 快照,请先在开发版执行 npm run push-meta 并重新部署')
}
cache = JSON.parse(fs.readFileSync(META_SNAPSHOT_PATH, 'utf8'))
return cache
}
const [skill, buff, hero] = await Promise.all([
loadConfigModule('SkillSet.ts'),
loadConfigModule('BuffSet.ts'),
loadConfigModule('heroSet.ts'),
])
cache = {
SkillSet: skill.SkillSet,
FieldSkillSet: skill.FieldSkillSet,
BuffList: buff.BuffList,
HERO_ROLE_BASE: hero.HERO_ROLE_BASE,
HERO_GROW_COEF: hero.HERO_GROW_COEF,
AtkSpeedSet: hero.AtkSpeedSet,
HeroDisVal: hero.HeroDisVal,
}
return cache
}
app.get('/api/meta', async (_req, res) => {
try {
if (!cache) await buildCache()
res.json({
...cache,
mode: MODE,
schemaVersion: SCHEMA_VERSION,
dbSchemaVersion: await store.getSchemaVersion(),
})
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
app.post('/api/refresh', async (_req, res) => {
try {
await buildCache()
res.json({ ok: true })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 版本闸门:线上设计版落后于库中结构版本时,写操作返回 409 强制只读 */
async function guardVersion(res) {
if (MODE !== 'remote') return true
const dbVer = await store.getSchemaVersion()
if (dbVer > SCHEMA_VERSION) {
res.status(409).json({
error: `线上设计版结构版本(v${SCHEMA_VERSION})低于数据库(v${dbVer}),请更新部署后再修改`,
dbSchemaVersion: dbVer,
schemaVersion: SCHEMA_VERSION,
})
return false
}
return true
}
/** 英雄数据:GET 从线上库读取 */
app.get('/api/heroes', async (_req, res) => {
try {
res.json({ heroes: await store.getHeroes() })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 英雄数据:POST 全量覆盖写入线上库 */
app.post('/api/heroes', async (req, res) => {
const heroes = req.body?.heroes
if (!Array.isArray(heroes)) {
return res.status(400).json({ error: 'heroes 必须为数组' })
}
try {
if (!(await guardVersion(res))) return
await store.saveHeroes(heroes)
res.json({ ok: true, count: heroes.length })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 预设库:GET 从线上库读取 */
app.get('/api/templates', async (_req, res) => {
try {
res.json(await store.getTemplates())
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 预设库:POST 全量覆盖写入线上库(结构:{ triggerPresets: [], fieldPresets: [] }) */
app.post('/api/templates', async (req, res) => {
const body = req.body || {}
const triggerPresets = body.triggerPresets
const fieldPresets = body.fieldPresets
if (!Array.isArray(triggerPresets) || !Array.isArray(fieldPresets)) {
return res.status(400).json({ error: 'triggerPresets 与 fieldPresets 必须为数组' })
}
// 触发预设必须绑定合法触发组
const groups = ['atking', 'atked', 'fstart', 'fend', 'dead']
const bad = triggerPresets.find((p) => !groups.includes(p.group))
if (bad) {
return res.status(400).json({ error: `触发预设 ${bad.id || bad.label} 的 group 非法:${bad.group}` })
}
try {
if (!(await guardVersion(res))) return
await store.saveTemplates(triggerPresets, fieldPresets)
res.json({ ok: true, triggerCount: triggerPresets.length, fieldCount: fieldPresets.length })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 序列化 overrides:TGroup 以字符串存储(如 "Team"),导出时还原为枚举表达式 TGroup.Team;
* buff_target 以数值存储(0-3),导出时还原为 BuffTarget.X 枚举表达式提升可读性 */
const BUFF_TARGET_NAMES = ['Hit', 'Self', 'Ally', 'Enemy']
function serializeOverrides(o) {
let s = JSON.stringify(o).replace(/"TGroup":\s*"(\w+)"/g, '"TGroup": TGroup.$1')
s = s.replace(/"buff_target":\s*([0-3])\b/g, (_, n) => `"buff_target": BuffTarget.${BUFF_TARGET_NAMES[+n]}`)
return s
}
/** 生成英雄 TS 条目(供 /api/apply 使用) */
function renderSingleHero(h) {
const lines = []
const indent = ' '
const push = (s) => lines.push(indent + s)
push(`// ${h.uuid} ${h.name}${h.comment ? ` — ${h.comment}` : ''}`)
push(`${h.uuid}: {`)
const roleExpr = `HERO_ROLE_BASE[HRole.${h.roleKey}]`
const stat = (k) => {
const base = h.statBase?.[k] ?? 0
if (base === 0) return `${roleExpr}.${k}`
return `${roleExpr}.${k} ${base > 0 ? '+' : '-'} ${Math.abs(base)}`
}
push(
` uuid: ${h.uuid}, name: ${JSON.stringify(h.name)}, path: ${JSON.stringify(h.path)}, fac: FacSet.HERO, rarity: ${h.rarity}, lv: 1, type: HType.${h.typeKey}, grow_type: HGrowType.${h.growKey}, role: HRole.${h.roleKey}, hp: ${stat('hp')}, ap: ${stat('ap')}, def: ${stat('def')},`
)
if (h.dis != null) push(` dis: ${h.dis},`)
if (h.speed != null) push(` speed: ${h.speed},`)
if (h.dodge != null) push(` dodge: ${h.dodge},`)
if (h.accuracy != null) push(` accuracy: ${h.accuracy},`)
// skills
push(` skills: {`)
for (const sk of h.skills) {
const parts = [`uuid: ${sk.uuid}`]
if (sk.name) parts.push(`name: ${JSON.stringify(sk.name)}`)
parts.push(`lv: 1`)
parts.push(`cd: ${sk.cdExpr || sk.cd}`)
parts.push(`ccd: 0`)
if (sk.overrides && Object.keys(sk.overrides).length) parts.push(`overrides: ${serializeOverrides(sk.overrides)}`)
if (sk.desc) parts.push(`desc: ${JSON.stringify(sk.desc)}`)
if (sk.lv_entries?.length) {
push(` ${sk.uuid}: { ${parts.join(', ')},`)
push(` lv_entries: [`)
for (const e of sk.lv_entries) {
const eparts = [`lv: ${e.lv}`]
if (e.s_lv) eparts.push(`s_lv: SkillLv.Lv${e.s_lv}`)
if (e.overrides && Object.keys(e.overrides).length) eparts.push(`overrides: ${serializeOverrides(e.overrides)}`)
if (e.desc) eparts.push(`desc: ${JSON.stringify(e.desc)}`)
push(` { ${eparts.join(', ')} },`)
}
push(` ],`)
push(` },`)
} else {
push(` ${sk.uuid}: { ${parts.join(', ')} },`)
}
}
push(` },`)
// triggers
const triggerKeys = ['atking', 'atked', 'fstart', 'fend', 'dead']
for (const tk of triggerKeys) {
const grp = h[tk]
if (!grp || !Object.keys(grp).length) continue
push(` ${tk}: {`)
for (const [sid, entries] of Object.entries(grp)) {
push(` ${sid}: [`)
for (const e of entries) {
const eparts = [`lv: ${e.lv}`]
if (e.s_lv) eparts.push(`s_lv: SkillLv.Lv${e.s_lv}`)
if (e.name) eparts.push(`name: ${JSON.stringify(e.name)}`)
eparts.push(`t_num: ${e.t_num}`)
// 概率参数:仅攻/受击触发消费;缺省(100/0)不输出,保持生成 TS 干净
if (e.t_chance != null && e.t_chance < 100) eparts.push(`t_chance: ${e.t_chance}`)
if (e.pity_add != null && e.pity_add > 0) eparts.push(`pity_add: ${e.pity_add}`)
if (e.overrides && Object.keys(e.overrides).length) eparts.push(`overrides: ${serializeOverrides(e.overrides)}`)
if (e.desc) eparts.push(`desc: ${JSON.stringify(e.desc)}`)
push(` { ${eparts.join(', ')} },`)
}
push(` ],`)
}
push(` },`)
}
// field
if (h.field?.length) {
push(` field: [`)
for (const f of h.field) {
const uuids = f.uuids.map((u) => {
const uparts = [`uuid: ${u.uuid}`, `value: ${u.value}`]
if (u.name) uparts.push(`name: ${JSON.stringify(u.name)}`)
return `{ ${uparts.join(', ')} }`
})
const desc = f.desc ? `, desc: ${JSON.stringify(f.desc)}` : ''
push(` { lv: ${f.lv}, uuids: [${uuids.join(', ')}]${desc} },`)
}
push(` ],`)
}
// 复活已纳入 dead 触发组,通过 dead 编辑器的 overrides 配置 revive_hp / revive_count
// 不再导出独立的 revive 字段
// bonus(统一额外属性加成:hp/ap/def 百分比 + crt/frz/stun/para 百分点;跳过空 attrs 档位)
const bonusList = (h.bonus || []).filter((b) => b.attrs && Object.keys(b.attrs).length)
if (bonusList.length) {
push(` bonus: [`)
for (const b of bonusList) {
// 展示字段(name/icon/info)非空才输出,保持底座纯净
const nameSeg = b.name ? ` name: ${JSON.stringify(b.name)},` : ''
const iconSeg = b.icon ? ` icon: ${JSON.stringify(b.icon)},` : ''
const infoSeg = b.info ? ` info: ${JSON.stringify(b.info)},` : ''
push(` { lv: ${b.lv}, attrs: ${JSON.stringify(b.attrs)},${nameSeg}${iconSeg}${infoSeg} },`)
}
push(` ],`)
}
push(` info: ${JSON.stringify(h.info || '')},`)
push(`},`)
return lines.join('\n') + '\n'
}
/**
* 直接写回 heroSet.ts:用编辑器数据替换英雄条目区域(HEROES_BEGIN/END 标记之间),
* 怪物条目与区域外的其它配置保持不变。
*/
const ROLE_CSV_KEY = { Tank: 'tank', Warrior: 'warrior', Assassin: 'assassin', Archer: 'archer', Mage: 'mage', Support: 'support' }
const TYPE_CSV_LABEL = { Melee: '近战', Mid: '中程', Long: '远程' }
// 攻速 cd 值 → CSV 档位描述
const CD_SPEED_LABELS = [[0.75, '极速0.75s'], [0.9, '快速0.90s'], [1.05, '中速1.05s'], [1.2, '慢速1.20s'], [1.5, '极慢1.50s']]
function cdSpeedLabel(cd) {
const hit = CD_SPEED_LABELS.find(([v]) => Math.abs(v - cd) < 0.001)
return hit ? hit[1] : `攻速${cd}s`
}
// 字段值转 CSV 单元格(含逗号/换行时加引号)
function csvCell(v) {
const s = String(v ?? '')
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
/** 覆写值转 CSV 简写:ap120 / stun10 / crt10 / bck10 / frz30 / para50 / summon2 / 附加易伤7002 */
function overrideShort(ov, { includeAp = true } = {}) {
if (!ov) return ''
const parts = []
if (includeAp && ov.ap != null) parts.push(`ap${ov.ap}`)
if (ov.crt != null) parts.push(`crt${ov.crt}`)
if (ov.stun != null) parts.push(`stun${ov.stun}`)
if (ov.para != null) parts.push(`para${ov.para}`)
if (ov.frz != null) parts.push(`frz${ov.frz}`)
if (ov.bck != null) parts.push(`bck${ov.bck}`)
if (ov.thorns_dmg != null) parts.push(`thorns${ov.thorns_dmg}`)
if (ov.thorns_rate != null) parts.push(`thorns${ov.thorns_rate}%`)
if (ov.summon_count != null && ov.summon_count > 1) parts.push(`summon${ov.summon_count}`)
if (ov.timed_buff_id != null) {
const buffName = cache?.BuffList?.[ov.timed_buff_id]?.name || ''
const val = ov.buff_value != null ? `${ov.buff_value}` : ''
const chance = ov.buff_chance != null ? `buff_chance${ov.buff_chance}` : ''
// buff_target 非默认(施法者自身)时标注目标,便于识别"挂敌方/挂队友"等跨阵营 buff
const tgtLabel = ov.buff_target != null && ov.buff_target !== 1 ? `→${BUFF_TARGET_NAMES[ov.buff_target]}` : ''
parts.push(`附加${val}${buffName}${ov.timed_buff_id}${chance}${tgtLabel}`)
}
// timed_debuff_id:命中被攻击者后施加(目标固定,无需标注)
if (ov.timed_debuff_id != null) {
const debuffName = cache?.BuffList?.[ov.timed_debuff_id]?.name || ''
const val = ov.debuff_value != null ? `${ov.debuff_value}` : ''
const chance = ov.debuff_chance != null ? `debuff_chance${ov.debuff_chance}` : ''
parts.push(`命中附加${val}${debuffName}${ov.timed_debuff_id}${chance}`)
}
// proc_skill:施法后概率触发额外技能(出手即判定,与命中/弹道数无关),标注被触发技能名/概率/覆写 ap(若有)
if (ov.proc_skill?.s_uuid != null) {
const procName = cache?.SkillSet?.[ov.proc_skill.s_uuid]?.name || ''
const procChance = ov.proc_skill.chance != null ? `${ov.proc_skill.chance}%` : '100%'
const procAp = ov.proc_skill.overrides?.ap != null ? `ap${ov.proc_skill.overrides.ap}` : ''
parts.push(`施法触发${procName}${ov.proc_skill.s_uuid}(${procChance}${procAp ? `;${procAp}` : ''})`)
}
return parts.join(';')
}
/** 技能 lv1 描述行:6004 攻击(ap100;慢速1.20s) */
function csvSkillLv1(sk, isUlt) {
if (!sk) return ''
const base = cache?.SkillSet?.[sk.uuid]
const name = sk.name || base?.name || ''
const ov = sk.overrides || {}
const parts = []
if (ov.ap != null) parts.push(`ap${ov.ap}`)
if (!isUlt) parts.push(cdSpeedLabel(sk.cd ?? 1.05))
const extra = overrideShort(ov, { includeAp: false })
if (extra) parts.push(extra)
return `${sk.uuid} ${name}(${parts.join(';')})`
}
/** 技能升档描述行:ap120 / stun10 / bck10 / 附加30%出血7007buff_chance30 */
function csvSkillLvUp(sk, lv) {
if (!sk) return ''
const entry = (sk.lv_entries || []).find((e) => e.lv === lv)
if (!entry) return ''
return overrideShort(entry.overrides || {})
}
/** 触发技能描述行:6004 奋力一击(t_num3;ap300;慢速1.20s):每三次攻击后立即打击敌人,对敌人造成300%伤害 */
function csvTrigger(grp) {
if (!grp) return '无'
for (const [sid, entries] of Object.entries(grp)) {
const e = entries?.[0]
if (!e) continue
const base = cache?.SkillSet?.[sid]
const name = e.name || base?.name || ''
const ov = e.overrides || {}
const parts = [`t_num${e.t_num}`]
// 攻/受击组附带概率信息(缺省 100 不展示)
if (e.t_chance != null && e.t_chance < 100) parts.push(`概率${e.t_chance}%`)
if (ov.ap != null) parts.push(`ap${ov.ap}`)
const desc = e.desc || ''
return `${sid} ${name}(${parts.join(';')}):${desc}`
}
return '无'
}
/** 光环描述行:7005 暴伤光环0.25 */
function csvField(field) {
if (!field?.length) return '无'
const f = field[0]
const parts = f.uuids.map((u) => {
const base = cache?.FieldSkillSet?.[u.uuid]
const name = u.name || base?.name || ''
return `${u.uuid} ${name}${u.value}`
})
return parts.join('+')
}
/** 英雄基础三维行:tank+20 / warrior+20 / archer */
function csvStatLine(h, key) {
const role = ROLE_CSV_KEY[h.roleKey] || h.roleKey || ''
const base = h.statBase?.[key] ?? 0
if (base === 0) return role
return `${role}${base > 0 ? '+' : ''}${base}`
}
/** bonus 描述行:lv9 ap+20% hp+15%;lv12 crt+10 ap+30% */
function csvBonus(bonus) {
if (!bonus?.length) return '无'
return bonus
.filter((b) => b.attrs && Object.keys(b.attrs).length)
.map((b) => {
const parts = Object.entries(b.attrs).map(([k, v]) => (k === 'hp' || k === 'ap' || k === 'def') ? `${k}+${v}%` : `${k}+${v}`)
return `lv${b.lv} ${parts.join(' ')}`
})
.join(';') || '无'
}
/** 生成英雄设计表 CSV 内容 */
function renderHeroCsv(heroes) {
const sorted = [...heroes].sort((a, b) => a.uuid - b.uuid)
const rows = []
rows.push(['字段', ...sorted.map((h) => h.uuid)])
rows.push(['英雄名称', ...sorted.map((h) => h.name || '')])
rows.push(['简化说明', ...sorted.map((h) => h.comment || '')])
rows.push(['rarity', ...sorted.map((h) => h.rarity ?? 1)])
rows.push(['类型', ...sorted.map((h) => ROLE_CSV_KEY[h.roleKey] || h.roleKey || '')])
rows.push(['形态', ...sorted.map((h) => TYPE_CSV_LABEL[h.typeKey] || h.typeKey || '')])
rows.push(['ap', ...sorted.map((h) => csvStatLine(h, 'ap'))])
rows.push(['hp', ...sorted.map((h) => csvStatLine(h, 'hp'))])
rows.push(['def', ...sorted.map((h) => csvStatLine(h, 'def'))])
rows.push(['dodge', ...sorted.map((h) => (h.dodge != null ? h.dodge : ''))])
rows.push(['accuracy', ...sorted.map((h) => (h.accuracy != null ? h.accuracy : ''))])
rows.push(['skill[0]_lv1', ...sorted.map((h) => csvSkillLv1(h.skills?.[0], false))])
rows.push(['skill[0]_lv2', ...sorted.map((h) => csvSkillLvUp(h.skills?.[0], 2))])
rows.push(['skill[1]_lv1', ...sorted.map((h) => csvSkillLv1(h.skills?.[1], true))])
rows.push(['skill[1]_lv8', ...sorted.map((h) => csvSkillLvUp(h.skills?.[1], 8))])
rows.push(['skill[1]_lv16', ...sorted.map((h) => csvSkillLvUp(h.skills?.[1], 16))])
rows.push(['atking[0]_lv4', ...sorted.map((h) => csvTrigger(h.atking))])
rows.push(['atked[0]_lv4', ...sorted.map((h) => csvTrigger(h.atked))])
rows.push(['fstart[0]_lv4', ...sorted.map((h) => csvTrigger(h.fstart))])
rows.push(['fend[0]_lv4', ...sorted.map((h) => csvTrigger(h.fend))])
rows.push(['dead[0]_lv4', ...sorted.map((h) => csvTrigger(h.dead))])
rows.push(['field[0]_lv4', ...sorted.map((h) => csvField(h.field))])
rows.push(['bonus', ...sorted.map((h) => csvBonus(h.bonus))])
return rows.map((r) => r.map(csvCell).join(',')).join('\n') + '\n'
}
/** 仅开发版可用的本地文件写操作(heroSet.ts / CSV),线上设计版调用返回 403 */
function localOnly(res) {
if (MODE === 'local') return true
res.status(403).json({ error: '线上设计版不支持此操作(写 heroSet.ts / 导出 CSV 请在项目开发版执行)' })
return false
}
app.post('/api/export-csv', async (req, res) => {
try {
if (!localOnly(res)) return
const heroes = req.body?.heroes
if (!Array.isArray(heroes) || !heroes.length) {
return res.status(400).json({ error: 'heroes 不能为空' })
}
if (!cache) await buildCache()
const content = renderHeroCsv(heroes)
fs.writeFileSync(CSV_PATH, content, 'utf8')
res.json({ ok: true, path: CSV_PATH, count: heroes.length, bytes: Buffer.byteLength(content) })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
app.get('/api/export-csv', (req, res) => {
if (!localOnly(res)) return
if (!fs.existsSync(CSV_PATH)) return res.status(404).json({ error: '尚未生成' })
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.send(fs.readFileSync(CSV_PATH, 'utf8'))
})
/** 导出 meta 快照(开发版专用):供 push-meta 脚本发布到线上设计版 */
app.get('/api/meta-snapshot', async (_req, res) => {
try {
if (!localOnly(res)) return
if (!cache) await buildCache()
const { mode: _m, schemaVersion: _v, dbSchemaVersion: _d, ...snapshot } = cache
res.json(snapshot)
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** HeroList 定义匹配(用于导出时同步图鉴/卡池数据源) */
const HERO_LIST_RE = /export const HeroList: number\[\] = \[[\s\S]*?\n\];/
/** 按导出英雄 uuid 升序重建 HeroList(每行 10 个,含去重),避免编辑器增删英雄后列表不同步 */
function syncHeroList(src, heroes) {
const uuids = [...new Set(heroes.map((h) => h.uuid))].sort((a, b) => a - b)
if (!uuids.length) return src
const lines = []
for (let i = 0; i < uuids.length; i += 10) {
lines.push(' ' + uuids.slice(i, i + 10).join(', ') + ',')
}
const block =
`export const HeroList: number[] = [\n` +
` // 编辑器导出自动同步(共 ${uuids.length} 个)\n` +
`${lines.join('\n')}\n` +
`];`
if (!HERO_LIST_RE.test(src)) {
throw new Error('heroSet.ts 中未找到 HeroList 定义,无法同步')
}
return src.replace(HERO_LIST_RE, block)
}
app.post('/api/apply', (req, res) => {
try {
if (!localOnly(res)) return
const heroes = req.body?.heroes
if (!Array.isArray(heroes) || !heroes.length) {
return res.status(400).json({ error: 'heroes 不能为空' })
}
const src = fs.readFileSync(HEROSET_PATH, 'utf8')
const bi = src.indexOf(HEROES_BEGIN)
const ei = src.indexOf(HEROES_END)
if (bi < 0 || ei < 0 || ei < bi) {
return res.status(500).json({ error: 'heroSet.ts 中未找到英雄区域标记(@hero-setup:heroes-begin/end)' })
}
const body = heroes.map(renderSingleHero).join('')
let next = src.slice(0, bi) + HEROES_BEGIN + '\n' + body + HEROES_END + src.slice(ei + HEROES_END.length)
// 同步 HeroList:图鉴展示与抽卡池(HeroCardPoolList)均由该列表驱动
next = syncHeroList(next, heroes)
fs.writeFileSync(HEROSET_PATH, next, 'utf8')
res.json({ ok: true, path: HEROSET_PATH, count: heroes.length, bytes: Buffer.byteLength(next) })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
const PORT = Number(process.env.PORT || 3001)
// 默认监听所有网卡,保证容器/局域网端口映射可用;可用 HOST=127.0.0.1 限制为本机
const HOST = process.env.HOST || '0.0.0.0'
// 线上设计版:托管前端构建产物(web/dist),单个服务即可对外提供完整站点
if (MODE === 'remote') {
const DIST_DIR = path.resolve(__dirname, '..', 'web', 'dist')
if (fs.existsSync(DIST_DIR)) {
app.use(express.static(DIST_DIR))
app.get(/^\/(?!api\/).*/, (_req, res) => res.sendFile(path.join(DIST_DIR, 'index.html')))
}
}
app.listen(PORT, HOST, () => {
console.log(`[hero-config-tool] mode=${MODE} schemaVersion=${SCHEMA_VERSION} listening http://${HOST}:${PORT}`)
})