v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan
家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统 - Electron + React + TS + MetonaSqlark(aria+OPFS) - 水墨中国风 UI - 引擎种子随机、确定性可回放 - 19 项单元测试、端到端冒烟验证
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user