Merge branch 'game' into refactor-trpc

This commit is contained in:
Justin Lin committed 2025-04-18 14:27:41 +08:00
commit 57d8f24930
23 files changed
+616 -13

No files matched your search

+12
View File
@@ -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"
}
}
+17
View File
@@ -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)
}
}
+37
View File
@@ -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)
}
}
+43
View File
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
import { ICommand } from '../types.js'
export interface Matcher {
match(command: ICommand): boolean
}
+10
View File
@@ -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)
}
}
+10
View File
@@ -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())
}
}
+10
View File
@@ -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.
+54
View File
@@ -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.
View File
Whitespace-only changes.
+45
View File
@@ -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[]
}
+107
View File
@@ -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)
})
+94
View File
@@ -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()
})
+88
View File
@@ -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)
})
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}