Merge remote-tracking branch 'origin/main' into feat-landing-page

This commit is contained in:
wancat committed 2024-03-01 23:13:15 +08:00
commit 6aed5789a5
13 files changed
+131 -146

No files matched your search

+59 -47
View File
@@ -26,53 +26,65 @@ const right = () => {
</script> </script>
<template> <template>
<div class="w-full flex items-center p-2"> <div class="h-full flex flex-col">
<font-awesome-icon <!-- Hint Header -->
:icon="['far', 'lightbulb']" <div class="flex items-center p-2 h-10">
class="text-yellow-200 p-2 content-center" <font-awesome-icon
/> :icon="['far', 'lightbulb']"
<h1 class="inline text-text w-1/2 font-xl p-2 m-1"> class="text-yellow-200 p-2 content-center"
<!-- Hint --> />
提示 <h1 class="inline text-text w-1/2 font-xl p-2 m-1">
</h1> <!-- Hint -->
<div v-if="current !== -1" class="flex w-full justify-end items-end"> 提示
<button </h1>
class="p-2 m-1 w-1/8" <div v-if="current !== -1" class="flex w-full justify-end items-end">
id="Lbutton" <button
@click="left" class="p-2 m-1 w-1/8"
:disabled="current <= 0" id="Lbutton"
> @click="left"
<font-awesome-icon :disabled="current <= 0"
:icon="['fas', 'arrow-right']" >
class="text-text" <font-awesome-icon
rotation="180" :icon="['fas', 'arrow-right']"
/> class="text-text"
</button> rotation="180"
<p class="inline text-text font-xl w-1/14 p-2 m-1">{{ current + 1 }}</p> />
<p class="inline text-text font-xl w-1/14 p-2 m-1">/</p> </button>
<p class="inline text-text font-xl w-1/14 p-2 m-1"> <p class="inline text-text font-xl w-1/14 p-2 m-1">{{ current + 1 }}</p>
{{ sessionStore.session.hints.length }} <p class="inline text-text font-xl w-1/14 p-2 m-1">/</p>
</p> <p class="inline text-text font-xl w-1/14 p-2 m-1">
<button {{ sessionStore.session.hints.length }}
class="p-2 m-1 w-1/8" </p>
id="Rbutton" <button
@click="right" class="p-2 m-1 w-1/8"
:disabled="current === sessionStore.session.hints.length - 1" id="Rbutton"
> @click="right"
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" /> :disabled="current === sessionStore.session.hints.length - 1"
</button> >
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
</button>
</div>
</div>
<div id="hint" class="bg-bg p-8 overflow-y-auto">
<ul>
<li
v-for="response in sessionStore.session.responses[current]"
:key="response"
class="text-text font-xl whitespace-pre-wrap"
>
<div v-for="content in response.content" :key="content">
<MarkdownText :content="content" />
</div>
</li>
<li
v-for="hint in sessionStore.session.hints[current]"
:key="hint"
class="text-text-primary font-xl whitespace-pre-wrap"
>
<MarkdownText :content="hint" />
</li>
</ul>
<br />
</div> </div>
</div> </div>
<div id="hint" class="bg-bg flex flex-wrap p-8">
<ul>
<li
v-for="hint in sessionStore.session.hints[current]"
:key="hint"
class="text-text font-xl whitespace-pre-wrap"
>
<MarkdownText :content="hint" />
</li>
</ul>
<br />
</div>
</template> </template>
+10 -18
View File
@@ -20,6 +20,7 @@ function newSession() {
pwd: '', pwd: '',
hints: [], hints: [],
tasks: [], tasks: [],
responses: [],
} }
} }
@@ -41,6 +42,7 @@ function setQuest(questId) {
async function setSession(session) { async function setSession(session) {
try { try {
console.log('Setting session...', session)
store.session = session store.session = session
store.session.graph = new FileGraph(session.graph) store.session.graph = new FileGraph(session.graph)
term.reset() term.reset()
@@ -85,16 +87,12 @@ function updateGraph(event) {
} }
} }
function updateHints(hints) { function newResponse(response) {
store.session.hints.push(hints) console.log('newResponse', response, 'session', store.session)
} store.session.responses.push(response.responses)
store.session.hints.push(response.hints)
function updateStatus(status) { store.session.tasks = response.tasks
store.session.status = status store.session.status = response.status
}
function setTasks(tasks) {
store.session.tasks = tasks
} }
export function useTerminal() { export function useTerminal() {
@@ -135,14 +133,8 @@ function setup() {
socket.on('graph', (event) => { socket.on('graph', (event) => {
updateGraph(event) updateGraph(event)
}) })
socket.on('hints', (event) => { socket.on('response', (response) => {
updateHints(event) newResponse(response)
})
socket.on('tasks', (tasks) => {
setTasks(tasks)
})
socket.on('status', (event) => {
updateStatus(event)
}) })
} }
+7 -7
View File
@@ -61,7 +61,7 @@ onMounted(async () => {
<!-- main --> <!-- main -->
<div id="main" class="h-[94vh] w-full flex p-3 space-x-3"> <div id="main" class="h-[94vh] w-full flex p-3 space-x-3">
<!-- Topic and Command List --> <!-- Topic and Command List -->
<div class="bg-bg-secondary h-full w-1/3 rounded-lg"> <div class="bg-bg-secondary h-full w-1/4 rounded-lg">
<section id="quest" class="h-full p-3 overflow-auto"> <section id="quest" class="h-full p-3 overflow-auto">
<QuestPart /> <QuestPart />
</section> </section>
@@ -71,17 +71,17 @@ onMounted(async () => {
</section> --> </section> -->
</div> </div>
<!-- Terminal and Hint --> <!-- Terminal and Hint -->
<div class="bg-bg h-full w-1/3 rounded-lg"> <div class="bg-bg h-full w-1/2 rounded-lg">
<section id="terminal" class="h-3/5 overflow-hidden"> <section id="hint" class="h-3/5">
<TerminalPart /> <HintPart />
</section> </section>
<hr class="border-border border" /> <hr class="border-border border" />
<section id="hint" class="h-2/5"> <section id="terminal" class="h-2/5 overflow-hidden">
<HintPart /> <TerminalPart />
</section> </section>
</div> </div>
<!-- Visualization --> <!-- Visualization -->
<div class="h-full w-1/3"> <div class="h-full w-1/4">
<div class="bg-border rounded-xl h-[90%] p-2"> <div class="bg-border rounded-xl h-[90%] p-2">
<div class="bg-bg rounded-lg h-full p-2"> <div class="bg-bg rounded-lg h-full p-2">
<section id="visualization" class="bg-bg-primary h-full rounded-md"> <section id="visualization" class="bg-bg-primary h-full rounded-md">
+9 -1
View File
@@ -1,5 +1,7 @@
import { model, Schema, Types } from 'mongoose' import { model, Schema, Types } from 'mongoose'
import { IResponse, responseSchema } from './response.js'
export interface INode { export interface INode {
path: string path: string
type: string type: string
@@ -46,13 +48,14 @@ export interface ISession {
user: Types.ObjectId user: Types.ObjectId
quest: string quest: string
containerId: string | null containerId: string | null
status: string status: 'active' | 'finished' | 'inactive'
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
finishedAt?: Date finishedAt?: Date
lastActivityAt: Date lastActivityAt: Date
tasks: ITask[] tasks: ITask[]
hints: string[][] hints: string[][]
responses: IResponse[][]
graph: INode graph: INode
} }
@@ -85,6 +88,11 @@ export const Session = model<ISession>(
}, },
tasks: [taskSchema], tasks: [taskSchema],
hints: [[String]], hints: [[String]],
responses: {
type: [[responseSchema]],
required: true,
default: [],
},
graph: { graph: {
type: nodeSchema, type: nodeSchema,
default: { default: {
@@ -6,8 +6,6 @@ import { pushToSession } from '../socket.js'
import { finishSession } from '../../models/sessionManager.js' import { finishSession } from '../../models/sessionManager.js'
import { asyncHandler } from '../../middleware/error.js' import { asyncHandler } from '../../middleware/error.js'
const commandCompleteCallbacks = new Map()
export const newCommand = asyncHandler(async (req: Request, res: Response) => { export const newCommand = asyncHandler(async (req: Request, res: Response) => {
const { command, pwd, output, error, params } = matchedData(req) const { command, pwd, output, error, params } = matchedData(req)
@@ -51,42 +49,14 @@ export const newCommand = asyncHandler(async (req: Request, res: Response) => {
c.stage = response.stage c.stage = response.stage
await c.save() await c.save()
if (response.stage === 'END') { session.responses.push(response.responses)
if ((session.status as string) === 'finished') {
await finishSession(session) await finishSession(session)
} }
commandCompleteCallbacks.set(session.id, response.callback) pushToSession(session.id, 'response', response)
pushToSession(session.id, 'status', 'pending')
res.status(201).json(response)
} else {
res.status(200).end()
} }
res.status(200).end()
await session.save() await session.save()
}) })
export const completedCommand = asyncHandler(
async (req: Request, res: Response) => {
const { sessionId } = req.user as any
const session = await Session.findById(sessionId)
if (!session) {
res.status(404).json({ message: 'session not found' })
return
}
if (session.status === 'inactive') {
res.status(400).json({ message: 'session is not active' })
return
}
const callback = commandCompleteCallbacks.get(session.id)
if (!callback) {
res.status(200).json({ message: 'no callback found' })
return
}
callback()
commandCompleteCallbacks.delete(session.id)
res.json({ message: 'ok' })
}
)
@@ -25,6 +25,7 @@ function sessionDetail(session: ISession) {
...sessionSummary(session), ...sessionSummary(session),
hints: session.hints, hints: session.hints,
tasks: session.tasks, tasks: session.tasks,
responses: session.responses,
graph: session.graph, graph: session.graph,
} }
} }
@@ -55,6 +56,7 @@ export const createSession = asyncHandler(
export const getSessionById = asyncHandler( export const getSessionById = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const { sessionId } = matchedData(req) const { sessionId } = matchedData(req)
console.log(sessionId)
const session = await Session.findOne({ const session = await Session.findOne({
_id: sessionId, _id: sessionId,
user: (req.user as Express.ExistingUser).id, user: (req.user as Express.ExistingUser).id,
-1
View File
@@ -5,6 +5,5 @@ import { newCommandValidator } from '../validators/commandValidator.js'
const router = Router() const router = Router()
router.post('/', newCommandValidator, commandController.newCommand) router.post('/', newCommandValidator, commandController.newCommand)
router.post('/completed', commandController.completedCommand)
export default router export default router
+5 -1
View File
@@ -22,6 +22,10 @@ router.post(
activeSessionValidator, activeSessionValidator,
sessionController.getOrCreateSession sessionController.getOrCreateSession
) )
router.get('/:id', sessionDetailValidator, sessionController.getSessionById) router.get(
'/:sessionId',
sessionDetailValidator,
sessionController.getSessionById
)
export default router export default router
@@ -9,6 +9,6 @@ export const checkSessionId = () =>
check('sessionId').notEmpty().isString().isMongoId() check('sessionId').notEmpty().isString().isMongoId()
export const sessionListValidator = [checkQuestId(), checkSessionStatus()] export const sessionListValidator = [checkQuestId(), checkSessionStatus()]
export const sessionDetailValidator = [checkSessionId(), noError] export const sessionDetailValidator = [checkSessionId()]
export const createSessionValidator = [checkQuestId(), noError] export const createSessionValidator = [checkQuestId(), noError]
export const activeSessionValidator = [checkQuestId(), noError] export const activeSessionValidator = [checkQuestId(), noError]
+11 -13
View File
@@ -8,12 +8,12 @@ import type {
IStage, IStage,
ITask, ITask,
} from '@linux-odyssey/models' } from '@linux-odyssey/models'
import { pushToSession } from '../api/socket.js'
export interface ExecuteResult { export interface ExecuteResult {
responses: IResponse[] responses: IResponse[]
hints: string[] hints: string[]
callback: () => void tasks?: ITask[]
status: string
stage?: string stage?: string
end?: boolean end?: boolean
} }
@@ -79,15 +79,17 @@ export default class SessionHandler {
this.addNewTasks() this.addNewTasks()
this.session.responses.push(stage.responses)
if (stage.id === 'END') {
this.session.status = 'finished'
}
return { return {
responses: stage.responses, responses: stage.responses,
hints: stage.hints, hints: stage.hints,
callback: () => { tasks: this.session.tasks,
pushToSession(this.session.id, 'tasks', this.session.tasks) status: this.session.status,
if (stage.hints && stage.hints.length > 0)
pushToSession(this.session.id, 'hints', stage.hints)
pushToSession(this.session.id, 'status', this.session.status)
},
} }
} }
@@ -98,11 +100,7 @@ export default class SessionHandler {
return { return {
responses, responses,
hints, hints,
callback: () => { status: this.session.status,
if (hints && hints.length > 0)
pushToSession(this.session.id, 'hints', hints)
pushToSession(this.session.id, 'status', this.session.status)
},
} }
} }
} }
+4 -4
View File
@@ -75,7 +75,7 @@ describe('example helloworld app', () => {
describe('Game Start Page UI', () => { describe('Game Start Page UI', () => {
const title = '終端機的基本知識' const title = '終端機的基本知識'
beforeEach(() => { beforeEach(() => {
cy.PrepareForGame() cy.PrepareForGame('get-started')
}) })
it('Check Header', () => { it('Check Header', () => {
cy.get('#GameTitle').should('contain', 'Linux Odyssey').and('be.visible') cy.get('#GameTitle').should('contain', 'Linux Odyssey').and('be.visible')
@@ -124,7 +124,7 @@ describe('example helloworld app', () => {
}) })
describe('Game Play', () => { describe('Game Play', () => {
beforeEach(() => { beforeEach(() => {
cy.PrepareForGame() cy.PrepareForGame('get-started')
}) })
it('Typing in Terminal', () => { it('Typing in Terminal', () => {
cy.typeInCommand('12345{enter}') cy.typeInCommand('12345{enter}')
@@ -136,7 +136,7 @@ describe('example helloworld app', () => {
cy.checkTaskInit() cy.checkTaskInit()
cy.InitTerminal() cy.InitTerminal()
cy.typeInCommand('echo start{enter}') cy.typeInCommand('echo start{enter}')
cy.checkPending() // cy.checkPending()
cy.waitUntilActive() cy.waitUntilActive()
cy.get('#Lbutton').should('be.visible').and('be.disabled') cy.get('#Lbutton').should('be.visible').and('be.disabled')
cy.get('#Rbutton').should('be.visible').and('be.disabled') cy.get('#Rbutton').should('be.visible').and('be.disabled')
@@ -144,7 +144,7 @@ describe('example helloworld app', () => {
cy.getQuestInfo('✓ 輸入 echo start 來開始教程').should('be.visible') cy.getQuestInfo('✓ 輸入 echo start 來開始教程').should('be.visible')
cy.getQuestInfo('➤ 在終端機輸入 echo hello').should('be.visible') cy.getQuestInfo('➤ 在終端機輸入 echo hello').should('be.visible')
cy.typeInCommand('echo hello{enter}') cy.typeInCommand('echo hello{enter}')
cy.checkPending() // cy.checkPending()
cy.waitUntilActive() cy.waitUntilActive()
cy.get('#Lbutton').should('be.visible').and('be.enabled') cy.get('#Lbutton').should('be.visible').and('be.enabled')
cy.get('#Rbutton').should('be.visible').and('be.disabled') cy.get('#Rbutton').should('be.visible').and('be.disabled')
-3
View File
@@ -1,7 +1,4 @@
describe('Complete quests with only commands', () => { describe('Complete quests with only commands', () => {
beforeEach(() => {
cy.PrepareForGame()
})
it('Complete quest : get-started', () => { it('Complete quest : get-started', () => {
cy.CompleteStageWithCommands('get-started') cy.CompleteStageWithCommands('get-started')
}) })
+18 -15
View File
@@ -38,7 +38,7 @@ Cypress.Commands.add('LoginWithPassword', (username, password) => {
cy.get('#password').type(password) cy.get('#password').type(password)
cy.get('#LogInOrSignUp').click() cy.get('#LogInOrSignUp').click()
}) })
Cypress.Commands.add('PrepareForGame', () => { Cypress.Commands.add('PrepareForGame', (questId) => {
cy.visit('/') cy.visit('/')
// make sure the login page is loaded // make sure the login page is loaded
cy.url().should('satisfy', (elements: string) => { cy.url().should('satisfy', (elements: string) => {
@@ -51,8 +51,9 @@ Cypress.Commands.add('PrepareForGame', () => {
) )
// make sure the map page is loaded // make sure the map page is loaded
cy.url().should('include', '/map') cy.url().should('include', '/map')
cy.visit('/game/get-started') const url = `/game/${questId}`
cy.url().should('include', '/game/get-started') cy.visit(url)
cy.url().should('include', url)
// check task first so the terminal has better possibility loaded // check task first so the terminal has better possibility loaded
cy.checkTaskInit() cy.checkTaskInit()
cy.get('.xterm-screen', { timeout: 10000 }) cy.get('.xterm-screen', { timeout: 10000 })
@@ -114,8 +115,7 @@ Cypress.Commands.add('checkPending', () => {
) )
}) })
Cypress.Commands.add('CompleteStageWithCommands', (stagename) => { Cypress.Commands.add('CompleteStageWithCommands', (stagename) => {
cy.visit(`/game/${stagename}`) cy.PrepareForGame(stagename)
cy.url().should('include', `/game/${stagename}`, { timeout: 50000 })
cy.log(`Completing stage :${stagename}`) cy.log(`Completing stage :${stagename}`)
cy.checkTaskInit() cy.checkTaskInit()
cy.CheckTextElement('#reset', '重來', 'Reset').click() cy.CheckTextElement('#reset', '重來', 'Reset').click()
@@ -123,19 +123,22 @@ Cypress.Commands.add('CompleteStageWithCommands', (stagename) => {
cy.InitTerminal() cy.InitTerminal()
cy.readFile(`../quests/${stagename}/answer.sh`, 'utf-8').then((answers) => { cy.readFile(`../quests/${stagename}/answer.sh`, 'utf-8').then((answers) => {
const answerarr = answers.split('\n') const answerarr = answers.split('\n')
for (const element of answerarr) { answerarr.forEach((element, index) => {
cy.typeInCommand(`${element}{enter}`) cy.typeInCommand(`${element}{enter}`)
if (answerarr.indexOf(element) + 1 !== answerarr.length) { if (index < answerarr.length - 1) {
cy.checkPending()
cy.waitUntilActive() cy.waitUntilActive()
} else {
cy.waitUntilActive(true).then(() => {
cy.CheckTextElement(
'#QuestCompleted',
'關卡完成!',
'Quest Completed!'
)
cy.get('div[class="modal"]').find('p').should('be.visible')
cy.get('#BacktoMap').should('be.visible').and('contain', '回到地圖')
})
} }
if (answerarr.indexOf(element) + 1 === answerarr.length) { })
cy.waitUntilActive(true)
cy.CheckTextElement('#QuestCompleted', '關卡完成!', 'Quest Completed!')
cy.get('div[class="modal"]').find('p').should('be.visible')
cy.get('#BacktoMap').should('be.visible').and('contain', '回到地圖')
}
}
}) })
}) })
Cypress.Commands.add('CheckTextElement', (id, chText, enText) => { Cypress.Commands.add('CheckTextElement', (id, chText, enText) => {