v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan
家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统 - Electron + React + TS + MetonaSqlark(aria+OPFS) - 水墨中国风 UI - 引擎种子随机、确定性可回放 - 19 项单元测试、端到端冒烟验证
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
out
|
||||
dist
|
||||
release
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,3 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
@metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
@@ -0,0 +1,43 @@
|
||||
# 仙途家族志 · Chronicle of the Immortal Clan
|
||||
|
||||
修仙 × 家族 × 经营 × 战斗的家族模拟器 0.1.0。
|
||||
|
||||
## 玩法
|
||||
|
||||
一座庄园、一家修士、四邻势力,随年月流转:族人修炼突破、建筑产出、探秘寻宝、婚丧嫁娶;
|
||||
仇家犯境则结阵应战;史书自动记下百年兴衰 —— 玩家扮演「家族意志」,延续香火于仙途。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm install # 依赖(含私有 registry:git.metona.cn)
|
||||
npm run dev # 开发模式(热更新)
|
||||
npm test # 引擎单元测试(vitest)
|
||||
npm run typecheck # 类型检查
|
||||
npm run build # 构建 out/
|
||||
npm run package # 打包桌面安装包(electron-builder)
|
||||
```
|
||||
|
||||
冒烟(主进程渲染 + OPFS 数据库就绪检查):
|
||||
|
||||
```bash
|
||||
npm run build && SMOKE_TEST=1 npx electron out/main/index.js --no-sandbox
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Electron + electron-vite + React + TypeScript
|
||||
- 数据库:@metona-team/metona-sqlark 0.7.4(aria 引擎 + OPFS),渲染进程内持久化
|
||||
- 引擎:纯 TypeScript(无 UI 依赖),种子随机数(sfc32),确定性可回放
|
||||
- 存储:每个存档槽一个 SQLark 库(快照滚动 12 份 + 编年史表),JSON 导出导入
|
||||
|
||||
## 存档位置
|
||||
|
||||
OPFS 数据位于 Electron userData 目录下数据库文件(aria 引擎 WAL/SSTable)。
|
||||
备份与迁移请用游戏内「设置 → 导出/导入」。
|
||||
|
||||
## 已知限制(0.1.0)
|
||||
|
||||
- 单机单窗口;MetonaSqlark aria 引擎的多标签页锁不构成影响
|
||||
- 战斗为自动结算战报,不做手动回合操作
|
||||
- 目前仅中文本地化
|
||||
@@ -0,0 +1,24 @@
|
||||
appId: com.metona.cotyc
|
||||
productName: 仙途家族志
|
||||
directories:
|
||||
output: release
|
||||
buildResources: resources
|
||||
files:
|
||||
- out/**
|
||||
- package.json
|
||||
asar: true
|
||||
npmRebuild: false
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
icon: resources/icon.png
|
||||
artifactName: ${productName}-${version}-win-${arch}.${ext}
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
category: Game
|
||||
maintainer: MetonaTeam
|
||||
artifactName: ${productName}-${version}-linux-${arch}.${ext}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
main: {},
|
||||
preload: {},
|
||||
renderer: {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src/renderer')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Generated
+7510
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "chronicle-of-the-immortal-clan",
|
||||
"productName": "仙途家族志",
|
||||
"version": "0.1.0",
|
||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "MetonaTeam",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"package": "npm run build && electron-builder"
|
||||
},
|
||||
"dependencies": {
|
||||
"@metona-team/metona-sqlark": "0.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^2.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"allowScripts": {
|
||||
"electron@33.4.11": true,
|
||||
"esbuild@0.21.5": true
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 MiB |
@@ -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) }
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 |
Vendored
+6
@@ -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 }>
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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})`
|
||||
}
|
||||
@@ -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 } } }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = ['黄阶', '玄阶', '地阶', '天阶', '仙阶']
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: '气' }
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,7 @@
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { matchesCond } from '../src/renderer/game/engine/systems/events'
|
||||
import { Cond } from '../src/renderer/game/data/events'
|
||||
|
||||
export function matchesCondPub(w: World, cond?: Cond): boolean {
|
||||
return matchesCond(w, cond)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Rng, seedToRng } from '../src/renderer/game/core/rng'
|
||||
|
||||
describe('Rng', () => {
|
||||
it('is deterministic for same seed', () => {
|
||||
const a = new Rng(seedToRng('hello'))
|
||||
const b = new Rng(seedToRng('hello'))
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(a.next()).toBe(b.next())
|
||||
}
|
||||
})
|
||||
|
||||
it('differs for different seeds', () => {
|
||||
const a = new Rng(seedToRng('aaa'))
|
||||
const b = new Rng(seedToRng('bbb'))
|
||||
const seq = 20
|
||||
let diff = false
|
||||
for (let i = 0; i < seq; i++) {
|
||||
if (a.next() !== b.next()) diff = true
|
||||
}
|
||||
expect(diff).toBe(true)
|
||||
})
|
||||
|
||||
it('produces values in [0, 1)', () => {
|
||||
const rng = new Rng(seedToRng('x'))
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const v = rng.next()
|
||||
expect(v).toBeGreaterThanOrEqual(0)
|
||||
expect(v).toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('int works within bounds', () => {
|
||||
const rng = new Rng(seedToRng('y'))
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const v = rng.int(0, 10)
|
||||
expect(v).toBeGreaterThanOrEqual(0)
|
||||
expect(v).toBeLessThanOrEqual(10)
|
||||
expect(Number.isInteger(v)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('state roundtrip continues identically', () => {
|
||||
const a = new Rng(seedToRng('z'))
|
||||
const b = new Rng(seedToRng('z'))
|
||||
for (let i = 0; i < 10; i++) {
|
||||
a.next()
|
||||
b.next()
|
||||
}
|
||||
const snap = a.getState()
|
||||
const c = new Rng(snap)
|
||||
expect(c.next()).toBe(b.next())
|
||||
expect(c.next()).toBe(b.next())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { combatPowerOf } from '../src/renderer/game/engine/systems/combat'
|
||||
import { marketPrice, buyItem, sellItem, buyTechnique } from '../src/renderer/game/engine/market'
|
||||
import { resolveBreakthrough } from '../src/renderer/game/engine/systems/cultivation'
|
||||
import { matchesCondPub } from './events.helpers'
|
||||
|
||||
describe('Combat power', () => {
|
||||
it('scales with realm', () => {
|
||||
const w = World.create({ seed: 'p', surname: '赵', familyName: '赵家', motto: 'm', difficulty: 'normal' })
|
||||
const head = w.head()
|
||||
const power = combatPowerOf(w, head)
|
||||
expect(power).toBeGreaterThan(3)
|
||||
power > 0
|
||||
})
|
||||
|
||||
it('higher realm beats lower realm', () => {
|
||||
const w = World.create({ seed: 'p2', surname: '赵', familyName: '赵家', motto: 'm', difficulty: 'normal' })
|
||||
const low = w.state.members['x5']
|
||||
const high = w.state.members['x4']
|
||||
expect(combatPowerOf(w, high)).toBeGreaterThan(combatPowerOf(w, low))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Market', () => {
|
||||
it('buy/sell roundtrip keeps stones consistent', () => {
|
||||
const w = World.create({ seed: 'mk', surname: '王', familyName: '王家', motto: 'm', difficulty: 'normal' })
|
||||
const fam = w.state.family
|
||||
const price = marketPrice(w, 'lingcao')
|
||||
const stones0 = fam.stones
|
||||
const buyOk = buyItem(w, 'lingcao', 11)
|
||||
expect(buyOk).toBe(stones0 >= price * 11)
|
||||
if (buyOk) {
|
||||
expect(fam.inventory['lingcao']).toBe(60 + 11)
|
||||
expect(fam.stones).toBe(stones0 - price * 11)
|
||||
sellItem(w, 'lingcao', 11)
|
||||
expect(fam.stones).toBe(stones0)
|
||||
}
|
||||
})
|
||||
|
||||
it('cannot buy beyond treasury', () => {
|
||||
const w = World.create({ seed: 'mk2', surname: '王', familyName: '王家', motto: 'm', difficulty: 'hard' })
|
||||
w.state.family.stones = 10
|
||||
expect(buyItem(w, 'lingcao', 999)).toBe(false)
|
||||
})
|
||||
|
||||
it('buy technique adds to library once', () => {
|
||||
const w = World.create({ seed: 'mk3', surname: '王', familyName: '王家', motto: 'm', difficulty: 'normal' })
|
||||
w.state.family.stones = 5000
|
||||
expect(buyTechnique(w, 't-zhenyu', 700)).toBe(true)
|
||||
expect(w.state.family.techniques.includes('t-zhenyu')).toBe(true)
|
||||
expect(buyTechnique(w, 't-zhenyu', 700)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Breakthrough', () => {
|
||||
it('advances realm on success', () => {
|
||||
const w = World.create({ seed: 'bt', surname: '李', familyName: '李家', motto: 'm', difficulty: 'easy' })
|
||||
const c = w.state.members['x5']
|
||||
c.realm = { major: 'mortal', minor: 0 }
|
||||
c.realmProgress = 100
|
||||
c.perception = 9
|
||||
c.mind = 9
|
||||
resolveBreakthrough(w, c, 0.3)
|
||||
expect(c.realm.major).toBe('qi')
|
||||
})
|
||||
|
||||
it('failed breakthrough costs progress but not death', () => {
|
||||
const w = World.create({ seed: 'bt2', surname: '李', familyName: '李家', motto: 'm', difficulty: 'normal' })
|
||||
const c = w.state.members['x5']
|
||||
c.realm = { major: 'mortal', minor: 0 }
|
||||
c.realmProgress = 100
|
||||
resolveBreakthrough(w, c, -0.5)
|
||||
// chance was min-clamped; both outcomes acceptable, but check invariants
|
||||
if (c.realm.major === 'mortal') {
|
||||
expect(c.realmProgress).toBeLessThan(100)
|
||||
} else {
|
||||
expect(c.realmProgress).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event conditions', () => {
|
||||
it('leading event ids exist', () => {
|
||||
const w = World.create({ seed: 'evc', surname: '刘', familyName: '刘家', motto: 'm', difficulty: 'normal' })
|
||||
expect(matchesCondPub(w, { minBuilding: { id: 'lingtian', level: 1 } })).toBe(true)
|
||||
expect(matchesCondPub(w, { minBuilding: { id: 'danfang', level: 1 } })).toBe(false)
|
||||
expect(matchesCondPub(w, { minAdult: 3 })).toBe(true)
|
||||
expect(matchesCondPub(w, { maxAdult: 2 })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { World, WorldEventBus } from '../src/renderer/game/engine/world'
|
||||
import { LogItem, ChronicleEntry, BattleLog } from '../src/renderer/game/types/domain'
|
||||
|
||||
const sinks: Set<WorldEventBus> = new Set()
|
||||
|
||||
resetSaveBus()
|
||||
export function resetSaveBus(): void {
|
||||
sinks.clear()
|
||||
}
|
||||
|
||||
export function attachLogSink(w: World): void {
|
||||
const bus: WorldEventBus = {
|
||||
onLog: () => undefined,
|
||||
onChronicle: () => undefined,
|
||||
onBattle: () => undefined,
|
||||
onPendingEvent: () => undefined,
|
||||
onGameOver: () => undefined
|
||||
}
|
||||
sinks.add(bus)
|
||||
w.out.push(bus)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||
|
||||
describe('World engine', () => {
|
||||
it('creates a valid starting family', () => {
|
||||
const w = World.create({ seed: 't1', surname: '林', familyName: '林家', motto: 'm', difficulty: 'normal' })
|
||||
expect(Object.values(w.state.members).length).toBe(5)
|
||||
expect(w.aliveMembers().length).toBe(5)
|
||||
expect(w.state.family.headId).toBe('x1')
|
||||
expect(w.ageOf(w.head())).toBe(35)
|
||||
})
|
||||
|
||||
it('survives 20 years (240 months) without NaN or throws', () => {
|
||||
const w = World.create({ seed: 'smoke', surname: '沈', familyName: '沈氏', motto: 'x', difficulty: 'normal' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
for (let i = 0; i < 240; i++) {
|
||||
if (w.state.gameOver) break
|
||||
w.advanceMonth()
|
||||
}
|
||||
const s = w.state
|
||||
expect(Number.isNaN(s.family.stones)).toBe(false)
|
||||
expect(s.year).toBeGreaterThan(10)
|
||||
for (const c of Object.values(s.members)) {
|
||||
expect(Number.isNaN(c.realmProgress)).toBe(false)
|
||||
expect(Number.isNaN(c.health)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('same seed plays identically (deterministic)', () => {
|
||||
const a = World.create({ seed: 'det', surname: '苏', familyName: '苏家', motto: 'm', difficulty: 'easy' })
|
||||
const b = World.create({ seed: 'det', surname: '苏', familyName: '苏家', motto: 'm', difficulty: 'easy' })
|
||||
resetSaveBus()
|
||||
attachLogSink(a)
|
||||
resetSaveBus()
|
||||
attachLogSink(b)
|
||||
const ignoreRng = (state: { rng: unknown }) => JSON.parse(JSON.stringify(state))
|
||||
for (let i = 0; i < 60; i++) {
|
||||
a.advanceMonth()
|
||||
b.advanceMonth()
|
||||
}
|
||||
a.state.pendingEvent && (a.state.pendingEvent = undefined)
|
||||
b.state.pendingEvent && (b.state.pendingEvent = undefined)
|
||||
const x = ignoreRng({ ...a.state, pendingEvent: undefined })
|
||||
const y = ignoreRng({ ...b.state, pendingEvent: undefined })
|
||||
expect(JSON.stringify(x)).toBe(JSON.stringify(y))
|
||||
})
|
||||
|
||||
it('families grow over time (births occur)', () => {
|
||||
const w = World.create({ seed: 'grow', surname: '叶', familyName: '叶家', motto: 'm', difficulty: 'easy' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
for (let i = 0; i < 240; i++) {
|
||||
if (w.state.gameOver) break
|
||||
w.advanceMonth()
|
||||
}
|
||||
expect(Object.keys(w.state.members).length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it('game over when everyone dies', () => {
|
||||
const w = World.create({ seed: 'diehard', surname: '麦', familyName: '麦家', motto: 'm', difficulty: 'hard' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
for (const c of Object.values(w.state.members)) {
|
||||
c.alive = false
|
||||
}
|
||||
for (let i = 0; i < 5; i++) w.advanceMonth()
|
||||
expect(w.state.gameOver).toBeTruthy()
|
||||
})
|
||||
|
||||
it('building upgrade consumes resources', () => {
|
||||
const w = World.create({ seed: 'bld', surname: '金', familyName: '金家', motto: 'm', difficulty: 'easy' })
|
||||
const before = w.state.family.inventory['lingkuang'] ?? 0
|
||||
w.state.family.inventory['lingkuang'] = (w.state.family.inventory['lingkuang'] ?? 0) + 100
|
||||
const ok = w.build('fangshi')
|
||||
expect(ok).toBe(true)
|
||||
const upgraded = w.upgrade('fangshi')
|
||||
expect(upgraded).toBe(true)
|
||||
expect(w.state.family.buildings['fangshi']).toBe(2)
|
||||
expect((w.state.family.inventory['lingkuang'] ?? 0) - before).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "react-jsx",
|
||||
"isolatedModules": true,
|
||||
"types": ["node", "vite/client"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "electron.vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
pool: 'forks'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user