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 }
}
@@ -0,0 +1,77 @@
import { GameState } from '../../types/domain'
import { TECHNIQUES } from '../../data/techniques'
import { describeRealm } from '../../data/realms'
import { aspirationById } from '../../data/aspirations'
import { postById } from '../../data/posts'
export interface BioLine {
at?: number
label: string
text: string
}
export function buildBiography(s: GameState, memberId: string): BioLine[] {
const c = s.members[memberId]
if (!c) return []
const lines: BioLine[] = []
const tech = TECHNIQUES.find((t) => t.id === c.techniqueId)
const asp = aspirationById(c.aspiration)
const post = postById(c.post)
lines.push({
label: '生平',
text: `${c.name},第${c.generation}代子孙${c.gender === 'male' ? '男' : '女'},生于${c.bornYear}${
c.deathYear ? `,殁于${c.deathYear}年(${c.deathCause ?? '寿终'}` : ',今尚在世'
}${post ? `曾居族中「${post.name}」之位。` : ''}${asp ? `平生志向:${asp.name}` : ''}${
tech ? `主修《${tech.name}》。` : ''
}`
})
// 突破脉络
const breaks = s.chronicle.filter((e) => e.category === 'breakthrough' && e.memberId === c.id)
const trials = s.chronicle.filter((e) => e.text.includes(c.name) && (e.category === 'battle' || e.category === 'exploration'))
for (const e of breaks) {
lines.push({ at: e.year, label: '修行', text: `${e.year}${e.month}月,${e.text.replace(new RegExp(`^${c.name}\\s*`), '')}`})
}
if (trials.length > 0) {
lines.push({ at: trials[0].year, label: '历险', text: `${trials[0].year}年·${trials[0].text}` })
}
// 婚育
const marriages = s.chronicle.filter((e) => e.category === 'marriage' && e.memberId === c.id)
for (const m of marriages) {
lines.push({ at: m.year, label: '姻缘', text: m.text })
}
if (c.children.length > 0) {
const kids = c.children.map((id) => s.members[id]?.name ?? '?').join('、')
lines.push({ label: '子嗣', text: `抚育子女${c.children.length}人:${kids}` })
}
// 大比
const hisTourneys = s.stats.tourneyHistory.filter((t) => {
const battle = s.battles.find((b) => b.year === t.year && b.title.includes('大比'))
return battle?.lines.some((l) => l.includes(c.name))
})
for (const t of hisTourneys) {
lines.push({ at: t.year, label: '大比', text: `${t.year}年,随队参加太虚大比,列第${t.rank}名。` })
}
// 墓碑铭文
if (!c.alive) {
const epitaph = EPITAPH[c.deathCause ?? '寿终'] ?? '一尘一土,终归青山。'
lines.push({ label: '墓志', text: `${c.name}之墓。${epitaph}` })
}
return lines
}
const EPITAPH: Record<string, string> = {
寿: '一尘一土,终归青山。子孙焚香,岁岁在此。',
'寿元将尽': '一尘一土,终归青山。子孙焚香,岁岁在此。',
: '稚子长眠,天地垂爱。家人悬灯,常照归路。',
'伤势不治': '壮士折戟,魂兮归来。家祠之下,与祖同列。',
'突破走火': '一念求道,九死未悔。灵前法灯,为君长明。',
'渡劫陨落': '雷霆战天,其志未竟。遗骨归山,英风尚在。',
'战殁': '马革裹尸,归葬桑梓。长剑挂壁,子孙相传。',
'云游飞升': '此去云深不知处,魂同列宿游太虚。'
}
@@ -0,0 +1,55 @@
import { Character } from '../../types/domain'
import type { World } from '../runtime/World'
export interface FamilyUnit {
parents: [Character | undefined, Character | undefined]
children: Character[]
gen: number
}
export interface GenRow {
gen: number
singles: Character[]
units: FamilyUnit[]
}
export function computeGenealogy(w: World): GenRow[] {
const alive = w.aliveMembers()
const byGen = new Map<number, Character[]>()
for (const c of alive) {
const g = byGen.get(c.generation) ?? []
g.push(c)
byGen.set(c.generation, g)
}
const gens = [...byGen.keys()].sort((a, b) => a - b)
const rows: GenRow[] = []
for (const gen of gens) {
const members = byGen.get(gen)!
const units: FamilyUnit[] = []
const used = new Set<string>()
for (const c of members) {
if (used.has(c.id)) continue
const spouse = c.spouseId ? w.state.members[c.spouseId] : undefined
if (spouse && spouse.alive && !used.has(spouse.id)) {
used.add(c.id)
used.add(spouse.id)
const children = [...c.children, ...spouse.children]
.filter((id, i, arr) => arr.indexOf(id) === i)
.map((id) => w.state.members[id])
.filter((ch): ch is Character => !!ch && ch.alive)
.sort((a, b) => a.bornYear - b.bornYear)
units.push({ parents: [c, spouse], children, gen })
}
}
const singles = members.filter((c) => !used.has(c.id))
rows.push({ gen, singles, units })
}
return rows
}
export function countDead(w: World): number {
return Object.values(w.state.members).filter((c) => !c.alive).length
}
@@ -0,0 +1,102 @@
import { GameState } from '../../types/domain'
import { MAJOR_ORDER } from '../../data/realms'
export interface LegacyDims {
renXing: number
daoXing: number
weiMing: number
xiangHuo: number
total: number
}
export interface LegacyArch {
title: string
poem: string[]
dims: LegacyDims
rank: number
verdict: string
}
export const VERDICTS: Record<string, { title: string; poem: string[] }> = {
feisheng: {
title: '飞升之资',
poem: ['海内升龙真气象,仙班亦录旧香名。', '遥看青山埋骨处,一脉烟霞送飞鸿。']
},
celestial: {
title: '仙朝遗脉',
poem: ['祖庭灯火三千里,曾照中天玉斗垂。', '后世儿孙开卷处,犹闻钟鼎旧清音。']
},
noble: {
title: '中州望族',
poem: ['钟鸣鼎食三百年,莫道朱门无圣贤。', '一炷心香传世绪,青灯犹自照芸编。']
},
warlord: {
title: '一方枭雄',
poem: ['剑气纵横镇八荒,家声不堕尽金汤。', '而今若问兴亡事,半壁江山问晚霜。']
},
recluse: {
title: '隐世大族',
poem: ['不向尘沙争利名,青山深处课桑耕。', '子孙自有莲舟意,一棹烟波到洞庭。']
},
modest: {
title: '积余之家',
poem: ['檐下燕飞春复秋,园蔬新雨补梁楸。', '粗茶淡饭家声在,犹胜浮云逐海流。']
},
dust: {
title: '冢中枯骨',
poem: ['一度花开一度尘,纸灰飞作旧人魂。', '若教重理当年事,且把残篇问故园。']
}
}
const RANK_MAP: [number, string][] = [
[160, 'celestial'],
[120, 'noble'],
[85, 'warlord'],
[55, 'recluse'],
[30, 'modest']
]
export function computeLegacy(s: GameState): LegacyDims {
const st = s.stats
const gens = Math.max(
...Object.values(s.members).map((c) => c.generation).concat(1)
)
const renXing = Math.round(gens * 18 + Math.min(st.popPeak, 40) * 1.6)
const daoXing = Math.round(st.maxRealmIdx * 26 + st.techniqueGrand * 4)
const weiMing = Math.round(st.repPeak * 1.4 + (st.tourneyBest ? (6 - st.tourneyBest) * 12 : 0))
const xiangHuo = Math.round(Math.min(s.year, 300) * 0.9 + st.feishengCount * 60)
return { renXing, daoXing, weiMing, xiangHuo, total: renXing + daoXing + weiMing + xiangHuo }
}
export function resolveLegacy(s: GameState): LegacyArch {
const dims = computeLegacy(s)
let key = 'dust'
if (s.stats.feishengCount > 0) key = 'feisheng'
else {
for (const [need, k] of RANK_MAP) {
if (dims.total >= need) {
key = k
break
}
}
}
const verdict = VERDICTS[key]
return { title: verdict.title, poem: verdict.poem, dims, rank: dims.total, verdict: key }
}
export function peakRealmIndex(members: GameState['members']): number {
let max = -1
for (const c of Object.values(members)) {
const idx = MAJOR_ORDER.indexOf(c.realm.major) * 10 + c.realm.minor
if (idx > max) max = idx
}
return max
}
export function grandTechniqueCount(members: GameState['members']): number {
let n = 0
for (const c of Object.values(members)) {
if ((c.techniqueRank ?? 0) >= 2) n++
}
return n
}
@@ -0,0 +1,80 @@
import { GameState } from '../../types/domain'
export interface LegacyReport {
years: number[]
population: number[]
reputation: number[]
power: number[]
deathCauses: Record<string, number>
breakthroughs: { year: number; count: number }[]
tourneys: { year: number; rank: number }[]
genSpan: { gen: number; from: number; to: number }[]
totalYears: number
}
export function buildReport(s: GameState): LegacyReport {
const reports = s.yearlyReports.slice().sort((a, b) => a.year - b.year)
const years = reports.map((r) => r.year)
const population = reports.map((r) => r.births - r.deaths)
const reputation = reports.map((r) => r.rep)
const power = reports.map((r) => r.power)
const deathCauses: Record<string, number> = {}
for (const e of s.chronicle) {
if (e.category !== 'death') continue
const cause = (e.text.match(/(寿元将尽|幼夭|伤势不治|突破走火|渡劫陨落|战殁|云游飞升)/)?.[0] ?? e.text.split('')[0] ?? '其他').slice(0, 14)
deathCauses[cause] = (deathCauses[cause] ?? 0) + 1
}
const btCount = new Map<number, number>()
for (const e of s.chronicle) {
if (e.category === 'breakthrough') btCount.set(e.year, (btCount.get(e.year) ?? 0) + 1)
}
const breakthroughs = [...btCount.entries()].map(([year, count]) => ({ year, count })).sort((a, b) => a.year - b.year)
const members = Object.values(s.members)
const genSpan: LegacyReport['genSpan'] = []
const byGen = new Map<number, { min: number; max: number }>()
for (const c of members) {
const span = byGen.get(c.generation) ?? { min: c.bornYear, max: c.bornYear }
span.min = Math.min(span.min, c.bornYear)
span.max = Math.max(span.max, c.deathYear ?? s.year)
byGen.set(c.generation, span)
}
for (const [gen, span] of [...byGen.entries()].sort((a, b) => a[0] - b[0])) {
genSpan.push({ gen, from: span.min, to: span.max })
}
return {
years,
population,
reputation,
power,
deathCauses,
breakthroughs,
tourneys: s.stats.tourneyHistory.slice(),
genSpan,
totalYears: s.year
}
}
/** 依据数据自动措辞的族史结语 */
export function verdictLine(r: LegacyReport): string {
if (r.years.length < 4) return '岁月尚浅,家族仍在晨雾中前行。'
const finalRep = r.reputation[r.reputation.length - 1]
const peakRep = Math.max(...r.reputation)
const peakIndex = r.reputation.indexOf(peakRep)
const peakPower = Math.max(...r.power)
const btSum = r.breakthroughs.reduce((a, b) => a + b.count, 0)
const mainCause = Object.entries(r.deathCauses).sort((a, b) => b[1] - a[1])[0]
const lines: string[] = []
if (peakRep >= 60) lines.push(`家族声望曾至 ${peakRep} 之巅(第${peakIndex + 1}年),四方来贺。`)
else if (finalRep < 0) lines.push('晚境声名凋零,门可罗雀。')
else lines.push('不显山露水,然香火未冷。')
lines.push(`战力峰值 ${peakPower},共记突破 ${btSum} 次。`)
if (mainCause && mainCause[1] > 0) lines.push(`族中弃世者多缘「${mainCause[0]}」。`)
const best = r.tourneys.reduce((m, t) => (m === null || t.rank < m.rank ? t : m), null as { year: number; rank: number } | null)
if (best) lines.push(`太虚大比最佳战果:第${best.rank}名(${best.year}年)。`)
return lines.join('')
}
@@ -0,0 +1,55 @@
import { GameState } from '../../types/domain'
export interface AxisEvent {
year: number
kind: '突破' | '大比' | '渡劫' | '婚' | '殇' | '战' | '飞升'
label: string
}
export interface AxisCell {
from: number
to: number
events: AxisEvent[]
births: number
deaths: number
}
/** 横轴年表:十年一格,把族簿大事压缩进一根时间轴 */
export function yearAxis(s: GameState, decadeSize = 10): AxisCell[] {
const startYear = Math.max(1, Math.min(...s.chronicle.map((e) => e.year).concat(1)))
const endYear = Math.max(startYear, s.year)
const cells = new Map<number, AxisCell>()
const cellOf = (year: number): AxisCell => {
const from = Math.floor((year - 1) / decadeSize) * decadeSize + 1
if (!cells.has(from)) cells.set(from, { from, to: Math.min(from + decadeSize - 1, endYear), events: [], births: 0, deaths: 0 })
return cells.get(from)!
}
for (const e of s.chronicle) {
if (e.year < startYear) continue
const cell = cellOf(e.year)
const kind: AxisEvent['kind'] | null =
e.category === 'breakthrough'
? /渡劫|天劫/.test(e.text)
? '渡劫'
: '突破'
: e.category === 'birth'
? null
: e.category === 'death'
? /飞升/.test(e.text) ? '飞升' : '殇'
: e.category === 'marriage'
? '婚'
: e.category === 'battle'
? '战'
: null
if (kind) cell.events.push({ year: e.year, kind, label: e.text.slice(0, 26) })
if (e.category === 'birth') cell.births++
if (e.category === 'death') cell.deaths++
}
return [...cells.entries()].sort((a, b) => a[0] - b[0]).map(([, c]) => c)
}
export function decadeLabel(from: number, to: number): string {
return from === to ? `${from}` : `${from}-${to}`
}
@@ -1,14 +1,14 @@
import type { World } from './world'
import { WorldEventBus } from './world'
import { YearlyReport, BattleLog, ChronicleEntry, SaveMeta } from '../types/domain'
import { marketPrice, buyItem, sellItem, buyTechnique } from './market'
import { sendMission, recallAll } from './systems/missions'
import { giftNpc, makePeace, marryNpcFamily } from './systems/diplomacy'
import { combatPowerOf } from './systems/combat'
import type { LogKind } from './world'
import { computeLegacy, resolveLegacy, LegacyArch } from '../core/legacy'
import { yearAxis, AxisCell } from '../core/yearaxis'
import { PACK } from '../data/registry'
import type { World } from './World'
import { WorldEventBus } from './World'
import { YearlyReport, BattleLog, ChronicleEntry, SaveMeta } from '../../types/domain'
import { marketPrice, buyItem, sellItem, buyTechnique } from '../sim/Market'
import { sendMission, recallAll } from './Systems/missions'
import { giftNpc, makePeace, marryNpcFamily } from './Systems/diplomacy'
import { combatPowerOf } from './Systems/combat'
import type { LogKind } from './World'
import { computeLegacy, resolveLegacy, LegacyArch } from '../narrative/legacy'
import { yearAxis, AxisCell } from '../narrative/yearaxis'
import { PACK } from '../../data/registry'
export type ActName =
| 'head.set'
@@ -1,14 +1,14 @@
import type { World } from '../world'
import { Character, BattleLog } from '../../types/domain'
import { basePower, describeRealm } from '../../data/realms'
import { pack } from '../../data/registry'
import { techniqueById } from '../../data/techniques'
import { EnemyDef, LootDef } from '../../data/secrets'
import type { World } from '../World'
import { Character, BattleLog } from '../../../types/domain'
import { basePower, describeRealm } from '../../../data/realms'
import { pack } from '../../../data/registry'
import { techniqueById } from '../../../data/techniques'
import { EnemyDef, LootDef } from '../../../data/secrets'
import { traitBonuses } from '../pcgen'
import { npcById } from '../../data/npcs'
import { aspirationById, fitBonusOf } from '../../data/aspirations'
import { formationById, FormationId } from '../../data/formations'
import { npcById } from '../../../data/npcs'
import { aspirationById, fitBonusOf } from '../../../data/aspirations'
import { formationById, FormationId } from '../../../data/formations'
export function combatPowerOf(w: World, c: Character): number {
if (!c.alive) return 0
@@ -1,16 +1,16 @@
import type { World } from '../world'
import { Character } from '../../types/domain'
import { ROOT_GRADES } from '../../data/elements'
import { masteryRateOfMajor } from '../../data/pacing'
import { aspirationById, fitBonusOf } from '../../data/aspirations'
import { techniqueById } from '../../data/techniques'
import { nextRealm, breakthroughBaseChance, realmDeathChance, describeRealm, MAJOR_ORDER } from '../../data/realms'
import type { World } from '../World'
import { Character } from '../../../types/domain'
import { ROOT_GRADES } from '../../../data/elements'
import { masteryRateOfMajor } from '../../../data/pacing'
import { aspirationById, fitBonusOf } from '../../../data/aspirations'
import { techniqueById } from '../../../data/techniques'
import { nextRealm, breakthroughBaseChance, realmDeathChance, describeRealm, MAJOR_ORDER } from '../../../data/realms'
import { lifespanOf } from './lifecycle'
import { traitBonuses } from '../pcgen'
import { newCharacter } from '../pcgen'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
import { ASPIRATION_IDS } from '../../data/aspirations'
import { seasonMod } from '../../data/season'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../kernel/names'
import { ASPIRATION_IDS } from '../../../data/aspirations'
import { seasonMod } from '../../../data/season'
import { needsTribulation, tribulationEventId } from './tribulation'
export function monthlyRate(w: World, c: Character): number {
@@ -1,5 +1,5 @@
import type { World } from '../world'
import { npcById } from '../../data/npcs'
import type { World } from '../World'
import { npcById } from '../../../data/npcs'
import { findEvent, fire } from './events'
export function diplomacyTick(w: World): void {
@@ -1,16 +1,16 @@
import type { World } from '../world'
import { Character } from '../../types/domain'
import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../data/events'
import { MAJOR_ORDER } from '../../data/realms'
import type { World } from '../World'
import { Character } from '../../../types/domain'
import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../../data/events'
import { MAJOR_ORDER } from '../../../data/realms'
import { npcById } from '../../data/npcs'
import { npcById } from '../../../data/npcs'
import { resolveRaid } from './combat'
import { sendMission } from './missions'
import { pack } from '../../data/registry'
import { pack } from '../../../data/registry'
import { findInheritor } from '../creation'
import { resolveTribulation } from './tribulation'
import { nextRealm, describeRealm } from '../../data/realms'
import { nextRealm, describeRealm } from '../../../data/realms'
const ALL_EVENTS: EventDef[] = [...EVENTS]
@@ -1,5 +1,5 @@
import type { World } from '../world'
import { MAJORS } from '../../data/realms'
import type { World } from '../World'
import { MAJORS } from '../../../data/realms'
import { calcLifespan } from '../pcgen'
export function lifespanOf(w: World, c: { realm: { major: keyof typeof MAJORS }; physique: number }): number {
@@ -1,9 +1,9 @@
import type { World } from '../world'
import { Character } from '../../types/domain'
import type { World } from '../World'
import { Character } from '../../../types/domain'
import { produceOffspring } from './cultivation'
import { yearGrowth } from './diplomacy'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
import { aspirationById } from '../../data/aspirations'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../kernel/names'
import { aspirationById } from '../../../data/aspirations'
export function yearStartMarriage(w: World): void {
yearGrowth(w)
@@ -1,10 +1,10 @@
import type { World } from '../world'
import { MissionState } from '../../types/domain'
import { FormationId } from '../../data/formations'
import { missionById, MissionDef, ENEMIES } from '../../data/secrets'
import type { World } from '../World'
import { MissionState } from '../../../types/domain'
import { FormationId } from '../../../data/formations'
import { missionById, MissionDef, ENEMIES } from '../../../data/secrets'
import { resolveEncounter, rollWarbooty } from './combat'
import { techniqueById } from '../../data/techniques'
import { describeRealm } from '../../data/realms'
import { techniqueById } from '../../../data/techniques'
import { describeRealm } from '../../../data/realms'
export function missionTick(w: World): void {
const active = w.state.missions.filter((m) => !m.done)
@@ -1,6 +1,6 @@
import type { World } from '../world'
import { aspirationById } from '../../data/aspirations'
import { seasonMod } from '../../data/season'
import type { World } from '../World'
import { aspirationById } from '../../../data/aspirations'
import { seasonMod } from '../../../data/season'
export function productionTick(w: World): void {
const fam = w.state.family
@@ -1,10 +1,10 @@
import type { World } from '../world'
import { Character } from '../../types/domain'
import { ENEMIES, EnemyDef } from '../../data/secrets'
import type { World } from '../World'
import { Character } from '../../../types/domain'
import { ENEMIES, EnemyDef } from '../../../data/secrets'
import { resolveEncounter } from './combat'
import { rankPower } from './events'
import { resolveBreakthrough } from './cultivation'
import { FormationId } from '../../data/formations'
import { FormationId } from '../../../data/formations'
export const SECTS = ['云栖宗', '太谷书院', '玄微剑阁', '丹霞洞天']
@@ -1,6 +1,6 @@
import type { World } from '../world'
import { Character } from '../../types/domain'
import { nextRealm, MAJOR_ORDER, realmDeathChance, describeRealm } from '../../data/realms'
import type { World } from '../World'
import { Character } from '../../../types/domain'
import { nextRealm, MAJOR_ORDER, realmDeathChance, describeRealm } from '../../../data/realms'
export const TRIB_MAJORS: string[] = ['foundation', 'core', 'nascent', 'spirit']
@@ -7,25 +7,25 @@ import {
LogItem,
Realm,
YearlyReport
} from '../types/domain'
import { Rng } from '../core/rng'
import { BUILDINGS } from '../data/buildings'
import { POSTS } from '../data/posts'
import { aspirationById as aspirationOf } from '../data/aspirations'
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
} from '../../types/domain'
import { Rng } from '../kernel/rng'
import { BUILDINGS } from '../../data/buildings'
import { POSTS } from '../../data/posts'
import { aspirationById as aspirationOf } from '../../data/aspirations'
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy'
import { createWorldState, findInheritor } from './creation'
import { SYSTEM_DEFS, SystemDef } from './capabilities'
import { emptyClock } from './clocks'
import { CotycPlugin, PluginContext, PluginStatus } from '../core/plugin'
import { CotycPlugin, PluginContext, PluginStatus } from '../kernel/plugin'
import { PluginManager } from './pluginManager'
import { EventDef } from '../data/events'
import { pack, DEFAULT_PACK, PACK } from '../data/registry'
import { EventDef } from '../../data/events'
import { pack, DEFAULT_PACK, PACK } from '../../data/registry'
import { CORE_PLUGINS } from './plugin-bootstrap'
import { GameClock } from '../core/clock'
import { SystemHook } from '../core/clock'
import { resolveBreakthrough } from './systems/cultivation'
import { applyEventChoice } from './systems/events'
import { combatPowerOf } from './systems/combat'
import { GameClock } from '../kernel/clock'
import { SystemHook } from '../kernel/clock'
import { resolveBreakthrough } from './Systems/cultivation'
import { applyEventChoice } from './Systems/events'
import { combatPowerOf } from './Systems/combat'
export type LogKind = LogItem['kind']
@@ -138,7 +138,7 @@ export class World {
}
}
private packOverride(partial: Partial<import('../data/registry').DataPack>): void {
private packOverride(partial: Partial<import('../../data/registry').DataPack>): void {
PACK.override(partial)
}
@@ -1,4 +1,4 @@
import type { PhaseId } from '../core/clock'
import type { PhaseId } from '../kernel/clock'
export interface SystemDef {
id: string
@@ -1,13 +1,13 @@
import { GameClock } from '../core/clock'
import { PluginContext } from '../core/plugin'
import type { World } from './world'
import { productionTick } from './systems/production'
import { deathTick, woundHealTick } from './systems/lifecycle'
import { cultivationTick } from './systems/cultivation'
import { missionTick } from './systems/missions'
import { eventRoll } from './systems/events'
import { diplomacyTick } from './systems/diplomacy'
import { yearStartMarriage } from './systems/marriage'
import { GameClock } from '../kernel/clock'
import { PluginContext } from '../kernel/plugin'
import type { World } from './World'
import { productionTick } from './Systems/production'
import { deathTick, woundHealTick } from './Systems/lifecycle'
import { cultivationTick } from './Systems/cultivation'
import { missionTick } from './Systems/missions'
import { eventRoll } from './Systems/events'
import { diplomacyTick } from './Systems/diplomacy'
import { yearStartMarriage } from './Systems/marriage'
/** 原语义保留:满门凋零后仅存生产/寿元与收尾 */
function ifAlive(fn: (w: World) => void): (w: World) => void {
@@ -1,9 +1,9 @@
import { Character, GameState, NpcFamilyState, RealmMajor } from '../types/domain'
import { Rng, seedToRng } from '../core/rng'
import { randomSurname, MALE_GIVEN, FEMALE_GIVEN } from '../core/names'
import { Character, GameState, NpcFamilyState, RealmMajor } from '../../types/domain'
import { Rng, seedToRng } from '../kernel/rng'
import { randomSurname, MALE_GIVEN, FEMALE_GIVEN } from '../kernel/names'
import { newCharacter } from './pcgen'
import { NPCS } from '../data/npcs'
import { World } from './world'
import { NPCS } from '../../data/npcs'
import { World } from './World'
export interface NewGameOptions {
seed: string
@@ -1,4 +1,4 @@
import { CotycPlugin } from '../core/plugin'
import { CotycPlugin } from '../kernel/plugin'
/** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */
export const examplePlugin: CotycPlugin = {
@@ -1,8 +1,8 @@
import { Character, Element, Gender, Realm, RealmMajor } from '../types/domain'
import { Rng } from '../core/rng'
import { ELEMENT_LIST, ROOT_GRADES } from '../data/elements'
import { MAJORS } from '../data/realms'
import { TRAIT_POOL, TRAITS } from '../data/traits'
import { Character, Element, Gender, Realm, RealmMajor } from '../../types/domain'
import { Rng } from '../kernel/rng'
import { ELEMENT_LIST, ROOT_GRADES } from '../../data/elements'
import { MAJORS } from '../../data/realms'
import { TRAIT_POOL, TRAITS } from '../../data/traits'
export function rollRoots(rng: Rng, parents?: { m?: Character; f?: Character }): { grade: number; primary: Element; secondary: Element[] } {
let grade: number
@@ -1,7 +1,7 @@
import { CotycPlugin } from '../core/plugin'
import { CotycPlugin } from '../kernel/plugin'
import { installCoreSystems } from './clocks'
import { EVENTS } from '../data/events'
import { DEFAULT_PACK } from '../data/registry'
import { EVENTS } from '../../data/events'
import { DEFAULT_PACK } from '../../data/registry'
/** 内置插件一:镇族基石(12 能力卡与时轮钩子) */
export function makeCoreSystemsPlugin(): CotycPlugin {
@@ -1,8 +1,8 @@
import { CotycPlugin, PluginContext, PluginStatus, PluginChange } from '../core/plugin'
import { SystemDef, SYSTEM_DEFS } from '../engine/capabilities'
import type { World } from '../engine/world'
import { SystemHook } from '../core/clock'
import { GameClock } from '../core/clock'
import { CotycPlugin, PluginContext, PluginStatus, PluginChange } from '../kernel/plugin'
import { SystemDef, SYSTEM_DEFS } from './capabilities'
import type { World } from './World'
import { SystemHook } from '../kernel/clock'
import { GameClock } from '../kernel/clock'
interface RuntimePlugin {
plugin: CotycPlugin
@@ -1,6 +1,6 @@
import type { World } from './world'
import { pack } from '../data/registry'
import { traitBonuses } from './pcgen'
import type { World } from '../runtime/World'
import { pack } from '../../data/registry'
import { traitBonuses } from '../runtime/pcgen'
export function marketPrice(w: World, itemId: string): number {
const item = pack().items[itemId]
@@ -48,7 +48,7 @@ export function techniquePrice(techId: string): number {
return TECH_GRADE_BASE[techId] ?? 200
}
import { TECHNIQUES } from '../data/techniques'
import { TECHNIQUES } from '../../data/techniques'
const TECHNIQUE_GRADE_PRICE: Record<number, number> = { 1: 120, 2: 300, 3: 700, 4: 1600 }
const TECH_GRADE_BASE: Record<string, number> = Object.fromEntries(