mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-06 03:20:13 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b77d91796 | ||
|
|
f0a09ac4d9 | ||
|
|
a732c1d119 | ||
|
|
da473ceb8f | ||
|
|
01d60e259c | ||
|
|
0aeb9fe47e | ||
|
|
fd4f3bf8f5 | ||
|
|
39c81e6b6e | ||
|
|
ef24270e7e | ||
|
|
13b90f83c4 | ||
|
|
cb77a3e266 | ||
|
|
61cb36f357 | ||
|
|
58f3bf7817 | ||
|
|
ccbc4ff4b0 | ||
|
|
aef760478c | ||
|
|
bda68ad293 | ||
|
|
af2f1ef707 | ||
|
|
3b4891412e | ||
|
|
ef588414db | ||
|
|
bc43184adc | ||
|
|
13fb8737a3 | ||
|
|
eaced63c68 | ||
|
|
b64cc6028c | ||
|
|
dbf26bb490 | ||
|
|
698dc368e6 | ||
|
|
0a8ef9a37e | ||
|
|
aeed963a75 | ||
|
|
128aae33ec | ||
|
|
b86697b6db | ||
|
|
cb1e395579 | ||
|
|
fbe1319183 | ||
|
|
94389d4e1c | ||
|
|
a66b4f5adb | ||
|
|
9498bb77ef | ||
|
|
6fe49c4f38 | ||
|
|
3985d5d089 | ||
|
|
253564376d | ||
|
|
f7e2b00358 | ||
|
|
d450613816 | ||
|
|
129d2ff49b | ||
|
|
ad0cb3e4b2 | ||
|
|
66365b106e | ||
|
|
f6ee9f91d4 | ||
|
|
67e84d8cf9 | ||
|
|
fda0357a73 | ||
|
|
e9e924165d | ||
|
|
937c5de6b8 | ||
|
|
53c5391f61 | ||
|
|
876cae6947 | ||
|
|
beb58e233e | ||
|
|
f7860c6197 | ||
|
|
5077f9c677 | ||
|
|
9be288d2de | ||
|
|
cd6f062556 | ||
|
|
c61f844e42 | ||
|
|
26e79690f8 | ||
|
|
fba92dad46 | ||
|
|
b3ed0e646e | ||
|
|
87b8ca5d04 | ||
|
|
2d95ca382f | ||
|
|
cdd22634e0 | ||
|
|
7e50c8e06c | ||
|
|
0685d71299 | ||
|
|
56049a0a29 | ||
|
|
9b5da4e818 | ||
|
|
ab63595fba | ||
|
|
52e9a13645 | ||
|
|
cfbc386c16 | ||
|
|
92173bb7e9 | ||
|
|
b4b8f8baee | ||
|
|
2fcca0c4b9 | ||
|
|
66434ff897 | ||
|
|
a711e08328 | ||
|
|
1616f62755 | ||
|
|
1d843f7355 | ||
|
|
d2cdd1304a | ||
|
|
254e4f75ed | ||
|
|
0f0f43b838 | ||
|
|
2dc40a2254 | ||
|
|
6e804f0ed4 | ||
|
|
3656868b66 | ||
|
|
cfce5ececa | ||
|
|
fb823814e0 | ||
|
|
f7780c3d98 | ||
|
|
dae9e841ca | ||
|
|
e622011b21 | ||
|
|
471b781d85 | ||
|
|
57d8f24930 | ||
|
|
6043226142 | ||
|
|
61d1871e95 | ||
|
|
ed4d04b658 | ||
|
|
963655c1f7 | ||
|
|
423548804a | ||
|
|
ac92950177 | ||
|
|
55a41dd3b5 | ||
|
|
529869671c | ||
|
|
6326b84a60 | ||
|
|
8cc006e3a4 | ||
|
|
d6ae6d818d | ||
|
|
6692ae2b9b | ||
|
|
fab02d03a9 | ||
|
|
9e44ebe415 | ||
|
|
019caf58e4 | ||
|
|
97ad1a7b50 | ||
|
|
cb4b9f429a | ||
|
|
5e11d2da11 | ||
|
|
a901ce005c | ||
|
|
4dd2a7cbe1 | ||
|
|
5cf375905d | ||
|
|
d7c5c756f3 | ||
|
|
44ecd3b87a | ||
|
|
1888781a47 | ||
|
|
860b257ea7 | ||
|
|
ba251fb933 | ||
|
|
84bd2620d8 | ||
|
|
b449d84c54 | ||
|
|
91ebaec22c | ||
|
|
58354b7cd8 | ||
|
|
31301419ad | ||
|
|
251139c159 | ||
|
|
a4ed4cf533 | ||
|
|
247f03b71a | ||
|
|
be22a121c8 | ||
|
|
261c6fc029 | ||
|
|
03b2993b56 | ||
|
|
2cc348687a | ||
|
|
2a3429106c | ||
|
|
a6720cfe39 | ||
|
|
4c292fd6ba | ||
|
|
5ba0021c37 | ||
|
|
19897e607f | ||
|
|
08e9191c4b | ||
|
|
aa95eda23c | ||
|
|
6bc37900d8 | ||
|
|
98f18627e5 | ||
|
|
19c44fbe52 | ||
|
|
c254d6c948 | ||
|
|
e66598f1a7 | ||
|
|
2a868ead48 | ||
|
|
9b4f50ebbd | ||
|
|
5fd24190d2 | ||
|
|
d2bb079f50 | ||
|
|
d959ab22fe | ||
|
|
9c8260a411 | ||
|
|
3c92c9d143 | ||
|
|
5208d87bd6 | ||
|
|
634464065d | ||
|
|
4c90793fc6 | ||
|
|
169587c54d | ||
|
|
4dd5f94c60 | ||
|
|
14bcb3b0d5 | ||
|
|
8281a9d882 | ||
|
|
dcf72d046c | ||
|
|
b73607fb8d | ||
|
|
b64d707d16 | ||
|
|
7dcac306f9 | ||
|
|
eddbda23d7 | ||
|
|
124c5cc401 | ||
|
|
21d08b8156 | ||
|
|
3745011504 | ||
|
|
3f08452ea4 | ||
|
|
acfb09c6ca | ||
|
|
c2c100e6e6 | ||
|
|
4033eefa0d | ||
|
|
334b34ef65 | ||
|
|
ac89d521fc | ||
|
|
60641ba0ad | ||
|
|
dd67bcb1bd | ||
|
|
d002b21aa8 | ||
|
|
033c808c02 | ||
|
|
1be4f73e7e | ||
|
|
9ddfcd2a86 | ||
|
|
ca36f6391a | ||
|
|
1572b2ea06 | ||
|
|
8f6914d46c | ||
|
|
517ab43e8c | ||
|
|
74c49e1df7 | ||
|
|
cb0f3cc3b0 | ||
|
|
5605b27127 | ||
|
|
a7368110dd | ||
|
|
bdb7def73a | ||
|
|
b74866e50f | ||
|
|
d577656eb1 | ||
|
|
6400aa4a56 | ||
|
|
887d205ec1 | ||
|
|
8597c22b9f | ||
|
|
d8c0ee4b20 | ||
|
|
8180e9a0cf | ||
|
|
6daa11bff7 | ||
|
|
976dc6363c | ||
|
|
8d0a210f0e | ||
|
|
c38104d043 | ||
|
|
83e563ab56 | ||
|
|
fa2059814c | ||
|
|
f9fa5a2102 | ||
|
|
8ed6acbcc1 | ||
|
|
d2452c91a6 | ||
|
|
5bac9ce099 | ||
|
|
2aaff0f7bc | ||
|
|
96ab39437b | ||
|
|
d6b606e807 | ||
|
|
b792992391 | ||
|
|
922100d8da | ||
|
|
52597e8c5e | ||
|
|
a6c160ac0e | ||
|
|
3859403b56 | ||
|
|
2a29b0b8ac | ||
|
|
aaa97e0dca | ||
|
|
3a28af0a67 | ||
|
|
551d502845 | ||
|
|
3f0191112d | ||
|
|
d565709a59 | ||
|
|
435a426f5b | ||
|
|
377afb3078 | ||
|
|
9d26951978 | ||
|
|
db3b00f8ee | ||
|
|
c09c4ff9e0 | ||
|
|
b3b374b3cd | ||
|
|
5ca187651d | ||
|
|
f2ca4a26ac | ||
|
|
462f17a779 | ||
|
|
3deabb25a9 | ||
|
|
3dad7ddccb | ||
|
|
25888a1465 | ||
|
|
3559814f55 | ||
|
|
8142ff816c | ||
|
|
9c160253c9 | ||
|
|
80e1a40b6e | ||
|
|
2b70f3c7cd | ||
|
|
2e29919ad9 | ||
|
|
ca79042a3b | ||
|
|
c8d2320bf5 | ||
|
|
441909efd5 | ||
|
|
90e3fc2bd0 | ||
|
|
598141d735 | ||
|
|
e11432be45 | ||
|
|
2c1fe7bb96 | ||
|
|
8c54bfe192 | ||
|
|
d292195302 | ||
|
|
6bbf70a258 | ||
|
|
526b856de7 | ||
|
|
1a20ffb295 | ||
|
|
4efa9b8831 | ||
|
|
3944e91d0c | ||
|
|
c30b4a7c6e | ||
|
|
14cd16b611 | ||
|
|
b07b60f6bd | ||
|
|
9d1bed7543 | ||
|
|
715352f77c | ||
|
|
c4550a395a | ||
|
|
5795cb16b8 | ||
|
|
691a6dcb25 | ||
|
|
979ea72f3a | ||
|
|
f9e40edb8a | ||
|
|
276bfee5ed | ||
|
|
f03c359f89 | ||
|
|
7db08a5cda | ||
|
|
b19f0da678 | ||
|
|
c87eef233c | ||
|
|
dabc40eb1e | ||
|
|
0efe2235f8 | ||
|
|
9d45993d0d | ||
|
|
99871fa299 | ||
|
|
248271355f | ||
|
|
79f9cb3e95 | ||
|
|
47c7ff04a9 | ||
|
|
f831f4d4e5 | ||
|
|
29799faa79 | ||
|
|
2727c9f4bb | ||
|
|
988cb65b51 | ||
|
|
4324932fc9 | ||
|
|
e9b49502ab | ||
|
|
d5519e7157 | ||
|
|
ed596c245d | ||
|
|
57c080d88b | ||
|
|
d795dbd788 | ||
|
|
218d433c14 | ||
|
|
48835f8830 | ||
|
|
9ed726d597 | ||
|
|
fcb52b9d9e | ||
|
|
dcd8f441f8 | ||
|
|
94ef3749f5 | ||
|
|
3398d18055 | ||
|
|
c64043316e | ||
|
|
7cdc542c19 | ||
|
|
78b9529699 | ||
|
|
8a31a58c56 | ||
|
|
103ce4774c | ||
|
|
4e6602d82f | ||
|
|
2eaf3c8f24 | ||
|
|
15465feda9 | ||
|
|
ea2ba2c7a0 | ||
|
|
0b136a830a | ||
|
|
3848ca6941 | ||
|
|
443e810471 | ||
|
|
198dcae3f8 | ||
|
|
7347c37a9e | ||
|
|
1d725d82ae | ||
|
|
b5c601f4a9 | ||
|
|
b449ea455a | ||
|
|
5bea2ea78c | ||
|
|
77fe2d37b3 | ||
|
|
65ad72223b | ||
|
|
d612b2d6af | ||
|
|
b5e20766a3 | ||
|
|
e5f5590a67 | ||
|
|
e83d913852 | ||
|
|
8280f7073d | ||
|
|
4f3752060d | ||
|
|
cb26e1dcb0 | ||
|
|
9eb08b45f4 | ||
|
|
3b4a808131 | ||
|
|
a8a19ca902 | ||
|
|
6967e230d0 | ||
|
|
6c111a4475 | ||
|
|
15278dfab2 | ||
|
|
0644783cc4 |
No files matched your search
@@ -0,0 +1,50 @@
|
|||||||
|
HOST=localhost
|
||||||
|
PORT=3000
|
||||||
|
BASE_URL=https://example.com
|
||||||
|
SECRET_KEY=YOU_SHOULD_CHANGE_THIS
|
||||||
|
BACKEND_URL=http://backend:3000
|
||||||
|
DOCKER_NETWORK=linux-odyssey-players
|
||||||
|
|
||||||
|
MONGO_ROOT_PASSWORD=adminpassword
|
||||||
|
MONGO_USER=commander
|
||||||
|
MONGO_PASS=commander
|
||||||
|
MONGO_DB=linux-odyssey
|
||||||
|
MONGO_URL=mongodb://commander:commander@db:27017/linux-odyssey
|
||||||
|
|
||||||
|
# Admin user credentials for analytics
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin
|
||||||
|
ADMIN_HOST=0.0.0.0
|
||||||
|
ADMIN_PORT=3001
|
||||||
|
# ADMIN_BASE_URL=http://0.0.0.0:3001
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
# CONTAINER_EXPIRY=60 # 60 minutes
|
||||||
|
# SESSION_MAX_AGE=168 # 7 days
|
||||||
|
# TRUSTED_PROXIES=1.2.3.4
|
||||||
|
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||||
|
|
||||||
|
# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5
|
||||||
|
# RATE_LIMIT_LOCKOUT_TIME=10
|
||||||
|
|
||||||
|
# GOOGLE_CLIENT_ID=
|
||||||
|
# GOOGLE_CLIENT_SECRET=
|
||||||
|
# GITHUB_CLIENT_ID=
|
||||||
|
# GITHUB_CLIENT_SECRET=
|
||||||
|
# SURVEY_URL=
|
||||||
|
# BUG_REPORT_URL=
|
||||||
|
|
||||||
|
# Docker related config
|
||||||
|
# DOCKER_NETWORK=host
|
||||||
|
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||||
|
# DOCKER_PREFIX=linuxodyssey/quest-
|
||||||
|
|
||||||
|
# To mount quest folder from host into container, for development purpose
|
||||||
|
# MOUNT_QUEST=true
|
||||||
|
|
||||||
|
# LOG_PATH=
|
||||||
|
|
||||||
|
# Testing, create a user with the following credentials, disable rate limiting
|
||||||
|
# TESTING=true
|
||||||
|
# TESTING_USERNAME=alex
|
||||||
|
# TESTING_PASSWORD=Alex1234
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
HOST=localhost
|
||||||
|
PORT=3000
|
||||||
|
BASE_URL=http://localhost:5173
|
||||||
|
MONGO_URL=mongodb://localhost:27017/odyssey-test
|
||||||
|
SECRET_KEY=sample-secret
|
||||||
|
DOCKER_NETWORK=linux-odyssey-players-dev
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
# BACKEND_URL=http://host.docker.internal:3000
|
||||||
|
# CONTAINER_EXPIRY=60 # 60 minutes
|
||||||
|
# SESSION_MAX_AGE=168 # 7 days
|
||||||
|
# TRUSTED_PROXIES=1.2.3.4
|
||||||
|
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||||
|
|
||||||
|
# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5
|
||||||
|
# RATE_LIMIT_LOCKOUT_TIME=10
|
||||||
|
|
||||||
|
# GOOGLE_CLIENT_ID=
|
||||||
|
# GOOGLE_CLIENT_SECRET=
|
||||||
|
# GITHUB_CLIENT_ID=
|
||||||
|
# GITHUB_CLIENT_SECRET=
|
||||||
|
# SURVEY_URL=
|
||||||
|
# BUG_REPORT_URL=
|
||||||
|
|
||||||
|
# Docker related config
|
||||||
|
# DOCKER_NETWORK=host
|
||||||
|
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||||
|
# DOCKER_PREFIX=linuxodyssey/quest-
|
||||||
|
|
||||||
|
# To mount quest folder from host into container, for development purpose
|
||||||
|
# MOUNT_QUEST=true
|
||||||
|
|
||||||
|
# LOG_PATH=
|
||||||
|
|
||||||
|
# Testing, create a user with the following credentials, disable rate limiting
|
||||||
|
# TESTING=true
|
||||||
|
# TESTING_USERNAME=alex
|
||||||
|
# TESTING_PASSWORD=Alex1234
|
||||||
+33
-7
@@ -20,9 +20,12 @@ module.exports = {
|
|||||||
settings: {
|
settings: {
|
||||||
'import/resolver': {
|
'import/resolver': {
|
||||||
node: {
|
node: {
|
||||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
extensions: ['.js', '.ts'],
|
||||||
|
moduleDirectory: ['node_modules', 'packages', 'server/src'],
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
alwaysTryTypes: true,
|
||||||
},
|
},
|
||||||
typescript: {},
|
|
||||||
},
|
},
|
||||||
vue: {
|
vue: {
|
||||||
version: '3.0', // Explicitly specify Vue 3
|
version: '3.0', // Explicitly specify Vue 3
|
||||||
@@ -49,7 +52,7 @@ module.exports = {
|
|||||||
|
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'error',
|
'warn',
|
||||||
{
|
{
|
||||||
args: 'after-used',
|
args: 'after-used',
|
||||||
argsIgnorePattern: '^_',
|
argsIgnorePattern: '^_',
|
||||||
@@ -63,21 +66,44 @@ module.exports = {
|
|||||||
|
|
||||||
// Allow missing file extensions
|
// Allow missing file extensions
|
||||||
'import/extensions': [
|
'import/extensions': [
|
||||||
'warn',
|
'off',
|
||||||
'ignorePackages',
|
'ignorePackages',
|
||||||
{
|
{
|
||||||
js: 'off',
|
js: 'never',
|
||||||
jsx: 'never',
|
jsx: 'never',
|
||||||
ts: 'never',
|
ts: 'never',
|
||||||
tsx: 'never',
|
tsx: 'never',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'no-unused-vars': 'warn',
|
||||||
|
'no-useless-constructor': 'off',
|
||||||
|
'@typescript-eslint/no-useless-constructor': 'error',
|
||||||
|
'no-empty-function': 'off',
|
||||||
|
'@typescript-eslint/no-empty-function': 'error',
|
||||||
|
'no-shadow': 'off',
|
||||||
|
'@typescript-eslint/no-shadow': 'error',
|
||||||
|
'no-restricted-imports': ['error', '../*'],
|
||||||
|
'import/no-relative-packages': 'off',
|
||||||
|
'no-await-in-loop': 'off',
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], // Adjust the pattern to match your test files
|
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'],
|
||||||
rules: {
|
rules: {
|
||||||
'import/extensions': 'off', // Turn off the rule for test files
|
'import/extensions': 'off',
|
||||||
|
'import/no-extraneous-dependencies': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['server/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'import/no-extraneous-dependencies': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/packages/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': 'off',
|
||||||
'import/no-extraneous-dependencies': 'off',
|
'import/no-extraneous-dependencies': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ on:
|
|||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
DOCKERHUB_USERNAME:
|
DOCKERHUB_USERNAME:
|
||||||
required: true
|
required: false
|
||||||
DOCKERHUB_TOKEN:
|
DOCKERHUB_TOKEN:
|
||||||
required: true
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@@ -42,6 +42,7 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
|
if: always()
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
@@ -53,5 +54,5 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ${{ inputs.dockerfilePath }}
|
file: ${{ inputs.dockerfilePath }}
|
||||||
target: ${{ inputs.target }}
|
target: ${{ inputs.target }}
|
||||||
push: true
|
push: ${{ secrets.DOCKERHUB_USERNAME != '' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -2,15 +2,6 @@ name: CI Checks
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
secrets:
|
|
||||||
GOOGLE_CLIENT_ID:
|
|
||||||
required: true
|
|
||||||
GOOGLE_CLIENT_SECRET:
|
|
||||||
required: true
|
|
||||||
GH_CLIENT_ID:
|
|
||||||
required: true
|
|
||||||
GH_CLIENT_SECRET:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
@@ -35,23 +26,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
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
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
|
||||||
@@ -12,7 +12,6 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
ci-checks:
|
ci-checks:
|
||||||
uses: ./.github/workflows/ci-checks.yml
|
uses: ./.github/workflows/ci-checks.yml
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: ci-checks
|
needs: ci-checks
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
ci-checks:
|
ci-checks:
|
||||||
uses: ./.github/workflows/ci-checks.yml
|
uses: ./.github/workflows/ci-checks.yml
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/build.yml
|
uses: ./.github/workflows/build.yml
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
ci-checks:
|
ci-checks:
|
||||||
uses: ./.github/workflows/ci-checks.yml
|
uses: ./.github/workflows/ci-checks.yml
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: ci-checks
|
needs: ci-checks
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
config
|
||||||
|
.quest
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -85,6 +88,7 @@ built
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.prod
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
.cache
|
.cache
|
||||||
|
|||||||
Vendored
+4
-1
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"editor.tabSize": 2,
|
"editor.tabSize": 2,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"yaml.schemas": {
|
||||||
|
"packages/game/quest.json": "quests/**/game.yml"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,15 @@
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- NodeJS
|
- NodeJS v20+
|
||||||
- Docker
|
- Docker
|
||||||
- Yarn
|
- Yarn: v1.22
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
- MongoDB requires x86 architecture to run for v5+ versions. You can track [this issue](https://github.com/linux-odyssey/linux-odyssey/issues/166) for updates.
|
||||||
|
|
||||||
|
Windows user please refer to [docs/windows-setup.md](docs/windows-setup.md) for setup instructions.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -15,30 +21,66 @@
|
|||||||
cd linux-odyssey
|
cd linux-odyssey
|
||||||
yarn install
|
yarn install
|
||||||
|
|
||||||
## Development
|
## Project Structures
|
||||||
|
|
||||||
Run mongoDB first:
|
1. **app**: frontend
|
||||||
|
2. **server**: backend
|
||||||
|
3. **quests**: tutorials scripts for each quest
|
||||||
|
4. **packages**: other packages
|
||||||
|
1. **analytics**: admin dashboard to analyze collected data
|
||||||
|
2. **constants**: some shared config values for app and server
|
||||||
|
3. **container**: the script run in container to capture users' command
|
||||||
|
4. **file-graph**: the algorithm to construct and modify file graph, used in both app and server
|
||||||
|
5. **models**: definitions of MondoDB collections (data schema)
|
||||||
|
6. **utils**: other utility code shared between packages
|
||||||
|
7. **game (in the future)**: core logic of game playing, will be extracted from server
|
||||||
|
|
||||||
docker compose -f docker-compose.dev.yml up -d db
|
## Configuration
|
||||||
|
|
||||||
The db will automatically restarts by Docker.
|
The configuration is stored in the `.env` file. You can copy from the sample file.
|
||||||
|
|
||||||
Build up dependencies:
|
cp .env.sample .env
|
||||||
|
|
||||||
yarn build:deps
|
The backend will use a SSH key pair to login to the player's container. The private key is on the host, and the public key is copied to the player's container.
|
||||||
|
|
||||||
Run dev server (frontend, backend):
|
The key pair is stored in `./config/ssh_key` and `./config/ssh_key.pub`. If it doesn't exist, the backend will generate a new pair and store them.
|
||||||
|
|
||||||
yarn dev
|
## Docker Setup
|
||||||
|
|
||||||
If you want to enable social login, you should have OAuth client token in a `.env` file:
|
### Docker compose files explaination
|
||||||
|
|
||||||
### Build Quest Images
|
1. **docker-compose.yml**: The one used for development. It sets up MongoDB and builds base quest image.
|
||||||
|
2. **docker-compose.prod.yml**: The one used for production deployment. It has a Nginx service, a backend, a database, an analytics service, and creates isolated networks for player containers.
|
||||||
|
3. **docker-compose.testing.yml**: Similar to production config, but sets up an one-time environment for end-to-end testing.
|
||||||
|
|
||||||
In each quest folder, there is a `game.yml` file, which is the quest definition. And a optional `Dockerfile` to overwrite the base image. If you want to include additional files, put them in a `home` folder. The files in `home` will **not** be copied to the image automatically, you have to copy them in the `Dockerfile`.
|
### Common Development Setup
|
||||||
|
|
||||||
|
This sets up a development database, and running development server for frontend and backend.
|
||||||
|
|
||||||
|
Pull the latest images:
|
||||||
|
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
Start the containers:
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
This will start the db. it will automatically restarts by Docker.
|
||||||
|
|
||||||
|
Building quest images:
|
||||||
|
|
||||||
|
docker compose build base
|
||||||
|
yarn build:quests
|
||||||
|
|
||||||
|
### Build Quest Images in Details
|
||||||
|
|
||||||
|
In each quest folder, there is a `game.yml` file, which is the quest definition.
|
||||||
|
|
||||||
|
If you want to include additional files, put them in a `home` folder. The files in `home` will be copied to the user's home on container creation automatically.
|
||||||
|
|
||||||
quests/
|
quests/
|
||||||
├── Dockerfile # Base image
|
├── Dockerfile # Base image
|
||||||
|
├── entrypoint.sh # Entrypoint for the container
|
||||||
├── discover
|
├── discover
|
||||||
│ └── game.yml
|
│ └── game.yml
|
||||||
├── helloworld
|
├── helloworld
|
||||||
@@ -54,14 +96,49 @@ In each quest folder, there is a `game.yml` file, which is the quest definition.
|
|||||||
└── home
|
└── home
|
||||||
└── ancient_scroll.txt
|
└── ancient_scroll.txt
|
||||||
|
|
||||||
|
An optional `Dockerfile` can overwrite the base image. You can use this to install additional dependencies or modify the environment.
|
||||||
|
|
||||||
|
Customized `entrypoint.sh` for each quest is not yet supported.
|
||||||
|
|
||||||
To build all quests:
|
To build all quests:
|
||||||
|
|
||||||
yarn build:quests
|
yarn build:quests
|
||||||
|
|
||||||
The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests.
|
The base image is built with docker-compose, so you can use `docker compose build base` to rebuild it. Once you rebuild the base image, you have to rebuild all quests.
|
||||||
|
|
||||||
|
### Optional: Mounting Host Directory
|
||||||
|
|
||||||
|
To improve the development experience, we can mount the host directory to the container in the pattern:
|
||||||
|
|
||||||
|
/path/to/linux-odyssey/quests/${quest_id}/home:/home/commander
|
||||||
|
/path/to/linux-odyssey/packages/container:/usr/local/lib/container
|
||||||
|
|
||||||
|
You can edit the files on your host, and the changes will be reflected in the container. This is disabled by default. To enable it, you can set the following environment variables:
|
||||||
|
|
||||||
|
MOUNT_QUEST=true
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Copy `.env.sample` to `.env` and customize it for your needs.
|
||||||
|
|
||||||
|
Run dev server (frontend, backend):
|
||||||
|
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
**IMPORTANT NOTE**: only the app and server have hot-reloading, other packages require manually running `yarn dev` in each package.
|
||||||
|
|
||||||
|
To develop analytics dashboard:
|
||||||
|
|
||||||
|
yarn analytics
|
||||||
|
|
||||||
|
The default username and password is `alex` and `Alex1234`.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
Unit tests:
|
||||||
|
|
||||||
|
yarn test:unit
|
||||||
|
|
||||||
Run Cypress locally, using the current development containers:
|
Run Cypress locally, using the current development containers:
|
||||||
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -78,76 +155,37 @@ Run Cypress in container, create brand-new containers along with it:
|
|||||||
# tearing off the containers
|
# tearing off the containers
|
||||||
./docker-scripts.sh down
|
./docker-scripts.sh down
|
||||||
|
|
||||||
### Deployment
|
## Deployment
|
||||||
|
|
||||||
|
Create production env
|
||||||
|
|
||||||
|
cp .env.prod.sample .env.prod
|
||||||
|
|
||||||
|
Edit `.env.prod`
|
||||||
|
|
||||||
|
BASE_URL=https://yourdomain.com # IMPORTANT to make login work
|
||||||
|
SECRET_KEY=xxxx # Change it to something safe!
|
||||||
|
|
||||||
|
Start the service
|
||||||
|
|
||||||
|
# Build the images (only allowed for maintainers)
|
||||||
docker compose -f docker-compose.prod.yml build
|
docker compose -f docker-compose.prod.yml build
|
||||||
docker compose -f docker-compose.prod.yml push
|
docker compose -f docker-compose.prod.yml push
|
||||||
|
|
||||||
# Server side
|
# Server side
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
yarn build:quests
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
### Necessary Configuration
|
||||||
|
|
||||||
To enable social login, you should have OAuth client token in a `.env` file:
|
To enable social login, you should have OAuth client token in a `.env` file:
|
||||||
You can copy from `example.env`
|
You can copy from `.env.sample`
|
||||||
|
|
||||||
# .env
|
**YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
|
||||||
BASE_URL=https://example.com
|
|
||||||
GOOGLE_CLIENT_ID=...
|
|
||||||
GOOGLE_CLIENT_SECRET=...
|
|
||||||
|
|
||||||
To enable it:
|
SECRET_KEY=your-secret-key
|
||||||
|
|
||||||
docker compose --env-file .env -f docker-compose.prod.yml up -d
|
You can generate a random key by running
|
||||||
|
|
||||||
## Standalone App (without Docker)
|
yarn setup
|
||||||
|
|
||||||
Source: `app/`
|
|
||||||
|
|
||||||
# Connect to dev server (https://odyssey.wancat.cc)
|
|
||||||
yarn app
|
|
||||||
|
|
||||||
# Connect to localhost (http://localhost:3000)
|
|
||||||
yarn app:local
|
|
||||||
|
|
||||||
# Connect to anyhost
|
|
||||||
API_ENDPOINT=http://example.com yarn app:local
|
|
||||||
|
|
||||||
## Swagger
|
|
||||||
|
|
||||||
docker compose up -d swagger
|
|
||||||
|
|
||||||
Open http://localhost:8080 to open Swagger
|
|
||||||
|
|
||||||
## CLI terminal client (Docker not required)
|
|
||||||
|
|
||||||
yarn cli
|
|
||||||
Usage: client [options] [command]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-s, --session <string> Session ID
|
|
||||||
-c, --create Create a new session
|
|
||||||
-h, --host <string> Server host (default: "http://localhost:3000")
|
|
||||||
--help display help for command
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
list List all sessions
|
|
||||||
|
|
||||||
connet to recent session:
|
|
||||||
|
|
||||||
yarn cli
|
|
||||||
|
|
||||||
connect to new session:
|
|
||||||
|
|
||||||
yarn cli -c
|
|
||||||
|
|
||||||
connect to specific session:
|
|
||||||
|
|
||||||
yarn cli -s 64d0de0367459c13004bc83f
|
|
||||||
|
|
||||||
connect to other host:
|
|
||||||
|
|
||||||
yarn cli -h https://example.com
|
|
||||||
|
|
||||||
list sessions:
|
|
||||||
|
|
||||||
yarn cli list
|
|
||||||
+6
-17
@@ -1,26 +1,15 @@
|
|||||||
# Base stage
|
# Base stage
|
||||||
FROM node:20-alpine as base
|
FROM node:20-alpine as build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json yarn.lock tsconfig.json ./
|
COPY package.json yarn.lock ./
|
||||||
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
|
||||||
COPY packages/utils/package.json ./packages/utils/package.json
|
|
||||||
COPY packages/constants/package.json ./packages/constants/package.json
|
|
||||||
COPY packages/models/package.json ./packages/models/package.json
|
|
||||||
COPY app/package.json ./app/package.json
|
COPY app/package.json ./app/package.json
|
||||||
|
COPY server/package.json ./server/package.json
|
||||||
RUN yarn install --frozen-lockfile
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
# Dev stage
|
COPY tsconfig.json ./tsconfig.json
|
||||||
FROM base as dev
|
COPY server ./server
|
||||||
WORKDIR /app/app
|
COPY packages ./packages
|
||||||
CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
|
|
||||||
|
|
||||||
# Build stage
|
|
||||||
FROM base as build
|
|
||||||
COPY packages/file-graph ./packages/file-graph
|
|
||||||
COPY packages/utils ./packages/utils
|
|
||||||
COPY packages/constants ./packages/constants
|
|
||||||
COPY packages/models ./packages/models
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
RUN yarn workspace app build
|
RUN yarn workspace app build
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -5,24 +5,23 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"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 && vite build",
|
||||||
|
"type-check": "vue-tsc",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formbricks/js": "^2.1.1",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||||
"@linux-odyssey/constants": "*",
|
"@trpc/client": "^11.1.0",
|
||||||
"@linux-odyssey/file-graph": "*",
|
|
||||||
"@linux-odyssey/utils": "*",
|
|
||||||
"@linux-odyssey/models": "*",
|
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.8.2",
|
||||||
"dompurify": "^3.0.6",
|
"dompurify": "^3.2.4",
|
||||||
"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",
|
||||||
@@ -35,13 +34,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/validator": "^13.11.9",
|
"@types/validator": "^13.11.9",
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
|
||||||
"@vue/compiler-sfc": "^3.3.13",
|
"@vue/compiler-sfc": "^3.3.13",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^5.3.3",
|
"@vitejs/plugin-vue": "^5.2.2",
|
||||||
"vite": "^4.5.3",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"vue-tsc": "^2.0.10"
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.3.1",
|
||||||
|
"vue-tsc": "^2.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File renamed without changes.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -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>
|
||||||
@@ -20,7 +20,21 @@ function nodeClass() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a href="#" :class="nodeClass()">{{ node.discovered ? node.name : '???' }}</a>
|
<a href="#" :class="nodeClass()">
|
||||||
|
<font-awesome-icon
|
||||||
|
v-if="node.type === 'directory'"
|
||||||
|
:icon="['far', 'folder']"
|
||||||
|
class="text-text-primary size-8"
|
||||||
|
/>
|
||||||
|
<font-awesome-icon
|
||||||
|
v-else
|
||||||
|
:icon="['far', 'file']"
|
||||||
|
class="text-text-primary size-8"
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
{{ node.discovered ? node.name : '???' }}
|
||||||
|
{{ node.name === '' && node.discovered ? '/' : '' }}
|
||||||
|
</a>
|
||||||
<ul :class="nodeClass()" v-if="node.children && node.children.length > 0">
|
<ul :class="nodeClass()" v-if="node.children && node.children.length > 0">
|
||||||
<li :class="nodeClass()" v-for="child in node.children" :key="child.path">
|
<li :class="nodeClass()" v-for="child in node.children" :key="child.path">
|
||||||
<FileNode :node="child" :pwd="pwd" />
|
<FileNode :node="child" :pwd="pwd" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -25,14 +25,37 @@ const html = computed(() => {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-content :deep(p) {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
.markdown-content :deep(ol) {
|
.markdown-content :deep(ol) {
|
||||||
list-style: decimal;
|
list-style: decimal;
|
||||||
padding-left: 1em;
|
padding-left: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.markdown-content :deep(code) {
|
.markdown-content :deep(ul) {
|
||||||
background-color: #282a33;
|
list-style: disc;
|
||||||
|
padding-left: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.markdown-content :deep(pre) {
|
||||||
|
background-color: #2e3440;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #d8dee9;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 1px solid #4c566a;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-family: 'monospace';
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content :deep(code:not(pre code)) {
|
||||||
|
background-color: #2e3440;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
padding: 0.25rem;
|
padding: 0.2rem 0.4rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.9rem;
|
||||||
|
color: #d8dee9;
|
||||||
|
font-family: 'monospace';
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { ref, defineProps, onMounted } from 'vue'
|
||||||
|
import MarkdownText from './MarkdownText.vue'
|
||||||
|
import { trpc } from '../utils/trpc'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
questTitle: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
questId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
questUnlocked: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['closeIntro'])
|
||||||
|
const showIntro = ref(true) // Controls whether the modal is visible or not
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { questTitle, questId, questUnlocked } = props
|
||||||
|
const questInstruction = ref('')
|
||||||
|
const questColor = ref('')
|
||||||
|
const questTextColor = ref('')
|
||||||
|
const closeModal = () => {
|
||||||
|
showIntro.value = false
|
||||||
|
emit('closeIntro', true)
|
||||||
|
router.push({ name: 'map' })
|
||||||
|
}
|
||||||
|
const handleQuests = (id: string) => {
|
||||||
|
if (questUnlocked) {
|
||||||
|
router.push({ name: 'game', params: { questId: id } })
|
||||||
|
} else {
|
||||||
|
toast.warning('你還沒完成前一個關卡!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleColour = () => {
|
||||||
|
if (questUnlocked) {
|
||||||
|
questColor.value = '#00ff00'
|
||||||
|
questTextColor.value = '#000000'
|
||||||
|
} else {
|
||||||
|
questColor.value = '#505050'
|
||||||
|
questTextColor.value = '#a0a0a0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(async () => {
|
||||||
|
const quest = await trpc.quests.getQuestDetail.query(questId)
|
||||||
|
questInstruction.value = quest.instruction
|
||||||
|
handleColour()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="showIntro" class="modal">
|
||||||
|
<div class="modal-content bg-bg-secondary rounded-lg">
|
||||||
|
<span class="close text-text content-between" @click="closeModal"
|
||||||
|
>×</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
id="QuestTitle"
|
||||||
|
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
|
||||||
|
>
|
||||||
|
{{ questTitle }}
|
||||||
|
</h2>
|
||||||
|
<div class="w-full h-3/5 p-8">
|
||||||
|
<div class="px-5 whitespace-pre-wrap">
|
||||||
|
<MarkdownText
|
||||||
|
class="text-text markdown-content"
|
||||||
|
:content="questInstruction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
id="StartAdventure"
|
||||||
|
target="_blank"
|
||||||
|
@click="handleQuests(questId)"
|
||||||
|
:style="{ backgroundColor: questColor, color: questTextColor }"
|
||||||
|
class="inline-flex justify-center rounded-lg text-base font-black py-3 mt-5 w-full text-lg"
|
||||||
|
>開始冒險</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
position: relative;
|
||||||
|
margin: 10% auto;
|
||||||
|
padding: 20px;
|
||||||
|
width: 60%;
|
||||||
|
height: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 10px;
|
||||||
|
font-size: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.markdown-content {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+236
-171
@@ -1,189 +1,254 @@
|
|||||||
<!-- eslint-disable no-console -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { use, init } from 'echarts/core'
|
import { DAG } from '../../../packages/utils'
|
||||||
import { GraphChart } from 'echarts/charts'
|
import { trpc } from '../utils/trpc'
|
||||||
import { SVGRenderer } from 'echarts/renderers'
|
import useUserProfile from '../store/userProfile'
|
||||||
import {
|
import QuestIntro from './QuestIntro.vue'
|
||||||
TitleComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
DataZoomComponent,
|
|
||||||
} from 'echarts/components'
|
|
||||||
import { DAG } from '@linux-odyssey/utils'
|
|
||||||
import api from '../utils/api'
|
|
||||||
import { NodeImage } from '../img/svg.js'
|
|
||||||
|
|
||||||
const marginX = 500
|
const store = useUserProfile()
|
||||||
const marginY = 300
|
|
||||||
const chartContainer = ref(null)
|
|
||||||
let chartInstance = null
|
|
||||||
const fullwidth = window.screen.width
|
|
||||||
|
|
||||||
use([
|
|
||||||
GraphChart,
|
|
||||||
SVGRenderer,
|
|
||||||
TitleComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
DataZoomComponent,
|
|
||||||
])
|
|
||||||
|
|
||||||
async function getQuests() {
|
|
||||||
try {
|
|
||||||
const res = await api.get('/quests')
|
|
||||||
return res.data
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => ({
|
|
||||||
renderer: 'svg',
|
|
||||||
tooltip: {},
|
|
||||||
animationDurationUpdate: 1500,
|
|
||||||
animationEasingUpdate: 'quinticInOut',
|
|
||||||
// dataZoom: [
|
|
||||||
// {
|
|
||||||
// type: 'slider', // This type means "slider data zoom" which provides a slider bar for zooming
|
|
||||||
// orient: 'vertical', // This makes the data zoom component vertical
|
|
||||||
// start: 0, // The starting percentage of the window out of the data extent
|
|
||||||
// end: 100, // The ending percentage of the window out of the data extent
|
|
||||||
// show: true,
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'graph',
|
|
||||||
layout: 'none',
|
|
||||||
symbol: () => {
|
|
||||||
return NodeImage
|
|
||||||
},
|
|
||||||
symbolSize: [fullwidth / 13, fullwidth / 30],
|
|
||||||
roam: 'move',
|
|
||||||
zoom: 1.5,
|
|
||||||
center: ['120%', '30%'],
|
|
||||||
label: {
|
|
||||||
show: true,
|
|
||||||
fontSize: fullwidth / 90,
|
|
||||||
},
|
|
||||||
edgeSymbol: ['pin', 'arrow'],
|
|
||||||
edgeSymbolSize: [2, 15],
|
|
||||||
edgeLabel: {
|
|
||||||
fontSize: 25,
|
|
||||||
},
|
|
||||||
data: nodes,
|
|
||||||
// links: [],
|
|
||||||
links: edges,
|
|
||||||
lineStyle: {
|
|
||||||
width: 3,
|
|
||||||
color: 'gray',
|
|
||||||
opacity: 4,
|
|
||||||
curveness: 0.08,
|
|
||||||
},
|
|
||||||
itemStyle: {
|
|
||||||
color: (params: any) => {
|
|
||||||
const {
|
|
||||||
data: { completed, unlocked },
|
|
||||||
} = params
|
|
||||||
if (completed) {
|
|
||||||
return '#00ff00'
|
|
||||||
}
|
|
||||||
if (unlocked) {
|
|
||||||
return '#ADADB5'
|
|
||||||
}
|
|
||||||
return '#505050'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
function getOption(quests: any, progress: any) {
|
|
||||||
const dag = new DAG(quests)
|
|
||||||
|
|
||||||
const nodes = dag.getNodes().map((node) => ({
|
|
||||||
id: node._id,
|
|
||||||
name: node.title,
|
|
||||||
y: marginX * node.index - (marginX * dag.getLayer(node._id)) / 2,
|
|
||||||
x: marginY * node.layer * 2,
|
|
||||||
completed: progress[node._id]?.completed || false,
|
|
||||||
unlocked: node.requirements.every((req: any) => progress[req]?.completed),
|
|
||||||
}))
|
|
||||||
const lineAppearence = (node: any) => {
|
|
||||||
try {
|
|
||||||
const { unlocked } = node
|
|
||||||
return {
|
|
||||||
type: unlocked ? 'line' : 'dashed',
|
|
||||||
color: unlocked ? '#ADADB5' : '#454552',
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const edges = dag.getEdgesArray().map((edge: any) => ({
|
|
||||||
source: edge[0],
|
|
||||||
target: edge[1],
|
|
||||||
lineStyle: lineAppearence(nodes.find((node) => node.id === edge[1])),
|
|
||||||
}))
|
|
||||||
return genOption(nodes, edges)
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
function initChart(option: any) {
|
const svgWidth = ref(window.innerWidth)
|
||||||
chartInstance = init(chartContainer.value)
|
const svgHeight = ref(window.innerHeight)
|
||||||
chartInstance.setOption(option)
|
|
||||||
chartInstance.on('click', (params: any) => {
|
|
||||||
const {
|
|
||||||
data: { id, unlocked },
|
|
||||||
} = params
|
|
||||||
|
|
||||||
if (unlocked) {
|
const marginX = 120
|
||||||
router.push({ name: 'game', params: { questId: id } })
|
const marginY = 120
|
||||||
} else {
|
|
||||||
toast.warning('你還沒完成前一個關卡!')
|
const nodeWidth = 160
|
||||||
}
|
const nodeHeight = 50
|
||||||
})
|
const opened = ref<Node | null>(null)
|
||||||
|
type Node = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
index: number
|
||||||
|
completed: boolean
|
||||||
|
unlocked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Edge = {
|
||||||
|
source: Node
|
||||||
|
target: Node
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphData = ref<{ nodes: Node[]; edges: Edge[] }>({
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStartX = ref(0)
|
||||||
|
const dragStartY = ref(0)
|
||||||
|
const offsetX = ref(0)
|
||||||
|
const offsetY = ref(0)
|
||||||
|
|
||||||
|
function startDrag(event: MouseEvent) {
|
||||||
|
isDragging.value = true
|
||||||
|
dragStartX.value = event.clientX - offsetX.value
|
||||||
|
dragStartY.value = event.clientY - offsetY.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag(event: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
offsetX.value = event.clientX - dragStartX.value
|
||||||
|
offsetY.value = event.clientY - dragStartY.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeGraphData() {
|
||||||
|
if (!store.progress) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const quests = await trpc.quests.getQuests.query()
|
||||||
|
const dag = new DAG(quests)
|
||||||
|
const nodesValues = dag.getNodes()
|
||||||
|
|
||||||
|
const nodes = nodesValues.map((node) => ({
|
||||||
|
id: node.id,
|
||||||
|
title: node.title,
|
||||||
|
x: marginX * node.layer * 2,
|
||||||
|
y:
|
||||||
|
marginY * node.index -
|
||||||
|
(marginX * dag.getLayer(node.id)) / 2 +
|
||||||
|
svgHeight.value / 2,
|
||||||
|
completed: store.progress[node.id]?.completed || false,
|
||||||
|
unlocked: node.requirements.every(
|
||||||
|
(req: string) => store.progress[req]?.completed
|
||||||
|
),
|
||||||
|
index: node.index,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const edges = dag
|
||||||
|
.getEdgesArray()
|
||||||
|
.map((edge) => ({
|
||||||
|
source: nodes.find((n) => n.id === edge[0]),
|
||||||
|
target: nodes.find((n) => n.id === edge[1]),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(edge): edge is Edge =>
|
||||||
|
edge.source !== undefined && edge.target !== undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
graphData.value = { nodes, edges }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error computing graph data:', error)
|
||||||
|
toast.error('Failed to load quest data')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleNodeClick(node: Node) {
|
||||||
|
opened.value = node
|
||||||
|
}
|
||||||
|
function closeIntro(close: boolean) {
|
||||||
|
if (close) {
|
||||||
|
opened.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const quests = await getQuests()
|
await store.loadUserProfile()
|
||||||
const progress = await getProgress()
|
await computeGraphData()
|
||||||
const option = getOption(quests, progress)
|
})
|
||||||
initChart(option)
|
|
||||||
|
watch(() => store.progress, computeGraphData, { deep: true })
|
||||||
|
|
||||||
|
const curvedPath = computed(() => {
|
||||||
|
return (source: Node, target: Node) => {
|
||||||
|
// const dx = target.x - source.x
|
||||||
|
// const dy = target.y - source.y
|
||||||
|
// const dr = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
const dr = 0
|
||||||
|
const sweep = target.index > source.index ? 0 : 1 // Determines if the arc should curve up or down based on node index
|
||||||
|
return `M${source.x},${source.y}A${dr},${dr} 0 0,${sweep} ${target.x},${target.y}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodeStyle = computed(() => {
|
||||||
|
return (node: Node) => {
|
||||||
|
if (node.completed) return 'completed'
|
||||||
|
if (node.unlocked) return 'unlocked'
|
||||||
|
return 'locked'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const edgeStyle = computed(() => {
|
||||||
|
return (edge: Edge) => {
|
||||||
|
return edge.target.unlocked ? 'unlocked' : 'locked'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative bg-black w-full h-full flex">
|
<div class="relative bg-black w-full h-full flex">
|
||||||
<img
|
<div
|
||||||
src="../img/catelogbg.svg"
|
class="flex place-content-center w-full bg-catelogbg bg-cover bg-scroll"
|
||||||
alt="bg"
|
|
||||||
class="p-2 stroke-2 scale-y-120 justify-center items-center"
|
|
||||||
/>
|
|
||||||
<h1
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
踏上你的Linux冒險之旅吧!
|
<h1
|
||||||
</h1>
|
class="p-10 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
|
||||||
<div class="flex flex-wrap absolute w-full h-full z-1">
|
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
|
||||||
<div ref="chartContainer" class="w-full h-full flex flex-wrap"></div>
|
>
|
||||||
|
踏上你的Linux冒險之旅吧!
|
||||||
|
</h1>
|
||||||
|
<svg
|
||||||
|
:width="svgWidth"
|
||||||
|
:height="svgHeight"
|
||||||
|
class="absolute"
|
||||||
|
@mousedown="startDrag"
|
||||||
|
@mousemove="drag"
|
||||||
|
@mouseup="endDrag"
|
||||||
|
@mouseleave="endDrag"
|
||||||
|
>
|
||||||
|
<g :transform="`translate(${offsetX}, ${offsetY})`">
|
||||||
|
<g
|
||||||
|
v-for="edge in graphData.edges"
|
||||||
|
:key="`${edge.source.id}-${edge.target.id}`"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
:d="curvedPath(edge.source, edge.target)"
|
||||||
|
:class="['edge', edgeStyle(edge)]"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
v-for="node in graphData.nodes"
|
||||||
|
:key="node.id"
|
||||||
|
@click="handleNodeClick(node)"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
:x="node.x - nodeWidth / 2"
|
||||||
|
:y="node.y - nodeHeight / 2"
|
||||||
|
:width="nodeWidth"
|
||||||
|
:height="nodeHeight"
|
||||||
|
rx="10"
|
||||||
|
ry="10"
|
||||||
|
:class="['node', nodeStyle(node)]"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
:x="node.x"
|
||||||
|
:y="node.y"
|
||||||
|
text-anchor="middle"
|
||||||
|
alignment-baseline="middle"
|
||||||
|
:class="['node-text', nodeStyle(node)]"
|
||||||
|
font-size="18"
|
||||||
|
>
|
||||||
|
{{ node.title }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<QuestIntro
|
||||||
|
:questTitle="opened.title"
|
||||||
|
:questId="opened.id"
|
||||||
|
:questUnlocked="opened.unlocked"
|
||||||
|
v-if="opened"
|
||||||
|
@close-intro="closeIntro"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
svg {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
&.completed {
|
||||||
|
fill: #6cf76c;
|
||||||
|
}
|
||||||
|
&.unlocked {
|
||||||
|
fill: #8c8c92;
|
||||||
|
}
|
||||||
|
&.locked {
|
||||||
|
fill: #505050;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-text {
|
||||||
|
&.completed {
|
||||||
|
fill: #1d1d1d;
|
||||||
|
}
|
||||||
|
&.unlocked {
|
||||||
|
fill: #ffffff;
|
||||||
|
}
|
||||||
|
&.locked {
|
||||||
|
fill: #a0a0a0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge {
|
||||||
|
stroke-width: 3;
|
||||||
|
fill: none;
|
||||||
|
&.unlocked {
|
||||||
|
stroke: #adadb5;
|
||||||
|
stroke-dasharray: none;
|
||||||
|
}
|
||||||
|
&.locked {
|
||||||
|
stroke: #454552;
|
||||||
|
stroke-dasharray: 5, 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,9 +9,11 @@ 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()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const defineComponent = () => ({
|
const defineComponent = () => ({
|
||||||
components: {
|
components: {
|
||||||
Vue3Lottie,
|
Vue3Lottie,
|
||||||
@@ -22,6 +24,7 @@ const defineComponent = () => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const backtoMap = async () => {
|
const backtoMap = async () => {
|
||||||
try {
|
try {
|
||||||
router.push({ name: 'map' })
|
router.push({ name: 'map' })
|
||||||
@@ -67,6 +70,21 @@ setTimeout(() => {
|
|||||||
>回到地圖</a
|
>回到地圖</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="position: relative; height: 100vh; overflow: auto">
|
||||||
|
<iframe
|
||||||
|
src="https://app.formbricks.com/s/clyffdm0c0be2wh6335clle0l"
|
||||||
|
frameborder="0"
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { openBugReport, openQuestSurvey } from '../../utils/formbricks'
|
||||||
|
|
||||||
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,31 +21,39 @@ const continuePlay = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section class="mt-2 flex flex-row gap-3 justify-center">
|
||||||
class="h-fit w-fit px-1 py-1 grid grid-cols-4 gap-2 place-content-between"
|
<button
|
||||||
>
|
id="survey"
|
||||||
<button id="survey" class="bg-bg-secondary rounded-lg p-2">
|
class="bg-bg-secondary rounded-lg p-2"
|
||||||
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
|
@click="openQuestSurvey"
|
||||||
<font-awesome-icon
|
>
|
||||||
:icon="['fas', 'file-invoice']"
|
<font-awesome-icon
|
||||||
class="text-text-primary px-1"
|
:icon="['fas', 'file-invoice']"
|
||||||
/>
|
class="text-text-primary px-1"
|
||||||
<p class="text-text-primary inline ml-1 hidden md:flex">問卷</p>
|
/>
|
||||||
</a>
|
<p class="text-text-primary inline md:flex">問卷</p>
|
||||||
</button>
|
</button>
|
||||||
<button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
|
<button
|
||||||
|
title="Bug Report"
|
||||||
|
@click="openBugReport"
|
||||||
|
class="bg-bg-secondary rounded-lg p-2"
|
||||||
|
>
|
||||||
|
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
|
||||||
|
<p class="text-text-primary inline ml-1 md:flex">回報</p>
|
||||||
|
</button>
|
||||||
|
<!-- <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 px-1"
|
class="text-text-disabled px-1"
|
||||||
/>
|
/>
|
||||||
<p class="text-text-disabled inline ml-1 hidden md:flex">解答</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 px-1"
|
class="text-text-primary px-1"
|
||||||
/>
|
/>
|
||||||
<p class="text-text-primary inline ml-1 hidden md:flex">重來</p>
|
<p class="text-text-primary inline ml-1 md:flex">重來</p>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="continue"
|
id="continue"
|
||||||
@@ -55,7 +64,7 @@ const continuePlay = async () => {
|
|||||||
:icon="['far', 'circle-right']"
|
:icon="['far', 'circle-right']"
|
||||||
class="text-text-primary px-1"
|
class="text-text-primary px-1"
|
||||||
/>
|
/>
|
||||||
<p class="text-text-primary inline ml-1 hidden md:flex">繼續</p>
|
<p class="text-text-primary inline ml-1 md:flex">繼續</p>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
+44
-21
@@ -1,16 +1,23 @@
|
|||||||
<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 hintDetails = ref<HTMLDetailsElement | null>(null)
|
||||||
() => sessionStore.session.hints,
|
const length = computed(() => {
|
||||||
(hints) => {
|
return sessionStore.session?.responses?.length ?? 0
|
||||||
current.value = hints.length - 1
|
})
|
||||||
},
|
|
||||||
{ deep: true }
|
const disabled = computed(() => {
|
||||||
)
|
return current.value === length.value - 1
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(length, () => {
|
||||||
|
console.log('length update', length.value)
|
||||||
|
current.value = length.value - 1
|
||||||
|
})
|
||||||
|
|
||||||
const left = () => {
|
const left = () => {
|
||||||
if (current.value > 0) {
|
if (current.value > 0) {
|
||||||
@@ -19,10 +26,23 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = computed(() => {
|
||||||
|
if (!sessionStore.session) return null
|
||||||
|
if (current.value < 0) return null
|
||||||
|
if (current.value >= sessionStore.session.responses.length) return null
|
||||||
|
return sessionStore.session.responses[current.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(response, () => {
|
||||||
|
if (hintDetails.value) {
|
||||||
|
hintDetails.value.open = false
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -32,7 +52,7 @@ const right = () => {
|
|||||||
: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>
|
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">說明</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"
|
||||||
@@ -49,35 +69,38 @@ 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="disabled ? 'text-gray-600' : 'text-text'"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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-if="response"
|
||||||
:key="response.type"
|
:key="response.type"
|
||||||
class="text-text font-xl whitespace-pre-wrap"
|
class="text-text font-xl whitespace-pre-wrap"
|
||||||
>
|
>
|
||||||
<div v-for="content in response.content" :key="content">
|
<MarkdownText :content="response.content" />
|
||||||
<MarkdownText :content="content" />
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
v-for="hint in sessionStore.session.hints[current]"
|
v-if="response?.hint"
|
||||||
:key="hint"
|
|
||||||
class="text-text-primary font-xl whitespace-pre-wrap"
|
class="text-text-primary font-xl whitespace-pre-wrap"
|
||||||
>
|
>
|
||||||
<MarkdownText :content="hint" />
|
<details ref="hintDetails">
|
||||||
|
<summary class="cursor-pointer">提示</summary>
|
||||||
|
<MarkdownText :content="response.hint" />
|
||||||
|
</details>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<br />
|
<br />
|
||||||
@@ -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'
|
|
||||||
|
|
||||||
|
const sessionStore = useSession()
|
||||||
const tasks = computed(() => {
|
const tasks = computed(() => {
|
||||||
return sessionStore.session.tasks.map((task: Task) => {
|
console.log('session', sessionStore.session)
|
||||||
|
return sessionStore.session?.tasks.map((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 {
|
||||||
|
|||||||
@@ -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,23 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, onUnmounted, 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)
|
||||||
|
const term = useTerminal()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const term = useTerminal()
|
|
||||||
if (!terminal.value) return
|
if (!terminal.value) return
|
||||||
term.mount(terminal.value)
|
term.mount(terminal.value)
|
||||||
window.addEventListener('resize', () => term.resizeScreen())
|
|
||||||
|
const handleResize = () => term.resizeScreen()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
|
<div class="flex flex-col h-full">
|
||||||
<button id="Terminal" class="text-text h-[5%]">終端機</button>
|
<div class="flex items-center gap-2 p-1">
|
||||||
<div
|
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text" />
|
||||||
id="terminal"
|
<button id="Terminal" class="text-text">終端機</button>
|
||||||
ref="terminal"
|
</div>
|
||||||
class="bg-bg-secondary h-[calc(100%-5%)] p-0.5"
|
<div
|
||||||
></div>
|
id="terminal"
|
||||||
|
ref="terminal"
|
||||||
|
class="bg-bg-secondary flex-grow overflow-hidden px-2"
|
||||||
|
style="height: calc(100vh - 40px)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
@@ -45,14 +48,14 @@ import sessionStore from '../../store/session'
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 50%;
|
right: 50%;
|
||||||
border-top: 1px solid #00ff00;
|
border-top: 2px solid #00ff00;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
.tree li::after {
|
.tree li::after {
|
||||||
right: auto;
|
right: auto;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
border-left: 1px solid #00ff00;
|
border-left: 2px solid #00ff00;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree li:only-child::after,
|
.tree li:only-child::after,
|
||||||
@@ -70,7 +73,7 @@ import sessionStore from '../../store/session'
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tree li:last-child::before {
|
.tree li:last-child::before {
|
||||||
border-right: 1px solid #00ff00;
|
border-right: 2px solid #00ff00;
|
||||||
border-radius: 0 5px 0 0;
|
border-radius: 0 5px 0 0;
|
||||||
-webkit-border-radius: 0 5px 0 0;
|
-webkit-border-radius: 0 5px 0 0;
|
||||||
-moz-border-radius: 0 5px 0 0;
|
-moz-border-radius: 0 5px 0 0;
|
||||||
@@ -86,7 +89,7 @@ import sessionStore from '../../store/session'
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
border-left: 1px solid #00ff00;
|
border-left: 2px solid #00ff00;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
@@ -97,7 +100,7 @@ import sessionStore from '../../store/session'
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-family: arial, verdana, tahoma;
|
font-family: arial, verdana, tahoma;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -109,18 +112,10 @@ import sessionStore from '../../store/session'
|
|||||||
-moz-transition: all 0.5s;
|
-moz-transition: all 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.discovered {
|
.tree li a.pwd {
|
||||||
background: #526b4e;
|
border: 3px solid #00ff00;
|
||||||
color: #000;
|
background: #008080;
|
||||||
border: 1px solid #94a0b4;
|
font-weight: bold;
|
||||||
}
|
font-size: 13px;
|
||||||
|
|
||||||
ul.discovered,
|
|
||||||
li.discovered {
|
|
||||||
border-color: #94a0b4;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.pwd {
|
|
||||||
box-shadow: 0 0 40px rgba(255, 255, 0, 20);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,13 +3,13 @@ import { onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { logout } from '../../utils/auth'
|
import { logout } from '../../utils/auth'
|
||||||
import { bugReportUrl, surveyUrl } from '../../config'
|
import { trpc } from '../../utils/trpc'
|
||||||
import { reset } from '../../store/session'
|
import { openBugReport } from '../../utils/formbricks'
|
||||||
import userProfileStore, {
|
import useSession from '../../store/session'
|
||||||
loadUserProfile,
|
import useUserProfile from '../../store/userProfile'
|
||||||
resetUserProfile,
|
|
||||||
} from '../../store/userProfile'
|
|
||||||
|
|
||||||
|
const userStore = useUserProfile()
|
||||||
|
const sessionStore = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -17,8 +17,8 @@ const toast = useToast()
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await logout()
|
await logout()
|
||||||
resetUserProfile()
|
userStore.resetUserProfile()
|
||||||
reset()
|
sessionStore.reset()
|
||||||
router.push({ name: 'login' })
|
router.push({ name: 'login' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('登出失敗')
|
toast.error('登出失敗')
|
||||||
@@ -26,9 +26,14 @@ const handleLogout = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openSurvey = async () => {
|
||||||
|
const surveyUrl = await trpc.links.surveyLink.query()
|
||||||
|
window.open(surveyUrl, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await loadUserProfile()
|
await userStore.loadUserProfile()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('無法讀取使用者資料')
|
toast.error('無法讀取使用者資料')
|
||||||
console.error(err)
|
console.error(err)
|
||||||
@@ -59,20 +64,20 @@ onMounted(async () => {
|
|||||||
class="text-text inline-block whitespace-nowrap px-1.5"
|
class="text-text inline-block whitespace-nowrap px-1.5"
|
||||||
style="font-size: 2vh"
|
style="font-size: 2vh"
|
||||||
>
|
>
|
||||||
{{ userProfileStore.username }}
|
{{ userStore.username }}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<button
|
||||||
title="Survey"
|
title="Survey"
|
||||||
:href="surveyUrl"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="md:h-5 md:w-5 px-1.5 w-auto"
|
class="md:h-5 md:w-5 px-1.5 w-auto"
|
||||||
|
@click="openSurvey"
|
||||||
>
|
>
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
:icon="['fas', 'file-invoice']"
|
:icon="['fas', 'file-invoice']"
|
||||||
class="text-text-primary inline"
|
class="text-text-primary inline"
|
||||||
/>
|
/>
|
||||||
<span class="text-text sm:hidden px-2">Survey</span>
|
<span class="text-text sm:hidden px-2">Survey</span>
|
||||||
</a>
|
</button>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
title="LeaderBoard"
|
title="LeaderBoard"
|
||||||
to="/leaderboard"
|
to="/leaderboard"
|
||||||
@@ -85,15 +90,14 @@ onMounted(async () => {
|
|||||||
<font-awesome-icon :icon="['fas', 'map']" class="text-text-primary" />
|
<font-awesome-icon :icon="['fas', 'map']" class="text-text-primary" />
|
||||||
<span class="text-text sm:hidden px-2">Map</span>
|
<span class="text-text sm:hidden px-2">Map</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<a
|
<button
|
||||||
title="Bug Report"
|
title="Bug Report"
|
||||||
:href="bugReportUrl"
|
@click="openBugReport"
|
||||||
target="_blank"
|
|
||||||
class="md:h-5 md:w-5 px-1.5 w-auto"
|
class="md:h-5 md:w-5 px-1.5 w-auto"
|
||||||
>
|
>
|
||||||
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
|
<font-awesome-icon :icon="['fas', 'bug']" class="text-text-primary" />
|
||||||
<span class="text-text sm:hidden px-2">Bug Report</span>
|
<span class="text-text sm:hidden px-2">Bug Report</span>
|
||||||
</a>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Sign Out"
|
title="Sign Out"
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
|
|||||||
+33
-27
@@ -3,27 +3,30 @@ import { library } from '@fortawesome/fontawesome-svg-core'
|
|||||||
|
|
||||||
/* import specific icons */
|
/* import specific icons */
|
||||||
import {
|
import {
|
||||||
faBars,
|
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faList,
|
|
||||||
faGear,
|
|
||||||
faTriangleExclamation,
|
|
||||||
faExpand,
|
|
||||||
faTerminal,
|
|
||||||
faArrowRotateLeft,
|
|
||||||
faArrowRightFromBracket,
|
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
|
faArrowRightFromBracket,
|
||||||
|
faArrowRotateLeft,
|
||||||
|
faBars,
|
||||||
faBug,
|
faBug,
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faMap,
|
|
||||||
faTrophy,
|
|
||||||
faFileInvoice,
|
|
||||||
faEnvelope,
|
faEnvelope,
|
||||||
|
faExpand,
|
||||||
|
faFileInvoice,
|
||||||
|
faGear,
|
||||||
|
faList,
|
||||||
|
faMap,
|
||||||
|
faPlay,
|
||||||
|
faTerminal,
|
||||||
|
faTriangleExclamation,
|
||||||
|
faTrophy,
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
import {
|
import {
|
||||||
faLightbulb,
|
|
||||||
faCircleQuestion,
|
faCircleQuestion,
|
||||||
faCircleRight,
|
faCircleRight,
|
||||||
|
faFile,
|
||||||
|
faFolder,
|
||||||
|
faLightbulb,
|
||||||
} from '@fortawesome/free-regular-svg-icons'
|
} from '@fortawesome/free-regular-svg-icons'
|
||||||
import {
|
import {
|
||||||
faFacebook,
|
faFacebook,
|
||||||
@@ -33,26 +36,29 @@ import {
|
|||||||
|
|
||||||
/* add icons to the library */
|
/* add icons to the library */
|
||||||
library.add(
|
library.add(
|
||||||
faBars,
|
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faList,
|
|
||||||
faGear,
|
|
||||||
faTriangleExclamation,
|
|
||||||
faExpand,
|
|
||||||
faTerminal,
|
|
||||||
faArrowRotateLeft,
|
|
||||||
faArrowRightFromBracket,
|
|
||||||
faArrowRight,
|
faArrowRight,
|
||||||
|
faArrowRightFromBracket,
|
||||||
|
faArrowRotateLeft,
|
||||||
|
faBars,
|
||||||
faBug,
|
faBug,
|
||||||
faMap,
|
|
||||||
faTrophy,
|
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faLightbulb,
|
faEnvelope,
|
||||||
|
faExpand,
|
||||||
|
faFileInvoice,
|
||||||
|
faGear,
|
||||||
|
faList,
|
||||||
|
faMap,
|
||||||
|
faPlay,
|
||||||
|
faTerminal,
|
||||||
|
faTriangleExclamation,
|
||||||
|
faTrophy,
|
||||||
faCircleQuestion,
|
faCircleQuestion,
|
||||||
faCircleRight,
|
faCircleRight,
|
||||||
faGithub,
|
faFile,
|
||||||
faGoogle,
|
faFolder,
|
||||||
faFileInvoice,
|
faLightbulb,
|
||||||
faFacebook,
|
faFacebook,
|
||||||
faEnvelope
|
faGithub,
|
||||||
|
faGoogle
|
||||||
)
|
)
|
||||||
+11
-1
@@ -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'
|
||||||
@@ -8,16 +10,24 @@ import Toast, { POSITION } from 'vue-toastification'
|
|||||||
/* import Vue Lottie */
|
/* import Vue Lottie */
|
||||||
import Vue3Lottie from 'vue3-lottie'
|
import Vue3Lottie from 'vue3-lottie'
|
||||||
import 'vue-toastification/dist/index.css'
|
import 'vue-toastification/dist/index.css'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import formbricks from './utils/formbricks'
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
router.afterEach(() => {
|
||||||
|
if (typeof formbricks !== 'undefined') {
|
||||||
|
formbricks.registerRouteChange()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
position: POSITION.TOP_RIGHT,
|
position: POSITION.TOP_RIGHT,
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-140
@@ -1,156 +1,126 @@
|
|||||||
import { reactive } from 'vue'
|
import { defineStore } from 'pinia'
|
||||||
import { useToast } from 'vue-toastification'
|
import {
|
||||||
import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
|
FileGraph,
|
||||||
import { IQuest } from '@linux-odyssey/models'
|
type FileGraphUpdateEvent,
|
||||||
import api from '../utils/api'
|
} from '../../../packages/file-graph'
|
||||||
|
import type { IResponse, ITask } from '../../../packages/game'
|
||||||
|
import type { SessionDetail } from '../../../server/src/routers/sessionRouter'
|
||||||
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 type { Session } from '../types'
|
||||||
import { Session, StageResponse } from '../types'
|
import { trpc } from '../utils/trpc'
|
||||||
|
|
||||||
const toast = useToast()
|
const socket = new Socket()
|
||||||
|
const term = new SocketTerminal()
|
||||||
|
let hasSetup = false
|
||||||
|
|
||||||
function newSession() {
|
interface QuestDetailResponse {
|
||||||
const graph = new FileGraph({
|
id: string
|
||||||
path: '/',
|
title: string
|
||||||
type: 'folder',
|
instruction: string
|
||||||
discovered: false,
|
requirements: string[]
|
||||||
})
|
}
|
||||||
return {
|
|
||||||
_id: '',
|
interface SessionUpdate {
|
||||||
status: 'inactive',
|
status: string
|
||||||
graph,
|
responses: IResponse[]
|
||||||
pwd: '',
|
tasks: ITask[]
|
||||||
hints: [],
|
graphUpdate: FileGraphUpdateEvent
|
||||||
tasks: [],
|
|
||||||
responses: [],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Store {
|
interface Store {
|
||||||
session: Session
|
session: Session | null
|
||||||
questId: string
|
questId: string
|
||||||
quest: IQuest | null
|
quest: QuestDetailResponse | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = reactive<Store>({
|
const useSession = defineStore('session', {
|
||||||
session: newSession(),
|
state: (): Store => ({
|
||||||
questId: '',
|
session: null,
|
||||||
quest: null,
|
questId: '',
|
||||||
|
quest: null,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async setQuest(questId: string) {
|
||||||
|
this.quest = await trpc.quests.getQuestDetail.query(questId)
|
||||||
|
this.questId = questId
|
||||||
|
},
|
||||||
|
async setSession(session: SessionDetail) {
|
||||||
|
this.session = {
|
||||||
|
...session,
|
||||||
|
graph: new FileGraph(session.graph),
|
||||||
|
pwd: '/home/commander',
|
||||||
|
}
|
||||||
|
term.reset()
|
||||||
|
await socket.connect(this.session)
|
||||||
|
term.focus()
|
||||||
|
},
|
||||||
|
async createSession() {
|
||||||
|
const session = await trpc.session.createSession.mutate({
|
||||||
|
questId: this.questId,
|
||||||
|
})
|
||||||
|
await this.setSession(session)
|
||||||
|
socket.emit('terminal', 'echo start\n')
|
||||||
|
},
|
||||||
|
async getActiveSession() {
|
||||||
|
const session = await trpc.session.getActiveSession.query({
|
||||||
|
questId: this.questId,
|
||||||
|
})
|
||||||
|
if (session) {
|
||||||
|
this.setSession(session)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
newUpdate(update: SessionUpdate) {
|
||||||
|
if (!this.session) return
|
||||||
|
this.session.responses = update.responses
|
||||||
|
this.session.tasks = update.tasks
|
||||||
|
this.session.status = update.status
|
||||||
|
// if (
|
||||||
|
// response.status === 'finished' &&
|
||||||
|
// this.session.status !== 'finished'
|
||||||
|
// ) {
|
||||||
|
// this.finish()
|
||||||
|
// }
|
||||||
|
// this.session.status = response.status
|
||||||
|
},
|
||||||
|
finish() {
|
||||||
|
if (!this.session) return
|
||||||
|
this.session.status = 'finished'
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
socket.reset()
|
||||||
|
term.reset()
|
||||||
|
this.$reset()
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
if (hasSetup) return
|
||||||
|
socket.on('terminal', (data: string) => {
|
||||||
|
term.write(data)
|
||||||
|
})
|
||||||
|
term.onData((data: string) => {
|
||||||
|
socket.emit('terminal', data)
|
||||||
|
})
|
||||||
|
socket.on('graph', (event: FileGraphUpdateEvent) => {
|
||||||
|
if (!this.session) return
|
||||||
|
this.session.graph.handleEvent(event)
|
||||||
|
if (event.pwd) {
|
||||||
|
this.session.pwd = event.pwd
|
||||||
|
}
|
||||||
|
})
|
||||||
|
socket.on('update', (update: SessionUpdate) => {
|
||||||
|
this.newUpdate(update)
|
||||||
|
})
|
||||||
|
hasSetup = true
|
||||||
|
},
|
||||||
|
setStatus(status: string) {
|
||||||
|
if (!this.session) return
|
||||||
|
this.session.status = status
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const socket = new Socket()
|
|
||||||
const term = new SocketTerminal(40, 80)
|
|
||||||
|
|
||||||
function setQuest(questId: string) {
|
|
||||||
return api.get(`/quests/${questId}`).then((res) => {
|
|
||||||
store.questId = res.data._id
|
|
||||||
store.quest = res.data
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setSession(session: Session) {
|
|
||||||
try {
|
|
||||||
console.log('Setting session...', session)
|
|
||||||
store.session = session
|
|
||||||
store.session.graph = new FileGraph(session.graph)
|
|
||||||
term.reset()
|
|
||||||
await socket.connect(session)
|
|
||||||
term.focus()
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err)
|
|
||||||
if (err instanceof Error) {
|
|
||||||
toast.error(err.message)
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to connect to the session. Please try again later.')
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
store.session.graph.discover(event.discover)
|
|
||||||
}
|
|
||||||
if (event.pwd) {
|
|
||||||
store.session.pwd = event.pwd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function newResponse(response: StageResponse) {
|
|
||||||
console.log('newResponse', response, 'session', store.session)
|
|
||||||
store.session.responses.push(response.responses)
|
|
||||||
store.session.hints.push(response.hints)
|
|
||||||
store.session.tasks = response.tasks
|
|
||||||
store.session.status = response.status
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTerminal() {
|
export function useTerminal() {
|
||||||
return term
|
return term
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reset() {
|
export default useSession
|
||||||
socket.reset()
|
|
||||||
term.reset()
|
|
||||||
store.session = newSession()
|
|
||||||
store.quest = null
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
term.write(data)
|
|
||||||
})
|
|
||||||
term.onData((data: string) => {
|
|
||||||
socket.emit('terminal', data)
|
|
||||||
})
|
|
||||||
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
|
|
||||||
updateGraph(event)
|
|
||||||
})
|
|
||||||
socket.on('response', (response: StageResponse) => {
|
|
||||||
newResponse(response)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setup()
|
|
||||||
|
|
||||||
export default store
|
|
||||||
@@ -1,30 +1,35 @@
|
|||||||
import { reactive } from 'vue'
|
import { defineStore } from 'pinia'
|
||||||
import api from '../utils/api'
|
import { trpc } from '../utils/trpc'
|
||||||
|
|
||||||
const store = reactive({
|
interface IProgress {
|
||||||
username: '',
|
quest: string
|
||||||
email: '',
|
sessions: string[]
|
||||||
progress: {},
|
completed: boolean
|
||||||
|
startedAt: Date
|
||||||
|
finishedAt?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
progress: Record<string, IProgress>
|
||||||
|
}
|
||||||
|
|
||||||
|
const useUserProfile = defineStore('userProfile', {
|
||||||
|
state: (): UserProfile => ({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
progress: {},
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async loadUserProfile() {
|
||||||
|
const res = await trpc.users.getMyProfile.query()
|
||||||
|
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
|
|
||||||
+4
-6
@@ -1,6 +1,5 @@
|
|||||||
import { FileGraph } from '@linux-odyssey/file-graph'
|
import type { FileGraph } from '../../packages/file-graph'
|
||||||
// eslint-disable-next-line import/no-relative-packages
|
import type { IResponse, ITask } from '../../packages/game'
|
||||||
import { IResponse } from '@linux-odyssey/models'
|
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: number
|
id: number
|
||||||
@@ -20,7 +19,6 @@ export interface Session {
|
|||||||
status: string
|
status: string
|
||||||
graph: FileGraph
|
graph: FileGraph
|
||||||
pwd: string
|
pwd: string
|
||||||
hints: string[][]
|
tasks: ITask[]
|
||||||
tasks: Task[]
|
responses: IResponse[]
|
||||||
responses: IResponse[][]
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import formbricks from '@formbricks/js/website'
|
||||||
|
import useSession from '../store/session'
|
||||||
|
import useUserProfile from '../store/userProfile'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
formbricks.init({
|
||||||
|
environmentId: 'clzcdqqn0000kadvtqykdo0pc',
|
||||||
|
apiHost: 'https://app.formbricks.com',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openQuestSurvey() {
|
||||||
|
const sessionStore = useSession()
|
||||||
|
const userStore = useUserProfile()
|
||||||
|
|
||||||
|
formbricks.track('quest_completed', {
|
||||||
|
hiddenFields: {
|
||||||
|
quest: sessionStore.questId,
|
||||||
|
session: sessionStore.session?._id,
|
||||||
|
username: userStore.username,
|
||||||
|
email: userStore.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openBugReport() {
|
||||||
|
const sessionStore = useSession()
|
||||||
|
const userStore = useUserProfile()
|
||||||
|
|
||||||
|
formbricks.track('bug_report', {
|
||||||
|
hiddenFields: {
|
||||||
|
quest: sessionStore.questId,
|
||||||
|
session: sessionStore.session?._id,
|
||||||
|
username: userStore.username,
|
||||||
|
email: userStore.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default formbricks
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { io, Socket } from 'socket.io-client'
|
import { io, Socket } from 'socket.io-client'
|
||||||
import { Session } from '../types'
|
import type { Session } from '../types'
|
||||||
|
|
||||||
type SocketCallback = (data: any) => void
|
type SocketCallback = (data: any) => void
|
||||||
|
|
||||||
|
|||||||
+21
-25
@@ -6,33 +6,39 @@ class SocketTerminal {
|
|||||||
private term: Terminal
|
private term: Terminal
|
||||||
private fitAddon: FitAddon
|
private fitAddon: FitAddon
|
||||||
|
|
||||||
constructor(rows: number, cols: number) {
|
constructor() {
|
||||||
this.term = new Terminal({
|
this.term = new Terminal({
|
||||||
rows, // 行数
|
convertEol: true,
|
||||||
cols, // 不指定行数,自动回车后光标从下一行开始
|
scrollback: 1000,
|
||||||
convertEol: true, // 启用时,光标将设置为下一行的开头
|
fontSize: 16,
|
||||||
// scrollback: 50, //终端中的回滚量
|
|
||||||
disableStdin: false, // 是否应禁用输入
|
|
||||||
// cursorStyle: "underline", //光标样式
|
|
||||||
cursorBlink: true, // 光标闪烁
|
|
||||||
theme: {
|
theme: {
|
||||||
foreground: '#ECECEC', // 字体
|
foreground: '#ECECEC',
|
||||||
background: '#000000', // 背景色
|
background: '#000000',
|
||||||
cursor: 'help', // 设置光标
|
cursor: 'help',
|
||||||
},
|
},
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 'normal',
|
|
||||||
})
|
})
|
||||||
this.fitAddon = new FitAddon()
|
this.fitAddon = new FitAddon()
|
||||||
this.term.loadAddon(this.fitAddon)
|
this.term.loadAddon(this.fitAddon)
|
||||||
this.fitAddon.fit()
|
}
|
||||||
|
|
||||||
|
resizeScreen(): void {
|
||||||
|
try {
|
||||||
|
this.fitAddon.fit()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to resize terminal:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mount(reference: HTMLElement): void {
|
||||||
|
this.term.open(reference)
|
||||||
|
this.resizeScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
write(message: string): void {
|
write(message: string): void {
|
||||||
this.term.write(message)
|
this.term.write(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
onData(callback: any): void {
|
onData(callback: (data: string) => void): void {
|
||||||
this.term.onData(callback)
|
this.term.onData(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,16 +46,6 @@ class SocketTerminal {
|
|||||||
this.term.focus()
|
this.term.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
resizeScreen(): void {
|
|
||||||
this.fitAddon.fit()
|
|
||||||
}
|
|
||||||
|
|
||||||
mount(reference: HTMLElement): void {
|
|
||||||
this.term.open(reference)
|
|
||||||
this.term.focus()
|
|
||||||
this.resizeScreen()
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.term.reset()
|
this.term.reset()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createTRPCClient, httpBatchLink } from '@trpc/client'
|
||||||
|
import type { AppRouter } from '../../../server/src/routers'
|
||||||
|
|
||||||
|
export const trpc = createTRPCClient<AppRouter>({
|
||||||
|
links: [
|
||||||
|
httpBatchLink({
|
||||||
|
url: '/trpc',
|
||||||
|
// You can pass any HTTP headers you wish here
|
||||||
|
// async headers() {
|
||||||
|
// return {
|
||||||
|
// authorization: getAuthCookie(),
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { isValidUsername } from '@linux-odyssey/utils'
|
import { isValidUsername } from '../../../packages/utils'
|
||||||
import AuthForm from '../components/AuthForm.vue'
|
import AuthForm from '../components/AuthForm.vue'
|
||||||
import Background from '../components/DynamicBackground.vue'
|
import Background from '../components/DynamicBackground.vue'
|
||||||
import { TooManyRequestsError, ValidationError } from '../utils/errors'
|
import { TooManyRequestsError, ValidationError } from '../utils/errors'
|
||||||
|
|||||||
+24
-25
@@ -1,20 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { useToast } from 'vue-toastification'
|
import useSession from '../store/session'
|
||||||
import sessionStore, { init } from '../store/session'
|
import useUserProfile from '../store/userProfile'
|
||||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||||
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
||||||
// import CommandlistPart from '../components/CommandlistPart.vue'
|
import DescriptionPart from '../components/game/DescriptionPart.vue'
|
||||||
import HintPart from '../components/game/HintPart.vue'
|
|
||||||
import QuestPart from '../components/game/QuestPart.vue'
|
import QuestPart from '../components/game/QuestPart.vue'
|
||||||
import TerminalPart from '../components/game/TerminalPart.vue'
|
import TerminalPart from '../components/game/TerminalPart.vue'
|
||||||
import VisualizationPart from '../components/game/VisualizationPart.vue'
|
import VisualizationPart from '../components/game/VisualizationPart.vue'
|
||||||
import ControlPalette from '../components/game/ControlPalette.vue'
|
import ControlPalette from '../components/game/ControlPalette.vue'
|
||||||
import CompleteModal from '../components/game/CompleteModal.vue'
|
import CompleteModal from '../components/game/CompleteModal.vue'
|
||||||
import { LoadQuestError, LoadSessionError } from '../utils/errors'
|
import StartButton from '../components/game/StartButton.vue'
|
||||||
|
import { openQuestSurvey } from '../utils/formbricks'
|
||||||
|
|
||||||
|
const sessionStore = useSession()
|
||||||
|
const userStore = useUserProfile()
|
||||||
|
|
||||||
const completed = computed(() => {
|
const completed = computed(() => {
|
||||||
return sessionStore.session.status === 'finished'
|
return sessionStore.session?.status === 'finished'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(completed, (newValue, oldValue) => {
|
||||||
|
if (newValue && !oldValue) {
|
||||||
|
console.log('finish', userStore)
|
||||||
|
openQuestSurvey()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -24,23 +34,11 @@ 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>
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ onMounted(async () => {
|
|||||||
<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-full pt-10 w-full flex p-3 space-x-3">
|
<div id="main" class="h-full pt-10 w-full flex p-3 space-x-3">
|
||||||
@@ -72,8 +70,9 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<!-- Terminal and Hint -->
|
<!-- Terminal and Hint -->
|
||||||
<div class="bg-bg h-full rounded-lg w-1/2">
|
<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 />
|
<DescriptionPart />
|
||||||
</section>
|
</section>
|
||||||
<hr class="border-border border" />
|
<hr class="border-border border" />
|
||||||
<section id="terminal" class="h-2/5 overflow-hidden">
|
<section id="terminal" class="h-2/5 overflow-hidden">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import validator from 'validator'
|
import validator from 'validator'
|
||||||
import { isValidUsername } from '@linux-odyssey/utils'
|
import { isValidUsername } from '../../../packages/utils'
|
||||||
import { passwordPolicy } from '@linux-odyssey/constants'
|
import { passwordPolicy } from '../../../packages/constants'
|
||||||
import AuthForm from '../components/AuthForm.vue'
|
import AuthForm from '../components/AuthForm.vue'
|
||||||
import DynamicBackground from '../components/DynamicBackground.vue'
|
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export default {
|
|||||||
DEFAULT: '#FF0000',
|
DEFAULT: '#FF0000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
catelogbg: "url('/catelogbg.svg')",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
+3
-26
@@ -1,30 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"files": [],
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../packages/utils" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "../packages/constants" },
|
{ "path": "./tsconfig.node.json" }
|
||||||
{ "path": "../packages/file-graph" },
|
|
||||||
{ "path": "../packages/models" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@game': path.resolve(__dirname, '../packages/game'),
|
||||||
|
'@models': path.resolve(__dirname, '../packages/models'),
|
||||||
|
'@utils': path.resolve(__dirname, '../packages/utils'),
|
||||||
|
'@constants': path.resolve(__dirname, '../packages/constants'),
|
||||||
|
'@file-graph': path.resolve(__dirname, '../packages/file-graph'),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/trpc': {
|
||||||
|
target: process.env.API_TARGET || 'http://localhost:3000',
|
||||||
|
changeOrigin: false,
|
||||||
|
xfwd: true,
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.API_TARGET || 'http://localhost:3000',
|
target: process.env.API_TARGET || 'http://localhost:3000',
|
||||||
changeOrigin: false,
|
changeOrigin: false,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
error_log /var/log/nginx/error.log debug;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
@@ -10,10 +11,29 @@ server {
|
|||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3000;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /trpc/ {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api-docs/ {
|
location /api-docs/ {
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /socket.io/ {
|
location /socket.io/ {
|
||||||
@@ -23,5 +43,9 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
+10
-25
@@ -1,4 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: linuxodyssey/frontend
|
image: linuxodyssey/frontend
|
||||||
@@ -10,7 +9,7 @@ services:
|
|||||||
dockerfile: app/Dockerfile
|
dockerfile: app/Dockerfile
|
||||||
target: prod
|
target: prod
|
||||||
networks:
|
networks:
|
||||||
internal:
|
- internal
|
||||||
ports:
|
ports:
|
||||||
- 8080:80
|
- 8080:80
|
||||||
|
|
||||||
@@ -29,15 +28,14 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
|
- ./config:/app/config
|
||||||
|
- ./quests:/app/quests:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env.prod
|
||||||
environment:
|
environment:
|
||||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
|
||||||
- DOCKER_NETWORK=linux-odyssey-players
|
- DOCKER_NETWORK=linux-odyssey-players
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
|
||||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
|
|
||||||
- BACKEND_URL=http://backend:3000
|
- BACKEND_URL=http://backend:3000
|
||||||
- BASE_URL=${BASE_URL}
|
- HOST_PROJECT_ROOT=${PWD}
|
||||||
|
|
||||||
analytics:
|
analytics:
|
||||||
image: linuxodyssey/analytics
|
image: linuxodyssey/analytics
|
||||||
@@ -52,13 +50,11 @@ services:
|
|||||||
- internal
|
- internal
|
||||||
ports:
|
ports:
|
||||||
- '3001:3001'
|
- '3001:3001'
|
||||||
environment:
|
env_file:
|
||||||
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
|
- .env.prod
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: mongo
|
image: mongo:6
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
@@ -67,19 +63,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- mongo:/data/db
|
- mongo:/data/db
|
||||||
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
|
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
|
||||||
environment:
|
env_file:
|
||||||
- BASE_URL=${BASE_URL}
|
- .env.prod
|
||||||
- MONGO_INITDB_ROOT_USERNAME=admin
|
|
||||||
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
|
|
||||||
- MONGO_USER=${MONGO_USER}
|
|
||||||
- MONGO_PASS=${MONGO_PASSWORD}
|
|
||||||
- MONGO_DB=${MONGO_DB}
|
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
|
||||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
|
||||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
|
||||||
- SURVEY_URL=${SURVEY_URL}
|
|
||||||
- BUG_REPORT_URL=${BUG_REPORT_URL}
|
|
||||||
|
|
||||||
base:
|
base:
|
||||||
image: linuxodyssey/quest-base
|
image: linuxodyssey/quest-base
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mongo:6
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
ports:
|
||||||
|
- '27017:27017'
|
||||||
|
volumes:
|
||||||
|
- ./data:/data/db
|
||||||
|
|
||||||
|
base:
|
||||||
|
image: linuxodyssey/quest-base
|
||||||
|
networks:
|
||||||
|
- players-dev
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./quests/Dockerfile
|
||||||
|
extra_hosts:
|
||||||
|
- 'host.docker.internal:host-gateway'
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
players-dev:
|
||||||
|
name: linux-odyssey-players-dev
|
||||||
|
internal: false
|
||||||
+4
-3
@@ -32,15 +32,16 @@ down() {
|
|||||||
dump() {
|
dump() {
|
||||||
# Define service name and dump directory name
|
# Define service name and dump directory name
|
||||||
SERVICE_NAME="db"
|
SERVICE_NAME="db"
|
||||||
|
COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
DUMP_NAME="mongodump-$(date --iso).archive"
|
DUMP_NAME="mongodump-$(date --iso).archive"
|
||||||
CONTAINER_ID=$(docker compose ps -q $SERVICE_NAME)
|
CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q $SERVICE_NAME)
|
||||||
|
|
||||||
# Fetch credentials from Docker environment variables
|
# Fetch credentials from Docker environment variables
|
||||||
MONGO_URL=$(docker compose exec -T backend printenv MONGO_URL)
|
MONGO_URL=$(docker compose -f $COMPOSE_FILE exec -T backend printenv MONGO_URL)
|
||||||
echo mongo uri: $MONGO_URL
|
echo mongo uri: $MONGO_URL
|
||||||
|
|
||||||
# Dump MongoDB data using credentials
|
# Dump MongoDB data using credentials
|
||||||
docker compose exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME -d odyssey-test" &&
|
docker compose -f $COMPOSE_FILE exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME --uri=$MONGO_URL" &&
|
||||||
|
|
||||||
# Copy the dump from the container to the host
|
# Copy the dump from the container to the host
|
||||||
# Note: You will need to know the exact container name for this step
|
# Note: You will need to know the exact container name for this step
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Windows Environment Setup
|
||||||
|
|
||||||
|
## Install WSL and Ubuntu
|
||||||
|
|
||||||
|
Please refer to the Official [Setup WSL](https://learn.microsoft.com/en-us/windows/wsl/setup/environment) on Microsoft's website.
|
||||||
|
|
||||||
|
Run the following command in PowerShell as Administrator:
|
||||||
|
|
||||||
|
wsl --install
|
||||||
|
|
||||||
|
Then restart the computer. After restarting, you can open Ubuntu terminal by searching "Ubuntu" in Start menu. You will be prompted to create a new user account. Once you have done that, you can continue with the following steps.
|
||||||
|
|
||||||
|
## Setup Environment (In Ubuntu terminal)
|
||||||
|
|
||||||
|
1. Update and upgrade
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Install Git
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt-get install git
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Install Node.js (Version 20.x)
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt-get install nodejs
|
||||||
|
sudo apt install build-essential
|
||||||
|
sudo npm install -g yarn
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install Docker
|
||||||
|
|
||||||
|
(Refer to [get.docker.com](https://get.docker.com/))
|
||||||
|
|
||||||
|
To install the latest stable versions of Docker CLI, Docker Engine, and their
|
||||||
|
dependencies:
|
||||||
|
|
||||||
|
1. Download the script
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -fsSL https://get.docker.com -o install-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
1. verify the script's content
|
||||||
|
|
||||||
|
```
|
||||||
|
cat install-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
1. run the script with --dry-run to verify the steps it executes
|
||||||
|
|
||||||
|
```
|
||||||
|
sh install-docker.sh --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOTE: the script will recommend you to use Docker Desktop instead, but we need to use Docker inside WSL for the backend to work.** Just ignore it and wait for the installation to finish.
|
||||||
|
|
||||||
|
1. run the script either as root, or using sudo to perform the installation.
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo sh install-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Verify the installation
|
||||||
|
|
||||||
|
```
|
||||||
|
docker --version
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Add your user to the docker group
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOTE: You need to create a new WSL terminal to apply the changes.**
|
||||||
|
|
||||||
|
1. Verify the installation
|
||||||
|
|
||||||
|
```
|
||||||
|
docker ps
|
||||||
|
```
|
||||||
|
This should return something like:
|
||||||
|
|
||||||
|
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||||
|
|
||||||
|
## Run the Project (In Ubuntu terminal)
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/lancatlin/linux-odyssey.git
|
||||||
|
cd linux-odyssey
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional: To open the project in VSCode, run the following command inside the project folder:
|
||||||
|
|
||||||
|
```
|
||||||
|
code .
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
It is ok if there are some warnings and `gyp: ERR` errors, just verify there is `done` at the end.
|
||||||
|
|
||||||
|
1. Copy the environment variables
|
||||||
|
|
||||||
|
```
|
||||||
|
cp .env.sample .env
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Docker setup
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose pull
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d db
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Check folder ./config exist and is empty -->
|
||||||
|
<!-- Check .env file exist -->
|
||||||
|
|
||||||
|
1. Run the project
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn build
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Check ./config/ssh_key.pub is a file not a folder -->
|
||||||
|
|
||||||
|
Enjoy!
|
||||||
+12
-10
@@ -16,7 +16,6 @@
|
|||||||
"build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build",
|
"build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build",
|
||||||
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"",
|
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"",
|
||||||
"server": "yarn workspace server dev",
|
"server": "yarn workspace server dev",
|
||||||
"cli": "yarn workspace client start",
|
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --ignore-path .prettierignore --write app server",
|
"format": "prettier --ignore-path .prettierignore --write app server",
|
||||||
"test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
|
"test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
|
||||||
@@ -34,26 +33,29 @@
|
|||||||
"@jest/globals": "^29.7.0",
|
"@jest/globals": "^29.7.0",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^20.10.4",
|
"@types/node": "^20.10.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
"@vue/eslint-config-prettier": "^7.1.0",
|
"@vue/eslint-config-prettier": "^7.1.0",
|
||||||
"babel-jest": "^29.7.0",
|
"babel-jest": "^29.7.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^8.44.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-airbnb-base": "^15.0.0",
|
"eslint-config-airbnb-base": "^15.0.0",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-import-resolver-typescript": "^3.6.1",
|
"eslint-import-resolver-typescript": "^3.6.1",
|
||||||
"eslint-plugin-cypress": "^2.15.1",
|
"eslint-plugin-cypress": "^2.15.1",
|
||||||
"eslint-plugin-import": "^2.29.0",
|
"eslint-plugin-import": "^2.29.0",
|
||||||
"eslint-plugin-node": "^11.1.0",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
|
||||||
"eslint-plugin-vue": "^9.15.1",
|
"eslint-plugin-vue": "^9.15.1",
|
||||||
|
"globals": "^16.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3",
|
||||||
|
"vue-eslint-parser": "^9.4.2",
|
||||||
|
"zod-to-json-schema": "^3.24.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"typescript-eslint": "^7.4.0"
|
"latest": "^0.2.0"
|
||||||
}
|
},
|
||||||
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,26 @@
|
|||||||
FROM node:20-alpine as build
|
FROM node:20-alpine as build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ./package.json yarn.lock tsconfig.json ./
|
COPY ./package.json yarn.lock ./
|
||||||
COPY packages/analytics/package.json ./packages/analytics/package.json
|
COPY packages/analytics/package.json ./packages/analytics/package.json
|
||||||
COPY packages/models/package.json ./packages/models/package.json
|
|
||||||
RUN yarn install --frozen-lockfile
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY tsconfig.json ./tsconfig.json
|
||||||
|
COPY packages ./packages
|
||||||
|
|
||||||
|
RUN yarn workspace analytics build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine as prod
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/packages/analytics ./packages/analytics
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=3001
|
ENV PORT=3001
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM build as prod
|
|
||||||
COPY packages/analytics ./packages/analytics
|
|
||||||
COPY packages/models ./packages/models
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
WORKDIR /app/packages/analytics
|
WORKDIR /app/packages/analytics
|
||||||
RUN yarn build
|
|
||||||
CMD [ "yarn", "start" ]
|
CMD [ "yarn", "start" ]
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"watch": ["src", "views"],
|
|
||||||
"ext": "ts,handlebars,js",
|
|
||||||
"ignore": ["src/**/*.spec.ts"],
|
|
||||||
"exec": "node --loader ts-node/esm src/index.ts"
|
|
||||||
}
|
|
||||||
@@ -3,30 +3,27 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
|
||||||
"license": "GPL",
|
"license": "GPL",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/analytics/src/index.js",
|
||||||
"build": "tsc -b -f",
|
"build": "tsc",
|
||||||
"watch": "tsc -b --watch",
|
"type-check": "tsc --noEmit",
|
||||||
"dev": "nodemon"
|
"dev": "tsx watch src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@linux-odyssey/models": "*",
|
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.20.0",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
"express-handlebars": "^7.1.2",
|
"express-handlebars": "^7.1.2",
|
||||||
"mongoose": "^8.0.3"
|
"mongoose": "^8.9.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@swc/core": "^1.4.2",
|
"@swc/core": "^1.4.2",
|
||||||
"@swc/helpers": "^0.5.6",
|
"@swc/helpers": "^0.5.6",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.11.20",
|
"@types/node": "^20.11.20",
|
||||||
"nodemon": "^3.1.0",
|
|
||||||
"regenerator-runtime": "^0.14.1",
|
"regenerator-runtime": "^0.14.1",
|
||||||
"ts-node": "^10.9.2",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,14 +21,16 @@ interface Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
host: process.env.HOST || 'localhost',
|
host: process.env.ADMIN_HOST || 'localhost',
|
||||||
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3001,
|
port: process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001,
|
||||||
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
|
db: process.env.MONGO_URL || 'mongodb://localhost:27017/odyssey-test',
|
||||||
username: getOrFailed('ADMIN_USERNAME'),
|
username: getOrFailed('ADMIN_USERNAME'),
|
||||||
password: getOrFailed('ADMIN_PASSWORD'),
|
password: getOrFailed('ADMIN_PASSWORD'),
|
||||||
baseUrl:
|
baseUrl:
|
||||||
process.env.BASE_URL ||
|
process.env.ADMIN_BASE_URL ||
|
||||||
`http://${process.env.HOST || 'localhost'}:${process.env.PORT ? parseInt(process.env.PORT, 10) : 3001}`,
|
`http://${process.env.ADMIN_HOST || 'localhost'}:${
|
||||||
|
process.env.ADMIN_PORT ? parseInt(process.env.ADMIN_PORT, 10) : 3001
|
||||||
|
}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import Pagination from '../models/pagination.js'
|
import Pagination from '../models/pagination'
|
||||||
import { errorCommands } from '../models/commands.js'
|
import { errorCommands } from '../models/commands'
|
||||||
|
|
||||||
export async function commandListController(
|
export async function commandListController(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response } from 'express'
|
import { Request, Response } from 'express'
|
||||||
import { userCount } from '../models/users.js'
|
import { userCount } from '../models/users'
|
||||||
import { questList } from '../models/quests.js'
|
import { questList } from '../models/quests'
|
||||||
|
|
||||||
export async function homeController(req: Request, res: Response) {
|
export async function homeController(req: Request, res: Response) {
|
||||||
const quests = await questList()
|
const quests = await questList()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Request, Response } from 'express'
|
import { Request, Response } from 'express'
|
||||||
import { questList } from '../models/quests.js'
|
import { questList } from '../models/quests'
|
||||||
|
|
||||||
export async function questListController(req: Request, res: Response) {
|
export async function questListController(req: Request, res: Response) {
|
||||||
const quests = await questList()
|
const quests = await questList()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response } from 'express'
|
import { Request, Response } from 'express'
|
||||||
import Pagination from '../models/pagination.js'
|
import Pagination from '../models/pagination'
|
||||||
import { sessionList, sessionDetail } from '../models/sessions.js'
|
import { sessionList, sessionDetail } from '../models/sessions'
|
||||||
|
|
||||||
export async function sessionListController(req: Request, res: Response) {
|
export async function sessionListController(req: Request, res: Response) {
|
||||||
const { nextKey, order } = req.query as { nextKey: string; order: string }
|
const { nextKey, order } = req.query as { nextKey: string; order: string }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response } from 'express'
|
import { Request, Response } from 'express'
|
||||||
import mongoose from 'mongoose'
|
import mongoose from 'mongoose'
|
||||||
import Pagination from '../models/pagination.js'
|
import Pagination from '../models/pagination'
|
||||||
import { userList, userDetail, idToUser } from '../models/users.js'
|
import { userList, userDetail, idToUser } from '../models/users'
|
||||||
|
|
||||||
export async function userListController(req: Request, res: Response) {
|
export async function userListController(req: Request, res: Response) {
|
||||||
const { nextKey, order } = req.query as { nextKey: string; order: string }
|
const { nextKey, order } = req.query as { nextKey: string; order: string }
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import connectDB from '@linux-odyssey/models'
|
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { engine } from 'express-handlebars'
|
import { engine } from 'express-handlebars'
|
||||||
import basicAuth from 'express-basic-auth'
|
import basicAuth from 'express-basic-auth'
|
||||||
import config from './config.js'
|
import { connectDB } from '../../models'
|
||||||
import router from './router.js'
|
import config from './config'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await connectDB(config.db)
|
await connectDB(config.db)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Command } from '@linux-odyssey/models'
|
import { Command } from '../../../models'
|
||||||
import Pagination from './pagination.js'
|
import Pagination from './pagination'
|
||||||
|
|
||||||
// eslint-disable-next-line import/prefer-default-export
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
export async function errorCommands(pagination: Pagination) {
|
export async function errorCommands(pagination: Pagination) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable class-methods-use-this */
|
/* eslint-disable class-methods-use-this */
|
||||||
// eslint-disable-next-line max-classes-per-file
|
/* eslint-disable max-classes-per-file */
|
||||||
import mongoose, { Types } from 'mongoose'
|
import mongoose, { Types } from 'mongoose'
|
||||||
|
|
||||||
export const DESC: string = 'desc'
|
export const DESC: string = 'desc'
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
/* eslint-disable object-shorthand */
|
import { UserProfile } from '../../../models'
|
||||||
/* eslint-disable func-names */
|
|
||||||
import { UserProfile } from '@linux-odyssey/models'
|
|
||||||
|
|
||||||
export function questList() {
|
export interface QuestProgress {
|
||||||
|
_id: string
|
||||||
|
started: number
|
||||||
|
completed: number
|
||||||
|
completedRate: number
|
||||||
|
failed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function questList(): Promise<QuestProgress[]> {
|
||||||
return UserProfile.aggregate([
|
return UserProfile.aggregate([
|
||||||
{
|
{
|
||||||
$project: {
|
$project: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Command, Session } from '@linux-odyssey/models'
|
import { Command, Session } from '../../../models'
|
||||||
import Pagination from './pagination.js'
|
import Pagination from './pagination'
|
||||||
import {
|
import {
|
||||||
CommandObject,
|
CommandObject,
|
||||||
SessionDetail,
|
SessionDetail,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Session, User } from '@linux-odyssey/models'
|
import { HydratedDocument, Types } from 'mongoose'
|
||||||
import { Types } from 'mongoose'
|
import { Session, User, IUser, ISession } from '../../../models'
|
||||||
import Pagination from './pagination.js'
|
import Pagination from './pagination'
|
||||||
import { IUser, UserSessionDetail } from '../interface.js'
|
import { UserSessionDetail } from '../interface'
|
||||||
|
|
||||||
function loginMethods(user: IUser): string[] {
|
function loginMethods(user: IUser): string[] {
|
||||||
const methods: string[] = []
|
const methods: string[] = []
|
||||||
@@ -23,7 +23,11 @@ export function userCount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function userList(pagination: Pagination) {
|
export async function userList(pagination: Pagination) {
|
||||||
const users = await Session.aggregate([
|
const users: {
|
||||||
|
userData: HydratedDocument<IUser>
|
||||||
|
count: number
|
||||||
|
lastActivityAt: Date
|
||||||
|
}[] = await Session.aggregate([
|
||||||
pagination.match('user'),
|
pagination.match('user'),
|
||||||
{
|
{
|
||||||
$group: {
|
$group: {
|
||||||
@@ -62,7 +66,7 @@ export async function userList(pagination: Pagination) {
|
|||||||
export async function userDetail(
|
export async function userDetail(
|
||||||
id: Types.ObjectId
|
id: Types.ObjectId
|
||||||
): Promise<UserSessionDetail[]> {
|
): Promise<UserSessionDetail[]> {
|
||||||
const sessions = await Session.find({
|
const sessions: HydratedDocument<ISession>[] = await Session.find({
|
||||||
user: id,
|
user: id,
|
||||||
})
|
})
|
||||||
return sessions.map((session): UserSessionDetail => {
|
return sessions.map((session): UserSessionDetail => {
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
|
||||||
import { homeController } from './controllers/index.js'
|
import { homeController } from './controllers/index'
|
||||||
import {
|
import { userListController, userDetailController } from './controllers/users'
|
||||||
userListController,
|
import { questListController } from './controllers/quests'
|
||||||
userDetailController,
|
import { commandListController } from './controllers/commands'
|
||||||
} from './controllers/users.js'
|
|
||||||
import { questListController } from './controllers/quests.js'
|
|
||||||
import { commandListController } from './controllers/commands.js'
|
|
||||||
import {
|
import {
|
||||||
sessionDetailController,
|
sessionDetailController,
|
||||||
sessionListController,
|
sessionListController,
|
||||||
} from './controllers/sessions.js'
|
} from './controllers/sessions'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "./dist"
|
"outDir": "./dist"
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "../models"],
|
||||||
"references": [{ "path": "../models" }],
|
|
||||||
"ts-node": {
|
"ts-node": {
|
||||||
"swc": true
|
"swc": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@linux-odyssey/constants",
|
|
||||||
"type": "module",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"exports": "./dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"license": "GPL",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"dev": "tsc -w"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"outDir": "./dist"
|
|
||||||
},
|
|
||||||
"include": ["*.js", "*.ts"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const toArray = (val: unknown) => (typeof val === 'string' ? [val] : val)
|
||||||
+20
-49
@@ -1,10 +1,6 @@
|
|||||||
/* eslint-disable no-restricted-syntax */
|
|
||||||
const fs = require('fs').promises
|
const fs = require('fs').promises
|
||||||
const { exit } = require('process')
|
const { exit } = require('process')
|
||||||
const minimist = require('minimist')
|
const handleCommand = require('./handleCommand.js')
|
||||||
const axios = require('axios')
|
|
||||||
const { colorize, printResponses, printHints } = require('./print.js')
|
|
||||||
const discoverFiles = require('./discover.js')
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
API_ENDPOINT,
|
API_ENDPOINT,
|
||||||
@@ -16,13 +12,6 @@ const {
|
|||||||
CMD_EXIT_CODE,
|
CMD_EXIT_CODE,
|
||||||
} = process.env
|
} = process.env
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: `${API_ENDPOINT}/api/v1`,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${TOKEN}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async function readOrNone(file) {
|
async function readOrNone(file) {
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(file, 'utf8')
|
return await fs.readFile(file, 'utf8')
|
||||||
@@ -31,34 +20,6 @@ async function readOrNone(file) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandListeners = {
|
|
||||||
ls: [discoverFiles],
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCommand(command) {
|
|
||||||
const argv = minimist(command.split(' '))
|
|
||||||
const name = argv._[0]
|
|
||||||
const listeners = commandListeners[name]
|
|
||||||
if (!listeners) return {}
|
|
||||||
return listeners.reduce(async (result, listener) => {
|
|
||||||
const res = await listener(argv)
|
|
||||||
return { ...res, ...result }
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResponse({ responses, hints, end }) {
|
|
||||||
if (responses) {
|
|
||||||
await printResponses(responses, 90)
|
|
||||||
}
|
|
||||||
if (hints) {
|
|
||||||
await printHints(hints, 90)
|
|
||||||
}
|
|
||||||
if (end) {
|
|
||||||
console.log(colorize('Quest completed!', 'blue'))
|
|
||||||
}
|
|
||||||
await api.post('/commands/completed')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
if (!CMD_NAME) exit(0)
|
if (!CMD_NAME) exit(0)
|
||||||
|
|
||||||
@@ -75,15 +36,21 @@ async function main() {
|
|||||||
params,
|
params,
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/commands', payload)
|
const response = await fetch(`${API_ENDPOINT}/api/v1/commands`, {
|
||||||
if (!res.data || !res.data.responses) return
|
method: 'POST',
|
||||||
await handleResponse(res.data)
|
headers: {
|
||||||
} catch (err) {
|
Authorization: `Bearer ${TOKEN}`,
|
||||||
if (err.response) {
|
'Content-Type': 'application/json',
|
||||||
console.error(err.response.data)
|
},
|
||||||
return
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed to send command: ${response.statusText}`)
|
||||||
|
const responseBody = await response.text()
|
||||||
|
console.error(`Response Body: ${responseBody}`)
|
||||||
}
|
}
|
||||||
console.error(err.message)
|
} catch (e) {
|
||||||
|
console.error('Error occurred while sending command:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +60,10 @@ main()
|
|||||||
console.log(
|
console.log(
|
||||||
'發生預期外的錯誤,請嘗試重新啟動 Quest,如果問題持續發生,請幫我們填寫右上角的錯誤回報表單。'
|
'發生預期外的錯誤,請嘗試重新啟動 Quest,如果問題持續發生,請幫我們填寫右上角的錯誤回報表單。'
|
||||||
)
|
)
|
||||||
console.error(err)
|
if (err.response && err.response.data) {
|
||||||
|
console.log(err.response.data)
|
||||||
|
} else {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
exit(1)
|
exit(1)
|
||||||
})
|
})
|
||||||
@@ -1,21 +1,33 @@
|
|||||||
const fs = require('fs').promises
|
const fs = require('fs').promises
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const { resolvePath } = require('./utils')
|
||||||
|
|
||||||
async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
|
async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
|
||||||
const stats = await fs.stat(inputPath)
|
let stats
|
||||||
|
try {
|
||||||
|
stats = await fs.stat(inputPath)
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ENOENT') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
const name = path.basename(inputPath)
|
const name = path.basename(inputPath)
|
||||||
const discovered = level <= 1 && (hiddenFiles || !name.startsWith('.'))
|
const discovered = level <= 1 && (hiddenFiles || !name.startsWith('.'))
|
||||||
const files = [
|
const self = {
|
||||||
{
|
path: inputPath,
|
||||||
path: inputPath,
|
name,
|
||||||
name,
|
type: stats.isDirectory() ? 'directory' : 'file',
|
||||||
type: stats.isDirectory() ? 'folder' : 'file',
|
discovered,
|
||||||
discovered,
|
}
|
||||||
},
|
const files = [self]
|
||||||
]
|
|
||||||
if (stats.isDirectory() && level < 2) {
|
if (stats.isDirectory() && level < 2) {
|
||||||
try {
|
try {
|
||||||
let entries = await fs.readdir(inputPath)
|
let entries = await fs.readdir(inputPath)
|
||||||
|
if (entries.length === 0) {
|
||||||
|
self.empty = true
|
||||||
|
return files
|
||||||
|
}
|
||||||
if (level > 0) {
|
if (level > 0) {
|
||||||
entries = entries.slice(0, 5)
|
entries = entries.slice(0, 5)
|
||||||
}
|
}
|
||||||
@@ -35,15 +47,23 @@ async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
|
|||||||
async function discoverFiles(argv) {
|
async function discoverFiles(argv) {
|
||||||
const targetPath = argv._.length < 2 ? ['.'] : argv._.slice(1)
|
const targetPath = argv._.length < 2 ? ['.'] : argv._.slice(1)
|
||||||
try {
|
try {
|
||||||
const result = await Promise.all(
|
const result = (
|
||||||
targetPath
|
await Promise.all(
|
||||||
.map((p) => path.resolve(process.cwd(), p))
|
targetPath
|
||||||
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
|
.map((p) => resolvePath(p))
|
||||||
)
|
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
|
||||||
return { discover: result.flat() }
|
)
|
||||||
|
).flat()
|
||||||
|
if (result.length === 0) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return { discover: result }
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = discoverFiles
|
module.exports = {
|
||||||
|
discoverFiles,
|
||||||
|
collectFilesInfo,
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
const fs = require('fs').promises
|
||||||
|
const path = require('path')
|
||||||
|
const minimist = require('minimist')
|
||||||
|
const { discoverFiles, collectFilesInfo } = require('./discover')
|
||||||
|
const { resolvePath } = require('./utils')
|
||||||
|
|
||||||
|
const commandListeners = {
|
||||||
|
ls: {
|
||||||
|
opts: {
|
||||||
|
boolean: ['a', 'l', 'h'],
|
||||||
|
},
|
||||||
|
handler: discoverFiles,
|
||||||
|
},
|
||||||
|
cd: {
|
||||||
|
handler: changeDirectory,
|
||||||
|
},
|
||||||
|
touch: {
|
||||||
|
handler: createFiles,
|
||||||
|
},
|
||||||
|
mkdir: {
|
||||||
|
opts: {
|
||||||
|
boolean: ['p'],
|
||||||
|
},
|
||||||
|
handler: createFiles,
|
||||||
|
},
|
||||||
|
cp: {
|
||||||
|
opts: {
|
||||||
|
boolean: ['r'],
|
||||||
|
},
|
||||||
|
handler: copyFiles,
|
||||||
|
},
|
||||||
|
rm: {
|
||||||
|
opts: {
|
||||||
|
boolean: ['r', 'f', 'v'],
|
||||||
|
},
|
||||||
|
handler: removeFiles,
|
||||||
|
},
|
||||||
|
rmdir: {
|
||||||
|
opts: {
|
||||||
|
boolean: ['p', 'v'],
|
||||||
|
},
|
||||||
|
handler: removeFiles,
|
||||||
|
},
|
||||||
|
mv: {
|
||||||
|
handler: moveFiles,
|
||||||
|
},
|
||||||
|
pwd: {
|
||||||
|
handler: showCurrentPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommand(command) {
|
||||||
|
const name = command.split(' ')[0]
|
||||||
|
const listener = commandListeners[name]
|
||||||
|
if (!listener) return {}
|
||||||
|
const argv = minimist(command.split(' '), listener.opts)
|
||||||
|
return listener.handler(argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeDirectory() {
|
||||||
|
return {
|
||||||
|
pwd: process.cwd(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFiles(argv) {
|
||||||
|
const { _ } = argv
|
||||||
|
const files = _.slice(1)
|
||||||
|
const results = []
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = resolvePath(file)
|
||||||
|
try {
|
||||||
|
const exists = await fs.stat(filePath)
|
||||||
|
if (exists) {
|
||||||
|
results.push({
|
||||||
|
path: filePath,
|
||||||
|
type: exists.isFile() ? 'file' : 'directory',
|
||||||
|
discovered: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
add: results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyFiles(argv) {
|
||||||
|
const { _ } = argv
|
||||||
|
const files = _.slice(1)
|
||||||
|
if (files.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const dest = files.pop()
|
||||||
|
const destPath = resolvePath(dest)
|
||||||
|
return {
|
||||||
|
discover: await collectFilesInfo(destPath, 0, true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFiles(argv) {
|
||||||
|
const files = argv._.slice(1)
|
||||||
|
const results = []
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = resolvePath(file)
|
||||||
|
try {
|
||||||
|
await fs.access(filePath, fs.constants.F_OK)
|
||||||
|
} catch {
|
||||||
|
results.push({
|
||||||
|
path: filePath,
|
||||||
|
type: 'file',
|
||||||
|
discovered: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
remove: results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveFiles(argv) {
|
||||||
|
const { _ } = argv
|
||||||
|
const sources = _.slice(1)
|
||||||
|
if (sources.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const dest = sources.pop()
|
||||||
|
const results = {
|
||||||
|
add: [],
|
||||||
|
remove: [],
|
||||||
|
}
|
||||||
|
// check source
|
||||||
|
for (const source of sources) {
|
||||||
|
const sourcePath = resolvePath(source)
|
||||||
|
try {
|
||||||
|
await fs.access(sourcePath, fs.constants.F_OK)
|
||||||
|
} catch {
|
||||||
|
results.remove.push({
|
||||||
|
path: sourcePath,
|
||||||
|
type: 'file',
|
||||||
|
discovered: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check dest
|
||||||
|
const destPath = resolvePath(dest)
|
||||||
|
results.add = await collectFilesInfo(destPath, 0, true)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCurrentPath() {
|
||||||
|
const pwd = process.cwd()
|
||||||
|
const dirs = pwd.split('/')
|
||||||
|
let currentPath = '/'
|
||||||
|
const result = []
|
||||||
|
for (const dir of dirs) {
|
||||||
|
currentPath = path.join(currentPath, dir)
|
||||||
|
result.push({
|
||||||
|
path: currentPath,
|
||||||
|
type: 'directory',
|
||||||
|
discovered: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
add: result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleCommand
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
"watch": ""
|
"watch": ""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
|
||||||
"minimist": "^1.2.8"
|
"minimist": "^1.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
/* eslint-disable no-restricted-syntax */
|
|
||||||
/* eslint-disable no-await-in-loop */
|
|
||||||
const readline = require('readline')
|
|
||||||
|
|
||||||
const RETURN_SYMBOL = '\u21B5'
|
|
||||||
|
|
||||||
// ANSI color map
|
|
||||||
const ANSI_COLORS = {
|
|
||||||
default: 0,
|
|
||||||
black: 30,
|
|
||||||
red: 31,
|
|
||||||
green: 32,
|
|
||||||
yellow: 33,
|
|
||||||
blue: 34,
|
|
||||||
magenta: 35,
|
|
||||||
cyan: 36,
|
|
||||||
white: 37,
|
|
||||||
}
|
|
||||||
|
|
||||||
function colorCode(color) {
|
|
||||||
return `\x1b[${ANSI_COLORS[color]}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
function colorize(content, color) {
|
|
||||||
if (!color) return content
|
|
||||||
return `${colorCode(color)}${content}${colorCode('default')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdin.setRawMode(true)
|
|
||||||
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
terminal: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
function waitForEnter() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
rl.once('line', resolve)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLine(content, color) {
|
|
||||||
let result = colorCode(color)
|
|
||||||
let codeBlock = false
|
|
||||||
for (const c of content) {
|
|
||||||
switch (c) {
|
|
||||||
case '`':
|
|
||||||
result += colorCode(codeBlock ? color : 'green')
|
|
||||||
codeBlock = !codeBlock
|
|
||||||
break
|
|
||||||
|
|
||||||
default:
|
|
||||||
result += c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result + colorCode('default') + RETURN_SYMBOL
|
|
||||||
}
|
|
||||||
|
|
||||||
function printLine(content, delay, color) {
|
|
||||||
const printContent = formatLine(content, color)
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let i = 0
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
process.stdout.write(printContent[i])
|
|
||||||
i += 1
|
|
||||||
if (i >= printContent.length) {
|
|
||||||
clearInterval(timer)
|
|
||||||
process.stdout.write('\n')
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
}, delay)
|
|
||||||
|
|
||||||
waitForEnter().then(() => {
|
|
||||||
clearInterval(timer)
|
|
||||||
if (i >= printContent.length) return
|
|
||||||
process.stdout.write(printContent.slice(i))
|
|
||||||
process.stdout.write('\n')
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function printResponses(responses, delay = 100) {
|
|
||||||
console.log()
|
|
||||||
console.log(colorize('==============================', 'green'))
|
|
||||||
for (const response of responses) {
|
|
||||||
const { type, content, speaker, color } = response
|
|
||||||
if (type === 'narrative') {
|
|
||||||
for (const line of content) {
|
|
||||||
await printLine(line, delay, color || 'cyan')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (type === 'dialogue') {
|
|
||||||
console.log(colorize(`${speaker}:`, 'green'))
|
|
||||||
for (const line of content) {
|
|
||||||
await printLine(` ${line}`, delay, color || 'cyan')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(colorize('------------------------------', 'green'))
|
|
||||||
console.log()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function printHints(hints, delay = 100) {
|
|
||||||
for (const hint of hints) {
|
|
||||||
await printLine(hint, delay, 'yellow')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
printResponses,
|
|
||||||
printHints,
|
|
||||||
colorize,
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const os = require('os')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
function resolvePath(p) {
|
||||||
|
return path.resolve(process.cwd(), p.replace('~', os.homedir()))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolvePath,
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -2,25 +2,28 @@ import { basename } from './utils.js'
|
|||||||
|
|
||||||
export interface FileObject {
|
export interface FileObject {
|
||||||
path: string
|
path: string
|
||||||
type: string
|
type: 'file' | 'directory'
|
||||||
discovered: boolean
|
discovered: boolean
|
||||||
|
empty?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class File implements FileObject {
|
export default class File implements FileObject {
|
||||||
path: string
|
path: string
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: 'file' | 'directory'
|
||||||
discovered: boolean
|
discovered: boolean
|
||||||
|
empty?: boolean
|
||||||
|
|
||||||
constructor(obj: FileObject) {
|
constructor(obj: FileObject) {
|
||||||
this.path = obj.path.trimEnd()
|
this.path = obj.path.trimEnd()
|
||||||
this.name = basename(obj.path)
|
this.name = basename(obj.path)
|
||||||
this.type = obj.type
|
this.type = obj.type
|
||||||
this.discovered = obj.discovered
|
this.discovered = obj.discovered
|
||||||
|
this.empty = obj.empty && obj.type === 'directory'
|
||||||
}
|
}
|
||||||
|
|
||||||
isDirectory() {
|
isDirectory() {
|
||||||
return this.type === 'folder'
|
return this.type === 'directory'
|
||||||
}
|
}
|
||||||
|
|
||||||
contains(file: FileObject) {
|
contains(file: FileObject) {
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { FileObject } from './file.js'
|
import { FileObject } from './file.js'
|
||||||
import FileNode from './fileNode.js'
|
import FileNode from './fileNode.js'
|
||||||
|
|
||||||
export interface Event {
|
export interface FileGraphUpdateEvent {
|
||||||
add?: FileObject[]
|
add?: FileObject[]
|
||||||
remove?: FileObject[]
|
remove?: FileObject[]
|
||||||
discover?: FileObject[]
|
discover?: FileObject[]
|
||||||
|
pwd?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class FileGraph extends FileNode {
|
export default class FileGraph extends FileNode {
|
||||||
@@ -20,7 +21,7 @@ export default class FileGraph extends FileNode {
|
|||||||
|
|
||||||
remove(files: FileObject[]) {
|
remove(files: FileObject[]) {
|
||||||
// Logic to remove a file node from the graph
|
// Logic to remove a file node from the graph
|
||||||
// If the file node is a folder, also remove all children
|
// If the file node is a directory, also remove all children
|
||||||
// If the file node doesn't exist, throw an error
|
// If the file node doesn't exist, throw an error
|
||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
@@ -33,6 +34,10 @@ export default class FileGraph extends FileNode {
|
|||||||
// Overwrite the file graph with the provided files
|
// Overwrite the file graph with the provided files
|
||||||
// Remove file nodes that don't exist anymore and add new ones
|
// Remove file nodes that don't exist anymore and add new ones
|
||||||
|
|
||||||
|
if (eventFiles.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const newNode = new FileNode(eventFiles[0])
|
const newNode = new FileNode(eventFiles[0])
|
||||||
|
|
||||||
eventFiles.slice(1).forEach((file) => {
|
eventFiles.slice(1).forEach((file) => {
|
||||||
@@ -42,19 +47,16 @@ export default class FileGraph extends FileNode {
|
|||||||
this.merge(newNode)
|
this.merge(newNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEvent(event: Event) {
|
handleEvent(event: FileGraphUpdateEvent) {
|
||||||
// eslint-disable-next-line default-case
|
// eslint-disable-next-line default-case
|
||||||
switch (true) {
|
if (event.add) {
|
||||||
case !!event.add:
|
this.add(event.add)
|
||||||
this.add(event.add)
|
}
|
||||||
break
|
if (event.remove) {
|
||||||
case !!event.remove:
|
this.remove(event.remove)
|
||||||
this.remove(event.remove)
|
}
|
||||||
break
|
if (event.discover) {
|
||||||
case !!event.discover:
|
this.discover(event.discover)
|
||||||
this.discover(event.discover)
|
|
||||||
break
|
|
||||||
// Implement other cases if needed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import File, { FileObject } from './file.js'
|
import File, { FileObject } from './file.js'
|
||||||
import {
|
import { ParentNotExistsError, FileNotExistsError } from './errors.js'
|
||||||
DuplicateItemError,
|
|
||||||
ParentNotExistsError,
|
|
||||||
FileNotExistsError,
|
|
||||||
} from './errors.js'
|
|
||||||
import { dirname } from './utils.js'
|
import { dirname } from './utils.js'
|
||||||
|
|
||||||
export default class FileNode extends File {
|
export interface IFileNode {
|
||||||
|
path: string
|
||||||
|
type: 'file' | 'directory'
|
||||||
|
discovered: boolean
|
||||||
|
children?: IFileNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class FileNode extends File implements IFileNode {
|
||||||
children: FileNode[]
|
children: FileNode[]
|
||||||
|
|
||||||
constructor(file: FileObject | FileNode) {
|
constructor(file: FileObject | IFileNode) {
|
||||||
super(file)
|
super(file)
|
||||||
this.children = []
|
this.children = []
|
||||||
if ((<FileNode>file).children)
|
if ((<FileNode>file).children)
|
||||||
@@ -22,15 +25,24 @@ export default class FileNode extends File {
|
|||||||
) {
|
) {
|
||||||
const file = new FileNode(fileInput)
|
const file = new FileNode(fileInput)
|
||||||
if (file.path === this.path) {
|
if (file.path === this.path) {
|
||||||
throw new DuplicateItemError(
|
if (!this.discovered) {
|
||||||
`File ${file.path} already exists in the tree: ${this.toString()}`
|
this.discovered = file.discovered
|
||||||
)
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.children.some((child) => child.path === file.path)) {
|
let existing = false
|
||||||
throw new DuplicateItemError(
|
for (const child of this.children) {
|
||||||
`File ${file.path} already exists in the tree: ${this.toString()}`
|
if (child.path === file.path) {
|
||||||
)
|
existing = true
|
||||||
|
if (!child.discovered) {
|
||||||
|
child.discovered = file.discovered
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentPath = dirname(file.path)
|
const parentPath = dirname(file.path)
|
||||||
@@ -41,11 +53,11 @@ export default class FileNode extends File {
|
|||||||
// Recursively call addChild on the correct child node
|
// Recursively call addChild on the correct child node
|
||||||
const childNode = this.children.find((child) => child.contains(file))
|
const childNode = this.children.find((child) => child.contains(file))
|
||||||
if (childNode) {
|
if (childNode) {
|
||||||
childNode.addChild(file)
|
childNode.addChild(file, { makeParents })
|
||||||
} else if (makeParents) {
|
} else if (makeParents) {
|
||||||
const parent = new FileNode({
|
const parent = new FileNode({
|
||||||
path: parentPath,
|
path: parentPath,
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: false,
|
discovered: false,
|
||||||
children: [file],
|
children: [file],
|
||||||
})
|
})
|
||||||
@@ -88,6 +100,12 @@ export default class FileNode extends File {
|
|||||||
// Update the state of this node
|
// Update the state of this node
|
||||||
this.discovered = this.discovered || fileNode.discovered
|
this.discovered = this.discovered || fileNode.discovered
|
||||||
|
|
||||||
|
// the provided directory is empty, remove all children
|
||||||
|
if (fileNode.empty) {
|
||||||
|
this.children = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fileNode.children.forEach((child) => {
|
fileNode.children.forEach((child) => {
|
||||||
const existingChild = this.children.find((c) => c.path === child.path)
|
const existingChild = this.children.find((c) => c.path === child.path)
|
||||||
if (existingChild) {
|
if (existingChild) {
|
||||||
@@ -133,4 +151,11 @@ export default class FileNode extends File {
|
|||||||
.join('')
|
.join('')
|
||||||
return `${name}\n${childrenString}`
|
return `${name}\n${childrenString}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
include(filePath: string, type?: 'file' | 'directory'): boolean {
|
||||||
|
if (this.path === filePath) {
|
||||||
|
return type ? this.type === type : true
|
||||||
|
}
|
||||||
|
return this.children.some((child) => child.include(filePath, type))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as File } from './file'
|
||||||
|
export type { FileObject } from './file'
|
||||||
|
export { default as FileNode } from './fileNode'
|
||||||
|
export type { IFileNode } from './fileNode'
|
||||||
|
export { default as FileGraph } from './fileGraph'
|
||||||
|
export type { FileGraphUpdateEvent } from './fileGraph'
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@linux-odyssey/file-graph",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"exports": "./dist/index.js",
|
|
||||||
"license": "MIT",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"dev": "tsc -w",
|
|
||||||
"test": "yarn build && jest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { default as File, FileObject } from './file.js'
|
|
||||||
export { default as FileNode } from './fileNode.js'
|
|
||||||
export { default as FileGraph, Event } from './fileGraph.js'
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, test, expect } from '@jest/globals'
|
import { describe, test, expect } from '@jest/globals'
|
||||||
import File from '../src/file.js'
|
import File from '../file.js'
|
||||||
|
|
||||||
describe('File', () => {
|
describe('File', () => {
|
||||||
let file: File
|
let file: File
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
file = new File({ path: '/home/user', type: 'folder', discovered: true })
|
file = new File({ path: '/home/user', type: 'directory', discovered: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
const filesToCompare = [
|
const filesToCompare = [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, test, expect } from '@jest/globals'
|
import { describe, test, expect } from '@jest/globals'
|
||||||
import FileGraph from '../src/fileGraph.js'
|
import FileGraph from '../fileGraph.js'
|
||||||
import FileNode from '../src/fileNode.js'
|
import FileNode from '../fileNode.js'
|
||||||
|
import { FileObject } from '../file.js'
|
||||||
|
|
||||||
describe('FileGraph', () => {
|
describe('FileGraph', () => {
|
||||||
let root: FileNode
|
let root: FileNode
|
||||||
@@ -8,7 +9,7 @@ describe('FileGraph', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
root = new FileNode({
|
root = new FileNode({
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -16,7 +17,7 @@ describe('FileGraph', () => {
|
|||||||
test('add files to graph', () => {
|
test('add files to graph', () => {
|
||||||
const fileGraph = new FileGraph(root)
|
const fileGraph = new FileGraph(root)
|
||||||
|
|
||||||
const filesToAdd = [
|
const filesToAdd: FileObject[] = [
|
||||||
{
|
{
|
||||||
path: '/home/user/file1.txt',
|
path: '/home/user/file1.txt',
|
||||||
type: 'file',
|
type: 'file',
|
||||||
@@ -24,7 +25,12 @@ describe('FileGraph', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1',
|
path: '/home/user/folder1',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/folder2/file2.txt',
|
||||||
|
type: 'file',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -32,7 +38,34 @@ describe('FileGraph', () => {
|
|||||||
fileGraph.add(filesToAdd)
|
fileGraph.add(filesToAdd)
|
||||||
|
|
||||||
expect(fileGraph.children.length).toBe(1)
|
expect(fileGraph.children.length).toBe(1)
|
||||||
expect(fileGraph.children[0].children[0].children.length).toBe(2)
|
expect(fileGraph.children[0].children[0].children.length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignore files that already exist', () => {
|
||||||
|
const fileGraph = new FileGraph(root)
|
||||||
|
|
||||||
|
const filesToAdd: FileObject[] = [
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1',
|
||||||
|
type: 'directory',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1/file1.txt',
|
||||||
|
type: 'file',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
fileGraph.add(filesToAdd)
|
||||||
|
|
||||||
|
expect(fileGraph.children.length).toBe(1)
|
||||||
|
expect(fileGraph.children[0].children[0].children.length).toBe(1)
|
||||||
|
|
||||||
|
fileGraph.add([filesToAdd[1]])
|
||||||
|
|
||||||
|
expect(fileGraph.children.length).toBe(1)
|
||||||
|
expect(fileGraph.children[0].children[0].children.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('remove files from graph', () => {
|
test('remove files from graph', () => {
|
||||||
@@ -41,12 +74,12 @@ describe('FileGraph', () => {
|
|||||||
const filesToAdd = [
|
const filesToAdd = [
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1',
|
path: '/home/user/folder1',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/user/folder2',
|
path: '/home/user/folder2',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,22 +94,22 @@ describe('FileGraph', () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
fileGraph.add(filesToAdd)
|
fileGraph.add(filesToAdd as FileObject[])
|
||||||
|
|
||||||
const filesToRemove = [
|
const filesToRemove = [
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1/file1.txt',
|
path: '/home/user/folder1/file1.txt',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/user/folder2',
|
path: '/home/user/folder2',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
fileGraph.remove(filesToRemove)
|
fileGraph.remove(filesToRemove as FileObject[])
|
||||||
|
|
||||||
expect(fileGraph.children[0].children[0].children.length).toBe(1)
|
expect(fileGraph.children[0].children[0].children.length).toBe(1)
|
||||||
expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1')
|
expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1')
|
||||||
@@ -90,7 +123,7 @@ describe('FileGraph', () => {
|
|||||||
const discoverFiles = [
|
const discoverFiles = [
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1',
|
path: '/home/user/folder1',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,7 +143,7 @@ describe('FileGraph', () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
fileGraph.discover(discoverFiles)
|
fileGraph.discover(discoverFiles as FileObject[])
|
||||||
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||||
3
|
3
|
||||||
)
|
)
|
||||||
@@ -122,12 +155,12 @@ describe('FileGraph', () => {
|
|||||||
const filesToAdd = [
|
const filesToAdd = [
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1',
|
path: '/home/user/folder1',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/user/folder2',
|
path: '/home/user/folder2',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -142,12 +175,12 @@ describe('FileGraph', () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
fileGraph.add(filesToAdd)
|
fileGraph.add(filesToAdd as FileObject[])
|
||||||
|
|
||||||
const newFiles = [
|
const newFiles = [
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1',
|
path: '/home/user/folder1',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -167,11 +200,43 @@ describe('FileGraph', () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
fileGraph.discover(newFiles)
|
fileGraph.discover(newFiles as FileObject[])
|
||||||
|
|
||||||
expect(fileGraph.children[0].children[0].children.length).toBe(2)
|
expect(fileGraph.children[0].children[0].children.length).toBe(2)
|
||||||
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||||
3
|
3
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('discover a empty directory which children just being deleted', () => {
|
||||||
|
const fileGraph = new FileGraph(root)
|
||||||
|
const discoverFiles = [
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1',
|
||||||
|
type: 'directory',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1/file1.txt',
|
||||||
|
type: 'file',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
fileGraph.add(discoverFiles as FileObject[])
|
||||||
|
expect(fileGraph.include('/home/user/folder1/file1.txt', 'file')).toBe(true)
|
||||||
|
|
||||||
|
fileGraph.discover([
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1',
|
||||||
|
type: 'directory',
|
||||||
|
discovered: true,
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(fileGraph.include('/home/user/folder1/file1.txt', 'file')).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, test, expect } from '@jest/globals'
|
import { describe, test, expect } from '@jest/globals'
|
||||||
import { DuplicateItemError, ParentNotExistsError } from '../src/errors.js'
|
import { ParentNotExistsError } from '../errors.js'
|
||||||
import FileNode from '../src/fileNode.js'
|
import FileNode, { IFileNode } from '../fileNode.js'
|
||||||
import { FileObject } from '../src/file.js'
|
import { FileObject } from '../file.js'
|
||||||
|
|
||||||
function createNodeFromFiles(files: FileObject[]) {
|
function createNodeFromFiles(files: FileObject[]) {
|
||||||
const node = new FileNode(files[0])
|
const node = new FileNode(files[0])
|
||||||
@@ -42,17 +42,16 @@ describe('FileNode', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rootNode = new FileNode({
|
rootNode = new FileNode({
|
||||||
path: '/home/rudeus',
|
path: '/home/rudeus',
|
||||||
name: 'rudeus',
|
type: 'directory',
|
||||||
type: 'folder',
|
|
||||||
discovered: true,
|
discovered: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should throw error when adding duplicate item', () => {
|
test('should override discovered when adding duplicate item', () => {
|
||||||
const child = {
|
const child: FileObject = {
|
||||||
path: '/home/rudeus/test',
|
path: '/home/rudeus/test',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adding the child for the first time
|
// Adding the child for the first time
|
||||||
@@ -61,16 +60,16 @@ describe('FileNode', () => {
|
|||||||
expect(rootNode.children.length).toBe(1)
|
expect(rootNode.children.length).toBe(1)
|
||||||
|
|
||||||
// Trying to add the same child again should throw an error
|
// Trying to add the same child again should throw an error
|
||||||
expect(() => {
|
child.discovered = true
|
||||||
rootNode.addChild(child)
|
rootNode.addChild(child)
|
||||||
}).toThrow(DuplicateItemError)
|
|
||||||
|
expect(rootNode.children[0].discovered).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should throw error when parent does not exist', () => {
|
test('should throw error when parent does not exist', () => {
|
||||||
const child = {
|
const child: FileObject = {
|
||||||
path: '/home/rudeus/alphabet/test',
|
path: '/home/rudeus/alphabet/test',
|
||||||
name: 'test',
|
type: 'directory',
|
||||||
type: 'folder',
|
|
||||||
discovered: true,
|
discovered: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,22 +80,19 @@ describe('FileNode', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('adds nested children to the tree', () => {
|
test('adds nested children to the tree', () => {
|
||||||
const files = [
|
const files: FileObject[] = [
|
||||||
{
|
{
|
||||||
path: '/home/rudeus/forgotten_scroll.txt',
|
path: '/home/rudeus/forgotten_scroll.txt',
|
||||||
name: 'forgotten_scroll.txt',
|
|
||||||
type: 'file',
|
type: 'file',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/rudeus/test',
|
path: '/home/rudeus/test',
|
||||||
name: 'test',
|
type: 'directory',
|
||||||
type: 'folder',
|
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/rudeus/test/a',
|
path: '/home/rudeus/test/a',
|
||||||
name: 'a',
|
|
||||||
type: 'file',
|
type: 'file',
|
||||||
discovered: false,
|
discovered: false,
|
||||||
},
|
},
|
||||||
@@ -111,9 +107,8 @@ describe('FileNode', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('adds nested children to the tree with makeParents option', () => {
|
test('adds nested children to the tree with makeParents option', () => {
|
||||||
const file = {
|
const file: FileObject = {
|
||||||
path: '/home/rudeus/test/a/b/c/d/e',
|
path: '/home/rudeus/test/a/b/c/d/e',
|
||||||
name: 'a',
|
|
||||||
type: 'file',
|
type: 'file',
|
||||||
discovered: false,
|
discovered: false,
|
||||||
}
|
}
|
||||||
@@ -129,34 +124,34 @@ describe('FileNode', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('merge-subtree', () => {
|
test('merge-subtree', () => {
|
||||||
const root = {
|
const root: FileObject = {
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
}
|
}
|
||||||
const files1 = [
|
const files1: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/d', type: 'file', discovered: true },
|
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/f', type: 'file', discovered: true },
|
{ path: '/b/c/f', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const files2 = [
|
const files2: FileObject[] = [
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/e', type: 'file', discovered: true },
|
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/g', type: 'file', discovered: true },
|
{ path: '/b/c/g', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const expected = [
|
const expected: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/e', type: 'file', discovered: true },
|
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/g', type: 'file', discovered: true },
|
{ path: '/b/c/g', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
mergeTest(files1, files2, expected)
|
mergeTest(files1, files2, expected)
|
||||||
@@ -166,90 +161,94 @@ describe('FileNode', () => {
|
|||||||
// Test if the node to merge is only the middle part of the tree
|
// Test if the node to merge is only the middle part of the tree
|
||||||
// (i.e. the node to merge doesn't contains all level of children)
|
// (i.e. the node to merge doesn't contains all level of children)
|
||||||
// The original tree should keep the children that are not in the node to merge
|
// The original tree should keep the children that are not in the node to merge
|
||||||
const root = {
|
const root: FileObject = {
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
}
|
}
|
||||||
const files1 = [
|
const files1: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/d', type: 'file', discovered: true },
|
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/f', type: 'file', discovered: true },
|
{ path: '/b/c/f', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const files2 = [
|
const files2: FileObject[] = [
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/e', type: 'file', discovered: true },
|
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const expected = [
|
const expected: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/e', type: 'file', discovered: true },
|
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/f', type: 'file', discovered: true },
|
{ path: '/b/c/f', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
mergeTest(files1, files2, expected)
|
mergeTest(files1, files2, expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('merge-hidden', () => {
|
test('merge-hidden', () => {
|
||||||
const root = {
|
const root: FileObject = {
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
}
|
}
|
||||||
const files1 = [
|
const files1: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/.b', type: 'file', discovered: false },
|
{ path: '/.b', type: 'file' as const, discovered: false },
|
||||||
{ path: '/c', type: 'folder', discovered: true },
|
{ path: '/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/c/.d', type: 'file', discovered: false },
|
{ path: '/c/.d', type: 'file' as const, discovered: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
const files2 = [
|
const files2: FileObject[] = [
|
||||||
{ path: '/c', type: 'folder', discovered: true },
|
{ path: '/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/c/.d', type: 'file', discovered: true },
|
{ path: '/c/.d', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const expected = [
|
const expected: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/.b', type: 'file', discovered: false },
|
{ path: '/.b', type: 'file' as const, discovered: false },
|
||||||
{ path: '/c', type: 'folder', discovered: true },
|
{ path: '/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/c/.d', type: 'file', discovered: true },
|
{ path: '/c/.d', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
mergeTest(files1, files2, expected)
|
mergeTest(files1, files2, expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('remove-child', () => {
|
test('remove-child', () => {
|
||||||
const root = {
|
const root: FileObject = {
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
}
|
}
|
||||||
const files = [
|
const files: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/d', type: 'file', discovered: true },
|
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/f', type: 'file', discovered: true },
|
{ path: '/b/c/f', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const fileToRemove = { path: '/b/c', type: 'folder', discovered: true }
|
const fileToRemove: FileObject = {
|
||||||
|
path: '/b/c',
|
||||||
|
type: 'directory',
|
||||||
|
discovered: true,
|
||||||
|
}
|
||||||
|
|
||||||
const expected = [
|
const expected: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/d', type: 'file', discovered: true },
|
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
removeChildTest(files, fileToRemove, expected)
|
removeChildTest(files, fileToRemove, expected)
|
||||||
@@ -257,9 +256,9 @@ describe('FileNode', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('load nested fileNode', () => {
|
test('load nested fileNode', () => {
|
||||||
const data = {
|
const data: IFileNode = {
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -269,12 +268,12 @@ test('load nested fileNode', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/b',
|
path: '/b',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '/b/c',
|
path: '/b/c',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"composite": true,
|
|
||||||
"outDir": "./dist"
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
File renamed without changes.
@@ -0,0 +1,48 @@
|
|||||||
|
import { Condition } from './condition/Condition'
|
||||||
|
import {
|
||||||
|
ICommand,
|
||||||
|
ICondition,
|
||||||
|
IFileExistenceChecker,
|
||||||
|
IRequirements,
|
||||||
|
IResponse,
|
||||||
|
} from './schema'
|
||||||
|
|
||||||
|
export class Event {
|
||||||
|
id: string
|
||||||
|
private condition: Condition
|
||||||
|
private requirements: string[]
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id: string,
|
||||||
|
condition: ICondition,
|
||||||
|
private response: IResponse,
|
||||||
|
requirements?: IRequirements
|
||||||
|
) {
|
||||||
|
this.id = id
|
||||||
|
this.condition = new Condition(condition)
|
||||||
|
this.requirements = requirements || []
|
||||||
|
}
|
||||||
|
|
||||||
|
async satisfies(
|
||||||
|
command: ICommand,
|
||||||
|
checker: IFileExistenceChecker
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.condition.satisfies(command, checker)
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkRequirements(completed: string[]): boolean {
|
||||||
|
return this.requirements.every((req) => completed.includes(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
private finished(completed: string[]): boolean {
|
||||||
|
return completed.includes(this.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
active(completed: string[]): boolean {
|
||||||
|
return !this.finished(completed) && this.checkRequirements(completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
getResponse(): IResponse {
|
||||||
|
return this.response
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* eslint-disable max-classes-per-file */
|
||||||
|
import { Event } from './Event'
|
||||||
|
import { IStageException, IGlobalException } from './schema/exception'
|
||||||
|
|
||||||
|
export class StageException extends Event {
|
||||||
|
readonly catchAll: boolean
|
||||||
|
|
||||||
|
constructor({ id, condition, response, catchAll }: IStageException) {
|
||||||
|
super(id, condition, response)
|
||||||
|
this.catchAll = catchAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GlobalException extends Event {
|
||||||
|
constructor({ id, condition, response, requirements }: IGlobalException) {
|
||||||
|
super(id, condition, response, requirements)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
import { Event } from './Event'
|
||||||
|
import { GlobalException } from './Exception'
|
||||||
|
import {
|
||||||
|
IQuest,
|
||||||
|
IFileExistenceChecker,
|
||||||
|
ICommand,
|
||||||
|
IResponse,
|
||||||
|
ITask,
|
||||||
|
} from './schema'
|
||||||
|
import { Stage } from './Stage'
|
||||||
|
|
||||||
|
export class Quest {
|
||||||
|
private quest: IQuest
|
||||||
|
private stages: Stage[]
|
||||||
|
private exceptions: GlobalException[] = []
|
||||||
|
private events: Map<string, Event> = new Map()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
quest: IQuest,
|
||||||
|
private checker: IFileExistenceChecker
|
||||||
|
) {
|
||||||
|
this.quest = quest
|
||||||
|
this.stages = quest.stages.map((stage) => new Stage(stage))
|
||||||
|
for (const stage of this.stages) {
|
||||||
|
this.events.set(stage.id, stage)
|
||||||
|
for (const exception of stage.getExceptions()) {
|
||||||
|
this.events.set(exception.id, exception)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const exception of quest.exceptions ?? []) {
|
||||||
|
const globalException = new GlobalException(exception)
|
||||||
|
this.exceptions.push(globalException)
|
||||||
|
this.events.set(globalException.id, globalException)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveStages(completed: string[]) {
|
||||||
|
return this.stages.filter((stage) => stage.active(completed))
|
||||||
|
}
|
||||||
|
|
||||||
|
private getActiveExceptions(completed: string[]) {
|
||||||
|
return this.exceptions.filter((exception) => exception.active(completed))
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSatisfiedEvent(
|
||||||
|
command: ICommand,
|
||||||
|
completed: string[]
|
||||||
|
): Promise<string | null> {
|
||||||
|
const stages = this.getActiveStages(completed)
|
||||||
|
let catchAllException: string | null = null
|
||||||
|
for (const stage of stages) {
|
||||||
|
const satisfies = await stage.satisfies(command, this.checker)
|
||||||
|
if (satisfies) {
|
||||||
|
return stage.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const exception of stages.flatMap((stage) => stage.getExceptions())) {
|
||||||
|
if (exception.catchAll && !catchAllException) {
|
||||||
|
catchAllException = exception.id
|
||||||
|
} else if (await exception.satisfies(command, this.checker)) {
|
||||||
|
return exception.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const exception of this.getActiveExceptions(completed)) {
|
||||||
|
if (await exception.satisfies(command, this.checker)) {
|
||||||
|
return exception.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return catchAllException
|
||||||
|
}
|
||||||
|
|
||||||
|
getResponses(completed: string[]): IResponse[] {
|
||||||
|
return completed.map((id) => this.events.get(id)!.getResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
getTasks(completed: string[]): ITask[] {
|
||||||
|
const completedSet = new Set(completed)
|
||||||
|
const completedStages = this.stages
|
||||||
|
.filter((stage) => completedSet.has(stage.id))
|
||||||
|
.map((stage) => ({
|
||||||
|
id: stage.id,
|
||||||
|
name: stage.name,
|
||||||
|
completed: true,
|
||||||
|
}))
|
||||||
|
const activeStages = this.getActiveStages(completed).map((stage) => ({
|
||||||
|
id: stage.id,
|
||||||
|
name: stage.name,
|
||||||
|
completed: false,
|
||||||
|
}))
|
||||||
|
return [...completedStages, ...activeStages]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { FileGraph, IFileNode } from '../file-graph'
|
||||||
|
import { Quest } from './Quest'
|
||||||
|
import {
|
||||||
|
IQuest,
|
||||||
|
ICommand,
|
||||||
|
ISession,
|
||||||
|
IFileExistenceChecker,
|
||||||
|
IResponse,
|
||||||
|
ITask,
|
||||||
|
} from './schema'
|
||||||
|
|
||||||
|
export class GameSession {
|
||||||
|
private completed: string[] = []
|
||||||
|
private quest: Quest
|
||||||
|
private graph: FileGraph
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
{ completedEvents, graph }: Partial<ISession>,
|
||||||
|
quest: IQuest,
|
||||||
|
checker: IFileExistenceChecker
|
||||||
|
) {
|
||||||
|
this.completed = completedEvents || []
|
||||||
|
this.quest = new Quest(quest, checker)
|
||||||
|
this.graph = new FileGraph(
|
||||||
|
graph ?? { path: '/', type: 'directory', discovered: false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get completedEvents() {
|
||||||
|
return this.completed
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(stageId: string) {
|
||||||
|
this.completed.push(stageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveStages(): string[] {
|
||||||
|
return this.quest.getActiveStages(this.completed).map((stage) => stage.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async runCommand(command: ICommand) {
|
||||||
|
if (command.params) {
|
||||||
|
this.graph.handleEvent(command.params)
|
||||||
|
}
|
||||||
|
const stageId = await this.quest.findSatisfiedEvent(command, this.completed)
|
||||||
|
if (stageId) {
|
||||||
|
this.complete(stageId)
|
||||||
|
}
|
||||||
|
return stageId
|
||||||
|
}
|
||||||
|
|
||||||
|
isFinished() {
|
||||||
|
return this.getActiveStages().length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
getResponses(): IResponse[] {
|
||||||
|
return this.quest.getResponses(this.completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
getTasks(): ITask[] {
|
||||||
|
return this.quest.getTasks(this.completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
getGraph(): IFileNode {
|
||||||
|
return this.graph
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { IStage } from './schema'
|
||||||
|
import { StageException } from './Exception'
|
||||||
|
import { Event } from './Event'
|
||||||
|
|
||||||
|
export class Stage extends Event {
|
||||||
|
name: string
|
||||||
|
private exceptions: StageException[]
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
condition,
|
||||||
|
requirements,
|
||||||
|
exceptions,
|
||||||
|
response,
|
||||||
|
}: IStage) {
|
||||||
|
super(id, condition, response, requirements)
|
||||||
|
this.name = name || ''
|
||||||
|
this.exceptions =
|
||||||
|
exceptions?.map((exception) => new StageException(exception)) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
getExceptions(): StageException[] {
|
||||||
|
return this.exceptions
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema'
|
||||||
|
import fs from 'fs'
|
||||||
|
import { questSchema } from './schema'
|
||||||
|
|
||||||
|
const jsonSchema = zodToJsonSchema(questSchema)
|
||||||
|
|
||||||
|
fs.writeFileSync('quest.json', JSON.stringify(jsonSchema, null, 2))
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ICommand } from '../schema'
|
||||||
|
import { Matcher } from './Matcher.js'
|
||||||
|
|
||||||
|
export class CommandMatcher implements Matcher {
|
||||||
|
private matcher: RegExp
|
||||||
|
constructor(command: string, flags?: string) {
|
||||||
|
this.matcher = new RegExp(command, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
match(command: ICommand): boolean {
|
||||||
|
return !!command.command && this.matcher.test(command.command)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
ICommand,
|
||||||
|
IFileExistenceChecker,
|
||||||
|
ICondition,
|
||||||
|
IFileExistenceInput,
|
||||||
|
} from '../schema'
|
||||||
|
import { PwdMatcher } from './PwdMatcher.js'
|
||||||
|
import { CommandMatcher } from './CommandMatcher.js'
|
||||||
|
import { ErrorMatcher } from './ErrorMatcher.js'
|
||||||
|
import { Matcher } from './Matcher.js'
|
||||||
|
import { OutputMatcher } from './OutputMatcher.js'
|
||||||
|
import { OrMatcher } from './OrMatcher.js'
|
||||||
|
import { NotMatcher } from './NotMatcher.js'
|
||||||
|
import { checkFiles } from './FileMatcher.js'
|
||||||
|
|
||||||
|
export class Condition {
|
||||||
|
private files: IFileExistenceInput[] = []
|
||||||
|
private matchers: Matcher[] = []
|
||||||
|
|
||||||
|
constructor({ command, output, error, pwd, or, not, files }: ICondition) {
|
||||||
|
if (command) this.matchers.push(new CommandMatcher(command))
|
||||||
|
if (output) this.matchers.push(new OutputMatcher(output))
|
||||||
|
if (pwd) this.matchers.push(new PwdMatcher(pwd))
|
||||||
|
if (or) {
|
||||||
|
const conditions = or.map((condition) => new Condition(condition))
|
||||||
|
this.matchers.push(new OrMatcher(conditions))
|
||||||
|
}
|
||||||
|
if (not) this.matchers.push(new NotMatcher(new Condition(not)))
|
||||||
|
if (files) this.files = files
|
||||||
|
this.matchers.push(new ErrorMatcher(error)) // always check unexpected error
|
||||||
|
}
|
||||||
|
|
||||||
|
match(command: ICommand): boolean {
|
||||||
|
return this.matchers.every((matcher) => {
|
||||||
|
return matcher.match(command)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(checker: IFileExistenceChecker): Promise<boolean> {
|
||||||
|
if (this.matchers.length === 0) return true
|
||||||
|
return checkFiles(checker, this.files)
|
||||||
|
}
|
||||||
|
|
||||||
|
async satisfies(
|
||||||
|
command: ICommand,
|
||||||
|
checker: IFileExistenceChecker
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Error matcher is always true
|
||||||
|
if (this.matchers.length === 1 && this.files.length === 0) return false
|
||||||
|
// eslint-disable-next-line no-return-await
|
||||||
|
return this.match(command) && (await this.check(checker))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 264 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user