diff --git a/backend/server/routers/ctf.py b/backend/server/routers/ctf.py new file mode 100644 index 000000000..378103f9f --- /dev/null +++ b/backend/server/routers/ctf.py @@ -0,0 +1,283 @@ +""" +Implementation for the 2023 CTF. + + +Scenario: + In the whimsical world of the CTF (Capture The Flag), an extraordinary + challenge awaits our protagonist, Ollie the Otter. Ollie, a brilliant otter + with a passion for education, has embarked on an academic journey like no + other. But there's a twist – Ollie's unique otter nature has led to some + special conditions on his degree planning adventure. + + Ollie's task is to navigate through a degree planner system called + "Circles," specially designed to accommodate his otter-specific + requirements. In this intriguing scenario, you must help Ollie, the otter + scholar, chart his academic path using Circles. + + Ollie is planning his 3 year Computer Science (3778) degree starting in 2024 + and wants to take a Computer Science major and Mathematics minor. Help him set + up his degree in the degree wizard! + + When you are done, press the `Validate CTF` button on the term planner page + to recieve your flags. + +# Stage 1. The Otter's Academic Odyssey: Charting Uncharted Waters + Being the pioneer Otter in academia, Ollie faces some unique conditions + that must be fulfilled for him to successfully navigate his degree. Let's + tackle parts 0-3 to obtain the first flag. + +# Stage 2. The Kafka Quandary: Or How I Learned To Stop Worrying and Love The +# Handbook + Ollie encounters additional hurdles due to university policies, with + challenges 4 through 7 directly related to these policy-driven obstacles. + Join Ollie in overcoming these challenges and guiding him through this + academic maze + +# Stage 3: (HARD) Numbers, Notions, and the Cult of Calculus: An O-Week Odyssey + During Orientation Week (O-week), Ollie had a chance encounter with a group + of math students. Little did he know that this encounter would lead to him + being initiated into a number-centric cult. Over time, Ollie developed + superstitions about certain numbers, and the ultimate challenges he faces + revolve around aligning his degree plan with his newfound beliefs. With the + last challenges, Ollie on his quest to complete his degree while also + seeking the approval of the enigmatic number cult +""" +from typing import Callable, Optional + +from fastapi import APIRouter +from server.routers.model import PlannerData + +router = APIRouter( + prefix="/ctf", tags=["ctf"], responses={404: {"description": "Not found"}} +) + +def all_courses(data: PlannerData) -> set[str]: + """ + Returns all courses from a planner + """ + return { + course + for year in data.plan + for term in year + for course in term + } + + +def get_code(course: str) -> int: + """ + Returns the code of a courseCode + EG: COMP1511 -> 1511 + """ + return int(course[4:]) + + +def get_faculty(course: str) -> str: + """ + Returns the faculty of a courseCode + EG: COMP1511 -> COMP + """ + return course[:4] + + +def gen_eds(courses: set[str]) -> set[str]: + """ + Returns all gen eds from a set of courses + """ + return set( + course + for course in courses + if not course.startswith("COMP") and not course.startswith("MATH") + ) + + +def hard_requirements(data: PlannerData) -> bool: + # NOTE: Can't check start year from this + # Frontend should handle most of this anyways + # including validity of the program + return ( + data.program == "3778" + and "COMPA1" in data.specialisations + and "MATHC2" in data.specialisations + and len(data.plan) == 3 + ) + + +def extended_courses(data: PlannerData) -> bool: + """ + Must take atleast 3 courses with extended in the name + """ + extended_courses = { + "COMP3821", + "COMP3891", + "COMP6841", + "COMP6843" + "COMP6845" + } + return len(extended_courses & all_courses(data)) >= 3 + + +def summer_course(data: PlannerData) -> bool: + """ + Must take atleast one summer course + """ + return any( + course.startswith("COMP") + for year in data.plan + for course in year[0] + ) + + + +def term_sums_even(data: PlannerData) -> bool: + """ + Check that the sum of the course codes in even terms is even + """ + is_even: Callable[[int], bool] = lambda x: x % 2 == 0 + print("Checking even") + for y, year in enumerate(data.plan): + # Exclude summer term + odd terms + for i, term in enumerate(year[2::2], 2): + term_sum = sum(map(get_code, term.keys())) + print(f"{y}T{i} sum: {term_sum}") + if not is_even(term_sum): + print("failed: ", term) + return False + + return True + +# TODO +def term_sums_odd(data: PlannerData) -> bool: + """ + Check that the sum of the course codes in odd terms is odd + """ + is_odd: Callable[[int], bool] = lambda x: x % 2 == 1 + print("Checking odd") + for year in data.plan[::2]: + # Exclude summer term + even terms + for term in year[1::2]: + term_sum = sum(map(get_code, term.keys())) + if not is_odd(term_sum): + print("failed: ", term) + return False + return True + +def comp1511_marks(data: PlannerData) -> bool: + """ + Ollie must achieve a mark of 100 in COMP1511 to keep his scholarship + """ + for year in data.plan: + for term in year: + for course in term: + _, marks = term[course] # type: ignore + if course == "COMP1511": + return marks == 100 + + return False + + +def gen_ed_sum(data: PlannerData) -> bool: + """ + The sum of GENED course codes must not exceed 2200 + """ + return sum(map(get_code, gen_eds(all_courses(data)))) <= 2200 + + +def gen_ed_faculty(data: PlannerData) -> bool: + """ + Gen-Eds must all be from different faculties + """ + gen_eds_facs = list(map(get_faculty, gen_eds(all_courses(data)))) + return len(gen_eds_facs) == len(set(gen_eds_facs)) + + +def same_code_diff_faculty(data: PlannerData) -> bool: + """ + Must take two courses with the same code but, from different faculties + """ + codes = list(map(get_code, all_courses(data))) + # Can't have duplicate of a course since it's a set + return len(codes) != len(set(codes)) + + +def math_limit(data: PlannerData) -> bool: + """ + In your N-th year, you can only take N + 1 math courses + """ + for i, year in enumerate(data.plan, 1): + num_math = len([ + course + for term in year + for course in term + if course.startswith("MATH") + ]) + if num_math > i + 1: + return False + + return True + +def six_threes_limit(data: PlannerData) -> bool: + """ + There can by at most 6 occurrences of the number 3 in the entire + planner + """ + all_codes = "".join(str(get_code(course)) for course in all_courses(data)) + return all_codes.count("3") <= 6 + +def comp1531_third_year(data: PlannerData) -> bool: + """ + COMP1531 must be taken in the third year + """ + third_year = data.plan[2] + for term in third_year: + for course in term: + if course == "COMP1531": + return True + + return False + +# (validator_func, message, Optional) +requirements: list[tuple[Callable[[PlannerData], bool], str, Optional[str]]] = [ + # Challenge 1 + (hard_requirements, "Before you can submit, you must check that you are in a 3 year CS degree and have a math minor", None), + (summer_course, "Ollie must take one summer COMP course.", None), + (comp1511_marks, "To keep their scholarship, Ollie must achieve a mark of 100 in COMP1511.", None), + (extended_courses, "Ollie must complete at least THREE COMP courses with extended in the name that have not been discontinued.", "levelup{mVd3_1t_2_un1}"), + # Challenge 2 + (comp1531_third_year, "Unable to find a partner earlier, Ollie must take COMP1531 in their third year.", None), + (gen_ed_faculty, "The university has decided that General Education must be very general. As such, each Gen-Ed unit that Ollie takes must be from a different faculty.", None), + (math_limit, "The university has become a big believer in spaced repetition and want to prevent students from cramming subjects for their minors. Now, in their N-th year, Ollie can only take N + 1 math courses.", None), + (gen_ed_sum, "Course codes now reflect the difficulty of a course. To avoid extremely stressful terms, the sum of Olli's Gen-Ed course codes must not exceed 2200.", "levelup{i<3TryMesters}"), + # Challenge 3 + (same_code_diff_faculty, "You must take two courses from different faculties that have the same course code.", None), + (term_sums_even, "You must ensure that the sum of your course codes in even terms is even. Note that summer courses do not count towards this.", None), + (term_sums_odd, "You must ensure that the sum of your course codes in odd terms is odd. Note that summer courses do not count towards this.", None), + (six_threes_limit, "In all your course codes, there can be at most 6 occurrences of the number 3", "levelup{CU1Tur3d}"), +] + +@router.post("/validateCtf/") +def validate_ctf(data : PlannerData): + """ + Validates the CTF + """ + passed: list[str] = [] + flags: list[str] = [] + for req_num, (fn, msg, flag) in enumerate(requirements): + if not fn(data): + return { + "valid": False, + "passed": passed, + "failed": req_num, + "flags": flags, + "message": msg + } + passed.append(msg) + if flag is not None: + flags.append(flag) + print("Ok: ", req_num) + return { + "valid": True, + "failed": -1, + "passed": passed, + "flags": flags, + "message": "Congratulations! You have passed all the requirements for the CTF." + } diff --git a/backend/server/server.py b/backend/server/server.py index 96d80ccf5..626688053 100644 --- a/backend/server/server.py +++ b/backend/server/server.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from data.config import LIVE_YEAR -from server.routers import courses, planner, programs, specialisations, followups +from server.routers import courses, planner, programs, specialisations, followups, ctf app = FastAPI() @@ -45,6 +45,7 @@ app.include_router(programs.router) app.include_router(specialisations.router) app.include_router(followups.router) +app.include_router(ctf.router) @app.get("/") diff --git a/frontend/src/pages/TermPlanner/OptionsHeader/OptionsHeader.tsx b/frontend/src/pages/TermPlanner/OptionsHeader/OptionsHeader.tsx index a2b682227..5365a2589 100644 --- a/frontend/src/pages/TermPlanner/OptionsHeader/OptionsHeader.tsx +++ b/frontend/src/pages/TermPlanner/OptionsHeader/OptionsHeader.tsx @@ -20,6 +20,7 @@ import HelpMenu from '../HelpMenu/HelpMenu'; import ImportPlannerMenu from '../ImportPlannerMenu'; import SettingsMenu from '../SettingsMenu'; import { isPlannerEmpty } from '../utils'; +import ValidateCtfButton from '../ValidateCtfButton/ValidateCtfButton'; import S from './styles'; // Used for tippy stylings import 'tippy.js/dist/tippy.css'; @@ -94,6 +95,11 @@ const OptionsHeader = ({ plannerRef }: Props) => { + +
+ +
+
{!isPlannerEmpty(years) && ( diff --git a/frontend/src/pages/TermPlanner/ValidateCtfButton/ValidateCtfButton.tsx b/frontend/src/pages/TermPlanner/ValidateCtfButton/ValidateCtfButton.tsx new file mode 100644 index 000000000..c2ccc9a77 --- /dev/null +++ b/frontend/src/pages/TermPlanner/ValidateCtfButton/ValidateCtfButton.tsx @@ -0,0 +1,109 @@ +/* eslint-disable */ +import React from 'react'; +import { useSelector } from 'react-redux'; +import axios from 'axios'; +import prepareCoursesForValidationPayload from 'utils/prepareCoursesForValidationPayload'; +import { RootState } from 'config/store'; +import CS from '../common/styles'; +import S from './styles'; +import { Typography } from 'antd'; +import styled from 'styled-components'; + +type CtfResult = { + valid: boolean; + failed: number; + passed: Array; + message: string; + flags: Array; +}; + +const { Text, Title } = Typography; + +const TextBlock = styled(Text)` + display: block; + padding: 1em; + color: ${({ theme }) => theme.graph.tabTextColor}; +`; + +const loadingResult: CtfResult = { + valid: false, + failed: 0, + passed: [], + message: 'Loading...', + flags: [], +}; + +const ModalTitle = styled(Title)` + margin: 0 !important; + color: ${({ theme }) => theme.text} !important; +`; + + +const ValidateCtfButton = () => { + const planner = useSelector((state: RootState) => state.planner); + const degree = useSelector((state: RootState) => state.degree); + const [open, setOpen] = React.useState(false); + const [result, setResult] = React.useState(loadingResult); + + const validateCtf = async () => { + // TODO: Call this async and disaplay output + setOpen(true); + const res = await axios.post( + '/ctf/validateCtf/', + prepareCoursesForValidationPayload(planner, degree, false) + ); + setResult(res.data); + console.log(res.data); + }; + + return ( + <> + Validate CTF + setOpen(false)} + onCancel={() => setOpen(false)} + width="60%" + > + Passed Challenges + + { +
    + {result.passed.map((challenge, index) => ( + <> +
  1. + {challenge} +
  2. + + ))} +
+ } +
+ Unlocked Flags + +
    + {(result.flags.length > 0) && result.flags.map((flag, index) => ( + <> +
  1. + {flag} +
  2. + + ))} +
+
+ + { + result.valid ? ( + {result.message} + ) : ( + Next: {result.message} + ) + } + +
+ + ); +}; + +export default ValidateCtfButton; diff --git a/frontend/src/pages/TermPlanner/ValidateCtfButton/index.ts b/frontend/src/pages/TermPlanner/ValidateCtfButton/index.ts new file mode 100644 index 000000000..413e16ef4 --- /dev/null +++ b/frontend/src/pages/TermPlanner/ValidateCtfButton/index.ts @@ -0,0 +1,3 @@ +import ValidateCtfButton from './ValidateCtfButton'; + +export default ValidateCtfButton; diff --git a/frontend/src/pages/TermPlanner/ValidateCtfButton/styles.ts b/frontend/src/pages/TermPlanner/ValidateCtfButton/styles.ts new file mode 100644 index 000000000..2f300e114 --- /dev/null +++ b/frontend/src/pages/TermPlanner/ValidateCtfButton/styles.ts @@ -0,0 +1,26 @@ +import { Modal as antdModal } from 'antd'; +import styled from 'styled-components'; + +// NOTE: Very hacky way to override modal styling +// THIS is also just duplicate code of the +// EditMarksModal styling. +// A better programmer might refactor this +const Modal = styled(antdModal)` + .ant-modal-footer { + border: 0px; + } + .ant-modal-header { + border-color: ${({ theme }) => theme.editMark.borderColorHeader}; + } + .ant-btn-default { + background-color: ${({ theme }) => theme.editMark.backgroundColor}; + border-color: ${({ theme }) => theme.editMark.borderColor}; + color: ${({ theme }) => theme.editMark.color}; + &:hover { + background-color: ${({ theme }) => theme.editMark.backgroundColorHover}; + border-color: ${({ theme }) => theme.purplePrimary}; + } + } +`; + +export default { Modal };