From cfce5ececa06313ff2332b02c876f1930c241055 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Mon, 21 Apr 2025 11:41:07 +0800 Subject: [PATCH] feat: add global exceptions --- packages/game/src/Quest.ts | 20 +++++++++++++++- packages/game/src/condition/Condition.ts | 10 ++++++++ packages/game/src/schema/exception.ts | 3 ++- packages/game/src/schema/quest.ts | 5 ++-- packages/game/test/condition.test.ts | 8 +++++++ packages/game/test/exceptions.test.ts | 30 ++++++++++++++++++++++++ 6 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/game/src/Quest.ts b/packages/game/src/Quest.ts index 239c2f7..d074791 100644 --- a/packages/game/src/Quest.ts +++ b/packages/game/src/Quest.ts @@ -25,6 +25,18 @@ export class Quest { return this.stages.filter((stage) => stage.active(completed)) } + private getActiveExceptions(completed: string[]) { + return ( + this.quest.exceptions?.filter( + (exception) => + !completed.includes(exception.id) && + exception.requirements.every((requirement) => + completed.includes(requirement) + ) + ) ?? [] + ) + } + async findSatisfiedStage( command: ICommand, completed: string[] @@ -38,7 +50,13 @@ export class Quest { } for (const exception of stages.flatMap((stage) => stage.exceptions)) { const condition = new Condition(exception.condition) - if (await condition.match(command)) { + if (await condition.satisfies(command, this.checker)) { + return exception.id + } + } + for (const exception of this.getActiveExceptions(completed)) { + const condition = new Condition(exception.condition) + if (await condition.satisfies(command, this.checker)) { return exception.id } } diff --git a/packages/game/src/condition/Condition.ts b/packages/game/src/condition/Condition.ts index 3d8c8dd..15927fa 100644 --- a/packages/game/src/condition/Condition.ts +++ b/packages/game/src/condition/Condition.ts @@ -40,4 +40,14 @@ export class Condition { if (this.matchers.length === 0) return true return checkFiles(checker, this.files) } + + async satisfies( + command: ICommand, + checker: IFileExistenceChecker + ): Promise { + // Error matcher is always true + if (this.matchers.length === 1 && this.files.length === 0) return false + // eslint-disable-next-line no-return-await + return this.match(command) && (await this.check(checker)) + } } diff --git a/packages/game/src/schema/exception.ts b/packages/game/src/schema/exception.ts index 97d6b7e..a0da264 100644 --- a/packages/game/src/schema/exception.ts +++ b/packages/game/src/schema/exception.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { conditionSchema } from './condition.js' import { responseSchema } from './response.js' +import { requirementsSchema } from './requirements.js' export const exceptionSchema = z.object({ id: z.string(), @@ -17,7 +18,7 @@ export const stageExceptionSchema = exceptionSchema.extend({ export type StageException = z.infer export const globalExceptionSchema = exceptionSchema.extend({ - requirements: z.array(z.string()), + requirements: requirementsSchema.default([]), }) export type GlobalException = z.infer diff --git a/packages/game/src/schema/quest.ts b/packages/game/src/schema/quest.ts index fc626aa..2f11392 100644 --- a/packages/game/src/schema/quest.ts +++ b/packages/game/src/schema/quest.ts @@ -1,8 +1,7 @@ import { z } from 'zod' import { stageSchema } from './stage.js' import { requirementsSchema } from './requirements.js' -// import { globalExceptionSchema } from './exception.js' -// import { requirementSchema } from './requirement.js' +import { globalExceptionSchema } from './exception.js' export const questSchema = z.object({ id: z.string(), @@ -11,7 +10,7 @@ export const questSchema = z.object({ instruction: z.string(), requirements: requirementsSchema, stages: z.array(stageSchema), - // exceptions: z.array(globalExceptionSchema).default([]), + exceptions: z.array(globalExceptionSchema).optional(), }) export type IQuest = z.infer diff --git a/packages/game/test/condition.test.ts b/packages/game/test/condition.test.ts index ad05f5c..8650523 100644 --- a/packages/game/test/condition.test.ts +++ b/packages/game/test/condition.test.ts @@ -5,6 +5,7 @@ import { PwdMatcher } from '../src/condition/PwdMatcher' import { CommandMatcher } from '../src/condition/CommandMatcher' import { ErrorMatcher } from '../src/condition/ErrorMatcher.js' import { conditionSchema } from '../src/schema' +import { MockFileChecker } from './asyncCondition.test' test('command match', () => { const matcher = new CommandMatcher('^echo start$') @@ -92,3 +93,10 @@ it('should parse condition', () => { } expect(conditionSchema.safeParse(condition).error).toBeUndefined() }) + +it('should not match empty condition', async () => { + const condition = new Condition({}) + expect( + await condition.satisfies({ command: 'echo start' }, new MockFileChecker()) + ).toBe(false) +}) diff --git a/packages/game/test/exceptions.test.ts b/packages/game/test/exceptions.test.ts index c252b10..316328d 100644 --- a/packages/game/test/exceptions.test.ts +++ b/packages/game/test/exceptions.test.ts @@ -43,6 +43,24 @@ const quest: IQuest = { response: { type: 'narrative', content: 'Narrative 2' }, }, ], + exceptions: [ + { + id: 'exception3', + condition: { + command: 'rm', + }, + response: { type: 'narrative', content: 'Exception 3' }, + requirements: [], + }, + { + id: 'exception4', + condition: { + command: 'mv', + }, + response: { type: 'narrative', content: 'Exception 4' }, + requirements: ['stage1'], + }, + ], } describe('exceptions', () => { @@ -57,4 +75,16 @@ describe('exceptions', () => { console.log('completed', session.completedStages) expect(await session.runCommand({ command: 'ls' })).toBe('stage2') }) + + it('should return the global exception', async () => { + const session = new Session( + { completedStages: [] }, + quest, + new MockFileChecker() + ) + expect(await session.runCommand({ command: 'rm' })).toBe('exception3') + expect(await session.runCommand({ command: 'mv' })).toBeNull() + session.complete('stage1') + expect(await session.runCommand({ command: 'mv' })).toBe('exception4') + }) })