Files
ChronicleOfTheImmortalClan/src/renderer/game/engine/runtime/modSchema.ts
T
thzxx a7fe166e85 v0.1.34b: 插件新卡+MOD 功法效果口/patch 层/冲突日志
【插件】SYSTEM_DEFS + worldview 观察卡/tech 功法卡(14);PluginContext + addTechniqueDef(全字段入 PACK 数组, grade≤3 自动池) + onWorldSimTick 观察回调(零 rng 零时序——proxy 追踪注销);
【MOD】ModPackData.techniques 补 4 流派效果字段;新增 patch 层(target 数值 mult 0.5~2/add——install 应用 uninstall 复原,validate 规则进预检);同 id 功法冲突 warn 日志
【门面】Runtime 未动;卸载回滚栈 patchUndo
2026-08-23 22:09:38 +08:00

172 lines
8.6 KiB
TypeScript
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.
/**
* MOD 包 schema0.1.25《乾坤一统》)
* MOD = 可分发的外部内容包(JSON 单文件/文件)——Manifest + 纯数据。
* MOD 经 ModManager 转换为 CotycPlugin 进标准插件管线(持久化/双闸/回滚免费获得)。
*/
import { EventDef } from '../../data/events'
import { NpcFamilyDef } from '../../data/npcs'
export type ModKind = 'content' | 'data' | 'rules'
export interface ModPackData {
/** 追加事件(需与既有 id 无冲突) */
events?: EventDef[]
/** 世界模板(worldgen 池候选——新档生成时参与) */
npcs?: NpcFamilyDef[]
/** 世界生成词库注入 */
worldgen?: {
styles?: string[]
regions?: string[]
fams?: string[]
suffixes?: string[]
}
/** 灾因扩展(0.1.26:进入世界灾签循环) */
calamities?: Array<{ name: string; brief?: string; family?: Record<string, number>; effect?: Record<string, number> }>
/** 世界数值白名单覆写(0.1.27 声明口径,0.1.28 接线;卸载复位) */
worldNum?: Record<string, number>
/** 丹方/铸器配方 */
pills?: Array<{ output: string; name: string; danfangLevel: number; stones: number; lingcao: number; beastcore: number; desc: string }>
forges?: Array<{ output: string; name: string; stones: number; lingkuang: number; beastcore: number; desc: string }>
/** 0.1.31 内容面总攻:物品/功法(PACK 合入)、建筑/职事/志向/阵型/性格(双源注册) */
items?: Array<{ id: string; name: string; kind: 'resource' | 'material' | 'pill' | 'talisman' | 'brew' | 'artifact'; basePrice: number; desc: string; icon: string }>
techniques?: Array<{ id: string; name: string; grade: number; element: string; path: string; expBonus: number; powerBonus: number; desc: string; critChance?: number; guardBonus?: number; tribBonus?: number; secretBonus?: number }>
buildings?: Array<{ id: string; name: string; icon: string; desc: string; kind: 'produce' | 'function'; maxLevel: number; produceExpr?: string; upgradeStones?: number; upgradeMineral?: number }>
/** 0.1.34 数值 patch 层(mult 0.5~2 / add 实数——仅新开档生效;老档世界形成为档记录) */
patch?: Array<{ target: 'technique' | 'item' | 'building'; id: string; mult?: number; add?: number }>
posts?: Array<{ id: string; name: string; effectType: string; value: number; max: number; desc: string }>
aspirations?: Array<{ id: string; name: string; desc: string; effectType: string; value: number }>
formations?: Array<{ id: string; name: string; icon: string; desc: string; atk: number; def: number; retreatWound: number }>
traits?: Array<{ id: string; name: string; desc: string; bonus?: Record<string, number> }>
}
export interface ModPack {
id: string
name: string
/** MOD 类型(0.1.31):content=内容包 / data=实体包 / rules=规则包 */
kind?: ModKind
version: string
gameVersion?: string
author?: string
description?: string
/** 模式范式版本(0.1.26 起建议声明 = 1) */
schema?: number
depends?: string[]
conflicts?: string[]
data: ModPackData
}
export type ModParseResult = { ok: true; pack: ModPack } | { ok: false; error: string }
const ID_RE = /^[a-z0-9][a-z0-9-_]{1,31}$/i
/** 纯解析(任何文本 → ModPack 或错误);校验层供 vitest 直测 */
export function modParse(text: string): ModParseResult {
let raw: unknown
try {
raw = JSON.parse(text)
} catch {
return { ok: false, error: 'MOD 文件不是合法 JSON。' }
}
// P0-7 bundle 分支:游戏内导出的合并包({app:'cotymod',bundle:[...]})→ 逐包递归(首包解析失败即报)
const asRec = raw as { app?: string; bundle?: unknown[] }
if (asRec?.app === 'cotymod' && Array.isArray(asRec.bundle)) {
if (asRec.bundle.length === 0) return { ok: false, error: 'bundle 为空。' }
for (const b of asRec.bundle) {
const r = modParse(JSON.stringify(b))
if (!r.ok) return { ok: false, error: `bundle 内包非法:${r.error}` }
}
return modParse(JSON.stringify(asRec.bundle[0]))
}
if (!raw || typeof raw !== 'object') return { ok: false, error: 'MOD 内容为空。' }
const p = raw as Record<string, unknown>
if (typeof p.id !== 'string' || !ID_RE.test(p.id)) return { ok: false, error: '缺失/非法的 MOD id。' }
if (typeof p.name !== 'string' || !p.name.trim()) return { ok: false, error: '缺失 MOD 名称。' }
if (typeof p.version !== 'string' || !p.version.trim()) return { ok: false, error: '缺失 MOD 版本号。' }
const data = (p.data ?? {}) as Record<string, unknown>
if (!data || typeof data !== 'object') return { ok: false, error: 'MOD data 段缺失。' }
if (Array.isArray(data.events)) {
for (const e of data.events) {
if (!e || typeof e !== 'object') return { ok: false, error: 'MOD 事件表含非法行。' }
const ev = e as Record<string, unknown>
if (typeof ev.id !== 'string' || typeof ev.name !== 'string' || !Array.isArray(ev.options)) {
return { ok: false, error: `MOD 事件 ${String(ev.id ?? '?')} 缺 id/name/options。` }
}
}
}
if (Array.isArray(data.npcs)) {
for (const n of data.npcs) {
const nn = n as Record<string, unknown>
if (typeof nn.id !== 'string' || typeof nn.name !== 'string' || typeof nn.region !== 'string') {
return { ok: false, error: 'MOD 模板表含非法行。' }
}
}
}
return { ok: true, pack: raw as ModPack }
}
/** P1-O 全量解析:bundle → 全部包;单包 → [包];非法 → [] */
export function modParseAll(text: string): ModPack[] {
let raw: unknown
try { raw = JSON.parse(text) } catch { return [] }
const asRec = raw as { app?: string; bundle?: unknown[] }
if (asRec?.app === 'cotymod' && Array.isArray(asRec.bundle)) {
const out: ModPack[] = []
for (const b of asRec.bundle) {
const r = modParse(JSON.stringify(b))
if (r.ok) out.push(r.pack)
}
return out
}
const r = modParse(text)
return r.ok ? [r.pack] : []
}
export const MOD_SCHEMA_VERSION = 1
export interface ModValidation {
ok: boolean
warnings: string[]
}
const CATEGORIES = ['daily', 'major', 'fate'] as const
const REALMS = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit'] as const
/** 范式校验(0.1.26 规范):深度字段检查——event 形状/模板境界/配方非负 */
export function validateMod(pack: ModPack): ModValidation {
const warnings: string[] = []
if (pack.schema !== undefined && pack.schema !== MOD_SCHEMA_VERSION) {
warnings.push(`schema 版本 ${pack.schema} ≠ 当前支持 ${MOD_SCHEMA_VERSION}(提示 MOD 作者更新)。`)
}
if (!pack.author) warnings.push('缺 author(建议填,供玩家辨识)。')
for (const e of pack.data.events ?? []) {
if (!CATEGORIES.includes(e.category as never)) warnings.push(`事件 ${e.id} category 非法(daily|major|fate)。`)
if (!(e.weight >= 0 && e.weight <= 100)) warnings.push(`事件 ${e.id} weight 超界(0~100)。`)
if (!e.text || !e.text.trim()) warnings.push(`事件 ${e.id} 缺正文。`)
if (!e.options || e.options.length === 0) warnings.push(`事件 ${e.id} 缺选项。`)
for (const o of e.options ?? []) {
if (!o.label) warnings.push(`事件 ${e.id} 某选项缺 label。`)
if (!o.eff || typeof o.eff !== 'object') warnings.push(`事件 ${e.id} 某选项缺 eff(可空对象)。`)
}
}
for (const n of pack.data.npcs ?? []) {
if (!REALMS.includes(n.leaderRealm)) warnings.push(`模板 ${n.id} leaderRealm 非法(${REALMS.join('|')})。`)
if (!(n.initialPower >= 60 && n.initialPower <= 400)) warnings.push(`模板 ${n.id} initialPower 建议 60~400。`)
}
for (const r of pack.data.pills ?? []) {
if ([r.stones, r.lingcao, r.beastcore].some((v) => v < 0)) warnings.push(`丹方 ${r.output} 含负成本。`)
if (!(r.danfangLevel >= 1 && r.danfangLevel <= 5)) warnings.push(`丹方 ${r.output} danfangLevel 建议 1~5。`)
}
for (const r of pack.data.forges ?? []) {
if ([r.stones, r.lingkuang, r.beastcore].some((v) => v < 0)) warnings.push(`配方 ${r.output} 含负成本。`)
}
for (const t of pack.data.techniques ?? []) {
if (!t.grade || t.grade < 1 || t.grade > 4) warnings.push(`功法 ${t.id} grade 建议 1~4。`)
if (Math.abs(t.expBonus) > 0.5 || Math.abs(t.powerBonus) > 1) warnings.push(`功法 ${t.id} 数值强度提醒(expBonus≤0.5/powerBonus≤1)。`)
}
for (const p of pack.data.patch ?? []) {
if (!(p.mult === undefined || (p.mult >= 0.5 && p.mult <= 2))) warnings.push(`patch ${p.target}:${p.id} mult 限 0.5~2。`)
if (p.mult !== undefined && p.add !== undefined) warnings.push(`patch ${p.target}:${p.id} mult/add 二选一(勿同给)。`)
}
return { ok: warnings.length === 0, warnings }
}