update: hooks, web

This commit is contained in:
Vick Scarlet
2026-08-16 20:06:16 +08:00
committed by 神戸小鳥
parent 2f91709d1e
commit 322f775796
67 changed files with 2339 additions and 2243 deletions
+7 -9
View File
@@ -13,9 +13,7 @@ export function parse(condition: string): ConditionTree {
const catchString = (i: number): void => {
const str = condition.substring(cursor, i).trim()
cursor = i
if (str) {
stack[0]?.push(str)
}
if (str) stack[0]?.push(str)
}
for (let i = 0; i < length; i++) {
@@ -118,13 +116,13 @@ function checkProp(property: PropertyContainer, condition: string): boolean {
switch (symbol) {
case '>':
return propData > conditionData
return (propData ?? 0) > conditionData
case '<':
return propData < conditionData
return (propData ?? 0) < conditionData
case '>=':
return propData >= conditionData
return (propData ?? 0) >= conditionData
case '<=':
return propData <= conditionData
return (propData ?? 0) <= conditionData
case '=':
if (propData instanceof Set) {
return propData.has(conditionData)
@@ -132,7 +130,7 @@ function checkProp(property: PropertyContainer, condition: string): boolean {
if (Array.isArray(propData)) {
return propData.includes(conditionData)
}
return propData == conditionData
return (propData ?? 0) == conditionData
case '!=':
if (propData instanceof Set) {
return !propData.has(conditionData)
@@ -140,7 +138,7 @@ function checkProp(property: PropertyContainer, condition: string): boolean {
if (Array.isArray(propData)) {
return !propData.includes(conditionData)
}
return propData != conditionData
return (propData ?? 0) != conditionData
case '?': // 🌟 包含判定符(如 属性值 是否在 [1,2,3] 数组范围内)
if (propData instanceof Set) {
+1 -26
View File
@@ -1,29 +1,4 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"extends": "../../tsconfig.json",
"include": ["**/*"]
}
+13
View File
@@ -3,6 +3,7 @@
"type": "module",
"version": "3.0.0",
"author": "Vick Scarlet <vick@syaro.io>",
"main": "src/index.ts",
"scripts": {
"lint": "eslint .",
"test": "bun test"
@@ -15,5 +16,17 @@
},
"eslintConfig": {
"extends": "../../package.json"
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
},
"./*": {
"types": "./src/*.ts",
"import": "./src/*.ts",
"default": "./src/*.ts"
}
}
}
+6 -1
View File
@@ -1,6 +1,7 @@
import { expect, test, describe } from 'bun:test'
import { createState } from './state'
import { trigger } from './achievement'
import { AchievementOpportunity } from '@remake/data'
import { enableMapSet } from 'immer'
enableMapSet()
@@ -20,7 +21,11 @@ describe('Achievement', () => {
test('trigger [times:500]', () => {
const p = { ...profile, times: 500 }
const result = trigger('END', createState(allocation, []), p)
const result = trigger(
AchievementOpportunity.End,
createState(allocation, []),
p,
)
expect(result.state.achievements).toContain(101) // 既视感
expect(result.state.achievements).toContain(102) // 孟婆愁
expect(result.state.achievements).toContain(103) // 所有人都是我
+4 -7
View File
@@ -1,13 +1,10 @@
import type {
Achievement,
AchievementOpportunity,
} from '@remake/data/achievement'
import achievements from '@remake/data/achievement'
import type { GameState, ProfileState } from '@/state'
import type { Achievement, AchievementOpportunity } from '@remake/data'
import { achievements } from '@remake/data'
import type { GameState, ProfileState } from './state'
import { createFlatState } from './state'
import { check } from '@remake/condition'
import { produce } from 'immer'
import type { TriggerResult } from '@/game'
import type { TriggerResult } from './game'
const OpportunityMap = Map.groupBy(
achievements.keys(),
+5 -5
View File
@@ -1,11 +1,11 @@
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 { propsEffect, createFlatState } from '@/state'
import type { Properties } from './state'
import type { 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 type { TriggerResult } from './game'
export function check(
event: Event['id'],
@@ -30,7 +30,7 @@ export function trigger(
draft.events.add(eventId)
if (!effect) return
if (effect.LIF) draft.life += effect.LIF
const pe = {} as Partial<Properties>
const pe: Partial<Properties> = {}
if (effect.CHR) pe.charm = effect.CHR
if (effect.INT) pe.intelligence = effect.INT
if (effect.STR) pe.strength = effect.STR
+26 -39
View File
@@ -1,26 +1,23 @@
import ages from '@remake/data/age'
import { propsEffect } from '@/state'
import { enableMapSet, produce } from 'immer'
import type { Achievement, Event, Talent } from '@remake/data'
import { ages, AchievementOpportunity as Ao } from '@remake/data'
import type { Allocation, 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 { pull, exclude, replacement, additionalPoints } from './talent'
import { trigger as ttr } from './talent'
import { trigger as atr } from './achievement'
import { trigger as etr, check as ec } from './event'
import type { RNG } from '@remake/vitex'
import { pickWeight } from '@remake/vitex'
import { produce, enableMapSet } from 'immer'
enableMapSet()
export type * as achievement from '@/achievement'
export type * as event from '@/event'
export type * as talent from '@/talent'
export type * as state from '@/state'
import type { GameState, ProfileState } from '@/state'
export type { GameState, ProfileState }
export interface TriggerResult<T> {
state: GameState
triggers: T[]
}
export { pull } from '@/talent'
// export { summary, nextProfile } from '@/state'
import { replacement, additionalPoints } from '@/talent'
import type { ReplacementResult, AdditionalPoints } from '@/talent'
import type { RNG } from '@remake/vitex'
export interface TalentsPickedResult {
talents: ReplacementResult
additionalPoints: AdditionalPoints
@@ -34,8 +31,6 @@ export function talentsPicked(
return { talents: r, additionalPoints: ap }
}
import { createState } from '@/state'
export interface StartResult {
state: GameState
achievements: Achievement['id'][]
@@ -45,19 +40,9 @@ export function start(
...args: Parameters<typeof createState>
): StartResult {
const state = createState(...args)
const ar = achievementTrigger('START', state, profile)
const ar = atr(Ao.Start, state, profile)
return { state: ar.state, achievements: ar.triggers }
}
import type { Achievement } from '@remake/data/achievement'
import type { Event } from '@remake/data/event'
import type { Talent } from '@remake/data/talent'
import { trigger as achievementTrigger } from '@/achievement'
import { trigger as eventTrigger } from '@/event'
import { trigger as talentTrigger } from '@/talent'
import { check as eventCheck } from '@/event'
import { pickWeight } from '@remake/vitex'
export interface NextResult {
state: GameState
age: number
@@ -76,13 +61,13 @@ export function next(
draft.props = propsEffect(state.props, { age: 1 })
})
const age = s.props.current.age
const tr = talentTrigger(s, profile, rng)
const tr = ttr(s, profile, rng)
const events = ages
.get(age)!
.event.filter(([e]) => eventCheck(e, tr.state, profile))
.event.filter(([e]) => ec(e, tr.state, profile))
const event = pickWeight(events, rng)!
const er = eventTrigger(event, tr.state, profile)
const ar = achievementTrigger('TRAJECTORY', er.state, profile)
const er = etr(event, tr.state, profile)
const ar = atr(Ao.Trajectory, er.state, profile)
const end = ar.state.life < 1
return {
state: ar.state,
@@ -94,8 +79,6 @@ export function next(
}
}
import { summary as stateSummary } from '@/state'
export interface SummaryResult {
state: GameState
summary: number
@@ -105,12 +88,11 @@ export function summary(
state: GameState,
profile: ProfileState,
): SummaryResult {
const ar = achievementTrigger('SUMMARY', state, profile)
const ar = atr(Ao.Summary, state, profile)
const s = stateSummary(ar.state)
return { state: ar.state, summary: s, achievements: ar.triggers }
}
import { nextProfile } from '@/state'
export interface EndResult {
profile: ProfileState
achievements: Achievement['id'][]
@@ -121,7 +103,12 @@ export function end(
profile: ProfileState,
lockedTalent?: Talent['id'],
) {
const ar = achievementTrigger('END', state, profile)
const ar = atr(Ao.End, state, profile)
const p = nextProfile(profile, ar.state, lockedTalent)
return { profile: p, achievements: ar.triggers }
}
export { pull, exclude }
export type { GameState, ProfileState, Allocation, RNG }
export type { PullOptions, ReplacementResult }
export type { AdditionalPoint, AdditionalPoints }
+1 -1
View File
@@ -1 +1 @@
export * from '@/game'
export * from './game'
+1 -4
View File
@@ -1,10 +1,7 @@
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 { achievements, events, talents } from '@remake/data'
import type { GameState, ProfileState, Properties } from './state'
import { createFlatState, createHLProperties, propsEffect } from './state'
import { SupportedFlatStateKeys } from './state'
+7 -9
View File
@@ -1,8 +1,6 @@
import type { Talent } from '@remake/data/talent'
import type { Event } from '@remake/data/event'
import type { Achievement } from '@remake/data/achievement'
import type { Achievement, Event, Talent } from '@remake/data'
import { produce } from 'immer'
import { sum } from '@remake/vitex'
import { sum, keys } from '@remake/vitex'
/** 基础的属性 */
export interface Properties {
@@ -112,7 +110,7 @@ type FlatMapper<Key extends FlatStateKey> = (
state: FlatTarget,
) => FlatState[Key]
const FlatMappers = {
const FlatMappers: { [Key in FlatStateKey]: FlatMapper<Key> } = {
AGE: state => state.game.props.current.age,
CHR: state => state.game.props.current.charm,
INT: state => state.game.props.current.intelligence,
@@ -139,9 +137,9 @@ const FlatMappers = {
ATLT: state => state.profile.talents,
AACH: state => state.profile.achievements,
SUM: ({ game }) => summary(game),
} as { [Key in FlatStateKey]: FlatMapper<Key> }
}
export const SupportedFlatStateKeys = new Set(Object.keys(FlatMappers))
export const SupportedFlatStateKeys = new Set(keys(FlatMappers))
const flatStateHandle = {
get<Key extends FlatStateKey>(target: FlatTarget, prop: Key) {
@@ -175,7 +173,7 @@ export function propsEffect(hlp: HLProperties, effect: Partial<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)[]) {
for (const key of keys(a)) {
result[key] = Math.max(a[key], b[key])
}
return result
@@ -184,7 +182,7 @@ export function highestProperties(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)[]) {
for (const key of keys(a)) {
result[key] = Math.min(a[key], b[key])
}
return result
+1 -2
View File
@@ -2,8 +2,7 @@ import { expect, test, describe } from 'bun:test'
import type { ProfileState } from './state'
import type { PullOptions } from './talent'
import { pull, exclude, additionalPoints, replacement } from './talent'
import { createState } from './state'
import talents from '@remake/data/talent'
import { talents } from '@remake/data'
import { produce } from 'immer'
import { enableMapSet } from 'immer'
enableMapSet()
+10 -10
View File
@@ -1,12 +1,12 @@
import type { Talent, TalentGrade } from '@remake/data/talent'
import talents from '@remake/data/talent'
import type { Properties } from '@/state'
import type { GameState, ProfileState } from '@/state'
import type { Talent, TalentGrade } from '@remake/data'
import { talents } from '@remake/data'
import type { Properties } from './state'
import type { GameState, ProfileState } from './state'
import { propsEffect, createFlatState } from './state'
import { check } from '@remake/condition'
import { pick, pickWeight, type RNG } from '@remake/vitex'
import { produce } from 'immer'
import type { TriggerResult } from '@/game'
import type { TriggerResult } from './game'
const Grades: TalentGrade[] = [0, 1, 2, 3] as const
const GradeMap = Map.groupBy(
@@ -16,10 +16,6 @@ const GradeMap = Map.groupBy(
export const count = talents.size
export function get(talent: number) {
return talents.get(talent)
}
export type PullRate = Map<TalentGrade, number>
export type PullRateAddition = {
mode: 'add' | 'multiply'
@@ -64,7 +60,11 @@ export interface PullOptions {
rate: RateOptions
}
export function pull(options: PullOptions, profile: ProfileState, rng?: RNG) {
export function pull(
options: PullOptions,
profile: ProfileState,
rng?: RNG,
): Talent['id'][] {
const rate = Array.from(
talentRateWithAddition(options.rate, profile).entries(),
)
+1 -29
View File
@@ -1,32 +1,4 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"paths": {
"@/*": ["./src/*"],
},
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"extends": "../../tsconfig.json",
"include": ["**/*"]
}
+5 -1
View File
@@ -5,11 +5,15 @@
"main": "src/index.ts",
"author": "Vick Scarlet <vick@syaro.io>",
"scripts": {
"build": "bunx --bun v-transform@3.0.1 transform -t ts -w src -d dist \"**/[!~$]*.xlsx\" ",
"build": "bunx --bun v-transform transform -t ts -w src -d dist \"**/[!~$]*.xlsx\" --map",
"lint": "eslint .",
"test": "bun test"
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./*": {
"types": "./dist/*.ts",
"import": "./dist/*.ts"
+12 -6
View File
@@ -1,12 +1,18 @@
/** 成就稀有度 */
export type AchievementGrade = 0 | 1 | 2 | 3
export enum AchievementGrade {
White = 0,
Blue = 1,
Purple = 2,
Orange = 3,
}
/** 成就触发时机 */
export type AchievementOpportunity =
| 'START' // 分配完成点数,点击开始新人生后
| 'TRAJECTORY' // 每一年的人生经历中
| 'SUMMARY' // 人生结束,点击人生总结后
| 'END' // 游戏完成,点击重开 重开次数在这之后才会+1
export enum AchievementOpportunity {
Start = 'START', // 分配完成点数,点击开始新人生后
Trajectory = 'TRAJECTORY', // 每一年的人生经历中
Summary = 'SUMMARY', // 人生结束,点击人生总结后
End = 'END', // 游戏完成,点击重开 重开次数在这之后才会+1
}
/** 成就 */
export type Achievement = {
+12
View File
@@ -0,0 +1,12 @@
export * from '../dist/achievement'
export { default as achievements } from '../dist/achievement'
export * from '../dist/age'
export { default as ages } from '../dist/age'
export * from '../dist/character'
export { default as characters } from '../dist/character'
export * from '../dist/event'
export { default as events } from '../dist/event'
export * from '../dist/specialthanks'
export { default as specialThanks } from '../dist/specialthanks'
export * from '../dist/talent'
export { default as talents } from '../dist/talent'
+6 -1
View File
@@ -1,5 +1,10 @@
/** 天赋稀有度 */
export type TalentGrade = 0 | 1 | 2 | 3
export enum TalentGrade {
White = 0,
Blue = 1,
Purple = 2,
Orange = 3,
}
/** 天赋效果 */
export type TalentEffect = {
+1 -26
View File
@@ -1,29 +1,4 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"extends": "../../tsconfig.json",
"include": ["**/*"]
}
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./setup-tests.ts"] # 在运行测试前加载环境
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@remake/hooks",
"type": "module",
"version": "3.0.0",
"author": "Vick Scarlet <vick@syaro.io>",
"main": "./src/index.ts",
"scripts": {
"lint": "bunx --bun eslint",
"test": "bun test"
},
"dependencies": {
"@remake/core": "workspace:*",
"@remake/data": "workspace:*",
"@remake/vitex": "workspace:*"
},
"peerDependencies": {
"jotai": "^2.20.2",
"react": "^19.2.8"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.11.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.18",
"eslint-config-react": "^1.1.7"
},
"eslintConfig": {
"extends": "react"
},
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
},
"./*": {
"import": "./src/*.ts",
"types": "./src/*.ts"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import { GlobalRegistrator } from '@happy-dom/global-registrator'
GlobalRegistrator.register()
import { afterEach, expect } from 'bun:test'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
expect.extend(matchers)
// Optional: cleans up `render` after each test
afterEach(() => {
cleanup()
})
+69
View File
@@ -0,0 +1,69 @@
import { useCallback } from 'react'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useConfig } from './config'
import { useReplaced } from './talent'
import type { Allocation } from '@remake/core'
import type { RNG } from '@remake/vitex'
import { keys, shuffle, random as frandom } from '@remake/vitex'
const init: Allocation = { charm: 0, intelligence: 0, strength: 0, money: 0 }
const allocAtom = atom<Allocation>(init)
const alloced = (alloc: Allocation) =>
Object.values(alloc).reduce((a, b) => a + b, 0)
export const usePoints = () => {
const { points } = useConfig()
const { additionalPoints } = useReplaced()
return Math.max(points + additionalPoints.points, 0)
}
export const useLeftPoints = () => {
const points = usePoints()
const alloc = useAtomValue(allocAtom)
return points - alloced(alloc)
}
export const useAllocator = () => {
const { allocate } = useConfig()
const total = usePoints()
const [alloc, setAlloc] = useAtom(allocAtom)
const allocator = useCallback(
(key: keyof Allocation, value: number) => {
setAlloc(prev => {
const left = total - alloced(prev) + prev[key]
const max = Math.min(left, allocate)
const final = Math.max(Math.min(max, value), 0)
if (prev[key] === final) return prev
return { ...prev, [key]: final }
})
},
[allocate, total],
)
return [alloc, allocator] as const
}
export const usePointRandomizer = () => {
const { allocate } = useConfig()
const total = usePoints()
const setAlloc = useSetAtom(allocAtom)
return useCallback(
(rng?: RNG) => {
const suffled = shuffle(keys(init), rng)
let left = total
const alloc = { ...init }
while (suffled.length) {
const key = suffled.pop()!
const max = Math.min(allocate, left)
const min = Math.max(0, left - suffled.length * allocate)
const value = frandom(max, min, rng)
alloc[key] = value
left -= value
}
setAlloc(alloc)
},
[allocate, total],
)
}
export const useAllocSubmit = () => {}
+24
View File
@@ -0,0 +1,24 @@
import { useCallback } from 'react'
import { atom, useSetAtom, useAtomValue } from 'jotai'
import type { PullOptions } from '@remake/core'
export interface Config {
pick: number
pull: PullOptions
points: number
allocate: number
mode: number
}
export const configAtom = atom<Config | null>(null)
export const useConfigInject = () => {
const setConfig = useSetAtom(configAtom)
return useCallback((config: Config) => setConfig(config), [setConfig])
}
export const useConfig = () => {
const config = useAtomValue(configAtom)
if (!config) throw new Error('Game config is not set')
return config
}
+6
View File
@@ -0,0 +1,6 @@
export * from './config'
export * from './profile'
export * from './talent'
export * from './play'
export * from './alloc'
export type * from '@remake/core'
+59
View File
@@ -0,0 +1,59 @@
import { useTransition, useCallback } from 'react'
import { atom, useSetAtom, useAtomValue, useAtom } from 'jotai'
import { useConfig } from './config'
import { useProfile } from './profile'
import { end } from '@remake/core'
import type { GameState } from '@remake/core'
import type { Talent } from '@remake/data/talent'
export enum Step {
Idle = 'idle',
Mode = 'mode',
Pick = 'pick',
Alloc = 'alloc',
Play = 'play',
Summary = 'summary',
}
const stepAtom = atom<Step>(Step.Idle)
const gameStateAtom = atom<GameState | null>(null)
export const useStep = () => useAtomValue(stepAtom)
export const useSetStep = () => useSetAtom(stepAtom)
export const useRemake = () => {
const config = useConfig()
const [profile] = useProfile()
const setStep = useSetAtom(stepAtom)
return useCallback(() => {
const step = profile.times < config.mode ? Step.Pick : Step.Mode
setStep(step)
}, [config, profile, setStep])
}
export const useEnd = () => {
const [profile, setProfile] = useProfile()
const [step, setStep] = useAtom(stepAtom)
const [state, setState] = useAtom(gameStateAtom)
const [pending, startTransition] = useTransition()
const ender = useCallback(
(talent?: Talent['id']) => {
startTransition(async () => {
if (!state || step === Step.Summary)
throw new Error(
'Game state is not available or already ended.',
)
const { profile: next, achievements } = end(
state,
profile,
talent,
)
setStep(Step.Idle)
setState(null)
setProfile(next)
})
},
[state, step, profile, setStep, setState, setProfile],
)
return [pending, ender] as const
}
+14
View File
@@ -0,0 +1,14 @@
import { describe, test, expect } from 'bun:test'
import { screen, renderHook } from '@testing-library/react'
import { useProfile, useProfileInject } from './profile'
describe('profile', () => {
test('inject', () => {
expect(() => renderHook(() => useProfile())).toThrow()
// const inject = renderHook(() => useProfileInject())
})
test('no inject throw', () => {
expect(() => renderHook(() => useProfile())).toThrow()
// const inject = renderHook(() => useProfileInject())
})
})
+19
View File
@@ -0,0 +1,19 @@
import { useCallback } from 'react'
import { atom, useAtom, useSetAtom } from 'jotai'
import type { ProfileState } from '@remake/core'
const profileAtom = atom<ProfileState | null>(null)
export const useProfile = () => {
const [profile, setProfile] = useAtom(profileAtom)
if (!profile) throw new Error('Profile is not loaded')
return [profile, setProfile] as const
}
export const useProfileInject = () => {
const setProfile = useSetAtom(profileAtom)
return useCallback(
(profile: ProfileState) => setProfile(profile),
[setProfile],
)
}
+100
View File
@@ -0,0 +1,100 @@
import { useState, useCallback } from 'react'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useConfig } from './config'
import { useProfile } from './profile'
import { useSetStep, Step } from './play'
import { pull, exclude, talentsPicked } from '@remake/core'
import type { TalentsPickedResult, RNG } from '@remake/core'
import type { Talent } from '@remake/data/talent'
const pickedAtom = atom(new Set<Talent['id']>())
const replacedAtom = atom<TalentsPickedResult | null>(null)
export const usePicked = () => {
const picked = useAtomValue(pickedAtom)
if (!picked) throw new Error('No talents picked')
return picked
}
export const useReplaced = () => {
const replaced = useAtomValue(replacedAtom)
if (!replaced) throw new Error('No talents replaced')
return replaced
}
export const useTalentPuller = () => {
const { pull: p } = useConfig()
const [profile] = useProfile()
const [pulled, setPulled] = useState<Talent['id'][] | null>(null)
const puller = useCallback(
(rng?: RNG) => setPulled(pull(p, profile, rng)),
// (rng?: RNG) =>
// setPulled([
// 1142, 1143, 1144, 1145, 1146, 1086, 1122, 1111, 1130, 1048,
// ]),
[p, profile, setPulled],
)
return [pulled, puller] as const
}
export type TalentPickerResult =
| {
type: 'ok'
talent?: never
}
| {
type: 'not-enough'
talent?: never
}
| {
type: 'exclude'
talent: Talent['id']
}
export const useTalentPicker = () => {
const { pick } = useConfig()
const [picked, setPicked] = useAtom(pickedAtom)
const picker = useCallback(
(talent: Talent['id']): TalentPickerResult => {
if (!picked) {
setPicked(new Set([talent]))
return { type: 'ok' }
}
if (picked.has(talent)) {
const next = new Set(picked)
next.delete(talent)
setPicked(next)
return { type: 'ok' }
}
if (picked.size >= pick) return { type: 'not-enough' }
const e = exclude(talent, picked)
if (e) return { type: 'exclude', talent: e }
const next = new Set([...picked, talent])
setPicked(next)
return { type: 'ok' }
},
[pick, picked, setPicked],
)
return [picked, picker] as const
}
export const useSubmitIsEnable = () => {
const { pick } = useConfig()
const picked = useAtomValue(pickedAtom)
const enabled = picked.size >= pick
return [enabled, pick] as const
}
export const useTalentSubmit = () => {
const picked = useAtomValue(pickedAtom)
const setReplaced = useSetAtom(replacedAtom)
const [enabled] = useSubmitIsEnable()
const next = useSetStep()
return useCallback(
(rng?: RNG) => {
if (!enabled) throw new Error('Not enough talents picked')
setReplaced(talentsPicked(picked, rng))
next(Step.Alloc)
},
[enabled, picked, setReplaced, next],
)
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["react", "bun"],
},
"include": ["**/*"]
}
+15
View File
@@ -22,3 +22,18 @@ export function pickWeight<T>(items: WeightItem<T>[], rng?: RNG) {
export function sum(arr: number[]) {
return arr.reduce((sum, v) => sum + v, 0)
}
export function shuffle<T>(array: T[], rng?: RNG): T[] {
const result = [...array]
for (let i = result.length - 1; i > 0; i--) {
const j = random(i, 0, rng)
const temp = result[i]
result[i] = result[j]!
result[j] = temp!
}
return result
}
export function keys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[]
}
+1 -26
View File
@@ -1,29 +1,4 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": true,
"noUnusedParameters": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitAny": true
},
"extends": "../../tsconfig.json",
"include": ["**/*"]
}