refactor(0.1.14-P1): 内核归位——game/ 按引擎架构重排(零行为漂移)

结构(旧 game/core+engine → 新 engine/ 域):
- engine/kernel/  时钟/随机/插件协议/fxqueue/timesense/format/urgency/guide/names(原 core)
- engine/narrative/  legacy/报告/列传/谱系/年轴(原 core 叙事族)
- engine/runtime/   World/creation/pcgen/ApiFacade/capabilities/pluginManager/boot/clocks + Systems/*(12 系统)
- engine/sim/     Market(未来 WorldSim 同行)
- 旧 game/core、engine/systems、engine/world.ts 等路径全部废弃(无 re-export 兼容层)

验证:35 套件/967 测试全绿(金钟罩三档零漂移=纯搬迁无行为变化)
typecheck 0 error
This commit is contained in:
2026-08-23 13:23:25 +08:00
parent 2528226a9f
commit ff6df8054c
89 changed files with 281 additions and 281 deletions
+84
View File
@@ -0,0 +1,84 @@
import type { World } from '../runtime/World'
/**
* 统一时轮调度:所有系统以 phase 注册,由时钟按固定顺序驱动。
* - 月度 phaseproduction / aging / cultivation / missions / events / diplomacy / epilogue
* - 年首钩子:onYearStart(婚配 → 声望岁贡 → 岁末族簿)
* 新增系统 = 注册一行,不再修改 advanceMonth 本体。
*/
export type PhaseId =
| 'production'
| 'aging'
| 'cultivation'
| 'missions'
| 'events'
| 'diplomacy'
| 'epilogue'
export type SystemHook = (w: World) => void
export interface PhaseStat {
phase: PhaseId
ms: number
count: number
}
export const PHASE_ORDER: PhaseId[] = [
'production',
'aging',
'cultivation',
'missions',
'events',
'diplomacy',
'epilogue'
]
export class GameClock {
private monthly = new Map<PhaseId, SystemHook[]>()
private yearly: SystemHook[] = []
register(phase: PhaseId, fn: SystemHook): () => void {
const list = this.monthly.get(phase) ?? []
list.push(fn)
this.monthly.set(phase, list)
return () => {
const li = list.indexOf(fn)
if (li >= 0) list.splice(li, 1)
}
}
onYearStart(fn: SystemHook): () => void {
this.yearly.push(fn)
return () => {
const i = this.yearly.indexOf(fn)
if (i >= 0) this.yearly.splice(i, 1)
}
}
fireYearStart(w: World): void {
for (const fn of this.yearly) fn(w)
}
stepMonthly(w: World): PhaseStat[] {
const report: PhaseStat[] = []
for (const phase of PHASE_ORDER) {
const t0 = performance.now()
const fns = [...(this.monthly.get(phase) ?? [])]
for (const fn of fns) fn(w)
const ms = performance.now() - t0
report.push({ phase, ms, count: fns.length })
}
return report
}
subscriptionCount(): number {
let n = 0
for (const list of this.monthly.values()) n += list.length
return n + this.yearly.length
}
snapshot(): Array<{ phase: PhaseId; count: number }> {
return PHASE_ORDER.map((phase) => ({ phase, count: (this.monthly.get(phase) ?? []).length }))
}
}
+11
View File
@@ -0,0 +1,11 @@
export function fmt(n: number): string {
const abs = Math.abs(n)
if (abs >= 10000) return `${(n / 10000).toFixed(1)}`
if (abs >= 1000 && abs < 10000) {
// 1.2千
const t = Math.floor(abs / 1000)
const rem = Math.floor((abs % 1000) / 100)
return rem > 0 ? `${n < 0 ? '-' : ''}${t}.${rem}` : `${n < 0 ? '-' : ''}${t}`
}
return String(n)
}
@@ -0,0 +1,72 @@
export type FxKind = 'spark' | 'mist' | 'ripple' | 'blade' | 'pulse' | 'seasonShift' | 'drift'
export type FxMode = 'soft' | 'std' | 'full'
export interface FxEmit {
kind: FxKind
payload?: Record<string, number | string>
at: number
}
export interface FxBus {
emit(e: FxEmit): void
}
/** 竞态安全发射闸:单循环、队列不覆盖、档位过滤、reduced-motion 降级 */
export class FxGate {
private queue: FxEmit[] = []
private draining = false
mode: FxMode = 'std'
preferReduced = false
constructor(private bus: FxBus) {}
setMode(m: FxMode): void {
this.mode = m
}
setReduced(r: boolean): void {
this.preferReduced = r
}
allowed(kind: FxKind): boolean {
if (this.mode === 'soft') return kind === 'ripple' || kind === 'pulse' || kind === 'drift'
if (this.mode === 'full') return true
return kind !== 'blade' // std:火花/雾/涟漪/脉动,刀光仅 full
}
emit(kind: FxKind, payload?: FxEmit['payload']): void {
if (!this.allowed(kind) || this.preferReduced || this.draining) return
this.queue.push({ kind, payload, at: Date.now() })
this.drain()
}
private drain(): void {
if (this.draining) return
this.draining = true
// 单帧集中派发(不逐个 setTimeout,防止栈深+竞态覆盖)
const batch = this.queue
this.queue = []
try {
for (const e of batch) this.bus.emit(e)
} finally {
this.draining = false
}
}
pending(): number {
return this.queue.length + (this.draining ? 1 : 0)
}
/** 纯逻辑:资源差量(防负值与 NaN,供漂字用) */
static deltas(prev: Record<string, number>, next: Record<string, number>): Record<string, number> {
const out: Record<string, number> = {}
const keys = new Set([...Object.keys(prev), ...Object.keys(next)])
for (const k of keys) {
const a = prev[k] ?? 0
const b = next[k] ?? 0
if (Number.isFinite(a) && Number.isFinite(b) && b !== a) out[k] = b - a
}
return out
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { World } from '../runtime/World'
export interface GuideStep {
id: string
title: string
hint: string
reward: string
}
export const GUIDE_STEPS: GuideStep[] = [
{ id: 'intro', title: '认识你的族人', hint: '点开「宗族」页中任意成员,看看他的灵根、境界与禀性。', reward: '感悟 +20 灵石' },
{ id: 'cultivate', title: '点一人闭关', hint: '在成员详情中点「闭关」,让他专心致志地修行。', reward: '修为小成' },
{ id: 'build', title: '营建一项产业', hint: '到「领地」页营建一座【灵田】或【坊市】,让家中有进项。', reward: '家族起步' },
{ id: 'progress', title: '推进一个月', hint: '点顶部「推进一月」,看族人锻、金堂响、家书至。', reward: '正式开族' }
]
export interface GuideState {
step: number
done: boolean
}
export function computeGuide(w: World): GuideState {
const step = w.state.family.flag['guideStep'] as number | undefined ?? 0
return { step, done: step >= GUIDE_STEPS.length }
}
export function guideAdvance(w: World, targetStep: number): number {
const cur = w.state.family.flag['guideStep'] as number | undefined ?? 0
if (targetStep > cur) {
w.state.family.flag['guideStep'] = Math.min(GUIDE_STEPS.length, targetStep)
}
return w.state.family.flag['guideStep'] as number
}
+30
View File
@@ -0,0 +1,30 @@
export const SURNAME_POOL = [
'林', '苏', '沈', '谢', '顾', '萧', '叶', '江', '秦', '裴',
'柳', '陆', '云', '姜', '晏', '楚', '洛', '许', '宋', '薛',
'韩', '白', '纪', '容', '卫', '燕', '温', '孟', '阮',
'池', '岑', '傅', '虞', '尹', '霍', '曲', '齐'
]
export const MALE_GIVEN = [
'长青', '天行', '玄机', '惊羽', '逐月', '凌风', '无涯', '浩然', '慕白', '景行',
'玄青', '清尘', '衡之', '元白', '既白', '拾遗', '忘机', '观澜', '承影', '风眠',
'听澜', '常宁', '若拙', '怀瑾', '望舒', '断岳', '疏影', '临渊', '未央', '扶摇',
'云疏', '星河', '知竹', '不归', '须眉', '长恨', '夜阑', '青崖', '子衿', '方舟',
'晓风', '修远', '行舟', '暮雪', '佐卿', '御风', '惊蛰', '白露', '秋声', '冬青'
]
export const FEMALE_GIVEN = [
'青鸾', '月瑶', '星若', '清欢', '婉芯', '若雪', '凝霜', '沐婉', '疏桐', '归鹤',
'临夏', '小满', '云笙', '芷若', '洛神', '巧儿', '霜华', '雨薇', '兰心', '扶摇',
'晴雪', '芷凝', '吟月', '采薇', '爱雪', '听雨', '素问', '千雪', '疏影', '知微',
'曼卿', '昭容', '碧衣', '青眉', '玲珑', '止水', '言心', '灼华', '云鬓', '惊鸿',
'晚棠', '夜莺', '南絮', '海棠', '锦瑟', '泠泠', '天心', '妙音', '含章', '碧瑶'
]
export const MISSION_DESCRIPTORS = [
'顽劣', '沉静', '木讷', '机敏', '温厚', '孤高', '谦逊', '豪迈', '精细', '洒脱'
]
export function randomSurname(rng: { pick<T>(a: T[]): T }): string {
return rng.pick(SURNAME_POOL)
}
+56
View File
@@ -0,0 +1,56 @@
import { GameClock, SystemHook } from './clock'
import { DataPack } from '../../data/registry'
import { EventDef } from '../../data/events'
import type { World } from '../runtime/World'
export type PluginKind = 'system' | 'data' | 'events' | 'content'
export interface PluginHookGuard {
onLoad?(): void
onDisable?(): void
onEnable?(): void
onUninstall?(): void
}
export interface CotycPlugin {
id: string
name: string
version: string
author?: string
description: string
kind: PluginKind
dependencies?: string[]
conflicts?: string[]
install(ctx: PluginContext): void
uninstall?(ctx: PluginContext): void
hooks?: PluginHookGuard
protected?: boolean
}
export interface PluginContext {
world: World
clock: GameClock
register: (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void
onYearStart: (fn: SystemHook) => () => void
addCapability: (cap: { id: string; name: string; version: string; desc: string }) => void
removeCapability: (id: string) => void
enableCapability: (id: string, enabled: boolean) => void
overridePack: (partial: Partial<DataPack>) => void
resetPack: () => void
addEventPool: (id: string, events: EventDef[]) => void
removeEventPool: (id: string) => void
}
export interface PluginStatus {
id: string
name: string
version: string
kind: PluginKind
installed: boolean
enabled: boolean
protected: boolean
description: string
dependencies?: string[]
}
export type PluginChange = { id: string; action: 'install' | 'remove' | 'enable' | 'disable' }
+107
View File
@@ -0,0 +1,107 @@
import type { RngState } from '../../types/domain'
function xmur3(str: string): () => number {
let h = 1779033703 ^ str.length
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
h = (h << 13) | (h >>> 19)
}
return function () {
h = Math.imul(h ^ (h >>> 16), 2246822507)
h = Math.imul(h ^ (h >>> 13), 3266489909)
return (h ^= h >>> 16) >>> 0
}
}
export function seedToRng(seed: string): RngState {
const s = xmur3(seed)
let a = s()
let b = s()
let c = s()
let d = s()
if (a === 0 && b === 0 && c === 0 && d === 0) d = 0x9e3779b9
return { a, b, c, d }
}
const U32 = 4294967296
export class Rng {
state: RngState
nextCount = 0
constructor(state: RngState) {
this.state = { ...state }
}
getState(): RngState {
return { ...this.state }
}
next(): number {
this.nextCount++
const s = this.state
const t = ((s.a + s.b + s.d) | 0) >>> 0
s.d = (s.d + 1) | 0
s.a = (s.b ^ (s.b >>> 9)) >>> 0
s.b = (s.c + (s.c << 3)) | 0
s.c = ((s.c << 21) | (s.c >>> 11)) >>> 0
s.c = (s.c + t) | 0
return t / U32
}
int(min: number, max: number): number {
return Math.floor(this.next() * (max - min + 1)) + min
}
pick<T>(arr: T[]): T {
if (arr.length === 0) throw new Error('Rng.pick: 空数组无可选项')
return arr[this.int(0, arr.length - 1)]
}
chance(p: number): boolean {
return this.next() < p
}
shuffle<T>(arr: T[]): T[] {
const a = [...arr]
if (a.length <= 1) return a
for (let i = a.length - 1; i > 0; i--) {
const j = this.int(0, i)
const t = a[i]
a[i] = a[j]
a[j] = t
}
return a
}
between(min: number, max: number): number {
return this.next() * (max - min) + min
}
}
/**
* 全工程统一随机收集器:
* - rollSeed:开局播种(crypto 级)
* - audioNoise:音效白噪(种子独立,永不干扰引擎序列)
* - engineRng:引擎世界随机(World.rng 实例,见 world.ts
*/
export class RngHub {
private static audioRng = new Rng(seedToRng('cotyc-audio-dither-v1'))
static rollSeed(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `seed-${crypto.randomUUID()}`
}
return `seed-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9)}`
}
static audioNoise01(): number {
return RngHub.audioRng.next()
}
}
export function rngAudit(rng: RigRng): { total: number } {
return { total: rng.nextCount }
}
type RigRng = Rng
@@ -0,0 +1,51 @@
import { Season } from '../../data/season'
export const TIDES: string[] = [
'立春', '雨水', '惊蛰', '春分', '清明', '谷雨',
'立夏', '小满', '芒种', '夏至', '小暑', '大暑',
'立秋', '处暑', '白露', '秋分', '寒露', '霜降',
'立冬', '小雪', '大雪', '冬至', '小寒', '大寒'
]
/** 季节 → 节气(每月两个节气:tideOf(month, phase) 返回 0..1 */
export function tideOf(month: number, phase: 0 | 1): string {
const idx = Math.min(23, (month - 1) * 2 + phase)
return TIDES[idx] ?? '立春'
}
/** 年轮进度(0..1)——用于顶栏年环弧段 */
export function yearRing(month: number): number {
return (month - 1) / 12
}
export interface SeasonTint {
accent: string
tint: string
glow: string
name: string
desc: string
}
const TINTS: Record<Season, SeasonTint> = {
spring: { accent: '#9cc27c', tint: '#7a9a5c', glow: '114, 178, 106', name: '春·熏风', desc: '草木萌芽,灵田争长。' },
summer: { accent: '#d8b35f', tint: '#b48c46', glow: '216, 172, 96', name: '夏·蝉鸣', desc: '心火正旺,修炼如炉。' },
autumn: { accent: '#d0954f', tint: '#a97a3e', glow: '212, 148, 84', name: '秋·霜叶', desc: '市集兴旺,收获正忙。' },
winter: { accent: '#7f9eb5', tint: '#5c7d94', glow: '127, 158, 181', name: '冬·雪静', desc: '闭藏蓄势,闭关养气。' }
}
export function seasonTint(season: Season): SeasonTint {
return TINTS[season]
}
/** CSS 变量包裹:data-season 驱动整页色温(4-8% 级,金碧基调内) */
export function seasonCssVars(season: Season): Record<string, string> {
const t = TINTS[season]
return {
'--season-accent': t.accent,
'--season-glow': t.glow
}
}
export function tintLabel(season: Season): string {
return TINTS[season].name
}
@@ -0,0 +1,25 @@
import type { World } from '../runtime/World'
export interface Urgency {
blockedCount: number
injuredCount: number
missionCount: number
marriageReady: number
noHeir: boolean
headFrail: boolean
}
export function computeUrgency(w: World): Urgency {
const alive = w.aliveMembers()
const blockedCount = alive.filter((c) => c.realmProgress >= 100 && c.realm.major !== 'spirit').length
const injuredCount = alive.filter((c) => c.state === 'wounded' || c.health < 30).length
const missionCount = w.state.missions.filter((m) => !m.done).length
const marriageReady = alive.filter(
(c) => c.state !== 'expedition' && c.state !== 'apprentice' && (!c.spouseId || w.isWidowed(c)) && w.ageOf(c) >= 16 && w.ageOf(c) <= 46
).length
const head = w.state.members[w.state.family.headId]
const headFrail = !!head && head.alive && w.ageOf(head) > 55
const adultHeir = alive.some((c) => c.id !== head?.id && w.ageOf(c) >= 16)
const noHeir = !adultHeir || !head?.alive
return { blockedCount, injuredCount, missionCount, marriageReady, noHeir, headFrail }
}