diff --git a/apps/web/src/components/Replaced.tsx b/apps/web/src/components/Replaced.tsx index 51c718f..933112f 100644 --- a/apps/web/src/components/Replaced.tsx +++ b/apps/web/src/components/Replaced.tsx @@ -1,6 +1,5 @@ -import type { Talent } from '@remake/data/talent' +import { type Talent, talents } from '@remake/data' import TalentComponent from './Talent' -import talents from '@remake/data/talent' import './Replaced.css' export interface TalentProps { diff --git a/apps/web/src/components/Talent.tsx b/apps/web/src/components/Talent.tsx index 29210a7..20daa65 100644 --- a/apps/web/src/components/Talent.tsx +++ b/apps/web/src/components/Talent.tsx @@ -1,5 +1,4 @@ -import type { Talent } from '@remake/data/talent' -import talents from '@remake/data/talent' +import { type Talent, talents } from '@remake/data' import './Talent.css' export interface TalentProps { diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index eb65d87..c956fef 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -27,6 +27,11 @@ export const AllocLimit = dev.allocate ?? 10 export const DefaultSpirit = dev.spirit ?? 5 // 天赋抽取个数 export const PullCount = dev.pull ?? 10 +// 名人模式抽取个数 +export const CharacterPullCount = dev.chara ?? 3 +// 名人模式抽取权重切线 +export const CharacterWeightKnife = dev.knife ?? 10 + // 天赋抽取基础概率 export const PullRateBase: Config['pull']['rate']['base'] = new Map([ [0, 889], @@ -113,6 +118,10 @@ export const config: Config = { additions: PullRateAdditions, }, }, + chara: { + count: CharacterPullCount, + knife: CharacterWeightKnife, + }, } export default config diff --git a/apps/web/src/containers/Play.tsx b/apps/web/src/containers/Play.tsx index 0b77bf4..a945f2f 100644 --- a/apps/web/src/containers/Play.tsx +++ b/apps/web/src/containers/Play.tsx @@ -1,10 +1,9 @@ import { useState, useRef } from 'react' import { useLayoutEffect, useEffect } from 'react' -import { useNext, useGotoSummary } from '@remake/hooks' +import { useNext, useGotoSummary, type Log } from '@remake/hooks' import { useJudge } from '@/hooks/judge' -import type { Log } from '@remake/hooks' -import { properties } from '@/display' import { achievements, events, talents } from '@remake/data' +import { properties } from '@/display' import { AutoInterval } from '@/config' import { format } from '@remake/vitex' import './Play.css' diff --git a/apps/web/src/storage.ts b/apps/web/src/storage.ts index 82b1cba..6f1ed08 100644 --- a/apps/web/src/storage.ts +++ b/apps/web/src/storage.ts @@ -16,16 +16,14 @@ if (localStorage.getItem('version') !== '3.0.0') { localStorage.getItem('extendTalent') || 'null', ) if (lockedTalents) profile.locked = [lockedTalents] - const unique = JSON.parse( - localStorage.getItem('uniqueWaTaShi') || 'null', - ) - if (unique) profile.unique = unique localStorage.setItem('profile', JSON.stringify(profile)) localStorage.removeItem('times') localStorage.removeItem('ACHV') localStorage.removeItem('AEVT') localStorage.removeItem('ATLT') localStorage.removeItem('extendTalent') + const unique = localStorage.getItem('uniqueWaTaShi') + if (unique) localStorage.setItem('unique', unique) localStorage.removeItem('uniqueWaTaShi') } localStorage.setItem('version', '3.0.0') diff --git a/packages/core/src/character.spec.ts b/packages/core/src/character.spec.ts new file mode 100644 index 0000000..78db4c1 --- /dev/null +++ b/packages/core/src/character.spec.ts @@ -0,0 +1,61 @@ +import { expect, test, describe } from 'bun:test' +import { pullCharacter, uniqueGenerate } from './character' +import { enableMapSet } from 'immer' +enableMapSet() + +describe('Character', () => { + const sum = (map: Map) => + Array.from(map.values()).reduce((acc, val) => acc + val, 0) + test('pull', () => { + let result = pullCharacter( + { count: 10, knife: 10 }, + { times: 0, drawns: new Map() }, + ) + expect(result.characters).not.toContainValue(null) + expect(result.characters.length).toBe(10) + expect(result.times.times).toBe(10) + expect(result.times.drawns.size).toBe(10) + result = pullCharacter({ count: 3, knife: 10 }, result.times) + expect(result.characters).not.toContainValue(null) + expect(result.characters.length).toBe(3) + expect(result.times.times).toBe(13) + expect(sum(result.times.drawns)).toBe(13) + result = pullCharacter({ count: 3, knife: 10 }, result.times) + expect(result.characters).not.toContainValue(null) + expect(result.characters.length).toBe(3) + expect(result.times.times).toBe(16) + expect(sum(result.times.drawns)).toBe(16) + result = pullCharacter({ count: 20, knife: 1 }, result.times) + expect(result.characters).not.toContainValue(null) + expect(result.characters.length).toBe(20) + expect(result.times.times).toBe(36) + expect(sum(result.times.drawns)).toBe(36) + }) + + const wr = (s: number, e: number) => { + const length = e - s + 1 + return Array.from( + { length }, + (_, i) => [s + i, Math.min(i + 1, length - i)] as const, + ) + } + + test('unique', () => { + const unique = uniqueGenerate({ prop: wr(0, 10), talent: wr(1, 5) }) + expect(unique.property).toHaveProperty('CHR') + expect(unique.property).toHaveProperty('INT') + expect(unique.property).toHaveProperty('STR') + expect(unique.property).toHaveProperty('MNY') + expect(unique.property.CHR).toBeGreaterThanOrEqual(0) + expect(unique.property.INT).toBeGreaterThanOrEqual(0) + expect(unique.property.STR).toBeGreaterThanOrEqual(0) + expect(unique.property.MNY).toBeGreaterThanOrEqual(0) + expect(unique.talent.length).toBeGreaterThanOrEqual(0) + expect(unique.property.CHR).toBeLessThanOrEqual(10) + expect(unique.property.INT).toBeLessThanOrEqual(10) + expect(unique.property.STR).toBeLessThanOrEqual(10) + expect(unique.property.MNY).toBeLessThanOrEqual(10) + expect(unique.talent.length).toBeLessThanOrEqual(5) + console.debug(unique) + }) +}) diff --git a/packages/core/src/character.ts b/packages/core/src/character.ts new file mode 100644 index 0000000..6859c0c --- /dev/null +++ b/packages/core/src/character.ts @@ -0,0 +1,71 @@ +import { type Character, type Talent, characters, talents } from '@remake/data' +import { type RNG, type WeightItem, pick, pickWeight } from '@remake/vitex' + +export type UniqueChara = Omit +export interface UniqueGenCfg { + prop: WeightItem[] + talent: WeightItem[] +} +export function uniqueGenerate(config: UniqueGenCfg, rng?: RNG) { + const CHR = pickWeight(config.prop, rng) ?? 0 + const INT = pickWeight(config.prop, rng) ?? 0 + const STR = pickWeight(config.prop, rng) ?? 0 + const MNY = pickWeight(config.prop, rng) ?? 0 + let count = pickWeight(config.talent, rng) ?? 0 + const property = { CHR, INT, STR, MNY } + const ids = Array.from(talents.keys()) + const picked = new Set() + while (count > 0) { + const t = pick(ids, rng)! + if (picked.has(t)) continue + if (talents.get(t)!.exclusive) continue + picked.add(t) + count-- + } + return { property, talent: Array.from(picked) } satisfies UniqueChara +} + +export interface PullCharaOpt { + count: number + knife: number +} +export interface PullCharaTms { + times: number + drawns: Map +} +export interface PullCharaRet { + characters: Character['id'][] + times: PullCharaTms +} +export function pullCharacter( + opt: PullCharaOpt, + tms: PullCharaTms, + rng?: RNG, +): PullCharaRet { + const { count, knife } = opt + const drawns = new Map(tms.drawns) + const picked = new Set() + for (let i = 0; i < count; i++) { + const weightMap = deriveWeightMap(tms.times + i, knife, drawns) + picked.forEach(id => weightMap.delete(id)) + const id = pickWeight(Array.from(weightMap.entries()), rng)! + picked.add(id) + drawns.set(id, (drawns.get(id) ?? 0) + 1) + } + return { + characters: Array.from(picked), + times: { times: tms.times + count, drawns }, + } +} + +function deriveWeightMap( + times: number, + knife: number, + drawns: PullCharaTms['drawns'], +): Map { + const max = Math.max(0, ...drawns.values()) + const base = times - knife * Math.floor((times - max) / (knife || 1)) || 1 + return new Map( + Array.from(characters.keys(), id => [id, base - (drawns.get(id) ?? 0)]), + ) +} diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index a31985d..1a14300 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,11 +1,9 @@ -import type { Event } from '@remake/data/event' -import events from '@remake/data/event' -import type { Properties } from './state' -import type { GameState, ProfileState } from './state' +import { type Event, events } from '@remake/data' +import type { Properties, GameState, ProfileState } from './state' import { propsEffect, createFlatState } from './state' import { check as checkCondition } from '@remake/condition' -import { produce } from 'immer' import type { TriggerResult } from './game' +import { produce } from 'immer' export function check( event: Event['id'], diff --git a/packages/core/src/game.spec.ts b/packages/core/src/game.spec.ts index cddf7f7..0ae6709 100644 --- a/packages/core/src/game.spec.ts +++ b/packages/core/src/game.spec.ts @@ -1,6 +1,6 @@ import { expect, test, describe } from 'bun:test' import { createState, propsEffect, summary as stateSummary } from './state' -import { talentsPicked, start, next, summary, end } from './game' +import { pick, start, next, summary, end } from './game' import { produce } from 'immer' import { enableMapSet } from 'immer' enableMapSet() @@ -22,7 +22,7 @@ describe('Achievement', () => { test('talentsPicked', () => { // 挑战者、阴间福袋、轮盘赌 - const result = talentsPicked([1122, 1145, 1146]) + const result = pick([1122, 1145, 1146]) expect(result.talents.chains.size).toBeGreaterThan(1) expect(result.additionalPoints.source).toContainEqual({ talent: 1122, diff --git a/packages/core/src/game.ts b/packages/core/src/game.ts index c26af59..5d3c175 100644 --- a/packages/core/src/game.ts +++ b/packages/core/src/game.ts @@ -1,11 +1,9 @@ import type { Achievement, Event, Talent } from '@remake/data' import { ages, AchievementOpportunity as Ao } from '@remake/data' -import type { Allocation, GameState, ProfileState } from './state' -import type { Properties } from './state' +import type { GameState, ProfileState } from './state' import { createState, nextProfile, propsEffect } from './state' import { summary as stateSummary } from './state' -import type { PullOptions, ReplacementResult } from './talent' -import type { AdditionalPoint, AdditionalPoints } from './talent' +import type { ReplacementResult, AdditionalPoints } from './talent' import { pull, exclude, replacement, additionalPoints } from './talent' import { trigger as ttr } from './talent' import { trigger as atr } from './achievement' @@ -19,14 +17,11 @@ export interface TriggerResult { state: GameState triggers: T[] } -export interface TalentsPickedResult { +export interface PickResult { talents: ReplacementResult additionalPoints: AdditionalPoints } -export function talentsPicked( - talents: Iterable, - rng?: RNG, -): TalentsPickedResult { +export function pick(talents: Iterable, rng?: RNG): PickResult { const r = replacement(talents, rng) const ap = additionalPoints(r.talents) return { talents: r, additionalPoints: ap } @@ -110,6 +105,3 @@ export function end( } export { pull, exclude } -export type { GameState, ProfileState, Properties, RNG } -export type { PullOptions, ReplacementResult, Allocation } -export type { AdditionalPoint, AdditionalPoints } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 252d9cd..000d8cd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,10 @@ -export * from './game' +export type { RNG } from '@remake/vitex' +export type { GameState, ProfileState, Properties, Allocation } from './state' +export type { PullOptions, ReplacementResult } from './talent' +export type { AdditionalPoint, AdditionalPoints } from './talent' +export type { PullCharaOpt, PullCharaTms, PullCharaRet } from './character' +export type { UniqueChara, UniqueGenCfg } from './character' +export type { PickResult, StartResult } from './game' +export type { NextResult, SummaryResult, EndResult } from './game' +export { pull, exclude } from './talent' +export { pick, start, next, summary, end } from './game' diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 4763e38..67dafa1 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -1,4 +1,4 @@ -import type { Achievement, Event, Talent, Character } from '@remake/data' +import type { Achievement, Event, Talent } from '@remake/data' import { produce } from 'immer' import { sum, keys } from '@remake/vitex' @@ -37,9 +37,6 @@ export interface GameState { talentTriggers: Map // 本局天赋触发次数 } -/** 唯一角色 */ -export type UniqueCharacter = Omit - /** 持久化存储的数据 */ export interface ProfileState { times: number // 游戏次数 @@ -49,7 +46,6 @@ export interface ProfileState { achievements: Set // 达成的成就 highest?: Properties // 历史最高属性 lowest?: Properties // 历史最低属性 - unique?: UniqueCharacter // 唯一角色属性 } export function createState( diff --git a/packages/core/src/talent.ts b/packages/core/src/talent.ts index 518b954..bcb0e64 100644 --- a/packages/core/src/talent.ts +++ b/packages/core/src/talent.ts @@ -1,7 +1,5 @@ -import type { Talent, TalentGrade } from '@remake/data' -import { talents } from '@remake/data' -import type { Properties } from './state' -import type { GameState, ProfileState } from './state' +import { type Talent, type TalentGrade, talents } from '@remake/data' +import type { Properties, GameState, ProfileState } from './state' import { propsEffect, createFlatState } from './state' import { check } from '@remake/condition' import { pick, pickWeight, type RNG } from '@remake/vitex' diff --git a/packages/hooks/src/alloc.ts b/packages/hooks/src/alloc.ts index bf89c62..e48dc29 100644 --- a/packages/hooks/src/alloc.ts +++ b/packages/hooks/src/alloc.ts @@ -1,10 +1,8 @@ import { useCallback } from 'react' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' -import { useConfig } from './config' -import { useReplaced } from './talent' +import { useConfig, useReplaced } from '.' import type { Allocation } from '@remake/core' -import type { RNG } from '@remake/vitex' -import { keys, shuffle, random as frandom } from '@remake/vitex' +import { keys, shuffle, random as frandom, type RNG } from '@remake/vitex' export type UserAllocation = Omit const init: UserAllocation = { diff --git a/packages/hooks/src/config.ts b/packages/hooks/src/config.ts index fc8cf06..81d300b 100644 --- a/packages/hooks/src/config.ts +++ b/packages/hooks/src/config.ts @@ -1,7 +1,6 @@ import { useCallback } from 'react' import { atom, useSetAtom, useAtomValue } from 'jotai' -import type { PullOptions } from '@remake/core' - +import type { PullOptions, PullCharaOpt } from '@remake/core' export interface Config { /** 游戏锁定天赋数量 */ lock: number @@ -9,8 +8,6 @@ export interface Config { max: number /** 游戏最少选择天赋数量 */ min: number - /** 天赋抽取个数 */ - pull: PullOptions /** 初始属性点 */ points: number /** 单项属性点最大限制 */ @@ -19,6 +16,10 @@ export interface Config { spirit: number /** 模式选择限制 */ mode: number + /** 天赋抽取个数 */ + pull: PullOptions + /** 名人模式配置 */ + chara: PullCharaOpt } export const configAtom = atom(null) diff --git a/packages/hooks/src/play.ts b/packages/hooks/src/play.ts index b42f377..2e5e38b 100644 --- a/packages/hooks/src/play.ts +++ b/packages/hooks/src/play.ts @@ -1,12 +1,10 @@ import { useCallback, useRef, useState } from 'react' import { atom, useSetAtom, useAtomValue, useAtom } from 'jotai' -import { useConfig } from './config' -import { useProfile } from './profile' -import { useReplaced, useTalentReset } from './talent' -import { useAlloc, useAllocReset } from './alloc' +import { useConfig, useProfile, useReplaced, useAlloc } from '.' +import { useTalentReset, useAllocReset } from '.' import { start, next, summary, end } from '@remake/core' import type { GameState, Properties, NextResult } from '@remake/core' -import type { Talent } from '@remake/data/talent' +import type { Talent } from '@remake/data' export enum Step { Idle = 'idle', diff --git a/packages/hooks/src/talent.ts b/packages/hooks/src/talent.ts index 698a080..48dcbac 100644 --- a/packages/hooks/src/talent.ts +++ b/packages/hooks/src/talent.ts @@ -1,14 +1,12 @@ import { useState, useCallback } from 'react' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' -import { useConfig } from './config' -import { useProfile } from './profile' -import { useSetStep } from './play' -import { pull, exclude, talentsPicked } from '@remake/core' -import type { TalentsPickedResult, RNG } from '@remake/core' -import type { Talent } from '@remake/data/talent' +import { useConfig, useProfile, useSetStep } from '.' +import { pull, exclude, pick, type PickResult } from '@remake/core' +import type { Talent } from '@remake/data' +import type { RNG } from '@remake/vitex' export const pickedAtom = atom(new Set()) -export const replacedAtom = atom(null) +export const replacedAtom = atom(null) export const useTalentReset = () => { const setPicked = useSetAtom(pickedAtom) @@ -41,25 +39,16 @@ export const useTalentPuller = () => { return [pulled, puller] as const } -export type TalentPickerResult = - | { - type: 'ok' - talent?: never - } - | { - type: 'not-enough' - talent?: never - } - | { - type: 'exclude' - talent: Talent['id'] - } +export type PickOk = { type: 'ok'; talent?: never } +export type PickNe = { type: 'ne'; talent?: never } +export type PickEx = { type: 'ex'; talent: Talent['id'] } +export type PickerResult = PickOk | PickNe | PickEx export const useTalentPicker = () => { const { max } = useConfig() const [picked, setPicked] = useAtom(pickedAtom) const picker = useCallback( - (talent: Talent['id']): TalentPickerResult => { + (talent: Talent['id']): PickerResult => { if (!picked) { setPicked(new Set([talent])) return { type: 'ok' } @@ -70,9 +59,9 @@ export const useTalentPicker = () => { setPicked(next) return { type: 'ok' } } - if (picked.size >= max) return { type: 'not-enough' } + if (picked.size >= max) return { type: 'ne' } const e = exclude(talent, picked) - if (e) return { type: 'exclude', talent: e } + if (e) return { type: 'ex', talent: e } const next = new Set([...picked, talent]) setPicked(next) return { type: 'ok' } @@ -97,7 +86,7 @@ export const useTalentSubmit = () => { return useCallback( (rng?: RNG) => { if (!enabled) throw new Error('Not enough talents picked') - setReplaced(talentsPicked(picked, rng)) + setReplaced(pick(picked, rng)) setStep(Step.Alloc) }, [enabled, picked, setReplaced, setStep], diff --git a/packages/vitex/index.ts b/packages/vitex/index.ts index 4e5bade..cd0d660 100644 --- a/packages/vitex/index.ts +++ b/packages/vitex/index.ts @@ -1,5 +1,5 @@ export type RNG = (max?: number, min?: number) => number -export function random(max: number, min: number = 0, rng?: RNG): number { +export function random(max: number = 1, min: number = 0, rng?: RNG): number { if (rng) return rng(max, min) return Math.floor(Math.random() * (max - min + 1)) + min } @@ -7,7 +7,7 @@ export function pick(items: T[], rng?: RNG) { if (items.length === 0) return null return items[random(items.length - 1, 0, rng)] ?? null } -export type WeightItem = [T, number] +export type WeightItem = readonly [T, number] export function pickWeight(items: WeightItem[], rng?: RNG) { if (items.length === 0) return null const totalWeight = items.reduce((sum, [, weight]) => sum + weight, 0)