v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan

家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统
- Electron + React + TS + MetonaSqlark(aria+OPFS)
- 水墨中国风 UI
- 引擎种子随机、确定性可回放
- 19 项单元测试、端到端冒烟验证
This commit is contained in:
2026-08-23 00:04:16 +08:00
commit adb22f0558
69 changed files with 14305 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
import { app, BrowserWindow, ipcMain, dialog, shell, protocol, net } from 'electron'
import { join, normalize, sep } from 'path'
import { readFileSync, writeFileSync } from 'fs'
import { pathToFileURL } from 'url'
const SCHEME = 'app'
const RENDERER_ROOT = join(__dirname, '../renderer')
protocol.registerSchemesAsPrivileged([
{
scheme: SCHEME,
privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true }
}
])
let mainWindow: BrowserWindow | null = null
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 1120,
minHeight: 720,
show: false,
autoHideMenuBar: true,
backgroundColor: '#191512',
title: '仙途家族志',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
const iconPath = join(__dirname, '../../resources/icon.png')
try {
readFileSync(iconPath)
mainWindow.setIcon(iconPath)
} catch {
// no icon in dev context; packaged builds use the builder icon
}
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
})
if (process.env.SMOKE_TEST) {
const wc = mainWindow.webContents
const shotsDir = process.env.SMOKE_SHOTS_DIR
wc.on('console-message', (_e, level, message) => {
console.log(`[renderer:${level}] ${message}`)
})
wc.on('did-finish-load', async () => {
try {
const title = await wc.executeJavaScript('document.title')
let bootReady = 0
for (let i = 0; i < 40; i++) {
bootReady = await wc.executeJavaScript(
"Number(window.__cotycBootReady ?? 0)"
)
if (bootReady === 1) break
await new Promise((r) => setTimeout(r, 250))
}
const rootLen = await wc.executeJavaScript(
"document.getElementById('root')?.children.length ?? -1"
)
let smoke2 = 'skipped'
let shotCount = 0
if (bootReady === 1 && rootLen > 0) {
if (shotsDir) {
await new Promise((r) => setTimeout(r, 900))
await capture(wc, shotsDir, 'boot')
shotCount++
}
smoke2 = await (async () => {
await wc.executeJavaScript('window.__cotycDebug.startNewGame()')
await new Promise((r) => setTimeout(r, 600))
for (let i = 0; i < 34; i++) {
await wc.executeJavaScript('window.__cotycDebug.advance()')
await wc.executeJavaScript('window.__cotycDebug.resolvePending()')
}
await new Promise((r) => setTimeout(r, 500))
await wc.executeJavaScript('window.__cotycDebug.closeBattles()')
if (shotsDir) {
await capture(wc, shotsDir, 'game-family')
shotCount++
await wc.executeJavaScript('window.__cotycDebug.openPanel("territory")')
await new Promise((r) => setTimeout(r, 400))
await capture(wc, shotsDir, 'game-territory')
shotCount++
await wc.executeJavaScript('window.__cotycDebug.openPanel("market")')
await new Promise((r) => setTimeout(r, 400))
await capture(wc, shotsDir, 'game-market')
shotCount++
await wc.executeJavaScript('window.__cotycDebug.openPanel("chronicle")')
await new Promise((r) => setTimeout(r, 400))
await capture(wc, shotsDir, 'game-chronicle')
shotCount++
}
const year = (await wc.executeJavaScript('window.__cotycDebug.getYear()')) as number
const famName = await wc.executeJavaScript(
"document.querySelector('.fam-name')?.textContent ?? ''"
)
return `year=${year} fam=${famName} shots=${shotCount}`
})()
}
console.log(`[SMOKE] title=${title} rootChildren=${rootLen} bootReady=${bootReady} game=${smoke2}`)
app.exit(bootReady === 1 && rootLen > 0 ? 0 : 1)
} catch (e) {
console.error('[SMOKE] failed', e)
app.exit(1)
}
})
}
mainWindow.webContents.setWindowOpenHandler((details) => {
void shell.openExternal(details.url)
return { action: 'deny' }
})
if (process.env.ELECTRON_RENDERER_URL) {
void mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
void mainWindow.loadURL(`app://bundle/index.html`)
}
}
async function capture(
wc: Electron.WebContents,
dir: string,
name: string
): Promise<void> {
const image = await wc.capturePage()
const buf = image.toPNG()
const { mkdirSync, writeFileSync } = await import('fs')
const { join } = await import('path')
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, `${name}.png`), buf)
console.log(`[SHOT] ${dir}/${name}.png`)
}
function serveAppProtocol(): void {
protocol.handle(SCHEME, (req) => {
const url = new URL(req.url)
let pathname = decodeURIComponent(url.pathname)
if (pathname === '/') pathname = '/index.html'
const target = normalize(join(RENDERER_ROOT, pathname)) + sep
const resolved = normalize(join(RENDERER_ROOT, pathname))
const rootPrefix = normalize(RENDERER_ROOT) + sep
const safe = resolved.startsWith(rootPrefix) || resolved === normalize(RENDERER_ROOT)
if (!safe) {
return new Response('forbidden', { status: 403 })
}
return net.fetch(pathToFileURL(resolved).toString())
})
}
app.whenReady().then(() => {
serveAppProtocol()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
app.quit()
})
ipcMain.handle('save:export', async (_ev, json: string, defaultName: string) => {
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
const result = await dialog.showSaveDialog(win!, {
title: '导出存档',
defaultPath: `${defaultName}.json`,
filters: [{ name: '存档文件', extensions: ['json'] }]
})
if (result.canceled || !result.filePath) return { ok: false as const }
try {
writeFileSync(result.filePath, json, 'utf-8')
return { ok: true as const }
} catch (e) {
return { ok: false as const, error: String(e) }
}
})
ipcMain.handle('save:import', async () => {
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
const result = await dialog.showOpenDialog(win!, {
title: '导入存档',
filters: [{ name: '存档文件', extensions: ['json'] }],
properties: ['openFile']
})
if (result.canceled || result.filePaths.length === 0) return { ok: false as const }
try {
const text = readFileSync(result.filePaths[0], 'utf-8')
return { ok: true as const, text }
} catch (e) {
return { ok: false as const, error: String(e) }
}
})
+21
View File
@@ -0,0 +1,21 @@
import { contextBridge, ipcRenderer } from 'electron'
export interface SaveExportResult {
ok: boolean
error?: string
}
export interface SaveImportResult {
ok: boolean
text?: string
error?: string
}
const api = {
exportSave: (json: string, defaultName: string): Promise<SaveExportResult> =>
ipcRenderer.invoke('save:export', json, defaultName),
importSave: (): Promise<SaveImportResult> => ipcRenderer.invoke('save:import')
}
contextBridge.exposeInMainWorld('api', api)
export type Api = typeof api
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { useGameStore } from './ui/store'
import Boot from './ui/screens/Boot'
import NewGame from './ui/screens/NewGame'
import GameScreen from './ui/screens/GameScreen'
import { EventModal } from './ui/components/EventModal'
import { BattleModal } from './ui/components/BattleModal'
export default function App() {
const screen = useGameStore((s) => s.screen)
const pendingEventId = useGameStore((s) => s.pendingEventId)
const battleView = useGameStore((s) => s.battleView)
useEffect(() => {
useGameStore.getState().init()
}, [])
return (
<div className="app">
{screen === 'boot' && <Boot />}
{screen === 'newgame' && <NewGame />}
{screen === 'game' && <GameScreen />}
{pendingEventId && <EventModal />}
{battleView && <BattleModal />}
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" preserveAspectRatio="xMidYMax slice">
<defs>
<filter id="blur2"><feGaussianBlur stdDeviation="2"/></filter>
<filter id="blur6"><feGaussianBlur stdDeviation="6"/></filter>
<filter id="blur14"><feGaussianBlur stdDeviation="14"/></filter>
<linearGradient id="mist" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f0dcae" stop-opacity="0"/>
<stop offset="1" stop-color="#f0dcae" stop-opacity="0.30"/>
</linearGradient>
<linearGradient id="mist2" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e8cf9b" stop-opacity="0"/>
<stop offset="1" stop-color="#e8cf9b" stop-opacity="0.22"/>
</linearGradient>
</defs>
<!-- 朱砂日 -->
<circle cx="862" cy="168" r="70" fill="#c0503c" opacity="0.28" filter="url(#blur14)"/>
<circle cx="862" cy="168" r="42" fill="#c65d44" opacity="0.62" filter="url(#blur6)"/>
<circle cx="862" cy="168" r="26" fill="#d87a54" opacity="0.85" filter="url(#blur2)"/>
<!-- 远山(淡金线) -->
<path d="M0 560 L130 430 240 520 360 400 500 500 620 380 760 490 900 410 1030 500 1130 440 1200 480 L1200 800 0 800 Z"
fill="#8a6f45" opacity="0.16" filter="url(#blur2)"/>
<!-- 中景 -->
<path d="M0 620 L160 480 300 580 470 440 640 560 810 470 960 560 1100 490 1200 540 L1200 800 0 800 Z"
fill="#6d5330" opacity="0.30"/>
<!-- 近景山 -->
<path d="M0 700 L150 560 340 660 520 520 720 650 900 550 1080 660 1200 590 L1200 800 0 800 Z"
fill="#3f2f18" opacity="0.55"/>
<!-- 山头雪线 -->
<path d="M470 440 L520 522 L570 452" fill="none" stroke="#e8cf9b" stroke-width="3" opacity="0.5" filter="url(#blur2)"/>
<path d="M150 560 L200 622 L250 566" fill="none" stroke="#e8cf9b" stroke-width="3" opacity="0.4" filter="url(#blur2)"/>
<!-- 崖頂孤松 -->
<g stroke="#f2e3bf" stroke-width="5" stroke-linecap="round" opacity="0.85" fill="none" filter="url(#blur2)">
<path d="M520 520 q-10 -40 8 -66"/>
<path d="M528 458 q26 -18 52 -2 q-18 10 -52 2"/>
<path d="M516 492 q-30 -12 -52 4 q20 14 52 -4"/>
</g>
<!-- 孤舟 -->
<g fill="#f2e3bf" opacity="0.75" filter="url(#blur2)">
<path d="M180 700 q40 -14 84 0 q-44 12 -84 0"/>
<path d="M222 692 l0 -26"/>
<path d="M222 666 l22 4 l-22 8" stroke="#f2e3bf" stroke-width="2.4" fill="none"/>
</g>
<!-- 雁阵 -->
<g stroke="#f0e6c8" stroke-width="2.6" opacity="0.9" fill="none">
<path d="M840 220 q16 -11 32 0 q16 -11 32 0"/>
<path d="M770 252 q14 -10 28 0 q14 -10 28 0"/>
<path d="M900 196 q13 -9 26 0 q13 -9 26 0"/>
</g>
<!-- 雾层 -->
<rect x="0" y="540" width="1200" height="130" fill="url(#mist)"/>
<rect x="0" y="640" width="1200" height="160" fill="url(#mist2)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+6
View File
@@ -0,0 +1,6 @@
interface Window {
api?: {
exportSave: (json: string, defaultName: string) => Promise<{ ok: boolean; error?: string }>
importSave: () => Promise<{ ok: boolean; text?: string; error?: string }>
}
}
+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)
}
+76
View File
@@ -0,0 +1,76 @@
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
constructor(state: RngState) {
this.state = { ...state }
}
getState(): RngState {
return { ...this.state }
}
next(): number {
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 {
return arr[this.int(0, arr.length - 1)]
}
chance(p: number): boolean {
return this.next() < p
}
shuffle<T>(arr: T[]): T[] {
const a = [...arr]
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
}
}
+97
View File
@@ -0,0 +1,97 @@
export interface BuildingDef {
id: string
name: string
icon: string
desc: string
kind: 'produce' | 'function'
maxLevel: number
produceTable?: (level: number) => { stone?: number; lingcao?: number; lingkuang?: number; beastcore?: number }
upgradeCost: (level: number) => { stones: number; lingkuang: number }
extra?: Record<string, string>
}
export const BUILDINGS: Record<string, BuildingDef> = {
lingtian: {
id: 'lingtian', name: '灵田', icon: '田',
desc: '种植灵草,每月产出稳定。',
kind: 'produce', maxLevel: 5,
produceTable: (l) => ({ lingcao: 10 * l }),
upgradeCost: (l) => ({ stones: 80 * Math.pow(1.7, l - 1), lingkuang: 12 * l })
},
yaoyuan: {
id: 'yaoyuan', name: '药园', icon: '药',
desc: '培育稀有药草以炼丹药。',
kind: 'produce', maxLevel: 5,
produceTable: (l) => ({ lingcao: 5 * l, beastcore: l >= 3 ? 1 : 0 }),
upgradeCost: (l) => ({ stones: 110 * Math.pow(1.7, l - 1), lingkuang: 15 * l })
},
lingkuang: {
id: 'lingkuang', name: '灵矿', icon: '矿',
desc: '开采灵石矿脉,供给炼器布阵。',
kind: 'produce', maxLevel: 5,
produceTable: (l) => ({ lingkuang: 8 * l }),
upgradeCost: (l) => ({ stones: 130 * Math.pow(1.7, l - 1), lingkuang: 10 * l })
},
fangshi: {
id: 'fangshi', name: '坊市', icon: '市',
desc: '设摊售货,每月进账灵石。',
kind: 'produce', maxLevel: 5,
produceTable: (l) => ({ stone: 55 * l }),
upgradeCost: (l) => ({ stones: 150 * Math.pow(1.7, l - 1), lingkuang: 20 * l })
},
danfang: {
id: 'danfang', name: '丹房', icon: '丹',
desc: '炼制丹药,等级越高成丹率越高。',
kind: 'function', maxLevel: 5,
upgradeCost: (l) => ({ stones: 170 * Math.pow(1.7, l - 1), lingkuang: 18 * l }),
extra: { craftChance: '0.5 + 0.1*L' }
},
cangshu: {
id: 'cangshu', name: '藏书阁', icon: '书',
desc: '收藏功法典籍,等级越高越能寻得宝典。',
kind: 'function', maxLevel: 5,
upgradeCost: (l) => ({ stones: 160 * Math.pow(1.7, l - 1), lingkuang: 12 * l }),
extra: { unlockGrade: '1 + L' }
},
juling: {
id: 'juling', name: '聚灵阵', icon: '阵',
desc: '汇聚天地灵气,全族修炼加成。',
kind: 'function', maxLevel: 5,
upgradeCost: (l) => ({ stones: 200 * Math.pow(1.7, l - 1), lingkuang: 30 * l }),
extra: { expBonus: '0.05 * L' }
},
yanwu: {
id: 'yanwu', name: '演武场', icon: '武',
desc: '操练武艺,家族战力加成。',
kind: 'function', maxLevel: 5,
upgradeCost: (l) => ({ stones: 140 * Math.pow(1.7, l - 1), lingkuang: 25 * l }),
extra: { powerBonus: '0.04 * L' }
},
dongfu: {
id: 'dongfu', name: '洞府', icon: '府',
desc: '闭关修行之所,闭关者修为加成。',
kind: 'function', maxLevel: 5,
upgradeCost: (l) => ({ stones: 220 * Math.pow(1.7, l - 1), lingkuang: 35 * l }),
extra: { expBonus: '0.08 * L' }
},
zongci: {
id: 'zongci', name: '宗祠', icon: '祠',
desc: '供奉先祖,家族声望与生育兴旺。',
kind: 'function', maxLevel: 5,
upgradeCost: (l) => ({ stones: 120 * Math.pow(1.7, l - 1), lingkuang: 8 * l }),
extra: { repBonus: '0.3 * L /年' }
},
lingshou: {
id: 'lingshou', name: '灵兽园', icon: '兽',
desc: '豢养灵兽,战阵之助,偶得兽核。',
kind: 'function', maxLevel: 5,
upgradeCost: (l) => ({ stones: 190 * Math.pow(1.7, l - 1), lingkuang: 28 * l }),
extra: { powerBonus: '0.05 * L' }
}
}
export const BUILDING_IDS = Object.keys(BUILDINGS)
export function buildingById(id: string): BuildingDef {
return BUILDINGS[id]
}
+27
View File
@@ -0,0 +1,27 @@
import { Element } from '../types/domain'
export const ELEMENT_LIST: Element[] = ['金', '木', '水', '火', '土']
export const ROOT_GRADE_NAMES = ['伪灵根', '凡品灵根', '玄品灵根', '真品灵根', '天品灵根', '仙品灵根']
export const ROOT_GRADE_COLORS = ['#8a8078', '#9cb27c', '#7fa7c9', '#b08cd9', '#e6b84c', '#e05a5a']
export interface RootGradeDef {
name: string
expBonus: number
drawWeight: number
}
export const ROOT_GRADES: Record<number, RootGradeDef> = {
0: { name: '伪灵根', expBonus: 0.5, drawWeight: 14 },
1: { name: '凡品灵根', expBonus: 0.8, drawWeight: 38 },
2: { name: '玄品灵根', expBonus: 1.0, drawWeight: 30 },
3: { name: '真品灵根', expBonus: 1.3, drawWeight: 12 },
4: { name: '天品灵根', expBonus: 1.7, drawWeight: 5 },
5: { name: '仙品灵根', expBonus: 2.2, drawWeight: 1.5 }
}
export function describeRoots(roots: { grade: number; primary: Element; secondary: Element[] }): string {
const parts = [roots.primary, ...roots.secondary].join('')
return `${ROOT_GRADE_NAMES[roots.grade] ?? '伪灵根'}(${parts})`
}
+305
View File
@@ -0,0 +1,305 @@
export interface Cond {
all?: Cond[]
any?: Cond[]
not?: Cond
minYear?: number
minGeneration?: number
minHeadRealm?: string
minBuilding?: { id: string; level: number }
minRep?: number
maxRep?: number
minResource?: { id: string; n: number }
minAdult?: number
maxAdult?: number
minMembers?: number
eligibleAdult?: number
relation?: { npcId: string; gt?: number; lt?: number }
flag?: { key: string; eq: number | boolean | string }
hasMeditation?: boolean
noWarForYears?: number
minTechCount?: number
}
export interface MemberEffect {
by: 'exp' | 'wound' | 'heal' | 'breakthrough' | 'fatal' | 'repGain' | 'inspire' | 'loot' | 'madness' | 'genius'
target: 'random' | 'head' | 'youngest' | 'oldest' | 'highestPerception' | 'highestPower' | 'highestFortune' | 'all'
n?: number
desc?: string
}
export interface EffectDef {
res?: Record<string, number>
rep?: number
relation?: Record<string, number>
addBuilding?: string
memberBy?: MemberEffect
pillGain?: Record<string, number>
mission?: string
raid?: { npcId: string }
flag?: Record<string, number | boolean | string>
techniqueChance?: number
artifactChance?: number
addTech?: string
}
export interface EventOptionDef {
label: string
hint?: string
eff: EffectDef
note?: string
}
export interface EventDef {
id: string
name: string
category: 'daily' | 'major' | 'fate'
weight: number
once?: boolean
cond?: Cond
text: string
options: EventOptionDef[]
}
export function eventCategoryName(cat: EventDef['category']): string {
return cat === 'daily' ? '族中日常' : cat === 'major' ? '家国大事' : '乾坤造化'
}
export const EVENTS: EventDef[] = [
{
id: 'ev-youdao', name: '云游道人', category: 'daily', weight: 8,
text: '一名袖袍破旧的云游道人路过祖宅门前,自称识得丹药机巧,愿以一枚聚气丹换五斤灵草。',
options: [
{ label: '换!', hint: '灵草-5,聚气丹+1', eff: { res: { lingcao: -5 }, pillGain: { 'pill-qiyuan': 1 } } },
{ label: '送他些斋饭', hint: '灵石-10,声望+2', eff: { res: { stones: -10 }, rep: 2 } },
{ label: '赶走', hint: '无', eff: {} }
]
},
{
id: 'ev-chonghai', name: '灵田虫害', category: 'daily', weight: 8,
cond: { minBuilding: { id: 'lingtian', level: 1 } },
text: '成片的灵草被青翅虫啃食,照此下去今季收成不保。',
options: [
{ label: '请灵师除虫', hint: '灵石-60', eff: { res: { stones: -60 } } },
{ label: '亲自下田燃烧草', hint: '收成-40%', eff: { res: { lingcao: -8 } } },
{ label: '冷眼旁观', hint: '灵草-20', eff: { res: { lingcao: -20 } } }
]
},
{
id: 'ev-liumin', name: '流民投奔', category: 'daily', weight: 6,
cond: { minRep: 0 },
text: '乱世流民逃难至此,成群跪在庄外,望收留他们做佃户。',
options: [
{ label: '开仓设粥', hint: '灵石-40,声望+4', eff: { res: { stones: -40 }, rep: 4 } },
{ label: '遣散', hint: '声望-2', eff: { rep: -2 } },
{ label: '收为佃户', hint: '灵田产量长增', eff: { res: { stones: -30 }, rep: 2, flag: { tenants: true } } }
]
},
{
id: 'ev-takuang', name: '后山塌方', category: 'daily', weight: 6,
cond: { minBuilding: { id: 'lingkuang', level: 1 } },
text: '后山矿洞一段塌方,矿工数人被困,矿脉暂无人敢下。',
options: [
{ label: '出灵石救治', hint: '灵石-50', eff: { res: { stones: -50 } } },
{ label: '调族人救援', hint: '随机成员负伤', eff: { memberBy: { by: 'wound', target: 'random', n: 15 } } },
{ label: '封洞了事', hint: '矿脉-5', eff: { res: { lingkuang: -5 } } }
]
},
{
id: 'ev-lieshou', name: '猎户献兽', category: 'daily', weight: 5,
cond: { minRep: 10 },
text: '山中猎户捉得一头灵鹿,闻贵族历代尚灵,特来献上。',
options: [
{ label: '买下放生', hint: '灵石-20', eff: { res: { stones: -20 } } },
{ label: '收为坐骑', hint: '兽核+2', eff: { res: { beastcore: 2 } } },
{ label: '收他入庄', hint: '声望+1', eff: { rep: 1 } }
]
},
{
id: 'ev-jiangjiang', name: '巧匠上门', category: 'daily', weight: 5,
cond: { minResource: { id: 'stones', n: 20 } },
text: '一位独臂巧匠携着琳琅法器上门求售,说是祖传手笔。',
options: [
{ label: '买一件回头客', hint: '灵石-100,凡器+1', eff: { res: { stones: -100 }, pillGain: { 'weapon-fan': 1 } } },
{ label: '替他说媒去', hint: '声望+2', eff: { rep: 2 } },
{ label: '让他留个住址', hint: '无', eff: {} }
]
},
{
id: 'ev-lingyu', name: '灵雨', category: 'daily', weight: 6,
cond: { minBuilding: { id: 'lingtian', level: 1 } },
text: '一夜灵雨滂沱,田里的灵草吸饱了灵雨,长势惊人。',
options: [
{ label: '天佑我家!', hint: '灵草+60', eff: { res: { lingcao: 60 } } }
]
},
{
id: 'ev-yupei', name: '孩童拾玉', category: 'daily', weight: 5,
text: '族中孩童在后山拾到一枚玄玉,触手生温,似有不凡。',
options: [
{ label: '供奉宗祠', hint: '声望+5', eff: { rep: 5, flag: { jade: true } } },
{ label: '锁入库房', hint: '灵石+80', eff: { res: { stones: 80 } } },
{ label: '让孩童留之', hint: '其气运+2', eff: { memberBy: { by: 'loot', target: 'youngest', n: 2 }, flag: { jade2: true } } }
]
},
{
id: 'ev-shangdui', name: '路过商队', category: 'daily', weight: 6,
text: '一支四海王家的商队路过,问庄上可有灵矿出手,价给得公道。',
options: [
{ label: '出货', hint: '灵矿-15,灵石+240', eff: { res: { lingkuang: -15, stones: 240 } } },
{ label: '请他们留宿', hint: '声望+2', eff: { rep: 2 } },
{ label: '不理', hint: '无', eff: {} }
]
},
{
id: 'ev-zhusu', name: '祝氏家奴', category: 'daily', weight: 5,
cond: { relation: { npcId: 'n-nulei', lt: 20 } },
text: '捉住一个东丘祝氏的家奴,偷我庄上的灵草,被族人绑来听候发落。',
options: [
{ label: '重罚后放回', hint: '祝氏关系-8', eff: { relation: { 'n-nulei': -8 } } },
{ label: '送还祝氏', hint: '祝氏关系+6', eff: { relation: { 'n-nulei': 6 } } },
{ label: '就地正法', hint: '祝氏关系-20,声望+3', eff: { relation: { 'n-nulei': -20 }, rep: 3 } }
]
},
{
id: 'ev-lunda', name: '论道会', category: 'daily', weight: 5,
cond: { minAdult: 2 },
text: '方圆修士开论道会,族人收到请帖,座上皆是气息不凡之辈。',
options: [
{ label: '遣长老赴会', hint: '随机成员修为+15%', eff: { memberBy: { by: 'exp', target: 'highestPower', n: 15 } } },
{ label: '全族观礼', hint: '声望+3', eff: { rep: 3 } },
{ label: '婉拒', hint: '无', eff: {} }
]
},
{
id: 'ev-wenyi', name: '西川瘟疫', category: 'major', weight: 7,
cond: { minYear: 2 },
text: '西川爆发古怪瘟疫,卢氏求药无门,遣人来向庄上求助,愿以族传丹方相谢。',
options: [
{ label: '赠药救人', hint: '灵草-30,灵石-50,木氏关系+15', eff: { res: { lingcao: -30, stones: -50 }, relation: { 'n-danxin': 15 }, rep: 5 } },
{ label: '坐地起价', hint: '灵石+200,木氏关系-15', eff: { res: { stones: 200 }, relation: { 'n-danxin': -15 } } },
{ label: '关门避险', hint: '声望-2', eff: { rep: -2 } }
]
},
{
id: 'ev-qiuchee', name: '借剑之请', category: 'major', weight: 5,
cond: { minBuilding: { id: 'cangshu', level: 1 } },
text: '玄影沈氏遣使来借一部剑典,言按借期十年奉还,愿以两部法器为押。',
options: [
{ label: '借!', hint: '法器+1,沈氏关系+12', eff: { pillGain: { 'weapon-qi': 1 }, relation: { 'n-xuanying': 12 } } },
{ label: '婉拒', hint: '沈氏关系-5', eff: { relation: { 'n-xuanying': -5 } } },
{ label: '借,但要人质', hint: '沈氏关系+2,声望-2', eff: { relation: { 'n-xuanying': 2 }, rep: -2 } }
]
},
{
id: 'ev-xiamaozi', name: '神秘夜访', category: 'fate', weight: 4,
once: true,
text: '月黑风高,一位蒙面客深夜造访,自称知晓祖地深处有一处上古洞府,愿以两成收成易开启之诀。',
options: [
{ label: '与他交易', hint: '灵石-120', eff: { res: { stones: -120 }, flag: { caveClue: true }, rep: 2 } },
{ label: '绑了问话', hint: '可能招祸', eff: { flag: { caveRumor: true }, rep: -2 } },
{ label: '赏夜赶出', hint: '无', eff: {} }
]
},
{
id: 'ev-zuling', name: '祖祠异动', category: 'fate', weight: 3,
once: true,
cond: { minBuilding: { id: 'zongci', level: 2 } },
text: '深夜祖祠忽闻钟鸣,牌位前竟现一尊淡影,凝视良久,留下二字:「续裔」。',
options: [
{ label: '上香叩拜', hint: '声望+8,全族修炼+', eff: { rep: 8, memberBy: { by: 'inspire', target: 'all', n: 5 }, flag: { ancestorBless: true } } },
{ label: '祭以三牲', hint: '声望+12', eff: { rep: 12, flag: { ancestorBless: true } } },
{ label: '令人守夜', hint: '无', eff: {} }
]
},
{
id: 'ev-leize', name: '万兽潮', category: 'fate', weight: 3,
once: true,
cond: { minYear: 3 },
text: '数万妖兽从东畔雷泽奔涌而下,漫山遍野,直逼庄墙!须速决断。',
options: [
{ label: '举族死守', hint: '战斗!打赢得兽核与声望', eff: { raid: { npcId: 'n-nulei' }, flag: { beastTide: true } } },
{ label: '迁建宗祠', hint: '声望-6,灵石-100', eff: { res: { stones: -100 }, rep: -6 } },
{ label: '联姻求救', hint: '灵石-80,木氏关系+8', eff: { res: { stones: -80 }, relation: { 'n-danxin': 8 } } }
]
},
{
id: 'ev-jobai', name: '卖女求荣', category: 'major', weight: 4,
cond: { minAdult: 1, maxAdult: 8 },
text: '有媒人上门,说东丘祝氏大公子瞧上族中一位姑娘,愿出三百灵石聘礼换两家结好。',
options: [
{ label: '应下这门亲', hint: '灵石+300,祝氏关系+10', eff: { res: { stones: 300 }, relation: { 'n-nulei': 10 } } },
{ label: '再加价', hint: '灵石+450,祝氏关系-5', eff: { res: { stones: 450 }, relation: { 'n-nulei': -5 } } },
{ label: '回绝', hint: '无', eff: {} }
]
},
{
id: 'ev-lingshou', name: '灵兽认主', category: 'major', weight: 4,
cond: { minBuilding: { id: 'lingshou', level: 2 } },
text: '园中一只幼兽七日不食,气若游丝,唯对一位族人亲昵异常。',
options: [
{ label: '牵线认主', hint: '随机成员获得灵兽伴侣', eff: { memberBy: { by: 'genius', target: 'highestFortune', n: 1 } } },
{ label: '卖了换钱', hint: '灵石+150', eff: { res: { stones: 150 } } },
{ label: '放归山林', hint: '声望+2', eff: { rep: 2 } }
]
},
{
id: 'ev-xianyi', name: '残简偶得', category: 'major', weight: 5,
cond: { minBuilding: { id: 'cangshu', level: 1 } },
text: '有人兜售一页古玉残简,字迹晦暗,却似有大道气息流动。',
options: [
{ label: '买下参悟', hint: '灵石-150', eff: { res: { stones: -150 }, techniqueChance: 0.8 } },
{ label: '献诸官家', hint: '声望+6', eff: { rep: 6 } },
{ label: '不信邪,扔了', hint: '无', eff: {} }
]
},
{
id: 'ev-kuangxie', name: '矿脉异动', category: 'major', weight: 4,
cond: { minBuilding: { id: 'lingkuang', level: 2 } },
text: '矿工急报:井下竟听到龙吟之声,地气翻涌,恐是宝脉将成。',
options: [
{ label: '加派人手', hint: '灵矿+80', eff: { res: { lingkuang: 80 } } },
{ label: '镇压封脉', hint: '灵矿-20,声望+3', eff: { res: { lingkuang: -20 }, rep: 3 } },
{ label: '上报求封赏', hint: '灵石+100,声望+5', eff: { res: { stones: 100 }, rep: 5 } }
]
},
{
id: 'ev-yaoqi', name: '玉露灵液', category: 'major', weight: 4,
cond: { minBuilding: { id: 'yaoyuan', level: 1 } },
text: '药园一株千年石斛今夜吐露玉露,紫光浸雾,实为难得。',
options: [
{ label: '专人采露', hint: '聚气丹+2', eff: { pillGain: { 'pill-qiyuan': 2 } } },
{ label: '留作传家', hint: '声望+5', eff: { rep: 5 } },
{ label: '泡酒宴客', hint: '声望+7', eff: { rep: 7 } }
]
},
{
id: 'ev-jiazu', name: '氏族纠纷', category: 'daily', weight: 5,
cond: { minAdult: 3 },
text: '族中兄弟为一处祖屋的归属争执不下,闹到了宗祠。',
options: [
{ label: '秉公裁断', hint: '声望+3', eff: { rep: 3, memberBy: { by: 'wound', target: 'random', n: 5 } } },
{ label: '各打五十大板', hint: '声望-1', eff: { rep: -1 } },
{ label: '拖到明年再说', hint: '声望-3', eff: { rep: -3 } }
]
},
{
id: 'ev-waizushao', name: '修士来投', category: 'major', weight: 5,
cond: { minRep: 20, maxAdult: 12 },
text: '一位炼气散修慕名而来,愿入庄效力,只是要一份例钱。',
options: [
{ label: '开门收下', hint: '族人+1', eff: { memberBy: { by: 'inspire', target: 'all', n: 0 }, rep: 1, flag: { recruit: true } } },
{ label: '考其心性', hint: '族人+1(或负伤)', eff: { memberBy: { by: 'loot', target: 'highestPower', n: 1 }, flag: { recruit2: true } } },
{ label: '婉拒', hint: '无', eff: {} }
]
},
{
id: 'ev-tianlei', name: '天雷淬体', category: 'fate', weight: 2,
once: true,
text: '晴空忽落一道紫雷,劈在宗祠檐角,却汇聚成光球,悬于一位弟子头顶。',
options: [
{ label: '接引练体', hint: '随机成员修为大涨', eff: { memberBy: { by: 'genius', target: 'random', n: 3 } } },
{ label: '存入玉瓶', hint: '聚气丹+3', eff: { pillGain: { 'pill-qiyuan': 3 } } },
{ label: '请道人镇压', hint: '技能+1', eff: { memberBy: { by: 'madness', target: 'random', n: 1 } } }
]
}
]
+43
View File
@@ -0,0 +1,43 @@
export interface ItemDef {
id: string
name: string
kind: 'resource' | 'pill' | 'artifact'
basePrice: number
desc: string
icon: string
cooldown?: number
}
export const ITEMS: Record<string, ItemDef> = {
lingcao: { id: 'lingcao', name: '灵草', kind: 'resource', basePrice: 8, desc: '最常见的修炼辅材,亦是炼丹基础。', icon: '草' },
lingkuang: { id: 'lingkuang', name: '灵矿', kind: 'resource', basePrice: 12, desc: '铸器与布阵常用矿物。', icon: '矿' },
beastcore: { id: 'beastcore', name: '兽核', kind: 'resource', basePrice: 30, desc: '妖兽一身的精华,可炼丹炼器。', icon: '核' },
'pill-qiyuan': { id: 'pill-qiyuan', name: '聚气丹', kind: 'pill', basePrice: 45, desc: '炼气期修士服之,一月修为大增。', icon: '气' },
'pill-ningyuan': { id: 'pill-ningyuan', name: '凝元丹', kind: 'pill', basePrice: 150, desc: '筑基以上可效,修为增长显著。', icon: '元' },
'pill-pojing': { id: 'pill-pojing', name: '破境丹', kind: 'pill', basePrice: 480, desc: '冲击瓶颈时的辅助神物,提升突破成功率。', icon: '破' },
'weapon-fan': { id: 'weapon-fan', name: '凡器', kind: 'artifact', basePrice: 120, desc: '普通铁器,聊胜于无。', icon: '凡' },
'weapon-qi': { id: 'weapon-qi', name: '法器', kind: 'artifact', basePrice: 450, desc: '蕴灵之器,可引动灵力。', icon: '法' },
'weapon-ling': { id: 'weapon-ling', name: '灵器', kind: 'artifact', basePrice: 1400, desc: '有灵之器,锋芒隐露。', icon: '灵' },
'weapon-fa': { id: 'weapon-fa', name: '法宝', kind: 'artifact', basePrice: 3600, desc: '罕世法宝,非金丹不能驾驭。', icon: '宝' }
}
export const ARTIFACT_POWER: Record<string, number> = {
'weapon-fan': 0.1,
'weapon-qi': 0.25,
'weapon-ling': 0.5,
'weapon-fa': 0.9
}
export function itemById(id: string): ItemDef {
return ITEMS[id]
}
export interface SimpleTradeItem {
id: string
name: string
price: number
desc: string
count: number
}
+43
View File
@@ -0,0 +1,43 @@
import { RealmMajor } from '../types/domain'
export interface NpcFamilyDef {
id: string
name: string
region: string
desc: string
style: string
leaderRealm: RealmMajor
initialPower: number
powerGrowth: [number, number]
sells?: string[]
buys?: string[]
}
export const NPCS: NpcFamilyDef[] = [
{
id: 'n-xuanying', name: '玄影沈氏', region: '北岳玄影峰', style: '剑修世家', desc: '隐于北岳的剑修吕氏,剑意凛冽,最为孤傲。',
leaderRealm: 'foundation', initialPower: 240, powerGrowth: [4, 10],
sells: ['weapon-qi', 'weapon-ling'], buys: ['lingcao', 'lingkuang']
},
{
id: 'n-danxin', name: '丹心木氏', region: '西川药谷', style: '丹道世家', desc: '悬壶济世的丹道世家,人脉广博,风格和缓。',
leaderRealm: 'foundation', initialPower: 200, powerGrowth: [3, 9],
sells: ['pill-qiyuan', 'pill-ningyuan', 'pill-pojing'], buys: ['lingcao', 'beastcore']
},
{
id: 'n-sihai', name: '四海王氏', region: '南都连港', style: '商盟世族', desc: '商通四海,富可敌国,只认灵石不认人。',
leaderRealm: 'foundation', initialPower: 160, powerGrowth: [5, 12],
sells: ['lingcao', 'lingkuang'], buys: ['beastcore', 'lingkuang']
},
{
id: 'n-nulei', name: '怒雷祝氏', region: '东丘雷泽', style: '兵修蛮门', desc: '雷泽蛮族的世仇,性情火爆,最易生衅。',
leaderRealm: 'core', initialPower: 300, powerGrowth: [5, 13],
sells: [], buys: ['lingkuang', 'beastcore']
}
]
export function npcById(id: string): NpcFamilyDef {
const n = NPCS.find((x) => x.id === id)
if (!n) throw new Error(`npc not found: ${id}`)
return n
}
+14
View File
@@ -0,0 +1,14 @@
import { RealmMajor } from '../types/domain'
export const MAJOR_RATE: Record<RealmMajor, number> = {
mortal: 1,
qi: 1,
foundation: 0.62,
core: 0.38,
nascent: 0.23,
spirit: 0.15
}
export function masteryRateOfMajor(major: RealmMajor): number {
return MAJOR_RATE[major] ?? 1
}
+119
View File
@@ -0,0 +1,119 @@
import { Element, Realm, RealmMajor } from '../types/domain'
export interface MajorDef {
name: string
short: string
minorLayers: number
lifespan: number
// 修为点数 第1层到满层所需(每层指数)
expBase: number
expGrowth: number
}
export const MAJORS: Record<RealmMajor, MajorDef> = {
mortal: { name: '凡人', short: '凡', minorLayers: 0, lifespan: 62, expBase: 0, expGrowth: 1 },
qi: { name: '炼气', short: '炼气', minorLayers: 9, lifespan: 110, expBase: 60, expGrowth: 1.35 },
foundation: { name: '筑基', short: '筑基', minorLayers: 3, lifespan: 165, expBase: 1400, expGrowth: 1.6 },
core: { name: '金丹', short: '金丹', minorLayers: 3, lifespan: 260, expBase: 6200, expGrowth: 1.7 },
nascent: { name: '元婴', short: '元婴', minorLayers: 3, lifespan: 400, expBase: 22000, expGrowth: 1.75 },
spirit: { name: '化神', short: '化神', minorLayers: 3, lifespan: 600, expBase: 60000, expGrowth: 1.8 }
}
export const MAJOR_ORDER: RealmMajor[] = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit']
export const MAJOR_NAMES: Record<RealmMajor, string> = {
mortal: '凡人',
qi: '炼气',
foundation: '筑基',
core: '金丹',
nascent: '元婴',
spirit: '化神'
}
export function describeRealm(realm: Realm): string {
if (realm.major === 'mortal') return '凡人'
const def = MAJORS[realm.major]
return `${def.name}${realm.minor + 1}`
}
export function realmTier(realm: Realm): number {
return MAJOR_ORDER.indexOf(realm.major) * 10 + (realm.major === 'mortal' ? 0 : realm.minor + 1)
}
export function compareRealm(a: Realm, b: Realm): number {
return realmTier(a) - realmTier(b)
}
export function maxRealmExp(major: RealmMajor, minor: number): number {
if (major === 'mortal') return 0
const def = MAJORS[major]
return Math.round(def.expBase * Math.pow(def.expGrowth, minor))
}
export function nextRealm(realm: Realm): Realm | null {
const idx = MAJOR_ORDER.indexOf(realm.major)
if (realm.major === 'mortal') return { major: 'qi', minor: 0 }
if (idx >= MAJOR_ORDER.length - 1 && realm.minor >= MAJORS[realm.major as RealmMajor].minorLayers - 1) return null
const def = MAJORS[realm.major as RealmMajor]
if (realm.minor + 1 < def.minorLayers) return { major: realm.major, minor: realm.minor + 1 }
return { major: MAJOR_ORDER[idx + 1], minor: 0 }
}
export function basePower(realm: Realm): number {
if (realm.major === 'mortal') return 1 * (realm.minor * 0.15 + 1)
const def = MAJORS[realm.major]
return Math.pow(MODEL_BASE, MAJOR_ORDER.indexOf(realm.major)) * (1 + realm.minor * 0.18)
}
const MODEL_BASE = 3.4
export function realmDeathChance(realm: Realm, mind: number): number {
switch (realm.major) {
case 'mortal':
return 0.002
case 'qi':
return 0.004
case 'foundation':
return 0.008
case 'core':
return 0.02
case 'nascent':
return 0.035
case 'spirit':
return 0.05
default:
return 0.005
}
}
export function breakthroughBaseChance(realm: Realm): number {
switch (realm.major) {
case 'mortal':
return 0.9
case 'qi':
return 0.45
case 'foundation':
return 0.32
case 'core':
return 0.22
case 'nascent':
return 0.14
case 'spirit':
return 0.08
default:
return 0.4
}
}
export interface TechniqueDef {
id: string
name: string
grade: number
element: Element
path: string
expBonus: number
powerBonus: number
desc: string
}
export const TECHNIQUE_GRADE_NAMES = ['黄阶', '玄阶', '地阶', '天阶', '仙阶']
+117
View File
@@ -0,0 +1,117 @@
import { RealmMajor } from '../types/domain'
export interface EnemyDef {
id: string
name: string
realm: RealmMajor
strength: number
icon: string
desc: string
}
export const ENEMIES: EnemyDef[] = [
{ id: 'e-huiyuan', name: '灰狼王', realm: 'qi', strength: 0.8, icon: '狼', desc: '啸聚山林的妖兽头领,凶性难驯。' },
{ id: 'e-tiebei', name: '铁背熊罴', realm: 'qi', strength: 1.1, icon: '熊', desc: '皮糙肉厚的山中猛兽,以力御敌。' },
{ id: 'e-muche', name: '赤瞳木魅', realm: 'foundation', strength: 1.0, icon: '木', desc: '千年老树成精,嗜血成性。' },
{ id: 'e-baiqi', name: '白骨修士', realm: 'foundation', strength: 1.2, icon: '骨', desc: '枯骨道人的傀儡余孽,邪气森森。' },
{ id: 'e-huanhan', name: '幻寒螭', realm: 'core', strength: 1.15, icon: '螭', desc: '远古寒螭之遗种,吐息生霜。' },
{ id: 'e-yeshen', name: '夜哭古影', realm: 'core', strength: 1.3, icon: '影', desc: '怨念凝结,闻见其声者神魂惑乱。' },
{ id: 'e-jiaoyun', name: '焦云妖鹤', realm: 'nascent', strength: 1.2, icon: '鹤', desc: '焚山煮海的老妖,万兽避让。' },
{ id: 'e-kuangzun', name: '堕墟魔尊', realm: 'spirit', strength: 1.5, icon: '魔', desc: '屠戮一族的魔道巨擘,举手投足皆天威。' }
]
export interface MissionStageDef {
kind: 'event' | 'combat' | 'resource' | 'boss'
months: number
title: string
text?: { safe?: string; good?: string; bad?: string }
enemyId?: string
loot?: LootDef
}
export interface LootDef {
resources: Record<string, [number, number]>
artifactChance: number
techniqueChance: number
beastcoreChance?: number
}
export interface MissionDef {
id: string
name: string
region: string
icon: string
desc: string
realmHint: RealmMajor
minMembers: number
maxMembers: number
risk: number
stages: MissionStageDef[]
completionLoot: LootDef
}
export const MISSIONS: MissionDef[] = [
{
id: 'm-anmoku', name: '暗墨林', region: '祖城西郊', icon: '林',
desc: '灵气暗涌的密林,常有低阶妖兽出没,是子弟试炼的好去处。',
realmHint: 'qi', minMembers: 1, maxMembers: 4, risk: 0.5,
stages: [
{ kind: 'event', months: 3, title: '深入林间', text: { safe: '一路无波,采得几株灵草压惊。', bad: '林深迷路,误入瘴气,队伍折损了体力。' } },
{ kind: 'combat', months: 2, title: '遭遇袭击', enemyId: 'e-tiebei' },
{ kind: 'resource', months: 2, title: '寻获残洞', loot: { resources: { lingcao: [30, 80], beastcore: [1, 3] }, artifactChance: 0.05, techniqueChance: 0.03 } }
],
completionLoot: { resources: { lingcao: [20, 60], lingkuang: [5, 15] }, artifactChance: 0.08, techniqueChance: 0.03 }
},
{
id: 'm-xuangu', name: '玄冰谷', region: '北境寒渊', icon: '冰',
desc: '寒潭彻骨,谷中灵泉可淬骨洗髓,也匿着护泉的凶兽。',
realmHint: 'qi', minMembers: 1, maxMembers: 4, risk: 0.65,
stages: [
{ kind: 'resource', months: 3, title: '踏雪寻泉', loot: { resources: { lingkuang: [20, 50] }, artifactChance: 0.02, techniqueChance: 0.02 } },
{ kind: 'combat', months: 2, title: '泉主现身', enemyId: 'e-huiyuan' },
{ kind: 'boss', months: 2, title: '冰潭之下', enemyId: 'e-baiqi' }
],
completionLoot: { resources: { lingkuang: [30, 70], beastcore: [2, 5] }, artifactChance: 0.15, techniqueChance: 0.06 }
},
{
id: 'm-guzhan', name: '古战阵', region: '南陵荒原', icon: '战',
desc: '上古两族交兵的战场,杀气千年未散,宝物与凶险兼存。',
realmHint: 'foundation', minMembers: 2, maxMembers: 4, risk: 0.75,
stages: [
{ kind: 'event', months: 3, title: '踏勘阵眼', text: { safe: '寻得阵眼遗骸,得一部残简。', bad: '触动禁制,一行人负伤遁走。' } },
{ kind: 'combat', months: 2, title: '怨灵缠身', enemyId: 'e-baiqi' },
{ kind: 'combat', months: 2, title: '守阵铁骑', enemyId: 'e-huanhan' },
{ kind: 'resource', months: 3, title: '挖开枯井', loot: { resources: { lingkuang: [40, 90], beastcore: [2, 6] }, artifactChance: 0.2, techniqueChance: 0.08 } }
],
completionLoot: { resources: { lingkuang: [40, 90], beastcore: [2, 8] }, artifactChance: 0.22, techniqueChance: 0.1 }
},
{
id: 'm-lingshan', name: '灵鹫山', region: '东海之滨', icon: '鹫',
desc: '孤峰入云,鹫鸣震谷,山腹有灵矿与丹谷仙人遗泽。',
realmHint: 'foundation', minMembers: 2, maxMembers: 4, risk: 0.85,
stages: [
{ kind: 'combat', months: 2, title: '登山遇鹫', enemyId: 'e-muche' },
{ kind: 'resource', months: 3, title: '灵鹫巢穴', loot: { resources: { lingkuang: [50, 120], beastcore: [3, 8] }, artifactChance: 0.25, techniqueChance: 0.15 } },
{ kind: 'boss', months: 3, title: '丹谷试剑', enemyId: 'e-yeshen' }
],
completionLoot: { resources: { lingcao: [60, 140], beastcore: [4, 10] }, artifactChance: 0.3, techniqueChance: 0.2 }
},
{
id: 'm-tiankeng', name: '天地秘境', region: '传说天外', icon: '玄',
desc: '百年一开的无上秘境,传言其中有登天造化。',
realmHint: 'core', minMembers: 3, maxMembers: 5, risk: 1.0,
stages: [
{ kind: 'event', months: 2, title: '踏入秘境', text: { safe: '奇花异果满目,造化之气扑面。', bad: '紫霄神雷击碎阵法,众人惊魂不定。' } },
{ kind: 'combat', months: 2, title: '守宫神兽', enemyId: 'e-huanhan' },
{ kind: 'combat', months: 3, title: '天劫傀儡', enemyId: 'e-jiaoyun' },
{ kind: 'boss', months: 3, title: '群雄夺宝', enemyId: 'e-kuangzun' }
],
completionLoot: { resources: { lingcao: [100, 220], lingkuang: [80, 160], beastcore: [10, 22] }, artifactChance: 0.6, techniqueChance: 0.5 }
}
]
export function missionById(id: string): MissionDef {
const m = MISSIONS.find((x) => x.id === id)
if (!m) throw new Error(`mission not found: ${id}`)
return m
}
+27
View File
@@ -0,0 +1,27 @@
import { TechniqueDef } from './realms'
export const TECHNIQUES: TechniqueDef[] = [
{ id: 't-qinglian', name: '青莲剑诀', grade: 1, element: '木', path: '剑修', expBonus: 0.12, powerBonus: 0.15, desc: '青莲花开,剑气如雨。' },
{ id: 't-canglei', name: '苍雷炼体功', grade: 1, element: '金', path: '体修', expBonus: 0.1, powerBonus: 0.2, desc: '引雷淬体,肉身如兵。' },
{ id: 't-xuanyue', name: '玄月斩灵诀', grade: 1, element: '水', path: '剑修', expBonus: 0.12, powerBonus: 0.1, desc: '月华落处,万灵俯首。' },
{ id: 't-lieliuxin', name: '离火心经', grade: 1, element: '火', path: '丹修', expBonus: 0.14, powerBonus: 0.08, desc: '以火养心,丹道通神。' },
{ id: 't-houtu', name: '厚土镇岳功', grade: 1, element: '土', path: '体修', expBonus: 0.1, powerBonus: 0.16, desc: '不动如山,守御无双。' },
{ id: 't-baihui', name: '百草经', grade: 1, element: '木', path: '丹修', expBonus: 0.13, powerBonus: 0.06, desc: '识百草而济世人。' },
{ id: 't-zhenyu', name: '阵纹天书', grade: 2, element: '土', path: '阵修', expBonus: 0.16, powerBonus: 0.12, desc: '经纬天地,伏杀千里。' },
{ id: 't-hanyu', name: '寒玉心法', grade: 2, element: '水', path: '剑修', expBonus: 0.15, powerBonus: 0.18, desc: '心如寒玉,剑出无痕。' },
{ id: 't-liehuo', name: '烈火焚空诀', grade: 2, element: '火', path: '剑修', expBonus: 0.16, powerBonus: 0.24, desc: '烈焰焚空,寸草不留。' },
{ id: 't-wanjian', name: '万剑归一诀', grade: 2, element: '金', path: '剑修', expBonus: 0.14, powerBonus: 0.28, desc: '万道剑影,尽归一念。' },
{ id: 't-yushen', name: '驭兽真解', grade: 2, element: '木', path: '御灵', expBonus: 0.15, powerBonus: 0.14, desc: '万物有灵,皆可为兵。' },
{ id: 't-geling', name: '化灵经', grade: 3, element: '水', path: '符修', expBonus: 0.2, powerBonus: 0.22, desc: '墨落成灵,一符镇幽。' },
{ id: 't-jiuxiao', name: '九霄龙吟诀', grade: 3, element: '金', path: '剑修', expBonus: 0.18, powerBonus: 0.35, desc: '龙吟九霄,一剑开天。' },
{ id: 't-tianya', name: '天涯孤鸿法', grade: 3, element: '火', path: '符修', expBonus: 0.21, powerBonus: 0.25, desc: '孤鸿照影,天涯为路。' },
{ id: 't-qiankun', name: '乾坤七星阵', grade: 4, element: '土', path: '阵修', expBonus: 0.25, powerBonus: 0.3, desc: '七星成阵,乾坤为牢。' },
{ id: 't-xiantian', name: '先天混元功', grade: 4, element: '木', path: '体修', expBonus: 0.28, powerBonus: 0.38, desc: '混元一气,万法不侵。' }
]
export function techniqueById(id: string | undefined): TechniqueDef | null {
if (!id) return null
return TECHNIQUES.find((t) => t.id === id) ?? null
}
export const TECHNIQUES_MARKET_POOL = TECHNIQUES.filter((t) => t.grade <= 3)
+31
View File
@@ -0,0 +1,31 @@
export interface TraitDef {
id: string
name: string
desc: string
expBonus?: number
breakBonus?: number
windBonus?: number
charmBonus?: number
danger: number
priceMult?: number
}
export const TRAITS: Record<string, TraitDef> = {
tiangan: { id: 'tiangan', name: '坚毅', desc: '突破成功率提升,突破失败代价减轻。', breakBonus: 0.06, expBonus: 0, danger: 0 },
jizao: { id: 'jizao', name: '急躁', desc: '突破更鲁莽,失败代价更高,但修炼略快。', expBonus: 0.08, danger: 0.7 },
shensui: { id: 'shensui', name: '沉静', desc: '心性稳重,突破不轻易失败。', breakBonus: 0.05, danger: 0 },
xinheng: { id: 'xinheng', name: '性狠', desc: '战时战力提升,掳掠资源更多。', windBonus: 0.06, danger: 0.2 },
haoxiao: { id: 'haoxiao', name: '嗜酒', desc: '心境易起伏,但与人亲近。', charmBonus: 0.04, danger: 0.15 },
xinnian: { id: 'xinnian', name: '多情', desc: '魅力提升,联姻时好感度更高。', charmBonus: 0.08, danger: 0.1 },
zisheng: { id: 'zisheng', name: '自律', desc: '修为获取速度提升。', expBonus: 0.1, danger: 0 },
lijian: { id: 'lijian', name: '利己', desc: '贸易买卖价格更优。', priceMult: 0.05, danger: 0.3 },
keji: { id: 'keji', name: '克己', desc: '重伤时不易陨落。', breakBonus: 0.02, danger: 0 },
youmo: { id: 'youmo', name: '潇洒', desc: '风波中更易化险为夷。', windBonus: 0.05, danger: 0 },
guguai: { id: 'guguai', name: '古怪', desc: '行为难以捉摸,战力微增。', windBonus: 0.08, danger: 0.3 },
chiyi: { id: 'chiyi', name: '斥医', desc: '不喜丹药,突破失败易重伤。', breakBonus: -0.04, danger: 0.6 },
jingkan: { id: 'jingkan', name: '精研', desc: '闭关修炼效率更高。', expBonus: 0.12, danger: 0 },
haoyi: { id: 'haoyi', name: '豪义', desc: '声望获取更多。', charmBonus: 0.06, danger: 0.2 },
yiqi: { id: 'yiqi', name: '易喜', desc: '脾气如火,战斗极勇。', windBonus: 0.1, danger: 0.5 }
}
export const TRAIT_POOL = Object.keys(TRAITS)
+192
View File
@@ -0,0 +1,192 @@
import { Character, GameState, NpcFamilyState, RealmMajor } from '../types/domain'
import { Rng, seedToRng } from '../core/rng'
import { randomSurname, MALE_GIVEN, FEMALE_GIVEN } from '../core/names'
import { newCharacter } from './pcgen'
import { NPCS } from '../data/npcs'
import { World } from './world'
export interface NewGameOptions {
seed: string
surname: string
familyName: string
motto: string
difficulty: 'easy' | 'normal' | 'hard'
}
export function createWorldState(opts: NewGameOptions): GameState {
const rng = new Rng(seedToRng(opts.seed))
const surname = opts.surname.trim() || randomSurname(rng)
const familyName = opts.familyName.trim() || `${surname}`
const diff = opts.difficulty
const stones = diff === 'easy' ? 1200 : diff === 'normal' ? 800 : 550
const npcStrength = diff === 'easy' ? 0.9 : diff === 'normal' ? 1 : 1.15
const state: GameState = {
schemaVersion: 1,
seed: opts.seed,
rng: rng.getState(),
year: 1,
month: 1,
seq: 0,
family: {
surname,
name: familyName,
motto: opts.motto.trim() || '耕读传家,术法继世',
crest: '#c9a227',
estate: '青云庄',
yearFounded: 1,
generation: 1,
reputation: 5,
stones,
inventory: {
lingcao: 60,
lingkuang: 30,
beastcore: 0,
'pill-qiyuan': 2,
'pill-ningyuan': 1
},
buildings: { lingtian: 1, zongci: 1 },
techniques: ['t-qinglian', 't-houtu'],
missionIds: [],
headId: '',
difficulty: diff,
flag: { priceMult: 1, tenants: 0, headBless: 0 }
},
members: {},
npcFamilies: Object.fromEntries(
NPCS.map((n) => [
n.id,
{
id: n.id,
name: n.name,
region: n.region,
power: Math.round(n.initialPower * npcStrength),
relation: 0,
allied: false,
raidCount: 0
} as NpcFamilyState
])
),
missions: [],
chronicle: [],
battles: [],
eventQueue: [],
completedEvents: [],
flags: {},
totalTicks: 0
}
const w = new World(state, [])
const male1: string = rng.pick(MALE_GIVEN)
const female1: string = rng.pick(FEMALE_GIVEN)
const head: Character = newCharacter(rng, {
name: `${surname}${male1}`,
gender: 'male',
generation: 1,
bornYear: 1 - 35,
age: 35,
realm: { major: 'qi', minor: 4 },
isHead: true,
isFounder: true,
fortuneBase: 7
})
head.realmProgress = 40
head.techniqueId = 't-qinglian'
head.traits = ['tiangan', 'shensui']
const wife: Character = newCharacter(rng, {
name: `${surname}${female1}`,
gender: 'female',
generation: 1,
bornYear: 1 - 33,
age: 33,
realm: { major: 'qi', minor: 2 },
fortuneBase: 6
})
wife.realmProgress = 55
head.spouseId = 'x2'
wife.spouseId = 'x1'
head.children = ['x3', 'x5']
const elderBrother: Character = newCharacter(rng, {
name: `${surname}${rng.pick(MALE_GIVEN)}`,
gender: 'male',
generation: 2,
bornYear: 1 - 16,
age: 16,
realm: { major: 'qi', minor: 1 },
father: head,
mother: wife
})
elderBrother.realmProgress = 20
elderBrother.techniqueId = 't-houtu'
elderBrother.fatherId = 'x1'
elderBrother.motherId = 'x2'
const sister: Character = newCharacter(rng, {
name: `${surname}${rng.pick(FEMALE_GIVEN)}`,
gender: 'female',
generation: 2,
bornYear: 1 - 12,
age: 12,
realm: { major: 'mortal', minor: 0 },
father: head,
mother: wife
})
sister.fatherId = 'x1'
sister.motherId = 'x2'
const uncle: Character = newCharacter(rng, {
name: `${surname}${rng.pick(MALE_GIVEN)}`,
gender: 'male',
generation: 1,
bornYear: 1 - 45,
age: 45,
realm: { major: 'qi', minor: 6 },
fortuneBase: 6
})
uncle.realmProgress = 30
uncle.techniqueId = 't-houtu'
uncle.traits = ['xinheng', 'shensui']
uncle.id = 'x4'
uncle.spouseHouse = 'sihai王氏'
uncle.children = []
head.id = 'x1'
wife.id = 'x2'
elderBrother.id = 'x3'
sister.id = 'x5'
wife.children = ['x3', 'x5']
state.members = { x1: head, x2: wife, x3: elderBrother, x4: uncle, x5: sister }
state.family.headId = 'x1'
state.seq = 10
w.chronicle('misc', `${surname}氏一族定居山阴,立${familyName}。庄主${head.name},年方三十五。`, head.id, true)
w.log('info', `青云庄立,${familyName}始兴。`)
return state
}
export function findInheritor(world: World): Character | undefined {
const alive = world.aliveMembers()
if (alive.length === 0) return undefined
const head = world.state.members[world.state.family.headId]
const candidates = alive.filter((c) => c.id !== head?.id)
if (candidates.length === 0) return undefined
const byBlood = candidates
.filter((c) => (head && head.children.includes(c.id)) || (c.fatherId === head?.id))
.sort((a, b) => world.ageOf(b) - world.ageOf(a))
if (byBlood.length > 0) return byBlood[0]
const byRealm = [...candidates].sort((a, b) => {
const ra = realmRank(a.realm)
const rb = realmRank(b.realm)
return rb - ra || b.charm - a.charm || world.ageOf(b) - world.ageOf(a)
})
return byRealm[0]
}
function realmRank(realm: { major: RealmMajor; minor: number }): number {
const order: RealmMajor[] = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit']
return order.indexOf(realm.major) * 10 + realm.minor
}
+48
View File
@@ -0,0 +1,48 @@
import { World } from './world'
import { ITEMS } from '../data/items'
export function marketPrice(w: World, itemId: string): number {
const base = ITEMS[itemId]?.basePrice ?? 1
const fam = w.state.family
const mult = typeof fam.flag['priceMult'] === 'number' ? (fam.flag['priceMult'] as number) : 1
const mood = fam.reputation >= 40 ? 1.06 : fam.reputation >= 20 ? 1.02 : 0.98
return Math.max(1, Math.round(base * mult * mood))
}
export function buyItem(w: World, itemId: string, count: number): boolean {
const fam = w.state.family
const total = marketPrice(w, itemId) * count
if (total > fam.stones) return false
fam.stones -= total
fam.inventory[itemId] = (fam.inventory[itemId] ?? 0) + count
return true
}
export function sellItem(w: World, itemId: string, count: number): boolean {
const fam = w.state.family
const have = fam.inventory[itemId] ?? 0
if (have < count) return false
fam.inventory[itemId] = have - count
fam.stones += marketPrice(w, itemId) * count
return true
}
export function buyTechnique(w: World, techId: string, price: number): boolean {
const fam = w.state.family
if (fam.techniques.includes(techId)) return false
if (fam.stones < price) return false
fam.stones -= price
fam.techniques.push(techId)
return true
}
export function techniquePrice(techId: string): number {
const grade = TECH_GRADE_BASE[techId] ?? 200
return grade
}
import { TECHNIQUES } from '../data/techniques'
const TECH_GRADE_BASE: Record<string, number> = Object.fromEntries(
TECHNIQUES.map((t) => [t.id, [120, 300, 700, 1600, 3600][t.grade] ?? 300])
)
+120
View File
@@ -0,0 +1,120 @@
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'
export function rollRoots(rng: Rng, parents?: { m?: Character; f?: Character }): { grade: number; primary: Element; secondary: Element[] } {
let grade: number
if (parents && parents.m && parents.f) {
const mix = (parents.m.roots.grade + parents.f.roots.grade) / 2
const roll = rng.next()
if (roll < 0.3) grade = Math.round(mix)
else if (roll < 0.8) grade = Math.round(mix) + 1
else grade = Math.round(mix) - 1
if (rng.chance(0.15)) grade = Math.min(5, grade + 2)
} else {
const total = Object.entries(ROOT_GRADES).reduce((s, [k, v]) => s + v.drawWeight, 0)
let r = rng.next() * total
grade = 1
for (const [k, v] of Object.entries(ROOT_GRADES)) {
r -= v.drawWeight
if (r <= 0) {
grade = Number(k)
break
}
}
}
grade = Math.max(0, Math.min(5, grade))
const primaryCandidate = parents && (parents.m || parents.f)
? [parents.m!.roots.primary, parents.f!.roots.primary]
: ELEMENT_LIST
const primary = rng.pick(primaryCandidate)
const secondaryCount = grade >= 3 ? rng.int(1, 2) : grade === 2 ? rng.int(0, 1) : 0
const rest = ELEMENT_LIST.filter((e) => e !== primary)
const secondary = rng.shuffle(rest).slice(0, secondaryCount)
return { grade, primary, secondary }
}
export function rollPersonality(rng: Rng): string[] {
const n = rng.chance(0.5) ? 2 : 1
return rng.shuffle([...TRAIT_POOL]).slice(0, n)
}
export function rollAttributes(rng: Rng, base: number, variance: number): number {
const v = Math.round(base + rng.between(-variance, variance))
return Math.max(1, Math.min(10, v))
}
export function calcLifespan(major: RealmMajor, physique: number): number {
const base = MAJORS[major].lifespan
return Math.round(base * (0.9 + physique * 0.02))
}
export function newCharacter(
rng: Rng,
opts: {
name: string
gender: Gender
generation: number
bornYear: number
age: number
mother?: Character
father?: Character
realm?: Realm
isHead?: boolean
isFounder?: boolean
fortuneBase?: number
}
): Character {
const realm: Realm = opts.realm ?? { major: 'mortal', minor: 0 }
const roots = rollRoots(rng, opts.father || opts.mother ? { m: opts.father, f: opts.mother } : undefined)
const levelBonus = MAJORS[realm.major as RealmMajor].minorLayers > 0 ? realm.minor * 0.4 : 0
const perception = rollAttributes(rng, 4.5 + roots.grade * 0.8 + levelBonus * 0.25, 1.8)
const physique = rollAttributes(rng, 4 + roots.grade * 0.5 + levelBonus * 0.3, 1.8)
const mind = rollAttributes(rng, 4.5 + roots.grade * 0.3, 1.8)
const charm = rollAttributes(rng, 4.5, 2.2)
const fortune = rollAttributes(rng, (opts.fortuneBase ?? 5) + roots.grade * 0.4, 2.2)
return {
id: '',
name: opts.name,
gender: opts.gender,
generation: opts.generation,
children: [],
bornYear: opts.bornYear,
age: opts.age,
realm,
realmProgress: 0,
roots,
perception,
physique,
mind,
charm,
fortune,
traits: rollPersonality(rng),
state: 'idle',
health: 100,
alive: true,
isHead: opts.isHead,
isFounder: opts.isFounder
}
}
export function traitBonuses(character: Character): { exp: number; breakBonus: number; windBonus: number; charmBonus: number; priceMult: number } {
let exp = 0
let breakBonus = 0
let windBonus = 0
let charmBonus = 0
let priceMult = 0
for (const t of character.traits) {
const def = TRAITS[t]
if (!def) continue
exp += def.expBonus ?? 0
breakBonus += def.breakBonus ?? 0
windBonus += def.windBonus ?? 0
charmBonus += def.charmBonus ?? 0
priceMult += def.priceMult ?? 0
}
return { exp: 1 + exp, breakBonus, windBonus, charmBonus, priceMult }
}
+213
View File
@@ -0,0 +1,213 @@
import { World } from '../world'
import { Character, BattleLog, NpcFamilyState } from '../../types/domain'
import { basePower, describeRealm } from '../../data/realms'
import { ARTIFACT_POWER } from '../../data/items'
import { techniqueById } from '../../data/techniques'
import { EnemyDef, LootDef } from '../../data/secrets'
import { TECHNIQUES } from '../../data/techniques'
import { traitBonuses } from '../pcgen'
import { npcById } from '../../data/npcs'
export function combatPowerOf(w: World, c: Character): number {
if (!c.alive) return 0
const base = basePower(c.realm)
const stat = 1 + (c.perception + c.physique) / 32
const tech = techniqueById(c.techniqueId)
const techBonus = tech ? 1 + tech.powerBonus : 1
const equip = c.equipment ? 1 + (ARTIFACT_POWER[c.equipment] ?? 0) : 1
const trait = 1 + traitBonuses(c).windBonus
const health = 0.5 + 0.5 * (c.health / 100)
return round1(base * stat * techBonus * equip * trait * health)
}
function round1(n: number): number {
return Math.round(n * 10) / 10
}
export function enemyPowerOf(enemy: EnemyDef, risk: number): number {
const base = basePower({ major: enemy.realm, minor: 2 })
return Math.round(base * enemy.strength * (1.05 + risk * 0.55))
}
export function npcPowerOf(npc: NpcFamilyState): number {
return Math.round(npc.power)
}
export interface EncounterResult {
win: boolean
draw: boolean
lines: string[]
loot?: Record<string, number>
losses: string[]
}
const WIN_DESC = [
'你我咬紧牙关,剑光铺天盖地,那厮节节败退。',
'阵中爆出一声大喝,众人齐攻要害,对方哀嚎退走。',
'硬撼三合,杀得对方胆寒,丢下敌辎拽着尾巴逃了。'
]
const LOSE_DESC = [
'对方攻势如潮,我方左支右绌,且战且退。',
'眼睁睁瞧着族中子弟咳血倒地,只得弃了阵脚。',
'护山大阵差点被轰裂,残兵败将忍着羞辱撤回。',
'突袭来得隐秘,伤亡不小,幸好退路还在。'
]
const DRAW_DESC = [
'杀了个天昏地暗,双方均伤,各自罢手。',
'僵持半晌,天入暮色,双方收阵戒备而退。'
]
export function resolveEncounter(
w: World,
opts: {
title: string
enemy: EnemyDef
risk: number
team: Character[]
kind: BattleLog['kind']
year: number
month: number
}
): EncounterResult {
let team = 0
for (const c of opts.team) team += combatPowerOf(w, c)
const enemy = enemyPowerOf(opts.enemy, opts.risk)
const jitter = w.rng.between(0.88, 1.12)
const teamFinal = Math.round(team * jitter)
const roll = w.rng.next()
const win = teamFinal >= enemy * 1.08
const lose = teamFinal < enemy * 0.82
const draw = !win && !lose
const year = opts.year
const month = opts.month
const names = opts.team.map((c) => c.name).join('、')
const lines: string[] = []
lines.push(`—— ${opts.title} ——`)
lines.push(`${year}${month}月,${names}遇上了【${opts.enemy.name}】。(敌势 ${enemy},我阵 ${teamFinal}`)
if (win) {
lines.push(`首战告捷:${w.rng.pick(WIN_DESC)}`)
} else if (lose) {
lines.push(`败象已成:${w.rng.pick(LOSE_DESC)}`)
} else {
lines.push(`来回缠斗:${w.rng.pick(DRAW_DESC)}`)
}
const loss: string[] = []
for (const c of opts.team) {
if (!c.alive || c.state === 'wounded') continue
const severity = w.rng.next()
if (!win) {
if (severity < 0.1 && w.rng.chance(opts.risk * 0.08 + 0.02)) {
c.alive = false
c.deathYear = year
c.deathCause = `战殁于${opts.enemy.name}之手`
loss.push(`${c.name} 陨落`)
w.chronicle('death', `${c.name} 战殁于${opts.enemy.name},一身所学俱付尘烟。`, c.id, true)
} else if (severity < 0.45) {
c.health = Math.max(1, c.health - 40 - w.rng.int(0, 25))
c.state = 'wounded'
loss.push(`${c.name} 重伤`)
}
} else if (w.rng.chance(0.12)) {
c.health = Math.max(1, c.health - 20 - w.rng.int(0, 15))
if (c.health < 35) c.state = 'wounded'
loss.push(`${c.name} 轻伤`)
}
}
let loot: Record<string, number> | undefined
if (win) {
loot = {}
const res = opts.risk > 0.8 ? { lingkuang: [20, 60], lingcao: [15, 40] } : { lingcao: [10, 30], lingkuang: [5, 20] }
for (const [k, r] of Object.entries(res)) {
const v = w.rng.int(r[0], r[1])
loot[k] = v
w.state.family.inventory[k] = (w.state.family.inventory[k] ?? 0) + v
}
lines.push(`此战缴获:${Object.entries(loot).map(([k, v]) => `${itemName(k)} ×${v}`).join('、')}`)
}
const result: EncounterResult = { win, draw, lines, loot, losses: loss }
const log: BattleLog = {
id: w.seq(),
year,
month,
title: opts.title,
kind: opts.kind,
lines,
winner: win ? 'player' : draw ? 'none' : 'enemy',
loot,
losses: loss
}
w.battle(log)
return result
}
function itemName(id: string): string {
const names: Record<string, string> = {
lingcao: '灵草',
lingkuang: '灵矿',
beastcore: '兽核',
stones: '灵石'
}
return names[id] ?? id
}
export function resolveRaid(
w: World,
npcId: string,
team: Character[]
): EncounterResult {
const npc = w.state.npcFamilies[npcId]
const def = npcById(npcId)
const enemy: EnemyDef = {
id: npcId,
name: `${npc.name}的劫掠队`,
realm: def.leaderRealm,
strength: 0.9,
icon: '袭',
desc: def.desc
}
const risk = 0.55
const res = resolveEncounter(w, {
title: `${npc.name}来袭!`,
enemy,
risk,
team,
kind: 'war',
year: w.state.year,
month: w.state.month
})
if (res.win) {
npc.relation = Math.min(60, npc.relation + 25)
w.state.family.reputation += 6
w.chronicle('battle', `击退${npc.name}的犯境,家族声威大振。`, undefined, true)
} else if (!res.draw) {
npc.relation = Math.max(-100, npc.relation - 15)
const st = w.state.family.stones
const lostFew = Math.min(st, Math.round(st * 0.25))
w.state.family.stones -= lostFew
if (lostFew > 0) w.log('bad', `宗族仓廪被劫掠,损失灵石 ${lostFew}`)
}
return res
}
export function rollWarbooty(w: World, loot: LootDef): Record<string, number> {
const result: Record<string, number> = {}
for (const [k, r] of Object.entries(loot.resources)) {
const v = w.rng.int(r[0], r[1])
result[k] = v
w.state.family.inventory[k] = (w.state.family.inventory[k] ?? 0) + v
}
if (loot.artifactChance && w.rng.chance(loot.artifactChance)) {
const pool = ['weapon-fan', 'weapon-qi', 'weapon-ling']
const a = w.rng.pick(pool)
w.state.family.inventory[a] = (w.state.family.inventory[a] ?? 0) + 1
result[a] = 1
}
if (loot.techniqueChance && w.rng.chance(loot.techniqueChance)) {
const t = w.rng.pick(TECHNIQUES)
w.state.family.techniques.push(t.id)
result['tech'] = 1
}
return result
}
@@ -0,0 +1,134 @@
import { World } from '../world'
import { Character } from '../../types/domain'
import { ROOT_GRADES } from '../../data/elements'
import { masteryRateOfMajor } from '../../data/pacing'
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'
export function monthlyRate(w: World, c: Character): number {
const st = w.state
let rate = 1
rate *= 0.5 + c.perception * 0.1
rate *= ROOT_GRADES[c.roots.grade]?.expBonus ?? 0.5
const tech = techniqueById(c.techniqueId)
if (tech && c.realm.major !== 'mortal') rate *= 1 + tech.expBonus
else if (c.realm.major !== 'mortal') rate *= 0.65
const buildings = st.family.buildings
const juling = buildings['juling'] ?? 0
rate *= 1 + juling * 0.05
if (c.state === 'meditation') {
rate *= 1.35
const dongfu = buildings['dongfu'] ?? 0
rate *= 1 + dongfu * 0.08
} else if (c.state === 'expedition') {
rate *= 0.25
} else if (c.state === 'wounded') {
rate *= c.health > 40 ? 0.5 : 0.15
}
if (w.ageOf(c) < 8) rate *= 0.4
if (w.ageOf(c) > 55) rate *= 0.7
rate *= masteryRateOfMajor(c.realm.major)
return rate
}
export function cultivationTick(w: World): void {
for (const c of Object.values(w.state.members)) {
if (!c.alive) continue
const rate = monthlyRate(w, c)
if (rate <= 0) continue
c.realmProgress = Math.min(100, c.realmProgress + rate)
if (c.realmProgress >= 100) {
const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999)
if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) {
resolveBreakthrough(w, c, 0)
}
}
}
}
export function perAttemptChance(w: World, c: Character): number {
const base = breakthroughBaseChance(c.realm)
const mind = c.mind * 0.008
const traits = traitBonuses(c)
const headMind = (w.state.members[w.state.family.headId]?.mind ?? 5) * 0.004
const healthMod = c.health > 60 ? 0.04 : -0.06
const bless = w.state.family.flag['headBless'] ? 0.03 : 0
return Math.min(0.95, Math.max(0.02, base + mind + traits.breakBonus + headMind + healthMod + bless))
}
export function resolveBreakthrough(w: World, c: Character, boost: number): void {
if (!c.alive || c.realmProgress < 100) return
const next = nextRealm(c.realm)
if (!next) return
const p = Math.min(0.95, Math.max(0.05, perAttemptChance(w, c) + boost))
c.lastBreakthroughAttempt = w.state.year * 12 + w.state.month
if (w.rng.chance(p)) {
const majorJump = next.major !== c.realm.major
c.realm = next
c.realmProgress = 0
if (majorJump) {
c.health = 100
}
const desc = describeRealm(next)
w.chronicle('breakthrough', `${c.name} 突破至【${desc}】。`, c.id, majorJump)
w.log('good', `${c.name} 突破到 ${desc}`)
if (next.major === 'spirit') {
w.chronicle('breakthrough', `华夏震惊:${c.name} 踏入化神之列。`, c.id, true)
}
} else {
c.health = Math.max(1, c.health - 8 - w.rng.int(0, 10))
let log = `${c.name} 冲击瓶颈失败,灵力紊乱受创。`
if (c.traits.includes('jizao') || c.traits.includes('yiqi')) {
c.health = Math.max(1, c.health - 14)
log = `${c.name} 强行突破遭反噬,气息受创。`
}
const majorIdx = MAJOR_ORDER.indexOf(c.realm.major)
if (majorIdx >= 3 && w.rng.chance(realmDeathChance(c.realm, c.mind))) {
c.alive = false
c.deathYear = w.state.year
c.deathCause = '突破走火'
w.chronicle('death', `${c.name} 妄图冲击瓶颈,走火入魔而陨。`, c.id, true)
w.log('bad', `${c.name} 突破走火,当场陨落。`)
return
}
if (c.health < 20) c.state = 'wounded'
const loss = 40 + w.rng.int(0, 25)
c.realmProgress = Math.max(0, Math.min(95, 100 - loss - boost * 60))
w.log('bad', log)
}
}
export function produceOffspring(
w: World,
opts: {
father: Character | null
mother: Character | null
generation: number
bornYear: number
surname: string
spouseHouse?: string
}
): Character {
const rng = w.rng
const newborn = newCharacter(rng, {
name: `${opts.surname}${rng.pick(rng.chance(0.52) ? MALE_GIVEN : FEMALE_GIVEN)}`,
gender: rng.chance(0.52) ? 'male' : 'female',
generation: opts.generation,
bornYear: opts.bornYear,
age: 0,
realm: { major: 'mortal', minor: 0 },
father: opts.father ?? undefined,
mother: opts.mother ?? undefined
})
if (opts.spouseHouse) newborn.spouseHouse = opts.spouseHouse
return newborn
}
export function lifespanCheckPoint(w: World, c: Character): number {
return lifespanOf(w, c)
}
@@ -0,0 +1,93 @@
import { World } from '../world'
import { npcById } from '../../data/npcs'
import { findEvent, fire } from './events'
export function diplomacyTick(w: World): void {
const s = w.state
const drift = w.rng.chance(0.15)
for (const npc of Object.values(s.npcFamilies)) {
if (drift) {
if (npc.relation > 0) npc.relation -= 1
else if (npc.relation < 0) npc.relation += 1
}
if (npc.relation < -50) {
const last = (w.state.family.flag[`raidCD-${npc.id}`] as number | undefined) ?? 0
if (s.year - last >= 2 && w.rng.chance(0.045)) {
fire(w, `ev-raid-${npc.id}`)
}
}
}
}
export function yearGrowth(w: World): void {
const s = w.state
for (const npc of Object.values(s.npcFamilies)) {
const def = npcById(npc.id)
const [a, b] = def.powerGrowth
npc.power += w.rng.int(a, b)
}
}
export function npcRelation(w: World, npcId: string): number {
return w.state.npcFamilies[npcId]?.relation ?? 0
}
export function giftNpc(w: World, npcId: string, stones: number): boolean {
const fam = w.state.family
if (stones <= 0 || fam.stones < stones) return false
fam.stones -= stones
const npc = w.state.npcFamilies[npcId]
const gain = Math.max(1, Math.round(stones / 12))
npc.relation = Math.min(100, npc.relation + gain)
w.log('info', `厚礼送往${npc.name},两家关系 +${gain}`)
return true
}
export function makePeace(w: World, npcId: string): boolean {
const fam = w.state.family
const npc = w.state.npcFamilies[npcId]
if (fam.stones < 200) return false
fam.stones -= 200
npc.relation = Math.max(npc.relation + 35, 0)
w.chronicle('diplomacy', `${npc.name}立下和约,两家罢兵互市。`, undefined, true)
w.log('good', `${npc.name}言和。`)
return true
}
export function marryNpcFamily(w: World, npcId: string): boolean {
const s = w.state
const fam = s.family
const npc = s.npcFamilies[npcId]
if (!npc || npc.relation < 25) return false
const eligible = w
.aliveMembers()
.filter((c) => w.ageOf(c) >= 18 && w.ageOf(c) <= 42 && c.state !== 'expedition')
.filter((c) => !c.spouseId)
if (eligible.length === 0) return false
const npcDef = npcById(npcId)
const candidate = w.rng.pick(eligible)
candidate.spouseHouse = npc.name
npc.relation += 20
fam.reputation += 4
w.chronicle('marriage', `${candidate.name}${npc.name}联姻,两家绸缪通好。`, candidate.id, true)
w.log('good', `${candidate.name}${npc.name}联姻成功!每年或降麟儿。`)
return true
}
export function arrangeWedding(w: World, aId: string, bId: string): boolean {
const a = w.memberById(aId)
const b = w.memberById(bId)
if (!a.alive || !b.alive || a.spouseId || b.spouseId) return false
if (a.gender === b.gender) return false
if (a.fatherId === b.fatherId && a.fatherId) return false
a.spouseId = b.id
b.spouseId = a.id
const aAge = w.ageOf(a)
const bAge = w.ageOf(b)
if (w.state.family.flag['tenants']) {
w.state.family.reputation += 1
}
w.chronicle('marriage', `${a.name}${aAge})与${b.name}${bAge})拜堂成亲。`, a.id, true)
w.log('good', `${a.name}${b.name} 结为连理。`)
return true
}
+290
View File
@@ -0,0 +1,290 @@
import { World } from '../world'
import { Character } from '../../types/domain'
import { Cond, EffectDef, EventDef, EVENTS, MemberEffect } from '../../data/events'
import { MAJOR_ORDER } from '../../data/realms'
import { TECHNIQUES } from '../../data/techniques'
import { MISSIONS } from '../../data/secrets'
import { npcById } from '../../data/npcs'
import { resolveRaid } from './combat'
import { sendMission } from './missions'
const ALL_EVENTS: EventDef[] = [...EVENTS]
export function findEvent(id: string): EventDef | undefined {
return ALL_EVENTS.find((e) => e.id === id) ?? dynamicEventFor(id)
}
export function dynamicEventFor(id: string): EventDef | undefined {
if (id.startsWith('ev-raid-')) {
const npcId = id.replace('ev-raid-', '')
const npc = npcById(npcId)
return {
id,
name: `${npc.name}来犯`,
category: 'major',
weight: 0,
text: `${npc.name}与贵庄积怨已久,如今撕破脸面,遣来劫掠队围门叫战。`,
options: [
{ label: '迎战!', hint: '大战一场,胜则大利,败则伤财', eff: { raid: { npcId } } },
{ label: '割地求和', hint: '灵石-250,关系+25', eff: { res: { stones: -250 }, relation: { [npcId]: 25 }, flag: { [npcId]: 'paid' } } },
{ label: '先议和缓兵', hint: '关系+10', eff: { relation: { [npcId]: 10 } } }
]
}
}
return undefined
}
export function matchesCond(w: World, cond?: Cond): boolean {
if (!cond) return true
const s = w.state
const fam = s.family
const alive = w.aliveMembers()
const adults = alive.filter((c) => w.ageOf(c) >= 16)
const head = s.members[fam.headId]
if (cond.all && !cond.all.every((c) => matchesCond(w, c))) return false
if (cond.any && !cond.any.some((c) => matchesCond(w, c))) return false
if (cond.not && matchesCond(w, cond.not)) return false
if (cond.minYear !== undefined && s.year < cond.minYear) return false
if (cond.minGeneration !== undefined && fam.generation < cond.minGeneration) return false
if (cond.minHeadRealm !== undefined && (!head || MAJOR_ORDER.indexOf(head.realm.major) < MAJOR_ORDER.indexOf(cond.minHeadRealm as never))) return false
if (cond.minBuilding && (fam.buildings[cond.minBuilding.id] ?? 0) < cond.minBuilding.level) return false
if (cond.minRep !== undefined && fam.reputation < cond.minRep) return false
if (cond.maxRep !== undefined && fam.reputation > cond.maxRep) return false
if (cond.minResource && (fam.inventory[cond.minResource.id] ?? 0) < cond.minResource.n) return false
if (cond.minAdult !== undefined && adults.length < cond.minAdult) return false
if (cond.maxAdult !== undefined && adults.length > cond.maxAdult) return false
if (cond.minMembers !== undefined && alive.length < cond.minMembers) return false
if (cond.eligibleAdult !== undefined) {
const eligible = adults.filter((c) => !c.spouseId && !c.spouseHouse)
if (eligible.length < cond.eligibleAdult) return false
}
if (cond.hasMeditation && !alive.some((c) => c.state === 'meditation')) return false
if (cond.relation) {
const r = s.npcFamilies[cond.relation.npcId]?.relation ?? 0
if (cond.relation.gt !== undefined && r <= cond.relation.gt) return false
if (cond.relation.lt !== undefined && r >= cond.relation.lt) return false
}
if (cond.flag) {
const v = fam.flag[cond.flag.key]
if (v !== cond.flag.eq) return false
}
if (cond.minTechCount !== undefined && fam.techniques.length < cond.minTechCount) return false
return true
}
export function eventRoll(w: World): void {
const s = w.state
if (s.pendingEvent || s.eventQueue.length > 0) {
if (!s.pendingEvent && s.eventQueue.length > 0) {
fire(w, s.eventQueue.shift()!)
}
return
}
const roll = w.rng.next()
const category: 'daily' | 'major' | 'fate' | undefined = roll < 0.5 ? 'daily' : roll < 0.78 ? 'major' : roll < 0.86 ? 'fate' : undefined
if (!category) return
const candidates = ALL_EVENTS.filter(
(e) =>
e.category === category &&
!(e.once && s.completedEvents.includes(e.id)) &&
matchesCond(w, e.cond)
)
if (candidates.length === 0) return
const total = candidates.reduce((a, e) => a + e.weight, 0)
let r = w.rng.next() * total
for (const e of candidates) {
r -= e.weight
if (r <= 0) {
fire(w, e.id)
return
}
}
}
export function fire(w: World, id: string): void {
w.state.pendingEvent = id
w.pendingEvent(id)
}
export function applyEventChoice(w: World, eventId: string, optionIdx: number): void {
const s = w.state
const def = findEvent(eventId)
if (!def) {
s.pendingEvent = undefined
return
}
const opt = def.options[optionIdx]
if (opt) {
applyEffect(w, opt.eff)
if (def.once && !s.completedEvents.includes(def.id)) s.completedEvents.push(def.id)
}
s.pendingEvent = undefined
}
// ---------------- effects ----------------
function pickMember(w: World, spec: MemberEffect): Character | Character[] {
const alive = w.aliveMembers()
if (alive.length === 0) return []
const byTarget = (t: string): Character[] => {
const sorted = [...alive]
switch (t) {
case 'random':
return [w.rng.pick(sorted)]
case 'head': {
const h = w.state.members[w.state.family.headId]
return h && h.alive ? [h] : []
}
case 'youngest':
return [sorted.sort((a, b) => w.ageOf(a) - w.ageOf(b))[0]]
case 'oldest':
return [sorted.sort((a, b) => w.ageOf(b) - w.ageOf(a))[0]]
case 'highestPerception':
return [sorted.sort((a, b) => b.perception - a.perception)[0]]
case 'highestPower':
return [sorted.sort((a, b) => rankPower(w, b) - rankPower(w, a))[0]]
case 'highestFortune':
return [sorted.sort((a, b) => b.fortune - a.fortune)[0]]
case 'all':
return sorted
default:
return [w.rng.pick(sorted)]
}
}
return byTarget(spec.target)
}
export function rankPower(w: World, c: Character): number {
const order = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit']
return order.indexOf(c.realm.major) * 10 + c.realm.minor
}
function applyEffect(w: World, eff: EffectDef): void {
const s = w.state
const fam = s.family
if (eff.res) {
for (const [k, v] of Object.entries(eff.res)) {
if (k === 'stones') fam.stones += v
else fam.inventory[k] = Math.max(0, (fam.inventory[k] ?? 0) + v)
}
}
if (eff.pillGain) {
for (const [k, v] of Object.entries(eff.pillGain)) fam.inventory[k] = (fam.inventory[k] ?? 0) + v
}
if (eff.rep) {
fam.reputation += eff.rep
if (Math.abs(eff.rep) >= 4) w.log(eff.rep > 0 ? 'good' : 'bad', `家族声望${eff.rep > 0 ? '上升' : '下跌'}${Math.abs(eff.rep)}`)
}
if (eff.relation) {
for (const [k, v] of Object.entries(eff.relation)) {
const npc = s.npcFamilies[k]
if (npc) npc.relation = Math.max(-100, Math.min(100, npc.relation + v))
}
}
if (eff.addBuilding && !fam.buildings[eff.addBuilding]) {
fam.buildings[eff.addBuilding] = 1
}
if (eff.flag) {
Object.assign(fam.flag, eff.flag)
}
if (eff.techniqueChance && w.rng.chance(eff.techniqueChance)) {
const t = w.rng.pick(TECHNIQUES)
if (!fam.techniques.includes(t.id)) {
fam.techniques.push(t.id)
w.log('good', `得《${t.name}》残篇,录入藏书阁。`)
}
}
if (eff.artifactChance && w.rng.chance(eff.artifactChance)) {
const a = w.rng.pick(['weapon-fan', 'weapon-qi', 'weapon-ling'])
fam.inventory[a] = (fam.inventory[a] ?? 0) + 1
w.log('good', '库中多了一件法器。')
}
if (eff.addTech) {
if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech)
}
if (eff.memberBy) {
const targets = pickMember(w, eff.memberBy)
const by = eff.memberBy.by
const n = eff.memberBy.n ?? 1
const list = Array.isArray(targets) ? targets : [targets]
for (const c of list) {
if (!c.alive) continue
switch (by) {
case 'exp':
c.realmProgress = Math.min(100, c.realmProgress + n)
w.log('info', `${c.name} 感悟顿生,修为精进。`)
break
case 'wound':
c.health = Math.max(1, c.health - 20 - n)
if (c.health < 35) c.state = 'wounded'
w.log('bad', `${c.name} 因此事负伤。`)
break
case 'heal':
c.health = Math.min(100, c.health + 20)
break
case 'breakthrough':
c.realmProgress = 100
break
case 'fatal': {
if (w.rng.chance(0.35)) {
c.alive = false
c.deathYear = s.year
c.deathCause = '遭遇不测'
w.chronicle('death', `${c.name} 突遭不测,殒命于家宅之内。`, c.id, true)
} else {
c.health = Math.max(1, c.health - 60)
c.state = 'wounded'
}
break
}
case 'repGain':
fam.reputation += 2
break
case 'inspire':
c.realmProgress = Math.min(100, c.realmProgress + n)
break
case 'loot':
c.fortune = Math.min(12, c.fortune + n)
break
case 'madness':
c.mind = Math.max(1, c.mind - 1)
c.health = Math.max(30, c.health - 10)
break
case 'genius':
c.perception = Math.min(10, c.perception + 1)
c.mind = Math.min(10, c.mind + 1)
break
}
}
}
if (eff.mission) {
const def = MISSIONS.find((m) => m.id === eff.mission)
if (def) {
const squad = w
.aliveMembers()
.filter((c) => w.ageOf(c) >= 16 && c.state !== 'expedition' && c.realm.major !== 'mortal')
.sort((a, b) => rankPower(w, b) - rankPower(w, a))
.slice(0, def.maxMembers)
if (squad.length >= def.minMembers) {
sendMission(w, def.id, squad.map((c) => c.id))
w.log('info', `家族闻讯而动,遣人奔赴【${def.name}】。`)
}
}
}
if (eff.raid) {
const npc = s.npcFamilies[eff.raid.npcId]
if (npc) {
const team = w
.aliveMembers()
.filter((c) => w.ageOf(c) >= 16 && c.state !== 'expedition')
.sort((a, b) => rankPower(w, b) - rankPower(w, a))
.slice(0, 4)
if (team.length > 0) {
resolveRaid(w, eff.raid.npcId, team)
fam.flag[`raidCD-${eff.raid.npcId}`] = s.year
}
}
}
}
@@ -0,0 +1,47 @@
import { 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 {
return calcLifespan(c.realm.major, c.physique)
}
export function deathTick(w: World): void {
for (const c of Object.values(w.state.members)) {
if (!c.alive) continue
const age = w.ageOf(c)
const span = lifespanOf(w, c)
const softCap = span * 0.85
let p = 0
if (age >= span) p = 0.35
else if (age >= softCap) {
const t = (age - softCap) / (span - softCap)
p = Math.min(0.3, Math.pow(t, 5) * 0.9)
}
if (c.health < 30) p += 0.18
if (age < 2) p = Math.max(p, 0.02)
if (p > 0 && w.rng.chance(p)) {
c.alive = false
c.deathYear = w.state.year
const cause = age < 2 ? '幼夭' : c.health < 30 ? '伤势不治' : '寿元将尽'
c.deathCause = cause
w.chronicle('death', `${c.name} 辞世,年 ${age}${age < 2 ? '族人无不痛惜。' : c.health < 30 ? '临终前仍在牵挂家族。' : '族人焚香送别。'}`, c.id, true)
w.log('bad', `${c.name}${age}岁)${cause}`)
}
}
}
export function woundHealTick(w: World): void {
for (const c of Object.values(w.state.members)) {
if (!c.alive) continue
if (c.state === 'wounded') {
c.health = Math.min(100, c.health + 12 + c.physique)
if (c.health >= 95) {
c.state = 'idle'
w.log('info', `${c.name} 伤势痊愈。`)
}
} else if (c.health < 100) {
c.health = Math.min(100, c.health + 2 + c.physique * 0.5)
}
}
}
@@ -0,0 +1,112 @@
import { World } from '../world'
import { Character } from '../../types/domain'
import { produceOffspring } from './cultivation'
import { yearGrowth } from './diplomacy'
import { MALE_GIVEN, FEMALE_GIVEN } from '../../core/names'
export function yearStartMarriage(w: World): void {
yearGrowth(w)
const s = w.state
const fam = s.family
const zongci = fam.buildings['zongci'] ?? 0
const birthBase = 0.34 + zongci * 0.03 + (fam.difficulty === 'easy' ? 0.06 : fam.difficulty === 'hard' ? -0.06 : 0)
const couples = buildCouples(w)
// 族内夫妇
for (const couple of couples.internal) {
const [a, b] = couple
const father = a.gender === 'male' ? a : b
const mother = a.gender === 'male' ? b : a
const fatherAge = w.ageOf(father)
const motherAge = w.ageOf(mother)
if (fatherAge < 18 || fatherAge > 52 || motherAge < 16 || motherAge > 46) continue
const p = birthBase * (0.75 + mother.physique * 0.05)
if (!w.rng.chance(p)) continue
const gen = Math.max(father.generation, mother.generation) + 1
const first = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname })
w.addMember(first, father, mother)
let note = `${fam.surname}氏新增一员,名唤${first.name},生年 ${s.year}`
if (w.rng.chance(0.03)) {
const twin = produceOffspring(w, { father, mother, generation: gen, bornYear: s.year, surname: fam.surname })
w.addMember(twin, father, mother)
note += ` 双生之喜!双子名唤${twin.name}`
}
w.chronicle('birth', note, first.id, true)
w.log('good', `${fam.surname}家诞下新丁:${first.name}`)
}
// 联姻外孙来投
for (const member of Object.values(s.members)) {
if (!member.alive || !member.spouseHouse) continue
if (member.gender !== 'female') continue
const age = w.ageOf(member)
if (age < 18 || age > 44) continue
if (!w.rng.chance(0.16)) continue
const gen = member.generation + 1
const house = member.spouseHouse
const child = produceOffspring(w, { father: null, mother: null, generation: gen, bornYear: s.year, surname: fam.surname })
child.spouseHouse = house
w.addMember(child)
w.chronicle('birth', `${member.name}${house}携幼子归来,名唤${child.name}`, child.id, true)
w.log('info', `${member.name} 领着小辈回门投亲。`)
}
// 媒人撮合族内婚(含续弦与再醮)
const isWidowed = (c: Character): boolean => {
if (!c.spouseId) return false
const sp = w.state.members[c.spouseId]
return !!sp && !sp.alive
}
const men = w
.aliveMembers()
.filter((c) => c.gender === 'male' && c.state !== 'expedition' && (isWidowed(c) || !c.spouseId))
.filter((c) => {
const age = w.ageOf(c)
return age >= 18 && age <= 48
})
const women = w
.aliveMembers()
.filter((c) => c.gender === 'female' && c.state !== 'expedition' && (isWidowed(c) || !c.spouseId))
.filter((c) => {
const age = w.ageOf(c)
return age >= 16 && age <= 42
})
for (const m of men) {
if (w.rng.chance(0.28) && women.length > 0) {
const candidate = women.filter(
(x) => !(x.fatherId && x.fatherId === m.fatherId) && x !== m && !x.children.includes(m.id) && !m.children.includes(x.id)
)
if (candidate.length === 0) continue
const bride = w.rng.pick(candidate)
m.spouseId = bride.id
bride.spouseId = m.id
w.chronicle('marriage', `${m.name}${bride.name}缔结连理。`, m.id, true)
w.log('info', `${m.name}${bride.name} 成婚。`)
const idx = women.indexOf(bride)
if (idx >= 0) women.splice(idx, 1)
}
}
fam.generation = Math.max(
fam.generation,
...Object.values(s.members).filter((c) => c.alive).map((c) => c.generation)
)
}
function buildCouples(w: World): { internal: [Character, Character][]; cross: Character[] } {
const internal: [Character, Character][] = []
const cross: Character[] = []
for (const member of Object.values(w.state.members)) {
if (!member.alive || !member.spouseId) continue
const spouse = w.state.members[member.spouseId]
if (!spouse?.alive) continue
const key = [member.id, spouse.id].sort().join('|')
if (internal.some(([a, b]) => [a.id, b.id].sort().join('|') === key)) continue
internal.push([member, spouse])
}
for (const member of Object.values(w.state.members)) {
if (member.alive && member.spouseHouse) cross.push(member)
}
return { internal, cross }
}
@@ -0,0 +1,151 @@
import { World } from '../world'
import { MissionState } from '../../types/domain'
import { missionById, MissionDef, ENEMIES } from '../../data/secrets'
import { resolveEncounter, rollWarbooty } from './combat'
import { techniqueById } from '../../data/techniques'
import { describeRealm } from '../../data/realms'
export function missionTick(w: World): void {
const alive = w.state.missions.filter((m) => !m.done)
for (const m of alive) {
m.stageMonth++
if (m.stageMonth < 2) continue
const def = missionById(m.defId)
const stage = def.stages[m.stage]
if (!stage || m.stageMonth < stage.months) continue
if (stage.kind === 'event') {
const good = w.rng.chance(0.6)
if (good) {
m.log.push(`${m.stageMonth}月:${stage.title}——${stage.text?.safe ?? '安然无事。'}`)
if (w.rng.chance(0.2)) {
const squad = squadOf(w, m)
squad.forEach((c) => (c.realmProgress = Math.min(100, c.realmProgress + 4)))
m.log.push('途中参悟,众人皆有精进。')
}
} else {
m.log.push(`${m.stageMonth}月:${stage.title}——${stage.text?.bad ?? '遭遇凶险。'}`)
const squad = squadOf(w, m)
const victim = w.rng.pick(squad)
victim.health = Math.max(1, victim.health - 25 - w.rng.int(0, 15))
if (victim.health < 35) victim.state = 'wounded'
}
} else if (stage.kind === 'resource') {
const loot = rollWarbooty(w, stage.loot ?? def.completionLoot)
m.log.push(`${stage.title}:收获 ${lootText(loot)}`)
} else if (stage.kind === 'combat' || stage.kind === 'boss') {
const enemy = ENEMIES.find((e) => e.id === stage.enemyId) ?? ENEMIES[0]
const squad = squadOf(w, m)
const res = resolveEncounter(w, {
title: `${def.name} · ${stage.title}`,
enemy,
risk: def.risk * (stage.kind === 'boss' ? 1.15 : 1),
team: squad,
kind: 'scout',
year: w.state.year,
month: w.state.month
})
const line = res.win ? '战而胜之,征程继续!' : res.draw ? '僵持之后双方罢手,队伍休整再进。' : '不敌,只得暂避锋芒。'
m.log.push(line)
if (!res.win) {
if (stage.kind === 'boss') {
m.done = true
m.result = res.draw ? 'stalemate' : 'retreat'
m.log.join(' ')
w.chronicle('exploration', `${def.name}探路不遂,${resultText(m)}`, undefined, false)
}
}
}
m.stage++
m.stageMonth = 0
if (m.stage >= def.stages.length && !m.done) {
m.done = true
m.result = 'success'
const total = rollWarbooty(w, def.completionLoot)
m.log.push(`凯旋而归,清点战利:${lootText(total)}`)
const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、')
w.chronicle(
'exploration',
`${survivors} 圆满完成【${def.name}】之行。`,
undefined,
true
)
w.log('good', `${def.name} 探索归来,获得丰厚收获。`)
}
}
}
function squadOf(w: World, m: MissionState) {
return m.memberIds.map((id) => w.memberById(id)).filter((c) => c.alive)
}
function lootText(loot: Record<string, number>): string {
const names: Record<string, string> = {
lingcao: '灵草',
lingkuang: '灵矿',
beastcore: '兽核',
stones: '灵石',
'weapon-fan': '凡器',
'weapon-qi': '法器',
'weapon-ling': '灵器',
'pill-qiyuan': '聚气丹',
'pill-ningyuan': '凝元丹',
tech: '功法'
}
return Object.entries(loot)
.map(([k, v]) => `${names[k] ?? k}×${v}`)
.join('、')
}
function resultText(m: MissionState): string {
if (m.result === 'success') return '平安返回'
if (m.result === 'retreat') return '败退而回'
return '铩羽归来'
}
export function canSendMission(w: World, def: MissionDef, members: string[]): boolean {
if (members.length < def.minMembers || members.length > def.maxMembers) return false
for (const id of members) {
const c = w.memberById(id)
if (!c.alive || c.state === 'expedition') return false
}
return w.state.missions.filter((m) => !m.done).length < 3
}
export function sendMission(w: World, defId: string, members: string[]): boolean {
const def = missionById(defId)
if (!canSendMission(w, def, members)) return false
const m: MissionState = {
id: w.seq(),
defId,
memberIds: members,
startYear: w.state.year,
startMonth: w.state.month,
stage: 0,
stageMonth: 0,
log: [`冬衣已备,饯行酒干,众人于 ${w.state.year}${w.state.month} 月出发。`],
done: false
}
members.forEach((id) => {
const c = w.memberById(id)
c.state = 'expedition'
})
w.state.missions.push(m)
w.state.family.missionIds.push(m.id)
w.log('info', `队伍出发探索【${def.name}】。`)
return true
}
export function recallAll(w: World, missionId: string): void {
const m = w.state.missions.find((x) => x.id === missionId)
if (!m || m.done) return
m.done = true
m.result = 'recall'
m.memberIds.forEach((id) => {
const c = w.memberById(id)
if (c.alive) c.state = 'idle'
})
w.log('info', '探索队伍奉命返家。')
}
@@ -0,0 +1,51 @@
import { World } from '../world'
export function productionTick(w: World): void {
const fam = w.state.family
const inv = fam.inventory
const parts: string[] = []
const lvl = (b: string) => fam.buildings[b] ?? 0
const lingtian = lvl('lingtian')
const yaoyuan = lvl('yaoyuan')
const lingkuang = lvl('lingkuang')
const fangshi = lvl('fangshi')
const lingshou = lvl('lingshou')
if (lingtian > 0) {
const v = 10 * lingtian
inv.lingcao = (inv.lingcao ?? 0) + v
parts.push(`灵田+${v}灵草`)
}
if (yaoyuan > 0) {
const v = 5 * yaoyuan
inv.lingcao = (inv.lingcao ?? 0) + v
parts.push(`药园+${v}药草`)
if (yaoyuan >= 3) {
inv.beastcore = (inv.beastcore ?? 0) + 1
parts.push('药园+1兽核')
}
}
if (lingkuang > 0) {
const v = 8 * lingkuang
inv.lingkuang = (inv.lingkuang ?? 0) + v
parts.push(`灵矿+${v}灵矿`)
}
if (fangshi > 0) {
const v = 55 * fangshi
fam.stones += v
parts.push(`坊市+${v}灵石`)
}
if (lingshou > 0 && w.rng.chance(0.35)) {
inv.beastcore = (inv.beastcore ?? 0) + 1
parts.push('灵兽园+1兽核')
}
void parts
if (w.state.month % 3 === 0) {
const drift = w.rng.between(-0.04, 0.04)
const cur = typeof fam.flag['priceMult'] === 'number' ? (fam.flag['priceMult'] as number) : 1
fam.flag['priceMult'] = Math.max(0.78, Math.min(1.25, cur + drift))
}
}
+312
View File
@@ -0,0 +1,312 @@
import {
BattleLog,
Character,
ChronicleEntry,
GameState,
Id,
LogItem,
Realm
} from '../types/domain'
import { Rng } from '../core/rng'
import { BUILDINGS } from '../data/buildings'
import { productionTick } from './systems/production'
import { deathTick, woundHealTick } from './systems/lifecycle'
import { cultivationTick, resolveBreakthrough } from './systems/cultivation'
import { missionTick } from './systems/missions'
import { eventRoll, applyEventChoice } from './systems/events'
import { diplomacyTick } from './systems/diplomacy'
import { yearStartMarriage } from './systems/marriage'
import { createWorldState, findInheritor } from './creation'
import { combatPowerOf } from './systems/combat'
export type LogKind = LogItem['kind']
export interface WorldEventBus {
onLog(kind: LogKind, text: string): void
onChronicle(entry: ChronicleEntry, important: boolean): void
onBattle(log: BattleLog): void
onPendingEvent(id: string): void
onGameOver(reason: string, year: number): void
}
export class World {
state: GameState
rng: Rng
out: WorldEventBus[]
constructor(state: GameState, out: WorldEventBus[] = []) {
this.state = state
this.rng = new Rng(state.rng)
this.out = out
}
seq(): Id {
this.state.seq++
return `x${this.state.seq.toString(36)}`
}
syncRng(): void {
this.state.rng = this.rng.getState()
}
log(kind: LogKind, text: string): void {
this.out.forEach((o) => o.onLog(kind, text))
}
chronicle(cat: ChronicleEntry['category'], text: string, memberId?: Id, important = false): void {
const entry: ChronicleEntry = {
id: this.seq(),
year: this.state.year,
month: this.state.month,
category: cat,
text,
memberId,
important
}
this.state.chronicle.push(entry)
this.out.forEach((o) => o.onChronicle(entry, important))
}
battle(log: BattleLog): void {
this.state.battles.push(log)
this.out.forEach((o) => o.onBattle(log))
}
pendingEvent(id: string): void {
this.out.forEach((o) => o.onPendingEvent(id))
}
gameOver(reason: string, year: number): void {
this.state.gameOver = { year, reason }
this.out.forEach((o) => o.onGameOver(reason, year))
}
memberById(id: Id): Character {
const c = this.state.members[id]
if (!c) throw new Error(`member not found ${id}`)
return c
}
aliveMembers(): Character[] {
return Object.values(this.state.members).filter((c) => c.alive)
}
ageOf(c: Character): number {
return this.state.year - c.bornYear
}
head(): Character {
return this.memberById(this.state.family.headId)
}
advanceMonth(): void {
const s = this.state
s.month++
if (s.month > 12) {
s.month = 1
s.year++
this.yearStart()
}
s.totalTicks++
productionTick(this)
deathTick(this)
woundHealTick(this)
if (this.aliveMembers().length > 0) {
cultivationTick(this)
missionTick(this)
eventRoll(this)
diplomacyTick(this)
}
this.checkHead()
}
private yearStart(): void {
yearStartMarriage(this)
}
reputationDrift(): void {
const cur = this.state.family.reputation
const drift = cur > 0 ? -1.5 : cur < 0 ? 1.2 : 0
if (drift !== 0) this.state.family.reputation = Math.round(cur + drift)
}
totalFamilyReputation(): number {
return this.state.family.reputation
}
private checkHead(): void {
const s = this.state
if (s.gameOver) return
const headId = s.family.headId
if (!headId) return
const head = this.memberById(headId)
if (head.alive) return
const heir = findInheritor(this)
if (heir) {
this.assignHead(heir.id, true)
} else if (this.aliveMembers().length === 0) {
this.gameOver('满门凋零,香火断绝', s.year)
}
}
// ==================== player actions ====================
assignHead(id: Id, silent = false): void {
const c = this.memberById(id)
if (!c.alive) return
if (this.state.family.headId && !silent) {
const old = this.memberById(this.state.family.headId)
old.isHead = false
} else {
const oldId = this.state.family.headId
if (oldId && this.state.members[oldId]) this.state.members[oldId].isHead = false
}
c.isHead = true
this.state.family.headId = id
if (!silent) {
this.chronicle('misc', `${c.name} 继任为家主。`, c.id, true)
this.log('info', `${c.name} 继任为家主。`)
}
}
setMeditation(id: Id, on: boolean): void {
const c = this.memberById(id)
if (!c.alive || c.state === 'expedition') return
c.state = on ? 'meditation' : 'idle'
}
giveTechnique(memberId: Id, techId: string): void {
const c = this.memberById(memberId)
c.techniqueId = techId
}
teachTechnique(techId: string, cost: number): boolean {
const fam = this.state.family
if (fam.techniques.includes(techId)) return false
if (fam.stones < cost) return false
fam.stones -= cost
fam.techniques.push(techId)
this.log('info', `藏书阁续得《${techId}》,译作一名。`)
return true
}
equip(memberId: Id, artifact: string): void {
const c = this.memberById(memberId)
if (!c.alive) return
c.equipment = artifact
}
takePill(memberId: Id, pill: string): void {
const c = this.memberById(memberId)
const inv = this.state.family.inventory
if (!c.alive || (inv[pill] ?? 0) <= 0) return
inv[pill] = inv[pill]! - 1
if (pill === 'pill-pojing') {
if (c.realmProgress >= 100) {
this.resolveBottleneck(c, 0.22)
} else {
this.memberById(memberId).realmProgress = Math.min(100, c.realmProgress + 20)
this.log('info', `${c.name} 服下破境丹,灵力充盈。`)
}
} else {
const pct = pill === 'pill-qiyuan' ? 18 : 30
c.realmProgress = Math.min(100, c.realmProgress + pct)
this.log('info', `${c.name} 服下丹药,修为精进。`)
}
}
assistedBreakthrough(id: Id): void {
const c = this.memberById(id)
if (!c.alive || c.realmProgress < 100) return
this.resolveBottleneck(c, 0.06 + this.head().mind * 0.005)
}
private resolveBottleneck(c: Character, boost: number): void {
resolveBreakthrough(this, c, boost)
}
build(id: string): boolean {
const fam = this.state.family
const def = BUILDINGS[id]
if (!def) return false
if (fam.buildings[id]) return false
const cost = def.upgradeCost(1)
if (fam.stones < cost.stones) return false
fam.stones -= cost.stones
fam.buildings[id] = 1
this.chronicle('building', `建成「${def.name}」。`, undefined, true)
this.log('info', `建成「${def.name}」。`)
return true
}
upgrade(id: string): boolean {
const fam = this.state.family
const def = BUILDINGS[id]
const lvl = fam.buildings[id]
if (!def || !lvl || lvl >= def.maxLevel) return false
const cost = def.upgradeCost(lvl + 1)
if (fam.stones < cost.stones || (fam.inventory['lingkuang'] ?? 0) < cost.lingkuang) return false
fam.stones -= cost.stones
fam.inventory['lingkuang'] -= cost.lingkuang
fam.buildings[id] = lvl + 1
this.log('info', `${def.name}」升至 ${lvl + 1} 级。`)
return true
}
craftPill(kind: 'qiyuan' | 'ningyuan'): boolean {
const fam = this.state.family
const lvl = fam.buildings['danfang']
if (!lvl) return false
const cost = kind === 'qiyuan'
? { lingcao: 15, beastcore: 0, stones: 20 }
: { lingcao: 25, beastcore: 4, stones: 60 }
if ((fam.inventory['lingcao'] ?? 0) < cost.lingcao) return false
if ((fam.inventory['beastcore'] ?? 0) < cost.beastcore) return false
if (fam.stones < cost.stones) return false
fam.inventory['lingcao'] -= cost.lingcao
fam.inventory['beastcore'] -= cost.beastcore
fam.stones -= cost.stones
fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] =
(fam.inventory[kind === 'qiyuan' ? 'pill-qiyuan' : 'pill-ningyuan'] ?? 0) + 1
this.log('info', `丹房炼成一枚${kind === 'qiyuan' ? '聚气丹' : '凝元丹'}`)
return true
}
addMember(c: Character, father?: Character, mother?: Character): void {
const s = this.state
if (father || mother) {
if (father) {
c.fatherId = father.id
father.children.push(c.id)
}
if (mother) mother.children.push(c.id)
}
c.id = c.id || this.seq()
s.members[c.id] = c
}
familyPower(): number {
const fam = this.state.family
const bonus = 1 + (fam.buildings['yanwu'] ?? 0) * 0.04 + (fam.buildings['lingshou'] ?? 0) * 0.05
const top = this.aliveMembers()
.map((c) => combatPowerOf(this, c))
.sort((a, b) => b - a)
.slice(0, 4)
.reduce((a, b) => a + b, 0)
return Math.round(top * bonus)
}
static create(opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }): World {
const state = createWorldState(opts)
return new World(state)
}
}
export function makeWorldFromSave(state: GameState): World {
return new World(state, [])
}
export function applyChoice(world: World, eventId: string, optionIdx: number): void {
applyEventChoice(world, eventId, optionIdx)
}
+140
View File
@@ -0,0 +1,140 @@
import { SaveSlot } from './slots'
import { GameState, SaveMeta, SnapshotMeta } from '../types/domain'
import type { SaveDbDriver } from './slots'
// Adapter for @metona-team/metona-sqlark
// The driver interface keeps the engine swappable (testable in node too).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFactory = { create: (config: any) => Promise<any> }
let MetonaSqlark: AnyFactory | null = null
interface SqlarkLike {
query: (sql: string, params?: unknown[]) => Promise<unknown[]>
}
export function setSqlarkBackend(impl: AnyFactory): void {
MetonaSqlark = impl
}
export async function makeDriver(): Promise<SaveDbDriver> {
const factory = await getSqlarkFactory()
return new SqlarkDriver(factory)
}
class SqlarkDriver implements SaveDbDriver {
private db: SqlarkLike | null = null
constructor(private factory: AnyFactory) {}
async open(name: string): Promise<void> {
if (this.db) return
this.db = (await this.factory.create({
name,
mode: 'aria',
diskEngine: 'opfs',
aria: { walSyncMode: 'full', compression: true }
})) as SqlarkLike
}
async close(): Promise<void> {
this.db = null
}
async run(sql: string, params: unknown[] = []): Promise<unknown> {
if (!this.db) throw new Error('db not open')
await this.db.query(sql, params)
return null
}
async all<T>(sql: string, params: unknown[] = []): Promise<T[]> {
if (!this.db) throw new Error('db not open')
const rows = (await this.db.query(sql, params)) as T[]
return rows ?? []
}
async exec(sql: string): Promise<unknown> {
if (!this.db) throw new Error('db not open')
await this.db.query(sql)
return null
}
}
const META_DB = 'cotyc-appmeta'
let singletonManager: SlotManager | null = null
export function getSlotManager(): SlotManager {
if (!singletonManager) singletonManager = new SlotManager()
return singletonManager
}
export async function getSqlarkFactory(): Promise<AnyFactory> {
if (!MetonaSqlark) {
const mod = await import('@metona-team/metona-sqlark')
MetonaSqlark = mod.MetonaSqlark ?? mod.MeSqlark
}
if (!MetonaSqlark) throw new Error('metona-sqlark not available')
return MetonaSqlark
}
const slotCache = new Map<number, SaveSlot>()
export async function getSaveSlot(slot: number): Promise<SaveSlot> {
let s = slotCache.get(slot)
if (!s) {
const factory = await getSqlarkFactory()
s = new SaveSlot(slot, new SqlarkDriver(factory))
slotCache.set(slot, s)
}
return s
}
export class SlotManager {
private metaDb: SqlarkLike | null = null
async ensureMeta(): Promise<void> {
if (this.metaDb) return
this.metaDb = (await (await getSqlarkFactory()).create({
name: META_DB,
mode: 'aria',
diskEngine: 'opfs',
aria: { walSyncMode: 'full' }
})) as SqlarkLike
await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`)
}
async listSlotMetas(): Promise<(SaveMeta | null)[]> {
await this.ensureMeta()
const rows = await this.metaDb!.query(`SELECT slot, meta FROM slots ORDER BY slot`)
const map = new Map<number, SaveMeta>()
for (const r of rows as { slot: number; meta: string }[]) {
try {
map.set(r.slot, JSON.parse(r.meta) as SaveMeta)
} catch {
map.set(r.slot, null as unknown as SaveMeta)
}
}
const out: (SaveMeta | null)[] = []
for (let i = 1; i <= 4; i++) {
out.push(map.get(i) ?? null)
}
return out
}
async updateSlotMeta(slot: number, meta: SaveMeta): Promise<void> {
await this.ensureMeta()
const existing = await this.metaDb!.query(`SELECT slot FROM slots WHERE slot = ?`, [slot])
const json = JSON.stringify(meta)
if (existing.length > 0) {
await this.metaDb!.query(`UPDATE slots SET meta = ? WHERE slot = ?`, [json, slot])
} else {
await this.metaDb!.query(`INSERT INTO slots (slot, meta) VALUES (?, ?)`, [slot, json])
}
}
async removeSlotMeta(slot: number): Promise<void> {
await this.ensureMeta()
await this.metaDb!.query(`DELETE FROM slots WHERE slot = ?`, [slot])
}
}
export type { GameState, SaveMeta, SnapshotMeta }
+156
View File
@@ -0,0 +1,156 @@
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
const DB_PREFIX = 'cotyc-save-'
export interface SaveDbDriver {
open(name: string): Promise<void>
close(): Promise<void>
run(sql: string, params?: unknown[]): Promise<unknown>
all<T>(sql: string, params?: unknown[]): Promise<T[]>
exec(sql: string): Promise<unknown>
}
export interface TableDef {
name: string
cols: Record<string, string>
}
export class SaveSlot {
constructor(
public slot: number,
private driver: SaveDbDriver
) {}
private get name(): string {
return `${DB_PREFIX}${this.slot}`
}
async open(): Promise<void> {
await this.driver.open(this.name)
await this.ensureSchema()
}
private async ensureSchema(): Promise<void> {
const driver = this.driver
await driver.exec(`CREATE TABLE IF NOT EXISTS meta (key string PRIMARY KEY, value string)`)
await driver.exec(`CREATE TABLE IF NOT EXISTS snapshot (id string PRIMARY KEY, year number, month number, savedAt string, label string, data string)`)
await driver.exec(`CREATE TABLE IF NOT EXISTS chronicle (id string PRIMARY KEY, year number, month number, category string, important number, memberId string, text string)`)
}
async saveState(state: GameState, label: string): Promise<string> {
const id = `${state.year}.${state.month}.${Date.now().toString(36)}`
const json = JSON.stringify(state)
await this.driver.run(
`INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`,
[id, state.year, state.month, new Date().toISOString(), label, json]
)
const keep = 12
const rows = await this.driver.all<{ id: string }>(
`SELECT id FROM snapshot ORDER BY year DESC, month DESC, savedAt DESC`
)
if (rows.length > keep) {
const drop = rows.slice(keep).map((r) => r.id)
for (const d of drop) {
await this.driver.run(`DELETE FROM snapshot WHERE id = ?`, [d])
}
}
return id
}
async saveChronicle(chronicle: GameState['chronicle']): Promise<void> {
const driver = this.driver
for (const e of chronicle) {
try {
const exists = await driver.all<{ id: string }>(`SELECT id FROM chronicle WHERE id = ?`, [e.id])
if (exists.length > 0) continue
await driver.run(
`INSERT INTO chronicle (id, year, month, category, important, memberId, text) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[e.id, e.year, e.month, e.category, e.important ? 1 : 0, e.memberId ?? null, e.text]
)
} catch {
// chronicle table redundancy is best-effort
}
}
}
async listSnapshots(): Promise<SnapshotMeta[]> {
const rows = await this.driver.all<SnapshotMeta & { data?: string }>(`SELECT id, year, month, savedAt, label FROM snapshot ORDER BY year DESC, month DESC`)
return rows.map((r) => ({ id: r.id, year: r.year, month: r.month, savedAt: r.savedAt, label: r.label }))
}
async loadState(id?: string): Promise<GameState | null> {
const rows = await this.driver.all<{ data: string }>(
id ? `SELECT data FROM snapshot WHERE id = ?` : `SELECT data FROM snapshot ORDER BY rowid DESC LIMIT 1`
, id ? [id] : [])
if (!rows || rows.length === 0) return null
return JSON.parse(rows[0]!.data) as GameState
}
async deleteSnapshot(id: string): Promise<void> {
await this.driver.run(`DELETE FROM snapshot WHERE id = ?`, [id])
}
async getMeta(): Promise<SaveMeta | null> {
const rows = await this.driver.all<{ value: string }>(`SELECT value FROM meta WHERE key = 'meta'`)
if (!rows || rows.length === 0) return null
return JSON.parse(rows[0].value) as SaveMeta
}
async setMeta(meta: SaveMeta): Promise<void> {
const exists = await this.driver.all<{ key: string }>(`SELECT key FROM meta WHERE key = 'meta'`)
const json = JSON.stringify(meta)
if (exists.length > 0) {
await this.driver.run(`UPDATE meta SET value = ? WHERE key = 'meta'`, [json])
} else {
await this.driver.run(`INSERT INTO meta (key, value) VALUES ('meta', ?)`, [json])
}
}
async exportAll(): Promise<string> {
const state = await this.loadState()
const meta = await this.getMeta()
return JSON.stringify({ app: 'cotyc', schemaVersion: 1, meta, state })
}
async importAll(data: string): Promise<boolean> {
try {
const parsed = JSON.parse(data) as { app?: string; schemaVersion?: number; state?: GameState }
if (!parsed.state) return false
await this.driver.exec(`DELETE FROM snapshot`)
await this.driver.exec(`DELETE FROM meta`)
await this.saveState(parsed.state, 'import')
const aliveCount = Object.values(parsed.state.members).filter((c) => c.alive).length
await this.setMeta(buildSimpleMeta(parsed.state, this.slot, aliveCount))
return true
} catch {
return false
}
}
async wipe(): Promise<void> {
await this.driver.exec(`DELETE FROM snapshot`)
await this.driver.exec(`DELETE FROM meta`)
await this.driver.exec(`DELETE FROM chronicle`)
}
}
export function buildSimpleMeta(state: GameState, slot: number, memberCount: number): SaveMeta {
return {
slot,
surname: state.family.surname,
name: state.family.name,
estate: state.family.estate,
year: state.year,
month: state.month,
generation: state.family.generation,
members: memberCount,
reputation: state.family.reputation,
updatedAt: new Date().toISOString(),
version: state.schemaVersion
}
}
export function saveNameOf(meta: SaveMeta | null): string {
if (!meta) return ''
return `${meta.name} · ${meta.estate}`
}
+182
View File
@@ -0,0 +1,182 @@
export type Id = string
export type Gender = 'male' | 'female'
export type Element = '金' | '木' | '水' | '火' | '土'
export type RealmMajor = 'mortal' | 'qi' | 'foundation' | 'core' | 'nascent' | 'spirit'
export interface Realm {
major: RealmMajor
minor: number
}
export type CharState = 'idle' | 'meditation' | 'expedition' | 'wounded' | 'closed'
export interface SpiritRoots {
grade: number
primary: Element
secondary: Element[]
}
export interface Character {
id: Id
name: string
gender: Gender
generation: number
fatherId?: Id
motherId?: Id
spouseId?: Id
spouseHouse?: string
children: Id[]
bornYear: number
deathYear?: number
deathCause?: string
age: number
realm: Realm
realmProgress: number
roots: SpiritRoots
perception: number
physique: number
mind: number
charm: number
fortune: number
traits: string[]
techniqueId?: string
equipment?: string
state: CharState
health: number
alive: boolean
isHead?: boolean
isFounder?: boolean
lastBreakthroughAttempt?: number
monthProgress?: number
}
export interface FamilyState {
surname: string
name: string
motto: string
crest: string
estate: string
yearFounded: number
generation: number
reputation: number
stones: number
inventory: Record<string, number>
buildings: Record<string, number>
techniques: string[]
missionIds: Id[]
headId: Id
difficulty: 'easy' | 'normal' | 'hard'
flag: Record<string, number | boolean | string>
}
export interface NpcFamilyState {
id: string
name: string
region: string
power: number
relation: number
allied: boolean
alliedSinceYear?: number
warCooldownYear?: number
raidCount: number
}
export interface MissionState {
id: Id
defId: string
memberIds: Id[]
startYear: number
startMonth: number
stage: number
stageMonth: number
log: string[]
done: boolean
result?: string
}
export interface ChronicleEntry {
id: Id
year: number
month: number
category: 'birth' | 'marriage' | 'death' | 'breakthrough' | 'battle' | 'trade' | 'diplomacy' | 'exploration' | 'building' | 'event' | 'misc'
text: string
memberId?: Id
important: boolean
}
export interface BattleLog {
id: Id
year: number
month: number
title: string
kind: 'scout' | 'raid' | 'war'
lines: string[]
winner: 'player' | 'enemy' | 'none'
loot?: Record<string, number>
losses: string[]
}
export interface RngState {
a: number
b: number
c: number
d: number
}
export interface GameOver {
year: number
reason: string
}
export interface GameState {
schemaVersion: number
seed: string
rng: RngState
year: number
month: number
seq: number
family: FamilyState
members: Record<string, Character>
npcFamilies: Record<string, NpcFamilyState>
missions: MissionState[]
chronicle: ChronicleEntry[]
battles: BattleLog[]
eventQueue: string[]
pendingEvent?: string
completedEvents: string[]
flags: Record<string, number | boolean | string>
gameOver?: GameOver
totalTicks: number
}
export interface LogItem {
id: number
kind: 'info' | 'good' | 'bad' | 'war' | 'event' | 'chronicle'
text: string
year: number
month: number
}
export interface SaveMeta {
slot: number
surname: string
name: string
estate: string
year: number
month: number
generation: number
members: number
reputation: number
updatedAt: string
version: number
}
export interface SnapshotMeta {
id: string
year: number
month: number
savedAt: string
label: string
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:"
/>
<title>仙途家族志</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './ui/styles.css'
const root = document.getElementById('root')
if (root) {
createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
}
@@ -0,0 +1,35 @@
import { useGameStore } from '../store'
export function BattleModal() {
const battleView = useGameStore((s) => s.battleView)
if (!battleView) return null
const winnerLabel =
battleView.winner === 'player' ? '✦ 我方得胜 ✦' : battleView.winner === 'enemy' ? '✕ 我方败北 ✕' : '◌ 平分秋色 ◌'
return (
<div className="modal-outer">
<div className="modal battle-modal">
<div className="modal-title" style={{ fontSize: '1.3rem' }}>{battleView.title}</div>
<div className="modal-cat">
{battleView.year}{battleView.month} · <span className={battleView.winner === 'player' ? 'good' : battleView.winner === 'enemy' ? 'bad' : 'warn'}>{winnerLabel}</span>
</div>
<div className="battle-lines">
{battleView.lines.map((l, i) => (
<div key={i}>{l}</div>
))}
{battleView.losses.length > 0 && (
<div className="bad" style={{ marginTop: 8 }}>
{battleView.losses.join('、')}
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={() => useGameStore.setState({ battleView: undefined })}></button>
</div>
</div>
</div>
)
}
export default BattleModal
+42
View File
@@ -0,0 +1,42 @@
import { useGameStore } from '../store'
import { applyEventChoice } from '../../game/engine/systems/events'
import { eventCategoryName, EventDef } from '../../game/data/events'
export function EventModal() {
const pendingEventId = useGameStore((s) => s.pendingEventId)
const pendingEventDef = useGameStore((s) => s.pendingEventDef)
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const setSpeed = useGameStore((s) => s.setSpeed)
if (!pendingEventId || !pendingEventDef || !world) return null
const choose = (idx: number) => {
applyEventChoice(world, pendingEventId, idx)
useGameStore.setState({ pendingEventId: undefined, pendingEventDef: undefined })
bump()
// 重要事务后暂停推算,给玩家喘息
const sp = useGameStore.getState().speed
if (sp > 1) setSpeed(1)
}
return (
<div className="modal-outer">
<div className="modal">
<div className="modal-title">{pendingEventDef.name}</div>
<div className="modal-cat">
{eventCategoryName(pendingEventDef.category)}
{pendingEventDef.once && ' · 仅此一次'}
</div>
<div className="modal-text">{pendingEventDef.text}</div>
<div className="modal-opts">
{pendingEventDef.options.map((o, i) => (
<button key={i} className="opt-btn" onClick={() => choose(i)}>
{o.label}
{o.hint && <span className="hint">{o.hint}</span>}
</button>
))}
</div>
</div>
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { useGameStore } from '../store'
export function LogFeed() {
const feed = useGameStore((s) => s.logFeed)
const ref = useGameStore((s) => s.revision)
void ref
return (
<div className="feed">
{feed.slice().reverse().map((item) => (
<div key={item.id} className={`feed-item k-${item.kind}`}>
<span className="feed-date">{item.year}{item.month}</span>
{item.text}
</div>
))}
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useGameStore } from '../store'
import { Character } from '../../game/types/domain'
import { describeRealm } from '../../game/data/realms'
import { describeRoots } from '../../game/data/elements'
const STATE_LABEL: Record<string, { label: string; cls: string }> = {
idle: { label: '无事', cls: '' },
meditation: { label: '闭关', cls: 'good' },
expedition: { label: '出探', cls: 'warn' },
wounded: { label: '养伤', cls: 'bad' }
}
export function MemberCard({ c }: { c: Character }) {
const world = useGameStore((s) => s.world)
const setSelected = useGameStore((s) => s.setSelected)
if (!world) return null
const dead = !c.alive
const isHead = world.state.family.headId === c.id
const state = STATE_LABEL[c.state]
return (
<div className={`member-card ${dead ? 'dead' : ''}`} onClick={() => setSelected(c.id)}>
<div className="mc-head">
<div className="m-avatar">{world.state.family.surname}</div>
<div style={{ flex: 1 }}>
<div className="member-name">
<span>{c.name}</span>
{isHead && <span className="tag head-t"></span>}
{c.spouseHouse && <span className="tag"></span>}
</div>
<div className="member-realm">
{dead
? `殁于${c.deathYear}年 · ${c.deathCause ?? '寿终'}`
: `${describeRealm(c.realm)} · ${world.ageOf(c)}岁 · 修为${Math.floor(c.realmProgress)}%`}
</div>
</div>
</div>
<div className="member-tags">
<span className="tag">{describeRoots(c.roots)}</span>
{!dead && c.realmProgress >= 100 && <span className="tag gold-t"> · </span>}
{C_STATE[dead ? 'dead' : c.state] && (
<span className={`tag ${C_STATE[dead ? 'dead' : c.state]}`}>{STATE_LABEL[c.state]?.label ?? ''}</span>
)}
</div>
{!dead && (
<div className="mc-foot">
<div className="bar" title="灵气修为" style={{ height: 6 }}>
<div style={{ width: `${c.realmProgress}%` }} />
</div>
</div>
)}
</div>
)
}
const C_STATE: Record<string, string> = {
idle: '',
meditation: 'good',
expedition: 'warn',
wounded: 'bad',
dead: 'bad'
}
+167
View File
@@ -0,0 +1,167 @@
import { useGameStore } from '../store'
import { Character } from '../../game/types/domain'
import { describeRealm, nextRealm } from '../../game/data/realms'
import { describeRoots, ROOT_GRADE_NAMES } from '../../game/data/elements'
import { TRAITS } from '../../game/data/traits'
import { TECHNIQUES } from '../../game/data/techniques'
import { ITEMS, ARTIFACT_POWER } from '../../game/data/items'
import { combatPowerOf } from '../../game/engine/systems/combat'
import { lifespanOf } from '../../game/engine/systems/lifecycle'
export function MemberModal({ member }: { member: Character }) {
const world = useGameStore((s) => s.world)
const setSelected = useGameStore((s) => s.setSelected)
const bump = useGameStore((s) => s.bump)
if (!world) return null
const w = world
const s = w.state
const next = nextRealm(member.realm)
const nextName = next ? describeRealm(next) : '已至巅峰'
const tech = TECHNIQUES.find((t) => t.id === member.techniqueId)
const power = member.alive ? combatPowerOf(w, member) : 0
const lifespan = lifespanOf(w, member)
return (
<div className="modal-outer" onClick={() => setSelected(undefined)}>
<div className="modal member-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-title" style={{ fontSize: '1.4rem' }}>
{member.name}
{!member.alive && <span className="bad" style={{ fontSize: '1rem' }}> · </span>}
</div>
<div className="modal-cat">
{member.generation} · {member.gender === 'male' ? '男' : '女'} · {w.ageOf(member)} · 寿{lifespan} · {describeRoots(member.roots)}
</div>
<div className="mm-grid">
<div className="mm-line"><span></span><b>{describeRealm(member.realm)} {Math.floor(member.realmProgress)}%</b></div>
<div className="mm-line"><span></span><b className={member.realmProgress >= 100 ? 'gold' : ''}>{nextName}</b></div>
<div className="mm-line"><span></span><b>{Math.round(power)}</b></div>
<div className="mm-line"><span></span><b>{member.state === 'meditation' ? '闭关' : member.state === 'expedition' ? '外出' : member.state === 'wounded' ? '养伤' : '无事'}</b></div>
<div className="mm-line"><span></span><b>{Math.round(member.health)}</b></div>
<div className="mm-line"><span></span><b>{tech ? `${tech.name}(${['黄', '玄', '地', '天', '仙'][tech.grade]}阶)` : member.realm.major === 'mortal' ? '—' : '未习功法'}</b></div>
<div className="mm-line"><span></span><b>{member.equipment ? `${ITEMS[member.equipment].name}(+${Math.round((ARTIFACT_POWER[member.equipment] ?? 0) * 100)}%)` : '—'}</b></div>
<div className="mm-line"><span></span><b>{member.spouseId ? s.members[member.spouseId]?.name ?? '' : member.spouseHouse ?? '未婚'}</b></div>
</div>
<div className="bar bar-red" style={{ marginTop: 10 }} title="气血">
<div style={{ width: `${member.health}%` }} />
</div>
<div className="mm-sec">
<div className="mm-grid">
<div className="mm-line"><span></span><b>{member.perception}/10</b></div>
<div className="mm-line"><span></span><b>{member.physique}/10</b></div>
<div className="mm-line"><span></span><b>{member.mind}/10</b></div>
<div className="mm-line"><span></span><b>{member.charm}/10</b></div>
<div className="mm-line"><span></span><b>{member.fortune}/12</b></div>
<div className="mm-line"><span></span><b>{member.fortune ? (member.fortune > 7 ? '俊逸' : '端正') : '—'}</b></div>
</div>
</div>
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 4 }}></div>
{member.traits.map((t) => (
<span key={t} className="tag" style={{ marginRight: 4 }}>
{TRAITS[t]?.name ?? t}
</span>
))}
{member.traits.length === 0 && <span className="dim2"></span>}
</div>
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}></div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{member.alive && member.state !== 'expedition' && (
<button
className="btn btn-sm"
onClick={() => {
w.setMeditation(member.id, member.state !== 'meditation')
bump()
}}
>
{member.state === 'meditation' ? '出关' : '闭关'}
</button>
)}
{member.alive && member.realmProgress >= 100 && member.realm.major !== 'spirit' && (
<button
className="btn btn-sm btn-primary"
onClick={() => {
w.assistedBreakthrough(member.id)
bump()
}}
>
{nextName}
</button>
)}
{member.alive && (
<>
<button
className="btn btn-sm"
onClick={() => {
w.takePill(member.id, 'pill-qiyuan')
bump()
}}
>
</button>
{member.realm.major !== 'mortal' && (
<button
className="btn btn-sm"
onClick={() => {
w.takePill(member.id, 'pill-ningyuan')
bump()
}}
>
</button>
)}
{member.realmProgress >= 100 && member.realm.major !== 'mortal' && (
<button
className="btn btn-sm"
onClick={() => {
w.takePill(member.id, 'pill-pojing')
bump()
}}
>
</button>
)}
</>
)}
</div>
</div>
{member.alive && (
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}></div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{s.family.techniques.map((tid) => (
<button
key={tid}
className="btn btn-sm"
style={{ borderColor: member.techniqueId === tid ? 'var(--gold)' : 'var(--line)' }}
onClick={() => {
w.giveTechnique(member.id, tid)
bump()
}}
>
{TECHNIQUES.find((t) => t.id === tid)?.name ?? tid}
</button>
))}
{s.family.techniques.length === 0 && <span className="dim2"></span>}
</div>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
{member.children.length > 0 && (
<span className="dim2">
{member.children.map((cid) => s.members[cid]?.name ?? '?').join('、')}
</span>
)}
<button className="btn" onClick={() => setSelected(undefined)}></button>
</div>
</div>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
export { LogFeed } from './LogFeed'
export { EventModal } from './EventModal'
export { BattleModal } from './BattleModal'
export { MemberModal } from './MemberModal'
export { MemberCard } from './MemberCard'
export const RES_INFO = [
{ id: 'stones', label: '灵石', icon: '石' },
{ id: 'lingcao', label: '灵草', icon: '草' },
{ id: 'lingkuang', label: '灵矿', icon: '矿' },
{ id: 'beastcore', label: '兽核', icon: '核' },
{ id: 'pill-qiyuan', label: '聚气丹', icon: '气' }
]
+59
View File
@@ -0,0 +1,59 @@
import { useGameStore } from '../store'
import { useMemo, useState } from 'react'
import { ChronicleEntry } from '../../game/types/domain'
const CAT_NAME: Record<string, string> = {
birth: '诞庆',
marriage: '姻缘',
death: '祭丧',
breakthrough: '突破',
battle: '战事',
trade: '商贸',
diplomacy: '外交',
exploration: '寻宝',
building: '营造',
event: '奇遇',
misc: '家常'
}
export default function ChroniclePanel() {
const world = useGameStore((s) => s.world)
const revision = useGameStore((s) => s.revision)
void revision
const [filter, setFilter] = useState<string>('all')
const entries = useMemo(() => {
if (!world) return []
const all = [...world.state.chronicle]
const filtered = filter === 'all' ? all : all.filter((e) => e.category === filter)
return filtered.slice().sort((a, b) => (b.year - a.year) || (b.month - a.month))
}, [world, filter])
if (!world) return null
return (
<div>
<div className="help-text">
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
<button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : ''}`} onClick={() => setFilter('all')}></button>
{Object.entries(CAT_NAME).map(([k, v]) => (
<button key={k} className={`btn btn-sm ${filter === k ? 'btn-primary' : ''}`} onClick={() => setFilter(k)}>
{v}
</button>
))}
</div>
<div className="chronicle-list">
{entries.map((e) => (
<div key={e.id} className={`ch-item ${e.important ? 'gold' : ''}`}>
<span className="ch-year">{e.year}{e.month}</span>
<span>
<span className="tag" style={{ marginRight: 8 }}>{CAT_NAME[e.category] ?? e.category}</span>
{e.text}
</span>
</div>
))}
{entries.length === 0 && <div className="dim2"></div>}
</div>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useGameStore } from '../store'
import { npcById } from '../../game/data/npcs'
import { giftNpc, makePeace, marryNpcFamily } from '../../game/engine/systems/diplomacy'
import { useMemo } from 'react'
import { MAJOR_NAMES } from '../../game/data/realms'
const REL_TYPE = (r: number) =>
r >= 80 ? ['死生之交', 'good'] : r >= 50 ? ['通好之势', 'good'] : r >= 25 ? ['友善', 'warn'] : r >= 10 ? ['温和', 'warn'] : r > -10 ? ['中立', ''] : r > -30 ? ['嫌隙', ''] : r > -50 ? ['敌对', 'bad'] : ['仇雠', 'bad']
export default function DiplomacyPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
const w = world
const npcs = Object.values(w.state.npcFamilies)
const sorted = useMemo(() => npcs, [npcs])
return (
<div>
<div className="help-text">
</div>
{sorted.map((npc) => {
const def = npcById(npc.id)
const [relLabel, relCls] = REL_TYPE(npc.relation)
const married = npc.allied
return (
<div key={npc.id} className="npc-row">
<div className="npc-name">
<div style={{ fontSize: '1.15rem' }}>{npc.name}</div>
<div className="dim2" style={{ fontSize: '0.78rem' }}>{def.style} · {npc.region}</div>
</div>
<div className="relation-bar">
<div className="bar" style={{ borderColor: npc.relation >= 0 ? '#2e5a2e' : '#6e2f2f' }}>
<div
style={{
width: `${(npc.relation + 100) / 2}%`,
background: npc.relation >= 0 ? '#5a8a44' : '#8a4444'
}}
/>
</div>
<div className={`dim ${relCls}`} style={{ marginTop: 4, fontSize: '0.82rem' }}>
{npc.relation} · {relLabel} · {npc.power}
</div>
</div>
<div className="relation-num">
<span className="dim2">{MAJOR_NAMES[def.leaderRealm]}</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-sm" onClick={() => { giftNpc(w, npc.id, 120); bump() }}>
120
</button>
<button
className="btn btn-sm"
disabled={npc.relation < 25 || married}
onClick={() => { marryNpcFamily(w, npc.id); bump() }}
>
{married ? '已联姻' : '联姻'}
</button>
<button className="btn btn-sm" onClick={() => { (npc.relation = Math.max(-100, npc.relation - 20)); bump() }}>
</button>
{npc.relation < -30 && (
<button className="btn btn-sm" onClick={() => { makePeace(w, npc.id); bump() }}>
</button>
)}
</div>
</div>
)
})}
<div className="dim2" style={{ marginTop: 12 }}>
</div>
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
import { useGameStore } from '../store'
import { MISSIONS, missionById } from '../../game/data/secrets'
import { sendMission, recallAll } from '../../game/engine/systems/missions'
import { combatPowerOf } from '../../game/engine/systems/combat'
import { useState } from 'react'
import { describeRealm, MAJOR_ORDER } from '../../game/data/realms'
export default function ExpeditionPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const selectedMissionDef = useGameStore((s) => s.selectedMissionDef)
const setMissionDef = useGameStore((s) => s.setMissionDef)
const revision = useGameStore((s) => s.revision)
void revision
const [squad, setSquad] = useState<string[]>([])
if (!world) return null
const w = world
const s = w.state
const active = s.missions.filter((m) => !m.done)
const candidates = Object.values(s.members).filter(
(c) => c.alive && w.ageOf(c) >= 16 && c.state !== 'expedition'
)
const def = selectedMissionDef ? missionById(selectedMissionDef) : null
const toggle = (id: string) => {
setSquad((old) => (old.includes(id) ? old.filter((x) => x !== id) : [...old, id]))
}
return (
<div>
<div className="help-text">
</div>
<div className="mission-card">
<div className="card-title" style={{ fontSize: '1rem' }}></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(180px,1fr))', gap: 8 }}>
{MISSIONS.map((m) => {
const sel = selectedMissionDef === m.id
return (
<div
key={m.id}
style={{
border: sel ? '1px solid var(--gold)' : '1px solid var(--line)',
borderRadius: 4,
padding: '8px 10px',
cursor: 'pointer',
background: 'var(--panel)'
}}
onClick={() => {
setMissionDef(sel ? undefined : m.id)
setSquad([])
}}
>
<div><span className="s-icon">{m.icon}</span> {m.name}</div>
<div className="dim2" style={{ fontSize: '0.78rem' }}>{m.region} · {describeRealm({ major: m.realmHint, minor: 0 })}</div>
<div className="dim2" style={{ fontSize: '0.78rem' }}>{m.minMembers}-{m.maxMembers} · {m.stages.length}</div>
<div className="dim" style={{ fontSize: '0.8rem', marginTop: 3 }}>{m.desc}</div>
</div>
)
})}
</div>
{def && (
<div style={{ marginTop: 12, borderTop: '1px solid var(--line)', paddingTop: 10 }}>
<div className="dim" style={{ marginBottom: 6 }}>
{squad.length}/{def.maxMembers} {def.minMembers}
</div>
<div className="squad-picker">
{candidates.map((c) => (
<div
key={c.id}
className={`squadian ${squad.includes(c.id) ? 'sel' : ''}`}
onClick={() => toggle(c.id)}
>
{c.name}·{describeRealm(c.realm)}·{Math.round(combatPowerOf(w, c))}
</div>
))}
{candidates.length === 0 && <span className="dim2"></span>}
</div>
<button
className="btn btn-primary"
disabled={squad.length < def.minMembers || squad.length > def.maxMembers || active.length >= 3}
onClick={() => {
sendMission(w, def.id, squad)
setSquad([])
bump()
}}
>
</button>
</div>
)}
</div>
{active.length > 0 && (
<div>
<div className="card-title" style={{ fontSize: '1rem', marginTop: 8 }}></div>
{active.map((m) => {
const def2 = missionById(m.defId)
const stage = def2.stages[m.stage]
return (
<div key={m.id} className="mission-card">
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<b><span className="s-icon">{def2.icon}</span> {def2.name}</b>
<span className="dim2" style={{ fontSize: '0.85rem' }}>
{m.memberIds.map((id) => s.members[id]?.name ?? '?').join('、')}
</span>
<span className="dim" style={{ marginLeft: 'auto', fontSize: '0.85rem' }}>
{m.stage + 1}/{def2.stages.length}
{stage ? ` · ${stage.title}` : ''}
</span>
</div>
<div className="dim2" style={{ fontSize: '0.8rem', margin: '6px 0' }}>
{m.startYear}{m.startMonth} ·
</div>
<div className="dim" style={{ fontSize: '0.85rem', lineHeight: 1.6 }}>
{m.log.slice(-3).map((l, i) => (
<div key={i}>· {l}</div>
))}
</div>
<button className="btn btn-sm" style={{ marginTop: 6 }} onClick={() => { recallAll(w, m.id); bump() }}>
</button>
</div>
)
})}
</div>
)}
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { useGameStore } from '../store'
import { MemberCard } from '../components/MemberCard'
import { MemberModal } from '../components/MemberModal'
import { Character } from '../../game/types/domain'
export default function FamilyPanel() {
const world = useGameStore((s) => s.world)
const selectedMemberId = useGameStore((s) => s.selectedMemberId)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
const s = world.state
const alive = Object.values(s.members).filter((c) => c.alive)
const dead = Object.values(s.members).filter((c) => !c.alive)
const sorted = [...alive].sort((a, b) => {
const aHead = a.id === s.family.headId ? -1 : 0
const bHead = b.id === s.family.headId ? -1 : 0
return aHead - bHead || world.ageOf(b) - world.ageOf(a)
})
const selected = selectedMemberId ? s.members[selectedMemberId] as Character | undefined : undefined
return (
<div>
<div className="card" style={{ marginBottom: 12, display: 'flex', gap: 20, alignItems: 'center' }}>
<div>
<span className="card-title">{s.family.name}</span>{' '}
<span className="dim">{s.family.estate}</span>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 18 }}>
<span className="dim"> <b className="gold">{alive.length}</b></span>
<span className="dim"> <b className="gold">{s.family.reputation}</b></span>
<span className="dim"> <b className="gold">{s.family.generation}</b></span>
</div>
</div>
<div className="member-grid">
{sorted.map((c) => (
<MemberCard key={c.id} c={c} />
))}
{dead.map((c) => (
<MemberCard key={c.id} c={c} />
))}
</div>
{alive.length === 0 && (
<div className="card" style={{ marginTop: 20, textAlign: 'center' }}>
<div style={{ fontSize: '1.2rem' }}></div>
<div className="dim" style={{ marginTop: 6 }}></div>
</div>
)}
{selected && <MemberModal member={selected} />}
</div>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { useGameStore } from '../store'
import { ITEMS } from '../../game/data/items'
import { TECHNIQUES } from '../../game/data/techniques'
import { TECHNIQUE_GRADE_NAMES } from '../../game/data/realms'
import { marketPrice, buyItem, sellItem, buyTechnique } from '../../game/engine/market'
import { useMemo, useState } from 'react'
export default function MarketPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
const w = world
const fam = w.state.family
const inv = fam.inventory
const [tab, setTab] = useState<'goods' | 'tech'>('goods')
const goods = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'resource' || i.kind === 'pill'), [])
const artifacts = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'artifact'), [])
const marketTech = TECHNIQUES.filter((t) => t.grade <= 3)
const cangshuLv = fam.buildings['cangshu'] ?? 0
const price = (id: string) => marketPrice(w, id)
return (
<div>
<div className="tabs" style={{ padding: 0, background: 'none', border: 'none', marginBottom: 10 }}>
<div className={`tab ${tab === 'goods' ? 'sel' : ''}`} onClick={() => setTab('goods')}></div>
<div className={`tab ${tab === 'tech' ? 'sel' : ''}`} onClick={() => setTab('tech')}></div>
</div>
{tab === 'goods' && (
<>
<div className="help-text">
{Math.round(((fam.flag['priceMult'] as number) ?? 1) * 100)}%
</div>
<table className="market-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>/</th>
<th></th>
</tr>
</thead>
<tbody>
{[...goods, ...artifacts].map((item) => {
const have = item.id === 'lingcao' || item.id === 'lingkuang' || item.id === 'beastcore' || item.kind === 'pill' || item.kind === 'artifact'
? inv[item.id] ?? 0
: 0
const stock = item.kind === 'artifact' ? 3 : item.kind === 'pill' ? 5 : 9999
const count = item.kind === 'artifact' ? 1 : 5
return (
<tr key={item.id}>
<td>
<b><span className="s-icon">{item.icon}</span> {item.name}</b>
</td>
<td className="dim">{item.desc}</td>
<td>{price(item.id)} </td>
<td className="dim">{have}</td>
<td>
<button
className="btn btn-sm"
disabled={fam.stones < price(item.id) * count}
onClick={() => {
buyItem(w, item.id, count)
bump()
}}
>
{count}
</button>{' '}
{((item.kind === 'resource' || item.kind === 'pill') && have >= count) && (
<button
className="btn btn-sm"
onClick={() => {
sellItem(w, item.id, count)
bump()
}}
>
{count}
</button>
)}
{item.kind === 'artifact' && have > 0 && (
<span className="dim2"> </span>
)}
</td>
</tr>
)
})}
<tr>
<td><b><span className="s-icon"></span> </b></td>
<td className="dim">+ </td>
<td className="dim">{fam.buildings['danfang'] ? `${fam.buildings['danfang']}级丹房` : '未建丹房'}</td>
<td className="dim">{inv['lingcao'] ?? 0}</td>
<td>
{fam.buildings['danfang'] && (
<>
<button className="btn btn-sm" onClick={() => { w.craftPill('qiyuan'); bump() }}></button>{' '}
<button className="btn btn-sm" onClick={() => { w.craftPill('ningyuan'); bump() }}></button>
</>
)}
</td>
</tr>
</tbody>
</table>
</>
)}
{tab === 'tech' && (
<>
<div className="help-text">
{cangshuLv} {TECHNIQUE_GRADE_NAMES[Math.min(3, Math.max(0, cangshuLv))]}
</div>
<table className="market-table">
<thead>
<tr>
<th></th>
<th>/</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{marketTech
.filter((t) => t.grade <= 1 + cangshuLv)
.filter((t) => t.grade <= 3)
.map((t) => {
const owned = fam.techniques.includes(t.id)
const p = t.grade <= 1 + cangshuLv
const priceT = [120, 300, 700, 1600, 3600][t.grade] ?? 300
return (
<tr key={t.id}>
<td><b>{t.name}</b></td>
<td className="dim">{TECHNIQUE_GRADE_NAMES[t.grade]} · {t.path}</td>
<td className="dim">{t.desc}</td>
<td>+{Math.round((t.expBonus) * 100)}%</td>
<td>+{Math.round((t.powerBonus) * 100)}%</td>
<td>
{owned ? (
<span className="good"></span>
) : (
<button
className="btn btn-sm"
disabled={!p || fam.stones < priceT}
onClick={() => {
buyTechnique(w, t.id, priceT)
bump()
}}
>
{priceT}
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</>
)}
<div className="dim2" style={{ marginTop: 10 }}>
</div>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useGameStore } from '../store'
export default function SettingsPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const slot = useGameStore((s) => s.slot)
const saveNow = useGameStore((s) => s.saveNow)
const exportSave = useGameStore((s) => s.exportSave)
const importSave = useGameStore((s) => s.importSave)
const refreshSlots = useGameStore((s) => s.refreshSlots)
const go = useGameStore((s) => s.go)
const setSpeed = useGameStore((s) => s.setSpeed)
if (!world) return null
return (
<div>
<div className="card-title"></div>
<div className="set-row">
<span> {slot} </span>
<button className="btn btn-sm" onClick={() => void saveNow()}></button>
</div>
<div className="set-row">
<span>JSON </span>
<button className="btn btn-sm" onClick={() => void exportSave()}></button>
</div>
<div className="set-row">
<span></span>
<button className="btn btn-sm" onClick={() => void importSave(slot)}></button>
</div>
<div className="set-row">
<span></span>
<button className="btn btn-sm" onClick={() => void refreshSlots()}></button>
</div>
<div className="set-row">
<span></span>
<button className="btn btn-sm" onClick={() => setSpeed(0)}></button>
</div>
<div className="set-row">
<span></span>
<button
className="btn btn-sm"
onClick={() => {
void saveNow('离席前').then(() => {
setSpeed(0)
go('boot')
})
}}
>
</button>
</div>
<div className="set-row">
<span></span>
<button className="btn btn-sm" onClick={bump}></button>
</div>
<div className="card-title" style={{ marginTop: 20 }}></div>
<div className="help-text">
· <br />
· <br />
· <br />
· <br />
·
</div>
<div className="dim2"> 0.1.0 · Chronicle of the Immortal Clan</div>
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { useGameStore } from '../store'
import { BUILDINGS } from '../../game/data/buildings'
import { useMemo } from 'react'
export default function TerritoryPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
const fam = world.state.family
const ids = useMemo(() => Object.keys(BUILDINGS), [])
const levelLabel = (l: number) => '·'.repeat(l) + '。'.repeat(5 - l)
return (
<div>
<div className="help-text">
</div>
<div className="bld-grid">
{ids.map((id) => {
const def = BUILDINGS[id]
if (!def) return null
const level = fam.buildings[id] ?? 0
const built = level > 0
const cost = built && level >= def.maxLevel ? null : def.upgradeCost(built ? level + 1 : 1)
const produce = def.produceTable ? def.produceTable(built ? level : 1) : null
const canUpgrade =
!!cost &&
fam.stones >= cost.stones &&
fam.inventory['lingkuang'] >= cost.lingkuang
return (
<div key={id} className={`bld-card ${built ? '' : 'dim2'}`}>
<div className="bld-head">
<span className="bld-icon">{def.icon}</span>
<div>
<div>{def.name}</div>
<div className="dim2" style={{ fontSize: '0.75rem' }}>{def.kind === 'produce' ? '产出' : '功效'}</div>
</div>
<span className="bld-lv">{built ? `${level}` : ''}</span>
</div>
<div className="bld-desc">{def.desc}</div>
{produce && Object.keys(produce).length > 0 && (
<div className="bld-prod">
{Object.entries(produce)
.filter(([k]) => k !== 'beastcore' || (built && level >= 3))
.map(([k, v]) => `${itemLabel(k)} ×${v}`)
.join(' / ')}
/
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto' }}>
{built ? (
<>
{level < def.maxLevel ? (
<button
className="btn btn-sm"
disabled={!canUpgrade}
onClick={() => {
world.upgrade(id)
bump()
}}
>
{level + 1}{cost?.stones ?? 0} + {cost?.lingkuang ?? 0}
</button>
) : (
<span className="gold"></span>
)}
<span className="dim2" style={{ marginLeft: 'auto' }}>{levelLabel(level)}</span>
</>
) : (
<button
className="btn btn-sm btn-primary"
disabled={!canUpgrade}
onClick={() => {
world.build(id)
bump()
}}
>
{cost?.stones ?? 0} + {cost?.lingkuang ?? 0}
</button>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
function itemLabel(k: string): string {
const map: Record<string, string> = {
stone: '灵石',
lingcao: '灵草',
lingkuang: '灵矿',
beastcore: '兽核'
}
return map[k] ?? k
}
+71
View File
@@ -0,0 +1,71 @@
import { useGameStore } from '../store'
import { SaveMeta } from '../../game/types/domain'
import mountainUrl from '../../assets/mountain.svg'
function slotLabel(meta: SaveMeta | null): string {
if (!meta) return '虚位以待'
return `${meta.name} · ${meta.year}${meta.month}月 · ${meta.members}人 · 声望${meta.reputation}`
}
export default function Boot() {
const slots = useGameStore((s) => s.slots)
const go = useGameStore((s) => s.go)
const continueGame = useGameStore((s) => s.continueGame)
const deleteSave = useGameStore((s) => s.deleteSave)
const toast = useGameStore((s) => s.toast)
return (
<div className="boot">
<div className="boot-bg">
<img src={mountainUrl} alt="" />
</div>
<div className="boot-inner">
<div className="boot-title"></div>
<div className="boot-tagline"> · </div>
<div className="boot-divider" />
<div className="boot-en">CHRONICLE OF THE IMMORTAL CLAN</div>
<div className="boot-actions" style={{ marginTop: 26 }}>
<button className="boot-newgame" onClick={() => go('newgame')}>
</button>
{slots.map((meta, i) => (
<div key={i} className="boot-slot">
{meta && <div className="slot-no">{i + 1}</div>}
{meta ? (
<>
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => void continueGame(i + 1)}>
<div className="dim2" style={{ fontSize: '0.72rem', color: '#6d5d40' }}>{i + 1}</div>
<div style={{ color: '#dcc898', letterSpacing: '1px' }}>{slotLabel(meta)}</div>
</div>
<button
className="btn btn-sm btn-danger"
title="删除本档"
onClick={() => {
if (window.confirm(`确定删除第 ${i + 1} 档存档?此操作不可撤销。`)) {
void deleteSave(i + 1)
}
}}
>
</button>
</>
) : (
<div className="boot-slot-empty" style={{ flex: 1 }} onClick={() => go('newgame')}>
<span className="slot-no">{i + 1}</span>
<span style={{ color: '#6d5d40' }}></span>
</div>
)}
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 18 }}>
<span className="seal" style={{ fontSize: '0.62rem' }}></span>
<span style={{ color: '#6d5d40', fontSize: '0.78rem', letterSpacing: '2px' }}>
·
</span>
</div>
</div>
{toast && <div className="toast">{toast}</div>}
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useGameStore } from '../store'
import { PanelId } from '../store'
import { RES_INFO } from '../components'
import FamilyPanel from '../panels/FamilyPanel'
import TerritoryPanel from '../panels/TerritoryPanel'
import MarketPanel from '../panels/MarketPanel'
import DiplomacyPanel from '../panels/DiplomacyPanel'
import ExpeditionPanel from '../panels/ExpeditionPanel'
import ChroniclePanel from '../panels/ChroniclePanel'
import SettingsPanel from '../panels/SettingsPanel'
import { LogFeed } from '../components/LogFeed'
const TABS: { id: PanelId; label: string }[] = [
{ id: 'family', label: '宗族' },
{ id: 'territory', label: '领地' },
{ id: 'market', label: '坊市' },
{ id: 'diplomacy', label: '外交' },
{ id: 'expedition', label: '探秘' },
{ id: 'chronicle', label: '史书' },
{ id: 'settings', label: '设置' }
]
export default function GameScreen() {
const world = useGameStore((s) => s.world)
const panel = useGameStore((s) => s.panel)
const setPanel = useGameStore((s) => s.setPanel)
const revision = useGameStore((s) => s.revision)
const speed = useGameStore((s) => s.speed)
const setSpeed = useGameStore((s) => s.setSpeed)
const advance = useGameStore((s) => s.advance)
const saveNow = useGameStore((s) => s.saveNow)
const toast = useGameStore((s) => s.toast)
const gameOverReason = useGameStore((s) => s.gameOverReason)
void revision
void world
const st = useGameStore.getState()
const w = st.world
if (!w) return null
const s = w.state
const fam = s.family
return (
<div className="app">
<div className="topbar">
<div className="fam-name">{fam.name}</div>
<div className="date">
{s.year}{s.month} · {fam.generation} · {fam.reputation}
</div>
<div className="speed-ctl">
<button className={`speed-btn ${speed === 0 ? 'sel' : ''}`} onClick={() => setSpeed(0)}></button>
<button className={`speed-btn ${speed === 1 ? 'sel' : ''}`} onClick={() => setSpeed(1)}></button>
<button className={`speed-btn ${speed === 2 ? 'sel' : ''}`} onClick={() => setSpeed(2)}></button>
<button className={`speed-btn ${speed === 3 ? 'sel' : ''}`} onClick={() => setSpeed(3)}></button>
<button className="speed-btn" title="推进一月" onClick={() => void advance()}></button>
</div>
<div className="ress">
{RES_INFO.map((r) => (
<div key={r.id} className="res-item" title={r.label}>
<span className="res-icon">{r.icon}</span>
<span>{dispRes(r.id)}</span>
</div>
))}
</div>
<button className="btn btn-sm" title="手动存点" onClick={() => void saveNow()}></button>
</div>
<div className="tabs">
{TABS.map((t) => (
<div key={t.id} className={`tab ${panel === t.id ? 'sel' : ''}`} onClick={() => setPanel(t.id)}>
{t.label}
</div>
))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="dim2">{fam.motto}</span>
</div>
</div>
<div className="main">
<div className="main-left">
{panel === 'family' && <FamilyPanel />}
{panel === 'territory' && <TerritoryPanel />}
{panel === 'market' && <MarketPanel />}
{panel === 'diplomacy' && <DiplomacyPanel />}
{panel === 'expedition' && <ExpeditionPanel />}
{panel === 'chronicle' && <ChroniclePanel />}
{panel === 'settings' && <SettingsPanel />}
{gameOverReason && (
<div className="gameover-banner">
<h1></h1>
<p className="dim" style={{ fontSize: '1.1rem' }}>{gameOverReason}</p>
<p className="dim2" style={{ marginTop: 10 }}>
{s.year} {s.month}
</p>
</div>
)}
</div>
<div className="main-right">
<div className="feed-title"></div>
<LogFeed />
</div>
</div>
{toast && <div className="toast">{toast}</div>}
</div>
)
}
function dispRes(id: string): number {
const st = useGameStore.getState()
const w = st.world
if (!w) return 0
if (id === 'stones') return w.state.family.stones
return w.state.family.inventory[id] ?? 0
}
+93
View File
@@ -0,0 +1,93 @@
import { useMemo, useState } from 'react'
import { useGameStore } from '../store'
import { SURNAME_POOL } from '../../game/core/names'
export default function NewGame() {
const go = useGameStore((s) => s.go)
const startNewGame = useGameStore((s) => s.startNewGame)
const [surname, setSurname] = useState('林')
const [familyName, setFamilyName] = useState('林家')
const [motto, setMotto] = useState('耕读传家,术法继世')
const [difficulty, setDifficulty] = useState<'easy' | 'normal' | 'hard'>('normal')
const [slot, setSlot] = useState(1)
const [randoming, setRandoming] = useState(false)
const seed = useMemo(
() => `${Date.now()}-${Math.floor(Math.random() * 1e9)}-${Math.floor(Math.random() * 1e9)}`,
[randoming]
)
const rollNames = () => {
setRandoming((r) => !r)
const s = SURNAME_POOL[Math.floor(Math.random() * SURNAME_POOL.length)]
setSurname(s)
setFamilyName(`${s}`)
}
return (
<div className="newgame">
<div className="newgame-box">
<div className="card-title" style={{ textAlign: 'center', fontSize: '1.4rem' }}>
</div>
<div className="field">
<label></label>
<input value={surname} onChange={(e) => setSurname(e.target.value)} maxLength={2} />
<button className="btn btn-sm" onClick={rollNames}></button>
</div>
<div className="field">
<label></label>
<input value={familyName} onChange={(e) => setFamilyName(e.target.value)} maxLength={10} placeholder="例如:林氏" />
</div>
<div className="field">
<label></label>
<input value={motto} onChange={(e) => setMotto(e.target.value)} maxLength={20} />
</div>
<div className="field">
<label></label>
<div className="diff-grid">
{(
[
['easy', '顺境', '灵石丰足,时来运转'],
['normal', '中庸', '中规中矩,全靠经营'],
['hard', '逆境', '部曲拮据,波折不断']
] as const
).map(([k, label, desc]) => (
<div
key={k}
className={`diff-opt ${difficulty === k ? 'sel' : ''}`}
onClick={() => setDifficulty(k)}
>
<div>{label}</div>
<div className="dim2" style={{ fontSize: '0.78rem', marginTop: 2 }}>{desc}</div>
</div>
))}
</div>
</div>
<div className="field">
<label></label>
<select value={slot} onChange={(e) => setSlot(Number(e.target.value))}>
<option value={1}></option>
<option value={2}></option>
<option value={3}></option>
<option value={4}></option>
</select>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 8 }}>
<button className="btn" onClick={() => go('boot')}></button>
<button
className="btn btn-primary btn-lg"
onClick={() => {
void startNewGame({ seed, surname, familyName, motto, difficulty }, slot)
}}
>
</button>
</div>
<div className="dim2" style={{ textAlign: 'center' }}>
</div>
</div>
</div>
)
}
+292
View File
@@ -0,0 +1,292 @@
import { create } from 'zustand'
import { World, WorldEventBus } from '../game/engine/world'
import { EventDef } from '../game/data/events'
import { findEvent, applyEventChoice } from '../game/engine/systems/events'
import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta } from '../game/types/domain'
import { getSlotManager, getSaveSlot } from '../game/storage/db'
import { metaFromState, updateSlotMeta } from './storeHelper'
export type Screen = 'boot' | 'newgame' | 'game'
export type PanelId = 'family' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'settings'
export interface GameStore {
screen: Screen
world: World | null
slot: number
slots: (SaveMeta | null)[]
speed: number
timer: ReturnType<typeof setInterval> | null
logFeed: LogItem[]
pendingEventId?: string
pendingEventDef?: EventDef
battleView?: BattleLog
panel: PanelId
selectedMemberId?: string
selectedMissionDef?: string
revision: number
toast?: string
gameOverReason?: string
init: () => Promise<void>
go: (screen: Screen) => void
startNewGame: (opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }, slot: number) => Promise<void>
continueGame: (slot: number) => Promise<void>
loadSnapshot: (slot: number, snapshotId: string) => Promise<void>
advance: () => Promise<void>
addLog: (item: LogItem) => void
onChronicle: (e: ChronicleEntry, important: boolean) => void
onBattle: (log: BattleLog) => void
onPendingEvent: (id: string) => void
onGameOver: (reason: string) => void
saveNow: (label?: string) => Promise<void>
bump: () => void
setSpeed: (v: number) => void
setPanel: (p: PanelId) => void
setSelected: (id?: string) => void
setMissionDef: (id?: string) => void
exportSave: () => Promise<void>
importSave: (slot: number) => Promise<void>
deleteSave: (slot: number) => Promise<void>
refreshSlots: () => Promise<void>
}
const LOG_CAP = 260
let logSeq = 1
export const useGameStore = create<GameStore>((set, get) => ({
screen: 'boot',
world: null,
slot: 1,
slots: [],
speed: 0,
timer: null,
logFeed: [],
pendingEventId: undefined,
pendingEventDef: undefined,
battleView: undefined,
panel: 'family',
selectedMemberId: undefined,
selectedMissionDef: undefined,
revision: 0,
toast: undefined,
gameOverReason: undefined,
init: async () => {
const manager = getSlotManager()
const slots = await manager.listSlotMetas()
set({ slots })
if (typeof window !== 'undefined') {
;(window as unknown as Record<string, unknown>).__cotycBootReady = true
const hack = window as unknown as Record<string, unknown>
hack.__cotycDebug = {
startNewGame: () => get().startNewGame({ seed: 'smoke-' + Date.now(), surname: '林', familyName: '林氏', motto: 'm', difficulty: 'normal' }, 1),
advance: () => get().advance(),
resolvePending: () => {
const st = get()
if (st.pendingEventId && st.world) {
applyEventChoice(st.world, st.pendingEventId, 0)
useGameStore.setState({ pendingEventId: undefined, pendingEventDef: undefined })
st.bump()
}
},
getYear: () => get().world?.state.year ?? 0,
openPanel: (p: string) => {
useGameStore.setState({ panel: p as never })
},
closeBattles: () => {
useGameStore.setState({ battleView: undefined })
},
getChronicle: () => get().world?.state.chronicle?.slice().reverse() ?? [],
getFeed: () => get().logFeed.slice()
}
}
},
go: (screen) => set({ screen }),
startNewGame: async (opts, slot) => {
const world = World.create(opts)
set({ world, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0 })
const st = get()
st.world?.out.push(makeBus(st))
await st.saveNow('开局')
},
continueGame: async (slot) => {
const manager = getSlotManager()
const file = await getSaveSlot(slot)
await file.open()
const state = await file.loadState()
if (!state) throw new Error('找不到存档数据')
openState(state, slot)
await manager.updateSlotMeta(slot, metaFromState(state, slot))
},
loadSnapshot: async (slot, snapshotId) => {
const file = await getSaveSlot(slot)
await file.open()
const state = await file.loadState(snapshotId)
if (!state) throw new Error('找不到快照')
openState(state, slot)
},
advance: async () => {
const st = get()
const w = st.world
if (!w || st.pendingEventId || w.state.gameOver) return
try {
w.advanceMonth()
w.syncRng()
set((s) => ({ revision: s.revision + 1 }))
await Stash.save(st, '自动')
} catch (e) {
console.error(e)
}
},
addLog: (item) => {
set((s) => {
const next = (s.logFeed.length >= LOG_CAP ? s.logFeed.slice(s.logFeed.length - LOG_CAP + 1) : s.logFeed).concat(item)
return { logFeed: next }
})
},
onChronicle: (e, important) => {
if (important) {
set((s) => ({ revision: s.revision + 1 }))
}
},
onBattle: (log) => set({ battleView: log, revision: get().revision + 1 }),
onPendingEvent: (id) => {
const def = findEvent(id)
set({ pendingEventId: id, pendingEventDef: def })
},
onGameOver: (reason) => set({ gameOverReason: reason, speed: 0, revision: get().revision + 1 }),
saveNow: async (label = '手动') => {
await Stash.save(get(), label)
},
bump: () => set((s) => ({ revision: s.revision + 1 })),
setSpeed: (v) => {
const st = get()
if (st.timer) clearInterval(st.timer)
if (v <= 0) {
set({ speed: v, timer: null })
return
}
const ms = v === 1 ? 3200 : v === 2 ? 1800 : 900
if (v >= 3) v = 3
const timer = setInterval(() => {
const cur = get()
if (!cur.pendingEventId && cur.world && !cur.world.state.gameOver) {
void cur.advance()
}
}, ms)
set({ speed: v, timer })
},
setPanel: (p) => set({ panel: p }),
setSelected: (id) => set({ selectedMemberId: id }),
setMissionDef: (id) => set({ selectedMissionDef: id }),
exportSave: async () => {
const st = get()
if (!st.world || !window.api) return
const file = await getSaveSlot(st.slot)
const meta0 = metaFromState(st.world.state, st.slot)
st.world.syncRng()
await file.saveState(st.world.state, '导出前')
const json = await file.exportAll()
const meta = metaFromState(st.world.state, st.slot)
const r = await window.api.exportSave(json, `${meta.name}-年${meta.year}`)
if (r.ok) set({ toast: '存档已导出。' })
else set({ toast: `导出失败:${r.error ?? ''}` })
},
importSave: async (slot) => {
if (!window.api) return
const r = await window.api.importSave()
if (!r.ok || !r.text) {
set({ toast: `导入失败:${r.error ?? ''}` })
return
}
const file = await getSaveSlot(slot)
await file.open()
const ok = await file.importAll(r.text)
if (ok) {
set({ toast: '导入成功,重新读取存档槽。' })
await get().refreshSlots()
} else {
set({ toast: '导入失败:文件格式不正确。' })
}
},
deleteSave: async (slot) => {
const file = await getSaveSlot(slot)
await file.open()
await file.wipe()
const manager = getSlotManager()
await manager.removeSlotMeta(slot)
await get().refreshSlots()
},
refreshSlots: async () => {
const manager = getSlotManager()
const slots = await manager.listSlotMetas()
set({ slots })
}
}))
function openState(state: GameState, slot: number): void {
const world = new World(state)
const st = useGameStore.getState()
world.out.push(makeBus(st))
useGameStore.setState({
world,
slot,
screen: 'game',
panel: 'family',
logFeed: [],
battleView: undefined,
pendingEventId: undefined,
pendingEventDef: undefined,
revision: 1,
speed: 0,
gameOverReason: state.gameOver?.reason
})
}
function makeBus(st: GameStore): WorldEventBus {
return {
onLog: (kind, text) => {
const w = st.world
st.addLog({ id: logSeq++, kind, text, year: w?.state.year ?? 0, month: w?.state.month ?? 0 })
},
onChronicle: (e, important) => {
st.addLog({ id: logSeq++, kind: 'chronicle', text: `${e.year}${e.month}${e.text}`, year: e.year, month: e.month })
if (important) st.onChronicle(e, important)
},
onBattle: (log) => st.onBattle(log),
onPendingEvent: (id) => st.onPendingEvent(id),
onGameOver: (reason) => st.onGameOver(reason)
}
}
const Stash = {
async save(st: GameStore, label: string): Promise<void> {
if (!st.world) return
const file = await getSaveSlot(st.slot)
await file.open()
st.world.syncRng()
await file.saveState(st.world.state, label)
await file.saveChronicle(st.world.state.chronicle)
const meta = metaFromState(st.world.state, st.slot)
await file.setMeta(meta)
await updateSlotMeta(st.slot, meta)
}
}
+24
View File
@@ -0,0 +1,24 @@
import { GameState, SaveMeta } from '../game/types/domain'
import { getSlotManager } from '../game/storage/db'
export function metaFromState(state: GameState, slot: number): SaveMeta {
const alive = Object.values(state.members).filter((c) => c.alive).length
return {
slot,
surname: state.family.surname,
name: state.family.name,
estate: state.family.estate,
year: state.year,
month: state.month,
generation: state.family.generation,
members: alive,
reputation: state.family.reputation,
updatedAt: new Date().toISOString(),
version: state.schemaVersion
}
}
export async function updateSlotMeta(slot: number, meta: SaveMeta): Promise<void> {
const manager = getSlotManager()
await manager.updateSlotMeta(slot, meta)
}
File diff suppressed because it is too large Load Diff