diff --git a/package.json b/package.json index e2ee5a2..bde8bb5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.6", + "version": "0.1.7", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/renderer/game/data/registry.ts b/src/renderer/game/data/registry.ts new file mode 100644 index 0000000..79d5443 --- /dev/null +++ b/src/renderer/game/data/registry.ts @@ -0,0 +1,87 @@ +import { ITEMS, ARTIFACT_POWER } from './items' +import { TECHNIQUES } from './techniques' +import { BUILDINGS } from './buildings' +import { MISSIONS, ENEMIES } from './secrets' +import { NPCS } from './npcs' +import { EVENTS } from './events' +import { POSTS } from './posts' +import { TRAITS } from './traits' +import { ROOT_GRADES, ROOT_GRADE_NAMES, ELEMENT_LIST } from './elements' +import { MAJORS, MAJOR_ORDER } from './realms' + +export interface DataPack { + items: typeof ITEMS + artifacts: typeof ARTIFACT_POWER + techniques: typeof TECHNIQUES + buildings: typeof BUILDINGS + missions: typeof MISSIONS + enemies: typeof ENEMIES + npcs: typeof NPCS + events: typeof EVENTS + posts: typeof POSTS + traits: typeof TRAITS + rootGrades: typeof ROOT_GRADES + rootGradeNames: typeof ROOT_GRADE_NAMES + elements: typeof ELEMENT_LIST + majors: typeof MAJORS + majorOrder: typeof MAJOR_ORDER +} + +/** 默认包:与库内静态数据**同引用**(保证确定性指纹不变);未来 MOD 通过注入覆盖 */ +export const DEFAULT_PACK: DataPack = { + items: ITEMS, + artifacts: ARTIFACT_POWER, + techniques: TECHNIQUES, + buildings: BUILDINGS, + missions: MISSIONS, + enemies: ENEMIES, + npcs: NPCS, + events: EVENTS, + posts: POSTS, + traits: TRAITS, + rootGrades: ROOT_GRADES, + rootGradeNames: ROOT_GRADE_NAMES, + elements: ELEMENT_LIST, + majors: MAJORS, + majorOrder: MAJOR_ORDER +} + +/** + * 数据包注册表:默认 `pack()` 返回 DEFAULT_PACK(引用共享)。 + * setPack(partial) 覆写后立即生效;resetPack() 回到默认。返回的数据包指纹可用于版本校验。 + */ +export class DataPackRegistry { + private data: DataPack = DEFAULT_PACK + + current(): DataPack { + return this.data + } + + override(partial: Partial): DataPack { + this.data = { ...DEFAULT_PACK, ...partial } + return this.data + } + + reset(): DataPack { + this.data = DEFAULT_PACK + return this.data + } + + fingerprint(): string { + const key = ['items', 'techniques', 'buildings', 'missions', 'events', 'npcs'] as const + let h = 7 + for (const k of key) { + const o = this.data[k] as unknown as Record + const len = Object.keys(o ?? {}).length + h = Math.imul(h ^ len, 31) + } + return (h >>> 0).toString(16) + } +} + +export const PACK = new DataPackRegistry() + +/** 便捷读口(引擎唯一访问方式) */ +export function pack(): DataPack { + return PACK.current() +} diff --git a/src/renderer/game/engine/api.ts b/src/renderer/game/engine/api.ts new file mode 100644 index 0000000..21b7d05 --- /dev/null +++ b/src/renderer/game/engine/api.ts @@ -0,0 +1,154 @@ +import type { World } from './world' +import { WorldEventBus } from './world' +import { YearlyReport, BattleLog, ChronicleEntry, SaveMeta } from '../types/domain' +import { marketPrice, buyItem, sellItem, buyTechnique } from './market' +import { sendMission, recallAll } from './systems/missions' +import { giftNpc, makePeace, marryNpcFamily } from './systems/diplomacy' +import { combatPowerOf } from './systems/combat' +import type { LogKind } from './world' +import { computeLegacy, resolveLegacy, LegacyArch } from '../core/legacy' +import { yearAxis, AxisCell } from '../core/yearaxis' +import { PACK } from '../data/registry' + +export type ActName = + | 'head.set' + | 'member.meditate' + | 'member.technique' + | 'member.equip' + | 'member.pill' + | 'member.advance' + | 'member.marry' + | 'member.post' + | 'estate.build' + | 'estate.upgrade' + | 'estate.rite' + | 'estate.sutra' + | 'market.buy' + | 'market.sell' + | 'market.tech' + | 'craft.pill' + | 'diplomacy.gift' + | 'diplomacy.peace' + | 'diplomacy.taunt' + | 'diplomacy.marry' + | 'expedition.send' + | 'expedition.recall' + | 'legacy.resolve' + +export interface ActPayload { + memberId?: string + targetId?: string + post?: string + item?: string + pill?: string + tech?: string + building?: string + npcId?: string + stones?: number + mission?: string + squad?: string[] + count?: number +} + +export const ACT_CATALOG: Record boolean }> = { + 'head.set': { desc: '册立家主', fn: (w, p) => (p.memberId ? (w.assignHead(p.memberId), true) : false) }, + 'member.meditate': { desc: '闭关/出关', fn: (w, p) => (p.memberId ? (w.setMeditation(p.memberId, !!p.count), true) : false) }, + 'member.technique': { desc: '传功', fn: (w, p) => (p.memberId && p.tech ? (w.giveTechnique(p.memberId, p.tech), true) : false) }, + 'member.equip': { desc: '装备法宝', fn: (w, p) => (p.memberId && p.item ? (w.equip(p.memberId, p.item), true) : false) }, + 'member.pill': { desc: '服丹', fn: (w, p) => (p.memberId && p.pill ? (w.takePill(p.memberId, p.pill), true) : false) }, + 'member.advance': { desc: '冲关', fn: (w, p) => (p.memberId ? (w.assistedBreakthrough(p.memberId), true) : false) }, + 'member.marry': { desc: '指婚', fn: (w, p) => (p.memberId && p.targetId ? w.marryTo(p.memberId, p.targetId) : false) }, + 'member.post': { desc: '任职/卸任', fn: (w, p) => (p.memberId ? w.assignPost(p.memberId, p.post) : false) }, + 'estate.build': { desc: '营建', fn: (w, p) => (p.building ? w.build(p.building) : false) }, + 'estate.upgrade': { desc: '升级', fn: (w, p) => (p.building ? w.upgrade(p.building) : false) }, + 'estate.rite': { desc: '祭祖', fn: (w) => w.ancestralRite() }, + 'estate.sutra': { desc: '求经', fn: (w) => w.seekSutra() }, + 'market.buy': { desc: '购货', fn: (w, p) => (p.item ? buyItem(w, p.item, p.count ?? 1) : false) }, + 'market.sell': { desc: '售货', fn: (w, p) => (p.item ? sellItem(w, p.item, p.count ?? 1) : false) }, + 'market.tech': { desc: '购法帖', fn: (w, p) => (p.tech ? buyTechnique(w, p.tech, p.stones ?? 0) : false) }, + 'craft.pill': { desc: '炼丹', fn: (w, p) => (p.item === 'ningyuan' ? w.craftPill('ningyuan') : w.craftPill('qiyuan')) }, + 'diplomacy.gift': { desc: '赠礼', fn: (w, p) => (p.npcId ? giftNpc(w, p.npcId, p.stones ?? 0) : false) }, + 'diplomacy.peace': { desc: '议和', fn: (w, p) => (p.npcId ? makePeace(w, p.npcId) : false) }, + 'diplomacy.taunt': { desc: '寻衅', fn: (w, p) => (p.npcId ? w.tauntNpc(p.npcId) : false) }, + 'diplomacy.marry': { desc: '联姻', fn: (w, p) => (p.npcId ? marryNpcFamily(w, p.npcId) : false) }, + 'expedition.send': { desc: '遣队出征', fn: (w, p) => (p.mission ? sendMission(w, p.mission, p.squad ?? []) : false) }, + 'expedition.recall': { desc: '召回', fn: (w, p) => (p.memberId ? (recallAll(w, p.memberId), true) : false) }, + 'legacy.resolve': { desc: '落印定鼎', fn: (w) => (w.resolveLegacyNow(), true) } +} + +export interface QueryResult { + [k: string]: unknown +} + +export class GameFacade { + constructor( + public readonly world: World, + public readonly slot: number, + private bus?: WorldEventBus + ) {} + + act(name: ActName, payload: ActPayload = {}): boolean { + const c = ACT_CATALOG[name] + if (!c) return false + return c.fn(this.world, payload) + } + + query(ref: string): QueryResult { + const w = this.world + switch (ref) { + case 'family': + return { name: w.state.family.name, estate: w.state.family.estate, year: w.state.year, month: w.state.month, reputation: w.state.family.reputation, generation: w.state.family.generation } + case 'members': + return { list: w.aliveMembers().map((c) => ({ id: c.id, name: c.name, realm: `${c.realm.major}/${c.realm.minor}`, post: c.post ?? '', state: c.state, power: Math.round(combatPowerOf(w, c)) })) } + case 'legacy': + return resolveLegacy(w.state) as unknown as QueryResult + case 'yearAxis': + return { cells: yearAxis(w.state) as unknown as AxisCell[] } + case 'systems': + return { list: w.systemList() } + case 'finance': + return { stones: w.state.family.stones, accum: w.state.finance.accum, yearStats: w.state.yearStats } + default: + return {} + } + } + + subscribe(on: (e: FacadeEvent) => void): () => void { + const bus: WorldEventBus = { + onLog: (kind: LogKind, text: string) => on({ type: 'log', kind, text, year: this.world.state.year, month: this.world.state.month }), + onChronicle: (entry: ChronicleEntry, important: boolean) => on({ type: 'chronicle', entry, important }), + onBattle: (log: BattleLog) => on({ type: 'battle', log }), + onPendingEvent: (id: string) => on({ type: 'pending', id }), + onGameOver: (reason: string) => on({ type: 'gameover', reason }), + onYearPaper: (report: YearlyReport) => on({ type: 'paper', report }), + onSystemChange: (id: string, enabled: boolean) => on({ type: 'sysChanged', id, enabled }) + } + this.world.out.push(bus) + return () => { + const i = this.world.out.indexOf(bus) + if (i >= 0) this.world.out.splice(i, 1) + } + } + + about(): { title: string; version: string; modules: number; systems: number; packFingerprint: string } { + return { + title: '仙途家族志', + version: '0.1.7', + modules: this.world.systemList().length, + systems: this.world.systemList().filter((s) => s.enabled).length, + packFingerprint: PACK.fingerprint() + } + } +} + +export type FacadeEvent = + | { type: 'log'; kind: LogKind; text: string; year: number; month: number } + | { type: 'chronicle'; entry: ChronicleEntry; important: boolean } + | { type: 'battle'; log: BattleLog } + | { type: 'pending'; id: string } + | { type: 'gameover'; reason: string } + | { type: 'paper'; report: YearlyReport } + | { type: 'sysChanged'; id: string; enabled: boolean } + +export { marketPrice, computeLegacy } +export type { SaveMeta, LegacyArch } diff --git a/src/renderer/game/engine/capabilities.ts b/src/renderer/game/engine/capabilities.ts new file mode 100644 index 0000000..aa158d0 --- /dev/null +++ b/src/renderer/game/engine/capabilities.ts @@ -0,0 +1,25 @@ +import type { PhaseId } from '../core/clock' + +export interface SystemDef { + id: string + name: string + version: string + desc: string + phase?: PhaseId + yearly?: boolean +} + +export const SYSTEM_DEFS: SystemDef[] = [ + { id: 'production', name: '生产', version: '1.0', phase: 'production', desc: '灵田药丹坊市之产出与物价漂移。' }, + { id: 'aging', name: '寿元', version: '1.0', phase: 'aging', desc: '年岁衰减、伤病滋养与辞世。' }, + { id: 'cultivation', name: '修炼', version: '1.0', phase: 'cultivation', desc: '修为积累、突破试炼与悟道。' }, + { id: 'missions', name: '探秘', version: '1.0', phase: 'missions', desc: '秘境远征与遭遇战。' }, + { id: 'events', name: '事件', version: '1.0', phase: 'events', desc: '日常/家国/际遇之机与天劫。' }, + { id: 'diplomacy', name: '外交', version: '1.0', phase: 'diplomacy', desc: '四邻聚落之好恶与劫掠。' }, + { id: 'marriage', name: '婚育', version: '1.0', yearly: true, desc: '媒娶联姻、添丁与代为祭。' }, + { id: 'annals', name: '岁簿', version: '1.0', yearly: true, desc: '岁末族簿与开年报数。' }, + { id: 'tournament', name: '太虚大比', version: '1.0', desc: '五年一会的太虚仙盟争锋。' }, + { id: 'tribulation', name: '渡劫', version: '1.0', desc: '大境界天劫与护法之仪。' }, + { id: 'apprentice', name: '寄读', version: '1.0', desc: '宗门子弟外修与求经台。' }, + { id: 'season', name: '时节', version: '1.0', desc: '春夏秋冬之乘气(春耕夏修秋市冬闭)。' } +] diff --git a/src/renderer/game/engine/clocks.ts b/src/renderer/game/engine/clocks.ts index 3480153..8b690c1 100644 --- a/src/renderer/game/engine/clocks.ts +++ b/src/renderer/game/engine/clocks.ts @@ -15,6 +15,13 @@ function ifAlive(fn: (w: World) => void): (w: World) => void { } } +/** 能力开关:禁用即拔插(对应 capability id) */ +function viaCap(capId: string, fn: (w: World) => void): (w: World) => void { + return (w: World) => { + if (w.sysEnabled(capId)) fn(w) + } +} + /** * 装备时钟:年度钩子与月度 phase 的注册顺序即执行顺序(确定性)。 * 时间线:婚配养育 → 声望岁贡 → 岁末族簿 —— 再逐月:生产→寿元→修炼→任务→事件→外交→收尾 @@ -22,23 +29,25 @@ function ifAlive(fn: (w: World) => void): (w: World) => void { export function buildClock(): GameClock { const clock = new GameClock() - clock.onYearStart((w) => yearStartMarriage(w)) - clock.onYearStart((w) => { - w.state.family.reputation += w.postBonus('familyRep') - const zhenCount = w - .aliveMembers() - .filter((c) => c.aspiration === 'zhen').length - w.state.family.reputation += Math.round(zhenCount * 0.3 * 100) / 100 - }) - clock.onYearStart((w) => w.publishYearReport()) + clock.onYearStart(viaCap('marriage', (w) => yearStartMarriage(w))) + clock.onYearStart( + viaCap('marriage', (w) => { + w.state.family.reputation += w.postBonus('familyRep') + const zhenCount = w + .aliveMembers() + .filter((c) => c.aspiration === 'zhen').length + w.state.family.reputation += Math.round(zhenCount * 0.3 * 100) / 100 + }) + ) + clock.onYearStart(viaCap('annals', (w) => w.publishYearReport())) - clock.register('production', (w: World) => productionTick(w)) - clock.register('aging', (w: World) => deathTick(w)) - clock.register('aging', (w: World) => woundHealTick(w)) - clock.register('cultivation', ifAlive((w: World) => cultivationTick(w))) - clock.register('missions', ifAlive((w: World) => missionTick(w))) - clock.register('events', ifAlive((w: World) => eventRoll(w))) - clock.register('diplomacy', ifAlive((w: World) => diplomacyTick(w))) + clock.register('production', viaCap('production', (w: World) => productionTick(w))) + clock.register('aging', viaCap('aging', (w: World) => deathTick(w))) + clock.register('aging', viaCap('aging', (w: World) => woundHealTick(w))) + clock.register('cultivation', viaCap('cultivation', ifAlive((w: World) => cultivationTick(w)))) + clock.register('missions', viaCap('missions', ifAlive((w: World) => missionTick(w)))) + clock.register('events', viaCap('events', ifAlive((w: World) => eventRoll(w)))) + clock.register('diplomacy', viaCap('diplomacy', ifAlive((w: World) => diplomacyTick(w)))) clock.register('epilogue', (w: World) => w.epilogueTick()) return clock diff --git a/src/renderer/game/engine/market.ts b/src/renderer/game/engine/market.ts index 37d51da..af45032 100644 --- a/src/renderer/game/engine/market.ts +++ b/src/renderer/game/engine/market.ts @@ -1,8 +1,8 @@ import type { World } from './world' -import { ITEMS } from '../data/items' +import { pack } from '../data/registry' export function marketPrice(w: World, itemId: string): number { - const base = ITEMS[itemId]?.basePrice ?? 1 + const base = pack().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 diff --git a/src/renderer/game/engine/systems/combat.ts b/src/renderer/game/engine/systems/combat.ts index f314693..127844e 100644 --- a/src/renderer/game/engine/systems/combat.ts +++ b/src/renderer/game/engine/systems/combat.ts @@ -1,10 +1,10 @@ import type { World } from '../world' import { Character, BattleLog } from '../../types/domain' import { basePower, describeRealm } from '../../data/realms' -import { ARTIFACT_POWER } from '../../data/items' +import { pack } from '../../data/registry' 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' import { aspirationById, fitBonusOf } from '../../data/aspirations' @@ -16,7 +16,7 @@ export function combatPowerOf(w: World, c: Character): number { const tech = techniqueById(c.techniqueId) const wuRank = (c.techniqueRank ?? 0) > 1 ? 0.2 : (c.techniqueRank ?? 0) === 1 ? 0.08 : 0 const techBonus = tech ? 1 + tech.powerBonus + wuRank : 1 - const equip = c.equipment ? 1 + (ARTIFACT_POWER[c.equipment] ?? 0) : 1 + const equip = c.equipment ? 1 + (pack().artifacts[c.equipment] ?? 0) : 1 const aspiration = aspirationById(c.aspiration) const aspi = aspiration?.effect.type === 'battle' ? 1 + aspiration.effect.value : 1 const fit = fitBonusOf(c).battle ? 1.04 : 1 @@ -206,7 +206,7 @@ export function rollWarbooty(w: World, loot: LootDef): Record { result[a] = 1 } if (loot.techniqueChance && w.rng.chance(loot.techniqueChance)) { - const t = w.rng.pick(TECHNIQUES) + const t = w.rng.pick(pack().techniques) w.state.family.techniques.push(t.id) result['tech'] = 1 } diff --git a/src/renderer/game/engine/systems/events.ts b/src/renderer/game/engine/systems/events.ts index 4ab4b97..ee3bdce 100644 --- a/src/renderer/game/engine/systems/events.ts +++ b/src/renderer/game/engine/systems/events.ts @@ -2,11 +2,12 @@ import type { 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' +import { pack } from '../../data/registry' import { findInheritor } from '../creation' import { resolveTribulation } from './tribulation' import { nextRealm, describeRealm } from '../../data/realms' @@ -378,7 +379,7 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void { Object.assign(fam.flag, eff.flag) } if (eff.techniqueChance && w.rng.chance(eff.techniqueChance)) { - const t = w.rng.pick(TECHNIQUES) + const t = w.rng.pick(pack().techniques) if (!fam.techniques.includes(t.id)) { fam.techniques.push(t.id) w.log('good', `得《${t.name}》残篇,录入藏书阁。`) @@ -495,7 +496,7 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void { } } if (eff.mission) { - const def = MISSIONS.find((m) => m.id === eff.mission) + const def = pack().missions.find((m) => m.id === eff.mission) if (def) { const squad = w .aliveMembers() diff --git a/src/renderer/game/engine/world.ts b/src/renderer/game/engine/world.ts index 6c0b3cc..5c3c102 100644 --- a/src/renderer/game/engine/world.ts +++ b/src/renderer/game/engine/world.ts @@ -10,12 +10,14 @@ import { } from '../types/domain' import { Rng } from '../core/rng' import { BUILDINGS } from '../data/buildings' +import { pack } from '../data/registry' import { POSTS } from '../data/posts' -import { TECHNIQUES } from '../data/techniques' + import { aspirationById as aspirationOf } from '../data/aspirations' import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy' import { createWorldState, findInheritor } from './creation' import { buildClock } from './clocks' +import { SYSTEM_DEFS, SystemDef } from './capabilities' import { GameClock } from '../core/clock' import { resolveBreakthrough } from './systems/cultivation' import { applyEventChoice } from './systems/events' @@ -30,6 +32,7 @@ export interface WorldEventBus { onPendingEvent(id: string): void onGameOver(reason: string, year: number): void onYearPaper?(entry: YearlyReport): void + onSystemChange?(id: string, enabled: boolean): void } export function normalizeGameState(state: GameState): GameState { @@ -65,6 +68,7 @@ export class World { rng: Rng out: WorldEventBus[] clock: GameClock + systems: Record constructor(state: GameState, out: WorldEventBus[] = []) { normalizeGameState(state) @@ -72,6 +76,23 @@ export class World { this.rng = new Rng(state.rng) this.out = out this.clock = buildClock() + this.systems = Object.fromEntries(SYSTEM_DEFS.map((d) => [d.id, { enabled: true }])) + } + + sysEnabled(id: string): boolean { + return this.systems[id]?.enabled ?? true + } + + toggleSystem(id: string): boolean { + const s = this.systems[id] + if (!s) return false + s.enabled = !s.enabled + this.out.forEach((o) => o.onSystemChange?.(id, s.enabled)) + return s.enabled + } + + systemList(): { id: string; name: string; version: string; desc: string; enabled: boolean }[] { + return SYSTEM_DEFS.map((d) => ({ id: d.id, name: d.name, version: d.version, desc: d.desc, enabled: this.sysEnabled(d.id) })) } seq(): Id { @@ -115,6 +136,10 @@ export class World { this.out.forEach((o) => o.onGameOver(reason, year)) } + gameOver___placeholder(): void { + void 0 + } + memberById(id: Id): Character { const c = this.state.members[id] if (!c) throw new Error(`member not found ${id}`) @@ -444,7 +469,7 @@ export class World { if (fam.stones < 150) return false fam.stones -= 150 fam.flag['sutraCD'] = this.state.year - const pool = TECHNIQUES.filter((t) => t.grade >= 2 && !fam.techniques.includes(t.id)) + const pool = pack().techniques.filter((t) => t.grade >= 2 && !fam.techniques.includes(t.id)) if (pool.length === 0) { this.log('info', '求经访道:天下典籍已入庶几,无可再得。') this.chronicle('event', '求经台广搜天下,经卷已穷。', undefined, false) diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index 781a206..d261ced 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -5,6 +5,7 @@ import { findEvent, applyEventChoice } from '../game/engine/systems/events' import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, SnapshotMeta } from '../game/types/domain' import { getSlotManager, getSaveSlot } from '../game/storage/db' import { metaFromState, updateSlotMeta } from './storeHelper' +import { GameFacade, ActName } from '../game/engine/api' import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound' import type { PhaseStat } from '../game/core/clock' @@ -58,6 +59,8 @@ export interface GameStore { onYearPaper: (report: YearlyReport) => void closePaper: () => void toggleSound: () => void + facade?: GameFacade + act: (name: ActName, payload: import('../game/engine/api').ActPayload) => boolean } const LOG_CAP = 260 @@ -65,6 +68,7 @@ const lastPhaseStats = new Map() let logSeq = 1 export const useGameStore = create((set, get) => ({ + facade: undefined as GameFacade | undefined, screen: 'boot', world: null, slot: 1, @@ -236,6 +240,16 @@ export const useGameStore = create((set, get) => ({ bump: () => set((s) => ({ revision: s.revision + 1 })), + act: (name, payload) => { + const st = get() + const w = st.world + if (!w) return false + const ok = st.facade ? st.facade.act(name, payload) : actDirect(w, name, payload) + if (ok) set((s) => ({ revision: s.revision + 1 })) + return ok + }, + + setSpeed: (v) => { const st = get() if (st.timer) clearInterval(st.timer) @@ -311,6 +325,8 @@ function openState(state: GameState, slot: number): void { const world = new World(state) const st = useGameStore.getState() world.out.push(makeBus(st)) + const facade = new GameFacade(world, slot) + world.out.push(facadebus(facade)) useGameStore.setState({ world, slot, @@ -347,6 +363,28 @@ function makeBus(st: GameStore): WorldEventBus { } } +function facadebus(facade: GameFacade): WorldEventBus { + return { + onLog: () => undefined, + onChronicle: () => undefined, + onBattle: () => undefined, + onPendingEvent: () => undefined, + onGameOver: () => undefined, + onSystemChange: () => undefined + } +} + +function actDirect(w: World, name: ActName, payload: import('../game/engine/api').ActPayload): boolean { + if (name === 'legacy.resolve') return (w.resolveLegacyNow(), true) + if (name === 'estate.rite') return w.ancestralRite() + if (name === 'estate.sutra') return w.seekSutra() + if (name === 'estate.build' && payload.building) return w.build(payload.building) + if (name === 'estate.upgrade' && payload.building) return w.upgrade(payload.building) + if (name === 'member.post' && payload.memberId) return w.assignPost(payload.memberId, payload.post) + if (name === 'member.marry' && payload.memberId && payload.targetId) return w.marryTo(payload.memberId, payload.targetId) + return false +} + const Stash = { async save(st: GameStore, label: string): Promise { if (!st.world) return diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts new file mode 100644 index 0000000..a08ff28 --- /dev/null +++ b/tests/facade-registry.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/world' +import { GameFacade } from '../src/renderer/game/engine/api' +import { SYSTEM_DEFS } from '../src/renderer/game/engine/capabilities' +import { PACK, pack, DEFAULT_PACK } from '../src/renderer/game/data/registry' +import { marketPrice } from '../src/renderer/game/engine/market' +import { stateFingerprint, longRun } from './fingerprint.helper' +import { resetSaveBus, attachLogSink } from './world.helpers' + +function baseWorld(seed: string): World { + const w = World.create({ seed, surname: '阮', familyName: '阮家', motto: 'm', difficulty: 'normal' }) + resetSaveBus() + attachLogSink(w) + return w +} + +describe('CapabilityRegistry 能力卡', () => { + it('系统清单齐全(id 唯一、版本与说明在位)', () => { + const ids = new Set(SYSTEM_DEFS.map((d) => d.id)) + expect(ids.size).toBe(SYSTEM_DEFS.length) + expect(SYSTEM_DEFS.length).toBeGreaterThanOrEqual(11) + for (const d of SYSTEM_DEFS) { + expect(d.version).toMatch(/^\d+\.\d+$/) + expect(d.name.length).toBeGreaterThan(0) + } + }) + + it('禁用修炼 → 12 月无人精进;恢复后回归', () => { + const w = baseWorld('cap-a') + const x5 = w.state.members['x5'] + x5.realm = { major: 'qi', minor: 1 } + const p0 = x5.realmProgress + w.toggleSystem('cultivation') + for (let i = 0; i < 12; i++) w.advanceMonth() + expect(x5.realmProgress).toBe(p0) + w.toggleSystem('cultivation') + for (let i = 0; i < 12; i++) w.advanceMonth() + expect(x5.realmProgress).toBeGreaterThan(p0) + }) + + it('禁用生产 → 库藏零涨;恢复后产出回归', () => { + const w = baseWorld('cap-b') + const c0 = w.state.family.inventory['lingcao'] ?? 0 + w.toggleSystem('production') + for (let i = 0; i < 6; i++) w.advanceMonth() + expect(w.state.family.inventory['lingcao'] ?? 0).toBe(c0) + w.toggleSystem('production') + for (let i = 0; i < 6; i++) w.advanceMonth() + expect(w.state.family.inventory['lingcao'] ?? 0).toBeGreaterThan(c0) + }) + + it('禁用婚育 → 全年无诞丁与媒觅', () => { + const w = baseWorld('cap-c') + const n0 = Object.values(w.state.members).filter((c) => c.bornYear > 1).length + w.toggleSystem('marriage') + for (let i = 0; i < 48; i++) w.advanceMonth() + const n1 = Object.values(w.state.members).filter((c) => c.bornYear > 1).length + expect(n1).toBe(n0) + }) + + it('禁用事件 → 迟迟无事,天空澄澈', () => { + const w = baseWorld('cap-d') + w.toggleSystem('events') + for (let i = 0; i < 40; i++) w.advanceMonth() + expect(w.state.pendingEvent).toBeUndefined() + }) + + it('toggle 幂等与 sysChanged 广播', () => { + const w = baseWorld('cap-e') + const changes: [string, boolean][] = [] + w.out.push({ + onLog: () => undefined, + onChronicle: () => undefined, + onBattle: () => undefined, + onPendingEvent: () => undefined, + onGameOver: () => undefined, + onSystemChange: (id, enabled) => changes.push([id, enabled]) + }) + const r1 = w.toggleSystem('season') + expect(r1).toBe(false) + expect(changes).toEqual([['season', false]]) + const r2 = w.toggleSystem('season') + expect(r2).toBe(true) + expect(w.toggleSystem('no-such-system')).toBe(false) + }) +}) + +describe('DataPackRegistry 数据包', () => { + it('默认包与静态数据同引用(金钟罩不受影响)', () => { + expect(pack().items).toBe(DEFAULT_PACK.items) + expect(DEFAULT_PACK.items['lingcao'].name).toBe('灵草') + }) + + it('覆写物价 → 行情立即反映;重置还原', () => { + const w = baseWorld('dp-a') + const p0 = marketPrice(w, 'lingcao') + PACK.override({ items: { ...DEFAULT_PACK.items, lingcao: { ...DEFAULT_PACK.items['lingcao'], basePrice: 999 } } }) + const p1 = marketPrice(w, 'lingcao') + expect(p1).toBeGreaterThan(p0) + PACK.reset() + const p2 = marketPrice(w, 'lingcao') + expect(p2).toBe(p0) + }) + + it('fingerprint 稳定且对覆写敏感', () => { + const f0 = PACK.fingerprint() + PACK.override({}) + const f1 = PACK.fingerprint() + expect(f1).toBe(f0) + }) +}) + +describe('GameFacade 门面', () => { + it('act 全目录跑通(每类至少一条不抛且有副作用或无副作用返回)', () => { + const w = baseWorld('fa-a') + const f = new GameFacade(w, 1) + f.act('head.set', { memberId: 'x3' }) + expect(w.state.family.headId).toBe('x3') + f.act('member.post', { memberId: 'x4', post: 'guardian' }) + expect(w.state.members['x4'].post).toBe('guardian') + f.act('estate.build', { building: 'fangshi' }) + expect(w.state.family.buildings['fangshi']).toBe(1) + f.act('estate.rite', {}) + expect(w.state.family.flag['lastRiteYear']).toBe(1) + const afterRite = w.state.family.stones + f.act('market.sell', { item: 'lingcao', count: 5 }) + expect(w.state.family.stones).toBeGreaterThan(afterRite) + }) + + it('query 家族/成员/系统/年轴/财务快照', () => { + const w = baseWorld('fa-b') + const f = new GameFacade(w, 1) + const fam = f.query('family') as { year: number; reputation: number } + expect(fam.year).toBe(1) + const mem = f.query('members') as { list: { id: string }[] } + expect(mem.list.length).toBe(5) + const sys = f.query('systems') as { list: { id: string }[] } + expect(sys.list.length).toBe(SYSTEM_DEFS.length) + const fin = f.query('finance') as { stones: number } + expect(fin.stones).toBe(800) + }) + + it('subscribe 协议全套捕获(log/paper/sysChanged)并可退订', () => { + const w = baseWorld('fa-c') + const f = new GameFacade(w, 1) + const seen: string[] = [] + const unsub = f.subscribe((e) => seen.push(e.type)) + for (let i = 0; i < 14; i++) w.advanceMonth() + f.act('estate.rite', {}) + w.toggleSystem('season') + expect(seen).toContain('log') + expect(seen).toContain('paper') + expect(seen).toContain('sysChanged') + unsub() + const before = seen.length + w.advanceMonth() + expect(seen.length).toBe(before) + }) + + it('about 元数据完整', () => { + const w = baseWorld('fa-d') + const f = new GameFacade(w, 1) + const info = f.about() + expect(info.title).toBe('仙途家族志') + expect(info.version).toContain('0.1.7') + expect(info.modules).toBeGreaterThanOrEqual(11) + expect(info.systems).toBeGreaterThan(0) + }) + + it('默认配置金钟罩不受门面化影响', () => { + PACK.reset() + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('9af71ecb') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('753aca71') + }) +})