Compare commits

..
221 Commits
Author SHA1 Message Date
Lin, Hung-Shin 4a4ce11bcc Analytics revise (#91)
Analytics revise
2023-12-21 18:13:53 +08:00
Chen, Tai-Lin 9c650a4c26 fix: restore db name 2023-12-21 18:09:48 +08:00
Chen, Tai-Lin b96bcb27ad refactor: remove unused functions 2023-12-21 18:01:25 +08:00
Chen, Tai-Lin b0e12b2027 refactor: use questList to calculate total quests and completed 2023-12-21 17:58:52 +08:00
Chen, Tai-Lin 3b68693177 feat: add pagination buttons 2023-12-21 17:48:36 +08:00
Chen, Tai-Lin fa36b63fbe feat: add order to pagination 2023-12-21 17:28:48 +08:00
Chen, Tai-Lin 68db16419d refactor: add Pagination class 2023-12-21 17:09:20 +08:00
Chen, Tai-Lin 19cd10ab7b fix: session pagination 2023-12-21 16:37:37 +08:00
Chen, Tai-Lin bc0055781e feat: finish errorCommands pagination 2023-12-21 16:31:34 +08:00
Chen, Tai-Lin 8c634c8115 wip: models/commands analytics 2023-12-20 15:05:10 +08:00
Chen, Tai-Lin ceb9416fab update: mongoose version 2023-12-13 15:05:46 +08:00
Chen, Tai-Lin c6733814ba feat: add sorting order 2023-12-13 15:05:25 +08:00
Chen, Tai-Lin 1b94ae75ca feat: change to nextKey in users 2023-12-13 14:40:47 +08:00
Chen, Tai-Lin 3e7092dbd6 feat: modify analytics 2023-12-11 21:13:06 +08:00
Chen, Tai-Lin 61f627b125 feat: add quest page in analytics 2023-12-11 17:55:15 +08:00
Chen, Tai-Lin a578d4f910 fix: analytics completed rate 2023-12-11 01:09:06 +08:00
Lin, Hung-Shin 1bca61da40 feat: add mongo dump script, require ci checks before deployment (#89)
feat: add mongo dump script, require ci checks before deployment
2023-12-10 08:58:02 +08:00
Lin, Hung-Shin 660a7ce4e8 feat-automation-test (stabilise) (#90)
feat-automation-test (stabilise)
2023-12-10 08:57:18 +08:00
Chen, Tai-Lin ac31ca8659 feat: show analytics data page by page 2023-12-10 03:21:30 +08:00
Chen, Tai-Lin 9200336b49 fix: analytics modify 2023-12-10 02:21:02 +08:00
Chen, Tai-Lin c243e29002 fix: re-calculate completed rate in analytics 2023-12-10 01:25:27 +08:00
Annie Huang 175db7b8bc reduce timeout 2023-12-10 00:08:45 +08:00
Annie Huang 581e75ee57 Add configs retrying and longer timeout to load 2023-12-09 21:18:03 +08:00
Chen, Tai-Lin b625abae68 change docker compose file 2023-12-08 20:49:01 +08:00
dependabot[bot] b0bfbb789c build(deps-dev): bump vite from 4.4.4 to 4.4.12 (#88)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.4.4 to 4.4.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.4.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.4.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-06 15:27:17 +08:00
wancat 2ce277c7e5 fix: gpt-4-turbo not supported yet, rollback 2023-12-06 15:25:15 +08:00
wancat 7ef77267d3 fix: update to use main branch action 2023-12-06 15:21:32 +08:00
wancat d34ae3ef7a feat: change code review model to save budget 2023-12-06 15:17:13 +08:00
wancat bfa7ed1cf9 fix: require CI to be passed before deployment 2023-12-06 15:10:04 +08:00
wancat aa5088cc97 feat: add mongo dump and restore script 2023-12-06 15:07:46 +08:00
Annie Huang 0d0465f722 feat Automation Tests - Updates (#87)
- Change spec name
- Add spec and complete all quests
- Add Icon (totem version)
- Add some id for tests
- Add answer. sh for all quests

Not yet added verifying the buttons function and other pages
2023-12-05 00:46:18 +08:00
Annie Huang 360279f765 Add logo to nav bar 2023-12-05 00:21:15 +08:00
Annie Huang 6c73a8abc0 remove typing delay, add last (command) parameter
prevent pressing enter too quick
2023-12-04 17:21:13 +08:00
Annie Huang 55a0dcb443 edit typing delay 2023-12-04 16:51:01 +08:00
Annie Huang 5b3c52bd9b reduce typing delay and timeout 2023-12-04 16:45:25 +08:00
Annie Huang a911d953cd Add searching element timeout 2023-12-04 15:54:26 +08:00
Annie Huang c52b5a53cd extend typing delay 2023-12-04 15:41:53 +08:00
Annie Huang c9a43eeeb1 remove redundant class 2023-12-04 15:24:53 +08:00
Annie Huang 69624d7a5d Add logo to login/register page 2023-12-04 15:22:05 +08:00
Annie Huang 4681a743b4 update icon (previous one was boroken) 2023-12-04 14:43:59 +08:00
Annie Huang e0b3ec74de extend typing delay to prevent test fail 2023-12-04 14:05:21 +08:00
Annie Huang d0b179108a fix failing on finishing 2023-12-03 21:30:34 +08:00
Annie Huang 10ac3c720e Add check finished, typing delay 2023-12-03 19:16:02 +08:00
Annie Huang 7a3b6b1cfb - Add auto-complete quest command
- Change spec name
- Add spec complete all quests
- Add Icon (totem version)
- Add some id for tests
- Add answer.sh for all quests
2023-12-03 01:40:51 +08:00
Lin, Hung-Shin dc1d3f6ce5 Feat-optimise-ui (#85)
Feat-optimise-ui
2023-11-27 06:26:13 +08:00
Lin, Hung-Shin 5cfe6823cc change xterm back to normal fontWeight 2023-11-27 00:10:52 +08:00
Lin, Hung-Shin 6cd348433a feat: display code block in terminal to different color 2023-11-27 00:10:41 +08:00
Lin, Hung-Shin 617bb56f5f Improve quest exceptions (#84) 2023-11-27 00:08:03 +08:00
Chen, Tai-Lin a859b4ff3e fix exceptions error 2023-11-26 23:48:55 +08:00
Lin, Hung-Shin 46fb8407a6 Feat markdown (#86) 2023-11-26 23:44:45 +08:00
Lin, Hung-Shin c7d18ad95e fix: stage re-execute 2023-11-26 23:43:44 +08:00
Lin, Hung-Shin 8f31e4f2cd feat: separate Markdown to a component 2023-11-26 23:32:37 +08:00
Chen, Tai-Lin d4446186cd improve exceptions 2023-11-26 23:32:31 +08:00
Annie Huang 8fb8af036d change color in content, bigger text in term 2023-11-26 23:31:38 +08:00
Chen, Tai-Lin 68492cd932 improve exceptions in quest path 2023-11-26 23:25:44 +08:00
Chen, Tai-Lin 3c1afb92e1 improve exceptions 2023-11-26 23:17:02 +08:00
Lin, Hung-Shin abaacf7ee7 feat: display markdown to html 2023-11-26 23:16:13 +08:00
Chen, Tai-Lin a22b898c0c improve quest exceptions 2023-11-26 23:12:36 +08:00
Chen, Tai-Lin d64a4ae81d improve quests 2023-11-26 23:06:14 +08:00
Lin, Hung-Shin ebd81f7cb4 fix: set content in response to be required 2023-11-26 22:36:10 +08:00
CHEN, TAI-LIN f657f633b4 Feat global exception (#83)
- add global exceptions
- refactor quest models to reusable parts

other improvements:
- improve error handling
2023-11-26 22:33:11 +08:00
Lin, Hung-Shin fb6411e923 feat: logger display error stack 2023-11-26 19:21:58 +08:00
Lin, Hung-Shin a7e646c28b feat: check global exceptions 2023-11-26 19:20:30 +08:00
Lin, Hung-Shin 98a226f3ff feat: load global exceptions 2023-11-26 18:40:39 +08:00
Lin, Hung-Shin 49fb5a6a61 fix: Remove exceptions from stages 2023-11-26 18:32:06 +08:00
Lin, Hung-Shin fe69841622 feat: improve parsing quest error message 2023-11-26 18:31:45 +08:00
Lin, Hung-Shin bb968706ad feat: refactor quest models
+ separate stage and exception
+ reuse commandResponder
2023-11-26 18:29:52 +08:00
Annie Huang 849e669147 Map init position, pwd display, instruct speed 2023-11-26 18:27:34 +08:00
Lin, Hung-Shin b41e67ee0b fix: error in quest read (#81) 2023-11-24 12:59:46 +08:00
Chen, Tai-Lin 50a0bbbab1 add warning 2023-11-24 12:59:09 +08:00
Chen, Tai-Lin d712925d0f modify path 2023-11-24 12:56:57 +08:00
Lin, Hung-Shin b78398680d fix: add error handling (#82) 2023-11-24 12:53:00 +08:00
Lin, Hung-Shin c7f52b1003 fix: add error handling 2023-11-24 12:52:01 +08:00
Chen, Tai-Lin f008fb9f60 modify quest map 2023-11-24 12:49:17 +08:00
Chen, Tai-Lin 76a39add29 fix: error in quest read 2023-11-24 11:54:30 +08:00
Lin, Hung-Shin eb9118d5b1 Quest update (#80) 2023-11-24 09:58:09 +08:00
Chen, Tai-Lin cfdf8730e8 fix error 2023-11-24 09:55:17 +08:00
Chen, Tai-Lin 6651071580 fix: error in quest read 2023-11-24 09:54:18 +08:00
Lin, Hung-Shin f30f6472f5 fix: error in quests (#79) 2023-11-24 09:51:33 +08:00
Chen, Tai-Lin 5ac3b3088d fix: error in create quest 2023-11-24 09:50:15 +08:00
Chen, Tai-Lin acc4d5acba fix: error in create quest 2023-11-24 09:48:41 +08:00
Chen, Tai-Lin 1fb43b78e3 fix: erro in quest explore 2023-11-24 09:46:06 +08:00
Lin, Hung-Shin b27adc1f69 Add survey and leaderboard button (#78)
Add survey and leaderboard button
2023-11-24 00:59:34 +08:00
Annie Huang 6f68107405 Add survey and 2023-11-24 00:58:03 +08:00
Lin, Hung-Shin de406135a3 modify quest cast (#77) 2023-11-24 00:37:21 +08:00
CHEN, TAI-LIN 1822188c97 modify quest cast 2023-11-24 00:33:56 +08:00
Lin, Hung-Shin 38725c588b SKIP CI checks on deployment (#76)
SO BAAAAAAAAAAAD
2023-11-23 23:51:38 +08:00
Lin, Hung-Shin d95b763b45 SKIP CI checks on deployment 2023-11-23 23:50:51 +08:00
Lin, Hung-Shin f4de2a182e feat: add series of quests (#75) 2023-11-23 23:44:27 +08:00
Lin, Hung-Shin 93e2485dee Merge branch 'main' into quest 2023-11-23 23:40:03 +08:00
Lin, Hung-Shin 5f7e2a3d5a Feat complete animation (#73)
Add complete animation in complete modal
Add 3-state-colors on map: 
- green: completed 
- gray: unlocked
- dark: locked
* haven’t add back v-if because the pc is out of power
2023-11-23 23:37:47 +08:00
Annie Huang fe604f3781 change verify order 2023-11-23 23:36:30 +08:00
Lin, Hung-Shin 143b84afb4 Feat leaderboard (#74) 2023-11-23 23:34:18 +08:00
Lin, Hung-Shin c54bc68001 fix: remove debug log 2023-11-23 23:32:35 +08:00
Annie Huang 1944a69b53 extend timeout 2023-11-23 23:30:38 +08:00
Lin, Hung-Shin f03a544108 fix; cypress use 'get-started' as test quest 2023-11-23 23:29:19 +08:00
Annie Huang 277d619518 fix cypress fail 2023-11-23 23:27:58 +08:00
Chen, Tai-Lin 96c0785a7e change e2e test 2023-11-23 23:26:11 +08:00
Lin, Hung-Shin 2cc8a98734 Merge remote-tracking branch 'origin/quest' into quest 2023-11-23 23:00:47 +08:00
Lin, Hung-Shin d07b38c7d6 fix: no callback found error 2023-11-23 23:00:16 +08:00
Chen, Tai-Lin 00e59e37ee remove cheatsheet e2e test in cypress 2023-11-23 22:57:39 +08:00
Chen, Tai-Lin 580a24402b update gamepage 2023-11-23 22:51:29 +08:00
Annie Huang 66adfec365 fix method 2023-11-23 22:48:26 +08:00
Chen, Tai-Lin c6f45e10e0 analytics update 2023-11-23 22:40:35 +08:00
Chen, Tai-Lin 140a1249a6 quest update 2023-11-23 22:39:47 +08:00
Chen, Tai-Lin 5413cf4118 quest update 2023-11-23 22:29:08 +08:00
Lin, Hung-Shin 9e7065fbd7 fix: remove fake leaderboard responses 2023-11-23 22:28:36 +08:00
Lin, Hung-Shin 52fa8d6f3a fix: put leadeboard align top 2023-11-23 22:28:23 +08:00
Lin, Hung-Shin 88d19e7f87 fix: navbar overlap leaderboard 2023-11-23 22:22:48 +08:00
Lin, Hung-Shin a9a63ea164 feat: print hints one by one, fix unexpected breakline 2023-11-23 21:56:01 +08:00
Chen, Tai-Lin fc13e9371c quest update 2023-11-23 21:51:31 +08:00
Chen, Tai-Lin e664573215 hide cheatsheet 2023-11-23 21:38:47 +08:00
Chen, Tai-Lin 63ed4c098c all quests update 2023-11-23 21:21:38 +08:00
Chen, Tai-Lin 4d53f56397 update quest 2023-11-23 21:13:54 +08:00
Chen, Tai-Lin 057635eed6 all quest modify 2023-11-23 21:03:32 +08:00
Annie Huang 84cf74cdfc extend timeout 2023-11-23 17:00:30 +08:00
Annie Huang a7eeb8470a edit link text 2023-11-23 16:55:34 +08:00
Annie Huang c9500c7575 set back if completed 2023-11-23 16:51:18 +08:00
Chen, Tai-Lin dd3e6b4aac scroll leader board 2023-11-23 16:50:07 +08:00
Chen, Tai-Lin 4f55dcd929 update quest path 2023-11-23 16:38:11 +08:00
wancat 790e09b315 wip: design leaderboard 2023-11-23 16:28:47 +08:00
Annie Huang ce6d5aad66 Add complete animation 2023-11-23 16:16:20 +08:00
wancat a90b0a3c83 feat: load leadboard data in frontend 2023-11-23 15:49:20 +08:00
wancat 1a5655d7ce feat: rename numberOfCompletedQuests to score 2023-11-23 15:49:06 +08:00
wancat 196187d5c9 feat: add leaderboard api 2023-11-23 15:42:31 +08:00
Chen, Tai-Lin accf2bd0e2 update quest path 2023-11-23 15:06:34 +08:00
Lin, Hung-Shin 0d8ab8413a feat: add leaderboard declarations 2023-11-23 15:04:39 +08:00
Chen, Tai-Lin c42c558614 quest get-started 2023-11-23 14:37:08 +08:00
Chen, Tai-Lin c6e49a10be quest map modify 2023-11-23 14:02:02 +08:00
AnnieHuang 63c2443802 Add animejs for further usage 2023-11-23 13:57:41 +08:00
Annie f9e6228226 Add 3 stage color: locked, unlocked, completed 2023-11-23 13:57:41 +08:00
Annie Huang 937c510aa0 Add username to navbar (#72)
* Add username to navbar
* Set navbar title by props
2023-11-23 13:46:01 +08:00
Lin, Hung-Shin 779733c7ba Add status badges (#71) 2023-11-23 13:40:46 +08:00
Chen, Tai-Lin 639a7d06d9 change quest title 2023-11-22 23:11:10 +08:00
Chen, Tai-Lin 814ca72040 quests requirement update 2023-11-22 23:07:33 +08:00
Chen, Tai-Lin 821386a0e4 quest map modify 2023-11-22 23:06:52 +08:00
Chen, Tai-Lin 956af624c6 Merge branch 'quest-file' into quest 2023-11-22 19:28:34 +08:00
Chen, Tai-Lin 2e6a29cb92 quest update 2023-11-22 19:27:40 +08:00
Lin, Hung-Shin e360ebcf53 fix: reset user profile on logout 2023-11-22 14:53:41 +08:00
Lin, Hung-Shin 89f86fad30 feat: add username to navbar, pass title by props 2023-11-22 14:46:41 +08:00
Lin, Hung-Shin 6fddf5ee0c quest: add study commands to instruction 2023-11-22 13:57:09 +08:00
Lin, Hung-Shin 1ab3e577c1 quest: "remove" finish, a bug in last stage 2023-11-22 12:50:17 +08:00
Lin, Hung-Shin 28cd2a7ac7 fix: change build:quests output to console 2023-11-22 12:49:47 +08:00
Lin, Hung-Shin 2e1fe369a4 feat: default response to dialogue and spoken by Ada 2023-11-22 12:14:04 +08:00
Lin, Hung-Shin 21ca083628 Merge branch 'main' into quest-file 2023-11-22 11:39:24 +08:00
Lin, Hung-Shin 3cebcddc91 fix: expiryCheck error handling 2023-11-22 11:26:03 +08:00
Chen, Tai-Lin 7ba9e6b14b quest update 2023-11-22 11:05:11 +08:00
Lin, Hung-Shin cd03277fd4 quest: "move" change optional hint to exception 2023-11-22 10:44:52 +08:00
Lin, Hung-Shin 2c4bd67996 fix: mount quest function 2023-11-22 10:40:15 +08:00
Lin, Hung-Shin 06c36565d0 fix: container cli error handling 2023-11-22 10:39:57 +08:00
Lin, Hung-Shin ba641ca222 fix: container panic when discover not exists 2023-11-22 10:23:47 +08:00
Lin, Hung-Shin ff2241daa8 Add status badges 2023-11-22 00:10:11 +08:00
wancat d19b32c6d1 quest: add "move" 2023-11-21 23:58:44 +08:00
Lin, Hung-Shin d15a197db9 Feat Translation Chinese (Mandarin) (#68)
- Translate Component to Mandarin
- Relative size of map node and font
- Relative size of header title
- Fix Hint Text changing direction
- Modifying cypress tests for translates
- Add a parameter to check the language version
- Add commands for checking different version elements and placeholder
- Fix docker parse JSON
2023-11-21 23:31:33 +08:00
wancat 5b71944588 fix: redirect to map on social login 2023-11-21 23:23:20 +08:00
wancat 627ff1476b quest: "copy" add cp -r exception 2023-11-21 22:54:39 +08:00
wancat 06c694ee68 quest: add check empty file stage 2023-11-21 22:54:12 +08:00
Annie Huang 7320688827 fix: display format, docker parsejson, hint title 2023-11-21 22:53:43 +08:00
Lin, Hung-Shin a4a261291d Merge branch 'main' into feat-translate-ch 2023-11-21 22:28:57 +08:00
Annie Huang 4c85516bcb fix cypress /map problem
update packages
2023-11-21 22:24:34 +08:00
Lin, Hung-Shin 9f9a31719e Add logger in backend (#69)
* Replace `console.log` `console.error` with Winston logger
* Add ESLint warning of `console`
2023-11-21 22:16:56 +08:00
Chen, Tai-Lin b6b8010a60 Merge branch 'quest-file' into quest 2023-11-21 21:25:59 +08:00
Chen, Tai-Lin 3e441b1104 quest update 2023-11-21 21:24:21 +08:00
Annie 174941a37e Add map(header),continue to map,login direct - map 2023-11-21 21:13:33 +08:00
wancat bdd37ae747 feat: pass oauth secret to actions 2023-11-21 20:46:42 +08:00
Lin, Hung-Shin dd7cded09e feat: finsih copy 2023-11-21 15:11:05 +08:00
Annie f1d5374137 Change order to maksure Social Login is enabled 2023-11-21 15:05:03 +08:00
Annie 364cda39ad fix eslint problems 2023-11-21 14:42:36 +08:00
Lin, Hung-Shin 66ce015f0e feat: add exceptions to stage 2023-11-21 14:27:43 +08:00
Annie cc03e57c70 fix cypress command format 2023-11-21 14:20:55 +08:00
Annie f7907a5999 fix format 2023-11-21 14:01:17 +08:00
Annie 5d26029166 fix format 2023-11-21 13:58:09 +08:00
Annie 869f01a62b Add check chtext element and ids,fix quest problem 2023-11-21 12:56:15 +08:00
Lin, Hung-Shin d221465a73 fix: prevent log flooding g from socket message 2023-11-21 12:23:24 +08:00
Lin, Hung-Shin 56d96cf7a0 fix: only send hints when it's not empty 2023-11-21 12:16:01 +08:00
Lin, Hung-Shin a8ba2a640b feat: finish quest create 2023-11-21 12:15:44 +08:00
Lin, Hung-Shin 36f53da08a feat: make hints a group in one stage 2023-11-21 11:55:32 +08:00
Chen, Tai-Lin 0343a7ccee quest update 2023-11-21 11:27:47 +08:00
Lin, Hung-Shin 42ac27126c Merge remote-tracking branch 'origin/quest' into quest-file 2023-11-21 11:20:21 +08:00
Lin, Hung-Shin 4297f96913 Merge branch 'quest' into quest-file 2023-11-21 11:19:05 +08:00
Lin, Hung-Shin 691f12f7e7 feat: add title to quests 2023-11-21 11:17:49 +08:00
Lin, Hung-Shin c89abfbac5 fix: disable quest lock when testing 2023-11-21 11:14:02 +08:00
Chen, Tai-Lin 764c6812bd quest update 2023-11-21 11:11:40 +08:00
CHEN, TAI-LIN 882ce5f75d Quest file (#70) 2023-11-21 11:10:27 +08:00
CHEN, TAI-LIN b514fa8bea Merge branch 'quest' into quest-file 2023-11-21 11:09:23 +08:00
Lin, Hung-Shin 3fed851b7b feat: reload all quest on starting 2023-11-21 11:04:52 +08:00
Lin, Hung-Shin 3f8bcb5446 feat: disable quest locks when testing 2023-11-21 10:48:35 +08:00
Chen, Tai-Lin c7fb0997b7 quest update 2023-11-21 10:48:35 +08:00
Chen, Tai-Lin d4da065a95 fix: load quest erorr handling 2023-11-21 10:48:35 +08:00
Chen, Tai-Lin 65894f831a quest update 2023-11-21 10:48:35 +08:00
Chen, Tai-Lin 5fbdcd5182 fix: ui revise 2023-11-21 10:48:35 +08:00
Lin, Hung-Shin ee5e8afe7b fix: logger import 2023-11-21 10:38:36 +08:00
Lin, Hung-Shin a6b51a08e5 fix: mount log folder in test and production 2023-11-21 10:19:33 +08:00
Lin, Hung-Shin 0d886a686e fix: remove console log in backend 2023-11-21 10:15:06 +08:00
Annie Huang 9da23b5dc1 translation and little format fixture 2023-11-20 22:49:46 +08:00
Lin, Hung-Shin f6c3b33da9 Feat quests page (#67)
Feat quests page
2023-11-20 16:24:25 +08:00
Annie Huang 83994106a0 Add error handling 2023-11-20 15:41:08 +08:00
Annie Huang 2837354456 render-->renderer 2023-11-20 15:36:06 +08:00
Chen, Tai-Lin 6ff21053cf quest update 2023-11-20 15:26:32 +08:00
Annie Huang 4c1be84942 Swap code position of template and script 2023-11-20 15:08:14 +08:00
Annie Huang 659161a76e modify header title 2023-11-20 14:59:30 +08:00
Lin, Hung-Shin 82d1004ecf wip: removing console log 2023-11-20 14:59:14 +08:00
Annie Huang 12c6e493a5 Change lineStyle by unlocked,delete docker swagger 2023-11-20 14:41:03 +08:00
Lin, Hung-Shin 515aefcadc feat: add winston 2023-11-20 14:36:38 +08:00
Annie Huang 5d353b71e2 Change background to svg 2023-11-20 13:49:09 +08:00
Chen, Tai-Lin c9a24b92ad fix: load quest erorr handling 2023-11-20 12:58:18 +08:00
Chen, Tai-Lin 92b12a90d3 quest update 2023-11-20 12:51:48 +08:00
Chen, Tai-Lin f84a163b77 fix: ui revise 2023-11-20 12:51:25 +08:00
Annie Huang c272fe61a8 modify background, change color when completed 2023-11-20 01:38:06 +08:00
Annie Huang 46efb15273 Change node shape, fix broke (y-axis), add header 2023-11-19 00:40:16 +08:00
Annie Huang b5be0608ae Add background, modify classes 2023-11-18 22:10:17 +08:00
Lin, Hung-Shin 08e28f7725 feat: load user progress and check before click
* not display completed and unlocked status yet
2023-11-16 13:22:24 +08:00
Lin, Hung-Shin 34e9b20f2e feat: fetch quests from api 2023-11-16 13:09:35 +08:00
Lin, Hung-Shin b19d6bf6e2 feat: center the same layers 2023-11-16 12:53:44 +08:00
Lin, Hung-Shin 7a8df716da feat: add x coordinates and links, use SVG 2023-11-16 12:46:53 +08:00
Lin, Hung-Shin e018e8dfe4 refactor: encapsulate DAG 2023-11-16 12:46:30 +08:00
Lin, Hung-Shin ca8215486c feat: add dag into frontend (work in progress) 2023-11-15 14:58:00 +08:00
Lin, Hung-Shin 14d6ab961b fix: move format into constructor 2023-11-15 14:46:41 +08:00
Lin, Hung-Shin e18f0db5ad feat: Add edges 2023-11-15 14:43:34 +08:00
Lin, Hung-Shin fae2d8199d feat: calculate layer 2023-11-15 14:30:37 +08:00
Lin, Hung-Shin c92a2e1d70 feat: install echarts and initialize graph chart 2023-11-15 14:02:33 +08:00
170 changed files with 5718 additions and 1113 deletions

No files matched your search

+3 -2
View File
@@ -13,11 +13,11 @@ module.exports = {
],
parserOptions: {
// parser: 'babel-eslint',
ecmaVersion: 2020,
ecmaVersion: 13,
sourceType: 'module',
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-console': 'warn',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'import/extensions': 'off',
camelcase: ['error', { properties: 'never', ignoreDestructuring: true }],
@@ -28,5 +28,6 @@ module.exports = {
'no-plusplus': 'off',
'prettier/prettier': ['error', { endOfLine: 'auto', semi: false }],
'import/prefer-default-export': 'off',
'no-restricted-syntax': ['off', 'ForOfStatement'],
},
}
+11 -2
View File
@@ -2,6 +2,15 @@ name: CI Checks
on:
workflow_call:
secrets:
GOOGLE_CLIENT_ID:
required: true
GOOGLE_CLIENT_SECRET:
required: true
GH_CLIENT_ID:
required: true
GH_CLIENT_SECRET:
required: true
jobs:
ci:
@@ -40,6 +49,6 @@ jobs:
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GITHUB_CLIENT_ID: ${{ secrets.GITHUB_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }}
GITHUB_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
GITHUB_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
run: docker-compose -f docker-compose.testing.yml run cypress
+1
View File
@@ -12,6 +12,7 @@ concurrency:
jobs:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build:
needs: ci-checks
+2 -1
View File
@@ -10,6 +10,7 @@ concurrency:
jobs:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
secrets: inherit
code_review:
permissions:
@@ -24,7 +25,7 @@ jobs:
uses: mattzcarey/code-review-gpt@v0.1.4-alpha
with:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
MODEL: 'gpt-4'
MODEL: 'gpt-3.5-turbo'
GITHUB_TOKEN: ${{ github.token }}
build:
+1
View File
@@ -12,6 +12,7 @@ concurrency:
jobs:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build:
needs: ci-checks
+3
View File
@@ -1,5 +1,8 @@
# Linux Odyssey
[![Production](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml)
[![Staging](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml)
## Requirements
- NodeJS
+1 -1
View File
@@ -2,10 +2,10 @@
FROM node:20-alpine as base
WORKDIR /app
COPY package.json yarn.lock ./
COPY app/package.json ./app/package.json
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 app/package.json ./app/package.json
RUN yarn install --frozen-lockfile
# Dev stage
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/icon_totem.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linux Odyssey</title>
</head>
+5 -1
View File
@@ -20,11 +20,15 @@
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/utils": "*",
"axios": "^1.6.0",
"dompurify": "^3.0.6",
"echarts": "^5.4.3",
"marked": "^10.0.0",
"socket.io-client": "^4.7.1",
"validator": "^13.11.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"vue-toastification": "^2.0.0-rc.5",
"vue3-lottie": "^3.2.0",
"xterm": "^5.2.1",
"xterm-addon-attach": "^0.8.0",
"xterm-addon-fit": "^0.7.0"
@@ -34,6 +38,6 @@
"autoprefixer": "^10.4.14",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.3",
"vite": "^4.4.0"
"vite": "^4.4.12"
}
}
+9
View File
@@ -0,0 +1,9 @@
<svg width="638" height="732" viewBox="0 0 638 732" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M0 0H638V732H0V0Z" fill="url(#pattern0)"/>
<defs>
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_379_14" transform="matrix(0.00126582 0 0 0.00110327 0 -0.000333748)"/>
</pattern>
<image id="image0_379_14" width="790" height="907" xlink:href="data:image/png;base64,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 truncated
</defs>
</svg>

After

Width:  |  Height:  |  Size: 630 KiB

+95 -21
View File
@@ -41,7 +41,7 @@ const hasSocialLogins = computed(() => {
})
const success = () => {
router.push({ name: 'game', params: { questId: 'helloworld' } })
router.push({ name: 'map' })
}
const error = (err) => {
@@ -89,8 +89,13 @@ const handleChange = () => {
<div
class="bg-bg border-8 border-bg-primary flex flex-1 flex-col items-center justify-center rounded-3xl p-10"
>
<h1 class="text-text-primary text-3xl font-black mb-2">{{ title }}</h1>
<div class="text-error inline-flex justify-center items-center xl:hidden">
<div class="w-full flex items-center justify-center mb-2 gap-3">
<img src="../img/icon_totem.svg" class="h-10" />
<h1 class="text-text-primary text-3xl font-black" id="title">
{{ title }}
</h1>
</div>
<div class="text-error inline-flex justify-center items-center md:hidden">
<font-awesome-icon
:icon="['fas', 'circle-exclamation']"
class="px-1 text-sm"
@@ -100,6 +105,7 @@ const handleChange = () => {
<form @submit.prevent="handleSubmit()" class="w-full">
<div v-if="socialLogin && hasSocialLogins">
<a
id="GoogleLogin"
v-if="availableMethods.google"
class="inline-flex justify-center items-center rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2"
href="/api/v1/auth/google"
@@ -108,9 +114,13 @@ const handleChange = () => {
:icon="['fab', 'google']"
class="text-text-secondary px-3 text-lg"
/>
<span class="font-medium">Continue with Google</span>
<span class="font-medium">
<!-- Continue with Google -->
以 Google 繼續
</span>
</a>
<a
id="GitHubLogin"
v-if="availableMethods.github"
class="inline-flex justify-center items-center rounded-lg py-2 mt-3 bg-bg-primary text-text-secondary w-full border-text-secondary border-2"
href="/api/v1/auth/github"
@@ -119,17 +129,22 @@ const handleChange = () => {
:icon="['fab', 'github']"
class="text-text-secondary px-3 text-lg"
/>
<span class="font-medium">Continue with GitHub</span>
<span class="font-medium">
<!-- Continue with GitHub -->
以 GitHub 繼續
</span>
</a>
<p
id="or"
class="text-text-secondary flex justify-center mt-3"
v-if="type === 'login' || type === 'register'"
>
or
<!-- or -->
或
</p>
</div>
<div class="mb-6">
<input
<!-- <input
type="text"
id="username"
ref="usernameInput"
@@ -139,9 +154,21 @@ const handleChange = () => {
required
@input="handleChange()"
autocomplete="username"
/> -->
<input
type="text"
id="username"
ref="usernameInput"
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
:placeholder="type === 'login' ? '電子郵件 / 帳號名稱' : '帳號名稱'"
v-model="username"
required
@input="handleChange()"
autocomplete="username"
/>
<label
class="text-sm font-normal text-text-secondary"
id="NameRules"
v-if="type === 'username' || type === 'register'"
>
<p>可用小寫字母、數字、"_"和"-"</p>
@@ -149,7 +176,7 @@ const handleChange = () => {
</label>
</div>
<div class="mb-6" v-if="type === 'register'">
<input
<!-- <input
type="email"
id="email"
ref="emailInput"
@@ -159,10 +186,21 @@ const handleChange = () => {
required
@input="handleChange()"
autocomplete="email"
/> -->
<input
type="email"
id="email"
ref="emailInput"
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
placeholder="電子郵件"
v-model="email"
required
@input="handleChange()"
autocomplete="email"
/>
</div>
<div class="mb-6" v-if="type === 'login' || type === 'register'">
<input
<!-- <input
type="password"
id="password"
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
@@ -170,30 +208,66 @@ const handleChange = () => {
v-model="password"
required
@input="handleChange()"
/> -->
<input
type="password"
id="password"
class="my-4 bg-bg-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-bg-secondary"
placeholder="密碼"
v-model="password"
required
@input="handleChange()"
/>
</div>
<p class="text-error flex justify-center" v-if="errorMessage">
<p
id="ErrorDisplay"
class="text-error flex justify-center"
v-if="errorMessage"
>
{{ errorMessage }}
</p>
<button
id="LogInOrSignUp"
class="inline-flex justify-center rounded-lg font-black py-2 bg-text-primary text-bg w-full"
type="submit"
>
<span v-if="type === 'login'">Log In</span>
<span v-else>Sign Up</span>
<span v-if="type === 'login'">
<!-- Log In -->
登入
</span>
<span v-else>
<!-- Sign Up -->
註冊
</span>
</button>
<p class="text-text flex justify-center mt-3">
<span v-if="type === 'register'"
>Already have an account?
<RouterLink class="text-text-primary font-bold" to="/login">
<u>Log in</u>
<p id="SwitchLoginRegister" class="text-text flex justify-center mt-3">
<span id="CheckRegistered" v-if="type === 'register'">
<!-- Already have an account? -->
已經有帳號了嗎?
<RouterLink
id="SwitchtoLogin"
class="text-text-primary font-bold"
to="/login"
>
<u>
<!-- Log in -->
登入
</u>
</RouterLink></span
>
<span v-else-if="type === 'login'"
>Don't have an account?
<RouterLink class="text-text-primary font-bold" to="/register">
<u>Sign up</u>
<span id="CheckRegistered" v-else-if="type === 'login'">
<!-- Don't have an account? -->
還沒有帳號嗎?
<RouterLink
id="SwitchtoRegister"
class="text-text-primary font-bold"
to="/register"
>
<u>
<!-- Sign up -->
註冊
</u>
</RouterLink></span
>
</p>
+8 -4
View File
@@ -1,9 +1,13 @@
<script setup></script>
<template>
<font-awesome-icon :icon="['fas', 'list']" class="text-text-primary p-1" />
<h1 class="inline text-text-primary font-xl">Command Cheatsheets</h1>
<h1 id="CommandBook" class="inline text-text-primary font-xl">
<!-- Command Cheatsheets -->
指令之書
</h1>
<div id="cheatsheets">
<p class="text-text-secondary">--Command List--</p>
<p class="text-text-secondary">
<!-- --Command List-- -->
-- --指令列表-- --
</p>
</div>
</template>
+55 -7
View File
@@ -1,12 +1,36 @@
<script setup>
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { ref } from 'vue'
import { surveyUrl } from '../config'
import { Vue3Lottie } from 'vue3-lottie'
// import { surveyUrl } from '../config'
import CompleteAnimation from '../img/complete-animation.json'
const showModal = ref(true) // Controls whether the modal is visible or not
const router = useRouter()
const toast = useToast()
const closeModal = () => {
showModal.value = false
}
const defineComponent = () => ({
components: {
Vue3Lottie,
},
setup() {
return {
CompleteAnimation,
}
},
})
const backtoMap = async () => {
try {
router.push({ name: 'map' })
} catch (err) {
// useToast().error('Failed to load map')
toast.error('無法讀取地圖')
console.error(err)
}
}
// Simulate completing a quest to show the modal
setTimeout(() => {
@@ -18,18 +42,42 @@ setTimeout(() => {
<div v-if="showModal" class="modal">
<div class="modal-content bg-bg-secondary rounded-lg">
<span class="close text-text" @click="closeModal">&times;</span>
<h2 class="text-2xl text-text mb-5 w-full inline-flex justify-center">
Quest Completed!
<h2
id="QuestCompleted"
class="text-2xl text-text mb-5 w-full inline-flex justify-center"
>
<!-- Quest Completed! -->
關卡完成!
</h2>
<p class="text-base text-text">
<div class="z-10 right-1/2 top-1/2" @animationend="defineComponent">
<Vue3Lottie
:animationData="CompleteAnimation"
:height="200"
:width="200"
/>
</div>
<p class="text-base text-text text-center">恭喜你完成這一關!</p>
<p class="text-base text-text text-center">
接下來回到地圖,迎接新的挑戰吧!
</p>
<!-- <p class="text-base text-text">
您已成功完成挑戰,真的非常感謝您的參與!<br />
如果您能撥冗填寫這份問卷,我們將不勝感激,這對我們非常重要!
</p>
<a
</p> -->
<!-- <a
id="SurveyButton"
:href="surveyUrl"
target="_blank"
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
>填寫問卷</a
> -->
<a
id="BacktoMap"
target="_blank"
@click="backtoMap"
class="inline-flex justify-center rounded-lg text-base font-black py-2 mt-3 bg-text-primary w-full"
>回到地圖</a
>
</div>
</div>
+45 -7
View File
@@ -1,34 +1,72 @@
<template>
<section class="h-[10%] p-3 grid grid-cols-3 gap-4 place-content-between">
<button id="solution" class="bg-bg-disabled rounded-lg p-2">
<section class="h-[10%] p-3 grid grid-cols-4 gap-4 place-content-between">
<button id="survey" class="bg-bg-secondary rounded-lg p-2">
<a title="Survey" :href="surveyUrl" target="_blank" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'file-invoice']"
class="text-text-primary"
/>
<p class="text-text-primary inline ml-1">
<!-- Survey -->
問卷
</p>
</a>
</button>
<button disabled id="solution" class="bg-bg-disabled rounded-lg p-2">
<font-awesome-icon
:icon="['far', 'circle-question']"
class="text-text-disabled"
/>
<p class="text-text-disabled inline ml-1">Solution</p>
<p class="text-text-disabled inline ml-1">
<!-- Solution -->
解答
</p>
</button>
<button id="reset" class="bg-bg-secondary rounded-lg p-2" @click="reset">
<font-awesome-icon
:icon="['fas', 'arrow-rotate-left']"
class="text-text-primary"
/>
<p class="text-text-primary inline ml-1">Reset</p>
<p class="text-text-primary inline ml-1">
<!-- Reset -->
重來
</p>
</button>
<button id="continue" class="bg-bg-disabled rounded-lg p-2">
<button
id="continue"
class="bg-bg-secondary rounded-lg p-2"
@click="continuePlay"
>
<font-awesome-icon
:icon="['far', 'circle-right']"
class="text-text-disabled"
class="text-text-primary"
/>
<p class="text-text-disabled inline ml-1">Continue</p>
<p class="text-text-primary inline ml-1">
<!-- Continue -->
繼續
</p>
</button>
</section>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { createSession } from '../store/session'
import { surveyUrl } from '../config'
const router = useRouter()
const reset = async () => {
console.log('resetting...')
await createSession()
}
const continuePlay = async () => {
try {
router.push({ name: 'map' })
} catch (err) {
// useToast().error('Failed to load map')
useToast().error('無法讀取地圖')
console.error(err)
}
}
</script>
+6 -6
View File
@@ -1,9 +1,3 @@
<template>
<div id="box">
<canvas ref="canvas"></canvas>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
@@ -79,6 +73,12 @@ onMounted(() => {
})
</script>
<template>
<div id="box">
<canvas ref="canvas"></canvas>
</div>
</template>
<style scoped>
#box {
position: relative;
+9 -9
View File
@@ -1,12 +1,3 @@
<template>
<a href="#" :class="nodeClass()">{{ node.discovered ? node.name : '???' }}</a>
<ul :class="nodeClass()" v-if="node.children && node.children.length > 0">
<li :class="nodeClass()" v-for="child in node.children" :key="child.path">
<FileNode :node="child" :pwd="pwd" />
</li>
</ul>
</template>
<script setup>
const props = defineProps({
node: {
@@ -27,3 +18,12 @@ function nodeClass() {
return classes
}
</script>
<template>
<a href="#" :class="nodeClass()">{{ node.discovered ? node.name : '???' }}</a>
<ul :class="nodeClass()" v-if="node.children && node.children.length > 0">
<li :class="nodeClass()" v-for="child in node.children" :key="child.path">
<FileNode :node="child" :pwd="pwd" />
</li>
</ul>
</template>
+82 -7
View File
@@ -1,9 +1,14 @@
<script setup>
import { defineProps, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { logout } from '../utils/auth'
import { bugReportUrl } from '../config'
import sessionStore, { reset } from '../store/session'
import { bugReportUrl, surveyUrl } from '../config'
import { reset } from '../store/session'
import userProfileStore, {
loadUserProfile,
resetUserProfile,
} from '../store/userProfile'
const router = useRouter()
@@ -12,13 +17,49 @@ const toast = useToast()
const handleLogout = async () => {
try {
await logout()
resetUserProfile()
reset()
router.push({ name: 'login' })
} catch (err) {
toast.error('Failed to logout')
// toast.error('Failed to logout')
toast.error('登出失敗')
console.error(err)
}
}
const handleMap = async () => {
try {
router.push({ name: 'map' })
} catch (err) {
// toast.error('Failed to load map')
toast.error('無法讀取地圖')
console.error(err)
}
}
const handleLeaderBoard = async () => {
try {
router.push({ name: 'leaderboard' })
} catch (err) {
// toast.error('Failed to load map')
toast.error('無法讀取排行榜')
console.error(err)
}
}
defineProps({
title: {
type: String,
required: true,
},
})
onMounted(async () => {
try {
await loadUserProfile()
} catch (err) {
// toast.error('Failed to load user profile')
toast.error('無法讀取使用者資料')
}
})
</script>
<template>
@@ -34,14 +75,23 @@ const handleLogout = async () => {
<button id="backward" class="flex items-center h-5 w-5">
<font-awesome-icon
:icon="['fas', 'arrow-left']"
class="text-text-tertiary h-full w-full"
class="text-border h-full w-full"
/>
</button> -->
<p
<img src="../img/icon_totem.svg" class="h-5" />
<button
id="GameTitle"
class="text-text-primary inline-block font-bold whitespace-nowrap"
v-if="sessionStore.quest"
style="font-size: 2vh"
>
{{ sessionStore.quest.title }}
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">
@@ -63,6 +113,31 @@ const handleLogout = async () => {
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>
<button title="LeaderBoard" @click="handleLeaderBoard" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'trophy']"
class="text-text-primary h-full w-full"
/>
</button>
<button title="Map" @click="handleMap" class="h-5 w-5">
<font-awesome-icon
:icon="['fas', 'map']"
class="text-text-primary h-full w-full"
/>
</button>
<a
title="Bug Report"
:href="bugReportUrl"
+15 -5
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, watch } from 'vue'
import sessionStore from '../store/session'
import MarkdownText from './MarkdownText.vue'
const current = ref(-1)
watch(
@@ -30,8 +31,11 @@ const right = () => {
:icon="['far', 'lightbulb']"
class="text-yellow-200 p-2 content-center"
/>
<h1 class="inline text-text font-xl p-2 m-1">Hint</h1>
<div v-if="current !== -1" class="w-full flex justify-end">
<h1 class="inline text-text w-1/2 font-xl p-2 m-1">
<!-- Hint -->
提示
</h1>
<div v-if="current !== -1" class="flex w-full justify-end items-end">
<button
class="p-2 m-1 w-1/8"
id="Lbutton"
@@ -60,9 +64,15 @@ const right = () => {
</div>
</div>
<div id="hint" class="bg-bg flex flex-wrap p-8">
<p class="text-text font-xl whitespace-pre-wrap">
{{ sessionStore.session.hints[current] }}
</p>
<ul>
<li
v-for="hint in sessionStore.session.hints[current]"
:key="hint"
class="text-text font-xl whitespace-pre-wrap"
>
<MarkdownText :content="hint" />
</li>
</ul>
<br />
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup>
import { computed, defineProps } from 'vue'
import markdown2HTML from '../utils/markdown'
const props = defineProps({
content: {
type: String,
required: true,
},
})
const html = computed(() => {
return markdown2HTML(props.content)
})
</script>
<template>
<div class="markdown-content" v-html="html"></div>
</template>
<style scoped>
.markdown-content {
margin: 0;
padding: 0;
line-height: 1;
}
.markdown-content :deep(ol) {
list-style: decimal;
padding-left: 1em;
}
.markdown-content :deep(code) {
background-color: #282a33;
border-radius: 0.25rem;
padding: 0.25rem;
font-size: 0.875rem;
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<!-- eslint-disable no-console -->
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification'
import { use, init } from 'echarts/core'
import { GraphChart } from 'echarts/charts'
import { SVGRenderer } from 'echarts/renderers'
import {
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 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, edges) => ({
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) => {
const {
data: { completed, unlocked },
} = params
if (completed) {
return '#00ff00'
}
if (unlocked) {
return '#ADADB5'
}
return '#505050'
},
},
},
],
})
function getOption(quests, progress) {
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) => progress[req]?.completed),
}))
const lineAppearence = (node) => {
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) => ({
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()
function initChart(option) {
chartInstance = init(chartContainer.value)
chartInstance.setOption(option)
chartInstance.on('click', (params) => {
const {
data: { id, unlocked },
} = params
if (unlocked) {
router.push({ name: 'game', params: { questId: id } })
} else {
// toast.warning('You have not completed the previous quest yet!')
toast.warning('你還沒完成前一個關卡!')
}
})
}
onMounted(async () => {
const quests = await getQuests()
const progress = await getProgress()
const option = getOption(quests, progress)
initChart(option)
})
</script>
<template>
<div class="relative bg-black w-full h-full flex">
<img
src="../img/catelogbg.svg"
alt="bg"
class="p-2 stroke-2 scale-y-120 justify-center items-center"
/>
<h1
class="p-4 absolute w-fit z-2 font-mono flex flax-wrap text-xl"
style="width: 30%; height: 6%; font-size: 3vh; color: #00ff00"
>
<!-- Get through your linux journey! -->
踏上你的Linux冒險之旅吧!
</h1>
<div class="flex flex-wrap absolute w-full h-full z-1">
<div ref="chartContainer" class="w-full h-full flex flex-wrap"></div>
</div>
</div>
</template>
+27 -14
View File
@@ -1,28 +1,41 @@
<script setup>
import { computed } from 'vue'
import MarkdownText from './MarkdownText.vue'
import sessionStore from '../store/session'
const tasks = computed(() => {
return sessionStore.session.tasks.map((task) => {
const prefix = task.completed ? '✓' : '➤'
const color = task.completed ? 'text-text-primary' : 'text-text'
return {
...task,
class: `${color} markdown-content`,
content: `${prefix} ${task.name}`,
}
})
})
</script>
<template>
<div class="h-full w-full max-h-full">
<h1 id="topic" class="text-text" v-if="sessionStore.quest">
<p id="topic" class="text-text font-black" v-if="sessionStore.quest">
{{ sessionStore.quest.title }}
</h1>
<hr class="border-text-tertiary border-2 my-2" />
</p>
<br />
<div>
<div v-if="sessionStore.quest" class="whitespace-pre-wrap">
<p class="text-text">{{ sessionStore.quest.instruction }}</p>
<MarkdownText
class="text-text markdown-content"
:content="sessionStore.quest.instruction"
/>
<br />
<p class="text-text">Tasks:</p>
<p id="tasks" class="text-text">
<!-- Tasks: -->
任務:
</p>
<ul v-if="sessionStore.session">
<li v-for="task in sessionStore.session.tasks" :key="task.id">
<p v-if="task.completed" class="text-text-primary">
<span class="">✓</span>
{{ task.name }}
</p>
<p v-else class="text-text">
<span class="">➤</span>
{{ task.name }}
</p>
<li v-for="task in tasks" :key="task.id">
<MarkdownText :class="task.class" :content="task.content" />
</li>
</ul>
</div>
+4 -1
View File
@@ -1,6 +1,9 @@
<template>
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
<button class="text-text h-[5%]">Terminal</button>
<button id="Terminal" class="text-text h-[5%]">
<!-- Terminal -->
終端機
</button>
<div
id="terminal"
ref="terminal"
+1 -1
View File
@@ -121,6 +121,6 @@ li.discovered {
}
a.pwd {
box-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
box-shadow: 0 0 40px rgba(255, 255, 0, 20);
}
</style>
File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 1.3 MiB

+1
View File
@@ -0,0 +1 @@
{"v":"5.5.9","fr":60,"ip":0,"op":90,"w":640,"h":640,"nm":"Lottie_petard","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Pre-comp 1","parent":2,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[320,320,0],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[358.25,238.75],[313.75,278],[456.813,424.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":3},{"ddd":0,"ind":2,"ty":0,"nm":"Pre-comp 1","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.47,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[318,312,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.47,"y":1},"o":{"x":0.167,"y":0.987},"t":6.5,"s":[328,316,0],"to":[0,0,0],"ti":[0,0,0]},{"t":45,"s":[318,312,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[272.458,139.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[-54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[207.458,186.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[-54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[274.458,236.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[-54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[224.458,267.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[326.458,202.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":0,"nm":"confetti","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-61.57,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[345.257,314.37,0],"to":[0,0,0],"ti":[0,0,0]},{"t":15,"s":[314.458,272.571,0]}],"ix":2},"a":{"a":0,"k":[320,320,0],"ix":1},"s":{"a":0,"k":[54,54,100],"ix":6}},"ao":0,"w":640,"h":640,"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Shape Layer 4","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[319.25,320.25,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,104.369,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-27.614,0],[0,-27.614],[27.568,-1.605],[0,27.614]],"o":[[27.614,0],[0,27.614],[-26.718,1.555],[0,-27.614]],"v":[[-9,-26.75],[41,23.25],[-11.439,70.948],[-59,23.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.949020026712,0.886274988511,0.019608000213,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.9,0.840928201114,0.008999999364,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[8,-36.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[89.096,20.801],"ix":3},"r":{"a":0,"k":-45,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":60Line truncated
+9
View File
@@ -0,0 +1,9 @@
<svg width="638" height="732" viewBox="0 0 638 732" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M0 0H638V732H0V0Z" fill="url(#pattern0)"/>
<defs>
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_379_14" transform="matrix(0.00126582 0 0 0.00110327 0 -0.000333748)"/>
</pattern>
<image id="image0_379_14" width="790" height="907" xlink:href="data:image/png;base64,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 truncated
</defs>
</svg>

After

Width:  |  Height:  |  Size: 630 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="272" height="139" viewBox="0 0 272 139" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.640015 25.0339C0.640015 11.2268 11.8329 0.0339355 25.64 0.0339355H246.36C260.167 0.0339355 271.36 11.2268 271.36 25.0339V113.14C271.36 126.948 260.167 138.14 246.36 138.14H25.64C11.8329 138.14 0.640015 126.948 0.640015 113.14V25.0339Z" fill="#454552"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

+2
View File
@@ -0,0 +1,2 @@
export const NodeImage =
'path://M0.640015 25.0339C0.640015 11.2268 11.8329 0.0339355 25.64 0.0339355H246.36C260.167 0.0339355 271.36 11.2268 271.36 25.0339V113.14C271.36 126.948 260.167 138.14 246.36 138.14H25.64C11.8329 138.14 0.640015 126.948 0.640015 113.14V25.0339Z'
+7 -1
View File
@@ -15,6 +15,9 @@ import {
faArrowRight,
faBug,
faCircleExclamation,
faMap,
faTrophy,
faFileInvoice,
} from '@fortawesome/free-solid-svg-icons'
import {
faLightbulb,
@@ -36,10 +39,13 @@ library.add(
faArrowRightFromBracket,
faArrowRight,
faBug,
faMap,
faTrophy,
faCircleExclamation,
faLightbulb,
faCircleQuestion,
faCircleRight,
faGithub,
faGoogle
faGoogle,
faFileInvoice
)
+4
View File
@@ -5,6 +5,8 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import './library'
/* import Vue Toastification */
import Toast, { POSITION } from 'vue-toastification'
/* import Vue Lottie */
import Vue3Lottie from 'vue3-lottie'
import 'vue-toastification/dist/index.css'
import App from './App.vue'
@@ -22,4 +24,6 @@ const options = {
app.use(Toast, options)
app.use(Vue3Lottie, { name: 'Vue3Lottie' })
app.mount('#app')
+17 -9
View File
@@ -3,17 +3,17 @@ import GamePage from './views/GamePage.vue'
import LoginPage from './views/LoginPage.vue'
import RegisterPage from './views/RegisterPage.vue'
import ChooseUsernamePage from './views/ChooseUsernamePage.vue'
import QuestMapPage from './views/QuestMapPage.vue'
import LeaderboardPage from './views/LeaderboardPage.vue'
import { isLoggedIn } from './utils/auth'
const routes = [
{
path: '/',
redirect: {
name: 'game',
params: {
questId: 'helloworld',
},
name: 'register',
},
meta: { requiresGuest: true },
},
{
path: '/login',
@@ -40,6 +40,17 @@ const routes = [
component: ChooseUsernamePage,
meta: { requiresGuest: true },
},
{
path: '/map',
name: 'map',
component: QuestMapPage,
meta: { requiresAuth: true },
},
{
path: '/leaderboard',
name: 'leaderboard',
component: LeaderboardPage,
},
]
const router = createRouter({
@@ -59,13 +70,10 @@ router.beforeEach(async (to, from, next) => {
})
} else if (to.meta.requiresGuest && loggedIn) {
next({
name: 'game', // Or any default authenticated user route
params: {
questId: 'helloworld',
},
name: 'map',
})
} else {
next() // Always call next() at the end
next()
}
})
+1 -1
View File
@@ -86,7 +86,7 @@ function updateGraph(event) {
}
function updateHints(hints) {
store.session.hints.push(...hints)
store.session.hints.push(hints)
}
function updateStatus(status) {
+30
View File
@@ -0,0 +1,30 @@
import { reactive } from 'vue'
import api from '../utils/api'
const store = reactive({
username: '',
email: '',
progress: {},
})
export async function loadUserProfile() {
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) {
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
+7
View File
@@ -0,0 +1,7 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
export default function markdown2HTML(markdown) {
const html = marked.parse(markdown)
return DOMPurify.sanitize(html)
}
+5 -6
View File
@@ -13,12 +13,11 @@ class SocketTerminal {
disableStdin: false, // 是否应禁用输入
// cursorStyle: "underline", //光标样式
cursorBlink: true, // 光标闪烁
theme: {
foreground: '#ECECEC', // 字体
background: '#000000', // 背景色
cursor: 'help', // 设置光标
lineHeight: 20,
},
foreground: '#ECECEC', // 字体
background: '#000000', // 背景色
cursor: 'help', // 设置光标
fontSize: 18,
fontWeight: 'normal',
})
this.fitAddon = new FitAddon()
this.term.loadAddon(this.fitAddon)
+9 -4
View File
@@ -8,21 +8,24 @@ import { checkUsername, chooseUsername } from '../utils/auth'
async function check({ username, error }) {
if (username) {
if (!isValidUsername(username)) {
error('Invalid username.')
// error('Invalid username.')
error('無效的帳號名稱')
return
}
try {
await checkUsername(username)
} catch (err) {
if (err instanceof TooManyRequestsError) {
error('Too many requests. Try again in 2 minutes.')
// error('Too many requests. Try again in 2 minutes.')
error('太多請求,兩分鐘後再試一次。')
return
}
if (err instanceof ValidationError) {
error(err.message)
return
}
error('Something went wrong. Please try again later.')
// error('Something went wrong. Please try again later.')
error('出了點問題,請再試一次。')
}
}
}
@@ -33,7 +36,8 @@ async function handleSubmit({ username, success, error }) {
success()
} catch (err) {
if (err instanceof TooManyRequestsError) {
error('Too many requests. Try again in 2 minutes.')
// error('Too many requests. Try again in 2 minutes.')
error('太多請求,兩分鐘後再試一次。')
return
}
if (err instanceof ValidationError) {
@@ -44,6 +48,7 @@ async function handleSubmit({ username, success, error }) {
}
}
</script>
<template>
<div class="w-screen h-screen">
<Background class="w-full h-full" />
+13 -11
View File
@@ -3,7 +3,7 @@ import { computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import sessionStore, { init } from '../store/session'
import GameHeaderPart from '../components/GameHeaderPart.vue'
import CommandlistPart from '../components/CommandlistPart.vue'
// import CommandlistPart from '../components/CommandlistPart.vue'
import HintPart from '../components/HintPart.vue'
import QuestPart from '../components/QuestPart.vue'
import TerminalPart from '../components/TerminalPart.vue'
@@ -30,13 +30,15 @@ onMounted(async () => {
await init(props.questId)
} catch (err) {
if (err instanceof LoadQuestError) {
toast.error(`Failed to load quest: ${err.questId}. Please check the URL.`)
// toast.error(`Failed to load quest: ${err.questId}. Please check the URL.`)
toast.error(`無法讀取關卡: ${err.questId},請確認網頁連結。`)
return
}
if (err instanceof LoadSessionError) {
toast.error(
`Failed to create session for quest: ${err.questId}. Please re-login and try again.`
)
// toast.error(
// `Failed to create session for quest: ${err.questId}. Please re-login and try again.`
// )
toast.error(`無法建立工作階段: ${err.questId},請重新登入再試一次。`)
return
}
console.error(err)
@@ -48,7 +50,7 @@ onMounted(async () => {
<template>
<!-- game header -->
<div class="h-[6vh] w-full">
<GameHeaderPart />
<GameHeaderPart :title="sessionStore.quest?.title" />
</div>
<!-- current status indicator -->
<input
@@ -60,19 +62,20 @@ onMounted(async () => {
<div id="main" class="h-[94vh] w-full flex p-3 space-x-3">
<!-- Topic and Command List -->
<div class="bg-bg-secondary h-full w-1/3 rounded-lg">
<section id="quest" class="h-2/3 p-3">
<section id="quest" class="h-full p-3 overflow-auto">
<QuestPart />
</section>
<hr class="border-text-tertiary border" />
<!-- <hr class="border-border border" />
<section id="cmdlist" class="h-1/3">
<CommandlistPart />
</section>
</section> -->
</div>
<!-- Terminal and Hint -->
<div class="bg-bg h-full w-1/3 rounded-lg">
<section id="terminal" class="h-3/5">
<section id="terminal" class="h-3/5 overflow-hidden">
<TerminalPart />
</section>
<hr class="border-border border" />
<section id="hint" class="h-2/5">
<HintPart />
</section>
@@ -91,4 +94,3 @@ onMounted(async () => {
<CompleteModal v-if="completed" />
</div>
</template>
<script></script>
+81
View File
@@ -0,0 +1,81 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import DynamicBackground from '../components/DynamicBackground.vue'
import GameHeaderPart from '../components/GameHeaderPart.vue'
import api from '../utils/api'
const leaderboard = ref([])
const toast = useToast()
async function getLeaderboard() {
try {
const res = await api.get('/leaderboard')
leaderboard.value = res.data
} catch (err) {
console.error(err)
toast.error('讀取排行榜失敗')
}
}
onMounted(async () => {
await getLeaderboard()
})
</script>
<template>
<div class="w-screen h-screen bg-bg">
<DynamicBackground class="w-full h-full" />
<div
class="w-screen h-screen absolute top-0 left-0 flex flex-wrap justify-center content-center"
>
<div class="h-[6vh] w-full">
<GameHeaderPart title="排行榜" />
</div>
<div class="w-fit h-[94vh] m-3">
<div
class="h-full bg-bg border-8 border-bg-primary flex flex-col items-center rounded-3xl p-10"
>
<h1 class="text-text-primary text-3xl mb-5">排行榜</h1>
<div class="max-h-300 overflow-auto">
<table class="w-full text-sm text-left text-text">
<thead
class="text-xs text-text-secondary uppercase bg-bg-secondary"
>
<tr>
<th scope="col" class="px-6 py-3">使用者名稱</th>
<th scope="col" class="px-6 py-3">分數</th>
<th scope="col" class="px-6 py-3">完成關卡</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in leaderboard"
:key="user.username"
class="border-b bg-bg"
>
<th
scope="row"
class="px-6 py-4 font-medium text-text-primary whitespace-nowrap"
>
{{ user.username }}
</th>
<td class="px-6 py-4">{{ user.score }}</td>
<td class="px-6 py-4 text-text-secondary">
<span
class="mr-2"
v-for="quest in user.completedQuests"
:key="quest"
>
{{ quest }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
+9 -4
View File
@@ -15,22 +15,27 @@ const handleLogin = async ({ username, password, success, error }) => {
if (isSuccess) success()
} catch (err) {
if (err instanceof TooManyRequestsError) {
error('Too many requests. Try again in 2 minutes.')
// error('Too many requests. Try again in 2 minutes.')
error('太多請求,兩分鐘後再試一次。')
return
}
if (err instanceof UnauthorizedError) {
error('Wrong username or password.')
// error('Wrong username or password.')
error('錯誤的帳號名稱或密碼。')
return
}
if (err instanceof ValidationError) {
error('Invalid username or password.')
// error('Invalid username or password.')
error('無效的帳號名稱或密碼。')
return
}
console.error(err)
error('Something went wrong.')
// error('Something went wrong.')
error('出了點問題。')
}
}
</script>
<template>
<div class="w-screen h-screen">
<Background class="w-full h-full" />
+20
View File
@@ -0,0 +1,20 @@
<script setup>
import QuestMap from '../components/QuestMap.vue'
import GameHeaderPart from '../components/GameHeaderPart.vue'
</script>
<template>
<div class="h-[6vh] w-full">
<GameHeaderPart title="關卡地圖" />
<!-- <p
class="text-text-primary absolute z-1 font-bold inline-block tracking-widest"
style="left: 1.2%; top: 1.5%; height: 100%; font-size: 2vh"
>
Quest Map
Unix編年史
</p> -->
</div>
<div class="flex flex-col justify-center items-center w-full h-[94vh]">
<QuestMap />
</div>
</template>
+19 -10
View File
@@ -17,11 +17,13 @@ function handleRegister({ username, email, password, success, error }) {
.then(success)
.catch((err) => {
if (err instanceof TooManyRequestsError) {
error('Too many requests. Try again in 2 minutes.')
// error('Too many requests. Try again in 2 minutes.')
error('太多請求,兩分鐘後再試一次。')
return
}
if (err instanceof UnauthorizedError) {
error('Invalid username or password.')
// error('Invalid username or password.')
error('無效的帳號名稱或密碼。')
return
}
if (err instanceof ValidationError) {
@@ -29,23 +31,27 @@ function handleRegister({ username, email, password, success, error }) {
return
}
console.error(err)
error('Something went wrong.')
// error('Something went wrong.')
error('出了點問題。')
})
}
async function check({ username, email, password, error }) {
if (username && !isValidUsername(username)) {
error('Invalid username.')
// error('Invalid username.')
error('無效的帳號名稱')
return
}
if (email && !validator.isEmail(email)) {
error('Invalid email.')
// error('Invalid email.')
error('無效的電子郵件')
return
}
if (password && !validator.isStrongPassword(password, passwordPolicy)) {
error(
'Your password must be 8+ characters with at least one number, one upper and one lower case letter.'
)
// error(
// 'Your password must be 8+ characters with at least one number, one upper and one lower case letter.'
// )
error('密碼必須超過8個字元,至少包含一個數字、一個大寫及一個小寫。')
return
}
try {
@@ -56,14 +62,17 @@ async function check({ username, email, password, error }) {
return
}
if (err instanceof TooManyRequestsError) {
error('Too many requests. Try again in 2 minutes.')
// error('Too many requests. Try again in 2 minutes.')
error('太多請求,兩分鐘後再試一次。')
return
}
console.error(err)
error('Something went wrong.')
// error('Something went wrong.')
error('出了點問題。')
}
}
</script>
<template>
<div class="w-screen h-screen">
<Background class="w-full h-full" />
+1 -1
View File
@@ -18,7 +18,7 @@ export default {
DEFAULT: '#FFFFFF',
},
border: {
DEFAULT: '#526B4E',
DEFAULT: '#4A4B63',
},
error: {
DEFAULT: '#FF0000',
+1
View File
@@ -28,6 +28,7 @@ services:
- players
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./logs:/app/logs
env_file:
- .env
environment:
+1
View File
@@ -41,6 +41,7 @@ services:
- players
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./logs:/app/logs
environment:
- BASE_URL=http://localhost:5173
- MONGO_URL=mongodb://user:user-password@db-test:27017/odyssey-test
+5 -12
View File
@@ -33,6 +33,7 @@ services:
- ./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
@@ -41,7 +42,6 @@ services:
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
- SURVEY_URL=${SURVEY_URL}
- BUG_REPORT_URL=${BUG_REPORT_URL}
- HOST_PWD=${PWD}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
@@ -49,6 +49,8 @@ services:
- TESTING=true
- TESTING_USERNAME=alex
- TESTING_PASSWORD=Alex1234
- HOST_PWD=${PWD}
- MOUNT_QUEST=${MOUNT_QUEST}
analytics:
build:
@@ -63,8 +65,8 @@ services:
- '3001:3001'
environment:
- MONGO_URL=mongodb://db:27017/odyssey-test
- USERNAME=user
- PASSWORD=user
- ADMIN_USERNAME=alex
- ADMIN_PASSWORD=Alex1234
volumes:
- ./packages/analytics:/app/packages/analytics
- ./packages/models:/app/packages/models
@@ -78,15 +80,6 @@ services:
volumes:
- ./data:/data/db
swagger:
image: swaggerapi/swagger-editor:latest
ports:
- 8080:8080
volumes:
- ./server/swagger.yaml:/tmp/swagger.yaml
environment:
- SWAGGER_FILE=/tmp/swagger.yaml
base:
image: linuxodyssey/quest-base
build:
+34
View File
@@ -29,4 +29,38 @@ down() {
docker compose -f docker-compose.testing.yml down
}
dump() {
# Define service name and dump directory name
SERVICE_NAME="db"
DUMP_NAME="mongodump-$(date --iso).archive"
CONTAINER_ID=$(docker compose ps -q $SERVICE_NAME)
# Fetch credentials from Docker environment variables
MONGO_URL=$(docker compose exec -T backend printenv MONGO_URL)
echo mongo uri: $MONGO_URL
# Dump MongoDB data using credentials
docker compose exec -T $SERVICE_NAME sh -c "mongodump --gzip --archive=/tmp/$DUMP_NAME -d odyssey-test" &&
# Copy the dump from the container to the host
# Note: You will need to know the exact container name for this step
docker cp ${CONTAINER_ID}:/tmp/$DUMP_NAME . &&
echo "MongoDB data dumped successfully."
}
restore() {
# Define service name and dump directory name
SERVICE_NAME="db"
DUMP_NAME=$1
CONTAINER_ID=$(docker compose ps -q $SERVICE_NAME)
# Copy the dump from the host to the container
docker cp $DUMP_NAME ${CONTAINER_ID}:/tmp &&
# Dump MongoDB data using credentials
docker compose exec -T $SERVICE_NAME sh -c "mongorestore --gzip --archive=/tmp/$DUMP_NAME" &&
echo "MongoDB data dumped successfully."
}
$@
+2 -1
View File
@@ -13,7 +13,8 @@
"@linux-odyssey/models": "*",
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"express-handlebars": "^7.1.2"
"express-handlebars": "^7.1.2",
"mongoose": "^8.0.3"
},
"devDependencies": {
"nodemon": "^3.0.1"
File renamed without changes.
+18 -3
View File
@@ -1,9 +1,24 @@
/* eslint-disable object-shorthand */
import Pagination from '../models/pagination.js'
import { errorCommands } from '../models/commands.js'
// eslint-disable-next-line import/prefer-default-export
export async function commandListController(req, res) {
const payload = {
errorCommands: await errorCommands(),
const { nextKey, order } = req.query
const itemsPerPage = 100
try {
const pagination = new Pagination(order, itemsPerPage, nextKey)
const payload = await errorCommands(pagination)
const newNextKey = payload[payload.length - 1]?._id
res.render('commands', {
errorCommands: payload,
nextKey: newNextKey,
order: pagination.getOrder(),
})
} catch (error) {
console.error(error)
res.status(500).send('Error fetching data')
}
res.render('commands', payload)
}
+13 -4
View File
@@ -1,15 +1,24 @@
import { userCount } from '../models/users.js'
import { sessionCount, sessionCompleted } from '../models/sessions.js'
import { questList } from '../models/quests.js'
// eslint-disable-next-line import/prefer-default-export
export async function homeController(req, res) {
const quests = await questList()
const [totalQuests, totalCompleted] = quests.reduce(
([total, completed], quest) => [
total + quest.started,
completed + quest.completed,
],
[0, 0]
)
const payload = {
title: 'Linux Odyssey Admin',
userCount: await userCount(),
sessionCount: await sessionCount(),
sessionCompleted: await sessionCompleted(),
totalQuests,
totalCompleted,
}
payload.completedRate =
Math.round((payload.sessionCompleted / payload.sessionCount) * 10000) / 100
Math.round((payload.totalCompleted / payload.totalQuests) * 10000) / 100
res.render('home', payload)
}
@@ -0,0 +1,10 @@
import { questList } from '../models/quests.js'
// eslint-disable-next-line import/prefer-default-export
export async function questListController(req, res) {
const quests = await questList()
quests.forEach((quest) => {
quest.failed = quest.started - quest.completed
})
res.render('quests', { quests })
}
+18 -2
View File
@@ -1,8 +1,24 @@
import Pagination from '../models/pagination.js'
import { sessionList, sessionDetail } from '../models/sessions.js'
export async function sessionListController(req, res) {
const sessions = await sessionList()
res.render('sessions', { sessions })
const { nextKey, order } = req.query
const itemsPerPage = 50
try {
const pagination = new Pagination(order, itemsPerPage, nextKey)
const sessions = await sessionList(pagination)
const newNextKey = sessions[sessions.length - 1]?._id
res.render('sessions', {
sessions,
nextKey: newNextKey,
order: pagination.getOrder(),
})
} catch (error) {
console.log(error)
res.status(500).send('Error fetching session data')
}
}
export async function sessionDetailController(req, res) {
+18 -2
View File
@@ -1,6 +1,22 @@
import Pagination from '../models/pagination.js'
import { userList } from '../models/users.js'
// eslint-disable-next-line import/prefer-default-export
export async function userListController(req, res) {
res.render('users', { users: await userList() })
const { nextKey, order } = req.query
const itemsPerPage = 50
try {
const pagination = new Pagination(order, itemsPerPage, nextKey)
const users = await userList(pagination)
const newNextKey = users[users.length - 1]?._id
res.render('users', {
users,
nextKey: newNextKey,
order: pagination.getOrder(),
})
} catch (error) {
console.error(error)
res.status(500).send('Error fetching user data')
}
}
+36 -14
View File
@@ -1,22 +1,44 @@
import { Command } from '@linux-odyssey/models'
// eslint-disable-next-line import/prefer-default-export
export async function errorCommands() {
const commands = await Command.find({
$and: [
{ error: { $exists: true } },
{ error: { $ne: null } },
{ error: { $ne: '' } },
],
})
.populate({
path: 'session',
populate: { path: 'user' },
})
.exec()
return commands.map(({ command, error, createdAt, session }) => {
export async function errorCommands(pagination) {
const commands = await Command.aggregate([
pagination.match('_id'),
{
$match: {
$and: [
{ error: { $exists: true } },
{ error: { $ne: null } },
{ error: { $ne: '' } },
],
},
},
{
$lookup: {
from: 'sessions', // 替換成 'session' 對應的集合名稱
localField: 'session', // 替換成 'session' 對應的字段名稱
foreignField: '_id',
as: 'session',
},
},
{ $unwind: '$session' },
{
$lookup: {
from: 'users', // 替換成 'user' 對應的集合名稱
localField: 'session.user', // 替換成 'user' 對應的字段名稱
foreignField: '_id',
as: 'session.user',
},
},
{ $unwind: '$session.user' },
pagination.sort('_id'),
pagination.limit(),
])
return commands.map(({ _id, command, error, createdAt, session }) => {
const { user, quest } = session
return {
_id,
command: command.slice(0, 20),
error: error.slice(0, 50),
createdAt: createdAt?.toLocaleString(),
@@ -0,0 +1,74 @@
/* eslint-disable lines-between-class-members */
/* eslint-disable class-methods-use-this */
// eslint-disable-next-line max-classes-per-file
import mongoose from 'mongoose'
export const DESC = 'desc'
export const ASC = 'asc'
class Asc {
getOperator() {
return '$gt'
}
getOrder() {
return 1
}
getName() {
return ASC
}
}
class Desc {
getOperator() {
return '$lt'
}
getOrder() {
return -1
}
getName() {
return DESC
}
}
export default class Pagination {
nextKey = null
itemsPerPage = 50
order = null
constructor(order, itemsPerPage = 50, nextKey = null) {
this.itemsPerPage = itemsPerPage
if (!order || order === DESC) {
this.order = new Desc()
} else if (order === ASC) {
this.order = new Asc()
} else {
throw new Error('order must be asc or desc')
}
if (nextKey) {
this.nextKey = new mongoose.Types.ObjectId(nextKey)
}
}
match(field) {
const stage = this.nextKey
? { [field]: { [this.order.getOperator()]: this.nextKey } }
: {}
return { $match: stage }
}
sort(field) {
return { $sort: { [field]: this.order.getOrder() } }
}
limit() {
return { $limit: this.itemsPerPage }
}
getOrder() {
return this.order.getName()
}
}
+59
View File
@@ -0,0 +1,59 @@
/* eslint-disable object-shorthand */
/* eslint-disable func-names */
import { UserProfile } from '@linux-odyssey/models'
export function questList() {
return UserProfile.aggregate([
{
$project: {
progress: {
$objectToArray: '$progress',
},
},
},
{
$unwind: '$progress',
},
{
$group: {
_id: '$progress.v.quest',
started: {
$sum: 1,
},
completed: {
$sum: {
$cond: {
if: { $eq: ['$progress.v.completed', true] },
then: 1,
else: 0,
},
},
},
},
},
{
$project: {
_id: 1,
started: 1,
completed: 1,
completedRate: {
$cond: {
if: { $eq: ['$started', 0] },
then: 0,
else: {
$round: [
{ $multiply: [{ $divide: ['$completed', '$started'] }, 100] },
0,
],
},
},
},
},
},
{
$sort: {
completed: -1,
},
},
])
}
+5 -24
View File
@@ -1,28 +1,5 @@
import { Command, Session } from '@linux-odyssey/models'
export function sessionCount() {
return Session.find().count()
}
export function sessionCompleted() {
return Session.find({ status: 'finished' }).count()
}
export function averageTimeUsage() {
return Session.aggregate([
{
$group: {
_id: '$quest',
average: {
$avg: {
$subtract: ['$finishedAt', '$createdAt'],
},
},
},
},
])
}
function formatTime(time) {
const seconds = Math.floor(time / 1000)
const minutes = Math.floor(seconds / 60)
@@ -30,8 +7,9 @@ function formatTime(time) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
}
export async function sessionList() {
export async function sessionList(pagination) {
const sessions = await Session.aggregate([
pagination.match('_id'),
{
$lookup: {
from: 'users',
@@ -49,7 +27,10 @@ export async function sessionList() {
as: 'commands',
},
},
pagination.sort('_id'),
pagination.limit(),
])
return sessions.map(
({
_id,
+7 -3
View File
@@ -11,8 +11,9 @@ export function userCount() {
return User.find().count()
}
export async function userList() {
export async function userList(pagination) {
const users = await Session.aggregate([
pagination.match('user'),
{
$group: {
_id: '$user',
@@ -29,11 +30,14 @@ export async function userList() {
},
},
{ $unwind: '$userData' },
{ $sort: { 'userData.createdAt': 1 } },
pagination.sort('userData._id'),
pagination.limit(),
])
return users.map(({ userData, count, lastActivityAt }) => {
const { username, email, createdAt } = userData
const { _id, username, email, createdAt } = userData
return {
_id,
username,
email,
createdAt: createdAt?.toLocaleString(),
+2
View File
@@ -2,6 +2,7 @@ import { Router } from 'express'
import { homeController } from './controllers/index.js'
import { userListController } from './controllers/users.js'
import { questListController } from './controllers/quests.js'
import { commandListController } from './controllers/commands.js'
import {
sessionDetailController,
@@ -12,6 +13,7 @@ const router = Router()
router.get('/', homeController)
router.get('/users', userListController)
router.get('/quests', questListController)
router.get('/commands', commandListController)
router.get('/sessions', sessionListController)
router.get('/sessions/:id', sessionDetailController)
@@ -0,0 +1,48 @@
import mongoose from 'mongoose'
import Pagination, { ASC, DESC } from '../src/models/pagination.js'
describe('Pagination', () => {
it('should failed when not provide order', () => {
expect(new Pagination('').getOrder()).toBe(DESC)
expect(() => new Pagination('Hahaha')).toThrow()
})
it('should return correct limit', () => {
const pagination = new Pagination(DESC, 10)
expect(pagination.limit()).toMatchObject({ $limit: 10 })
})
it('desc should return correct order', async () => {
const pagination = new Pagination(DESC, 10)
expect(pagination.sort('_id')).toMatchObject({ $sort: { _id: -1 } })
expect(pagination.sort('time')).toMatchObject({ $sort: { time: -1 } })
})
it('asc should return correct order', async () => {
const pagination = new Pagination(ASC, 10)
expect(pagination.sort('_id')).toMatchObject({ $sort: { _id: 1 } })
expect(pagination.sort('time')).toMatchObject({ $sort: { time: 1 } })
})
it('should return correct match stage for empty nextKey', async () => {
const pagination = new Pagination(DESC, 10)
expect(pagination.match('_id')).toMatchObject({ $match: {} })
})
it('should return correct match stage for empty nextKey', async () => {
const nextKeyStr = '656c7fea28a4c087bfcefe12'
const pagination = new Pagination(DESC, 10, nextKeyStr)
const nextKey = new mongoose.Types.ObjectId(nextKeyStr)
expect(pagination.match('_id')).toMatchObject({
$match: {
_id: {
$lt: nextKey,
},
},
})
})
})
@@ -1,6 +1,10 @@
<h1>Commands</h1>
<div>
<h2>Error commands</h2>
<div class='pagination'>
<a href='/commands?order=asc'>Ascending</a>
<a href='/commands?order=desc'>Descending</a>
</div>
<table>
<tr>
<th>Command</th>
@@ -21,4 +25,7 @@
</tr>
{{/each}}
</table>
<div class='pagination'>
<a href='/commands?nextKey={{nextKey}}&order={{order}}'>Next</a>
</div>
</div>
+5 -5
View File
@@ -1,18 +1,18 @@
<h1>Welcome to {{title}}</h1>
<div>
<h2>Users</h2>
<h2>Overview</h2>
<table>
<tr>
<th>Registered Users</th>
<td>{{userCount}}</td>
</tr>
<tr>
<th>Session Count</th>
<td>{{sessionCount}}</td>
<th>Total Started Quests</th>
<td>{{totalQuests}}</td>
</tr>
<tr>
<th>Completed Session</th>
<td>{{sessionCompleted}}</td>
<th>Completed Quests</th>
<td>{{totalCompleted}}</td>
</tr>
<tr>
<th>Completed Rate</th>
@@ -8,6 +8,7 @@
<ul>
<li><a href='/'>Home</a></li>
<li><a href='/users'>Users</a></li>
<li><a href='/quests'>Quests</a></li>
<li><a href='/commands'>Commands</a></li>
<li><a href='/sessions'>Sessions</a></li>
</ul>
@@ -0,0 +1,20 @@
<h1>Quests</h1>
<div>
<table>
<tr>
<th>Id</th>
<th>Started</th>
<th>Completed</th>
<th>Failed</th>
<th>Completed Rate</th>
</tr>
{{#each quests}}
<tr>
<td>{{_id}}</td>
<td>{{started}}</td>
<td>{{completed}}</td>
<td>{{failed}}</td>
<td>{{completedRate}} %</td>
</tr>
{{/each}}
</table>
@@ -36,20 +36,20 @@
<table>
<tr>
<th>Command</th>
<th>Stage</th>
<th>PWD</th>
<th>Input</th>
<th>Output</th>
<th>Error</th>
<th>Stage</th>
<th>Created At</th>
<th>Input Time</th>
</tr>
{{#each commands}}
<tr>
<td>{{command}}</td>
<td>{{stage}}</td>
<td>{{pwd}}</td>
<td>{{command}}</td>
<td>{{output}}</td>
<td>{{error}}</td>
<td>{{stage}}</td>
<td>{{createdAt}}</td>
</tr>
{{/each}}
+9 -2
View File
@@ -1,27 +1,34 @@
<h1>Sessions</h1>
<div class='pagination'>
<a href='/sessions?order=asc'>Ascending</a>
<a href='/sessions?order=desc'>Descending</a>
</div>
<div>
<table>
<tr>
<th>User</th>
<th>Quest</th>
<th></th>
<th>Status</th>
<th>Created At</th>
<th>Finished At</th>
<th>Used Time</th>
<th>Command Count</th>
<th>Session</th>
</tr>
{{#each sessions}}
<tr>
<td>{{user}}</td>
<td>{{quest}}</td>
<td><a href='/sessions/{{_id}}'>Detail</a></td>
<td>{{status}}</td>
<td>{{createdAt}}</td>
<td>{{finishedAt}}</td>
<td>{{usedTime}}</td>
<td>{{commandCount}}</td>
<td><a href='/sessions/{{_id}}'>Detail</a></td>
</tr>
{{/each}}
</table>
</div>
<div class='pagination'>
<a href='/sessions?nextKey={{nextKey}}&order={{order}}'>Next</a>
</div>
+16 -9
View File
@@ -1,23 +1,30 @@
<h1>Users List</h1>
<div class='pagination'>
<a href='/users?order=asc'>Ascending</a>
<a href='/users?order=desc'>Descending</a>
</div>
<div>
<table>
<tr>
<th>Username</th>
<th>Email</th>
<th>Registration Method</th>
<th>Registration Time</th>
<th>Sessions Count</th>
<th>Last Activity</th>
<th>Sessions Count</th>
<th>Email</th>
<th>Registration Time</th>
<th>Registration Method</th>
</tr>
{{#each users}}
<tr>
<td>{{username}}</td>
<td>{{email}}</td>
<td>{{method}}</td>
<td>{{createdAt}}</td>
<td>{{sessionCount}}</td>
<td href='/users/{{_id}}'>{{username}}</td>
<td>{{lastActivityAt}}</td>
<td>{{sessionCount}}</td>
<td>{{email}}</td>
<td>{{createdAt}}</td>
<td>{{method}}</td>
</tr>
{{/each}}
</table>
</div>
<div class='pagination'>
<a href='/users?nextKey={{nextKey}}&order={{order}}'>Next</a>
</div>
+43 -33
View File
@@ -3,9 +3,26 @@ const fs = require('fs').promises
const { exit } = require('process')
const minimist = require('minimist')
const axios = require('axios')
const { colorize, printResponses } = require('./print.js')
const { colorize, printResponses, printHints } = require('./print.js')
const discoverFiles = require('./discover.js')
const {
API_ENDPOINT,
TOKEN,
PWD,
CMD_NAME,
CMD_OUTPUT_FILE,
CMD_ERROR_FILE,
CMD_EXIT_CODE,
} = process.env
const api = axios.create({
baseURL: `${API_ENDPOINT}/api/v1`,
headers: {
Authorization: `Bearer ${TOKEN}`,
},
})
async function readOrNone(file) {
try {
return await fs.readFile(file, 'utf8')
@@ -29,24 +46,21 @@ async function handleCommand(command) {
}, {})
}
async function main() {
const {
API_ENDPOINT,
TOKEN,
PWD,
CMD_NAME,
CMD_OUTPUT_FILE,
CMD_ERROR_FILE,
CMD_EXIT_CODE,
} = process.env
if (!CMD_NAME) exit(0)
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')
}
const api = axios.create({
baseURL: `${API_ENDPOINT}/api/v1`,
headers: {
Authorization: `Bearer ${TOKEN}`,
},
})
async function main() {
if (!CMD_NAME) exit(0)
const params = await handleCommand(CMD_NAME)
@@ -62,20 +76,8 @@ async function main() {
}
try {
const res = await api.post('/commands', payload)
if (!res.data) return
const { responses, hints, end } = res.data
if (responses) {
await printResponses(responses, 60)
await api.post('/commands/completed')
}
if (hints) {
for (const hint of hints) {
console.log(colorize(hint, 'yellow'))
}
}
if (end) {
console.log(colorize('Quest completed!', 'blue'))
}
if (!res.data || !res.data.responses) return
await handleResponse(res.data)
} catch (err) {
if (err.response) {
console.error(err.response.data)
@@ -85,4 +87,12 @@ async function main() {
}
}
main().then(exit)
main()
.then(exit)
.catch((err) => {
console.log(
'發生預期外的錯誤,請嘗試重新啟動 Quest,如果問題持續發生,請幫我們填寫右上角的錯誤回報表單。'
)
console.error(err)
exit(1)
})
+10 -6
View File
@@ -34,12 +34,16 @@ async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
async function discoverFiles(argv) {
const targetPath = argv._.length < 2 ? ['.'] : argv._.slice(1)
const result = await Promise.all(
targetPath
.map((p) => path.resolve(process.cwd(), p))
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
)
return { discover: result.flat() }
try {
const result = await Promise.all(
targetPath
.map((p) => path.resolve(process.cwd(), p))
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
)
return { discover: result.flat() }
} catch (err) {
return {}
}
}
module.exports = discoverFiles
+39 -9
View File
@@ -6,6 +6,7 @@ const RETURN_SYMBOL = '\u21B5'
// ANSI color map
const ANSI_COLORS = {
default: 0,
black: 30,
red: 31,
green: 32,
@@ -16,10 +17,13 @@ const ANSI_COLORS = {
white: 37,
}
function colorCode(color) {
return `\x1b[${ANSI_COLORS[color]}m`
}
function colorize(content, color) {
const colorCode = ANSI_COLORS[color]
if (!colorCode) return content
return `\x1b[${colorCode}m${content}\x1b[0m`
if (!color) return content
return `${colorCode(color)}${content}${colorCode('default')}`
}
process.stdin.setRawMode(true)
@@ -36,8 +40,25 @@ function waitForEnter() {
})
}
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 = `${colorize(content, color)}${RETURN_SYMBOL}`
const printContent = formatLine(content, color)
return new Promise((resolve) => {
let i = 0
const timer = setInterval(() => {
@@ -52,6 +73,7 @@ function printLine(content, delay, color) {
waitForEnter().then(() => {
clearInterval(timer)
if (i >= printContent.length) return
process.stdout.write(printContent.slice(i))
process.stdout.write('\n')
resolve()
@@ -61,25 +83,33 @@ function printLine(content, delay, color) {
async function printResponses(responses, delay = 100) {
console.log()
console.log(colorize('-------------', 'white'))
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 || 'white')
await printLine(line, delay, color || 'cyan')
}
}
if (type === 'dialogue') {
console.log(`${speaker}:`)
console.log(colorize(`${speaker}:`, 'green'))
for (const line of content) {
await printLine(` ${line}`, delay, color || 'green')
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')
}
rl.close()
}
module.exports = {
printResponses,
printHints,
colorize,
}
+19
View File
@@ -0,0 +1,19 @@
import { Schema } from 'mongoose'
import conditionSchema from './condition.js'
import responseSchema from './response.js'
import hintSchema from './hint.js'
const commandResponderSchema = new Schema({
condition: {
type: conditionSchema,
required: false,
},
responses: {
type: [responseSchema],
required: true,
},
})
commandResponderSchema.add(hintSchema)
export default commandResponderSchema
+28
View File
@@ -0,0 +1,28 @@
import { Schema } from 'mongoose'
const fileConditionSchema = new Schema({
path: {
type: String,
required: true,
},
type: String,
exists: {
type: Boolean,
default: true,
},
})
const conditionSchema = new Schema({
command: [String],
output: [String],
error: [String],
pwd: [String],
files: [fileConditionSchema],
})
conditionSchema.add({
or: [conditionSchema],
not: conditionSchema,
})
export default conditionSchema
+8
View File
@@ -0,0 +1,8 @@
import { Schema } from 'mongoose'
import commandResponderSchema from './commandResponder.js'
const exceptionSchema = new Schema({})
exceptionSchema.add(commandResponderSchema)
export default exceptionSchema
+10
View File
@@ -0,0 +1,10 @@
import { Schema } from 'mongoose'
import exceptionSchema from './exception.js'
import requirementSchema from './requirement.js'
const globalExceptionSchema = new Schema({})
globalExceptionSchema.add(exceptionSchema)
globalExceptionSchema.add(requirementSchema)
export default globalExceptionSchema
+10
View File
@@ -0,0 +1,10 @@
import { Schema } from 'mongoose'
const hintSchema = new Schema({
hints: {
type: [String],
required: true,
},
})
export default hintSchema
+1 -1
View File
@@ -6,6 +6,6 @@
"license": "GPL",
"exports": "./index.js",
"dependencies": {
"mongoose": "^7.6.0"
"mongoose": "^8.0.3"
}
}
+6 -68
View File
@@ -1,72 +1,6 @@
import { model, Schema } from 'mongoose'
const responseSchema = new Schema({
type: {
type: String,
required: true,
},
content: [String],
speaker: String,
color: String,
})
const fileConditionSchema = new Schema({
path: {
type: String,
required: true,
},
type: String,
exists: {
type: Boolean,
default: true,
},
})
const conditionSchema = new Schema({
command: [String],
output: [String],
error: [String],
pwd: [String],
files: [fileConditionSchema],
})
conditionSchema.add({
or: [conditionSchema],
not: conditionSchema,
})
const stageSchema = new Schema({
id: {
type: String,
required: true,
},
task: {
type: String,
},
requirements: [
{
type: String,
required: true,
},
],
repeatable: {
type: Boolean,
required: true,
default: false,
},
condition: {
type: conditionSchema,
required: true,
},
responses: {
type: [responseSchema],
required: true,
},
hints: {
type: [String],
required: true,
},
})
import stageSchema from './stage.js'
import globalExceptionSchema from './globalException.js'
const Quest = model(
'Quest',
@@ -100,6 +34,10 @@ const Quest = model(
type: [stageSchema],
required: true,
},
exceptions: {
type: [globalExceptionSchema],
required: true,
},
})
)
+12
View File
@@ -0,0 +1,12 @@
import { Schema } from 'mongoose'
const requirementSchema = new Schema({
requirements: [
{
type: String,
required: true,
},
],
})
export default requirementSchema
+18
View File
@@ -0,0 +1,18 @@
import { Schema } from 'mongoose'
const responseSchema = new Schema({
type: {
type: String,
enum: ['narrative', 'dialogue'],
default: 'dialogue',
required: true,
},
content: {
type: [String],
required: true,
},
speaker: { type: String, default: 'Ada' },
color: String,
})
export default responseSchema
+3 -1
View File
@@ -35,10 +35,12 @@ const Session = model(
user: {
type: Schema.ObjectId,
ref: 'User',
required: true,
},
quest: {
type: String,
ref: 'Quest',
required: true,
},
containerId: String,
status: {
@@ -54,7 +56,7 @@ const Session = model(
required: true,
},
tasks: [taskSchema],
hints: [String],
hints: [[String]],
graph: {
type: nodeSchema,
default: {
+24
View File
@@ -0,0 +1,24 @@
import { Schema } from 'mongoose'
import commandResponderSchema from './commandResponder.js'
import stageExceptionSchema from './stageException.js'
import requirementSchema from './requirement.js'
const stageSchema = new Schema({
id: {
type: String,
required: true,
},
task: {
type: String,
required: true,
},
exceptions: {
type: [stageExceptionSchema],
required: true,
},
})
stageSchema.add(commandResponderSchema)
stageSchema.add(requirementSchema)
export default stageSchema
+14
View File
@@ -0,0 +1,14 @@
import { Schema } from 'mongoose'
import exceptionSchema from './exception.js'
const stageExceptionSchema = new Schema({
catchAll: {
type: Boolean,
required: true,
default: false,
},
})
stageExceptionSchema.add(exceptionSchema)
export default stageExceptionSchema
+73
View File
@@ -0,0 +1,73 @@
export default class DAG {
#nodes = new Map()
#edges = new Set()
#layers = []
constructor(nodes) {
this.#nodes = new Map(nodes.map((node) => [node._id, node]))
this.#edges = new Set()
this.#nodes.forEach((_, id) => {
this.#setLayer(id)
this.#addEdges(id)
})
}
getNode(id) {
return this.#nodes.get(id)
}
getNodes() {
return Array.from(this.#nodes.values())
}
getEdges() {
return this.#edges
}
getEdgesArray() {
return Array.from(this.#edges.values())
}
getLayers() {
return this.#layers
}
getLayer(id) {
const node = this.getNode(id)
if (!node) {
throw new Error(`Node ${id} not found`)
}
return this.#layers[node.layer - 1]
}
#setLayer(id) {
const node = this.getNode(id)
if (!node) {
throw new Error(`Node ${id} not found`)
}
if (node.layer !== undefined) {
return node.layer
}
const previousLayer = node.requirements.map((rId) => this.#setLayer(rId))
node.layer = Math.max(...previousLayer, 0) + 1
if (this.#layers.length < node.layer) {
this.#layers.push(1)
} else {
this.#layers[node.layer - 1] += 1
}
node.index = this.#layers[node.layer - 1]
return node.layer
}
#addEdges(id) {
const node = this.getNode(id)
if (!node) {
throw new Error(`Node ${id} not found`)
}
node.requirements.forEach((rId) => {
this.#edges.add([rId, id])
})
}
}
+165
View File
@@ -0,0 +1,165 @@
import DAG from './dag.js'
describe('dag layers', () => {
it('two layer', () => {
const data = [
{
_id: 'helloworld',
requirements: [],
},
{
_id: 'spell',
requirements: ['helloworld'],
},
]
const dag = new DAG(data)
expect(dag.getNode('helloworld').layer).toBe(1)
expect(dag.getNode('spell').layer).toBe(2)
})
it('branch', () => {
const data = [
{
_id: 'helloworld',
requirements: [],
},
{
_id: 'spell',
requirements: ['helloworld'],
},
{
_id: 'discover',
requirements: ['helloworld'],
},
]
const dag = new DAG(data)
expect(dag.getNode('helloworld').layer).toBe(1)
expect(dag.getNode('spell').layer).toBe(2)
expect(dag.getNode('discover').layer).toBe(2)
})
it('merge', () => {
const data = [
{
_id: 'A',
requirements: [],
},
{
_id: 'B',
requirements: [],
},
{
_id: 'C',
requirements: ['A', 'B'],
},
]
const dag = new DAG(data)
expect(dag.getNode('A').layer).toBe(1)
expect(dag.getNode('B').layer).toBe(1)
expect(dag.getNode('C').layer).toBe(2)
})
it('skip', () => {
const data = [
{
_id: 'A',
requirements: [],
},
{
_id: 'B',
requirements: [],
},
{
_id: 'C',
requirements: ['A'],
},
{
_id: 'D',
requirements: ['B', 'C'],
},
]
const dag = new DAG(data)
expect(dag.getNode('A').layer).toBe(1)
expect(dag.getNode('B').layer).toBe(1)
expect(dag.getNode('C').layer).toBe(2)
expect(dag.getNode('D').layer).toBe(3)
})
})
describe('dag edges', () => {
it('one edge', () => {
const data = [
{
_id: 'helloworld',
requirements: [],
},
{
_id: 'spell',
requirements: ['helloworld'],
},
]
const dag = new DAG(data)
expect(dag.getEdges()).toEqual(new Set([['helloworld', 'spell']]))
})
it('branch edges', () => {
const data = [
{
_id: 'helloworld',
requirements: [],
},
{
_id: 'spell',
requirements: ['helloworld'],
},
{
_id: 'discover',
requirements: ['helloworld'],
},
]
const dag = new DAG(data)
expect(dag.getEdges()).toEqual(
new Set([
['helloworld', 'discover'],
['helloworld', 'spell'],
])
)
})
})
describe('dag layers', () => {
it('two layer', () => {
const data = [
{
_id: 'helloworld',
requirements: [],
},
{
_id: 'spell',
requirements: ['helloworld'],
},
]
const dag = new DAG(data)
expect(dag.getLayers()).toEqual([1, 1])
})
it('branch layers', () => {
const data = [
{
_id: 'helloworld',
requirements: [],
},
{
_id: 'spell',
requirements: ['helloworld'],
},
{
_id: 'discover',
requirements: ['helloworld'],
},
]
const dag = new DAG(data)
expect(dag.getLayers()).toEqual([1, 2])
})
})
+1
View File
@@ -1,2 +1,3 @@
export { isValidEmail, isValidUsername } from './auth.js'
export { buildFileCheckCmd } from './commands.js'
export { default as DAG } from './dag.js'
+3
View File
@@ -0,0 +1,3 @@
echo start
echo example
echo How are you
+75
View File
@@ -0,0 +1,75 @@
title: 輸出文字
instruction: |
在這個關卡中,你將會學到:
1. 如何使用 `echo` 指令
準備好就輸入 `echo start` 開始吧!
requirements:
- get-started
stages:
- id: start
task: 輸入 `echo start` 來開始教程
condition:
output:
- ^start$
responses:
- content:
- 你剛才使用的指令叫做 `echo`。
- '`echo` 指令可以將後方接續的文字輸出到終端機上。'
- 例如你輸入 `echo Hello`,終端機就會顯示 `Hello`。
- 你可以試著輸入 `echo` 並在後面加上一些文字,然後按下 Enter 鍵,就可以看到終端機上顯示了你剛才輸入的文字。
hints:
- ➤ 輸入 `echo` 並在後面加上一些文字
- ➤ 例如:`echo example`
- id: echo
task: 在終端機上顯示顯示任意文字
requirements:
- start
condition:
command:
- ^echo .*
responses:
- content:
- 你應該可以看到終端機上顯示了你在 `echo` 指令後面的文字。
- 接下來,請你試著在終端機上顯示 `How are you?`。
hints:
- ➤ 利用 `echo` 指令來在終端機上顯示 `How are you?`
- id: END
task: 在終端機上顯示 `How are you?`
requirements:
- echo
condition:
output:
- ^[Hh]ow are you\??$
exceptions:
- condition:
command:
- ^[Hh]ow are you\??$
responses:
- type: narrative
content:
- 在 `How are you?` 前面必須加上 `echo`
- condition:
command:
- ^echo$
responses:
- type: narrative
content:
- 在 `echo` 後面還需要加上一些文字
responses:
- content:
- 你成功的在終端機上顯示了 `How are you?`。
- 相信你已經了解如何使用 `echo` 指令來顯示文字了。
- 之後如果要在終端機顯示文字,記得可以使用 `echo` 指令。
exceptions:
- repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
+131
View File
@@ -0,0 +1,131 @@
title: 輸出文字
instruction: |
在這個關卡中,你將會學到:
1. 如何使用 `echo` 指令
2. `echo` 指令在遊戲中的用途
準備好就輸入 `echo start` 開始吧!
requirements:
- get-started
stages:
- id: warning-01
repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
- id: start
task: 輸入 `echo start` 來開始教程
condition:
output:
- ^start$
responses:
- content:
- 你剛才使用的指令叫做 `echo`。
- '`echo` 指令可以將後方接續的文字輸出到終端機上。'
- 例如你輸入 `echo Hello`,終端機就會顯示 `Hello`。
- 你可以試著輸入 `echo` 並在後面加上一些文字,然後按下 Enter 鍵,就可以看到終端機上顯示了你剛才輸入的文字。
hints:
- ➤ 輸入 `echo` 並在後面加上一些文字
- ➤ 例如:`echo example`
- id: echo
task: 在終端機上顯示顯示任意文字
requirements:
- start
condition:
command:
- ^echo .*
responses:
- content:
- 你應該可以看到終端機上顯示了你在 `echo` 指令後面的文字。
- 接下來,請你試著在終端機上顯示 `How are you?`。
hints:
- ➤ 利用 `echo` 指令來在終端機上顯示 `How are you?`
- id: how-are-you
task: 在終端機上顯示 `How are you?`
requirements:
- echo
condition:
output:
- ^[Hh]ow are you\??$
exceptions:
- condition:
command:
- ^[Hh]ow are you\??$
responses:
- type: narrative
content:
- 在 `How are you?` 前面必須加上 `echo`
- condition:
command:
- ^echo$
responses:
- type: narrative
content:
- 在 `echo` 後面還需要加上一些文字
responses:
- content:
- 你成功的在終端機上顯示了 `How are you?`。
- 相信你已經了解 `echo` 指令的用法了。
- 接下來,要像你介紹 `echo` 在遊戲中的特殊功能,那就是詠唱咒語。
- 你可以通過輸入 `echo [咒語]` 來詠唱咒語,並產生對應的特殊效果。
- 但是要注意,詠唱咒語只有在 Linux Odyssey 中才會有特殊效果。
- 在真實的 Linux 環境中,詠唱咒語並不會有任何特殊效果。
- 現在,有一段咒語叫做 `HELP`,請你試著使用 `echo` 詠唱這段咒語。
hints:
- ➤ 試著使用 `echo` 來詠唱 `HELP` 咒語
- ➤ 注意:咒語要使用大寫
- id: help
task: 詠唱 `HELP` 咒語
requirements:
- how-are-you
condition:
output:
- ^HELP$
responses:
- content:
- 你詠唱了 `HELP` 咒語,一位精靈出現了。
- type: dialogue
speaker: Unix 精靈
content:
- 你好,我是 Unix 精靈。
- 既然你使用了 `HELP` 咒語呼喚我,就讓我來教你一個咒語吧。
- 讓我想想... 好了,我想到了一個咒語,叫做 `WATER`。
- 這是施展水魔法的咒語。詠唱這個咒語可以施展水魔法,產生少量的水。
- 試著使用 `echo` 詠唱這個咒語吧。
hints:
- ➤ 詠唱 `WATER` 咒語
- id: END
task: 詠唱 `WATER` 咒語
requirements:
- help
condition:
output:
- ^WATER$
responses:
- content:
- 你施展了水魔法,一個水球出現在你的手中。
- type: dialogue
speaker: Unix 精靈
content:
- 很好,你已經學會了水魔法。
- content:
- 真厲害,你已經了解如何使用 `echo` 指令,並且學會了詠唱咒語的方法。
- 最後複習一下,你可以通過輸入 `echo [文字]` 來在終端機上顯示文字。
- 你也可以通過輸入 `echo [咒語]` 來詠唱咒語,並產生對應的特殊效果。
- 不要忘了,詠唱咒語只有在 Linux Odyssey 中才會有特殊效果。
- 在真實的 Linux 環境中,咒語只會被視為一串文字顯示出來而已。
- 以上就是 `echo` 指令的用法,請繼續努力,完成接下來的關卡吧!
hints: |
複習:
通過輸入 `echo [文字]` 來在終端機上顯示文字
通過輸入 `echo [咒語]` 來詠唱咒語
咒語只有在 Linux Odyssey 中才會有特殊效果
@@ -1,3 +1,3 @@
FROM linuxodyssey/quest-base
COPY home ${HOME}
COPY home ${HOME}
+12
View File
@@ -0,0 +1,12 @@
echo start
cat hello.txt
cat world.txt
echo continue
cp hello.txt hello2.txt
cat hello2.txt
cp world.txt world2.txt
cat world2.txt
cp -r notes notes2
ls notes2
cp hello.txt world.txt
cat world.txt
+262
View File
@@ -0,0 +1,262 @@
title: 複製檔案
instruction: |
在這個關卡中,你將會學到:
1. 如何用 `cp` 指令複製檔案
準備好就輸入 `echo start` 開始吧!
requirements:
- create
stages:
- id: start
task: 輸入 `echo start` 來開始
condition:
output:
- start
exceptions:
- condition:
command:
- ^echo$
responses:
- type: narrative
content:
- 你忘記輸入 `start` 了嗎?
- 請輸入 `echo start` 來開始
- catchAll: true
responses:
- type: narrative
content:
- 請輸入 `echo start` 來開始
responses:
- type: narrative
content:
- 今天我們要學習如何複製檔案。
- 現在資料夾裡有一個 `hello.txt` 跟一個 `world.txt`,請使用 `ls` 與 `cat` 來查看它們的內容。
- 看完之後,使用 `echo continue` 來繼續吧!
hints:
- ➤ 使用 `ls` 來查看資料夾內的檔案
- ➤ 使用 `cat hello.txt` 以及 `cat world.txt` 來查看檔案內容
- ➤ 如果終端機卡在某個指令無法輸入,請按下 `Ctrl + C` 來取消該指令
- id: cat-hello
task: 查看 `hello.txt` 的內容
requirements:
- start
condition:
command:
- ^cat hello.txt$
output:
- Hello
exceptions:
- condition:
command:
- ^cat$
responses:
- type: narrative
content:
- 你忘記輸入 `cat` 了嗎?
- 請輸入 `cat hello.txt` 來查看 `hello.txt` 的內容
- condition:
command:
- ^cat hello$
responses:
- type: narrative
content:
- 你忘記輸入 `.txt` 了嗎?
- 請輸入 `cat hello.txt` 來查看 `hello.txt` 的內容
responses:
- type: narrative
content:
- 你成功查看到 `hello.txt` 的內容了!
- id: cat-world
task: 查看 `world.txt` 的內容
requirements:
- start
condition:
command:
- ^cat world.txt$
output:
- World
responses:
- type: narrative
content:
- 你成功查看到 `world.txt` 的內容了!
- id: cat-continue
task: 使用 `echo continue` 來繼續
requirements:
- cat-hello
- cat-world
condition:
command:
- echo
responses:
- type: narrative
content:
- 接下來我們要來學習複製檔案。
- '`cp` 這個指令,是 copy 的縮寫,就是複製的意思。'
- 我們可以用 `cp [來源檔案] [目的檔案]` 的方式來複製檔案。
- 請你使用 `cp` 來複製 `hello.txt` 到 `hello2.txt`。
hints:
- ➤ 使用 `cp hello.txt hello2.txt` 來複製 `hello.txt` 到 `hello2.txt`
- id: cp-hello
task: 複製 `hello.txt` 到 `hello2.txt`
requirements:
- cat-continue
condition:
command:
- ^cp hello.txt hello2.txt$
files:
- path: hello2.txt
type: file
responses:
- type: narrative
content:
- 你成功複製 `hello.txt` 到 `hello2.txt` 了!
- 我們打開來確認一下 `hello2.txt` 的內容吧!
hints:
- ➤ 使用 `cat hello2.txt` 來查看 `hello2.txt` 的內容
- id: cat-hello2
requirements:
- cp-hello
task: 查看 `hello2.txt` 的內容
condition:
command:
- ^cat hello2.txt$
output:
- Hello
responses:
- type: narrative
content:
- 你成功查看到 `hello2.txt` 的內容了!
- 正如我們所見,`hello2.txt` 的內容跟 `hello.txt` 一模一樣。
- 那我們再來試試看複製 `world.txt` 到 `world2.txt` 吧!
hints:
- ➤ 使用 `cp world.txt world2.txt` 來複製 `world.txt` 到 `world2.txt`
- id: cp-world
requirements:
- cat-hello2
task: 複製 `world.txt` 到 `world2.txt`
condition:
command:
- ^cp world.txt world2.txt$
files:
- path: world2.txt
type: file
responses:
- type: narrative
content:
- 你成功複製 `world.txt` 到 `world2.txt` 了!
- 我們打開來確認一下 `world2.txt` 的內容吧!
hints:
- ➤ 使用 `cat world2.txt` 來查看 `world2.txt` 的內容
- id: cat-world2
requirements:
- cp-world
task: 查看 `world2.txt` 的內容
condition:
command:
- ^cat world2.txt$
output:
- World
responses:
- type: narrative
content:
- 你成功查看到 `world2.txt` 的內容了!
- '`world2.txt` 的內容跟 `world.txt` 一模一樣。'
- 接下來我們要來學習如何複製資料夾。
- 現在目錄下有一個 `notes` 資料夾,裡面有一個 `note.txt` 的檔案。
- 我們要能整個資料夾包含裡面的檔案一起複製到 `notes2`。
- 跟複製單一檔案不一樣的是,複製資料夾時,我們要加上 `-r` 的選項(recursive)代表將資料夾中的所有檔案都一起複製。
- 在使用指令時,如果想要調整指令的行為,可以在指令後面加上選項。
- 選項的格式通常是 `-` 加上一個字母,例如 `-r`。
- 選項擺放的位置沒有特別的限制,通常會跟在指令的後面,置於檔案名稱的前面。
- 請你使用 `cp -r notes notes2` 來複製 `notes` 資料夾到 `notes2`。
hints:
- ➤ 使用 `cp -r notes notes2` 來複製 `notes` 資料夾到 `notes2`
- ➤ 記得要加上 `-r` 的選項來複製所有檔案
- id: cp-notes
requirements:
- cat-world2
task: 複製 `notes` 資料夾到 `notes2`
condition:
command:
- ^cp
files:
- path: notes2
type: directory
- path: notes2/note.txt
type: file
responses:
- type: narrative
content:
- 你成功複製 `notes` 資料夾到 `notes2` 了!
- 我們打開來確認一下 `notes2` 資料夾的內容吧!
hints:
- ➤ 使用 `ls notes2` 來查看 `notes2` 資料夾的內容
exceptions:
- condition:
error:
- 'cp: omitting directory'
responses:
- type: narrative
content:
- 你忘記加上 `-r` 的參數了嗎?
- 請輸入 `cp -r notes notes2` 來複製 `notes` 資料夾到 `notes2`
- id: ls-notes2
requirements:
- cp-notes
task: 查看 `notes2` 資料夾的內容
condition:
command:
- ^ls notes2$
output:
- note.txt
responses:
- type: narrative
content:
- 你成功查看到 `notes2` 資料夾的內容了!
- 我們可以看到,整個 `notes` 資料夾包含裡面的檔案,都被複製到 `notes2` 資料夾了。
- 那麼最後我們要來試試看,如果複製一個檔案到已經存在的檔案會發生什麼事。
hints:
- ➤ 使用 `cp hello.txt world.txt` 來複製 `hello.txt` 到 `world.txt`
- id: cp-overwrite
requirements:
- ls-notes2
task: 複製 `hello.txt` 到 `world.txt`
condition:
command:
- ^cp hello.txt world.txt$
responses:
- type: narrative
content:
- 你成功複製 `hello.txt` 到 `world.txt` 了!
- 我們打開來確認一下 `world.txt` 的內容吧!
hints:
- ➤ 使用 `cat world.txt`
- id: END
requirements:
- cp-overwrite
task: 檢查 `world.txt` 的內容
condition:
command:
- ^cat world.txt$
output:
- Hello
responses:
- type: narrative
content:
- '`world.txt` 的內容變成 `Hello` 了!'
- 這是因為,當我們複製檔案時,如果目的檔案已經存在,那麼目的檔案的內容就會被覆蓋掉。
- 所以在複製檔案的時候,要特別注意目的檔案是否已經存在,否則可能會不小心把資料弄丟。
- 以上就是複製檔案的基本操作!
+1
View File
@@ -0,0 +1 @@
Hello
+1
View File
@@ -0,0 +1 @@
Note
+1
View File
@@ -0,0 +1 @@
World
+11
View File
@@ -0,0 +1,11 @@
echo start
touch hello.txt
ls
cat hello.txt
touch world.txt
ls
mkdir notes
ls
cd notes
touch note.txt
ls
+192
View File
@@ -0,0 +1,192 @@
title: 建立檔案與資料夾
instruction: |
在這個關卡中,你將會學到:
1. 如何使用 `touch` 建立檔案
2. 如何使用 `mkdir` 建立資料夾
準備好就輸入 `echo start` 開始吧!
requirements:
- read
stages:
- id: start
task: 輸入 `echo start` 來開始
condition:
command:
- ^echo start
responses:
- type: narrative
content:
- 這次要學習的指令是 `touch` 指令。
- '`touch` 指令可以建立一個檔案。'
- 接下來,請你試著在終端機上建立一個檔案,檔名為 `hello.txt`。
hints:
- ➤ 輸入 `touch hello.txt` 來建立一個檔案
- id: touch
task: 使用 `touch` 指令來建立一個檔案
requirements:
- start
condition:
files:
- path: hello.txt
type: file
responses:
- type: narrative
content:
- 你應該可以看到終端機上沒有任何回應。
- 這表示 `touch` 指令已經成功建立了一個檔案。
- 接下來,請你用之前學過的 `ls` 指令來查看目前所在資料夾中有哪些檔案。
hints:
- ➤ 輸入 `ls` 指令來查看目前所在資料夾中有哪些檔案
- id: ls-touch
task: 使用 `ls` 指令來查看新建立的檔案
requirements:
- touch
condition:
command:
- ^ls$
output:
- hello.txt
responses:
- type: narrative
content:
- 你應該可以看到終端機上顯示了 `hello.txt` 這個檔案。
- 我們來看看 `hello.txt` 檔案的內容吧!
- 我們可以使用之前學過的 `cat` 指令來查看檔案內容。
hints:
- ➤ 輸入 `cat hello.txt` 來查看 `hello.txt` 檔案的內容
- id: cat
task: 使用 `cat` 指令來查看 `hello.txt` 檔案的內容
condition:
command:
- ^cat hello.txt$
responses:
- type: narrative
content:
- 咦?竟然什麼都沒有!
- 沒錯,使用 `touch` 指令建立的檔案是空的。
- 我們在未來會學習如何編輯檔案的內容。
- 再來試試建立其他的檔案吧!
- 請你試著在終端機上建立另一個檔案,檔名為 `world.txt`。
hints:
- ➤ 輸入 `touch world.txt` 來建立一個檔案
- id: touch-2
task: 使用 `touch` 指令來建立另一個檔案 `world.txt`
requirements:
- ls-touch
condition:
files:
- path: world.txt
type: file
responses:
- type: narrative
content:
- 非常好,你已經成功建立了第二個檔案。
- 接下來,請你用之前學過的 `ls` 指令來查看剛剛建立的兩個檔案。
hints:
- ➤ 輸入 `ls` 指令來查看目前所在資料夾中有哪些檔案
- id: ls-2
task: 使用 `ls` 指令來查看剛剛建立的兩個檔案
requirements:
- touch-2
condition:
command:
- ^ls$
output:
- hello.txt
- world.txt
responses:
- type: narrative
content:
- 你應該可以看到終端機上顯示了 `hello.txt` 和 `world.txt` 這兩個檔案。
- 接下來,我們來試試看建立資料夾吧!
- '`mkdir` 指令可以建立一個空的資料夾。'
- 使用方式是 `mkdir [資料夾名稱]`。
- 請你試著在終端機上建立一個資料夾,名稱為 `notes`。
hints:
- ➤ 輸入 `mkdir notes` 來建立一個資料夾
- id: mkdir
task: 使用 `mkdir` 指令建立 `notes` 資料夾
requirements:
- ls-2
condition:
files:
- path: notes
type: folder
responses:
- type: narrative
content:
- 非常好,你已經成功建立了 `notes` 資料夾。
- 接下來,請你用 `ls` 指令來查看剛剛建立的資料夾。
- id: ls-mkdir
task: 使用 `ls` 查看剛建立的資料夾
requirements:
- mkdir
condition:
command:
- ^ls$
output:
- notes
responses:
- type: narrative
content:
- 你應該可以看到終端機上顯示了 `notes` 這個資料夾。
- 接下來,我們要在 `notes` 資料夾中建立一個 `note.txt` 的檔案。
- 首先,我們要先進入 `notes` 資料夾。
- 還記得之前的 `cd` 指令嗎?請你試著使用 `cd` 指令來進入 `notes` 資料夾。
hints:
- ➤ 輸入 `cd notes` 來進入 `notes` 資料夾
- ➤ 可以在輸入到 `cd n` 時按下 `Tab` 鍵來自動補齊資料夾名稱
- id: cd-notes
task: 移動到 `notes` 資料夾
requirements:
- ls-mkdir
condition:
pwd:
- ^/home/commander/notes$
responses:
- type: narrative
content:
- 你已經成功進入了 `notes` 資料夾。
- 接下來,請你試著在 `notes` 資料夾裡建立一個檔案,檔名為 `note.txt`。
hints:
- ➤ 輸入 `touch note.txt` 來建立一個檔案
- id: touch-note
task: 在 `notes` 中建立 `note.txt` 檔案
requirements:
- cd-notes
condition:
files:
- path: notes/note.txt
type: file
responses:
- type: narrative
content:
- 非常好,你已經成功建立了 `note.txt` 檔案。
- 跟之前一樣,你可以使用 `ls` 指令來查看目前所在資料夾中有哪些檔案。
- id: END
task: 使用 `ls` 指令來查看目前所在資料夾中有哪些檔案
requirements:
- touch-note
condition:
command:
- ^ls
output:
- note.txt
responses:
- type: narrative
content:
- 你應該可以看到終端機上顯示了 `note.txt` 這個檔案。
- 恭喜你完成了這個關卡!
- 在下一個關卡中,你將學習如何複製檔案與資料夾。
-6
View File
@@ -1,6 +0,0 @@
title: Discover the World!
instruction: |
試著探索這個世界吧!
requirements:
- spell
+142
View File
@@ -0,0 +1,142 @@
exceptions:
- condition:
command:
- '[\uff21-\uff3a\uff41-\uff5a]'
responses:
- type: narrative
content:
- '!! 請使用半形英文字母 !!'
- condition:
command:
- ' '
responses:
- type: narrative
content:
- '!! 請使用半形空格 !!'
- condition:
or:
- command:
- '`'
- command:
- \'
responses:
- type: narrative
content:
- '!! 不需要輸入上下引號 !!'
- condition:
command:
- ^echo\S$
responses:
- type: narrative
content:
- '!! `echo` 後面要加空格 !!'
- condition:
command:
- ^cd\S$
responses:
- type: narrative
content:
- '!! `cd` 後面要加空格 !!'
- condition:
command:
- ^ls\S$
responses:
- type: narrative
content:
- '!! `ls` 後面要加空格 !!'
- condition:
command:
- ^cat\S$
responses:
- type: narrative
content:
- '!! `cat` 後面要加空格 !!'
- condition:
command:
- ^touch\S$
responses:
- type: narrative
content:
- '!! `touch` 後面要加空格 !!'
- condition:
not:
command:
- ^rmdir$
command:
- ^rm\S$
responses:
- type: narrative
content:
- '!! `rm` 後面要加空格 !!'
- condition:
command:
- ^mv\S$
responses:
- type: narrative
content:
- '!! `mv` 後面要加空格 !!'
- condition:
command:
- ^mkdir\S$
responses:
- type: narrative
content:
- '!! `mkdir` 後面要加空格 !!'
- condition:
command:
- ^rmdir\S$
responses:
- type: narrative
content:
- '!! `rmdir` 後面要加空格 !!'
- condition:
command:
- ^cp\S$
responses:
- type: narrative
content:
- '!! `cp` 後面要加空格 !!'
- condition:
command:
- ^[Ii1]s$
responses:
- type: narrative
content:
- '!! `ls` 的首字母是小寫的 `L` !!'
- condition:
command:
- 嗨
responses:
- type: narrative
content:
- '嗨~'
- condition:
command:
- folderl
responses:
- type: narrative
content:
- '`folder1` 的 `1` 是數字 `1`,不是小寫的 `L` !!'
- condition:
command:
- filel
responses:
- type: narrative
content:
- '`file1` 的 `1` 是數字 `1`,不是小寫的 `L` !!'
+3
View File
@@ -0,0 +1,3 @@
FROM linuxodyssey/quest-base
COPY home ${HOME}
+8
View File
@@ -0,0 +1,8 @@
echo start
ls
cd folder1
ls
cd ..
cd folder2
ls
cd ..
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more