mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
Merge branch 'game' into refactor-trpc
This commit is contained in:
commit
57d8f24930
23 files changed
+616
-13
No files matched your search
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@linux-odyssey/game",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.ts",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ISession } from './types'
|
||||
|
||||
export class Session implements ISession {
|
||||
private completed: string[] = []
|
||||
|
||||
constructor({ completedStages }: ISession) {
|
||||
this.completed = completedStages
|
||||
}
|
||||
|
||||
get completedStages() {
|
||||
return this.completed
|
||||
}
|
||||
|
||||
complete(stageId: string) {
|
||||
this.completed.push(stageId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IStage, ICommand, IFileExistenceChecker, ISession } from './types'
|
||||
import { Condition } from './condition/Condition'
|
||||
|
||||
export class Stage {
|
||||
id: string
|
||||
name: string
|
||||
private condition: Condition
|
||||
private requirements: string[]
|
||||
|
||||
constructor({ id, name, condition, requirements }: IStage) {
|
||||
this.id = id
|
||||
this.name = name || ''
|
||||
this.condition = new Condition(condition)
|
||||
this.requirements = requirements || []
|
||||
}
|
||||
|
||||
async satisfies(
|
||||
command: ICommand,
|
||||
checker: IFileExistenceChecker
|
||||
): Promise<boolean> {
|
||||
return this.condition.match(command) && this.condition.check(checker)
|
||||
}
|
||||
|
||||
private checkRequirements(session: ISession): boolean {
|
||||
return this.requirements.every((req) =>
|
||||
session.completedStages.includes(req)
|
||||
)
|
||||
}
|
||||
|
||||
private finished(session: ISession): boolean {
|
||||
return session.completedStages.includes(this.id)
|
||||
}
|
||||
|
||||
active(session: ISession): boolean {
|
||||
return !this.finished(session) && this.checkRequirements(session)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ICommand } from '../types.js'
|
||||
import { Matcher } from './Matcher.js'
|
||||
|
||||
export class CommandMatcher implements Matcher {
|
||||
private matcher: RegExp
|
||||
constructor(command: string, flags?: string) {
|
||||
this.matcher = new RegExp(command, flags)
|
||||
}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return !!command.command && this.matcher.test(command.command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
ICommand,
|
||||
ICondition,
|
||||
IFileExistenceChecker,
|
||||
FileExistenceInput,
|
||||
} from '../types.js'
|
||||
import { PwdMatcher } from './PwdMatcher.js'
|
||||
import { CommandMatcher } from './CommandMatcher.js'
|
||||
import { ErrorMatcher } from './ErrorMatcher.js'
|
||||
import { Matcher } from './Matcher.js'
|
||||
import { OutputMatcher } from './OutputMatcher.js'
|
||||
import { OrMatcher } from './OrMatcher.js'
|
||||
import { NotMatcher } from './NotMatcher.js'
|
||||
import { checkFiles } from './FileMatcher.js'
|
||||
|
||||
export class Condition {
|
||||
private files: FileExistenceInput[] = []
|
||||
private matchers: Matcher[] = []
|
||||
|
||||
constructor({ command, output, error, pwd, or, not, files }: ICondition) {
|
||||
if (command) this.matchers.push(new CommandMatcher(command))
|
||||
if (output) this.matchers.push(new OutputMatcher(output))
|
||||
if (pwd) this.matchers.push(new PwdMatcher(pwd))
|
||||
if (or) {
|
||||
const conditions = or.map((condition) => new Condition(condition))
|
||||
this.matchers.push(new OrMatcher(conditions))
|
||||
}
|
||||
if (not) this.matchers.push(new NotMatcher(new Condition(not)))
|
||||
if (files) this.files = files
|
||||
this.matchers.push(new ErrorMatcher(error)) // always check unexpected error
|
||||
}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return this.matchers.every((matcher) => {
|
||||
return matcher.match(command)
|
||||
})
|
||||
}
|
||||
|
||||
async check(checker: IFileExistenceChecker): Promise<boolean> {
|
||||
if (this.matchers.length === 0) return true
|
||||
return checkFiles(checker, this.files)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Matcher } from './Matcher.js'
|
||||
import { ICommand } from '../types.js'
|
||||
|
||||
export class ErrorMatcher implements Matcher {
|
||||
private matcher?: RegExp
|
||||
constructor(errorPattern?: string) {
|
||||
if (errorPattern) {
|
||||
this.matcher = new RegExp(errorPattern)
|
||||
}
|
||||
}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
if (!this.matcher) {
|
||||
return !command.error
|
||||
}
|
||||
return this.matcher.test(command.error || '')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FileExistenceInput, IFileExistenceChecker } from '../types'
|
||||
|
||||
export async function checkFiles(
|
||||
checker: IFileExistenceChecker,
|
||||
files: FileExistenceInput[]
|
||||
) {
|
||||
const existences = await Promise.all(
|
||||
files.map((file) => checker.exists(file))
|
||||
)
|
||||
return existences.every((exists, index) => exists === files[index].exists)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ICommand } from '../types.js'
|
||||
|
||||
export interface Matcher {
|
||||
match(command: ICommand): boolean
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ICommand } from '../types.js'
|
||||
import { Matcher } from './Matcher.js'
|
||||
|
||||
export class NotMatcher implements Matcher {
|
||||
constructor(private condition: Matcher) {}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return !this.condition.match(command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ICommand } from '../types.js'
|
||||
import { Matcher } from './Matcher.js'
|
||||
|
||||
export class OrMatcher implements Matcher {
|
||||
constructor(private conditions: Matcher[]) {}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return this.conditions.some((condition) => condition.match(command))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ICommand } from '../types.js'
|
||||
import { Matcher } from './Matcher.js'
|
||||
|
||||
export class OutputMatcher implements Matcher {
|
||||
private matcher: RegExp
|
||||
constructor(pattern: string, args?: string) {
|
||||
this.matcher = new RegExp(pattern, args)
|
||||
}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return !!command.output && this.matcher.test(command.output.trim())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ICommand } from '../types.js'
|
||||
import { Matcher } from './Matcher.js'
|
||||
|
||||
export class PwdMatcher implements Matcher {
|
||||
constructor(private pwd: string) {}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return command.pwd === this.pwd
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const requirementsSchema = z.object({
|
||||
requirements: z.array(z.string()),
|
||||
})
|
||||
|
||||
export type IRequirements = z.infer<typeof requirementsSchema>
|
||||
|
||||
export const fileInputSchema = z.object({
|
||||
path: z.string(),
|
||||
type: z.enum(['file', 'folder']),
|
||||
})
|
||||
|
||||
export type IFileInput = z.infer<typeof fileInputSchema>
|
||||
|
||||
export const fileExistenceInputSchema = fileInputSchema.extend({
|
||||
exists: z.boolean(),
|
||||
})
|
||||
|
||||
export type IFileExistenceInput = z.infer<typeof fileExistenceInputSchema>
|
||||
|
||||
const baseConditionSchema = z.object({
|
||||
command: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
pwd: z.string().optional(),
|
||||
files: z.array(fileExistenceInputSchema).optional(),
|
||||
})
|
||||
|
||||
export type ICondition = z.infer<typeof baseConditionSchema> & {
|
||||
or?: ICondition[]
|
||||
not?: ICondition
|
||||
}
|
||||
|
||||
export const conditionSchema: z.ZodType<ICondition> =
|
||||
baseConditionSchema.extend({
|
||||
or: z.lazy(() => z.array(conditionSchema)).optional(),
|
||||
not: z.lazy(() => conditionSchema).optional(),
|
||||
})
|
||||
|
||||
export const responseSchema = z.object({
|
||||
type: z.enum(['narrative', 'dialogue']),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
export const stageSchema = z.object({
|
||||
id: z.string().regex(/^[a-zA-Z0-9_-]+$/),
|
||||
name: z.string().optional(),
|
||||
requirements: z.array(z.string()),
|
||||
condition: conditionSchema,
|
||||
response: responseSchema,
|
||||
})
|
||||
|
||||
export type IStage = z.infer<typeof stageSchema>
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,45 @@
|
||||
export interface ICommand {
|
||||
command?: string
|
||||
output?: string
|
||||
error?: string
|
||||
pwd?: string
|
||||
}
|
||||
|
||||
export interface ICondition {
|
||||
command?: string
|
||||
output?: string
|
||||
error?: string
|
||||
pwd?: string
|
||||
or?: ICondition[]
|
||||
not?: ICondition
|
||||
files?: FileExistenceInput[]
|
||||
}
|
||||
|
||||
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<boolean>
|
||||
}
|
||||
|
||||
export interface IStage {
|
||||
id: string
|
||||
name?: string
|
||||
requirements?: string[]
|
||||
condition: ICondition
|
||||
}
|
||||
|
||||
export interface ISession {
|
||||
completedStages: string[]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { test, expect } from '@jest/globals'
|
||||
import { IFileExistenceChecker, FileType, FileInput } from '../src/types.js'
|
||||
import { checkFiles } from '../src/condition/FileMatcher.js'
|
||||
import { Condition } from '../src/condition/Condition.js'
|
||||
|
||||
export class MockFileChecker implements IFileExistenceChecker {
|
||||
private files: FileInput[] = [
|
||||
{
|
||||
path: '/home/user/hello.txt',
|
||||
type: FileType.FILE,
|
||||
},
|
||||
{
|
||||
path: '/home/user/world.txt',
|
||||
type: FileType.FILE,
|
||||
},
|
||||
{
|
||||
path: '/home/user/Downloads',
|
||||
type: FileType.FOLDER,
|
||||
},
|
||||
]
|
||||
|
||||
async exists(file: FileInput): Promise<boolean> {
|
||||
return this.files.some((f) => f.path === file.path && f.type === file.type)
|
||||
}
|
||||
}
|
||||
|
||||
test('file matcher', async () => {
|
||||
const checker = new MockFileChecker()
|
||||
expect(
|
||||
await checkFiles(checker, [
|
||||
{
|
||||
path: '/home/user/hello.txt',
|
||||
type: FileType.FILE,
|
||||
exists: true,
|
||||
},
|
||||
])
|
||||
).toBe(true)
|
||||
expect(
|
||||
await checkFiles(checker, [
|
||||
{
|
||||
path: '/home/user/not-exists.txt',
|
||||
type: FileType.FILE,
|
||||
exists: true,
|
||||
},
|
||||
])
|
||||
).toBe(false)
|
||||
expect(
|
||||
await checkFiles(checker, [
|
||||
{
|
||||
path: '/home/user/not-exists.txt',
|
||||
type: FileType.FILE,
|
||||
exists: false,
|
||||
},
|
||||
])
|
||||
).toBe(true)
|
||||
expect(
|
||||
await checkFiles(checker, [
|
||||
{
|
||||
path: '/home/user/Downloads',
|
||||
type: FileType.FOLDER,
|
||||
exists: true,
|
||||
},
|
||||
])
|
||||
).toBe(true)
|
||||
expect(
|
||||
await checkFiles(checker, [
|
||||
{
|
||||
path: '/home/user/hello.txt',
|
||||
type: FileType.FILE,
|
||||
exists: true,
|
||||
},
|
||||
{
|
||||
path: '/home/user/Downloads',
|
||||
type: FileType.FOLDER,
|
||||
exists: true,
|
||||
},
|
||||
])
|
||||
).toBe(true)
|
||||
expect(
|
||||
await checkFiles(checker, [
|
||||
{
|
||||
path: '/home/user/hello.txt',
|
||||
type: FileType.FILE,
|
||||
exists: true,
|
||||
},
|
||||
{
|
||||
path: '/home/user/not-exist.txt',
|
||||
type: FileType.FILE,
|
||||
exists: true,
|
||||
},
|
||||
])
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('condition with file matcher', async () => {
|
||||
const checker = new MockFileChecker()
|
||||
const condition = new Condition({
|
||||
files: [
|
||||
{
|
||||
path: '/home/user/hello.txt',
|
||||
type: FileType.FILE,
|
||||
exists: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(await condition.check(checker)).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from '@jest/globals'
|
||||
import { Condition } from '../src/condition/Condition'
|
||||
import { OutputMatcher } from '../src/condition/OutputMatcher'
|
||||
import { PwdMatcher } from '../src/condition/PwdMatcher'
|
||||
import { CommandMatcher } from '../src/condition/CommandMatcher'
|
||||
import { ErrorMatcher } from '../src/condition/ErrorMatcher.js'
|
||||
import { conditionSchema } from '../src/schema'
|
||||
|
||||
test('command match', () => {
|
||||
const matcher = new CommandMatcher('^echo start$')
|
||||
expect(matcher.match({ command: 'echo start' })).toBe(true)
|
||||
expect(matcher.match({ command: 'echo start1' })).toBe(false)
|
||||
// Currently not supported
|
||||
expect(matcher.match({ command: 'echo "start"' })).toBe(false)
|
||||
})
|
||||
|
||||
test('error matcher', () => {
|
||||
const matcher = new ErrorMatcher()
|
||||
expect(matcher.match({ error: '' })).toBe(true)
|
||||
expect(matcher.match({ error: 'permission denied' })).toBe(false)
|
||||
})
|
||||
|
||||
test('error matcher with specific error', () => {
|
||||
const matcher = new ErrorMatcher('permission denied')
|
||||
expect(matcher.match({ error: '' })).toBe(false)
|
||||
expect(matcher.match({ error: 'file not found' })).toBe(false)
|
||||
expect(matcher.match({ error: 'cannot open files: permission denied' })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('pwd matcher', () => {
|
||||
const matcher = new PwdMatcher('/home/user')
|
||||
expect(matcher.match({ pwd: '/home/user' })).toBe(true)
|
||||
expect(matcher.match({ pwd: '/home/user1' })).toBe(false)
|
||||
expect(matcher.match({ pwd: '/home/user/anotherpath' })).toBe(false)
|
||||
expect(matcher.match({ pwd: '/var/home/user' })).toBe(false)
|
||||
})
|
||||
|
||||
test('output matcher', () => {
|
||||
const matcher = new OutputMatcher('^start$')
|
||||
expect(matcher.match({ output: 'start' })).toBe(true)
|
||||
expect(matcher.match({ output: 'start1' })).toBe(false)
|
||||
expect(matcher.match({ output: 'start\n' })).toBe(true)
|
||||
})
|
||||
|
||||
test('condition match command', () => {
|
||||
const condition = new Condition({
|
||||
command: '^echo start$',
|
||||
})
|
||||
|
||||
expect(
|
||||
condition.match({
|
||||
command: 'echo start',
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
condition.match({
|
||||
command: 'echo hello',
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
condition.match({ command: 'echo start', error: 'permission denied' })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('condition with OR', () => {
|
||||
const condition = new Condition({
|
||||
or: [{ command: '^echo start$' }, { command: '^echo hello$' }],
|
||||
})
|
||||
expect(condition.match({ command: 'echo start' })).toBe(true)
|
||||
expect(condition.match({ command: 'echo hello' })).toBe(true)
|
||||
expect(condition.match({ command: 'echo start1' })).toBe(false)
|
||||
})
|
||||
|
||||
test('condition with NOT', () => {
|
||||
const condition = new Condition({
|
||||
not: { command: '^echo start$' },
|
||||
})
|
||||
|
||||
expect(condition.match({ command: 'echo start' })).toBe(false)
|
||||
expect(condition.match({ command: 'echo hello' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should parse condition', () => {
|
||||
const condition = {
|
||||
command: 'echo start',
|
||||
output: 'start',
|
||||
not: {
|
||||
pwd: '/home/user',
|
||||
},
|
||||
}
|
||||
expect(conditionSchema.safeParse(condition).error).toBeUndefined()
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test, expect } from '@jest/globals'
|
||||
import { Stage } from '../src/Stage'
|
||||
import { FileType } from '../src/types'
|
||||
import { MockFileChecker } from './asyncCondition.test'
|
||||
import { Session } from '../src/Session'
|
||||
|
||||
const checker = new MockFileChecker()
|
||||
|
||||
test('check stage condition', async () => {
|
||||
const stage = new Stage({
|
||||
id: 'stage1',
|
||||
name: 'List the files using `ls`',
|
||||
condition: {
|
||||
command: 'ls',
|
||||
pwd: '/home/user',
|
||||
},
|
||||
})
|
||||
expect(
|
||||
await stage.satisfies(
|
||||
{
|
||||
command: 'ls',
|
||||
pwd: '/home/user',
|
||||
},
|
||||
checker
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await stage.satisfies(
|
||||
{
|
||||
command: 'ls',
|
||||
pwd: '/home/user/Downloads',
|
||||
},
|
||||
checker
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('check stage with async condition truthy', async () => {
|
||||
const stage = new Stage({
|
||||
id: 'stage1',
|
||||
name: 'Create file',
|
||||
condition: {
|
||||
files: [
|
||||
{
|
||||
path: '/home/user/hello.txt',
|
||||
type: FileType.FILE,
|
||||
exists: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(await stage.satisfies({}, checker)).toBe(true)
|
||||
})
|
||||
|
||||
test('check stage with async condition falsy', async () => {
|
||||
const stage = new Stage({
|
||||
id: 'stage1',
|
||||
name: 'Create file',
|
||||
condition: {
|
||||
files: [
|
||||
{
|
||||
path: '/home/user/not-exists.txt',
|
||||
type: FileType.FILE,
|
||||
exists: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(await stage.satisfies({}, checker)).toBe(false)
|
||||
})
|
||||
|
||||
test('stage active checks', () => {
|
||||
const session = new Session({
|
||||
completedStages: ['stage1'],
|
||||
})
|
||||
const stage3 = new Stage({
|
||||
id: 'stage3',
|
||||
requirements: ['stage1', 'stage2'],
|
||||
condition: {},
|
||||
})
|
||||
expect(stage3.active(session)).toBe(false)
|
||||
|
||||
session.complete('stage2')
|
||||
expect(stage3.active(session)).toBe(true)
|
||||
|
||||
session.complete('stage3')
|
||||
expect(stage3.active(session)).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in new issue
Block a user