Skip to content

Commit b7ac5db

Browse files
author
rmen527
committed
Finish singleplayer level
1 parent d4e3eae commit b7ac5db

3 files changed

Lines changed: 254 additions & 100 deletions

File tree

src/components/Codebox.tsx

Lines changed: 197 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,210 @@
1-
import { useState, useEffect, useRef } from "react";
2-
import { loadPyodide, type PyodideInterface } from "pyodide";
1+
import { useState } from "react";
2+
3+
// ---- types shared between executor and props ----
4+
export type ExpectedStep =
5+
| { kind: "assign"; var: string; val: number }
6+
| { kind: "print"; val: number };
37

48
interface CodeboxProps {
59
lightcol: string;
610
darkcol: string;
711
headerimage: string;
8-
onOutput: (output: string) => void;
12+
onOutput: (output: string, isError: boolean) => void;
13+
expectedTrace?: ExpectedStep[];
914
}
1015

11-
export default function Codebox(props: CodeboxProps) {
12-
13-
const [code, setCode] = useState("");
14-
15-
////////
16-
const [isRunning, setIsRunning] = useState(false);
17-
const [pyodideReady, setPyodideReady] = useState(false);
18-
19-
// Store the Pyodide instance so we only load it once
20-
const pyodideRef = useRef<PyodideInterface | null>(null);
21-
22-
// ←←← LOAD PYODIDE ONLY ONCE ←←←
23-
useEffect(() => {
24-
let mounted = true;
16+
// ---- expression validator ----
17+
// Allowed: single-char vars x/y/z, literal 1 only, operators +-*^, parens.
18+
// Spaces are ignored. :) and :( must be together (enforced by caller regex).
19+
function validateExpression(expr: string): boolean {
20+
const s = expr.replace(/\s/g, "");
21+
if (s.length === 0) return false;
22+
if (s.includes("**")) return false;
23+
24+
let i = 0;
25+
while (i < s.length) {
26+
const ch = s[i];
27+
if ("xyz".includes(ch)) {
28+
// no multi-char identifiers
29+
if (i + 1 < s.length && /[a-zA-Z]/.test(s[i + 1])) return false;
30+
i++;
31+
} else if (ch === "1") {
32+
// only 1 is a valid literal — 11, 12, etc. are invalid
33+
if (i + 1 < s.length && /[0-9]/.test(s[i + 1])) return false;
34+
i++;
35+
} else if (/[0-9]/.test(ch)) {
36+
return false; // 2, 3, … all forbidden
37+
} else if ("+-*^()".includes(ch)) {
38+
i++;
39+
} else {
40+
return false;
41+
}
42+
}
43+
return true;
44+
}
2545

26-
const initialize = async () => {
27-
try {
28-
const pyodide = await loadPyodide({
29-
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/",
30-
});
46+
function approxEqual(a: number, b: number): boolean {
47+
return Math.abs(a - b) < 1e-9;
48+
}
3149

32-
if (mounted) {
33-
pyodideRef.current = pyodide;
34-
setPyodideReady(true);
35-
}
36-
} catch (err) {
37-
console.error("Failed to load Pyodide", err);
38-
props.onOutput?.("Failed to load Python runtime 😢");
50+
// ---- interpreter ----
51+
type AlienStep =
52+
| { kind: "assign"; var: string; val: number; lineNum: number }
53+
| { kind: "print"; val: number; lineNum: number };
54+
55+
function runAlienCode(code: string, expectedTrace?: ExpectedStep[]): { output: string; error: boolean } {
56+
// Trim every line, drop blanks, keep original line numbers
57+
const lines: Array<{ text: string; lineNum: number }> = code
58+
.split("\n")
59+
.map((text, i) => ({ text: text.trim(), lineNum: i + 1 }))
60+
.filter((l) => l.text.length > 0);
61+
62+
if (lines.length === 0) {
63+
return { output: "Alien Error [Line 1]: Syntax Error", error: true };
64+
}
65+
66+
// :) assignment — space allowed around :) but NOT inside it
67+
const ASSIGNMENT_RE = /^([xyz])\s*:\)\s*(.+)$/;
68+
// :( print — any of x/y/z, space allowed before :(, NOT inside it
69+
const PRINT_RE = /^([xyz])\s*:\($/;
70+
71+
const vars: Record<string, number> = {};
72+
const alienTrace: AlienStep[] = [];
73+
const outputLines: string[] = [];
74+
75+
for (const { text: line, lineNum } of lines) {
76+
// --- print ---
77+
const printMatch = line.match(PRINT_RE);
78+
if (printMatch) {
79+
const v = printMatch[1];
80+
if (vars[v] === undefined) {
81+
return { output: `Alien Error [Line ${lineNum}]: Syntax Error`, error: true };
82+
}
83+
const val = vars[v];
84+
outputLines.push(String(val));
85+
alienTrace.push({ kind: "print", val, lineNum });
86+
continue;
87+
}
88+
89+
// --- assignment ---
90+
const assignMatch = line.match(ASSIGNMENT_RE);
91+
if (assignMatch) {
92+
const varName = assignMatch[1];
93+
const expr = assignMatch[2].trim();
94+
95+
if (!validateExpression(expr)) {
96+
return { output: `Alien Error [Line ${lineNum}]: Syntax Error`, error: true };
97+
}
98+
99+
// every referenced variable must be defined
100+
const exprClean = expr.replace(/\s/g, "");
101+
for (const v of ["x", "y", "z"]) {
102+
if (exprClean.includes(v) && vars[v] === undefined) {
103+
return { output: `Alien Error [Line ${lineNum}]: Syntax Error`, error: true };
39104
}
40-
};
41-
42-
initialize();
43-
44-
return () => {
45-
mounted = false;
46-
};
47-
}, [props.onOutput]);
48-
49-
const runCode = async () => {
50-
const pyodide = pyodideRef.current;
51-
if (!pyodide) {
52-
props.onOutput?.("Python runtime is still loading...");
53-
return;
105+
}
106+
107+
try {
108+
const jsExpr = expr.replace(/\^/g, "**");
109+
const result = new Function("x", "y", "z", `"use strict"; return (${jsExpr})`)(
110+
vars["x"], vars["y"], vars["z"]
111+
);
112+
if (typeof result !== "number" || !isFinite(result)) {
113+
return { output: `Alien Error [Line ${lineNum}]: Syntax Error`, error: true };
54114
}
115+
vars[varName] = result;
116+
alienTrace.push({ kind: "assign", var: varName, val: result, lineNum });
117+
} catch {
118+
return { output: `Alien Error [Line ${lineNum}]: Syntax Error`, error: true };
119+
}
120+
continue;
121+
}
122+
123+
// --- unrecognised line ---
124+
return { output: `Alien Error [Line ${lineNum}]: Syntax Error`, error: true };
125+
}
126+
127+
if (outputLines.length === 0) {
128+
return { output: "No output", error: true };
129+
}
130+
131+
// ---- line-by-line translation check ----
132+
if (expectedTrace && expectedTrace.length > 0) {
133+
if (alienTrace.length !== expectedTrace.length) {
134+
return { output: "Alien Error: Translation incorrect", error: true };
135+
}
136+
137+
for (let i = 0; i < alienTrace.length; i++) {
138+
const a = alienTrace[i];
139+
const e = expectedTrace[i];
140+
141+
if (a.kind !== e.kind) {
142+
return { output: `Alien Error [Line ${a.lineNum}]: Translation incorrect`, error: true };
143+
}
144+
145+
if (a.kind === "assign" && e.kind === "assign") {
146+
if (a.var !== e.var || !approxEqual(a.val, e.val)) {
147+
return { output: `Alien Error [Line ${a.lineNum}]: Translation incorrect`, error: true };
148+
}
149+
}
55150

56-
setIsRunning(true);
57-
let output = "";
58-
59-
try {
60-
// Capture print() output
61-
pyodide.setStdout({
62-
batched: (msg: string) => {
63-
output += msg + "\n";
64-
},
65-
});
66-
67-
const result = await pyodide.runPythonAsync(code);
68-
69-
const fullOutput =
70-
output.trim() +
71-
(result !== undefined ? "\n" + String(result) : "");
72-
73-
props.onOutput?.(fullOutput || "Code executed successfully (no output)");
74-
} catch (err: any) {
75-
props.onOutput?.(`Error: ${err.message}`);
76-
console.error(err);
77-
} finally {
78-
setIsRunning(false);
151+
if (a.kind === "print" && e.kind === "print") {
152+
if (!approxEqual(a.val, e.val)) {
153+
return { output: `Alien Error [Line ${a.lineNum}]: Translation incorrect`, error: true };
79154
}
80-
};
81-
////////
82-
83-
return (
84-
<div style={{ backgroundColor: props.lightcol, boxShadow: `0 12px 0 ${props.darkcol}` }} className={`rounded-2xl overflow-hidden flex flex-col mt-25`}>
85-
<div className="flex items-center justify-center p-4">
86-
<img src={"/" + props.headerimage} alt={props.headerimage} className="relative h-12 w-auto object-contain" />
87-
</div>
88-
89-
<textarea style={{ fontFamily: "'Fira Code', monospace", color: "white", backgroundColor: props.darkcol}} value={code} onChange={(e) => setCode(e.target.value)} placeholder="Write code here!"
90-
className="p-6 text-2xl mx-4 rounded-lg resize-none min-h-[300px]"></textarea>
91-
92-
<div className="m-4 mx-4 h-14">
93-
<button
94-
onClick={runCode}
95-
disabled={!pyodideReady || isRunning}
96-
style={{ backgroundColor: "#00A93D", boxShadow: `0 6px 0 #00531F`, opacity: !pyodideReady || isRunning ? 0.7 : 1}}
97-
className="rounded-2xl hover:brightness-110 active:brightness-90"
98-
>
99-
<img src="/run.png" alt="" className="h-14 px-6 py-3"/>
100-
</button>
101-
</div>
102-
</div>
103-
)
104-
}
155+
}
156+
}
157+
}
158+
159+
return { output: outputLines.join("\n"), error: false };
160+
}
161+
162+
// ---- component ----
163+
export default function Codebox(props: CodeboxProps) {
164+
const [code, setCode] = useState("");
165+
const [running, setRunning] = useState(false);
166+
167+
const handleRun = () => {
168+
setRunning(true);
169+
setTimeout(() => {
170+
const { output, error } = runAlienCode(code, props.expectedTrace);
171+
props.onOutput(output, error);
172+
setRunning(false);
173+
}, 300);
174+
};
175+
176+
return (
177+
<div
178+
style={{ backgroundColor: props.lightcol, boxShadow: `0 12px 0 ${props.darkcol}` }}
179+
className="rounded-2xl overflow-hidden flex flex-col mt-25"
180+
>
181+
<div className="flex items-center justify-center p-4">
182+
<img src={"/" + props.headerimage} alt={props.headerimage} className="relative h-12 w-auto object-contain" />
183+
</div>
184+
185+
<textarea
186+
style={{ fontFamily: "'Fira Code', monospace", color: "white", backgroundColor: props.darkcol }}
187+
value={code}
188+
onChange={(e) => setCode(e.target.value)}
189+
placeholder="Write alien code here!"
190+
className="p-6 text-2xl mx-4 rounded-lg resize-none min-h-[300px]"
191+
/>
192+
193+
<div className="m-4 mx-4 h-14">
194+
<button
195+
onClick={handleRun}
196+
disabled={running}
197+
style={{
198+
backgroundColor: "#00A93D",
199+
boxShadow: "0 6px 0 #00531F",
200+
opacity: running ? 0.7 : 1,
201+
cursor: running ? "not-allowed" : "pointer",
202+
}}
203+
className="rounded-2xl hover:brightness-110 active:brightness-90"
204+
>
205+
<img src="/run.png" alt="" className="h-14 px-6 py-3" />
206+
</button>
207+
</div>
208+
</div>
209+
);
210+
}

src/components/Textbox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export default function Textbox(props: TextboxProps) {
1414
<img src={"/" + props.headerimage} alt={props.headerimage} className="relative h-12 w-auto object-contain" />
1515
</div>
1616
<div style={{ backgroundColor: props.darkcol }} className="p-4 pb-12 mb-4 mx-4 rounded-lg">
17-
<p style={{ fontFamily: "'Fira Code', monospace", color: "white"}} className="text-xl">{props.text}</p>
17+
<p style={{ fontFamily: "'Fira Code', monospace", color: "white", whiteSpace: "pre-wrap"}} className="text-xl">{props.text}</p>
1818
</div>
1919
</div>
2020
)

0 commit comments

Comments
 (0)