Compare commits

...
Author SHA1 Message Date
Lin, Hung-Shin 99871fa299 Fix exception not showing bug (#143) 2024-07-02 11:58:27 +08:00
wancat 248271355f fix: restore quests changes to prevent conflicts 2024-07-02 11:57:55 +08:00
wancat 988cb65b51 fix: wrong exception condition 2024-07-01 10:41:48 +08:00
wancat 4324932fc9 fix: exception not showing 2024-07-01 10:41:26 +08:00
wancat e9b49502ab fix: progress not update when completing quest 2024-06-28 16:42:15 +08:00
Lin, Hung-Shin d5519e7157 Fix duplicate responses (#140) 2024-06-28 16:19:48 +08:00
wancat ed596c245d fix: duplicate response 2024-06-28 16:17:28 +08:00
Lin, Hung-Shin 57c080d88b Add start session button (#134)
- Add new file StartButton
- Covering Hint part and Terminal part
- Load instruction before pressing button
- Load session after pressing the button
2024-06-28 16:15:26 +08:00
wancat d795dbd788 refactor: Remove commented out code and unused imports 2024-06-28 16:14:26 +08:00
wancat 218d433c14 fix: remove unused logging 2024-06-28 16:05:45 +08:00
wancat 48835f8830 feat: change e2e to not required 2024-06-28 11:41:12 +08:00
wancat dcd8f441f8 feat: echo start after creating session 2024-06-27 12:14:15 +08:00
wancat 94ef3749f5 fix: socket & terminal states across quests 2024-06-27 12:04:35 +08:00
wancat 3398d18055 feat: load session and display start button if not exists 2024-06-27 11:55:34 +08:00
wancat c64043316e feat: replace getOrCreateActive session to getActiveSession 2024-06-27 11:54:28 +08:00
wancat 7cdc542c19 refactor: rewrite session store using pinia and fix start button 2024-06-27 11:23:21 +08:00
wancat 78b9529699 refactor: rewrite map page using user profile store 2024-06-27 10:52:02 +08:00
wancat 8a31a58c56 refactor: rewrite user profile store using pinia 2024-06-27 10:48:08 +08:00
wancat 103ce4774c refactor: rename dev docker compose file and remove unused services 2024-06-27 10:31:26 +08:00
Annie Huang 4e6602d82f change post to get (not yet success) 2024-06-27 10:30:00 +08:00
Annie Huang 2eaf3c8f24 auto type echo start 2024-06-27 10:30:00 +08:00
Annie Huang 15465feda9 auto connect session, create session after button 2024-06-27 10:30:00 +08:00
Annie Huang ea2ba2c7a0 load quest info before start 2024-06-27 10:30:00 +08:00
Annie Huang 0b136a830a add start button
- new stage will load previous stored data at first
2024-06-27 10:30:00 +08:00
Lin, Hung-Shin 3848ca6941 116 guest account (#137)
* add isGuest flag in User model
* add register guest api
* add continue as guest button in frontend
2024-06-27 10:03:04 +08:00
wancat 443e810471 feat: add isGuest migration script 2024-06-17 20:12:32 +08:00
wancat 198dcae3f8 feat: add isGuest field in user model 2024-06-17 19:50:58 +08:00
wancat 7347c37a9e feat: add register guest button 2024-06-17 19:49:00 +08:00
wancat 1d725d82ae feat: add register-guest api 2024-06-17 19:23:30 +08:00
wancat b5c601f4a9 feat: install date-fns 2024-06-17 19:23:23 +08:00
Annie Huang 5069ac12da fix map view 2024-04-11 23:39:57 +08:00
Lin, Hung-Shin cec4d86058 build(deps): bump tar from 6.2.0 to 6.2.1 (#131)
Bumps [tar](https://github.com/isaacs/node-tar) from 6.2.0 to 6.2.1.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/isaacs/node-tar/commit/bef7b1e4ffab822681fea2a9b22187192ed14717"><code>bef7b1e</code></a>
6.2.1</li>
<li><a
href="https://github.com/isaacs/node-tar/commit/fe8cd57da5686f8695415414bda49206a545f7f7"><code>fe8cd57</code></a>
prevent extraction in excessively deep subfolders</li>
<li><a
href="https://github.com/isaacs/node-tar/commit/fe7ebfdcede1f8a2e65db12e19ecc4b3a9934648"><code>fe7ebfd</code></a>
remove security.md</li>
<li>See full diff in <a
href="https://github.com/isaacs/node-tar/compare/v6.2.0...v6.2.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tar&package-manager=npm_and_yarn&previous-version=6.2.0&new-version=6.2.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/linux-odyssey/linux-odyssey/network/alerts).

</details>
2024-04-11 22:39:51 +08:00
dependabot[bot] 93b0bae204 build(deps): bump tar from 6.2.0 to 6.2.1
Bumps [tar](https://github.com/isaacs/node-tar) from 6.2.0 to 6.2.1.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v6.2.0...v6.2.1)

---
updated-dependencies:
- dependency-name: tar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-11 11:56:58 +00:00
Lin, Hung-Shin 4713b11c95 Fix e2e Testing (#130)
**Fix e2e broken caused by website modification**

- Add back "GameTitle" for **Linux Odyssey** in header
- Delete echo hint in quest **move** (It stucks the testing)
- Modify paths for several testing describes
- Fix type problems in command.ts
2024-04-11 15:19:38 +08:00
Annie Huang 36e6bc10d3 fix: e2e broken caused by website modification 2024-04-09 11:32:58 +08:00
Annie Huang 580d5fc3f8 Feat landing page - Mobile Optimisation (#112)
This optimisation is for mobile usage and also make header a better
component.
- Header Restruction
- Menu pop up when using smaller screen
- Seperate header components and other components
2024-04-07 23:49:53 +08:00
Annie Huang d8392ec3f6 delete redundant code 2024-04-07 23:44:41 +08:00
Annie Huang 5f99f42f5f fix type problems 2024-04-07 20:41:17 +08:00
Annie Huang bdf757c2d4 fix: header components in the middle 2024-04-07 20:40:44 +08:00
Annie Huang 453bfb7f1e delete redundant code 2024-04-06 22:06:48 +08:00
wancat 3b470ed952 fix: session store type 2024-04-06 22:06:48 +08:00
Annie Huang b4af2d46d3 fix hint display problems 2024-04-06 22:06:48 +08:00
Annie Huang 0e96e30b96 categorise components 2024-04-06 22:06:48 +08:00
Annie Huang 3e3ac58c6b Restruct heading 2024-04-06 22:06:48 +08:00
wancat 9f690548cf fix: session store type 2024-04-06 22:06:48 +08:00
Annie Huang cd2ec6df75 fix hint display problems 2024-04-06 22:06:48 +08:00
Annie Huang 8e7b5f2552 modify menu position while open 2024-04-06 22:06:48 +08:00
Annie Huang 385c6bc110 delete testing id 2024-04-06 22:06:48 +08:00
wancat 092cf284dd fix: session store type 2024-04-06 22:06:48 +08:00
Annie Huang 7806852cab fix hint display problems 2024-04-06 22:06:48 +08:00
Annie Huang 1e31cf3be5 fix file locations and transfer to ts 2024-04-06 22:06:29 +08:00
Annie Huang aa903fbacf fix file position 2024-04-06 22:06:29 +08:00
Annie Huang df980a7d56 delete redundant code 2024-04-06 22:06:29 +08:00
Annie Huang 980d0497f4 fix header justify end property 2024-04-06 22:06:29 +08:00
Annie Huang e37a23c34f mobile menu (but not justify end while bigger screen, still solving) 2024-04-06 22:06:29 +08:00
Annie Huang f4fc6de4cc canceling [..vh] 2024-04-06 22:06:29 +08:00
Annie Huang 9bfc6614ca hide some text when screen is small 2024-04-06 22:06:29 +08:00
Annie Huang 8c4a9ea871 add header to login and register page, modified header class but not fit for all circumstances 2024-04-06 22:06:29 +08:00
Annie Huang ad29137b86 categorise components - change path 2024-04-06 22:06:29 +08:00
Annie Huang b5e49547ea categorise components 2024-04-06 22:06:29 +08:00
Annie Huang 9a4f9b958a Restruct heading 2024-04-06 22:06:29 +08:00
Annie Huang 29602785cd rwd player opinions 2024-04-06 22:05:59 +08:00
54 changed files with 781 additions and 669 deletions

No files matched your search

-20
View File
@@ -35,23 +35,3 @@ jobs:
- name: Test - name: Test
run: yarn test run: yarn test
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Build Test Environment, Stage 1
run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress
- name: Build Test Quests
run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
- name: Run Cypress Tests
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
run: docker-compose -f docker-compose.testing.yml run cypress
+24
View File
@@ -0,0 +1,24 @@
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Build Test Environment, Stage 1
run: docker-compose -f docker-compose.testing.yml build base backend app-test cypress
- name: Build Test Quests
run: docker-compose -f docker-compose.testing.yml run --rm backend yarn build:quests
- name: Run Cypress Tests
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
run: docker-compose -f docker-compose.testing.yml run cypress
+1 -1
View File
@@ -19,7 +19,7 @@
Run mongoDB first: Run mongoDB first:
docker compose -f docker-compose.dev.yml up -d db docker compose up -d
The db will automatically restarts by Docker. The db will automatically restarts by Docker.
+3 -1
View File
@@ -6,6 +6,7 @@
"dev": "vite", "dev": "vite",
"dev:docker": "API_TARGET=http://backend:3000 vite", "dev:docker": "API_TARGET=http://backend:3000 vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"type-check": "vue-tsc -b",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -16,13 +17,14 @@
"@fortawesome/vue-fontawesome": "^3.0.3", "@fortawesome/vue-fontawesome": "^3.0.3",
"@linux-odyssey/constants": "*", "@linux-odyssey/constants": "*",
"@linux-odyssey/file-graph": "*", "@linux-odyssey/file-graph": "*",
"@linux-odyssey/utils": "*",
"@linux-odyssey/models": "*", "@linux-odyssey/models": "*",
"@linux-odyssey/utils": "*",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"axios": "^1.6.0", "axios": "^1.6.0",
"dompurify": "^3.0.6", "dompurify": "^3.0.6",
"echarts": "^5.4.3", "echarts": "^5.4.3",
"marked": "^10.0.0", "marked": "^10.0.0",
"pinia": "^2.1.7",
"socket.io-client": "^4.7.1", "socket.io-client": "^4.7.1",
"validator": "^13.11.0", "validator": "^13.11.0",
"vue": "^3.3.4", "vue": "^3.3.4",
+17
View File
@@ -83,6 +83,16 @@ const handleChange = () => {
errorMessage.value = 'Something went wrong' errorMessage.value = 'Something went wrong'
} }
} }
const registerGuest = async () => {
try {
await api.post('/auth/register-guest')
router.push({ name: 'map' })
} catch (err) {
console.error(err)
toast.error('Something went wrong.')
}
}
</script> </script>
<template> <template>
<!-- login page --> <!-- login page -->
@@ -219,6 +229,13 @@ const handleChange = () => {
</RouterLink></span </RouterLink></span
> >
</p> </p>
<button
id="register-guest"
class="rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2"
@click="registerGuest()"
>
以訪客身分繼續
</button>
</form> </form>
</div> </div>
</template> </template>
-142
View File
@@ -1,142 +0,0 @@
<script setup lang="ts">
import { defineProps, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { logout } from '../utils/auth'
import { bugReportUrl, surveyUrl } from '../config'
import { reset } from '../store/session'
import userProfileStore, {
loadUserProfile,
resetUserProfile,
} from '../store/userProfile'
const router = useRouter()
const toast = useToast()
const handleLogout = async () => {
try {
await logout()
resetUserProfile()
reset()
router.push({ name: 'login' })
} catch (err) {
toast.error('登出失敗')
console.error(err)
}
}
defineProps({
title: {
type: String,
required: false,
},
})
onMounted(async () => {
try {
await loadUserProfile()
} catch (err) {
console.error(err)
toast.error('無法讀取使用者資料')
}
})
</script>
<template>
<div id="header" class="bg-bg-secondary content-center h-full w-full p-1">
<div class="h-full flex items-center justify-center mx-3">
<div class="flex items-center gap-3">
<!-- <button id="stream" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'bars']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="backward" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'arrow-left']"
class="text-border h-full w-full"
/>
</button> -->
<img src="../img/icon_totem.svg" class="h-5" />
<button
id="GameTitle"
class="text-text-primary inline-block font-bold whitespace-nowrap"
style="font-size: 2vh"
>
Linux Odyssey
</button>
<p
id="QuestTitle"
class="text-text inline-block font-bold whitespace-nowrap"
style="font-size: 2vh"
>
{{ title }}
</p>
</div>
<div class="h-full w-full flex gap-3 items-center justify-end">
<!-- <button id="warning" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'triangle-exclamation']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="setting" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'gear']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="fullscreen" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'expand']"
class="text-text-primary h-full w-full"
/>
</button> -->
<p
id="UsernameText"
class="text-text inline-block whitespace-nowrap"
style="font-size: 2vh"
>
{{ userProfileStore.username }}
</p>
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'file-invoice']"
class="text-text-primary h-full w-full"
/>
</a>
<RouterLink title="LeaderBoard" to="/leaderboard" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'trophy']"
class="text-text-primary h-full w-full"
/>
</RouterLink>
<RouterLink title="Map" to="/map" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'map']"
class="text-text-primary h-full w-full"
/>
</RouterLink>
<a
title="Bug Report"
:href="bugReportUrl"
target="_blank"
class="h-5 w-5"
>
<font-awesome-icon
:icon="['fas', 'bug']"
class="text-text-primary h-full w-full"
/>
</a>
<button title="Sign Out" @click="handleLogout" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'arrow-right-from-bracket']"
class="text-text-primary h-full w-full"
/>
</button>
</div>
</div>
</div>
</template>
+5 -13
View File
@@ -14,7 +14,9 @@ import {
import { DAG } from '@linux-odyssey/utils' import { DAG } from '@linux-odyssey/utils'
import api from '../utils/api' import api from '../utils/api'
import { NodeImage } from '../img/svg.js' import { NodeImage } from '../img/svg.js'
import useUserProfile from '../store/userProfile'
const store = useUserProfile()
const marginX = 500 const marginX = 500
const marginY = 300 const marginY = 300
const chartContainer = ref(null) const chartContainer = ref(null)
@@ -40,16 +42,6 @@ async function getQuests() {
} }
} }
async function getProgress() {
try {
const res = await api.get('/users/me')
return res.data.progress
} catch (err) {
console.error(err)
throw err
}
}
const genOption = (nodes: any, edges: any) => ({ const genOption = (nodes: any, edges: any) => ({
renderer: 'svg', renderer: 'svg',
tooltip: {}, tooltip: {},
@@ -162,9 +154,9 @@ function initChart(option: any) {
} }
onMounted(async () => { onMounted(async () => {
await store.loadUserProfile()
const quests = await getQuests() const quests = await getQuests()
const progress = await getProgress() const option = getOption(quests, store.progress)
const option = getOption(quests, progress)
initChart(option) initChart(option)
}) })
</script> </script>
@@ -177,7 +169,7 @@ onMounted(async () => {
class="p-2 stroke-2 scale-y-120 justify-center items-center" class="p-2 stroke-2 scale-y-120 justify-center items-center"
/> />
<h1 <h1
class="p-4 absolute w-fit z-2 font-mono flex flax-wrap text-xl" class="p-10 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00" style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
> >
踏上你的Linux冒險之旅吧! 踏上你的Linux冒險之旅吧!
@@ -2,6 +2,6 @@
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" /> <font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
<h1 id="CommandBook" class="inline text-text-primary font-xl">指令之書</h1> <h1 id="CommandBook" class="inline text-text-primary font-xl">指令之書</h1>
<div id="cheatsheets"> <div id="cheatsheets">
<p class="text-text-secondary">--指令列表--</p> <p class="text-text-secondary">-- --指令列表-- --</p>
</div> </div>
</template> </template>
@@ -4,7 +4,7 @@ import { useToast } from 'vue-toastification'
import { ref } from 'vue' import { ref } from 'vue'
import { Vue3Lottie } from 'vue3-lottie' import { Vue3Lottie } from 'vue3-lottie'
// import { surveyUrl } from '../config' // import { surveyUrl } from '../config'
import CompleteAnimation from '../img/complete-animation.json' import CompleteAnimation from '../../img/complete-animation.json'
const showModal = ref(true) // Controls whether the modal is visible or not const showModal = ref(true) // Controls whether the modal is visible or not
const router = useRouter() const router = useRouter()
@@ -26,7 +26,6 @@ const backtoMap = async () => {
try { try {
router.push({ name: 'map' }) router.push({ name: 'map' })
} catch (err) { } catch (err) {
// useToast().error('Failed to load map')
toast.error('無法讀取地圖') toast.error('無法讀取地圖')
console.error(err) console.error(err)
} }
@@ -60,17 +59,6 @@ setTimeout(() => {
<p class="text-base text-text text-center"> <p class="text-base text-text text-center">
接下來回到地圖,迎接新的挑戰吧! 接下來回到地圖,迎接新的挑戰吧!
</p> </p>
<!-- <p class="text-base text-text">
您已成功完成挑戰,真的非常感謝您的參與!<br />
如果您能撥冗填寫這份問卷,我們將不勝感激,這對我們非常重要!
</p> -->
<!-- <a
id="SurveyButton"
:href="surveyUrl"
target="_blank"
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
>填寫問卷</a
> -->
<a <a
id="BacktoMap" id="BacktoMap"
target="_blank" target="_blank"
@@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { createSession } from '../store/session' import useSession from '../../store/session'
import { surveyUrl } from '../config' import { surveyUrl } from '../../config'
const router = useRouter() const router = useRouter()
const sessionStore = useSession()
const reset = async () => { const reset = async () => {
console.log('resetting...') console.log('resetting...')
await createSession() await sessionStore.createSession()
} }
const continuePlay = async () => { const continuePlay = async () => {
try { try {
@@ -20,29 +21,31 @@ const continuePlay = async () => {
</script> </script>
<template> <template>
<section class="h-[10%] p-3 grid grid-cols-4 gap-4 place-content-between"> <section
class="h-fit w-fit px-1 py-1 grid grid-cols-4 gap-2 place-content-between"
>
<button id="survey" class="bg-bg-secondary rounded-lg p-2"> <button id="survey" class="bg-bg-secondary rounded-lg p-2">
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5"> <a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
<font-awesome-icon <font-awesome-icon
:icon="['fas', 'file-invoice']" :icon="['fas', 'file-invoice']"
class="text-text-primary" class="text-text-primary px-1"
/> />
<p class="text-text-primary inline ml-1">問卷</p> <p class="text-text-primary inline ml-1 md:flex">問卷</p>
</a> </a>
</button> </button>
<button disabled id="solution" class="bg-bg-disabled rounded-lg p-2"> <button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
<font-awesome-icon <font-awesome-icon
:icon="['far', 'circle-question']" :icon="['far', 'circle-question']"
class="text-text-disabled" class="text-text-disabled px-1"
/> />
<p class="text-text-disabled inline ml-1">解答</p> <p class="text-text-disabled inline ml-1 md:flex">解答</p>
</button> </button>
<button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset"> <button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset">
<font-awesome-icon <font-awesome-icon
:icon="['fas', 'arrow-rotate-left']" :icon="['fas', 'arrow-rotate-left']"
class="text-text-primary" class="text-text-primary px-1"
/> />
<p class="text-text-primary inline ml-1">重來</p> <p class="text-text-primary inline ml-1 hidden md:flex">重來</p>
</button> </button>
<button <button
id="continue" id="continue"
@@ -51,9 +54,9 @@ const continuePlay = async () => {
> >
<font-awesome-icon <font-awesome-icon
:icon="['far', 'circle-right']" :icon="['far', 'circle-right']"
class="text-text-primary" class="text-text-primary px-1"
/> />
<p class="text-text-primary inline ml-1">繼續</p> <p class="text-text-primary inline ml-1 hidden md:flex">繼續</p>
</button> </button>
</section> </section>
</template> </template>
@@ -1,16 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import sessionStore from '../store/session' import useSession from '../../store/session'
import MarkdownText from './MarkdownText.vue' import MarkdownText from '../MarkdownText.vue'
const sessionStore = useSession()
const current = ref(-1) const current = ref(-1)
watch( const length = computed(() => {
() => sessionStore.session.hints, return sessionStore.session?.responses.length ?? 0
(hints) => { })
current.value = hints.length - 1
}, const disabled = computed(() => {
{ deep: true } return current.value === length.value - 1
) })
watch(length, () => {
current.value = length.value - 1
})
const left = () => { const left = () => {
if (current.value > 0) { if (current.value > 0) {
@@ -19,7 +24,7 @@ const left = () => {
} }
const right = () => { const right = () => {
if (current.value < sessionStore.session.hints.length - 1) { if (current.value < length.value - 1) {
current.value += 1 current.value += 1
} }
} }
@@ -27,16 +32,12 @@ const right = () => {
<template> <template>
<div class="h-full flex flex-col"> <div class="h-full flex flex-col">
<!-- Hint Header -->
<div class="flex items-center p-2 h-10"> <div class="flex items-center p-2 h-10">
<font-awesome-icon <font-awesome-icon
:icon="['far', 'lightbulb']" :icon="['far', 'lightbulb']"
class="text-yellow-200 p-2 content-center" class="text-yellow-200 p-2 content-center"
/> />
<h1 class="inline text-text w-1/2 font-xl p-2 m-1"> <h1 class="inline text-text w-1/2 font-xl p-2 m-1">提示 {{ length }}</h1>
<!-- Hint -->
提示
</h1>
<div v-if="current !== -1" class="flex w-full justify-end items-end"> <div v-if="current !== -1" class="flex w-full justify-end items-end">
<button <button
class="p-2 m-1 w-1/8" class="p-2 m-1 w-1/8"
@@ -53,13 +54,13 @@ const right = () => {
<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">{{ current + 1 }}</p>
<p class="inline text-text font-xl w-1/14 p-2 m-1">/</p> <p class="inline text-text font-xl w-1/14 p-2 m-1">/</p>
<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">
{{ sessionStore.session.hints.length }} {{ length }}
</p> </p>
<button <button
class="p-2 m-1 w-1/8" class="p-2 m-1 w-1/8"
id="Rbutton" id="Rbutton"
@click="right" @click="right"
:disabled="current === sessionStore.session.hints.length - 1" :disabled="disabled"
> >
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" /> <font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
</button> </button>
@@ -68,7 +69,7 @@ const right = () => {
<div id="hint" class="bg-bg p-8 overflow-y-auto"> <div id="hint" class="bg-bg p-8 overflow-y-auto">
<ul> <ul>
<li <li
v-for="response in sessionStore.session.responses[current]" v-for="response in sessionStore.session?.responses[current]"
:key="response.type" :key="response.type"
class="text-text font-xl whitespace-pre-wrap" class="text-text font-xl whitespace-pre-wrap"
> >
@@ -77,7 +78,7 @@ const right = () => {
</div> </div>
</li> </li>
<li <li
v-for="hint in sessionStore.session.hints[current]" v-for="hint in sessionStore.session?.hints[current]"
:key="hint" :key="hint"
class="text-text-primary font-xl whitespace-pre-wrap" class="text-text-primary font-xl whitespace-pre-wrap"
> >
@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import MarkdownText from './MarkdownText.vue' import MarkdownText from '../MarkdownText.vue'
import sessionStore from '../store/session' import useSession from '../../store/session'
import { Task } from '../types' import { Task } from '../../types'
const sessionStore = useSession()
const tasks = computed(() => { const tasks = computed(() => {
return sessionStore.session.tasks.map((task: Task) => { return sessionStore.session?.tasks.map((task: Task) => {
const prefix = task.completed ? '✓' : '➤' const prefix = task.completed ? '✓' : '➤'
const color = task.completed ? 'text-text-primary' : 'text-text' const color = task.completed ? 'text-text-primary' : 'text-text'
return { return {
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { computed } from 'vue'
import useSession from '../../store/session'
const toast = useToast()
const sessionStore = useSession()
const showCover = computed(() => sessionStore.session === null)
const startSession = async () => {
try {
await sessionStore.createSession()
} catch (err: any) {
console.error(err)
toast.error(err.message)
}
}
</script>
<template>
<div
v-if="showCover"
class="bg-bg-secondary h-full w-1/2 fixed bg-opacity-50 flex justify-center items-center"
>
<button
id="start"
class="bg-bg-secondary rounded-lg p-3"
@click="startSession"
>
<font-awesome-icon
:icon="['fas', 'play']"
class="text-text-primary h-full px-3"
/>
<p class="text-text-primary inline ml-1 md:flex">開始</p>
</button>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useTerminal } from '../store/session' import { useTerminal } from '../../store/session'
const terminal = ref<HTMLElement | null>(null) const terminal = ref<HTMLElement | null>(null)
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import FileNode from './FileNode.vue' import FileNode from '../FileNode.vue'
import sessionStore from '../store/session' import useSession from '../../store/session'
const sessionStore = useSession()
</script> </script>
<template> <template>
@@ -8,6 +10,7 @@ import sessionStore from '../store/session'
<ul> <ul>
<li> <li>
<FileNode <FileNode
v-if="sessionStore.session"
:node="sessionStore.session.graph" :node="sessionStore.session.graph"
:pwd="sessionStore.session.pwd" :pwd="sessionStore.session.pwd"
/> />
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { logout } from '../../utils/auth'
import { bugReportUrl, surveyUrl } from '../../config'
import useSession from '../../store/session'
import useUserProfile from '../../store/userProfile'
const userStore = useUserProfile()
const sessionStore = useSession()
const router = useRouter()
const toast = useToast()
const handleLogout = async () => {
try {
await logout()
userStore.resetUserProfile()
sessionStore.reset()
router.push({ name: 'login' })
} catch (err) {
toast.error('登出失敗')
console.error(err)
}
}
onMounted(async () => {
try {
await userStore.loadUserProfile()
} catch (err) {
toast.error('無法讀取使用者資料')
console.error(err)
}
})
</script>
<template>
<!-- <button id="warning" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'triangle-exclamation']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="setting" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'gear']"
class="text-text-primary h-full w-full"
/>
</button>
<button id="fullscreen" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'expand']"
class="text-text-primary h-full w-full"
/>
</button> -->
<p
id="UsernameText"
class="text-text inline-block whitespace-nowrap px-1.5"
style="font-size: 2vh"
>
{{ userStore.username }}
</p>
<a
title="Survey"
:href="surveyUrl"
target="_blank"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon
:icon="['fas', 'file-invoice']"
class="text-text-primary inline"
/>
<span class="text-text sm:hidden px-2">Survey</span>
</a>
<RouterLink
title="LeaderBoard"
to="/leaderboard"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon :icon="['fas', 'trophy']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Leader Board</span>
</RouterLink>
<RouterLink title="Map" to="/map" class="md:h-5 md:w-5 px-1.5 w-auto">
<font-awesome-icon :icon="['fas', 'map']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Map</span>
</RouterLink>
<a
title="Bug Report"
:href="bugReportUrl"
target="_blank"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
<span class="text-text sm:hidden px-2">Bug Report</span>
</a>
<button
title="Sign Out"
@click="handleLogout"
class="md:h-5 md:w-5 px-1.5 w-auto"
>
<font-awesome-icon
:icon="['fas', 'arrow-right-from-bracket']"
class="text-text-primary"
/>
<span class="text-text sm:hidden px-2">Sign Out</span>
</button>
</template>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { defineProps, ref } from 'vue'
defineProps({
title: {
type: String,
required: false,
},
headerComponent: {
type: Object,
required: false,
},
})
const menuOpen = ref(false)
const windowWidth = window.innerWidth < 768
const toggleMenu = () => {
menuOpen.value = !menuOpen.value
}
</script>
<template>
<div
class="w-full top-0 px-5 fixed flex flex-row flex-wrap bg-bg-secondary justify-between items-center sm:justify-between"
>
<div class="flex flex-row items-center gap-3" :class="{ hidden: menuOpen }">
<img src="../../img/icon_totem.svg" class="h-8 justify-self-start" />
<RouterLink
to="/"
id="GameTitle"
class="text-text-primary inline-block font-bold whitespace-nowrap pt-1 transition ease-in-out hover:scale-110 duration-300"
style="font-size: 2vh"
>
Linux Odyssey
</RouterLink>
<p
id="QuestTitle"
class="text-text inline-block font-bold whitespace-nowrap"
style="font-size: 2vh"
>
{{ title }}
</p>
</div>
<div
class="w-full flex flex-col items-center justify-end sm:w-auto sm:block sm:ml-6"
:class="{ hidden: !menuOpen }"
>
<component :is="headerComponent" />
</div>
<div
v-if="windowWidth"
:class="{ 'w-full flex justify-end pb-3': menuOpen }"
>
<button
type="button"
class="flex items-center h-5 w-5 rounded-md sm:hidden"
aria-controls="mobile-menu"
aria-expanded="false"
@click="toggleMenu"
>
<font-awesome-icon
:icon="['fas', 'bars']"
class="text-text-primary h-full w-full"
/>
</button>
</div>
</div>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { bugReportUrl } from '../../config'
const scrollToSection = (sectionId: string) => {
const section = document.getElementById(sectionId)
if (section) {
const container = document.querySelector('.overflow-y-auto') as HTMLElement // Replace '.overflow-y-auto' with the appropriate class or selector for your container
if (container) {
container.scrollTo({
top: section.offsetTop - container.offsetTop,
behavior: 'smooth', // Add smooth scrolling behavior if desired
})
}
}
}
</script>
<template>
<button
id="Intro"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('intro')"
style="font-size: 2vh"
>
前言
</button>
<button
id="Feature"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('feature')"
style="font-size: 2vh"
>
特色</button
><button
id="Info"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('about')"
style="font-size: 2vh"
>
關於
</button>
<button
id="Contact"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
@click="scrollToSection('contact')"
style="font-size: 2vh"
>
聯絡我們
</button>
<RouterLink
title="Sign In"
id="SignIn"
to="/register"
class="text-text inline-block font-bold whitespace-nowrap pt-1 px-2 hover:text-text-primary"
style="font-size: 2vh"
>
登入
</RouterLink>
<a title="Bug Report" :href="bugReportUrl" target="_blank" class="h-5 w-5">
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
</a>
</template>
+3 -1
View File
@@ -19,6 +19,7 @@ import {
faTrophy, faTrophy,
faFileInvoice, faFileInvoice,
faEnvelope, faEnvelope,
faPlay,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { import {
faLightbulb, faLightbulb,
@@ -54,5 +55,6 @@ library.add(
faGoogle, faGoogle,
faFileInvoice, faFileInvoice,
faFacebook, faFacebook,
faEnvelope faEnvelope,
faPlay
) )
+4
View File
@@ -1,4 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css' import './style.css'
/* import font awesome icon component */ /* import font awesome icon component */
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
@@ -13,9 +15,11 @@ import App from './App.vue'
import router from './router' import router from './router'
const app = createApp(App) const app = createApp(App)
const pinia = createPinia()
app.component('font-awesome-icon', FontAwesomeIcon) app.component('font-awesome-icon', FontAwesomeIcon)
app.use(pinia)
app.use(router) app.use(router)
const options = { const options = {
+62 -120
View File
@@ -1,142 +1,75 @@
import { reactive } from 'vue' import { defineStore } from 'pinia'
import { useToast } from 'vue-toastification'
import { FileGraph, FileObject } from '@linux-odyssey/file-graph' import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
import { IQuest } from '@linux-odyssey/models' import { IQuest } from '@linux-odyssey/models'
import api from '../utils/api' import { createSession, getActiveSession, getQuest } from '../utils/api'
import Socket from '../utils/socket' import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal' import SocketTerminal from '../utils/terminal'
import { LoadQuestError, LoadSessionError } from '../utils/errors'
import { Session, StageResponse } from '../types' import { Session, StageResponse } from '../types'
const toast = useToast() const socket = new Socket()
const term = new SocketTerminal(40, 80)
function newSession() { let hasSetup = false
const graph = new FileGraph({
path: '/',
type: 'folder',
discovered: false,
})
return {
_id: '',
status: 'inactive',
graph,
pwd: '',
hints: [],
tasks: [],
responses: [],
}
}
interface Store { interface Store {
session: Session session: Session | null
questId: string questId: string
quest: IQuest | null quest: IQuest | null
} }
const store = reactive<Store>({ const useSession = defineStore('session', {
session: newSession(), state: (): Store => ({
session: null,
questId: '', questId: '',
quest: null, quest: null,
}) }),
actions: {
const socket = new Socket() async setQuest(questId: string) {
const term = new SocketTerminal(40, 80) this.quest = await getQuest(questId)
this.questId = questId
function setQuest(questId: string) { },
return api.get(`/quests/${questId}`).then((res) => { async setSession(session: Session) {
store.questId = res.data._id this.session = session
store.quest = res.data this.session.graph = new FileGraph(session.graph)
})
}
async function setSession(session: Session) {
try {
console.log('Setting session...', session)
store.session = session
store.session.graph = new FileGraph(session.graph)
term.reset() term.reset()
await socket.connect(session) await socket.connect(session)
term.focus() term.focus()
} catch (err: any) { },
console.error(err) async createSession() {
if (err instanceof Error) { const session = await createSession(this.questId)
toast.error(err.message) await this.setSession(session)
} else { socket.emit('terminal', 'echo start\n')
toast.error('Failed to connect to the session. Please try again later.') },
async getActiveSession() {
const session = await getActiveSession(this.questId)
if (session) {
this.setSession(session)
} }
throw err },
} updateGraph(event: { discover?: FileObject[]; pwd?: string }) {
} if (!this.session) return
export async function createSession() {
try {
console.log('Creating a new session...')
const res = await api.post('/sessions', { questId: store.questId })
const { data } = res
await setSession(data)
} catch (err) {
console.error(err)
toast.error('Failed to create a new session. Please try again later.')
}
}
async function getActiveSession(questId: string) {
try {
const res = await api.post('/sessions/active', { questId })
await setSession(res.data)
} catch (err) {
console.error(err)
toast.warning('Failed to connect previous session. Creating a new one...')
await createSession()
}
}
function updateGraph(event: { discover: FileObject[]; pwd: string }) {
if (event.discover) { if (event.discover) {
store.session.graph.discover(event.discover) this.session.graph.discover(event.discover)
} }
if (event.pwd) { if (event.pwd) {
store.session.pwd = event.pwd this.session.pwd = event.pwd
} }
} },
newResponse(response: StageResponse) {
function newResponse(response: StageResponse) { if (!this.session) return
console.log('newResponse', response, 'session', store.session) this.session.responses.push(response.responses)
store.session.responses.push(response.responses) this.session.hints.push(response.hints)
store.session.hints.push(response.hints) if (response.tasks) {
store.session.tasks = response.tasks this.session.tasks = response.tasks
store.session.status = response.status }
} this.session.status = response.status
},
export function useTerminal() { reset() {
return term
}
export function reset() {
socket.reset() socket.reset()
term.reset() term.reset()
store.session = newSession() this.$reset()
store.quest = null },
} setup() {
if (hasSetup) return
export async function init(questId: string) {
if (!questId) throw new Error('No quest ID provided')
reset()
try {
await setQuest(questId)
} catch (err) {
console.error(err)
throw new LoadQuestError('Failed to load quest', questId)
}
try {
await getActiveSession(questId)
} catch (err) {
console.error(err)
throw new LoadSessionError('Failed to load session', questId)
}
}
function setup() {
socket.on('terminal', (data: string) => { socket.on('terminal', (data: string) => {
term.write(data) term.write(data)
}) })
@@ -144,13 +77,22 @@ function setup() {
socket.emit('terminal', data) socket.emit('terminal', data)
}) })
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => { socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
updateGraph(event) this.updateGraph(event)
}) })
socket.on('response', (response: StageResponse) => { socket.on('response', (response: StageResponse) => {
newResponse(response) this.newResponse(response)
}) })
hasSetup = true
},
setStatus(status: string) {
if (!this.session) return
this.session.status = status
},
},
})
export function useTerminal() {
return term
} }
setup() export default useSession
export default store
+30 -25
View File
@@ -1,30 +1,35 @@
import { reactive } from 'vue' import { defineStore } from 'pinia'
import api from '../utils/api' import { getUserProfile } from '../utils/api'
const store = reactive({ interface IProgress {
quest: string
sessions: string[]
completed: boolean
startedAt: Date
finishedAt?: Date
}
export interface UserProfile {
username: string
email: string
progress: Map<string, IProgress>
}
const useUserProfile = defineStore('userProfile', {
state: (): UserProfile => ({
username: '', username: '',
email: '', email: '',
progress: {}, progress: new Map<string, IProgress>(),
}),
actions: {
async loadUserProfile() {
const res = await getUserProfile()
this.$patch(res)
},
async resetUserProfile() {
this.$reset()
},
},
}) })
export async function loadUserProfile() { export default useUserProfile
try {
const res = await api.get('/users/me')
const { username, email, progress } = res.data
store.username = username
store.email = email
store.progress = progress
return store
} catch (err: any) {
console.error(err)
throw new Error(`Failed to load user profile: ${err.message}`)
}
}
export function resetUserProfile() {
store.username = ''
store.email = ''
store.progress = {}
}
export default store
+35
View File
@@ -1,4 +1,8 @@
import axios from 'axios' import axios from 'axios'
import { IQuest } from '@linux-odyssey/models'
import { GetSessionsRequest } from '@linux-odyssey/constants'
import { Session } from '../types'
import { UserProfile } from '../store/userProfile'
const api = axios.create({ const api = axios.create({
baseURL: '/api/v1', baseURL: '/api/v1',
@@ -8,3 +12,34 @@ const api = axios.create({
withCredentials: true, withCredentials: true,
}) })
export default api export default api
export async function getUserProfile(): Promise<UserProfile> {
const res = await api.get<UserProfile>('/users/me')
return res.data
}
export async function getQuest(id: string): Promise<IQuest> {
const res = await api.get<IQuest>(`/quests/${id}`)
return res.data
}
export async function createSession(questId: string): Promise<Session> {
const res = await api.post<Session>('/sessions', { questId })
return res.data
}
export async function getSessions(
request: GetSessionsRequest
): Promise<Session[]> {
const res = await api.get<Session[]>('/sessions', { params: request })
return res.data
}
export async function getActiveSession(
questId: string
): Promise<Session | null> {
const res = await api.get<Session | null>('/sessions/active', {
params: { questId },
})
return res.data
}
-1
View File
@@ -46,7 +46,6 @@ class SocketTerminal {
mount(reference: HTMLElement): void { mount(reference: HTMLElement): void {
this.term.open(reference) this.term.open(reference)
this.term.focus()
this.resizeScreen() this.resizeScreen()
} }
+26 -33
View File
@@ -1,19 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import useSession from '../store/session'
import sessionStore, { init } from '../store/session' import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderPart from '../components/GameHeaderPart.vue' import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
// import CommandlistPart from '../components/CommandlistPart.vue' import HintPart from '../components/game/HintPart.vue'
import HintPart from '../components/HintPart.vue' import QuestPart from '../components/game/QuestPart.vue'
import QuestPart from '../components/QuestPart.vue' import TerminalPart from '../components/game/TerminalPart.vue'
import TerminalPart from '../components/TerminalPart.vue' import VisualizationPart from '../components/game/VisualizationPart.vue'
import VisualizationPart from '../components/VisualizationPart.vue' import ControlPalette from '../components/game/ControlPalette.vue'
import ControlPalette from '../components/ControlPalette.vue' import CompleteModal from '../components/game/CompleteModal.vue'
import CompleteModal from '../components/CompleteModal.vue' import StartButton from '../components/game/StartButton.vue'
import { LoadQuestError, LoadSessionError } from '../utils/errors'
const sessionStore = useSession()
const completed = computed(() => { const completed = computed(() => {
return sessionStore.session.status === 'finished' return sessionStore.session?.status === 'finished'
}) })
const props = defineProps({ const props = defineProps({
@@ -23,39 +24,30 @@ const props = defineProps({
}, },
}) })
const toast = useToast()
onMounted(async () => { onMounted(async () => {
try { sessionStore.reset()
await init(props.questId) sessionStore.setup()
} catch (err: any) { await sessionStore.setQuest(props.questId)
if (err instanceof LoadQuestError) { await sessionStore.getActiveSession()
toast.error(`無法讀取關卡: ${err.questId},請確認網頁連結。`)
return
}
if (err instanceof LoadSessionError) {
toast.error(`無法建立工作階段: ${err.questId},請重新登入再試一次。`)
return
}
console.error(err)
toast.error(err.message)
}
}) })
</script> </script>
<template> <template>
<!-- game header --> <!-- game header -->
<div class="h-[6vh] w-full"> <div class="w-full">
<GameHeaderPart :title="sessionStore.quest?.title" /> <HeaderPart
:title="sessionStore.quest?.title"
:headerComponent="GameHeaderComponents"
/>
</div> </div>
<!-- current status indicator --> <!-- current status indicator -->
<input <input
type="hidden" type="hidden"
id="currentStatus" id="currentStatus"
:value="sessionStore.session.status" :value="sessionStore.session?.status"
/> />
<!-- main --> <!-- main -->
<div id="main" class="h-[94vh] w-full flex p-3 space-x-3"> <div id="main" class="h-full pt-10 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/4 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">
@@ -67,7 +59,8 @@ onMounted(async () => {
</section> --> </section> -->
</div> </div>
<!-- Terminal and Hint --> <!-- Terminal and Hint -->
<div class="bg-bg h-full w-1/2 rounded-lg"> <div class="bg-bg h-full rounded-lg w-1/2">
<StartButton :questId="$props.questId" />
<section id="hint" class="h-3/5"> <section id="hint" class="h-3/5">
<HintPart /> <HintPart />
</section> </section>
+27 -101
View File
@@ -1,97 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import ContactButton from '../components/ContactButton.vue' import ContactButton from '../components/ContactButton.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import { bugReportUrl, facebookUrl, githubUrl } from '../config' import LandingHeaderComponents from '../components/header/LandingHeaderComponents.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
const scrollToSection = (sectionId: string) => { import { facebookUrl, githubUrl } from '../config'
const section = document.getElementById(sectionId)
if (section) {
const container = document.querySelector('.overflow-y-auto') // Replace '.overflow-y-auto' with the appropriate class or selector for your container
if (container) {
container.scrollTo({
top: section.offsetTop,
behavior: 'smooth', // Add smooth scrolling behavior if desired
})
}
}
}
</script> </script>
<template> <template>
<div class="w-screen h-screen bg-scroll bg-bg"> <div class="w-screen h-screen bg-scroll bg-bg">
<DynamicBackground class="w-full h-full" /> <DynamicBackground class="w-full h-full" />
<div class="h-screen w-screen absolute top-0 left-0"> <div class="h-screen w-screen absolute top-0 left-0">
<!-- game header --> <!-- game header -->
<div <HeaderPart :headerComponent="LandingHeaderComponents" class="z-50" />
class="w-full h-[6vh] px-5 flex flex-row items-center justify-between bg-bg-secondary"
>
<div class="flex flex-row items-center gap-3">
<img src="../img/icon_totem.svg" class="h-8 justify-self-start" />
<button
id="GameTitle"
class="text-text-primary inline-block font-bold whitespace-nowrap pt-1"
style="font-size: 2vh"
>
Linux Odyssey
</button>
</div>
<div class="flex flex-row items-center gap-3">
<button
id="Intro"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('intro')"
style="font-size: 2vh"
>
Intro
</button>
<button
id="Feature"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('feature')"
style="font-size: 2vh"
>
Feature</button
><button
id="Info"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('about')"
style="font-size: 2vh"
>
About
</button>
<button
id="Contact"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
@click="scrollToSection('contact')"
style="font-size: 2vh"
>
Contact Us
</button>
<RouterLink
title="Sign In"
id="SignIn"
to="/register"
class="text-text inline-block font-bold whitespace-nowrap pt-1"
style="font-size: 2vh"
>
Sign In
</RouterLink>
<a
title="Bug Report"
:href="bugReportUrl"
target="_blank"
class="h-5 w-5"
>
<font-awesome-icon
:icon="['fas', 'bug']"
class="text-text-primary h-full w-full"
/>
</a>
</div>
</div>
<!-- main content --> <!-- main content -->
<main class="w-full h-[94vh] overflow-y-auto"> <main class="w-full h-full overflow-y-auto">
<div class="w-full flex flex-col items-center"> <div class="w-full flex flex-col items-center">
<div <div
class="flex flex-col md:flex-row mt-10 justify-around gap-5 items-center my-10" class="flex flex-col md:flex-row mt-10 justify-around gap-5 items-center my-10"
@@ -104,7 +26,6 @@ const scrollToSection = (sectionId: string) => {
Odyssey Odyssey
</h1> </h1>
</div> </div>
<!-- intro --> <!-- intro -->
<div class="w-full md:w-2/3 px-5"> <div class="w-full md:w-2/3 px-5">
<section <section
@@ -228,40 +149,43 @@ const scrollToSection = (sectionId: string) => {
鈦坦敏捷開發特別獎 第二名 鈦坦敏捷開發特別獎 第二名
</h1> </h1>
</div> </div>
<div class="container mx-auto">
<!-- <h1 <div
class="text-text-primary p-5 font-extrabold font-mono col-span-3 gap-3" class="justify-items-center grid grid-cols-1 sm:grid-cols-3 gap-5"
style="font-size: 3.5vh" >
<h1
class="text-text-primary text-3xl font-extrabold font-mono sm:col-span-3 gap-6"
> >
玩家感想 玩家感想
<!--可以做成翻頁卡牌一次一個-->
</h1> </h1>
<h1 class="text-text p-3" style="font-size: 2.5vh"> <h1 class="text-xl text-center">"很好玩很像益智遊戲"</h1>
"很好玩很像益智遊戲" <h1 class="text-xl text-center">
</h1>
<h1 class="text-text p-3" style="font-size: 2.5vh">
"我從來不知道自由軟體的存在,謝謝你們。" "我從來不知道自由軟體的存在,謝謝你們。"
</h1> </h1>
<h1 class="text-text p-3" style="font-size: 2.5vh"> <h1 class="text-xl text-center">
"還蠻好玩的,讓程式變好玩" "還蠻好玩的,讓程式變好玩"
</h1> </h1>
<h1 class="text-text p-3" style="font-size: 2.5vh"> <h1 class="text-xl text-center">
"以前不喜歡電腦課現在覺得喜歡一點點" "以前不喜歡電腦課現在覺得喜歡一點點"
</h1> </h1>
<h1 class="text-text p-3" style="font-size: 2.5vh"> <h1 class="text-xl text-center">
"程式真的對新手很友善,讓我學到了很多東西" "程式真的對新手很友善,讓我學到了很多東西"
</h1> </h1>
<h1 class="text-text p-3" style="font-size: 2.5vh"> <h1 class="text-xl text-center">
"感覺做得很好很簡明易懂關卡設計很用心" "感覺做得很好很簡明易懂關卡設計很用心"
</h1> </h1>
<h1 class="text-text p-3" style="font-size: 2.5vh"> <h1 class="text-xl text-center">
"這個體驗我覺得非常有趣,可以體驗到沒辦法體驗的程式,學長也都熱心指導,非常感謝學長的用心的講解,希望以後還可以再體驗這種活動" "這個體驗我覺得非常有趣,可以體驗到沒辦法體驗的程式,學長也都熱心指導,非常感謝學長的用心的講解,希望以後還可以再體驗這種活動"
</h1> </h1>
<h1 class="text-text p-3" style="font-size: 2.5vh"> <h1 class="text-xl text-center">
"以前我覺得寫程式很難不過現在覺得很有趣" "以前我覺得寫程式很難不過現在覺得很有趣"
</h1> </h1>
<h1 class="text-text p-3" style="font-size: 2.5vh"> <h1 class="text-xl text-center">
"以前覺得寫程式是我生活中碰不到而且很難的項目。但通過這堂課之後,覺得寫程式不再那麼難以親近" "以前覺得寫程式是我生活中碰不到而且很難的項目。但通過這堂課之後,覺得寫程式不再那麼難以親近"
</h1> --> </h1>
</div>
</div>
</section> </section>
</div> </div>
<section <section
@@ -271,7 +195,9 @@ const scrollToSection = (sectionId: string) => {
<h1 class="text-text-primary font-extrabold font-mono text-3xl"> <h1 class="text-text-primary font-extrabold font-mono text-3xl">
聯絡我們 聯絡我們
</h1> </h1>
<div class="flex flex-row items-center justify-around mt-5 gap-5"> <div
class="flex flex-row items-center justify-around mt-5 gap-5 mb-5"
>
<ContactButton <ContactButton
:url="facebookUrl" :url="facebookUrl"
:icons="['fab', 'facebook']" :icons="['fab', 'facebook']"
+4 -3
View File
@@ -2,7 +2,8 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import GameHeaderPart from '../components/GameHeaderPart.vue' import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
import api from '../utils/api' import api from '../utils/api'
const leaderboard = ref< const leaderboard = ref<
@@ -35,8 +36,8 @@ onMounted(async () => {
<div <div
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center" class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
> >
<div class="h-[6vh] w-full"> <div class="w-full">
<GameHeaderPart title="排行榜" /> <HeaderPart title="排行榜" :headerComponent="GameHeaderComponents" />
</div> </div>
<div class="w-fit h-[94vh] m-3"> <div class="w-fit h-[94vh] m-3">
<div <div
+4 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import Background from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import { import {
TooManyRequestsError, TooManyRequestsError,
@@ -45,10 +46,11 @@ const handleLogin = async ({
<template> <template>
<div class="w-screen h-screen"> <div class="w-screen h-screen">
<Background class="w-full h-full" /> <DynamicBackground class="w-full h-full" />
<div <div
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center" class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
> >
<HeaderPart />
<div class="w-fit"> <div class="w-fit">
<AuthForm @onSubmit="handleLogin" type="login" /> <AuthForm @onSubmit="handleLogin" type="login" />
</div> </div>
+9 -4
View File
@@ -1,13 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import QuestMap from '../components/QuestMap.vue' import QuestMap from '../components/QuestMap.vue'
import GameHeaderPart from '../components/GameHeaderPart.vue' import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
</script> </script>
<template> <template>
<div class="h-[6vh] w-full"> <div class="w-fit">
<GameHeaderPart title="關卡地圖" /> <HeaderPart
title="關卡地圖"
:headerComponent="GameHeaderComponents"
class="z-50"
/>
</div> </div>
<div class="flex flex-col justify-center items-center w-full h-[94vh]"> <div class="flex flex-col justify-center items-center w-full h-full pt-5">
<QuestMap /> <QuestMap />
</div> </div>
</template> </template>
+5 -4
View File
@@ -3,8 +3,8 @@ import validator from 'validator'
import { isValidUsername } from '@linux-odyssey/utils' import { isValidUsername } from '@linux-odyssey/utils'
import { passwordPolicy } from '@linux-odyssey/constants' import { passwordPolicy } from '@linux-odyssey/constants'
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import Background from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
import { checkUsername, register } from '../utils/auth' import { checkUsername, register } from '../utils/auth'
import { import {
UnauthorizedError, UnauthorizedError,
@@ -89,11 +89,12 @@ async function check({
<template> <template>
<div class="w-screen h-screen"> <div class="w-screen h-screen">
<Background class="w-full h-full" /> <DynamicBackground class="w-full h-full" />
<div <div
class="h-screen w-screen absolute top-0 left-0 flex flex-wrap justify-center content-center" class="h-screen w-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
> >
<div class="w-fit"> <HeaderPart />
<div class="w-fit mt-8">
<AuthForm <AuthForm
@onSubmit="handleRegister" @onSubmit="handleRegister"
@onChange="check" @onChange="check"
-95
View File
@@ -1,95 +0,0 @@
version: '3'
services:
app:
depends_on:
- backend
build:
context: .
dockerfile: app/Dockerfile
target: dev
ports:
- 5173:5173
networks:
- internal
volumes:
- ./app:/app/app
- ./packages:/app/packages
backend:
depends_on:
- db
build:
context: .
dockerfile: server/Dockerfile
target: dev
networks:
- internal
- players
ports:
- '3000:3000'
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./server/src:/app/server/src
- ./server/dist:/app/server/dist
- ./server/swagger.yaml:/app/server/swagger.yaml
- ./packages:/app/packages
- ./quests:/app/quests
- ./logs:/app/logs
environment:
- BASE_URL=http://localhost:5173
- MONGO_URL=mongodb://db:27017/odyssey-test
- DOCKER_NETWORK=linux-odyssey-players
- SECRET_KEY=b1d2e917-17b9-4fce-9055-082dee6ec5d6 # Only for testing
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
- SURVEY_URL=${SURVEY_URL}
- BUG_REPORT_URL=${BUG_REPORT_URL}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- TESTING=true
- TESTING_USERNAME=alex
- TESTING_PASSWORD=Alex1234
- HOST_PWD=${PWD}
- MOUNT_QUEST=${MOUNT_QUEST}
analytics:
build:
context: .
dockerfile: packages/analytics/Dockerfile
target: dev
depends_on:
- db
networks:
- internal
ports:
- '3001:3001'
environment:
- MONGO_URL=mongodb://db:27017/odyssey-test
- ADMIN_USERNAME=alex
- ADMIN_PASSWORD=Alex1234
volumes:
- ./packages/analytics:/app/packages/analytics
- ./packages/models:/app/packages/models
db:
image: mongo
restart: always
networks:
- internal
ports:
- '27017:27017'
volumes:
- ./data:/data/db
base:
image: linuxodyssey/quest-base
build:
context: .
dockerfile: ./quests/Dockerfile
networks:
internal:
players:
name: linux-odyssey-players
internal: true
+23
View File
@@ -0,0 +1,23 @@
version: '3'
services:
db:
image: mongo
restart: always
networks:
- internal
ports:
- '27017:27017'
volumes:
- ./data:/data/db
base:
image: linuxodyssey/quest-base
build:
context: .
dockerfile: ./quests/Dockerfile
networks:
internal:
players:
name: linux-odyssey-players
internal: true
+10 -3
View File
@@ -1,3 +1,5 @@
import { z } from 'zod'
export const passwordPolicy = { export const passwordPolicy = {
minLength: 8, minLength: 8,
minNumbers: 1, minNumbers: 1,
@@ -6,6 +8,11 @@ export const passwordPolicy = {
minSymbols: 0, minSymbols: 0,
} }
export default { export const getSessionsRequestSchema = z.object({
passwordPolicy, questId: z.string().optional(),
} status: z.enum(['active', 'inactive', 'finished']).optional(),
limit: z.coerce.number().optional(),
order: z.enum(['asc', 'desc']).optional(),
})
export type GetSessionsRequest = z.infer<typeof getSessionsRequestSchema>
+3
View File
@@ -10,5 +10,8 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"dev": "tsc -w" "dev": "tsc -w"
},
"dependencies": {
"zod": "^3.23.8"
} }
} }
+6
View File
@@ -28,6 +28,7 @@ export interface IUser {
socialLogins: Map<string, ISocialLogin> socialLogins: Map<string, ISocialLogin>
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
isGuest: boolean
} }
export const User = model<IUser>( export const User = model<IUser>(
@@ -51,6 +52,11 @@ export const User = model<IUser>(
default: {}, default: {},
required: true, required: true,
}, },
isGuest: {
type: Boolean,
default: false,
required: true,
},
}, },
{ {
timestamps: true, timestamps: true,
-1
View File
@@ -8,5 +8,4 @@ mv world.txt wow.txt
ls ls
mv notes docs mv notes docs
ls ls
echo hint
mv docs/hello.txt hi.txt mv docs/hello.txt hi.txt
+4 -1
View File
@@ -95,10 +95,13 @@ stages:
not: not:
pwd: pwd:
- ^/home/commander$ - ^/home/commander$
responses:
- type: narrative
content: content:
- 請留在 `/home/commander` 資料夾中。 - 請留在 `/home/commander` 資料夾中。
- 請輸入 `cd ~` 回到 `/home/commander` 資料夾。 - 請輸入 `cd ~` 回到 `/home/commander` 資料夾。
responses: responses:
- content: - type: narrative
content:
- 你成功地查看到 `file.txt` 的內容了。 - 你成功地查看到 `file.txt` 的內容了。
- 你已經會使用 `cat` 指令查看檔案的內容了。 - 你已經會使用 `cat` 指令查看檔案的內容了。
+23
View File
@@ -0,0 +1,23 @@
import connectDB, { User } from '@linux-odyssey/models'
async function migrate() {
await connectDB(
process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test'
)
await User.updateMany(
{
isGuest: { $exists: false },
},
{ $set: { isGuest: false } }
)
}
migrate()
.then(() => {
console.log('Migration completed')
process.exit(0)
})
.catch((err) => {
console.error(err)
process.exit(1)
})
+3 -1
View File
@@ -9,7 +9,8 @@
"clean": "node dist/cli/clean.js", "clean": "node dist/cli/clean.js",
"build": "tsc -b", "build": "tsc -b",
"dev": "nodemon", "dev": "nodemon",
"build:quests": "node dist/cli/build.js" "build:quests": "node dist/cli/build.js",
"ts-node": "node --loader ts-node/esm"
}, },
"dependencies": { "dependencies": {
"@linux-odyssey/constants": "*", "@linux-odyssey/constants": "*",
@@ -20,6 +21,7 @@
"commander": "^11.0.0", "commander": "^11.0.0",
"connect-mongo": "^5.0.0", "connect-mongo": "^5.0.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"date-fns": "^3.6.0",
"dockerode": "^3.3.5", "dockerode": "^3.3.5",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"express": "^4.19.2", "express": "^4.19.2",
+25 -1
View File
@@ -1,7 +1,7 @@
import type { Request, Response, NextFunction, Express } from 'express' import type { Request, Response, NextFunction, Express } from 'express'
import { matchedData } from 'express-validator' import { matchedData } from 'express-validator'
import { genJWT } from '../../utils/auth.js' import { genJWT } from '../../utils/auth.js'
import { createUser } from '../../models/userManager.js' import { createUser, createGuestUser } from '../../models/userManager.js'
import { asyncHandler } from '../../middleware/error.js' import { asyncHandler } from '../../middleware/error.js'
import logger from '../../utils/logger.js' import logger from '../../utils/logger.js'
@@ -39,6 +39,30 @@ export const register = asyncHandler(
} }
) )
export const registerGuest = asyncHandler(
async (req: Request, res: Response) => {
const user = await createGuestUser()
req.login(
{
id: user.id,
username: user.username,
email: user.email,
},
(err) => {
if (err) {
res.status(500).json({
message: 'error logging in',
})
return
}
res.status(201).json({
message: 'user created',
})
}
)
}
)
export function logout(req: Request, res: Response) { export function logout(req: Request, res: Response) {
req.logout((err) => { req.logout((err) => {
if (err) { if (err) {
@@ -49,8 +49,6 @@ export const newCommand = asyncHandler(async (req: Request, res: Response) => {
c.stage = response.stage c.stage = response.stage
await c.save() await c.save()
session.responses.push(response.responses)
if ((session.status as string) === 'finished') { if ((session.status as string) === 'finished') {
await finishSession(session) await finishSession(session)
} }
@@ -3,7 +3,6 @@ import type { Express, Request, Response } from 'express'
import { User, Session, ISession } from '@linux-odyssey/models' import { User, Session, ISession } from '@linux-odyssey/models'
import { import {
createNewSession, createNewSession,
getOrCreateActiveSession,
isQuestUnlocked, isQuestUnlocked,
} from '../../models/sessionManager.js' } from '../../models/sessionManager.js'
import { asyncHandler } from '../../middleware/error.js' import { asyncHandler } from '../../middleware/error.js'
@@ -56,7 +55,6 @@ 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,
@@ -71,7 +69,7 @@ export const getSessionById = asyncHandler(
} }
) )
export const getOrCreateSession = asyncHandler( export const getActiveSessionHandler = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const { questId } = matchedData(req) const { questId } = matchedData(req)
const user = await User.findById((req.user as Express.ExistingUser).id) const user = await User.findById((req.user as Express.ExistingUser).id)
@@ -79,8 +77,12 @@ export const getOrCreateSession = asyncHandler(
user && user &&
((await isQuestUnlocked(user, questId)) || config.testing.enabled) ((await isQuestUnlocked(user, questId)) || config.testing.enabled)
) { ) {
const session = await getOrCreateActiveSession(user, questId) const session = await Session.findOne({
res.json(sessionDetail(session)) user: user.id,
quest: questId,
status: 'active',
})
res.json(session ? sessionDetail(session) : null)
} else { } else {
res.status(403).json({ message: 'Quest is locked.' }) res.status(403).json({ message: 'Quest is locked.' })
} }
+3
View File
@@ -9,6 +9,7 @@ import {
logout, logout,
registerFromSession, registerFromSession,
socialLoginHandler, socialLoginHandler,
registerGuest,
} from '../controllers/authController.js' } from '../controllers/authController.js'
import { import {
checkUsernameValidators, checkUsernameValidators,
@@ -32,6 +33,8 @@ router.post(
router.post('/register', authenticateRateLimit, registerValidators, register) router.post('/register', authenticateRateLimit, registerValidators, register)
router.post('/register-guest', authenticateRateLimit, registerGuest)
router.post('/logout', logout) router.post('/logout', logout)
router.get( router.get(
+2 -2
View File
@@ -17,10 +17,10 @@ router.post(
sessionRateLimit, sessionRateLimit,
sessionController.createSession sessionController.createSession
) )
router.post( router.get(
'/active', '/active',
activeSessionValidator, activeSessionValidator,
sessionController.getOrCreateSession sessionController.getActiveSessionHandler
) )
router.get( router.get(
'/:sessionId', '/:sessionId',
+1 -3
View File
@@ -8,7 +8,6 @@ import SessionMiddleware from '../middleware/session.js'
import { genJWT } from '../utils/auth.js' import { genJWT } from '../utils/auth.js'
import logger from '../utils/logger.js' import logger from '../utils/logger.js'
// eslint-disable-next-line no-unused-vars
type SocketCallback = (event: string, ...args: any[]) => void type SocketCallback = (event: string, ...args: any[]) => void
const sessions = new Map<string, SocketCallback[]>() const sessions = new Map<string, SocketCallback[]>()
@@ -44,7 +43,6 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
next(new Error('User not found.')) next(new Error('User not found.'))
return return
} }
// console.log(socket.handshake)
const { sessionId } = socket.handshake.query const { sessionId } = socket.handshake.query
if ( if (
!sessionId || !sessionId ||
@@ -79,7 +77,7 @@ async function connectContainer(socket: Socket, next: (err?: Error) => void) {
stream, stream,
} }
next() next()
} catch (err) { } catch {
next(new Error('Failed to start container.')) next(new Error('Failed to start container.'))
} }
} }
+1 -1
View File
@@ -82,7 +82,7 @@ export async function deleteContainer(id: string) {
} }
try { try {
await container.stop() await container.stop()
} catch (error) { } catch {
logger.warn('Failed to stop container', id) logger.warn('Failed to stop container', id)
} }
await container.remove() await container.remove()
+1 -1
View File
@@ -112,7 +112,7 @@ export default class CommandHandler extends SessionHandler {
) )
) )
return checks.every((c) => c === true) return checks.every((c) => c === true)
} catch (e) { } catch {
return false return false
} }
} }
-15
View File
@@ -79,21 +79,6 @@ export async function createNewSession(
return session return session
} }
export async function getOrCreateActiveSession(
user: HydratedDocument<IUser>,
questId: string
): Promise<ISession> {
const session = await Session.findOne({
user,
quest: questId,
status: 'active',
})
if (session) {
return session
}
return createNewSession(user, questId)
}
export async function finishSession( export async function finishSession(
session: HydratedDocument<ISession> session: HydratedDocument<ISession>
): Promise<void> { ): Promise<void> {
+18
View File
@@ -1,4 +1,5 @@
import { Express } from 'express' import { Express } from 'express'
import { format } from 'date-fns'
import { User, UserProfile } from '@linux-odyssey/models' import { User, UserProfile } from '@linux-odyssey/models'
import { hashPassword } from '../utils/auth.js' import { hashPassword } from '../utils/auth.js'
@@ -31,6 +32,23 @@ export async function createUser(
return user return user
} }
export async function createGuestUser() {
const username = `guest_${format(new Date(), 'yyyyMMddHHmmss')}`
const user = new User({
username,
email: `${username}@example.com`,
isGuest: true,
})
await user.save()
const profile = new UserProfile({
user,
})
await profile.save()
return user
}
export function leaderboard() { export function leaderboard() {
return UserProfile.aggregate([ return UserProfile.aggregate([
{ {
+31 -2
View File
@@ -1,14 +1,14 @@
openapi: 3.0.0 openapi: 3.0.0
servers: servers:
- url: /api/v1 - url: /api/v1
- url: https://odyssey.wancat.cc/api/v1 - url: https://linuxodyssey.xyz/api/v1
- url: http://localhost:3000/api/v1 - url: http://localhost:3000/api/v1
info: info:
description: Linux Odyssey API description: Linux Odyssey API
version: '1.0.0' version: '1.0.0'
title: Session API title: Session API
contact: contact:
email: wancat@wancat.cc email: contact@linuxodyssey.xyz
license: license:
name: Apache 2.0 name: Apache 2.0
url: 'http://www.apache.org/licenses/LICENSE-2.0.html' url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
@@ -37,6 +37,19 @@ paths:
schema: schema:
type: string type: string
enum: [active, finished, inactive] enum: [active, finished, inactive]
- in: query
name: limit
description: limit the number of sessions to return
schema:
type: number
example: 10
- in: query
name: order
description: order the sessions by createdAt
schema:
type: string
enum: [asc, desc]
example: desc
responses: responses:
'200': '200':
description: list of existing session description: list of existing session
@@ -233,6 +246,22 @@ paths:
type: string type: string
example: connect.sid=s%3Aq1Z; Path=/; HttpOnly; SameSite=Strict; Secure example: connect.sid=s%3Aq1Z; Path=/; HttpOnly; SameSite=Strict; Secure
/auth/register-guest:
post:
summary: Register a new guest user
responses:
201:
description: User created
headers:
Set-Cookie:
schema:
type: string
example: connect.sid=s%3Aq1Z; Path=/; HttpOnly; SameSite=Strict; Secure
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/auth/login: /auth/login:
post: post:
summary: Log in a user summary: Log in a user
+4 -4
View File
@@ -5,7 +5,7 @@ export default function checkLoginUI() {
describe('example helloworld app', () => { describe('example helloworld app', () => {
describe('Login tests', () => { describe('Login tests', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/') cy.visit('/register')
}) })
it('Check Login Page Elements-Register', () => { it('Check Login Page Elements-Register', () => {
checkLoginUI() checkLoginUI()
@@ -83,11 +83,11 @@ describe('example helloworld app', () => {
cy.get('#UsernameText') cy.get('#UsernameText')
.should('contain', Cypress.env('defaultAccount')) .should('contain', Cypress.env('defaultAccount'))
.and('be.visible') .and('be.visible')
cy.findByRole('link', { name: 'Survey' }).should('be.visible')
cy.findByRole('link', { name: 'LeaderBoard' }).should('be.visible')
cy.findByRole('link', { name: 'Map' }).should('be.visible')
cy.findByRole('link', { name: 'Bug Report' }).should('be.visible') cy.findByRole('link', { name: 'Bug Report' }).should('be.visible')
cy.findByRole('button', { name: 'Sign Out' }).should('be.visible') cy.findByRole('button', { name: 'Sign Out' }).should('be.visible')
cy.findByRole('button', { name: 'Map' }).should('be.visible')
cy.findByRole('button', { name: 'LeaderBoard' }).should('be.visible')
cy.findByRole('link', { name: 'Survey' }).should('be.visible')
}) })
it('Check QuestInfo', () => { it('Check QuestInfo', () => {
cy.get('#topic').should('contain', title) cy.get('#topic').should('contain', title)
+5 -5
View File
@@ -8,7 +8,7 @@ declare global {
namespace Cypress { namespace Cypress {
interface Chainable { interface Chainable {
LoginWithPassword(username: string, password: string): Chainable<string> LoginWithPassword(username: string, password: string): Chainable<string>
PrepareForGame(): Chainable<void> PrepareForGame(questId: string): Chainable<string>
InitTerminal(): Chainable<void> InitTerminal(): Chainable<void>
typeInCommand(command: string): Chainable<string> typeInCommand(command: string): Chainable<string>
getQuestInfo(id: string): Chainable<JQuery<HTMLParagraphElement>> getQuestInfo(id: string): Chainable<JQuery<HTMLParagraphElement>>
@@ -38,8 +38,8 @@ 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', (questId) => { Cypress.Commands.add('PrepareForGame', (questId: string) => {
cy.visit('/') cy.visit('/login')
// 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) => {
const text = elements const text = elements
@@ -143,9 +143,9 @@ Cypress.Commands.add('CompleteStageWithCommands', (stagename) => {
}) })
Cypress.Commands.add('CheckTextElement', (id, chText, enText) => { Cypress.Commands.add('CheckTextElement', (id, chText, enText) => {
if (Cypress.env('isCHVersion')) { if (Cypress.env('isCHVersion')) {
cy.get(id, { timeout: 50000 }).should('contain', chText).and('be.visible') cy.get(id, { timeout: 7000 }).should('contain', chText).and('be.visible')
} else { } else {
cy.get(id, { timeout: 50000 }).should('contain', enText).and('be.visible') cy.get(id, { timeout: 7000 }).should('contain', enText).and('be.visible')
} }
}) })
Cypress.Commands.add('CheckPlaceholder', (id, chText, enText) => { Cypress.Commands.add('CheckPlaceholder', (id, chText, enText) => {
+31 -3
View File
@@ -2479,6 +2479,11 @@
"@vue/compiler-dom" "3.4.19" "@vue/compiler-dom" "3.4.19"
"@vue/shared" "3.4.19" "@vue/shared" "3.4.19"
"@vue/devtools-api@^6.5.0":
version "6.6.3"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.3.tgz#b23a588154cba8986bba82b6e1d0248bde3fd1a0"
integrity sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw==
"@vue/devtools-api@^6.5.1": "@vue/devtools-api@^6.5.1":
version "6.6.1" version "6.6.1"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.1.tgz#7c14346383751d9f6ad4bea0963245b30220ef83" resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.1.tgz#7c14346383751d9f6ad4bea0963245b30220ef83"
@@ -3594,6 +3599,11 @@ date-fns@^2.30.0:
dependencies: dependencies:
"@babel/runtime" "^7.21.0" "@babel/runtime" "^7.21.0"
date-fns@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf"
integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==
dayjs@^1.10.4: dayjs@^1.10.4:
version "1.11.10" version "1.11.10"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0"
@@ -6768,6 +6778,14 @@ pify@^2.2.0, pify@^2.3.0:
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==
pinia@^2.1.7:
version "2.1.7"
resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.1.7.tgz#4cf5420d9324ca00b7b4984d3fbf693222115bbc"
integrity sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==
dependencies:
"@vue/devtools-api" "^6.5.0"
vue-demi ">=0.14.5"
pirates@^4.0.1, pirates@^4.0.4: pirates@^4.0.1, pirates@^4.0.4:
version "4.0.6" version "4.0.6"
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9"
@@ -7717,9 +7735,9 @@ tar-stream@^2.0.0:
readable-stream "^3.1.1" readable-stream "^3.1.1"
tar@^6.1.11: tar@^6.1.11:
version "6.2.0" version "6.2.1"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73" resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a"
integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ== integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==
dependencies: dependencies:
chownr "^2.0.0" chownr "^2.0.0"
fs-minipass "^2.0.0" fs-minipass "^2.0.0"
@@ -8155,6 +8173,11 @@ vite@^4.5.3:
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" fsevents "~2.3.2"
vue-demi@>=0.14.5:
version "0.14.8"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.8.tgz#00335e9317b45e4a68d3528aaf58e0cec3d5640a"
integrity sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==
vue-eslint-parser@^9.4.2: vue-eslint-parser@^9.4.2:
version "9.4.2" version "9.4.2"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz#02ffcce82042b082292f2d1672514615f0d95b6d" resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz#02ffcce82042b082292f2d1672514615f0d95b6d"
@@ -8452,6 +8475,11 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zod@^3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
zrender@5.5.0: zrender@5.5.0:
version "5.5.0" version "5.5.0"
resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.5.0.tgz#54d0d6c4eda81a96d9f60a9cd74dc48ea026bc1e" resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.5.0.tgz#54d0d6c4eda81a96d9f60a9cd74dc48ea026bc1e"