- 指婚/续弦:成员详情可主动找人结亲(血亲自动排除、鳏寡可再醮) - 装备 UI:法宝从府库装备/替换,战力实时反馈 - 御敌点将:劫掠事件可自选迎战阵容(默认 Top4 可改) - 宗祠祭祖:每两年一次,灵石150 → 声望+6/全族修为小升 - 回档时间轴:每季快照列表,一键回卷(回卷前自动保当前档) - 战报匣子:史书页全文战报留存可翻阅 - 年度族簿纸笺:每年初弹收支/人丁/战力简报 - 媒人提示条 + 寻衅一年冷却 + 出生率与劫掠频率微调 - 单测 19→29(storage mock roundtrip/回档/指婚血亲/祭祖/寻衅/装备/账本)
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
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, squad?: string[]): 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, squad)
|
|
if (def.once && !s.completedEvents.includes(def.id)) s.completedEvents.push(def.id)
|
|
}
|
|
s.pendingEvent = undefined
|
|
}
|
|
|
|
export type OptionSquadPicker = (w: World) => { pool: string[]; initial: string[] }
|
|
|
|
// ---------------- 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, squad?: string[]): 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) {
|
|
let 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 (squad && squad.length > 0) {
|
|
team = squad.map((id) => w.memberById(id)).filter((c) => c.alive && c.state !== 'expedition' && w.ageOf(c) >= 16)
|
|
}
|
|
if (team.length > 0) {
|
|
resolveRaid(w, eff.raid.npcId, team)
|
|
fam.flag[`raidCD-${eff.raid.npcId}`] = s.year
|
|
}
|
|
}
|
|
}
|
|
}
|