v0.1.2: 宗族气韵(职事/悟道/谱系/碑录/庆典/飞升/音效)
- 职事任命:长老(+修炼)/供奉(+战力)/执事(+坊市)/掌教(+闭关) 每人加成叠加,上限管理 - 功法悟道:修习积悟性点,小成/大成逐级+战力,大境界突破传道于直系晚辈 - 宗族谱系页:世代分带 + 夫妻同卷 + 子孙连枝,点节点开详情,先人簿可查 - 功德碑:宗主辞世即勒石,宗祠页碑录永存 - 百年庆典 + 飞升之路事件链(留世坐镇/仙风入体 或 云游飞升/宗族蒙荫) - 合成音效(WebAudio):鼓点/钟鸣/纸韵/磬响,设置页开关 - 单测 29→38;typecheck/build 通过;GUI 冒烟受限于 WSLg 掉线(SMOKE-TIMEOUT 优雅退出)
This commit is contained in:
@@ -22,7 +22,7 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附
|
||||
```
|
||||
|
||||
冒烟会写真实用户数据目录(Linux 下 `~/.config/Electron`),跑完删掉该目录以免脏数据。
|
||||
无显示环境(WSLg 掉线)会打印 `[SMOKE-NO-DISPLAY]` 退出码 2 而非卡死;GUI 冒烟必须在 X11/WSLg 在线时跑(引擎与存储逻辑的回归请靠 vitest,不要依赖 GUI)。
|
||||
无显示环境(WSLg 掉线)会打印 `[SMOKE-TIMEOUT]` 退出码 2 而非卡死;GUI 冒烟必须在 X11/WSLg 在线时跑(引擎与存储逻辑的回归请靠 vitest,不要依赖 GUI)。另注意:`npx electron` 可能拉取**新版** electron 缓存版(与本仓库 33.x 不同),二进制安装以 `node_modules/electron` 为准。
|
||||
|
||||
## 环境坑
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chronicle-of-the-immortal-clan",
|
||||
"productName": "仙途家族志",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "MetonaTeam",
|
||||
|
||||
@@ -47,6 +47,14 @@ function createWindow(): void {
|
||||
|
||||
if (process.env.SMOKE_TEST) {
|
||||
const wc = mainWindow.webContents
|
||||
const smokeWatchdog = setTimeout(() => {
|
||||
console.log('[SMOKE-TIMEOUT] window never loaded (WSLg/X11 likely down); use vitest for logic regression')
|
||||
app.exit(2)
|
||||
}, 20000)
|
||||
wc.once('did-finish-load', () => {
|
||||
clearTimeout(smokeWatchdog)
|
||||
})
|
||||
void smokeWatchdog
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const url = wc.getURL()
|
||||
|
||||
@@ -6,6 +6,7 @@ import GameScreen from './ui/screens/GameScreen'
|
||||
import { EventModal } from './ui/components/EventModal'
|
||||
import { BattleModal } from './ui/components/BattleModal'
|
||||
import { PaperModal } from './ui/components/PaperModal'
|
||||
import { sClick } from './ui/sound'
|
||||
|
||||
export default function App() {
|
||||
const screen = useGameStore((s) => s.screen)
|
||||
@@ -14,6 +15,9 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
useGameStore.getState().init()
|
||||
const clicker = () => sClick()
|
||||
document.addEventListener('click', clicker, true)
|
||||
return () => document.removeEventListener('click', clicker, true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Character } from '../types/domain'
|
||||
import type { World } from '../engine/world'
|
||||
|
||||
export interface FamilyUnit {
|
||||
parents: [Character | undefined, Character | undefined]
|
||||
children: Character[]
|
||||
gen: number
|
||||
}
|
||||
|
||||
export interface GenRow {
|
||||
gen: number
|
||||
singles: Character[]
|
||||
units: FamilyUnit[]
|
||||
}
|
||||
|
||||
export function computeGenealogy(w: World): GenRow[] {
|
||||
const alive = w.aliveMembers()
|
||||
const byGen = new Map<number, Character[]>()
|
||||
for (const c of alive) {
|
||||
const g = byGen.get(c.generation) ?? []
|
||||
g.push(c)
|
||||
byGen.set(c.generation, g)
|
||||
}
|
||||
const gens = [...byGen.keys()].sort((a, b) => a - b)
|
||||
const rows: GenRow[] = []
|
||||
|
||||
for (const gen of gens) {
|
||||
const members = byGen.get(gen)!
|
||||
const units: FamilyUnit[] = []
|
||||
const used = new Set<string>()
|
||||
|
||||
for (const c of members) {
|
||||
if (used.has(c.id)) continue
|
||||
const spouse = c.spouseId ? w.state.members[c.spouseId] : undefined
|
||||
if (spouse && spouse.alive && !used.has(spouse.id)) {
|
||||
used.add(c.id)
|
||||
used.add(spouse.id)
|
||||
const children = [...c.children, ...spouse.children]
|
||||
.filter((id, i, arr) => arr.indexOf(id) === i)
|
||||
.map((id) => w.state.members[id])
|
||||
.filter((ch): ch is Character => !!ch && ch.alive)
|
||||
.sort((a, b) => a.bornYear - b.bornYear)
|
||||
units.push({ parents: [c, spouse], children, gen })
|
||||
}
|
||||
}
|
||||
|
||||
const singles = members.filter((c) => !used.has(c.id))
|
||||
rows.push({ gen, singles, units })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function countDead(w: World): number {
|
||||
return Object.values(w.state.members).filter((c) => !c.alive).length
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export interface EffectDef {
|
||||
techniqueChance?: number
|
||||
artifactChance?: number
|
||||
addTech?: string
|
||||
feisheng?: { stay: boolean }
|
||||
}
|
||||
|
||||
export interface EventOptionDef {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export type PostEffectType = 'expAll' | 'marketIncome' | 'battlePower' | 'meditation' | 'familyRep'
|
||||
|
||||
export interface PostDef {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
desc: string
|
||||
max: number
|
||||
effect: { type: PostEffectType; value: number }
|
||||
}
|
||||
|
||||
export const POSTS: Record<string, PostDef> = {
|
||||
head: {
|
||||
id: 'head',
|
||||
name: '家主',
|
||||
icon: '主',
|
||||
desc: '族纲所系:声望逐年 +1,决策由玩家亲定。',
|
||||
max: 1,
|
||||
effect: { type: 'familyRep', value: 1 }
|
||||
},
|
||||
elder: {
|
||||
id: 'elder',
|
||||
name: '长老',
|
||||
icon: '长',
|
||||
desc: '垂教宗族:全族修炼速度 +5%(每名)。',
|
||||
max: 2,
|
||||
effect: { type: 'expAll', value: 0.05 }
|
||||
},
|
||||
guardian: {
|
||||
id: 'guardian',
|
||||
name: '供奉',
|
||||
icon: '供',
|
||||
desc: '克敌之锐:宗族战力 +8%(每名)。',
|
||||
max: 2,
|
||||
effect: { type: 'battlePower', value: 0.08 }
|
||||
},
|
||||
steward: {
|
||||
id: 'steward',
|
||||
name: '执事',
|
||||
icon: '执',
|
||||
desc: '持家之能:坊市收入 +10%(每名)。',
|
||||
max: 2,
|
||||
effect: { type: 'marketIncome', value: 0.1 }
|
||||
},
|
||||
master: {
|
||||
id: 'master',
|
||||
name: '掌教',
|
||||
icon: '掌',
|
||||
desc: '授道之责:闭关子弟修炼 +6%(每名)。',
|
||||
max: 1,
|
||||
effect: { type: 'meditation', value: 0.06 }
|
||||
}
|
||||
}
|
||||
|
||||
export const POST_ORDER = ['head', 'elder', 'guardian', 'steward', 'master']
|
||||
|
||||
export function postById(id: string | undefined): PostDef | null {
|
||||
if (!id) return null
|
||||
return POSTS[id] ?? null
|
||||
}
|
||||
@@ -15,13 +15,20 @@ export function monthlyRate(w: World, c: Character): number {
|
||||
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
|
||||
if (tech && c.realm.major !== 'mortal') {
|
||||
rate *= 1 + tech.expBonus + (c.techniqueRank ?? 0) * 0.05
|
||||
} else if (c.realm.major !== 'mortal') {
|
||||
rate *= 0.65
|
||||
}
|
||||
const buildings = st.family.buildings
|
||||
const juling = buildings['juling'] ?? 0
|
||||
rate *= 1 + juling * 0.05
|
||||
rate *= 1 + w.postBonus('expAll')
|
||||
if (st.family.flag['fengFeiBless']) rate *= 1.05
|
||||
if (c.traits.includes('fengxian')) rate *= 1.3
|
||||
if (c.state === 'meditation') {
|
||||
rate *= 1.35
|
||||
rate *= 1 + w.postBonus('meditation')
|
||||
const dongfu = buildings['dongfu'] ?? 0
|
||||
rate *= 1 + dongfu * 0.08
|
||||
} else if (c.state === 'expedition') {
|
||||
@@ -41,6 +48,25 @@ export function cultivationTick(w: World): void {
|
||||
const rate = monthlyRate(w, c)
|
||||
if (rate <= 0) continue
|
||||
c.realmProgress = Math.min(100, c.realmProgress + rate)
|
||||
// 悟道进度:有功法且修为之外,另积一分慧根
|
||||
if (c.techniqueId && c.realm.major !== 'mortal') {
|
||||
const rank = c.techniqueRank ?? 0
|
||||
const gain = rate * (rank === 0 ? 0.5 : rank === 1 ? 0.3 : 0.15)
|
||||
c.techniqueProgress = Math.min(100, (c.techniqueProgress ?? 0) + gain)
|
||||
if (c.techniqueProgress >= 100) {
|
||||
if (rank === 0) {
|
||||
c.techniqueRank = 1
|
||||
c.techniqueProgress = 0
|
||||
w.log('good', `✦ ${c.name} 参悟《${techniqueName(c.techniqueId)}》小成,战力精进。`)
|
||||
w.chronicle('breakthrough', `${c.name} 参悟《${techniqueName(c.techniqueId)}》小成。`, c.id, false)
|
||||
} else if (rank === 1) {
|
||||
c.techniqueRank = 2
|
||||
c.techniqueProgress = 0
|
||||
w.log('good', `✦ ${c.name} 于《${techniqueName(c.techniqueId)}》上再进一层,臻至大成!`)
|
||||
w.chronicle('breakthrough', `${c.name} 将《${techniqueName(c.techniqueId)}》精修大成。`, c.id, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
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))) {
|
||||
@@ -50,6 +76,25 @@ export function cultivationTick(w: World): void {
|
||||
}
|
||||
}
|
||||
|
||||
function techniqueName(id: string | undefined): string {
|
||||
const t = techniqueById(id)
|
||||
return t ? t.name : '无名功法'
|
||||
}
|
||||
|
||||
function closestDisciple(w: World, c: Character): Character | null {
|
||||
const heirs = c.children
|
||||
.map((id) => w.state.members[id])
|
||||
.filter((ch): ch is Character => !!ch?.alive && !!ch.techniqueId)
|
||||
.sort((a, b) => rankScore(b) - rankScore(a))
|
||||
if (heirs.length > 0) return heirs[0]
|
||||
return null
|
||||
}
|
||||
|
||||
function rankScore(c: Character): number {
|
||||
const order = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit']
|
||||
return order.indexOf(c.realm.major) * 10 + c.realm.minor
|
||||
}
|
||||
|
||||
export function perAttemptChance(w: World, c: Character): number {
|
||||
const base = breakthroughBaseChance(c.realm)
|
||||
const mind = c.mind * 0.008
|
||||
@@ -73,12 +118,25 @@ export function resolveBreakthrough(w: World, c: Character, boost: number): void
|
||||
c.realmProgress = 0
|
||||
if (majorJump) {
|
||||
c.health = 100
|
||||
// 悟道传承:大境界圆满者,将心得传于同枝晚辈
|
||||
const apprentice = closestDisciple(w, c)
|
||||
if (apprentice) {
|
||||
const cur = apprentice.techniqueProgress ?? 0
|
||||
if (cur < 60) {
|
||||
apprentice.techniqueProgress = Math.max(cur, 60)
|
||||
w.log('info', `${apprentice.name} 承得 ${c.name} 破境感悟,修行一日千里。`)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
if (!w.state.flags['firstSpirit']) {
|
||||
w.state.flags['firstSpirit'] = w.state.year
|
||||
w.log('bad', `天地异象:族中第一位化神出世,仙路大门洞开。`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.health = Math.max(1, c.health - 8 - w.rng.int(0, 10))
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MISSIONS } from '../../data/secrets'
|
||||
import { npcById } from '../../data/npcs'
|
||||
import { resolveRaid } from './combat'
|
||||
import { sendMission } from './missions'
|
||||
import { findInheritor } from '../creation'
|
||||
|
||||
const ALL_EVENTS: EventDef[] = [...EVENTS]
|
||||
|
||||
@@ -31,6 +32,35 @@ export function dynamicEventFor(id: string): EventDef | undefined {
|
||||
]
|
||||
}
|
||||
}
|
||||
if (id === 'ev-centennial') {
|
||||
return {
|
||||
id,
|
||||
name: '百年庆典',
|
||||
category: 'fate',
|
||||
weight: 0,
|
||||
once: true,
|
||||
text: '百年元辰已至:这百年间,苗裔繁衍、香火未断。大宴宾客?还是告天祭祖?',
|
||||
options: [
|
||||
{ label: '大开宴席,张灯结彩', hint: '灵石-200,声望+15,全族心境大悦', eff: { res: { stones: -200 }, rep: 15, memberBy: { by: 'inspire', target: 'all', n: 4 } } },
|
||||
{ label: '设坛告天', hint: '灵石-100,声望+10', eff: { res: { stones: -100 }, rep: 10, flag: { centennial: 'rite' } } },
|
||||
{ label: '阖家简庆', hint: '声望+5', eff: { rep: 5 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
if (id === 'ev-feisheng') {
|
||||
return {
|
||||
id,
|
||||
name: '飞升之路',
|
||||
category: 'fate',
|
||||
weight: 0,
|
||||
once: true,
|
||||
text: '化神之上本为天堑,如今族中已有人触碰仙门。飞升之路敞开:宗族该作何抉择?',
|
||||
options: [
|
||||
{ label: '留仙坐镇', hint: '他留世镇族:天赋大盛', eff: { feisheng: { stay: true } } },
|
||||
{ label: '放仙飞升', hint: '他云游而去寻仙门:宗族蒙荫', eff: { feisheng: { stay: false } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -81,6 +111,18 @@ export function eventRoll(w: World): void {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 里程碑检测(优先于日常事件)
|
||||
if (s.year >= 100 && !s.completedEvents.includes('ev-centennial')) {
|
||||
fire(w, 'ev-centennial')
|
||||
return
|
||||
}
|
||||
const firstSpirit = s.flags['firstSpirit'] as number | undefined
|
||||
if (firstSpirit && s.year - firstSpirit >= 2 && s.year - firstSpirit <= 3 && !s.completedEvents.includes('ev-feisheng')) {
|
||||
fire(w, 'ev-feisheng')
|
||||
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
|
||||
@@ -206,6 +248,30 @@ function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
||||
if (eff.addTech) {
|
||||
if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech)
|
||||
}
|
||||
if (eff.feisheng) {
|
||||
const s2 = w.state
|
||||
const immortal = w
|
||||
.aliveMembers()
|
||||
.sort((a, b) => rankPower(w, b) - rankPower(w, a))[0]
|
||||
if (immortal) {
|
||||
if (eff.feisheng.stay) {
|
||||
if (!immortal.traits.includes('fengxian')) immortal.traits.push('fengxian')
|
||||
w.chronicle('event', `${immortal.name} 谢绝仙门征召,愿镇守此世,护佑宗族万世。`, immortal.id, true)
|
||||
w.log('good', `${immortal.name} 留世坐镇(仙风入体)。`)
|
||||
} else {
|
||||
immortal.alive = false
|
||||
immortal.deathYear = s2.year
|
||||
immortal.deathCause = '云游飞升'
|
||||
s2.family.flag['fengFeiBless'] = true
|
||||
w.chronicle('event', `${immortal.name} 于月首孤身东去,驾鹤而飞。天地留一缕仙风,庇佑宗族。`, immortal.id, true)
|
||||
w.log('good', `${immortal.name} 飞升而去,宗族蒙庇。`)
|
||||
if (s2.family.headId === immortal.id) {
|
||||
const heir = findInheritor(w)
|
||||
if (heir) w.assignHead(heir.id, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (eff.memberBy) {
|
||||
const targets = pickMember(w, eff.memberBy)
|
||||
const by = eff.memberBy.by
|
||||
|
||||
@@ -32,7 +32,7 @@ export function productionTick(w: World): void {
|
||||
parts.push(`灵矿+${v}灵矿`)
|
||||
}
|
||||
if (fangshi > 0) {
|
||||
const v = 55 * fangshi
|
||||
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome')))
|
||||
fam.stones += v
|
||||
parts.push(`坊市+${v}灵石`)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../types/domain'
|
||||
import { Rng } from '../core/rng'
|
||||
import { BUILDINGS } from '../data/buildings'
|
||||
import { POSTS } from '../data/posts'
|
||||
import { productionTick } from './systems/production'
|
||||
import { deathTick, woundHealTick } from './systems/lifecycle'
|
||||
import { cultivationTick, resolveBreakthrough } from './systems/cultivation'
|
||||
@@ -128,6 +129,7 @@ export class World {
|
||||
|
||||
private yearStart(): void {
|
||||
yearStartMarriage(this)
|
||||
this.state.family.reputation += this.postBonus('familyRep')
|
||||
const rep = this.state.family.reputation
|
||||
const power = this.familyPower()
|
||||
const report: YearlyReport = {
|
||||
@@ -164,9 +166,21 @@ export class World {
|
||||
if (!headId) return
|
||||
const head = this.memberById(headId)
|
||||
if (head.alive) return
|
||||
// 功德碑:宗主薨,勒石纪功
|
||||
const reignStart = (s.family.flag['reignStart'] as number | undefined) ?? 1
|
||||
const reignYears = Math.max(1, s.year - reignStart)
|
||||
const peak = s.family.reputation
|
||||
const top = this.aliveMembers().length
|
||||
this.chronicle(
|
||||
'misc',
|
||||
`功德碑:先主${head.name}承宗${reignYears}载,宗族声望达「${peak}」、丁口${top}。族人勒石铭功,立于宗祠。`,
|
||||
head.id,
|
||||
true
|
||||
)
|
||||
const heir = findInheritor(this)
|
||||
if (heir) {
|
||||
this.assignHead(heir.id, true)
|
||||
s.family.flag['reignStart'] = s.year
|
||||
} else if (this.aliveMembers().length === 0) {
|
||||
this.gameOver('满门凋零,香火断绝', s.year)
|
||||
}
|
||||
@@ -186,6 +200,7 @@ export class World {
|
||||
}
|
||||
c.isHead = true
|
||||
this.state.family.headId = id
|
||||
this.state.family.flag['reignStart'] = this.state.year
|
||||
if (!silent) {
|
||||
this.chronicle('misc', `${c.name} 继任为家主。`, c.id, true)
|
||||
this.log('info', `${c.name} 继任为家主。`)
|
||||
@@ -308,9 +323,40 @@ export class World {
|
||||
s.members[c.id] = c
|
||||
}
|
||||
|
||||
postCount(def: string): number {
|
||||
return this.aliveMembers().filter((c) => c.post === def).length
|
||||
}
|
||||
|
||||
assignPost(memberId: Id, postId: string | undefined): boolean {
|
||||
const c = this.memberById(memberId)
|
||||
if (!c.alive) return false
|
||||
if (postId === undefined || postId === '') {
|
||||
c.post = undefined
|
||||
return true
|
||||
}
|
||||
const def = POSTS[postId]
|
||||
if (!def || def.id === 'head') return false
|
||||
if (this.postCount(postId) >= def.max) return false
|
||||
c.post = postId
|
||||
return true
|
||||
}
|
||||
|
||||
postBonus(type: string): number {
|
||||
let sum = 0
|
||||
for (const c of this.aliveMembers()) {
|
||||
const def = POSTS[c.post ?? '']
|
||||
if (def && def.effect.type === type) sum += def.effect.value
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
familyPower(): number {
|
||||
const fam = this.state.family
|
||||
const bonus = 1 + (fam.buildings['yanwu'] ?? 0) * 0.04 + (fam.buildings['lingshou'] ?? 0) * 0.05
|
||||
const bonus =
|
||||
1 +
|
||||
(fam.buildings['yanwu'] ?? 0) * 0.04 +
|
||||
(fam.buildings['lingshou'] ?? 0) * 0.05 +
|
||||
this.postBonus('battlePower')
|
||||
const top = this.aliveMembers()
|
||||
.map((c) => combatPowerOf(this, c))
|
||||
.sort((a, b) => b - a)
|
||||
|
||||
@@ -42,6 +42,9 @@ export interface Character {
|
||||
fortune: number
|
||||
traits: string[]
|
||||
techniqueId?: string
|
||||
techniqueRank?: number
|
||||
techniqueProgress?: number
|
||||
post?: string
|
||||
equipment?: string
|
||||
state: CharState
|
||||
health: number
|
||||
|
||||
@@ -52,6 +52,11 @@ export function MemberCard({ c }: { c: Character }) {
|
||||
)
|
||||
}
|
||||
|
||||
function postLabel(c: Character): string {
|
||||
const map: Record<string, string> = { head: '家主', elder: '长老', guardian: '供奉', steward: '执事', master: '掌教' }
|
||||
return map[c.post ?? ''] ?? ''
|
||||
}
|
||||
|
||||
const C_STATE: Record<string, string> = {
|
||||
idle: '',
|
||||
meditation: 'good',
|
||||
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
import { POSTS, POST_ORDER } from '../../game/data/posts'
|
||||
|
||||
function equipableItems(member: Character, s: GameState): string[] {
|
||||
const inv = Object.keys(ARTIFACT_POWER).filter((ai) => (s.family.inventory[ai] ?? 0) > 0)
|
||||
@@ -185,6 +186,42 @@ export function MemberModal({ member }: { member: Character }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{member.alive && (
|
||||
<div className="mm-sec">
|
||||
<div className="dim" style={{ marginBottom: 6 }}>职事(族中分工)</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className={`btn btn-sm ${!member.post ? 'btn-primary' : ''}`}
|
||||
onClick={() => {
|
||||
w.assignPost(member.id, undefined)
|
||||
bump()
|
||||
}}
|
||||
>
|
||||
闲居
|
||||
</button>
|
||||
{POST_ORDER.filter((pid) => pid !== 'head').map((pid) => {
|
||||
const def = POSTS[pid]
|
||||
const maxed = w.postCount(pid) >= def.max && member.post !== pid
|
||||
return (
|
||||
<button
|
||||
key={pid}
|
||||
className={`btn btn-sm ${member.post === pid ? 'btn-primary' : ''}`}
|
||||
disabled={maxed && !!member.post}
|
||||
style={maxed && !member.post ? { opacity: 0.45 } : undefined}
|
||||
title={`${def.desc}${w.postCount(pid)}/${def.max} 在任`}
|
||||
onClick={() => {
|
||||
w.assignPost(member.id, pid)
|
||||
bump()
|
||||
}}
|
||||
>
|
||||
{def.name}({w.postCount(pid)}/{def.max})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{member.alive && w.canMarry(member.id) && (
|
||||
<div className="mm-sec">
|
||||
<div className="dim" style={{ marginBottom: 6 }}>指婚(寻配族内共居者,远亲无碍)</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useGameStore } from '../store'
|
||||
import { computeGenealogy } from '../../game/core/genealogy'
|
||||
import { describeRealm } from '../../game/data/realms'
|
||||
import { Character, FamilyState } from '../../game/types/domain'
|
||||
import { useState } from 'react'
|
||||
import { MemberModal } from '../components/MemberModal'
|
||||
|
||||
export default function GenealogyPanel() {
|
||||
const world = useGameStore((s) => s.world)
|
||||
const revision = useGameStore((s) => s.revision)
|
||||
const selectedMemberId = useGameStore((s) => s.selectedMemberId)
|
||||
const setSelected = useGameStore((s) => s.setSelected)
|
||||
const [deceasedTab, setDeceasedTab] = useState(false)
|
||||
void revision
|
||||
|
||||
if (!world) return null
|
||||
const w = world
|
||||
const s = w.state
|
||||
const rows = computeGenealogy(w)
|
||||
const deceased = !deceasedTab
|
||||
? []
|
||||
: Object.values(s.members).filter((c) => !c.alive).sort((a, b) => (a.deathYear ?? 0) - (b.deathYear ?? 0))
|
||||
const selected = selectedMemberId ? s.members[selectedMemberId] as Character | undefined : undefined
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="help-text">
|
||||
宗族谱系:世代分带,夫妻同卷,子孙列后。点任一名字可查详情;染灰者已下世,功绩铭记。
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
<button
|
||||
className={`btn btn-sm ${!deceasedTab ? 'btn-primary' : ''}`}
|
||||
onClick={() => setDeceasedTab(false)}
|
||||
>
|
||||
在世谱系
|
||||
</button>
|
||||
<button className={`btn btn-sm ${deceasedTab ? 'btn-primary' : ''}`} onClick={() => setDeceasedTab(true)}>
|
||||
先人簿({Object.values(s.members).filter((c) => !c.alive).length}位)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!deceasedTab &&
|
||||
rows.map((row) => (
|
||||
<div key={row.gen} className="genealogy-row">
|
||||
<div className="genealogy-gen">第{row.gen}代</div>
|
||||
<div className="genealogy-body">
|
||||
{row.units.map((unit, i) => (
|
||||
<div key={i} className="genealogy-unit">
|
||||
<div className="genealogy-parents">
|
||||
{unit.parents[0] && <GeneNode c={unit.parents[0]} w={w} />}
|
||||
<div className="genealogy-matron">×</div>
|
||||
{unit.parents[1] && <GeneNode c={unit.parents[1]} w={w} />}
|
||||
</div>
|
||||
{unit.children.length > 0 && (
|
||||
<>
|
||||
<div className="genealogy-drop" />
|
||||
<div className="genealogy-children">
|
||||
{unit.children.map((ch) => (
|
||||
<GeneNode key={ch.id} c={ch} w={w} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{row.singles.length > 0 && (
|
||||
<div className="genealogy-singles">
|
||||
{row.singles.map((c) => (
|
||||
<GeneNode key={c.id} c={c} w={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{row.units.length + row.singles.length === 0 && <div className="dim2">此代凋零</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{deceasedTab &&
|
||||
deceased.map((c) => (
|
||||
<div key={c.id} className="set-row" style={{ maxWidth: 760 }}>
|
||||
<span>
|
||||
<span className="ch-year" style={{ marginRight: 10 }}>殁于{c.deathYear}年</span>
|
||||
{c.name} · {c.gender === 'male' ? '男' : '女'} · 第{c.generation}代
|
||||
<span className="dim2" style={{ marginLeft: 8 }}>{c.deathCause ?? '寿终'}</span>
|
||||
</span>
|
||||
<button className="btn btn-sm" onClick={() => setSelected(c.id)}>阅</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selected && <MemberModal member={selected} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GeneNode({ c, w }: { c: Character; w: NonNullable<ReturnType<typeof useGameStore.getState>['world']> }) {
|
||||
const setSelected = useGameStore((s) => s.setSelected)
|
||||
const isHead = w.state.family.headId === c.id
|
||||
const rank = c.techniqueRank ?? 0
|
||||
const post = POST_NAME[c.post ?? ''] ?? ''
|
||||
return (
|
||||
<div
|
||||
className={`gene-node ${c.alive ? '' : 'gene-dead'} ${isHead ? 'gene-head' : ''}`}
|
||||
onClick={() => setSelected(c.id)}
|
||||
title={`${c.name} · ${describeRealm(c.realm)} · ${w.ageOf(c)}岁`}
|
||||
>
|
||||
<div className="gene-name">
|
||||
{c.name}
|
||||
{isHead && <span className="tag head-t" style={{ marginLeft: 4 }}>主</span>}
|
||||
{post && <span className="tag" style={{ marginLeft: 4 }}>{post}</span>}
|
||||
{!c.alive && <span className="tag" style={{ marginLeft: 4 }}>殁</span>}
|
||||
</div>
|
||||
<div className="gene-sub">
|
||||
{c.alive ? `${describeRealm(c.realm)} · ${w.ageOf(c)}岁` : `${c.deathCause ?? '寿终'}${c.deathYear ? `(${c.deathYear}年)` : ''}`}
|
||||
{rank > 0 && <span className="tag gold-t" style={{ marginLeft: 4 }}>悟{rank}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const POST_NAME: Record<string, string> = {
|
||||
head: '家主',
|
||||
elder: '长老',
|
||||
guardian: '供奉',
|
||||
steward: '执事',
|
||||
master: '掌教'
|
||||
}
|
||||
|
||||
export type { FamilyState }
|
||||
@@ -13,6 +13,8 @@ export default function SettingsPanel() {
|
||||
const snapshots = useGameStore((s) => s.snapshots)
|
||||
const go = useGameStore((s) => s.go)
|
||||
const setSpeed = useGameStore((s) => s.setSpeed)
|
||||
const soundOn = useGameStore((s) => s.soundOn)
|
||||
const toggleSound = useGameStore((s) => s.toggleSound)
|
||||
if (!world) return null
|
||||
|
||||
return (
|
||||
@@ -63,6 +65,12 @@ export default function SettingsPanel() {
|
||||
<span>刷新存档槽</span>
|
||||
<button className="btn btn-sm" onClick={() => void refreshSlots()}>刷新</button>
|
||||
</div>
|
||||
<div className="set-row">
|
||||
<span>音效(合成音:鼓点·钟鸣·纸韵)</span>
|
||||
<button className="btn btn-sm" onClick={toggleSound}>
|
||||
{soundOn ? '开(点击关)' : '关(点击开)'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="set-row">
|
||||
<span>暂停时间</span>
|
||||
<button className="btn btn-sm" onClick={() => setSpeed(0)}>停止流转</button>
|
||||
|
||||
@@ -104,6 +104,26 @@ export default function TerritoryPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
function MonumentList() {
|
||||
const world = useGameStore((s) => s.world)
|
||||
const revision = useGameStore((s) => s.revision)
|
||||
void revision
|
||||
if (!world) return null
|
||||
const monuments = world.state.chronicle.filter((e) => e.text.startsWith('功德碑')).slice().reverse()
|
||||
if (monuments.length === 0) return null
|
||||
return (
|
||||
<div className="monument-strip">
|
||||
<div className="card-title" style={{ fontSize: '0.98rem' }}>宗祠碑录</div>
|
||||
{monuments.slice(0, 4).map((m) => (
|
||||
<div key={m.id} className="dim" style={{ lineHeight: 1.7 }}>
|
||||
<span className="ch-year" style={{ marginRight: 8 }}>{m.year}年</span>
|
||||
{m.text.slice(3)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function itemLabel(k: string): string {
|
||||
const map: Record<string, string> = {
|
||||
stone: '灵石',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useGameStore } from '../store'
|
||||
import { PanelId } from '../store'
|
||||
import { RES_INFO } from '../components'
|
||||
import FamilyPanel from '../panels/FamilyPanel'
|
||||
import GenealogyPanel from '../panels/GenealogyPanel'
|
||||
import TerritoryPanel from '../panels/TerritoryPanel'
|
||||
import MarketPanel from '../panels/MarketPanel'
|
||||
import DiplomacyPanel from '../panels/DiplomacyPanel'
|
||||
@@ -12,6 +13,7 @@ import { LogFeed } from '../components/LogFeed'
|
||||
|
||||
const TABS: { id: PanelId; label: string }[] = [
|
||||
{ id: 'family', label: '宗族' },
|
||||
{ id: 'genealogy', label: '谱系' },
|
||||
{ id: 'territory', label: '领地' },
|
||||
{ id: 'market', label: '坊市' },
|
||||
{ id: 'diplomacy', label: '外交' },
|
||||
@@ -78,6 +80,7 @@ export default function GameScreen() {
|
||||
<div className="main">
|
||||
<div className="main-left">
|
||||
{panel === 'family' && <FamilyPanel />}
|
||||
{panel === 'genealogy' && <GenealogyPanel />}
|
||||
{panel === 'territory' && <TerritoryPanel />}
|
||||
{panel === 'market' && <MarketPanel />}
|
||||
{panel === 'diplomacy' && <DiplomacyPanel />}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// 合成音效:鼓点·钟鸣·纸韵(Web Audio,零素材依赖)
|
||||
let ctx: AudioContext | null = null
|
||||
let enabled = true
|
||||
|
||||
export function setSoundEnabled(on: boolean): void {
|
||||
enabled = on
|
||||
}
|
||||
|
||||
export function isSoundEnabled(): boolean {
|
||||
return enabled
|
||||
}
|
||||
|
||||
function ac(): AudioContext | null {
|
||||
if (!enabled) return null
|
||||
try {
|
||||
if (!ctx) {
|
||||
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||||
ctx = new AC()
|
||||
}
|
||||
if (ctx.state === 'suspended') void ctx.resume()
|
||||
return ctx
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function tone(freq: number, dur: number, gain: number, type: OscillatorType = 'sine', when = 0, slideTo?: number): void {
|
||||
const a = ac()
|
||||
if (!a) return
|
||||
const t0 = a.currentTime + when
|
||||
const o = a.createOscillator()
|
||||
const g = a.createGain()
|
||||
o.type = type
|
||||
o.frequency.setValueAtTime(freq, t0)
|
||||
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur)
|
||||
g.gain.setValueAtTime(0, t0)
|
||||
g.gain.linearRampToValueAtTime(gain, t0 + 0.012)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
|
||||
o.connect(g)
|
||||
g.connect(a.destination)
|
||||
o.start(t0)
|
||||
o.stop(t0 + dur + 0.05)
|
||||
}
|
||||
|
||||
function noise(dur: number, gain: number, when = 0, freq = 3200): void {
|
||||
const a = ac()
|
||||
if (!a) return
|
||||
const t0 = a.currentTime + when
|
||||
const len = Math.floor(a.sampleRate * dur)
|
||||
const buf = a.createBuffer(1, len, a.sampleRate)
|
||||
const data = buf.getChannelData(0)
|
||||
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len)
|
||||
const src = a.createBufferSource()
|
||||
src.buffer = buf
|
||||
const f = a.createBiquadFilter()
|
||||
f.type = 'highpass'
|
||||
f.frequency.value = freq
|
||||
const g = a.createGain()
|
||||
g.gain.setValueAtTime(gain, t0)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
|
||||
src.connect(f)
|
||||
f.connect(g)
|
||||
g.connect(a.destination)
|
||||
src.start(t0)
|
||||
}
|
||||
|
||||
/** 月度推进:极轻的硖墨声 */
|
||||
export function sTick(): void {
|
||||
noise(0.05, 0.015, 0, 5000)
|
||||
}
|
||||
|
||||
/** 开卷 / 纸笺 */
|
||||
export function sPaper(): void {
|
||||
noise(0.12, 0.03, 0, 2400)
|
||||
tone(620, 0.1, 0.015, 'sine', 0.02)
|
||||
}
|
||||
|
||||
/** 好消息:铜钟一声 */
|
||||
export function sGood(): void {
|
||||
tone(660, 0.5, 0.06, 'sine')
|
||||
tone(990, 0.42, 0.03, 'sine', 0.05)
|
||||
tone(1320, 0.3, 0.018, 'sine', 0.09)
|
||||
}
|
||||
|
||||
/** 坏消息:折磬低响 */
|
||||
export function sBad(): void {
|
||||
tone(196, 0.4, 0.05, 'triangle', 0, 130)
|
||||
tone(131, 0.5, 0.04, 'triangle', 0.04, 82)
|
||||
}
|
||||
|
||||
/** 战报:鼓点急雨 */
|
||||
export function sWar(): void {
|
||||
tone(120, 0.16, 0.07, 'sine', 0, 60)
|
||||
tone(120, 0.16, 0.06, 'sine', 0.16, 60)
|
||||
tone(160, 0.3, 0.06, 'sine', 0.32, 80)
|
||||
noise(0.28, 0.02, 0.36, 1500)
|
||||
}
|
||||
|
||||
/** 钟鸣:大事件(碑立/飞升) */
|
||||
export function sBell(): void {
|
||||
tone(392, 1.4, 0.06, 'sine')
|
||||
tone(494, 1.2, 0.035, 'sine', 0.08)
|
||||
tone(587, 1.0, 0.028, 'sine', 0.16)
|
||||
}
|
||||
|
||||
/** 突破:磬缶清越 */
|
||||
export function sGong(): void {
|
||||
tone(440, 0.9, 0.05, 'sine')
|
||||
tone(660, 0.7, 0.03, 'sine', 0.06)
|
||||
}
|
||||
|
||||
/** 按钮:纸面轻叩 */
|
||||
export function sClick(): void {
|
||||
noise(0.045, 0.03, 0, 4200)
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import { findEvent, applyEventChoice } from '../game/engine/systems/events'
|
||||
import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, SnapshotMeta } from '../game/types/domain'
|
||||
import { getSlotManager, getSaveSlot } from '../game/storage/db'
|
||||
import { metaFromState, updateSlotMeta } from './storeHelper'
|
||||
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
|
||||
|
||||
export type Screen = 'boot' | 'newgame' | 'game'
|
||||
export type PanelId = 'family' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'settings'
|
||||
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'settings'
|
||||
|
||||
export interface GameStore {
|
||||
screen: Screen
|
||||
@@ -28,6 +29,7 @@ export interface GameStore {
|
||||
gameOverReason?: string
|
||||
paperReport?: YearlyReport
|
||||
snapshots: SnapshotMeta[]
|
||||
soundOn: boolean
|
||||
|
||||
init: () => Promise<void>
|
||||
go: (screen: Screen) => void
|
||||
@@ -54,6 +56,7 @@ export interface GameStore {
|
||||
refreshSnapshots: () => Promise<void>
|
||||
onYearPaper: (report: YearlyReport) => void
|
||||
closePaper: () => void
|
||||
toggleSound: () => void
|
||||
}
|
||||
|
||||
const LOG_CAP = 260
|
||||
@@ -78,6 +81,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
gameOverReason: undefined,
|
||||
paperReport: undefined,
|
||||
snapshots: [],
|
||||
soundOn: true,
|
||||
|
||||
init: async () => {
|
||||
const manager = getSlotManager()
|
||||
@@ -145,6 +149,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
try {
|
||||
w.advanceMonth()
|
||||
w.syncRng()
|
||||
sTick()
|
||||
set((s) => ({ revision: s.revision + 1 }))
|
||||
await Stash.save(st, '自动')
|
||||
} catch (e) {
|
||||
@@ -176,12 +181,19 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
|
||||
onYearPaper: (report) => {
|
||||
if (report.year >= 2) {
|
||||
sPaper()
|
||||
set({ paperReport: report, speed: 0 })
|
||||
}
|
||||
},
|
||||
|
||||
closePaper: () => set({ paperReport: undefined }),
|
||||
|
||||
toggleSound: () => {
|
||||
const on = !get().soundOn
|
||||
set({ soundOn: on })
|
||||
setSoundEnabled(on)
|
||||
},
|
||||
|
||||
restoreSnapshot: async (snapshotId: string) => {
|
||||
const st = get()
|
||||
const file = await getSaveSlot(st.slot)
|
||||
@@ -299,6 +311,10 @@ function openState(state: GameState, slot: number): void {
|
||||
function makeBus(st: GameStore): WorldEventBus {
|
||||
return {
|
||||
onLog: (kind, text) => {
|
||||
if (kind === 'good') sGood()
|
||||
else if (kind === 'bad') sBad()
|
||||
else if (kind === 'war') sWar()
|
||||
else if (kind === 'chronicle') sBell()
|
||||
const w = st.world
|
||||
st.addLog({ id: logSeq++, kind, text, year: w?.state.year ?? 0, month: w?.state.month ?? 0 })
|
||||
},
|
||||
|
||||
@@ -1229,6 +1229,105 @@ body {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* ---------- 谱系 ---------- */
|
||||
.genealogy-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
background: linear-gradient(180deg, rgba(242, 233, 212, 0.035), rgba(242, 233, 212, 0.015));
|
||||
border: 1px solid rgba(140, 120, 80, 0.22);
|
||||
border-radius: 3px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.genealogy-gen {
|
||||
writing-mode: vertical-rl;
|
||||
color: #cdaa63;
|
||||
letter-spacing: 6px;
|
||||
font-size: 0.95rem;
|
||||
border-right: 1px solid rgba(140, 120, 80, 0.25);
|
||||
padding-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.genealogy-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.genealogy-unit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border-left: 3px solid rgba(150, 128, 88, 0.35);
|
||||
padding-left: 10px;
|
||||
}
|
||||
.genealogy-parents {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.genealogy-matron {
|
||||
color: #a09374;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.genealogy-drop {
|
||||
height: 12px;
|
||||
border-left: 2px solid rgba(150, 128, 88, 0.4);
|
||||
margin-left: 30px;
|
||||
}
|
||||
.genealogy-children,
|
||||
.genealogy-singles {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.gene-node {
|
||||
background: linear-gradient(180deg, #f4ecd9, #e7d6b4);
|
||||
color: #2e2415;
|
||||
border: 1px solid #b3a075;
|
||||
border-radius: 3px;
|
||||
padding: 5px 10px;
|
||||
min-width: 108px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 252, 240, 0.6);
|
||||
transition: all 0.14s;
|
||||
}
|
||||
.gene-node:hover {
|
||||
border-color: var(--gold);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.gene-dead {
|
||||
filter: saturate(0.4);
|
||||
opacity: 0.55;
|
||||
background: linear-gradient(180deg, #d9cfb8, #c8bb9e);
|
||||
}
|
||||
.gene-head {
|
||||
border-color: var(--vermilion);
|
||||
box-shadow: 0 0 0 1px rgba(169, 59, 46, 0.25), 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.gene-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.gene-sub {
|
||||
font-size: 0.75rem;
|
||||
color: #6d5b3e;
|
||||
margin-top: 1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.gene-node .tag {
|
||||
font-size: 0.62rem;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.genealogy-gen + .genealogy-body:has(.genealogy-singles:empty) .genealogy-singles {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------- 媒人提示 / 年度族簿 ---------- */
|
||||
.matchmaker-strip {
|
||||
background: linear-gradient(180deg, rgba(240, 214, 160, 0.12), rgba(240, 214, 160, 0.05));
|
||||
@@ -1307,3 +1406,15 @@ body {
|
||||
margin-bottom: 16px;
|
||||
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* ---------- 宗祠碑录 ---------- */
|
||||
.monument-strip {
|
||||
margin-bottom: 14px;
|
||||
background: rgba(20, 15, 9, 0.72);
|
||||
border: 1px solid #33291a;
|
||||
border-radius: 3px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.monument-strip .card-title {
|
||||
color: #cdaa63;
|
||||
}
|
||||
|
||||
+113
-1
@@ -2,8 +2,11 @@ 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 { resolveBreakthrough, monthlyRate } from '../src/renderer/game/engine/systems/cultivation'
|
||||
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
|
||||
import { computeGenealogy } from '../src/renderer/game/core/genealogy'
|
||||
import { matchesCondPub } from './events.helpers'
|
||||
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||
|
||||
describe('Combat power', () => {
|
||||
it('scales with realm', () => {
|
||||
@@ -153,3 +156,112 @@ describe('Yearly report', () => {
|
||||
expect(r.power).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Posts (职事)', () => {
|
||||
it('assigns up to max and rejects over-cap', () => {
|
||||
const w = World.create({ seed: 'posts', surname: '徐', familyName: '徐家', motto: 'm', difficulty: 'normal' })
|
||||
expect(w.assignPost('x3', 'elder')).toBe(true)
|
||||
expect(w.assignPost('x5', 'elder')).toBe(true)
|
||||
expect(w.assignPost('x4', 'elder')).toBe(false)
|
||||
expect(w.postCount('elder')).toBe(2)
|
||||
})
|
||||
|
||||
it('elder boosts cultivation rate', () => {
|
||||
const w = World.create({ seed: 'posts2', surname: '许', familyName: '许家', motto: 'm', difficulty: 'normal' })
|
||||
const c = w.state.members['x5']
|
||||
w.state.members['x3'].state = 'meditation'
|
||||
c.realm = { major: 'qi', minor: 1 }
|
||||
const r0 = monthlyRate(w, c)
|
||||
w.assignPost('x3', 'elder')
|
||||
w.assignPost('x4', 'elder')
|
||||
const r1 = monthlyRate(w, c)
|
||||
expect(r1).toBeGreaterThan(r0)
|
||||
})
|
||||
|
||||
it('steward boosts market income', () => {
|
||||
const w = World.create({ seed: 'posts3', surname: '邓', familyName: '邓家', motto: 'm', difficulty: 'normal' })
|
||||
w.state.family.buildings['fangshi'] = 2
|
||||
w.assignPost('x3', 'steward')
|
||||
const stones0 = w.state.family.stones
|
||||
w.advanceMonth()
|
||||
expect(w.state.family.stones).toBeGreaterThan(stones0 + 110)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Technique wudao (悟道)', () => {
|
||||
it('accumulates progress and ranks up talents', () => {
|
||||
const w = World.create({ seed: 'wudao', surname: '康', familyName: '康家', motto: 'm', difficulty: 'easy' })
|
||||
const c = w.state.members['x4']
|
||||
c.realm = { major: 'qi', minor: 1 }
|
||||
c.realmProgress = 0
|
||||
c.techniqueProgress = 95
|
||||
c.techniqueRank = 0
|
||||
c.techniqueId = 't-houtu'
|
||||
c.perception = 9
|
||||
for (let i = 0; i < 30; i++) w.advanceMonth()
|
||||
expect(c.techniqueRank ?? 0).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('carries wudao to a disciple on major jump', () => {
|
||||
const w = World.create({ seed: 'wd2', surname: '封', familyName: '封家', motto: 'm', difficulty: 'easy' })
|
||||
const c = w.state.members['x1']
|
||||
c.realm = { major: 'qi', minor: 9 }
|
||||
c.realmProgress = 100
|
||||
c.mind = 9
|
||||
c.perception = 9
|
||||
const child = w.state.members['x3']
|
||||
child.techniqueId = 't-houtu'
|
||||
child.techniqueProgress = 10
|
||||
child.realm = { major: 'qi', minor: 2 }
|
||||
resolveBreakthrough(w, c, 0.5)
|
||||
expect(c.realm.major).toBe('foundation')
|
||||
expect(child.techniqueProgress).toBeGreaterThanOrEqual(60)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Monument & milestones', () => {
|
||||
it('writes monument on head death, inherits reign', () => {
|
||||
const w = World.create({ seed: 'mon', surname: '侯', familyName: '侯家', motto: 'm', difficulty: 'normal' })
|
||||
const head = w.state.members['x1']
|
||||
head.alive = false
|
||||
head.deathYear = w.state.year
|
||||
w.advanceMonth()
|
||||
const monument = w.state.chronicle.find((e) => e.text.startsWith('功德碑'))
|
||||
expect(monument).toBeTruthy()
|
||||
expect(w.state.family.headId).toBe('x3')
|
||||
expect(w.state.family.flag['reignStart']).toBe(w.state.year)
|
||||
})
|
||||
|
||||
it('triggers centennial once at year 100', () => {
|
||||
const w = World.create({ seed: 'cen', surname: '欧阳', familyName: '欧阳家', motto: 'm', difficulty: 'normal' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
w.state.year = 99
|
||||
w.state.month = 12
|
||||
for (let i = 0; i < 3; i++) w.advanceMonth()
|
||||
expect(w.state.pendingEvent).toBe('ev-centennial')
|
||||
})
|
||||
|
||||
it('feisheng effect grants trail or ascends', () => {
|
||||
const w = World.create({ seed: 'fs', surname: '皇甫', familyName: '皇甫家', motto: 'm', difficulty: 'normal' })
|
||||
const c = w.state.members['x4']
|
||||
c.realm = { major: 'spirit', minor: 1 }
|
||||
applyEventChoice(w, 'ev-feisheng', 0)
|
||||
expect(c.traits).toContain('fengxian')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Genealogy', () => {
|
||||
it('builds rows per generation with couple units', () => {
|
||||
const w = World.create({ seed: 'gene', surname: '惠', familyName: '惠家', motto: 'm', difficulty: 'normal' })
|
||||
const rows = computeGenealogy(w)
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2)
|
||||
const second = rows.find((r) => r.gen === 2)
|
||||
expect(second).toBeTruthy()
|
||||
const units = second!.units
|
||||
// x1/x2 couple should appear in gen1
|
||||
const g1 = rows.find((r) => r.gen === 1)!
|
||||
expect(g1.units.some((u) => u.parents[0]?.id === 'x1' || u.parents[1]?.id === 'x1')).toBe(true)
|
||||
expect(units).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user