From fb823814e0411cceaf27c45cf65b5c97590a6f5d Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Mon, 21 Apr 2025 11:19:59 +0800 Subject: [PATCH] feat: add stage exceptions --- packages/game/src/Quest.ts | 7 +++ packages/game/src/Stage.ts | 5 +- packages/game/src/condition/Condition.ts | 8 +-- packages/game/src/schema/condition.ts | 4 ++ packages/game/src/schema/exception.ts | 23 +++++++++ packages/game/src/schema/index.ts | 20 +------- packages/game/src/schema/stage.ts | 2 + packages/game/test/asyncCondition.test.ts | 30 ++++++------ packages/game/test/exceptions.test.ts | 60 +++++++++++++++++++++++ packages/game/test/session.test.ts | 2 +- packages/game/test/stage.test.ts | 5 +- 11 files changed, 123 insertions(+), 43 deletions(-) create mode 100644 packages/game/src/schema/exception.ts create mode 100644 packages/game/test/exceptions.test.ts diff --git a/packages/game/src/Quest.ts b/packages/game/src/Quest.ts index 8757458..239c2f7 100644 --- a/packages/game/src/Quest.ts +++ b/packages/game/src/Quest.ts @@ -1,4 +1,5 @@ /* eslint-disable no-await-in-loop */ +import { Condition } from './condition/Condition' import { IQuest, IStage, @@ -35,6 +36,12 @@ export class Quest { return stage.id } } + for (const exception of stages.flatMap((stage) => stage.exceptions)) { + const condition = new Condition(exception.condition) + if (await condition.match(command)) { + return exception.id + } + } return null } diff --git a/packages/game/src/Stage.ts b/packages/game/src/Stage.ts index d7da490..ff7633c 100644 --- a/packages/game/src/Stage.ts +++ b/packages/game/src/Stage.ts @@ -1,17 +1,20 @@ import { IStage, ICommand, IFileExistenceChecker } from './schema' import { Condition } from './condition/Condition' +import { StageException } from './schema/exception' export class Stage { id: string name: string private condition: Condition private requirements: string[] + public readonly exceptions: StageException[] - constructor({ id, name, condition, requirements }: IStage) { + constructor({ id, name, condition, requirements, exceptions }: IStage) { this.id = id this.name = name || '' this.condition = new Condition(condition) this.requirements = requirements || [] + this.exceptions = exceptions || [] } async satisfies( diff --git a/packages/game/src/condition/Condition.ts b/packages/game/src/condition/Condition.ts index bce7e7f..3d8c8dd 100644 --- a/packages/game/src/condition/Condition.ts +++ b/packages/game/src/condition/Condition.ts @@ -1,9 +1,9 @@ import { ICommand, IFileExistenceChecker, - FileExistenceInput, -} from '../types.js' -import { ICondition } from '../schema/condition.js' + IFileInput, + ICondition, +} from '../schema' import { PwdMatcher } from './PwdMatcher.js' import { CommandMatcher } from './CommandMatcher.js' import { ErrorMatcher } from './ErrorMatcher.js' @@ -14,7 +14,7 @@ import { NotMatcher } from './NotMatcher.js' import { checkFiles } from './FileMatcher.js' export class Condition { - private files: FileExistenceInput[] = [] + private files: IFileInput[] = [] private matchers: Matcher[] = [] constructor({ command, output, error, pwd, or, not, files }: ICondition) { diff --git a/packages/game/src/schema/condition.ts b/packages/game/src/schema/condition.ts index fc16680..e32a6af 100644 --- a/packages/game/src/schema/condition.ts +++ b/packages/game/src/schema/condition.ts @@ -31,3 +31,7 @@ export const conditionSchema: z.ZodType = or: z.lazy(() => z.array(conditionSchema).optional()), not: z.lazy(() => conditionSchema.optional()), }) + +export interface IFileExistenceChecker { + exists(file: IFileInput): Promise +} diff --git a/packages/game/src/schema/exception.ts b/packages/game/src/schema/exception.ts new file mode 100644 index 0000000..97d6b7e --- /dev/null +++ b/packages/game/src/schema/exception.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { conditionSchema } from './condition.js' +import { responseSchema } from './response.js' + +export const exceptionSchema = z.object({ + id: z.string(), + condition: conditionSchema, + response: responseSchema, +}) + +export type Exception = z.infer + +export const stageExceptionSchema = exceptionSchema.extend({ + catchAll: z.boolean().default(false), +}) + +export type StageException = z.infer + +export const globalExceptionSchema = exceptionSchema.extend({ + requirements: z.array(z.string()), +}) + +export type GlobalException = z.infer diff --git a/packages/game/src/schema/index.ts b/packages/game/src/schema/index.ts index bdb7c02..cd022e7 100644 --- a/packages/game/src/schema/index.ts +++ b/packages/game/src/schema/index.ts @@ -1,4 +1,4 @@ -export { conditionSchema, ICondition } from './condition.js' +export * from './condition.js' export { stageSchema, IStage } from './stage.js' export { requirementsSchema, IRequirements } from './requirements.js' export { responseSchema, IResponse } from './response.js' @@ -11,24 +11,6 @@ export interface ICommand { pwd?: string } -export enum FileType { - FILE = 'file', - FOLDER = 'folder', -} - -export interface FileInput { - path: string - type: FileType -} - -export interface FileExistenceInput extends FileInput { - exists: boolean -} - -export interface IFileExistenceChecker { - exists(file: FileInput): Promise -} - export interface ISession { completedStages: string[] } diff --git a/packages/game/src/schema/stage.ts b/packages/game/src/schema/stage.ts index 818dae0..27d073b 100644 --- a/packages/game/src/schema/stage.ts +++ b/packages/game/src/schema/stage.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { conditionSchema } from './condition.js' import { responseSchema } from './response.js' +import { stageExceptionSchema } from './exception.js' export const stageSchema = z.object({ id: z.string().regex(/^[a-zA-Z0-9_-]+$/), @@ -8,6 +9,7 @@ export const stageSchema = z.object({ requirements: z.array(z.string()), condition: conditionSchema, response: responseSchema, + exceptions: stageExceptionSchema.array().optional(), }) export type IStage = z.infer diff --git a/packages/game/test/asyncCondition.test.ts b/packages/game/test/asyncCondition.test.ts index 53b34f1..642b2de 100644 --- a/packages/game/test/asyncCondition.test.ts +++ b/packages/game/test/asyncCondition.test.ts @@ -1,25 +1,25 @@ import { test, expect } from '@jest/globals' -import { IFileExistenceChecker, FileType, FileInput } from '../src/schema' +import { IFileExistenceChecker, IFileInput } from '../src/schema' import { checkFiles } from '../src/condition/FileMatcher.js' import { Condition } from '../src/condition/Condition.js' export class MockFileChecker implements IFileExistenceChecker { - private files: FileInput[] = [ + private files: IFileInput[] = [ { path: '/home/user/hello.txt', - type: FileType.FILE, + type: 'file', }, { path: '/home/user/world.txt', - type: FileType.FILE, + type: 'file', }, { path: '/home/user/Downloads', - type: FileType.FOLDER, + type: 'folder', }, ] - async exists(file: FileInput): Promise { + async exists(file: IFileInput): Promise { return this.files.some((f) => f.path === file.path && f.type === file.type) } } @@ -30,7 +30,7 @@ test('file matcher', async () => { await checkFiles(checker, [ { path: '/home/user/hello.txt', - type: FileType.FILE, + type: 'file', exists: true, }, ]) @@ -39,7 +39,7 @@ test('file matcher', async () => { await checkFiles(checker, [ { path: '/home/user/not-exists.txt', - type: FileType.FILE, + type: 'file', exists: true, }, ]) @@ -48,7 +48,7 @@ test('file matcher', async () => { await checkFiles(checker, [ { path: '/home/user/not-exists.txt', - type: FileType.FILE, + type: 'file', exists: false, }, ]) @@ -57,7 +57,7 @@ test('file matcher', async () => { await checkFiles(checker, [ { path: '/home/user/Downloads', - type: FileType.FOLDER, + type: 'folder', exists: true, }, ]) @@ -66,12 +66,12 @@ test('file matcher', async () => { await checkFiles(checker, [ { path: '/home/user/hello.txt', - type: FileType.FILE, + type: 'file', exists: true, }, { path: '/home/user/Downloads', - type: FileType.FOLDER, + type: 'folder', exists: true, }, ]) @@ -80,12 +80,12 @@ test('file matcher', async () => { await checkFiles(checker, [ { path: '/home/user/hello.txt', - type: FileType.FILE, + type: 'file', exists: true, }, { path: '/home/user/not-exist.txt', - type: FileType.FILE, + type: 'file', exists: true, }, ]) @@ -98,7 +98,7 @@ test('condition with file matcher', async () => { files: [ { path: '/home/user/hello.txt', - type: FileType.FILE, + type: 'file', exists: true, }, ], diff --git a/packages/game/test/exceptions.test.ts b/packages/game/test/exceptions.test.ts new file mode 100644 index 0000000..c252b10 --- /dev/null +++ b/packages/game/test/exceptions.test.ts @@ -0,0 +1,60 @@ +import { expect, it, describe } from '@jest/globals' +import { IQuest } from '../src/schema' +import { Session } from '../src/Session' +import { MockFileChecker } from './asyncCondition.test' + +const quest: IQuest = { + id: 'quest1', + title: 'Quest 1', + image: 'image1', + instruction: 'Instruction 1', + requirements: [], + stages: [ + { + id: 'stage1', + requirements: [], + condition: { + command: 'echo start', + }, + exceptions: [ + { + id: 'exception1', + condition: { + command: 'ls', + }, + response: { type: 'narrative', content: 'Exception 1' }, + catchAll: false, + }, + { + id: 'exception2', + condition: {}, + catchAll: true, + response: { type: 'narrative', content: 'Exception 2' }, + }, + ], + response: { type: 'narrative', content: 'Narrative 1' }, + }, + { + id: 'stage2', + requirements: ['stage1'], + condition: { + command: 'ls', + }, + response: { type: 'narrative', content: 'Narrative 2' }, + }, + ], +} + +describe('exceptions', () => { + it('should return the stage exception', async () => { + const session = new Session( + { completedStages: [] }, + quest, + new MockFileChecker() + ) + expect(await session.runCommand({ command: 'ls' })).toBe('exception1') + await session.runCommand({ command: 'echo start' }) + console.log('completed', session.completedStages) + expect(await session.runCommand({ command: 'ls' })).toBe('stage2') + }) +}) diff --git a/packages/game/test/session.test.ts b/packages/game/test/session.test.ts index 1742117..9eb0679 100644 --- a/packages/game/test/session.test.ts +++ b/packages/game/test/session.test.ts @@ -93,7 +93,7 @@ describe('Session', () => { ) }) - it('should return responses', () => { + it('should return all responses', () => { const session = new Session( { completedStages: ['stage1'], diff --git a/packages/game/test/stage.test.ts b/packages/game/test/stage.test.ts index 3b446f3..6d79ce7 100644 --- a/packages/game/test/stage.test.ts +++ b/packages/game/test/stage.test.ts @@ -1,6 +1,5 @@ import { test, expect } from '@jest/globals' import { Stage } from '../src/Stage' -import { FileType } from '../src/schema' import { MockFileChecker } from './asyncCondition.test' const checker = new MockFileChecker() @@ -47,7 +46,7 @@ test('check stage with async condition truthy', async () => { files: [ { path: '/home/user/hello.txt', - type: FileType.FILE, + type: 'file', exists: true, }, ], @@ -69,7 +68,7 @@ test('check stage with async condition falsy', async () => { files: [ { path: '/home/user/not-exists.txt', - type: FileType.FILE, + type: 'file', exists: true, }, ],