diff --git a/docs/remake.md b/docs/remake.md new file mode 100644 index 0000000..4d9b2a5 --- /dev/null +++ b/docs/remake.md @@ -0,0 +1,17 @@ +## 流程 + +重开 + 初始化 + 抽天赋(包含继承的天赋) + 天赋选择(检测天赋冲突) + 天赋替换(检测天赋冲突) + 点数分配(天赋额外点数) + 游戏循环 + 年龄增加一岁 + 天赋触发 + 事件触发(从对应年龄事件池随机事件) + 判断是否结束(生命值小于 1 结束) + 总评 + 天赋继承 + 存档数据 + 重回首页 \ No newline at end of file diff --git a/packages/core/src/event.spec.ts b/packages/core/src/event.spec.ts index b307cc4..e3e4008 100644 --- a/packages/core/src/event.spec.ts +++ b/packages/core/src/event.spec.ts @@ -1,6 +1,8 @@ import { expect, test, describe } from 'bun:test' import { createState } from './state' import { trigger } from './event' +import { enableMapSet } from 'immer' +enableMapSet() describe('Event', () => { const profile = { @@ -16,7 +18,7 @@ describe('Event', () => { money: 0, } - test('10003 branch 0 玉佩', () => { + test('branch 10003:0 [玉佩]', () => { const result = trigger( 10003, createState(allocation, [1001, 1002, 1003]), @@ -27,7 +29,7 @@ describe('Event', () => { expect(result.events).toEqual([10003, 10004]) }) - test('10003 branch 1 死了', () => { + test('branch 10003:1 [死了]', () => { const result = trigger(10003, createState(allocation, []), profile) expect(result.state.life).toBe(0) expect(result.state.events).toEqual(new Set([10003, 10000])) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 4d928ed..69bae86 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,23 +1,10 @@ -import type { EventEffect, Event } from '@remake/data/event' +import type { Event } from '@remake/data/event' import events from '@remake/data/event' -import type { Properties, Effect } from '@/state' +import type { Properties } from '@/state' import type { GameState, ProfileState } from '@/state' -import { stateEffect, createFlatState } from '@/state' +import { propsEffect, 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 -} +import { produce } from 'immer' export interface TriggerResult { state: GameState @@ -29,17 +16,24 @@ export function trigger( 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 { effect, branch } = events.get(eventId)! + const newState = produce(state, draft => { + draft.events.add(eventId) + if (!effect) return + if (effect.LIF) draft.life += effect.LIF + const pe = {} as Partial + if (effect.CHR) pe.charm = effect.CHR + if (effect.INT) pe.intelligence = effect.INT + if (effect.STR) pe.strength = effect.STR + if (effect.MNY) pe.money = effect.MNY + if (effect.SPR) pe.spirit = effect.SPR + draft.props = propsEffect(draft.props, pe) + }) 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) + if (branch) { + for (const { condition, event } of branch) { + if (check(flatState, condition)) { + const result = trigger(event, newState, profile) return { state: result.state, events: [eventId, ...result.events], diff --git a/packages/core/src/game.ts b/packages/core/src/game.ts index 56d8e92..17f48e8 100644 --- a/packages/core/src/game.ts +++ b/packages/core/src/game.ts @@ -1,4 +1,5 @@ -export interface GameEffects { - save: (profile: any) => Promise - load: () => Promise -} +import type { GameState, ProfileState } from '@/state' +import { enableMapSet } from 'immer' +enableMapSet() + +export type { GameState, ProfileState } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e69de29..2fbd5c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -0,0 +1 @@ +export * from '@/game' diff --git a/packages/core/src/state.spec.ts b/packages/core/src/state.spec.ts index a459567..b26bb79 100644 --- a/packages/core/src/state.spec.ts +++ b/packages/core/src/state.spec.ts @@ -5,13 +5,11 @@ 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]) +import type { GameState, ProfileState, Properties } from './state' +import { createFlatState, createHLProperties, propsEffect } from './state' +import { SupportedFlatStateKeys } from './state' +import { enableMapSet } from 'immer' +enableMapSet() /** * 核心递归辅助函数:从多维语法树节点中提取所有属性名 @@ -42,8 +40,8 @@ function getPropertiesFromCondition(condition: string): Set { return properties } -describe('FlatState', () => { - test('[Cover] event(include, exclude, branch)', () => { +describe('State', () => { + test('flat cover event(include, exclude, branch)', () => { const unmappedKeys = new Set() for (const item of events.values()) { const conditionsToTrack: { label: string; text: string }[] = [] @@ -68,7 +66,7 @@ describe('FlatState', () => { for (const { label, text } of conditionsToTrack) { const usedProperties = getPropertiesFromCondition(text) for (const key of usedProperties) { - if (!FlatProperties.has(key)) { + if (!SupportedFlatStateKeys.has(key)) { unmappedKeys.add( `[事件 ID: ${item.id}] 的 "${label}" 字段使用了未定义的键名: "${key}" (完整条件: "${text}")`, ) @@ -85,7 +83,7 @@ describe('FlatState', () => { expect(unmappedKeys.size).toBe(0) }) - test('[Cover] talent(condition)', () => { + test('flat cover talent(condition)', () => { const unmappedKeys = new Set() const allTalentItems = talents.values() @@ -95,7 +93,7 @@ describe('FlatState', () => { const usedProperties = getPropertiesFromCondition(conditionStr) for (const key of usedProperties) { - if (!FlatProperties.has(key)) { + if (!SupportedFlatStateKeys.has(key)) { unmappedKeys.add( `[天赋 ID: ${item.id}] 使用了未定义的键名: "${key}" (完整条件: "${conditionStr}")`, ) @@ -111,7 +109,7 @@ describe('FlatState', () => { expect(unmappedKeys.size).toBe(0) }) - test('[Cover] achievement(condition)', () => { + test('flat cover achievement(condition)', () => { const unmappedKeys = new Set() const allAchievementItems = achievements.values() @@ -121,7 +119,7 @@ describe('FlatState', () => { const usedProperties = getPropertiesFromCondition(conditionStr) for (const key of usedProperties) { - if (!FlatProperties.has(key)) { + if (!SupportedFlatStateKeys.has(key)) { unmappedKeys.add( `[成就 ID: ${item.id}] 使用了未定义的键名: "${key}" (完整条件: "${conditionStr}")`, ) @@ -137,37 +135,39 @@ describe('FlatState', () => { expect(unmappedKeys.size).toBe(0) }) - test('get', () => { + test('flat get', () => { 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, + props: { + lowest: { + age: 1, + charm: 2, + intelligence: 3, + strength: 4, + money: 5, + spirit: 6, + }, + current: { + 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]), - } + } as GameState const profile = { times: 41, @@ -175,30 +175,30 @@ describe('FlatState', () => { talents: new Set([43]), achievements: new Set([44]), events: new Set([45, 46]), - } + } as ProfileState 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['AGE']).toBe(game.props.current.age) + expect(flatState['CHR']).toBe(game.props.current.charm) + expect(flatState['INT']).toBe(game.props.current.intelligence) + expect(flatState['STR']).toBe(game.props.current.strength) + expect(flatState['MNY']).toBe(game.props.current.money) + expect(flatState['SPR']).toBe(game.props.current.spirit) + expect(flatState['HAGE']).toBe(game.props.highest.age) + expect(flatState['HCHR']).toBe(game.props.highest.charm) + expect(flatState['HINT']).toBe(game.props.highest.intelligence) + expect(flatState['HSTR']).toBe(game.props.highest.strength) + expect(flatState['HMNY']).toBe(game.props.highest.money) + expect(flatState['HSPR']).toBe(game.props.highest.spirit) + expect(flatState['LAGE']).toBe(game.props.lowest.age) + expect(flatState['LCHR']).toBe(game.props.lowest.charm) + expect(flatState['LINT']).toBe(game.props.lowest.intelligence) + expect(flatState['LSTR']).toBe(game.props.lowest.strength) + expect(flatState['LMNY']).toBe(game.props.lowest.money) + expect(flatState['LSPR']).toBe(game.props.lowest.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) @@ -207,4 +207,36 @@ describe('FlatState', () => { Math.floor((22 + 23 + 24 + 25 + 26) * 2 + 21 / 2), ) }) + + const allocation = { charm: 5, intelligence: 5, strength: 5, money: 5 } + test('createHLProperties', () => { + const props = createHLProperties(allocation) + expect(props.current.charm).toBe(allocation.charm) + expect(props.current.intelligence).toBe(allocation.intelligence) + expect(props.current.strength).toBe(allocation.strength) + expect(props.current.money).toBe(allocation.money) + expect(props.current.age).toBe(-1) + expect(props.current.spirit).toBe(0) + }) + + test('propsEffect', () => { + const props = createHLProperties(allocation) + const effect = { + age: 6, + charm: -2, + intelligence: 3, + strength: 4, + money: -5, + spirit: 7, + } + const effected = propsEffect(props, effect) + for (const key in effect) { + const k = key as keyof Properties + const c = props.current[k] + const be = c + effect[k] + expect(c).toBe(be) + expect(effected.highest[k]).toBe(Math.max(c, be)) + expect(effected.lowest[k]).toBe(Math.min(c, be)) + } + }) }) diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index bf8359c..4508ddb 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -1,11 +1,9 @@ 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 { produce } from 'immer' import { sum } from '@remake/vitex' -enableMapSet() - /** 基础的属性 */ export interface Properties { age: number @@ -16,22 +14,35 @@ export interface Properties { spirit: number } +export interface HLProperties { + current: Properties // 当前属性 + highest: Properties // 历史最高属性 + lowest: Properties // 历史最低属性 +} + export type Allocation = Omit +export function createProperties(allocation: Allocation) { + return { ...allocation, age: -1, spirit: 0 } +} + +export function createHLProperties(allocation: Allocation) { + const current = createProperties(allocation) + return { current, highest: { ...current }, lowest: { ...current } } +} export interface GameState { - properties: Properties // 本局属性 - highest: Properties // 本局最高属性 - lowest: Properties // 本局最低属性 + props: HLProperties // 本局属性 life: number // 本局生命值 talents: Set // 本局拥有的天赋 events: Set // 本局触发过的事件 achievements: Set // 本局达成的成就 + talentTriggers: Map // 本局天赋触发次数 } /** 持久化存储的数据 */ export interface ProfileState { times: number // 游戏次数 - external?: Talent['id'] // 继承的天赋 + lockedTalent?: Talent['id'] // 继承的天赋 talents: Set // 拥有过的天赋 events: Set // 触发过的事件 achievements: Set // 达成的成就 @@ -43,39 +54,37 @@ export function createState( allocation: Allocation, talents?: Iterable, ): GameState { - const properties = { ...allocation, age: -1, spirit: 0 } return { - properties, - highest: { ...properties }, - lowest: { ...properties }, + props: createHLProperties(allocation), life: 1, talents: new Set(talents), events: new Set(), achievements: new Set(), + talentTriggers: new Map(), } } 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'] + AGE: GameState['props']['current']['age'] + CHR: GameState['props']['current']['charm'] + INT: GameState['props']['current']['intelligence'] + STR: GameState['props']['current']['strength'] + MNY: GameState['props']['current']['money'] + SPR: GameState['props']['current']['spirit'] + HAGE: GameState['props']['highest']['age'] + HCHR: GameState['props']['highest']['charm'] + HINT: GameState['props']['highest']['intelligence'] + HSTR: GameState['props']['highest']['strength'] + HMNY: GameState['props']['highest']['money'] + HSPR: GameState['props']['highest']['spirit'] + LAGE: GameState['props']['lowest']['age'] + LCHR: GameState['props']['lowest']['charm'] + LINT: GameState['props']['lowest']['intelligence'] + LSTR: GameState['props']['lowest']['strength'] + LMNY: GameState['props']['lowest']['money'] + LSPR: GameState['props']['lowest']['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'] @@ -97,38 +106,40 @@ type FlatMapper = ( ) => FlatState[Key] const FlatMappers = { - AGE: state => state.game.properties.age, - CHR: state => state.game.properties.charm, - INT: state => state.game.properties.intelligence, - STR: state => state.game.properties.strength, - MNY: state => state.game.properties.money, - SPR: state => state.game.properties.spirit, + AGE: state => state.game.props.current.age, + CHR: state => state.game.props.current.charm, + INT: state => state.game.props.current.intelligence, + STR: state => state.game.props.current.strength, + MNY: state => state.game.props.current.money, + SPR: state => state.game.props.current.spirit, + HAGE: state => state.game.props.highest.age, + HCHR: state => state.game.props.highest.charm, + HINT: state => state.game.props.highest.intelligence, + HSTR: state => state.game.props.highest.strength, + HMNY: state => state.game.props.highest.money, + HSPR: state => state.game.props.highest.spirit, + LAGE: state => state.game.props.lowest.age, + LCHR: state => state.game.props.lowest.charm, + LINT: state => state.game.props.lowest.intelligence, + LSTR: state => state.game.props.lowest.strength, + LMNY: state => state.game.props.lowest.money, + LSPR: state => state.game.props.lowest.spirit, LIF: state => state.game.life, TLT: state => state.game.talents, EVT: state => state.game.events, - LAGE: state => state.game.lowest.age, - HAGE: state => state.game.highest.age, - LCHR: state => state.game.lowest.charm, - HCHR: state => state.game.highest.charm, - LINT: state => state.game.lowest.intelligence, - HINT: state => state.game.highest.intelligence, - LSTR: state => state.game.lowest.strength, - HSTR: state => state.game.highest.strength, - LMNY: state => state.game.lowest.money, - HMNY: state => state.game.highest.money, - LSPR: state => state.game.lowest.spirit, - HSPR: state => state.game.highest.spirit, TMS: state => state.profile.times, AEVT: state => state.profile.events, ATLT: state => state.profile.talents, AACH: state => state.profile.achievements, - SUM: state => { - const { age, ...others } = state.game.highest + SUM: ({ game: { props } }) => { + const { age, ...others } = props.highest const s = sum(Object.values(others)) return Math.floor(s * 2 + age / 2) }, } as { [Key in FlatStateKey]: FlatMapper } +export const SupportedFlatStateKeys = new Set(Object.keys(FlatMappers)) + const flatStateHandle = { get(target: FlatTarget, prop: Key) { return FlatMappers[prop]?.(target) @@ -140,43 +151,25 @@ export function createFlatState(game: GameState, profile: ProfileState) { return new Proxy({ game, profile }, flatStateHandle) as unknown as FlatState } -export interface Effect { - properties?: Partial - life?: number - talents?: Iterable - events?: Iterable - achievements?: Iterable -} - -export function stateEffect(state: GameState, effect: Effect) { - return produce(state, draft => { - for (const key in effect.properties) { +export function propsEffect(hlp: HLProperties, effect: Partial) { + return produce(hlp, draft => { + for (const key in effect) { const prop = key as keyof Properties - const value = effect.properties[prop]! - draft.properties[prop] += value + const value = effect[prop]! + draft.current[prop] += value draft.highest[prop] = Math.max( draft.highest[prop], - draft.properties[prop], + draft.current[prop], ) draft.lowest[prop] = Math.min( draft.lowest[prop], - draft.properties[prop], + draft.current[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 { +export 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)[]) { @@ -185,7 +178,7 @@ function highestProperties(a: Properties, b?: Properties): Properties { return result } -function lowestProperties(a: Properties, b?: Properties): Properties { +export 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)[]) { @@ -197,15 +190,15 @@ function lowestProperties(a: Properties, b?: Properties): Properties { export function nextProfile( profile: ProfileState, state: GameState, - external?: Talent['id'], + lockedTalent?: 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, + highest: highestProperties(state.props.highest, profile.highest), + lowest: lowestProperties(state.props.lowest, profile.lowest), + lockedTalent: lockedTalent ?? profile.lockedTalent, } } diff --git a/packages/core/src/talent.spec.ts b/packages/core/src/talent.spec.ts new file mode 100644 index 0000000..1609a74 --- /dev/null +++ b/packages/core/src/talent.spec.ts @@ -0,0 +1,98 @@ +import { expect, test, describe } from 'bun:test' +import type { ProfileState } from './state' +import type { TalentPullOptions } from './talent' +import { pull, exclude, additionalPoints, replacement } from './talent' +import { createState } from './state' +import talents from '@remake/data/talent' +import { produce } from 'immer' +import { enableMapSet } from 'immer' +enableMapSet() + +describe('Talent', () => { + const profile = { + times: 0, + talents: new Set([]), + achievements: new Set([]), + events: new Set([]), + } as ProfileState + + const options = { + count: 10, + rate: { + base: new Map([ + [0, 1000 - 111], + [1, 100], + [2, 10], + [3, 1], + ]), + additions: {}, + }, + } as TalentPullOptions + + test('pull external', () => { + const p = produce(profile, draft => { + draft.lockedTalent = 1001 + }) + const result = pull(options, p) + expect(result.length).toBe(options.count) + expect(result).toContain(p.lockedTalent!) + }) + + test('pull rate', () => { + const opts = produce(options, draft => { + draft.rate.additions = { + times: () => + new Map([ + [0, { mode: 'multiply', value: 0 }], + [1, { mode: 'multiply', value: 0 }], + [2, { mode: 'multiply', value: 0 }], + [3, { mode: 'multiply', value: 1 }], + ]), + } + }) + const result = pull(opts, profile) + expect(result.some(id => talents.get(id)?.grade === 3)).toBe(true) + }) + + test('exclude', () => { + expect(exclude(1003, [1004])).toBe(1004) + expect(exclude(1004, [1003])).toBe(1003) + expect(exclude(1001, [1002, 1003])).toBe(null) + }) + + test('additionalPoints', () => { + const result = additionalPoints([1001, 1002, 1003, 1007, 1019]) + expect(result).toEqual({ + source: [ + { talent: 1007, points: 2 }, + { talent: 1019, points: 4 }, + ], + points: 6, + }) + }) + + const allo = { charm: 5, intelligence: 5, strength: 5, money: 5 } + test('replacement talent [阴间福袋]', () => { + const talent = 1145 + const state = createState(allo, [talent]) + const result = replacement(state) + expect(result.changed).toBe(true) + expect(result.state.talents.size).toBeGreaterThan(1) + expect(result.chains.get(talent)).toBeDefined() + const source = talents.get(talent)!.replacement!.talent! + const replaced = result.chains.get(talent)!.at(0)! + expect(source.map(t => t[0])).toContain(replaced) + }) + + test('replacement grade [橙色转盘]', () => { + const talent = 1144 + const state = createState(allo, [talent]) + const result = replacement(state) + expect(result.changed).toBe(true) + expect(result.state.talents.size).toBeGreaterThan(1) + expect(result.chains.get(talent)).toBeDefined() + const source = talents.get(talent)!.replacement!.grade! + const replaced = result.chains.get(talent)!.at(0)! + expect(talents.get(replaced)?.grade).toBe(source) + }) +}) diff --git a/packages/core/src/talent.ts b/packages/core/src/talent.ts index fd95c04..f7c35dd 100644 --- a/packages/core/src/talent.ts +++ b/packages/core/src/talent.ts @@ -1,16 +1,17 @@ -import type { TalentEffect } from '@remake/data/talent' +import type { Talent, TalentGrade } from '@remake/data/talent' import talents from '@remake/data/talent' -import type { Properties } from './state' -import { pick } from '@remake/vitex' +import type { Properties } from '@/state' +import type { GameState, ProfileState } from '@/state' +import { propsEffect, createFlatState } from './state' +import { check } from '@remake/condition' +import { pick, pickWeight } from '@remake/vitex' +import { produce } from 'immer' -const TalentEffectPropertiesKeyMapper = { - MNY: () => 'money', - STR: () => 'strength', - INT: () => 'intelligence', - CHR: () => 'charm', - SPR: () => 'spirit', - RND: () => pick(['money', 'strength', 'intelligence', 'charm', 'spirit'])!, -} as Record keyof Properties> +const Grades: TalentGrade[] = [0, 1, 2, 3] as const +const GradeMap = Map.groupBy( + talents.keys().filter(t => !talents.get(t)!.exclusive), + t => talents.get(t)!.grade, +) export const count = talents.size @@ -18,4 +19,197 @@ export function get(talent: number) { return talents.get(talent) } -export function random(talent: number) {} +export type TalentPullRate = Map +export type TalentPullRateAddition = { + mode: 'add' | 'multiply' + value: number +} +export type TalentPullRateAdditions = { + [key in keyof ProfileState]?: ( + value: ProfileState[key], + ) => Map +} +export interface TalentRateOptions { + base: TalentPullRate + additions: TalentPullRateAdditions +} +const AddidtionInit = Grades.map(g => [g, 1]) as [TalentGrade, number][] +function talentRateWithAddition( + { base, additions }: TalentRateOptions, + profile: ProfileState, +) { + const rate = new Map(AddidtionInit) + for (const [key, getAddition] of Object.entries(additions)) { + const value = profile[key as keyof ProfileState] + for (const [k, v] of getAddition(value as any).entries()) { + const current = rate.get(k)! + if (v.mode === 'add') { + rate.set(k, current + v.value) + } else if (v.mode === 'multiply') { + rate.set(k, current * v.value) + } + } + } + for (const [g, addition] of rate.entries()) { + const b = base.get(g) + if (!b) throw new Error(`talent: base rate for grade ${g} is missing`) + rate.set(g, Math.max(b * addition, 0)) + } + return rate +} + +export interface TalentPullOptions { + count: number + rate: TalentRateOptions +} + +export function pull(options: TalentPullOptions, profile: ProfileState) { + const rate = Array.from( + talentRateWithAddition(options.rate, profile).entries(), + ) + const result = [] + if (profile.lockedTalent) result.push(profile.lockedTalent) + const map = new Map(Grades.map(g => [g, new Set(GradeMap.get(g))])) + for (let i = options.count - result.length; i > 0; i--) { + const grade = pickWeight(rate)! ?? 0 + const set = map.get(grade)! + if (set.size === 0) continue + const id = pick(Array.from(set))! + result.push(id) + set.delete(id) + } + return result +} + +export function exclude(talent: Talent['id'], list: Iterable) { + const { exclude } = talents.get(talent)! + for (const t of list) { + if (exclude) { + for (const e of exclude) { + if (t == e) return t + } + } + const excludeReverse = talents.get(t)!.exclude + if (excludeReverse) { + for (const e of excludeReverse) { + if (talent == e) return t + } + } + } + return null +} + +export interface TalentReplacementResult { + state: GameState + chains: Map + changed: boolean +} + +function chainReplace(t: Talent['id'], ts: Set) { + const { replacement: r } = talents.get(t)! + if (!r) return null + let picked: Talent['id'] | null = null + if (r.talent) { + const filtered = r.talent.filter(([id]) => exclude(id, ts) == null) + picked = pickWeight(filtered) + } else if (r.grade) { + const filtered = GradeMap.get(r.grade)!.filter( + id => exclude(id, ts) == null, + ) + picked = pick(filtered) + } + if (!picked) return null + const nts = new Set(ts) + nts.add(picked) + const next = chainReplace(picked, nts) as Talent['id'][] | null + if (next) return [picked, ...next] + return [picked] +} + +export function replacement(state: GameState): TalentReplacementResult { + const chains = new Map() as TalentReplacementResult['chains'] + const newTalents = new Set(state.talents) + let changed = false + for (const talent of state.talents) { + const chain = chainReplace(talent, newTalents) + if (!chain) continue + changed = true + chains.set(talent, chain) + chain.forEach(t => newTalents.add(t)) + } + if (!changed) return { state, chains, changed } + const newState = produce(state, draft => { + draft.talents = newTalents + }) + return { state: newState, chains, changed } +} + +export interface AdditionalPoint { + talent: Talent['id'] + points: number +} + +export interface AdditionalPoints { + source: AdditionalPoint[] + points: number +} + +export function additionalPoints(list: Talent['id'][]) { + const source: AdditionalPoint[] = [] + let total = 0 + for (const id of list) { + const { points } = talents.get(id)! + if (!points) continue + source.push({ talent: id, points }) + total += points + } + return { source, points: total } +} + +const TalentEffectRandomProperties = [ + 'money', + 'strength', + 'intelligence', + 'charm', + 'spirit', +] as (keyof Properties)[] +export interface TalentTriggerResult { + state: GameState + triggers: Talent['id'][] + changed: boolean +} + +export function trigger( + state: GameState, + profile: ProfileState, +): TalentTriggerResult { + const flatState = createFlatState(state, profile) + const triggers: Talent['id'][] = [] + for (const talent of state.talents) { + const { condition, max } = talents.get(talent)! + if (state.talentTriggers.get(talent) ?? 0 >= max) continue + if (condition && !check(flatState, condition)) continue + triggers.push(talent) + } + if (triggers.length === 0) return { state, triggers, changed: false } + const newState = produce(state, draft => { + for (const talent of triggers) { + const times = draft.talentTriggers.get(talent) ?? 0 + draft.talentTriggers.set(talent, times + 1) + const { effect } = talents.get(talent)! + if (!effect) continue + const pe = {} as Partial + if (effect.CHR) pe.charm = effect.CHR + if (effect.INT) pe.intelligence = effect.INT + if (effect.STR) pe.strength = effect.STR + if (effect.MNY) pe.money = effect.MNY + if (effect.SPR) pe.spirit = effect.SPR + if (effect.RND) { + const key = pick(TalentEffectRandomProperties)! + pe[key] = (pe[key] ?? 0) || effect.RND + } + draft.props = propsEffect(draft.props, pe) + } + }) + return { state: newState, triggers, changed: true } +} diff --git a/packages/data/src/talent.types.ts b/packages/data/src/talent.types.ts index 785fd01..7a1b1e1 100644 --- a/packages/data/src/talent.types.ts +++ b/packages/data/src/talent.types.ts @@ -20,14 +20,21 @@ export type TalentEffect = { /** 权重天赋 */ export type TalentWithWeight = [number, number] -/** 天赋盲盒 */ -export type TalentReplacement = { - /** 指定列表盲盒 */ - talent?: TalentWithWeight[] - /** 指定稀有度盲盒 */ - grade?: TalentGrade[] +/** 指定列表盲盒 */ +export type TalentReplacementTalent = { + talent: TalentWithWeight[] + grade?: never } +/** 指定稀有度盲盒 */ +export type TalentReplacementGrade = { + grade: TalentGrade + talent?: never +} + +/** 天赋盲盒 */ +export type TalentReplacement = TalentReplacementTalent | TalentReplacementGrade + /** 天赋 */ export type Talent = { /** 序号 */ @@ -45,11 +52,13 @@ export type Talent = { /** 专属天赋 */ readonly exclusive?: boolean /** 初始可用属性点调整 */ - readonly status?: number + readonly points?: number /** 互斥天赋 */ readonly exclude?: number[] /** 天赋盲盒 */ readonly replacement?: TalentReplacement + /** 天赋触发上限 */ + readonly max: number } // @vt-types-end @@ -80,7 +89,8 @@ export const transformers = { ) return [talent, weight ?? 1] }) - if (val.grade) val.grade = val.grade.map(Number) + if (val.grade) val.grade = Number(val.grade) return val }, + max: (val: any) => Number(val) || 1, } diff --git a/packages/data/src/talent.xlsx b/packages/data/src/talent.xlsx index 68bbd13..c3db11c 100644 Binary files a/packages/data/src/talent.xlsx and b/packages/data/src/talent.xlsx differ diff --git a/packages/vitex/index.ts b/packages/vitex/index.ts index 74ab3fc..2aa3cf5 100644 --- a/packages/vitex/index.ts +++ b/packages/vitex/index.ts @@ -5,7 +5,7 @@ export function random(max: number, min: number = 0, rng?: RNG): number { } export function pick(items: T[], rng?: RNG) { if (items.length === 0) return null - return items[random(items.length - 1, 0, rng)] + return items[random(items.length - 1, 0, rng)] ?? null } export type WeightItem = [T, number] export function pickWeight(items: WeightItem[], rng?: RNG) {