v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan
家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统 - Electron + React + TS + MetonaSqlark(aria+OPFS) - 水墨中国风 UI - 引擎种子随机、确定性可回放 - 19 项单元测试、端到端冒烟验证
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { matchesCond } from '../src/renderer/game/engine/systems/events'
|
||||
import { Cond } from '../src/renderer/game/data/events'
|
||||
|
||||
export function matchesCondPub(w: World, cond?: Cond): boolean {
|
||||
return matchesCond(w, cond)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Rng, seedToRng } from '../src/renderer/game/core/rng'
|
||||
|
||||
describe('Rng', () => {
|
||||
it('is deterministic for same seed', () => {
|
||||
const a = new Rng(seedToRng('hello'))
|
||||
const b = new Rng(seedToRng('hello'))
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(a.next()).toBe(b.next())
|
||||
}
|
||||
})
|
||||
|
||||
it('differs for different seeds', () => {
|
||||
const a = new Rng(seedToRng('aaa'))
|
||||
const b = new Rng(seedToRng('bbb'))
|
||||
const seq = 20
|
||||
let diff = false
|
||||
for (let i = 0; i < seq; i++) {
|
||||
if (a.next() !== b.next()) diff = true
|
||||
}
|
||||
expect(diff).toBe(true)
|
||||
})
|
||||
|
||||
it('produces values in [0, 1)', () => {
|
||||
const rng = new Rng(seedToRng('x'))
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const v = rng.next()
|
||||
expect(v).toBeGreaterThanOrEqual(0)
|
||||
expect(v).toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('int works within bounds', () => {
|
||||
const rng = new Rng(seedToRng('y'))
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const v = rng.int(0, 10)
|
||||
expect(v).toBeGreaterThanOrEqual(0)
|
||||
expect(v).toBeLessThanOrEqual(10)
|
||||
expect(Number.isInteger(v)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('state roundtrip continues identically', () => {
|
||||
const a = new Rng(seedToRng('z'))
|
||||
const b = new Rng(seedToRng('z'))
|
||||
for (let i = 0; i < 10; i++) {
|
||||
a.next()
|
||||
b.next()
|
||||
}
|
||||
const snap = a.getState()
|
||||
const c = new Rng(snap)
|
||||
expect(c.next()).toBe(b.next())
|
||||
expect(c.next()).toBe(b.next())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { combatPowerOf } from '../src/renderer/game/engine/systems/combat'
|
||||
import { marketPrice, buyItem, sellItem, buyTechnique } from '../src/renderer/game/engine/market'
|
||||
import { resolveBreakthrough } from '../src/renderer/game/engine/systems/cultivation'
|
||||
import { matchesCondPub } from './events.helpers'
|
||||
|
||||
describe('Combat power', () => {
|
||||
it('scales with realm', () => {
|
||||
const w = World.create({ seed: 'p', surname: '赵', familyName: '赵家', motto: 'm', difficulty: 'normal' })
|
||||
const head = w.head()
|
||||
const power = combatPowerOf(w, head)
|
||||
expect(power).toBeGreaterThan(3)
|
||||
power > 0
|
||||
})
|
||||
|
||||
it('higher realm beats lower realm', () => {
|
||||
const w = World.create({ seed: 'p2', surname: '赵', familyName: '赵家', motto: 'm', difficulty: 'normal' })
|
||||
const low = w.state.members['x5']
|
||||
const high = w.state.members['x4']
|
||||
expect(combatPowerOf(w, high)).toBeGreaterThan(combatPowerOf(w, low))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Market', () => {
|
||||
it('buy/sell roundtrip keeps stones consistent', () => {
|
||||
const w = World.create({ seed: 'mk', surname: '王', familyName: '王家', motto: 'm', difficulty: 'normal' })
|
||||
const fam = w.state.family
|
||||
const price = marketPrice(w, 'lingcao')
|
||||
const stones0 = fam.stones
|
||||
const buyOk = buyItem(w, 'lingcao', 11)
|
||||
expect(buyOk).toBe(stones0 >= price * 11)
|
||||
if (buyOk) {
|
||||
expect(fam.inventory['lingcao']).toBe(60 + 11)
|
||||
expect(fam.stones).toBe(stones0 - price * 11)
|
||||
sellItem(w, 'lingcao', 11)
|
||||
expect(fam.stones).toBe(stones0)
|
||||
}
|
||||
})
|
||||
|
||||
it('cannot buy beyond treasury', () => {
|
||||
const w = World.create({ seed: 'mk2', surname: '王', familyName: '王家', motto: 'm', difficulty: 'hard' })
|
||||
w.state.family.stones = 10
|
||||
expect(buyItem(w, 'lingcao', 999)).toBe(false)
|
||||
})
|
||||
|
||||
it('buy technique adds to library once', () => {
|
||||
const w = World.create({ seed: 'mk3', surname: '王', familyName: '王家', motto: 'm', difficulty: 'normal' })
|
||||
w.state.family.stones = 5000
|
||||
expect(buyTechnique(w, 't-zhenyu', 700)).toBe(true)
|
||||
expect(w.state.family.techniques.includes('t-zhenyu')).toBe(true)
|
||||
expect(buyTechnique(w, 't-zhenyu', 700)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Breakthrough', () => {
|
||||
it('advances realm on success', () => {
|
||||
const w = World.create({ seed: 'bt', surname: '李', familyName: '李家', motto: 'm', difficulty: 'easy' })
|
||||
const c = w.state.members['x5']
|
||||
c.realm = { major: 'mortal', minor: 0 }
|
||||
c.realmProgress = 100
|
||||
c.perception = 9
|
||||
c.mind = 9
|
||||
resolveBreakthrough(w, c, 0.3)
|
||||
expect(c.realm.major).toBe('qi')
|
||||
})
|
||||
|
||||
it('failed breakthrough costs progress but not death', () => {
|
||||
const w = World.create({ seed: 'bt2', surname: '李', familyName: '李家', motto: 'm', difficulty: 'normal' })
|
||||
const c = w.state.members['x5']
|
||||
c.realm = { major: 'mortal', minor: 0 }
|
||||
c.realmProgress = 100
|
||||
resolveBreakthrough(w, c, -0.5)
|
||||
// chance was min-clamped; both outcomes acceptable, but check invariants
|
||||
if (c.realm.major === 'mortal') {
|
||||
expect(c.realmProgress).toBeLessThan(100)
|
||||
} else {
|
||||
expect(c.realmProgress).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event conditions', () => {
|
||||
it('leading event ids exist', () => {
|
||||
const w = World.create({ seed: 'evc', surname: '刘', familyName: '刘家', motto: 'm', difficulty: 'normal' })
|
||||
expect(matchesCondPub(w, { minBuilding: { id: 'lingtian', level: 1 } })).toBe(true)
|
||||
expect(matchesCondPub(w, { minBuilding: { id: 'danfang', level: 1 } })).toBe(false)
|
||||
expect(matchesCondPub(w, { minAdult: 3 })).toBe(true)
|
||||
expect(matchesCondPub(w, { maxAdult: 2 })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { World, WorldEventBus } from '../src/renderer/game/engine/world'
|
||||
import { LogItem, ChronicleEntry, BattleLog } from '../src/renderer/game/types/domain'
|
||||
|
||||
const sinks: Set<WorldEventBus> = new Set()
|
||||
|
||||
resetSaveBus()
|
||||
export function resetSaveBus(): void {
|
||||
sinks.clear()
|
||||
}
|
||||
|
||||
export function attachLogSink(w: World): void {
|
||||
const bus: WorldEventBus = {
|
||||
onLog: () => undefined,
|
||||
onChronicle: () => undefined,
|
||||
onBattle: () => undefined,
|
||||
onPendingEvent: () => undefined,
|
||||
onGameOver: () => undefined
|
||||
}
|
||||
sinks.add(bus)
|
||||
w.out.push(bus)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||
|
||||
describe('World engine', () => {
|
||||
it('creates a valid starting family', () => {
|
||||
const w = World.create({ seed: 't1', surname: '林', familyName: '林家', motto: 'm', difficulty: 'normal' })
|
||||
expect(Object.values(w.state.members).length).toBe(5)
|
||||
expect(w.aliveMembers().length).toBe(5)
|
||||
expect(w.state.family.headId).toBe('x1')
|
||||
expect(w.ageOf(w.head())).toBe(35)
|
||||
})
|
||||
|
||||
it('survives 20 years (240 months) without NaN or throws', () => {
|
||||
const w = World.create({ seed: 'smoke', surname: '沈', familyName: '沈氏', motto: 'x', difficulty: 'normal' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
for (let i = 0; i < 240; i++) {
|
||||
if (w.state.gameOver) break
|
||||
w.advanceMonth()
|
||||
}
|
||||
const s = w.state
|
||||
expect(Number.isNaN(s.family.stones)).toBe(false)
|
||||
expect(s.year).toBeGreaterThan(10)
|
||||
for (const c of Object.values(s.members)) {
|
||||
expect(Number.isNaN(c.realmProgress)).toBe(false)
|
||||
expect(Number.isNaN(c.health)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('same seed plays identically (deterministic)', () => {
|
||||
const a = World.create({ seed: 'det', surname: '苏', familyName: '苏家', motto: 'm', difficulty: 'easy' })
|
||||
const b = World.create({ seed: 'det', surname: '苏', familyName: '苏家', motto: 'm', difficulty: 'easy' })
|
||||
resetSaveBus()
|
||||
attachLogSink(a)
|
||||
resetSaveBus()
|
||||
attachLogSink(b)
|
||||
const ignoreRng = (state: { rng: unknown }) => JSON.parse(JSON.stringify(state))
|
||||
for (let i = 0; i < 60; i++) {
|
||||
a.advanceMonth()
|
||||
b.advanceMonth()
|
||||
}
|
||||
a.state.pendingEvent && (a.state.pendingEvent = undefined)
|
||||
b.state.pendingEvent && (b.state.pendingEvent = undefined)
|
||||
const x = ignoreRng({ ...a.state, pendingEvent: undefined })
|
||||
const y = ignoreRng({ ...b.state, pendingEvent: undefined })
|
||||
expect(JSON.stringify(x)).toBe(JSON.stringify(y))
|
||||
})
|
||||
|
||||
it('families grow over time (births occur)', () => {
|
||||
const w = World.create({ seed: 'grow', surname: '叶', familyName: '叶家', motto: 'm', difficulty: 'easy' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
for (let i = 0; i < 240; i++) {
|
||||
if (w.state.gameOver) break
|
||||
w.advanceMonth()
|
||||
}
|
||||
expect(Object.keys(w.state.members).length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it('game over when everyone dies', () => {
|
||||
const w = World.create({ seed: 'diehard', surname: '麦', familyName: '麦家', motto: 'm', difficulty: 'hard' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
for (const c of Object.values(w.state.members)) {
|
||||
c.alive = false
|
||||
}
|
||||
for (let i = 0; i < 5; i++) w.advanceMonth()
|
||||
expect(w.state.gameOver).toBeTruthy()
|
||||
})
|
||||
|
||||
it('building upgrade consumes resources', () => {
|
||||
const w = World.create({ seed: 'bld', surname: '金', familyName: '金家', motto: 'm', difficulty: 'easy' })
|
||||
const before = w.state.family.inventory['lingkuang'] ?? 0
|
||||
w.state.family.inventory['lingkuang'] = (w.state.family.inventory['lingkuang'] ?? 0) + 100
|
||||
const ok = w.build('fangshi')
|
||||
expect(ok).toBe(true)
|
||||
const upgraded = w.upgrade('fangshi')
|
||||
expect(upgraded).toBe(true)
|
||||
expect(w.state.family.buildings['fangshi']).toBe(2)
|
||||
expect((w.state.family.inventory['lingkuang'] ?? 0) - before).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user