Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ module.exports = {
preset: 'ts-jest/presets/js-with-ts',
testEnvironment: 'node',
roots: ['./test/'],
setupFiles: ['<rootDir>/test/jest.setup.js'],
moduleNameMapper: {
'^@i18n$': '<rootDir>/src/util/i18n.ts',
},

// By default, jest will ignore all files in node_modules.
// But since babylonjs is published as modules, we need to tell jest to transform it (along with transforming our own code).
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"test": "jest",
"start": "yarn run start-dev",
"start-dev": "webpack-dev-server --host 0.0.0.0 --config=configs/webpack/dev.js",
"start-dev": "webpack-dev-server --config=configs/webpack/dev.js",
"start-dev-tunnel": "DEV_SERVER_TUNNEL=1 webpack-dev-server --config=configs/webpack/dev.js",
"start-dev-https": "DEV_SERVER_HTTPS=1 webpack-dev-server --config=configs/webpack/dev.js",
"install-ngrok": "bash scripts/install-ngrok.sh",
"share-ngrok": "bash scripts/share-ngrok.sh",
"start-prod": "yarn run build && node express.js",
"generate-i18n": "ts-node i18n/generate.ts",
"build-i18n": "ts-node i18n/build.ts"
Expand All @@ -26,6 +30,7 @@
"@babel/plugin-syntax-import-meta": "^7.10.4",
"@babel/plugin-transform-class-static-block": "^7.28.3",
"@types/chai": "^4.3.3",
"@types/earcut": "^3.0.0",
"@types/gapi": "^0.0.44",
"@types/gettext-parser": "^4.0.2",
"@types/jest": "^29.4.0",
Expand Down Expand Up @@ -55,7 +60,7 @@
"typescript": "^4.9.0",
"webpack": "^5.36.2",
"webpack-bundle-analyzer": "^5.2.0",
"webpack-cli": "^4.7.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.2.1",
"webpack-merge": "^5.7.3"
},
Expand All @@ -75,6 +80,7 @@
"discord.js": "^13.6.0",
"dotenv": "^10.0.0",
"dotenv-expand": "^5.1.0",
"earcut": "^3.0.2",
"express-http-proxy": "^1.6.3",
"express-prom-bundle": "^8.0.0",
"express-rate-limit": "^7.4.0",
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import ClassroomsDashboard from './pages/ClassroomsDashboard';
import ClassroomLeaderboard from './pages/ClassroomLeaderboard';
import ClassroomTeacherView from './pages/ClassroomTeacherView';
import ClassroomStudentView from './pages/ClassroomStudentView';
import CustomChallengeCreator from './pages/CustomChallengeCreator';
import { InterfaceMode } from './types/interfaceModes';
import { Settings } from 'components/constants/Settings';
import User, { AsyncUser } from 'state/State/User';
Expand Down Expand Up @@ -226,6 +227,7 @@ class App extends React.Component<Props, State> {
<Route path="/scene/:sceneId" element={<Root />} />
<Route path="/challenge/:challengeId" element={<ChallengeRoot />} />
<Route path="/curriculum" element={<CurriculumPage />} />
<Route path="/custom-challenges" element={<CustomChallengeCreator theme={DARK} />} />
<Route path="/classrooms" element={<ClassroomsDashboard theme={DARK} />} />
<Route path="/classrooms/:classroomId" element={<ClassroomLeaderboard theme={DARK} />} />
<Route path="/classrooms/:teacherId/teacherView" element={<ClassroomTeacherView theme={DARK} />} />
Expand Down
3 changes: 3 additions & 0 deletions src/components/Challenge/ChallengeMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface MenuProps extends StyleProps, ThemeProps {
onDashboardClick: () => void;
onLogoutClick: () => void;
onAiClick: () => void;
onEditCustomChallengeClick?: () => void;

onEndChallengeClick: () => void;
simulatorState: SimulatorState;
Expand Down Expand Up @@ -230,6 +231,7 @@ class ChallengeMenu extends React.PureComponent<Props, State> {
onLogoutClick,
onEndChallengeClick,
onAiClick,
onEditCustomChallengeClick,
simulatorState
} = props;

Expand Down Expand Up @@ -297,6 +299,7 @@ class ChallengeMenu extends React.PureComponent<Props, State> {
onAboutClick={onAboutClick}
onSettingsClick={onSettingsClick}
onAiClick={onAiClick}
onEditCustomChallengeClick={onEditCustomChallengeClick}
/>
) : undefined}
</Item>
Expand Down
9 changes: 8 additions & 1 deletion src/components/Challenge/ExtraMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { styled } from 'styletron-react';
import { StyleProps } from '../../util/style';
import { FontAwesome } from '../FontAwesome';
import { ThemeProps } from '../constants/theme';
import { faBook, faCogs, faCommentDots, faQuestion, faSignOutAlt, faRobot, faCircleInfo } from '@fortawesome/free-solid-svg-icons';
import { faBook, faCogs, faCommentDots, faQuestion, faSignOutAlt, faRobot, faCircleInfo, faSliders } from '@fortawesome/free-solid-svg-icons';

import tr from '@i18n';

Expand All @@ -23,6 +23,7 @@ export interface ExtraMenuPublicProps extends StyleProps, ThemeProps {
onSettingsClick: (event: React.MouseEvent) => void;
onAboutClick: (event: React.MouseEvent) => void;
onAiClick: (event: React.MouseEvent) => void;
onEditCustomChallengeClick?: (event: React.MouseEvent) => void;
tourRegistry?: TourRegistry;
onRetakeTourClick?: () => void;
}
Expand Down Expand Up @@ -109,6 +110,7 @@ class ExtraMenu extends React.PureComponent<Props, State> {
onSettingsClick,
locale,
onAiClick,
onEditCustomChallengeClick,
onRetakeTourClick
} = props;

Expand All @@ -117,6 +119,9 @@ class ExtraMenu extends React.PureComponent<Props, State> {
const settingsItem_ = (<Item theme={theme} onClick={onSettingsClick}><ItemIcon icon={faCogs} /> {LocalizedString.lookup(tr('Settings'), locale)}</Item>);
const aboutItem_ = (<Item theme={theme} onClick={onAboutClick}><ItemIcon icon={faQuestion} /> {LocalizedString.lookup(tr('About'), locale)}</Item>);
const feedbackItem_ = (<Item theme={theme} onClick={onFeedbackClick}><ItemIcon icon={faCommentDots} /> {LocalizedString.lookup(tr('Feedback'), locale)}</Item>);
const editCustomChallengeItem_ = onEditCustomChallengeClick
? (<Item theme={theme} onClick={onEditCustomChallengeClick}><ItemIcon icon={faSliders} /> {LocalizedString.lookup(tr('Edit challenge conditions & items'), locale)}</Item>)
: null;
const logoutItem_ = (<Item theme={theme} onClick={onLogoutClick}><ItemIcon icon={faSignOutAlt} /> {LocalizedString.lookup(tr('Logout'), locale)}</Item>);
const retakeTourItem_ = (<Item theme={theme} onClick={onRetakeTourClick}><ItemIcon icon={faCircleInfo} /> {LocalizedString.lookup(tr(`Retake Tour`), locale)}</Item>);
const tourContent_ = (
Expand All @@ -137,6 +142,7 @@ class ExtraMenu extends React.PureComponent<Props, State> {
<TourTarget registry={this.props.tourRegistry} targetKey={'extra-menu-feedback-button'} style={{ display: 'flex', height: '100%' }}>
{feedbackItem_}
</TourTarget>}
{editCustomChallengeItem_}
{retakeTourItem_}
<TourTarget registry={this.props.tourRegistry} targetKey={'extra-menu-logout-button'} style={{ display: 'flex', height: '100%' }}>
{logoutItem_}
Expand All @@ -153,6 +159,7 @@ class ExtraMenu extends React.PureComponent<Props, State> {
{onFeedbackClick &&
feedbackItem_
}
{editCustomChallengeItem_}
{retakeTourItem_}
{logoutItem_}

Expand Down
108 changes: 105 additions & 3 deletions src/components/Challenge/GoalList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ import { styled } from 'styletron-react';
import { faCheck } from '@fortawesome/free-solid-svg-icons';

import LocalizedString from '../../util/LocalizedString';
import Dict from '../../util/objectOps/Dict';
import { StyleProps } from '../../util/style';
import PredicateCompletion from '../../state/State/ChallengeCompletion/PredicateCompletion';
import { Goal } from '../../state/State/Challenge';
import { FontAwesome } from '../FontAwesome';
import {
oppositePlayAreaEventId,
parsePlayAreaEventId,
} from '../../util/playAreaSuccessGoals';
import { isCustomCanPoseChallengeEventId } from '../../util/customChallengeGoals';

const Container = styled('div', {});

Expand All @@ -18,9 +24,97 @@ const Item = styled('div', {

const Label = styled('div', {});

/** Base challenge event id from a goal row (strips optional Once latch suffix). */
function eventIdFromGoal_(goal: Goal): string {
return goal.exprId.replace(/Once$/, '');
}

function exprStateTrue_(
exprId: string,
predicateCompletion: PredicateCompletion | undefined,
type: 'success' | 'failure'
): boolean {
if (!predicateCompletion) return false;
if (predicateCompletion.exprStates[exprId] === true) return true;
if (type === 'failure' && !exprId.endsWith('Once')) {
return predicateCompletion.exprStates[`${exprId}Once`] === true;
}
return false;
}

function goalEventStateTrue_(goal: Goal, eventStates: Dict<boolean> | undefined): boolean {
if (!eventStates) return false;
const baseId = eventIdFromGoal_(goal);
if (eventStates[baseId] === true) return true;
if (eventStates[goal.exprId] === true) return true;
return false;
}

function goalHighlighted_(
goal: Goal,
predicateCompletion: PredicateCompletion | undefined,
eventStates: Dict<boolean> | undefined,
type: 'success' | 'failure'
): boolean {
const baseEventId = eventIdFromGoal_(goal);
if (isCustomCanPoseChallengeEventId(baseEventId) && eventStates) {
if (goalEventStateTrue_(goal, eventStates)) return true;
if (exprStateTrue_(goal.exprId, predicateCompletion, type)) return true;
return false;
}

const playAreaOnce =
!!parsePlayAreaEventId(baseEventId) && goal.exprId.endsWith('Once');
if (playAreaOnce) {
if (exprStateTrue_(goal.exprId, predicateCompletion, type)) return true;
if (type === 'success' && goalEventStateTrue_(goal, eventStates)) return true;
return false;
}
if (exprStateTrue_(goal.exprId, predicateCompletion, type)) return true;
if (type === 'success' && goalEventStateTrue_(goal, eventStates)) return true;
if (type === 'failure' && goalEventStateTrue_(goal, eventStates)) return true;
return false;
}

function oppositePlayAreaExprId_(goal: Goal): string | null {
const eventId = goal.exprId.replace(/Once$/, '');
const oppositeEventId = oppositePlayAreaEventId(eventId);
if (!oppositeEventId) return null;
return goal.exprId.endsWith('Once') ? `${oppositeEventId}Once` : oppositeEventId;
}

/** Play-area enter/leave (and item in/out) pairs share one zone — show at most one highlight. */
function playAreaGoalHighlighted_(
goal: Goal,
predicateCompletion: PredicateCompletion | undefined,
eventStates: Dict<boolean> | undefined,
otherPredicateCompletion: PredicateCompletion | undefined,
type: 'success' | 'failure'
): boolean {
if (!goalHighlighted_(goal, predicateCompletion, eventStates, type)) return false;
if (!parsePlayAreaEventId(goal.exprId)) return true;

const oppositeExprId = oppositePlayAreaExprId_(goal);
if (!oppositeExprId) return true;

const otherType = type === 'success' ? 'failure' : 'success';
const baseEventId = eventIdFromGoal_(goal);
if (type === 'success' && eventStates?.[baseEventId] === true) {
return true;
}
if (type === 'success' && exprStateTrue_(oppositeExprId, otherPredicateCompletion, otherType)) {
return false;
}
return true;
}

export interface GoalListProps extends StyleProps {
goals: Goal[];
predicateCompletion?: PredicateCompletion;
/** Live challenge event flags (used when predicate exprStates lag scene scripts). */
eventStates?: Dict<boolean>;
/** Opposite predicate completion (success ↔ failure) for play-area mutual exclusion. */
otherPredicateCompletion?: PredicateCompletion;
locale: LocalizedString.Language;
/**
* success goals are highlighted green when true;
Expand All @@ -32,19 +126,27 @@ export interface GoalListProps extends StyleProps {
const GoalList: React.FC<GoalListProps> = ({
goals,
predicateCompletion,
eventStates,
otherPredicateCompletion,
locale,
type,
style,
className
}) => (
<Container style={style} className={className}>
{goals.map(goal => {
const state = predicateCompletion ? predicateCompletion.exprStates[goal.exprId] : undefined;
const highlighted = playAreaGoalHighlighted_(
goal,
predicateCompletion,
eventStates,
otherPredicateCompletion,
type
);
const color = type === 'success' ? 'green' : 'red';
return (
<Item key={goal.exprId}>
{state === true && <FontAwesome icon={faCheck} style={{ color, marginRight: '0.5em' }} />}
<Label style={state === true ? { color } : undefined}>
{highlighted && <FontAwesome icon={faCheck} style={{ color, marginRight: '0.5em' }} />}
<Label style={highlighted ? { color } : undefined}>
{LocalizedString.lookup(goal.name, locale)}
</Label>
</Item>
Expand Down
3 changes: 3 additions & 0 deletions src/components/Challenge/SimMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export interface MenuPublicProps extends StyleProps, ThemeProps {
onAboutClick: () => void;
onDocumentationClick: () => void;
onAiClick: (event: React.MouseEvent) => void;
onEditCustomChallengeClick?: (event: React.MouseEvent) => void;
onDashboardClick: () => void;
onLogoutClick: () => void;

Expand Down Expand Up @@ -345,6 +346,7 @@ class SimMenu extends React.PureComponent<Props, State> {
onStartChallengeClick,
onDeleteSceneClick,
onAiClick,
onEditCustomChallengeClick,
simulatorState,
onRetakeTourClick,
locale,
Expand Down Expand Up @@ -475,6 +477,7 @@ class SimMenu extends React.PureComponent<Props, State> {
onFeedbackClick={onFeedbackClick}
onSettingsClick={onSettingsClick}
onAiClick={onAiClick}
onEditCustomChallengeClick={onEditCustomChallengeClick}
tourRegistry={this.props.tourRegistry}
onRetakeTourClick={onRetakeTourClick}
/>
Expand Down
37 changes: 33 additions & 4 deletions src/components/Challenge/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import { connect } from 'react-redux';
import { State as ReduxState } from '../../state';

import Async from '../../state/State/Async';
import Dict from '../../util/objectOps/Dict';
import LocalizedString from '../../util/LocalizedString';
import { AsyncChallenge } from '../../state/State/Challenge';
import { AsyncChallengeCompletion } from '../../state/State/ChallengeCompletion';
import PredicateCompletion from '../../state/State/ChallengeCompletion/PredicateCompletion';
import PredicateEditor from './PredicateEditor';
import GoalList from './GoalList';

Expand All @@ -24,6 +26,10 @@ import tr from '@i18n';
export interface ChallengePublicProps extends StyleProps, ThemeProps {
challenge: AsyncChallenge;
challengeCompletion: AsyncChallengeCompletion;
/** Scene event flags updated synchronously from the sim loop (see ChallengeRoot). */
liveEventStates?: Dict<boolean>;
liveSuccessCompletion?: PredicateCompletion;
liveFailureCompletion?: PredicateCompletion;
}

interface ChallengePrivateProps {
Expand Down Expand Up @@ -84,7 +90,7 @@ const SectionIcon = styled(FontAwesome, (props: ThemeProps) => ({
transition: 'opacity 0.2s'
}));

class Challenge extends React.PureComponent<Props, State> {
class Challenge extends React.Component<Props, State> {
constructor(props: Props) {
super(props);

Expand All @@ -107,7 +113,17 @@ class Challenge extends React.PureComponent<Props, State> {

render() {
const { props, state } = this;
const { style, className, theme, challenge, challengeCompletion, locale } = props;
const {
style,
className,
theme,
challenge,
challengeCompletion,
liveEventStates,
liveSuccessCompletion,
liveFailureCompletion,
locale,
} = props;
const { collapsed, modal } = state;


Expand All @@ -116,6 +132,15 @@ class Challenge extends React.PureComponent<Props, State> {

const latestChallengeCompletion = Async.latestValue(challengeCompletion);

const goalEventStates: Dict<boolean> = {
...(latestChallengeCompletion?.eventStates ?? {}),
...(liveEventStates ?? {}),
};

const successCompletion =
liveSuccessCompletion ?? latestChallengeCompletion?.success;
const failureCompletion =
liveFailureCompletion ?? latestChallengeCompletion?.failure;

return (
<>
Expand All @@ -125,7 +150,9 @@ class Challenge extends React.PureComponent<Props, State> {
<Section name={LocalizedString.lookup(tr('Success'), locale)} theme={theme}>
<GoalList
goals={latestChallenge.successGoals}
predicateCompletion={latestChallengeCompletion ? latestChallengeCompletion.success : undefined}
predicateCompletion={successCompletion}
eventStates={goalEventStates}
otherPredicateCompletion={failureCompletion}
locale={locale}
type="success"
/>
Expand All @@ -142,7 +169,9 @@ class Challenge extends React.PureComponent<Props, State> {
<Section name={LocalizedString.lookup(tr('Failure'), locale)} theme={theme}>
<GoalList
goals={latestChallenge.failureGoals}
predicateCompletion={latestChallengeCompletion ? latestChallengeCompletion.failure : undefined}
predicateCompletion={failureCompletion}
eventStates={goalEventStates}
otherPredicateCompletion={successCompletion}
locale={locale}
type="failure"
/>
Expand Down
Loading
Loading