diff --git a/AGENTS.md b/AGENTS.md index 8d0b998..bf02112 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md 仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。 -当前版本 **0.1.25**(《乾坤一统》:MOD 系统首版(userData/mods 扫描/解析/安装/持久化)+ worldGen NPC 落档修复 + 配方注册表)。 +当前版本 **0.1.26**(《墨规清辉》:MOD 规范范式(MOD_GUIDE + validateMod + 灾因注册表)+ 页面文本提亮可读性)。 ## 命令 diff --git a/docs/MOD_GUIDE.md b/docs/MOD_GUIDE.md new file mode 100644 index 0000000..34947d4 --- /dev/null +++ b/docs/MOD_GUIDE.md @@ -0,0 +1,129 @@ +# MOD 规范与范式标准 · Chronicle of the Immortal Clan + +> 版本 **0.1.26·《墨规清辉》** · MOD_SCHEMA_VERSION = 1 +> 每个 MOD 是一个**外部内容包**(纯 JSON,零脚本);安装后成为插件一等子类, +> 享有插件的全部设施:持久化(随档)、启用/停用、依赖/冲突检查、卸载自动回滚。 + +--- + +## 1. 命名与版本 + +| 项 | 规范 | +|---|---| +| `id` | `^[a-z0-9][a-z0-9-_]{1,31}$`(小写+数字+连字符/下划线;**全局唯一**,安装冲突即拒绝) | +| `version` | semver:`major.minor.patch`(如 `1.0.0`) | +| `schema` | **0.1.26 起建议声明**:`"schema": 1`(与 MOD_SCHEMA_VERSION 对齐;不符给升级提示) | +| 文件名 | `.cotymod`(推荐)或 `.json`,置于 `userData/mods/` | +| `gameVersion` | 可选声明"对哪个游戏版本制作"(仅展示,不强制) | + +## 2. 包结构(全 JSON 单文件) + +```jsonc +{ + "id": "jiangnan-feng", // 必填:全局唯一 + "name": "江南遗风", // 必填 + "version": "1.0.0", // 必填 + "schema": 1, // 建议:范式版本 + "author": "你的名号", // 建议:玩家辨识 + "description": "一句话说明来意。", // 建议 + "depends": ["other-mod-id"], // 先装依赖(转 mod- 前缀) + "conflicts": ["other-mod-id"], + "data": { + "events": [ /* EventDef 追加:见 §3 */ ], + "npcs": [ /* NpcFamilyDef 模板:见 §4 */ ], + "worldgen": { "fams": [], "regions": [], "styles": [], "suffixes": [] }, + "calamities": [ { "name": "剑灾", "family": { "lingcao": 0.7 }, "effect": { "beastcore": 0.2 } } ], + "pills": [ /* PillRecipe 追加 */ ], + "forges": [ /* ForgeRecipe 追加 */ ] + } +} +``` + +## 3. 事件(events) + +EventDef 全字段**纯数据**(与内置事件同构): + +```jsonc +{ + "id": "ev-jiangnan-rain", // 全局唯一!与核心/已装池冲突 → 拒绝安装 + "name": "江南烟雨", + "category": "daily", // daily | major | fate + "weight": 2, // 0~100(影响抽样概率) + "once": false, + "text": "蒙蒙细雨落长街。", + "options": [ + { "label": "静听雨声", "hint": "无", "eff": { "rep": 1 } } + // eff 字段见 data/events.ts EffectDef:res/rep/relation/addBuilding/ + // pillGain/mission/raid/flag/techniqueChance/artifactChance/addTech/ + // feisheng/tournament/formation/apprentice/trib + ] +} +``` + +**红线**:事件运行期是引擎核心时序——你的 eff 只影响数据与状态,**不得新增 `rng` 消耗**(会移动全局随机序列)。 + +## 4. 世界模板(npcs) + +```jsonc +{ + "id": "n-jiangnan-gu", // 建议 n- 前缀(与新贵生成池合并) + "name": "姑苏顾氏", + "region": "江南烟雨港", // 加入世界区域库 + "style": "风雅世家", + "desc": "临水而居,诗酒传家。", + "leaderRealm": "foundation", // mortal|qi|foundation|core|nascent|spirit + "initialPower": 150, // 建议 60~400 + "powerGrowth": [3, 9], + "sells": ["lingcao"], + "buys": ["lingcao", "lingkuang"] +} +``` + +模板进入 worldgen 词库池:**玩家"新开档"时参与世界生成**(老档世界不变形)。 +同 `worldgen` 段的 `fams/regions/styles/suffixes` 亦写入生成池。 + +## 5. 灾因(calamities) + +```jsonc +{ "name": "剑灾", "family": { "lingcao": 0.7 }, "effect": { "beastcore": 0.2 } } +``` + +- `name`:全局唯一(与默认 6 灾 + 已装 MOD 冲突即忽略) +- `family`:**玩家家族**生产乘量(1=正常) +- `effect`:**世界市场**池冲击乘量(-0.3 意为减少 30%) +- 灾签与世界循环(减产/市价/快讯/体感)全链路自动生效——这是本作灾年机制的扩展口 + +## 6. 配方(pills / forges) + +```jsonc +{ "output": "pill-jiujin", "name": "九金丹", "danfangLevel": 4, + "stones": 500, "lingcao": 60, "beastcore": 12, "desc": "丹房四级:百年丹。", + // forges 用: { "output": "weapon-xian", "stones": 900, "lingkuang": 20, "beastcore": 6, ... } +} +``` + +`output` 全局唯一;`danfangLevel` 建议 1~5(参与丹房门槛);**成本非负**(校验警告)。 + +## 7. 安全红线(必读) + +1. **零脚本**:MOD 只含 JSON——不做 eval/沙箱/运行时代码。 +2. **不碰 `World.rng`**:不新增随机消耗(见 §3 红线;否则世界确定性崩、金钟罩红)。 +3. **不改核心时序**:不重排 phase、不覆盖 advanceMonth;扩展走事件池/模板/灾因/配方/词库五口。 +4. **语义克制**:数值建议贴近现状量级(power 60~400、weight 0~100、成本仅低于内置顶级丹)。 +5. **命名唯一**:id/event id/模板 id/灾因名/配方 output 五个命名空间全部唯一。 + +## 8. 已验证的安装流程(行为承诺) + +- 安装:`World.installPlugin(modToPlugin(pack))` → 事件进池/模板注册/词库聚合/灾因入表/配方注册; +- `modPreflight` 先行:id 冲突拒绝 + 范式警告;`validateMod` 深度校验(category/weight/境界/非负); +- 启用/停用/卸载:插件双闸与自动回滚照常(卸载即全摘); +- 存档:MOD 状态随 `state.plugins` 持久化,读档自动重装(版本不符跳过并提示)。 + +## 9. 打包与分发 + +- 单文件 `.cotymod`(= JSON)直接分发;放入 `userData/mods/` 后游戏内「设置 → MOD 层 → 扫描目录 → 安装」。 +- 注意:**MOD 只影响新开档**的世界生成;已开档不受影响(世界种已落档)。 + +--- + +**一句话范式**:MOD = "给世界的材料包"——五口(事件/模板/词库/灾因/配方)入炉,生成与演化由引擎统一烹调。 diff --git a/package.json b/package.json index a38b3f6..44e8786 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.25", + "version": "0.1.26", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/renderer/game/engine/kernel/plugin.ts b/src/renderer/game/engine/kernel/plugin.ts index 54de446..8c0e6d9 100644 --- a/src/renderer/game/engine/kernel/plugin.ts +++ b/src/renderer/game/engine/kernel/plugin.ts @@ -42,6 +42,8 @@ export interface PluginContext { addForgeRecipe: (r: { output: string; name: string; stones: number; lingkuang: number; beastcore: number; desc: string }) => void /** 世界生成词库池注入(styles/regions/fams/suffixes) */ addWorldGenPool: (part: { styles?: string[]; regions?: string[]; fams?: string[]; suffixes?: string[] }) => void + /** 灾因注入(0.1.26:进入世界灾签循环;重名拒绝 false) */ + addCalamity: (name: string, opts: { family?: Partial>; effect?: Partial> }) => boolean addCapability: (cap: { id: string; name: string; version: string; desc: string }) => void removeCapability: (id: string) => void enableCapability: (id: string, enabled: boolean) => void @@ -55,6 +57,7 @@ export interface PluginStatus { id: string name: string version: string + author?: string kind: PluginKind installed: boolean enabled: boolean diff --git a/src/renderer/game/engine/runtime/ApiFacade.ts b/src/renderer/game/engine/runtime/ApiFacade.ts index c9c2a84..3c6a2d9 100644 --- a/src/renderer/game/engine/runtime/ApiFacade.ts +++ b/src/renderer/game/engine/runtime/ApiFacade.ts @@ -137,7 +137,7 @@ export class GameFacade { about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } { return { title: '仙途家族志', - version: '0.1.25', + version: '0.1.26', modules: this.world.systemList().length, systems: this.world.systemList().filter((s) => s.enabled).length, plugins: this.world.pluginList().length, diff --git a/src/renderer/game/engine/runtime/Systems/production.ts b/src/renderer/game/engine/runtime/Systems/production.ts index 5f2ff3b..e117775 100644 --- a/src/renderer/game/engine/runtime/Systems/production.ts +++ b/src/renderer/game/engine/runtime/Systems/production.ts @@ -1,7 +1,7 @@ import type { World } from '../World' import { aspirationById } from '../../../data/aspirations' import { seasonMod } from '../../../data/season' -import { CALAMITY_FAMILY } from '../../sim/worldsim-data' +import { calamityFamilyOf } from '../../sim/worldsim-data' export function productionTick(w: World): void { const fam = w.state.family @@ -19,9 +19,9 @@ export function productionTick(w: World): void { const merchants = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'market').length const springMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'field') : 0 // 灾年减产(CALAMITY_FAMILY 乘量) - const cl = w.state.worldSim?.calamity as (keyof typeof CALAMITY_FAMILY) | undefined - const farmFactor = cl ? (CALAMITY_FAMILY[cl]?.lingcao ?? 1) : 1 - const mineFactor = cl ? (CALAMITY_FAMILY[cl]?.lingkuang ?? 1) : 1 + const cl = w.state.worldSim?.calamity as string | undefined + const farmFactor = cl ? (calamityFamilyOf(cl).lingcao ?? 1) : 1 + const mineFactor = cl ? (calamityFamilyOf(cl).lingkuang ?? 1) : 1 if (lingtian > 0) { const v = Math.round(10 * lingtian * (1 + fielders * 0.05 + springMod) * farmFactor) inv.lingcao = (inv.lingcao ?? 0) + v diff --git a/src/renderer/game/engine/runtime/World.ts b/src/renderer/game/engine/runtime/World.ts index 8a3ea43..696f5dc 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -19,6 +19,7 @@ import { generateWorld } from '../sim/worldgen' import { registerNpcDef, NpcFamilyDef } from '../../data/npcs' import { addPillRecipe, addForgeRecipe } from '../../data/items' import { WorldGenPools } from '../sim/worldgen' +import { addCalamity as addCalamityImpl } from '../sim/worldsim-data' import { fire } from './Systems/events' import { createWorldState, findInheritor } from './creation' import { SYSTEM_DEFS, SystemDef } from './capabilities' @@ -206,6 +207,7 @@ export class World { addPillRecipe: (r) => addPillRecipe(r), addForgeRecipe: (r) => addForgeRecipe(r), addWorldGenPool: (part) => WorldGenPools.add(part), + addCalamity: (name, opts) => addCalamityImpl(name, opts), addCapability: (cap) => { // 0.1.23:能力卡注册入 World 实例(防跨档全局泄漏);UI 全局清单读 SYSTEM_DEFS 展示不受影响 if (!self.systems[cap.id]) self.systems[cap.id] = { enabled: true } diff --git a/src/renderer/game/engine/runtime/modManager.ts b/src/renderer/game/engine/runtime/modManager.ts index 90af879..174afd5 100644 --- a/src/renderer/game/engine/runtime/modManager.ts +++ b/src/renderer/game/engine/runtime/modManager.ts @@ -3,7 +3,22 @@ * 全量复用 PluginManager 管线:持久化/启停/双闸/自动回滚。 */ import { CotycPlugin } from '../kernel/plugin' -import { ModPack } from './modSchema' +import { ModPack, validateMod } from './modSchema' +import { EventDef } from '../../data/events' +import { findEvent } from './Systems/events' +import { World } from './World' + +/** 安装前检查:事件 id 与核心/已装池冲突 + 范式校验警告 */ +export function modPreflight(w: World, pack: ModPack): { ok: boolean; reason?: string; warnings: string[] } { + const v = validateMod(pack) + // 权威冲突检测:findEvent 即「池 + 动态分支」(raid/渡劫/大比/传薪/百年…)全量 + for (const e of pack.data.events ?? []) { + if (findEvent(e.id, w)) { + return { ok: false, reason: `事件 id 冲突:${e.id}`, warnings: v.warnings } + } + } + return { ok: true, warnings: v.warnings } +} export function modToPlugin(pack: ModPack): CotycPlugin { return { @@ -25,6 +40,10 @@ export function modToPlugin(pack: ModPack): CotycPlugin { // 配方补充 for (const r of pack.data.pills ?? []) ctx.addPillRecipe(r) for (const r of pack.data.forges ?? []) ctx.addForgeRecipe(r) + // 灾因补充(0.1.26:MOD 灾因进入世界循环) + for (const c of pack.data.calamities ?? []) { + ctx.addCalamity(c.name, { family: c.family, effect: c.effect }) + } } } } diff --git a/src/renderer/game/engine/runtime/modSchema.ts b/src/renderer/game/engine/runtime/modSchema.ts index b564a30..d477686 100644 --- a/src/renderer/game/engine/runtime/modSchema.ts +++ b/src/renderer/game/engine/runtime/modSchema.ts @@ -18,6 +18,8 @@ export interface ModPackData { fams?: string[] suffixes?: string[] } + /** 灾因扩展(0.1.26:进入世界灾签循环) */ + calamities?: Array<{ name: string; family?: Record; effect?: Record }> /** 丹方/铸器配方 */ 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 }> @@ -30,6 +32,8 @@ export interface ModPack { gameVersion?: string author?: string description?: string + /** 模式范式版本(0.1.26 起建议声明 = 1) */ + schema?: number depends?: string[] conflicts?: string[] data: ModPackData @@ -73,3 +77,44 @@ export function modParse(text: string): ModParseResult { } return { ok: true, pack: raw as ModPack } } + +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} 含负成本。`) + } + return { ok: warnings.length === 0, warnings } +} diff --git a/src/renderer/game/engine/runtime/pluginManager.ts b/src/renderer/game/engine/runtime/pluginManager.ts index 7c6185b..06639e7 100644 --- a/src/renderer/game/engine/runtime/pluginManager.ts +++ b/src/renderer/game/engine/runtime/pluginManager.ts @@ -149,6 +149,7 @@ export class PluginManager { id: p.id, name: p.name, version: p.version, + author: p.author, kind: p.kind, installed: rt.status.installed, enabled: rt.status.enabled, diff --git a/src/renderer/game/engine/sim/WorldSim.ts b/src/renderer/game/engine/sim/WorldSim.ts index dfc3a00..563714d 100644 --- a/src/renderer/game/engine/sim/WorldSim.ts +++ b/src/renderer/game/engine/sim/WorldSim.ts @@ -1,6 +1,6 @@ /** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts) */ import { World } from '../runtime/World' -import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, ERA_DURA, makeWorldSimState, NpcStance, REGION_TIDE, CALAMITY_EFFECT, CALAMITY_FAMILY, CalamityName, POOL_BASE } from './worldsim-data' +import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, ERA_DURA, makeWorldSimState, NpcStance, REGION_TIDE, calamityNames, calamityFamilyOf, calamityEffectOf, CalamityName, POOL_BASE } from './worldsim-data' import { pack } from '../../data/registry' import { ITEMS } from '../../data/items' import { npcById, getNpcDefs, registerNpcDef, unregisterNpcDef } from '../../data/npcs' @@ -89,7 +89,7 @@ export class WorldSim { if ((s.calamityLeft ?? 0) > 0) s.calamityLeft = (s.calamityLeft ?? 0) - 1 const calamityP = WORLDSIM.calamityChance * ERA_CONF[s.era ?? 'pingshi'].calamityMult if (this.w.state.month === 1 && rng.chance(calamityP)) { - const cl = rng.pick([...WORLDSIM.calamities]) + const cl = rng.pick(calamityNames()) s.calamity = cl s.calamityYear = this.w.state.year s.calamityLeft = WORLDSIM.calamityMonths @@ -409,7 +409,7 @@ function inv(w: World): Record { } function applyCalamityToMarket(s: WorldSimState, cl: CalamityName): void { - const eff = CALAMITY_EFFECT[cl] + const eff = calamityEffectOf(cl) for (const [id, pct] of Object.entries(eff) as [string, number][]) { s.marketPool[id] = Math.max(10, (s.marketPool[id] ?? 50) * (1 + pct)) } diff --git a/src/renderer/game/engine/sim/worldsim-data.ts b/src/renderer/game/engine/sim/worldsim-data.ts index 59fa9ac..cfd87f2 100644 --- a/src/renderer/game/engine/sim/worldsim-data.ts +++ b/src/renderer/game/engine/sim/worldsim-data.ts @@ -155,10 +155,13 @@ export const WORLDSIM = { newsKeep: 120 } -export type CalamityName = (typeof WORLDSIM.calamities)[number] +export type CalamityName = string + +/** 默认灾因集(聚合读口:默认+MOD/插件扩展——默认表和不变 → 基准世界指纹不漂) */ +export const DEFAULT_CALAMITIES = ['旱灾', '涝灾', '蝗灾', '疫病', '兽潮', '寒潮'] as const /** 灾因 → 家族实际生产乘量(1 = 正常) */ -export const CALAMITY_FAMILY: Record>> = { +export const CALAMITY_FAMILY: Record>> = { 旱灾: { lingcao: 0.7 }, 涝灾: { lingcao: 0.8, lingkuang: 0.95 }, 蝗灾: { lingcao: 0.55 }, @@ -168,7 +171,7 @@ export const CALAMITY_FAMILY: Record>> = { +export const CALAMITY_EFFECT: Record>> = { 旱灾: { lingcao: -0.3 }, 涝灾: { lingcao: -0.2, lingkuang: -0.1 }, 蝗灾: { lingcao: -0.45 }, @@ -176,3 +179,34 @@ export const CALAMITY_EFFECT: Record>> = {} +const EXTENDED_EFFECT: Record>> = {} + +export function addCalamity( + name: string, + opts: { family?: Partial>; effect?: Partial> } +): boolean { + if (DEFAULT_CALAMITIES.includes(name as never) || EXTENDED_CALAMITIES.includes(name)) return false + EXTENDED_CALAMITIES.push(name) + if (opts.family) EXTENDED_FAMILY[name] = opts.family + if (opts.effect) EXTENDED_EFFECT[name] = opts.effect + return true +} + +/** 聚合读口(WorldSim 灾签/体感/生产/Market 读取) */ +export function calamityNames(): string[] { + return [...DEFAULT_CALAMITIES, ...EXTENDED_CALAMITIES] +} +export function calamityFamilyOf(name: string): Partial> { + return EXTENDED_FAMILY[name] ?? CALAMITY_FAMILY[name] ?? {} +} +export function calamityEffectOf(name: string): Partial> { + return EXTENDED_EFFECT[name] ?? CALAMITY_EFFECT[name] ?? {} +} +/** 灾因中文描述(MOD 灾因缺省用名本身) */ +export function calamityDescOf(name: string): string { + return `${name}——灵植减产,市价将行。` +} diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index 03f8bfd..b929f5e 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -79,7 +79,7 @@ export default function SettingsPanel() {
{p.name} - v{p.version} · {p.kind} · {p.description} + v{p.version}{p.author ? ` · ${p.author}` : ''} · {p.kind} · {p.description}
@@ -256,7 +256,7 @@ export default function SettingsPanel() { · 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
-
版本 0.1.25 · Chronicle of the Immortal Clan
+
版本 0.1.26 · Chronicle of the Immortal Clan
) diff --git a/src/renderer/ui/styles.css b/src/renderer/ui/styles.css index c55ee10..a6047a9 100644 --- a/src/renderer/ui/styles.css +++ b/src/renderer/ui/styles.css @@ -209,7 +209,7 @@ html[data-blade] .battle-lines { .btn { background: linear-gradient(180deg, #20180f, #191209); border: 1px solid #3d3221; - color: #d8c8a8; + color: #e6d8ba; padding: 6px 14px; border-radius: 3px; cursor: pointer; @@ -295,11 +295,11 @@ html[data-blade] .battle-lines { } .dim { - color: #6d6150; + color: #a99c80; font-size: 0.9rem; } .dim2 { - color: #96896e; + color: #c3b595; font-size: 0.85rem; } .good { @@ -474,7 +474,7 @@ html[data-blade] .battle-lines { } .boot-en { letter-spacing: 5px; - color: #7d6c4a; + color: #a4916c; font-size: 0.72rem; } .boot-divider { @@ -580,7 +580,7 @@ html[data-blade] .battle-lines { } .newgame .card-title { font-size: 1.6rem; - color: #d9bd85; + color: #e6cd9a; letter-spacing: 10px; } .field { @@ -590,7 +590,7 @@ html[data-blade] .battle-lines { } .field label { width: 80px; - color: #b09f7c; + color: #c4b28a; letter-spacing: 2px; } .field input, @@ -622,7 +622,7 @@ html[data-blade] .battle-lines { border: 1px solid #3a3021; border-radius: 3px; cursor: pointer; - color: #cbbd9e; + color: #ddceac; transition: all 0.16s; } .diff-opt:hover { @@ -744,7 +744,7 @@ html[data-blade] .battle-lines { display: flex; align-items: center; gap: 5px; - color: #cbbd9e; + color: #ddceac; background: rgba(38, 29, 16, 0.6); border: 1px solid #2f2517; padding: 3px 9px; @@ -943,6 +943,7 @@ html[data-blade] .battle-lines { border-left: 2px solid transparent; margin-bottom: 3px; font-size: 0.85rem; + color: #efe4c9; line-height: 1.5; border-radius: 2px; } @@ -1092,7 +1093,7 @@ html[data-blade] .battle-lines { } .help-text { - color: #a2906c; + color: #c9b894; font-size: 0.85rem; line-height: 1.8; max-width: 680px; @@ -1358,7 +1359,7 @@ html[data-blade] .battle-lines { .opt-eff { display: block; font-size: 0.74rem; - color: rgba(150, 132, 96, 0.9); + color: rgba(196, 178, 140, 0.96); margin-top: 3px; letter-spacing: 0.02em; } @@ -1884,7 +1885,7 @@ html[data-blade] .battle-lines { padding: 3px 10px; cursor: pointer; font-family: inherit; - color: #cbbd9e; + color: #ddceac; transition: all 0.15s; } .world-badge:hover { diff --git a/tests/contrast-readability.test.ts b/tests/contrast-readability.test.ts new file mode 100644 index 0000000..6f801e4 --- /dev/null +++ b/tests/contrast-readability.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +/** WCAG 相对亮度与对比度(纯函数审计) */ +function luminance(hex: string): number { + const m = hex.replace('#', '') + const [r, g, b] = [0, 2, 4].map((i) => parseInt(m.slice(i, i + 2), 16) / 255) + const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)) + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) +} +function contrast(a: string, b: string): number { + const la = luminance(a), lb = luminance(b) + const [hi, lo] = la > lb ? [la, lb] : [lb, la] + return (hi + 0.05) / (lo + 0.05) +} + +describe('0.1.26 页面可读性(对比度审计)', () => { + const BG = '#100d0a' + it('次级文本 (dim/dim2/help-text) 对比度 ≥ 4.5:1', () => { + const cases: Array<[string, string]> = [ + ['#a99c80', '.dim'], + ['#c3b595', '.dim2'], + ['#c9b894', '.help-text'], + ['#efe4c9', '.feed-item'], + ['#e2d3b4', '.tag 文字'] as never + ] + for (const [hex, label] of cases) { + const c = contrast(hex, BG) + expect(c, `${label} 对比 ${c.toFixed(2)}`).toBeGreaterThanOrEqual(4.5) + } + }) + + it('主文本 paper 对比充足(≥8:1)', () => { + expect(contrast('#f2e9d4', BG)).toBeGreaterThanOrEqual(8) + }) + + it('历史暗色 #6d6150 不再被引用(提亮迁移核对)', () => { + const css = require('fs').readFileSync('src/renderer/ui/styles.css', 'utf-8') + expect(css.includes('#6d6150')).toBe(false) + expect(css.includes('#96896e')).toBe(false) + }) +}) diff --git a/tests/mod-system-0.1.25.test.ts b/tests/mod-system-0.1.25.test.ts index fa8743b..49d9077 100644 --- a/tests/mod-system-0.1.25.test.ts +++ b/tests/mod-system-0.1.25.test.ts @@ -4,6 +4,9 @@ import { modToPlugin } from '../src/renderer/game/engine/runtime/modManager' import { World } from '../src/renderer/game/engine/runtime/World' import { generateWorld, WorldGenPools } from '../src/renderer/game/engine/sim/worldgen' import { stateFingerprint, longRun } from './fingerprint.helper' +import { validateMod } from '../src/renderer/game/engine/runtime/modSchema' +import { addCalamity, calamityNames, calamityFamilyOf, DEFAULT_CALAMITIES } from '../src/renderer/game/engine/sim/worldsim-data' +import { modPreflight } from '../src/renderer/game/engine/runtime/modManager' const SAMPLE = JSON.stringify({ id: 'test-mod', name: '测试MOD', version: '1.0.0', author: 'tester', @@ -58,3 +61,52 @@ describe('0.1.25 MOD 系统', () => { // 世界生成默认池不变——与 0.1.24 基线值(golden 在 clock.test 固化)经 clock.test 锁死 }) }) + +describe('0.1.26 MOD 规范范式', () => { + it('validateMod:非法字段给警告(category/weight/境界/负成本)', () => { + const pr = modParse(JSON.stringify({ + id: 'bad-mod', name: '坏包', version: '1.0.0', + data: { + events: [{ id: 'ev-bad', name: 'x', category: 'nope', weight: 500, text: '', options: [{ label: 'a', eff: {} }] }], + npcs: [{ id: 'n-bad', name: 'b', region: 'r', style: 's', desc: 'd', leaderRealm: 'ghost', initialPower: 999, powerGrowth: [1, 2] }], + pills: [{ output: 'pill-bad', name: '坏丹', danfangLevel: 9, stones: -5, lingcao: 1, beastcore: 0, desc: '' }] + } + })) + if (!pr.ok) throw new Error(pr.error) + const v = validateMod(pr.pack) + expect(v.ok).toBe(false) + expect(v.warnings.length).toBeGreaterThanOrEqual(4) + }) + + it('灾因注册表:addCalamity 重名拒绝/合法生效/进灾签池', () => { + expect(addCalamity('旱灾', {})).toBe(false) // 默认重名 + expect(addCalamity('剑灾', { family: { lingcao: 0.7 }, effect: { beastcore: 0.2 } })).toBe(true) + expect(calamityNames()).toContain('剑灾') + expect(calamityFamilyOf('剑灾').lingcao).toBe(0.7) + expect(DEFAULT_CALAMITIES.length).toBe(6) // 默认不变 + }) + + it('MOD→插件灾因:安装后灾签池含扩展灾因', () => { + const pr = modParse(JSON.stringify({ + id: 'calam-mod', name: '灾包', version: '1.0.0', data: { + calamities: [{ name: '灵潮湍变', family: { lingcao: 0.8 }, effect: { lingkuang: -0.2 } }] + } + })) + if (!pr.ok) throw new Error(pr.error) + const w = World.create({ seed: 'cm-1', surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' }) + expect(w.installPlugin(modToPlugin(pr.pack)).ok).toBe(true) + expect(calamityNames()).toContain('灵潮湍变') + }) + + it('modPreflight:事件 id 冲突拒绝安装(给理由)', () => { + const pr = modParse(JSON.stringify({ + id: 'collide-mod', name: '撞包', version: '1.0.0', + data: { events: [{ id: 'ev-legacypass', name: 'x', category: 'daily', weight: 1, text: 'x', options: [{ label: 'a', eff: {} }] }] } + })) + if (!pr.ok) throw new Error(pr.error) + const w = World.create({ seed: 'cf-2', surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' }) + const r = modPreflight(w, pr.pack) + expect(r.ok).toBe(false) + expect(r.reason).toContain('冲突') + }) +})