update: state, event, condition, data.event.types

This commit is contained in:
Vick Scarlet
2026-07-30 19:56:12 +08:00
parent 97988a95ba
commit 329a4e3a81
18 changed files with 1216 additions and 793 deletions
+36
View File
@@ -0,0 +1,36 @@
import { expect, test, describe } from 'bun:test'
import { createState } from './state'
import { trigger } from './event'
describe('Event', () => {
const profile = {
times: 0,
talents: new Set([]),
achievements: new Set([]),
events: new Set([]),
}
const allocation = {
charm: 10,
intelligence: 10,
strength: 0,
money: 0,
}
test('10003 branch 0 玉佩', () => {
const result = trigger(
10003,
createState(allocation, [1001, 1002, 1003]),
profile,
)
expect(result.state.life).toBe(1)
expect(result.state.events).toEqual(new Set([10003, 10004]))
expect(result.events).toEqual([10003, 10004])
})
test('10003 branch 1 死了', () => {
const result = trigger(10003, createState(allocation, []), profile)
expect(result.state.life).toBe(0)
expect(result.state.events).toEqual(new Set([10003, 10000]))
expect(result.events).toEqual([10003, 10000])
})
})
+51
View File
@@ -0,0 +1,51 @@
import type { EventEffect, Event } from '@remake/data/event'
import events from '@remake/data/event'
import type { Properties, Effect } from '@/state'
import type { GameState, ProfileState } from '@/state'
import { stateEffect, createFlatState } from '@/state'
import { check } from '@remake/condition'
export function convertEffect(effect: EventEffect): Effect {
const converted = {} as Effect
const properties = [] as [keyof Properties, number][]
if (effect.CHR) properties.push(['charm', effect.CHR])
if (effect.INT) properties.push(['intelligence', effect.INT])
if (effect.STR) properties.push(['strength', effect.STR])
if (effect.MNY) properties.push(['money', effect.MNY])
if (effect.SPR) properties.push(['spirit', effect.SPR])
if (properties.length > 0)
converted.properties = Object.fromEntries(properties)
if (effect.LIF) converted.life = effect.LIF
return converted
}
export interface TriggerResult {
state: GameState
events: Event['id'][]
}
export function trigger(
eventId: number,
state: GameState,
profile: ProfileState,
): TriggerResult {
const event = events.get(eventId)
if (!event)
throw new Error(`[@remake/core][event] id:${eventId} not found.`)
const effect = event.effect ? convertEffect(event.effect) : {}
effect.events = [eventId]
const newState = stateEffect(state, effect)
const flatState = createFlatState(newState, profile)
if (event.branch) {
for (const branch of event.branch) {
if (check(flatState, branch.condition)) {
const result = trigger(branch.event, newState, profile)
return {
state: result.state,
events: [eventId, ...result.events],
}
}
}
}
return { state: newState, events: [eventId] }
}
+4
View File
@@ -0,0 +1,4 @@
export interface GameEffects {
save: (profile: any) => Promise<boolean>
load: () => Promise<any>
}
-2
View File
@@ -1,2 +0,0 @@
import { age } from '@remake/data/age'
import { checkCondition } from '@remake/condition'
+212
View File
@@ -0,0 +1,212 @@
import { expect, test, describe } from 'bun:test'
import { parse, type ConditionNode } from '@remake/condition'
import achievements from '@remake/data/achievement'
import events from '@remake/data/event'
import talents from '@remake/data/talent'
import { createFlatState } from './state'
const A = ['AGE', 'CHR', 'INT', 'STR', 'MNY', 'SPR', 'LIF', 'TLT', 'EVT', 'TMS']
const B = ['HAGE', 'HCHR', 'HINT', 'HSTR', 'HMNY', 'HSPR']
const C = ['LAGE', 'LCHR', 'LINT', 'LSTR', 'LMNY', 'LSPR']
const D = ['AEVT', 'ATLT', 'SUM']
const FlatProperties = new Set([...A, ...B, ...C, ...D])
/**
* 核心递归辅助函数:从多维语法树节点中提取所有属性名
*/
function extractKeysFromNode(node: ConditionNode, properties: Set<string>) {
if (Array.isArray(node)) {
for (const subNode of node) {
extractKeysFromNode(subNode, properties)
}
} else {
if (node === '&' || node === '|') return
// 匹配操作符(><!?=)前面的属性名部分
const match = node.match(/^([^><!?=]+)/)
if (match && match) {
properties.add(match[1]!.trim())
}
}
}
/**
* 从条件字符串中提取所有使用到的属性键名
*/
function getPropertiesFromCondition(condition: string): Set<string> {
const parsedConditions = parse(condition)
const properties = new Set<string>()
extractKeysFromNode(parsedConditions, properties)
return properties
}
describe('策划配置条件完整覆盖率测试', () => {
test('event [include, exclude, branch]', () => {
const unmappedKeys = new Set<string>()
for (const item of events.values()) {
const conditionsToTrack: { label: string; text: string }[] = []
if (item.include) {
conditionsToTrack.push({ label: 'include', text: item.include })
}
if (item.exclude) {
conditionsToTrack.push({ label: 'exclude', text: item.exclude })
}
if (item.branch && Array.isArray(item.branch)) {
item.branch.forEach((branchObj, index) => {
if (branchObj && branchObj.condition) {
conditionsToTrack.push({
label: `branch[${index}].condition (目标事件ID: ${branchObj.event})`,
text: branchObj.condition,
})
}
})
}
for (const { label, text } of conditionsToTrack) {
const usedProperties = getPropertiesFromCondition(text)
for (const key of usedProperties) {
if (!FlatProperties.has(key)) {
unmappedKeys.add(
`[事件 ID: ${item.id}] 的 "${label}" 字段使用了未定义的键名: "${key}" (完整条件: "${text}")`,
)
}
}
}
}
if (unmappedKeys.size > 0) {
console.error('\n❌ 发现事件配置表存在未定义的 Property 键名:')
unmappedKeys.forEach(msg => console.error(msg))
}
expect(unmappedKeys.size).toBe(0)
})
test('talent [condition]', () => {
const unmappedKeys = new Set<string>()
const allTalentItems = talents.values()
for (const item of allTalentItems) {
const conditionStr = item.condition
if (!conditionStr) continue
const usedProperties = getPropertiesFromCondition(conditionStr)
for (const key of usedProperties) {
if (!FlatProperties.has(key)) {
unmappedKeys.add(
`[天赋 ID: ${item.id}] 使用了未定义的键名: "${key}" (完整条件: "${conditionStr}")`,
)
}
}
}
if (unmappedKeys.size > 0) {
console.error('\n❌ 发现天赋配置表存在未定义的 Property 键名:')
unmappedKeys.forEach(msg => console.error(msg))
}
expect(unmappedKeys.size).toBe(0)
})
test('achievement [condition]', () => {
const unmappedKeys = new Set<string>()
const allAchievementItems = achievements.values()
for (const item of allAchievementItems) {
const conditionStr = item.condition
if (!conditionStr) continue
const usedProperties = getPropertiesFromCondition(conditionStr)
for (const key of usedProperties) {
if (!FlatProperties.has(key)) {
unmappedKeys.add(
`[成就 ID: ${item.id}] 使用了未定义的键名: "${key}" (完整条件: "${conditionStr}")`,
)
}
}
}
if (unmappedKeys.size > 0) {
console.error('\n❌ 发现成就配置表存在未定义的 Property 键名:')
unmappedKeys.forEach(msg => console.error(msg))
}
expect(unmappedKeys.size).toBe(0)
})
})
describe('FlatProperties', () => {
test('check key', () => {
const game = {
lowest: {
age: 1,
charm: 2,
intelligence: 3,
strength: 4,
money: 5,
spirit: 6,
},
properties: {
age: 11,
charm: 12,
intelligence: 13,
strength: 14,
money: 15,
spirit: 16,
},
highest: {
age: 21,
charm: 22,
intelligence: 23,
strength: 24,
money: 25,
spirit: 26,
},
life: 31,
talents: new Set([33]),
events: new Set([34, 35]),
achievements: new Set([36]),
}
const profile = {
times: 41,
external: 42,
talents: new Set([43]),
achievements: new Set([44]),
events: new Set([45, 46]),
}
const flatState = createFlatState(game, profile)
expect(flatState['AGE']).toBe(game.properties.age)
expect(flatState['CHR']).toBe(game.properties.charm)
expect(flatState['INT']).toBe(game.properties.intelligence)
expect(flatState['STR']).toBe(game.properties.strength)
expect(flatState['MNY']).toBe(game.properties.money)
expect(flatState['SPR']).toBe(game.properties.spirit)
expect(flatState['LIF']).toBe(game.life)
expect(flatState['TLT']).toEqual(game.talents)
expect(flatState['EVT']).toEqual(game.events)
expect(flatState['LAGE']).toBe(game.lowest.age)
expect(flatState['HAGE']).toBe(game.highest.age)
expect(flatState['LCHR']).toBe(game.lowest.charm)
expect(flatState['HCHR']).toBe(game.highest.charm)
expect(flatState['LINT']).toBe(game.lowest.intelligence)
expect(flatState['HINT']).toBe(game.highest.intelligence)
expect(flatState['LSTR']).toBe(game.lowest.strength)
expect(flatState['HSTR']).toBe(game.highest.strength)
expect(flatState['LMNY']).toBe(game.lowest.money)
expect(flatState['HMNY']).toBe(game.highest.money)
expect(flatState['LSPR']).toBe(game.lowest.spirit)
expect(flatState['HSPR']).toBe(game.highest.spirit)
expect(flatState['TMS']).toBe(profile.times)
expect(flatState['AEVT']).toEqual(profile.events)
expect(flatState['ATLT']).toEqual(profile.talents)
expect(flatState['AACH']).toEqual(profile.achievements)
expect(flatState['SUM']).toBe(
Math.floor((22 + 23 + 24 + 25 + 26) * 2 + 21 / 2),
)
})
})
+212
View File
@@ -0,0 +1,212 @@
import type { Talent } from '@remake/data/talent'
import type { Event } from '@remake/data/event'
import type { Achievement } from '@remake/data/achievement'
import { produce, enableMapSet } from 'immer'
import { sum } from '@remake/vitex'
enableMapSet()
/** 基础的属性 */
export interface Properties {
age: number
charm: number
intelligence: number
strength: number
money: number
spirit: number
}
export type Allocation = Omit<Properties, 'age' | 'spirit'>
export interface GameState {
properties: Properties // 本局属性
highest: Properties // 本局最高属性
lowest: Properties // 本局最低属性
life: number // 本局生命值
talents: Set<Talent['id']> // 本局拥有的天赋
events: Set<Event['id']> // 本局触发过的事件
achievements: Set<Achievement['id']> // 本局达成的成就
}
/** 持久化存储的数据 */
export interface ProfileState {
times: number // 游戏次数
external?: Talent['id'] // 继承的天赋
talents: Set<Talent['id']> // 拥有过的天赋
events: Set<Event['id']> // 触发过的事件
achievements: Set<Achievement['id']> // 达成的成就
highest?: Properties // 历史最高属性
lowest?: Properties // 历史最低属性
}
export function createState(
allocation: Allocation,
talents?: Iterable<Talent['id']>,
): GameState {
const properties = { ...allocation, age: -1, spirit: 0 }
return {
properties,
highest: { ...properties },
lowest: { ...properties },
life: 1,
talents: new Set(talents),
events: new Set(),
achievements: new Set(),
}
}
export interface FlatState {
AGE: GameState['properties']['age']
CHR: GameState['properties']['charm']
INT: GameState['properties']['intelligence']
STR: GameState['properties']['strength']
MNY: GameState['properties']['money']
SPR: GameState['properties']['spirit']
LIF: GameState['life']
TLT: GameState['talents']
EVT: GameState['events']
LAGE: GameState['lowest']['age']
HAGE: GameState['highest']['age']
LCHR: GameState['lowest']['charm']
HCHR: GameState['highest']['charm']
LINT: GameState['lowest']['intelligence']
HINT: GameState['highest']['intelligence']
LSTR: GameState['lowest']['strength']
HSTR: GameState['highest']['strength']
LMNY: GameState['lowest']['money']
HMNY: GameState['highest']['money']
LSPR: GameState['lowest']['spirit']
HSPR: GameState['highest']['spirit']
TMS: ProfileState['times']
AEVT: ProfileState['events']
ATLT: ProfileState['talents']
AACH: ProfileState['achievements']
SUM: number
}
interface FlatPropertiesTarget {
game: GameState
profile: ProfileState
}
const FlatMappers = {
AGE: (state: FlatPropertiesTarget) => state.game.properties.age,
CHR: (state: FlatPropertiesTarget) => state.game.properties.charm,
INT: (state: FlatPropertiesTarget) => state.game.properties.intelligence,
STR: (state: FlatPropertiesTarget) => state.game.properties.strength,
MNY: (state: FlatPropertiesTarget) => state.game.properties.money,
SPR: (state: FlatPropertiesTarget) => state.game.properties.spirit,
LIF: (state: FlatPropertiesTarget) => state.game.life,
TLT: (state: FlatPropertiesTarget) => state.game.talents,
EVT: (state: FlatPropertiesTarget) => state.game.events,
LAGE: (state: FlatPropertiesTarget) => state.game.lowest.age,
HAGE: (state: FlatPropertiesTarget) => state.game.highest.age,
LCHR: (state: FlatPropertiesTarget) => state.game.lowest.charm,
HCHR: (state: FlatPropertiesTarget) => state.game.highest.charm,
LINT: (state: FlatPropertiesTarget) => state.game.lowest.intelligence,
HINT: (state: FlatPropertiesTarget) => state.game.highest.intelligence,
LSTR: (state: FlatPropertiesTarget) => state.game.lowest.strength,
HSTR: (state: FlatPropertiesTarget) => state.game.highest.strength,
LMNY: (state: FlatPropertiesTarget) => state.game.lowest.money,
HMNY: (state: FlatPropertiesTarget) => state.game.highest.money,
LSPR: (state: FlatPropertiesTarget) => state.game.lowest.spirit,
HSPR: (state: FlatPropertiesTarget) => state.game.highest.spirit,
TMS: (state: FlatPropertiesTarget) => state.profile.times,
AEVT: (state: FlatPropertiesTarget) => state.profile.events,
ATLT: (state: FlatPropertiesTarget) => state.profile.talents,
AACH: (state: FlatPropertiesTarget) => state.profile.achievements,
SUM: (state: FlatPropertiesTarget) => {
const { age, ...others } = state.game.highest
const s = sum(Object.values(others))
return Math.floor(s * 2 + age / 2)
},
}
const flatPropertiesHandle = {
get(target: FlatPropertiesTarget, prop: string) {
if (prop in FlatMappers) {
return FlatMappers[prop as keyof typeof FlatMappers](target)
}
},
set() {
return true
},
}
export function createFlatState(game: GameState, profile: ProfileState) {
return new Proxy(
{ game, profile },
flatPropertiesHandle,
) as unknown as FlatState
}
export interface Effect {
properties?: Partial<Properties>
life?: number
talents?: Iterable<Talent['id']>
events?: Iterable<Event['id']>
achievements?: Iterable<Achievement['id']>
}
export function stateEffect(state: GameState, effect: Effect) {
return produce(state, draft => {
for (const key in effect.properties) {
const prop = key as keyof Properties
const value = effect.properties[prop]!
draft.properties[prop] += value
draft.highest[prop] = Math.max(
draft.highest[prop],
draft.properties[prop],
)
draft.lowest[prop] = Math.min(
draft.lowest[prop],
draft.properties[prop],
)
}
if (effect.life) draft.life += effect.life
if (effect.talents)
draft.talents = new Set([...draft.talents, ...effect.talents])
if (effect.events)
draft.events = new Set([...draft.events, ...effect.events])
if (effect.achievements)
draft.achievements = new Set([
...draft.achievements,
...effect.achievements,
])
})
}
function highestProperties(a: Properties, b?: Properties): Properties {
if (!b) return { ...a }
const result = {} as Properties
for (const key of Object.keys(a) as (keyof Properties)[]) {
result[key] = Math.max(a[key], b[key])
}
return result
}
function lowestProperties(a: Properties, b?: Properties): Properties {
if (!b) return { ...a }
const result = {} as Properties
for (const key of Object.keys(a) as (keyof Properties)[]) {
result[key] = Math.min(a[key], b[key])
}
return result
}
export function nextProfile(
profile: ProfileState,
state: GameState,
external?: Talent['id'],
) {
return {
times: profile.times + 1,
talents: profile.talents.union(state.talents),
events: profile.events.union(state.events),
achievements: profile.achievements.union(state.achievements),
highest: highestProperties(state.highest, profile.highest),
lowest: lowestProperties(state.lowest, profile.lowest),
external: external ?? profile.external,
}
}
+21
View File
@@ -0,0 +1,21 @@
import type { TalentEffect } from '@remake/data/talent'
import talents from '@remake/data/talent'
import type { Properties } from './state'
import { pick } from '@remake/vitex'
const TalentEffectPropertiesKeyMapper = {
MNY: () => 'money',
STR: () => 'strength',
INT: () => 'intelligence',
CHR: () => 'charm',
SPR: () => 'spirit',
RND: () => pick(['money', 'strength', 'intelligence', 'charm', 'spirit'])!,
} as Record<keyof TalentEffect, () => keyof Properties>
export const count = talents.size
export function get(talent: number) {
return talents.get(talent)
}
export function random(talent: number) {}