From 66b15321dd181c8e743df66db5ea9890444c2bc3 Mon Sep 17 00:00:00 2001 From: James Dougan Date: Fri, 13 Mar 2026 00:53:36 +0000 Subject: [PATCH 1/4] add start of hack_the_robot module --- src/jamkit/example/hack_the_robot_example.py | 14 ++ src/jamkit/hack_the_robot/__init__.py | 15 ++ src/jamkit/hack_the_robot/assets.py | 28 +++ src/jamkit/hack_the_robot/engine.py | 179 ++++++++++++++++++ src/jamkit/hack_the_robot/loader.py | 44 +++++ src/jamkit/hack_the_robot/missions.json | 41 ++++ src/jamkit/hack_the_robot/models.py | 30 +++ .../{assets => jam_collector}/__init__.py | 0 .../assets/__init__.py} | 0 src/jamkit/{ => jam_collector}/assets/bug.gif | Bin .../{ => jam_collector}/assets/fork_full.gif | Bin .../{ => jam_collector}/assets/raspberry.gif | Bin src/jamkit/{ => jam_collector}/sprites.py | 0 .../jam_collector/tests}/turtle_test.py | 4 +- src/jamkit/{ => jam_collector}/turtle.py | 0 15 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 src/jamkit/example/hack_the_robot_example.py create mode 100644 src/jamkit/hack_the_robot/__init__.py create mode 100644 src/jamkit/hack_the_robot/assets.py create mode 100644 src/jamkit/hack_the_robot/engine.py create mode 100644 src/jamkit/hack_the_robot/loader.py create mode 100644 src/jamkit/hack_the_robot/missions.json create mode 100644 src/jamkit/hack_the_robot/models.py rename src/jamkit/{assets => jam_collector}/__init__.py (100%) rename src/jamkit/{__int__.py => jam_collector/assets/__init__.py} (100%) rename src/jamkit/{ => jam_collector}/assets/bug.gif (100%) rename src/jamkit/{ => jam_collector}/assets/fork_full.gif (100%) rename src/jamkit/{ => jam_collector}/assets/raspberry.gif (100%) rename src/jamkit/{ => jam_collector}/sprites.py (100%) rename {tests => src/jamkit/jam_collector/tests}/turtle_test.py (92%) rename src/jamkit/{ => jam_collector}/turtle.py (100%) diff --git a/src/jamkit/example/hack_the_robot_example.py b/src/jamkit/example/hack_the_robot_example.py new file mode 100644 index 0000000..9f97fc9 --- /dev/null +++ b/src/jamkit/example/hack_the_robot_example.py @@ -0,0 +1,14 @@ +from jamkit.hack_the_robot import Robot, msg_1 + +robot = Robot() +robot.connect() + +# Mission 1 +robot.read_memory(msg_1) +decoded = "" +for ch in msg_1: + if ch.isalpha(): + decoded += chr(ord(ch) - 1) + else: + decoded += ch +robot.submit(decoded) \ No newline at end of file diff --git a/src/jamkit/hack_the_robot/__init__.py b/src/jamkit/hack_the_robot/__init__.py new file mode 100644 index 0000000..a5caa91 --- /dev/null +++ b/src/jamkit/hack_the_robot/__init__.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + +from .engine import Robot +from .loader import load_workshop + +_config_path = Path(__file__).with_name("missions.json") +_workshop = load_workshop(_config_path) + +# Export all assets as module level variables +for _asset_name, _asset in _workshop.assets.items(): + globals()[_asset_name] = _asset.value + +__all__ = ["Robot", *_workshop.assets.keys()] \ No newline at end of file diff --git a/src/jamkit/hack_the_robot/assets.py b/src/jamkit/hack_the_robot/assets.py new file mode 100644 index 0000000..3fb205b --- /dev/null +++ b/src/jamkit/hack_the_robot/assets.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +def load_asset_value(base_dir: Path, asset_def: dict[str, Any]) -> Any: + asset_type = asset_def["type"] + + if asset_type == "string": + return asset_def["value"] + + if asset_type == "text_file": + rel_path = asset_def["path"] + file_path = (base_dir / rel_path).resolve() + return file_path.read_text(encoding="utf-8") + + if asset_type == "lines_file": + rel_path = asset_def["path"] + file_path = (base_dir / rel_path).resolve() + return file_path.read_text(encoding="utf-8").splitlines() + + if asset_type == "list": + return asset_def["value"] + + if asset_type == "dict": + return asset_def["value"] + + raise ValueError(f"Unsupported asset type: {asset_type}") \ No newline at end of file diff --git a/src/jamkit/hack_the_robot/engine.py b/src/jamkit/hack_the_robot/engine.py new file mode 100644 index 0000000..0206542 --- /dev/null +++ b/src/jamkit/hack_the_robot/engine.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any + +from .loader import load_workshop +from .models import Asset, Mission, WorkshopConfig + +class Robot: + def __init__( + self, + config_path: str | Path | None = None, + *, + typing_delay: float = 0.06, + ) -> None: + if config_path is None: + config_path = Path(__file__).with_name("missions.json") + + self.config: WorkshopConfig = load_workshop(config_path) + self.typing_delay = typing_delay + self.connected = False + self.current_mission_index = 0 + self.hint_index_by_mission: dict[str, int] = {} + self._last_read_asset: str | None = None + + # Output Helpers + def _line(self, text: str = "") -> None: + print(text) + if self.typing_delay > 0: + time.sleep(self.typing_delay) + + def _block(self, lines: list[str]) -> None: + for line in lines: + self._line(line) + + def _banner(self, title: str) -> None: + self._line() + self._line("=" * 50) + self._line(title) + self._line("=" * 50) + + def _line_with_robot_name(self, msg: str = "") -> None: + text = f"[{self.config.robot_name}] {msg}" + self._line(text) + + def _block_with_robot_name(self, lines: list[str]) -> None: + output: list[str] = [] + for line in lines: + output.append(f"[{self.config.robot_name}] {line}") + + self._block(output) + + # Core flow + def connect(self) -> None: + self._banner(f"Connecting to {self.config.robot_name}") + self._block(self.config.connect_lines) + self.connected = True + self.show_mission() + + def show_mission(self) -> None: + mission = self.current_mission + self._banner(f"[{mission.id}] {mission.title}") + self._block(mission.intro) + + if mission.assets: + self._line() + self._line("Available mission assets:") + for asset_name in mission.assets: + self._line(f" - {asset_name}") + + @property + def current_mission(self) -> Mission: + return self.config.missions[self.current_mission_index] + + def get_asset(self, name: str) -> Any: + if name not in self.config.assets: + raise KeyError(f"Unknown asset: {name}") + return self.config.assets[name].value + + def read_memory(self, asset: Any) -> None: + matched_name = None + for name, obj in self.config.assets.items(): + if obj.value is asset: + matched_name = name + break + + self._line() + self._line_with_robot_name("Reading memory...") + if matched_name is not None: + self._last_read_asset = matched_name + self._line_with_robot_name(f"Memory label: {matched_name}") + else: + self._line_with_robot_name("External object received") + + self._line_with_robot_name("Memory read complete.") + + def show(self, value: Any) -> None: + self._line() + self._line_with_robot_name("Output:") + if isinstance(value, list): + for item in value: + self._line(str(item)) + else: + self._line(str(value)) + + self._line() + self._line_with_robot_name("If this looks correct, use robot.submit(...)") + + def hint(self) -> None: + mission = self.current_mission + idx = self.hint_index_by_mission.get(mission.id, 0) + + self._line() + if idx < len(mission.hints): + self._line_with_robot_name(f"Hint: {mission.hints[idx]}") + self.hint_index_by_mission[mission.id] = idx + 1 + else: + self._line_with_robot_name("No more hits available for this mission.") + + def submit(self, answer: Any) -> bool: + mission = self.current_mission + expected = mission.expected_answer + + ok = self._answers_match(expected, answer) + + self._line() + self._line_with_robot_name("Validating submission...") + + time.sleep(1) + + if ok: + if mission.success_lines: + self._block_with_robot_name(mission.success_lines) + else: + self._line_with_robot_name("Mission complete.") + + self.current_mission_index += 1 + if self.current_mission_index < len(self.config.missions): + self.show_mission() + else: + self._final_reboot() + + return True + + if mission.failure_lines: + self._block_with_robot_name(mission.failure_lines) + else: + self._line_with_robot_name("Submission incorrect.") + + self._line_with_robot_name("Use robot.hint() if you are stuck.") + return False + + def _answers_match(self, expected: Any, actual: Any) -> bool: + if isinstance(expected, str) and isinstance(actual, str): + return expected.strip().lower() == actual.strip().lower() + return expected == actual + + def _final_reboot(self) -> None: + old_typing_delay = self.typing_delay + self._banner("SYSTEM RESTORE") + + self.typing_delay = 1 + self._block( + [ + "Restoring backup...", + "Repairing corrupted memory sectors...", + "Re-enabling safety systems...", + "Rebooting core services...", + "", + "3...", + "2...", + "1...", + "", + f"{self.config.robot_name} ONLINE", + "Hello, friend. Thanks for fixing me." + ] + ) + self.typing_delay = old_typing_delay \ No newline at end of file diff --git a/src/jamkit/hack_the_robot/loader.py b/src/jamkit/hack_the_robot/loader.py new file mode 100644 index 0000000..543a18c --- /dev/null +++ b/src/jamkit/hack_the_robot/loader.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from .assets import load_asset_value +from .models import Asset, Mission, WorkshopConfig + +def load_workshop(config_path: str | Path) -> WorkshopConfig: + config_path = Path(config_path).resolve() + base_dir = config_path.parent + + raw = json.loads(config_path.read_text(encoding="utf-8")) + + assets: dict[str, Asset] = {} + for name, asset_def in raw.get("assets", {}).items(): + assets[name] = Asset( + name=name, + kind=asset_def["type"], + value=load_asset_value(base_dir, asset_def) + ) + + missions = [ + Mission( + id=m["id"], + title=m["title"], + intro=m.get("intro", []), + hints=m.get("hints", []), + expected_answer=m.get("expected_answer"), + success_lines=m.get("success_lines", []), + failure_lines=m.get("failure_lines", []), + assets=m.get("assets", []), + metadata=m.get("metadata", {}), + ) + for m in raw.get("missions", []) + ] + + return WorkshopConfig( + workshop_id=raw["workshop_id"], + robot_name=raw.get("robot_name", "JAMBOT-7"), + connect_lines=raw.get("connect_lines", []), + missions=missions, + assets=assets + ) diff --git a/src/jamkit/hack_the_robot/missions.json b/src/jamkit/hack_the_robot/missions.json new file mode 100644 index 0000000..13cfa20 --- /dev/null +++ b/src/jamkit/hack_the_robot/missions.json @@ -0,0 +1,41 @@ +{ + "workshop_id": "hack_the_robot", + "robot_name": "JAMBOT-7", + "connect_lines": [ + "Boot sequence link requested...", + "Secure tunnel established.", + "Warning: recovery mode active.", + "Robot memory is damaged. Operator assistance required." + ], + "assets": { + "msg_1": { + "type": "string", + "value": "Difdl uif mpht! sfqbjs_bddpvou vtfs je: twd_311 ???" + } + }, + "missions": [ + { + "id": "MISSION 1", + "title": "Decode the distress message", + "intro": [ + "A distress message was stored just before the robot lost control.", + "It is stored in memory as msg_1", + "Read it, decode it with a Caesar shift of 1, and submit the decoded message." + ], + "assets": ["msg_1"], + "hints": [ + "Try robot.read_memory(msg_1) first.", + "Use a loop to go through each character.", + "Use chr(ord(letter) - 1) to shift a letter backwards." + ], + "expected_answer": "check the logs! repair_account user id: svc_311 ???", + "success_lines": [ + "Distress message restored.", + "Clue found: command logs may reveal what happened." + ], + "failure_lines": [ + "[JAMBOT-7] Distress message does not match expected recovery output." + ] + } + ] +} \ No newline at end of file diff --git a/src/jamkit/hack_the_robot/models.py b/src/jamkit/hack_the_robot/models.py new file mode 100644 index 0000000..fc36337 --- /dev/null +++ b/src/jamkit/hack_the_robot/models.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +@dataclass +class Asset: + name: str + kind: str + value: Any + +@dataclass +class Mission: + id: str + title: str + intro: list[str] = field(default_factory=list) + hints: list[str] = field(default_factory=list) + expected_answer: any = None + success_lines: list[str] = field(default_factory=list) + failure_lines: list[str] = field(default_factory=list) + assets: list[str] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + +@dataclass +class WorkshopConfig: + workshop_id: str + robot_name: str + connect_lines: list[str] + missions: list[Mission] + assets: dict[str, Asset] \ No newline at end of file diff --git a/src/jamkit/assets/__init__.py b/src/jamkit/jam_collector/__init__.py similarity index 100% rename from src/jamkit/assets/__init__.py rename to src/jamkit/jam_collector/__init__.py diff --git a/src/jamkit/__int__.py b/src/jamkit/jam_collector/assets/__init__.py similarity index 100% rename from src/jamkit/__int__.py rename to src/jamkit/jam_collector/assets/__init__.py diff --git a/src/jamkit/assets/bug.gif b/src/jamkit/jam_collector/assets/bug.gif similarity index 100% rename from src/jamkit/assets/bug.gif rename to src/jamkit/jam_collector/assets/bug.gif diff --git a/src/jamkit/assets/fork_full.gif b/src/jamkit/jam_collector/assets/fork_full.gif similarity index 100% rename from src/jamkit/assets/fork_full.gif rename to src/jamkit/jam_collector/assets/fork_full.gif diff --git a/src/jamkit/assets/raspberry.gif b/src/jamkit/jam_collector/assets/raspberry.gif similarity index 100% rename from src/jamkit/assets/raspberry.gif rename to src/jamkit/jam_collector/assets/raspberry.gif diff --git a/src/jamkit/sprites.py b/src/jamkit/jam_collector/sprites.py similarity index 100% rename from src/jamkit/sprites.py rename to src/jamkit/jam_collector/sprites.py diff --git a/tests/turtle_test.py b/src/jamkit/jam_collector/tests/turtle_test.py similarity index 92% rename from tests/turtle_test.py rename to src/jamkit/jam_collector/tests/turtle_test.py index 792d28e..2056091 100644 --- a/tests/turtle_test.py +++ b/src/jamkit/jam_collector/tests/turtle_test.py @@ -1,6 +1,6 @@ # pi_eater.py — using jamkit.py -from jamkit.turtle import Grid, Head, ImageItem, draw_tail, hits -from jamkit import sprites +from jamkit.jam_collector.turtle import Grid, Head, ImageItem, draw_tail, hits +from jamkit.jam_collector import sprites # 32 x 40, 30 g = Grid(cell=32, width=1280, height=960, title="Pi Eater", bg="black") diff --git a/src/jamkit/turtle.py b/src/jamkit/jam_collector/turtle.py similarity index 100% rename from src/jamkit/turtle.py rename to src/jamkit/jam_collector/turtle.py From e0aabd40292a8cae9a959b1715ecb6eb2e586e45 Mon Sep 17 00:00:00 2001 From: James Dougan Date: Fri, 13 Mar 2026 18:04:33 +0000 Subject: [PATCH 2/4] add remaining features --- src/jamkit/hack_the_robot/__init__.py | 6 +- src/jamkit/hack_the_robot/assets.py | 7 + src/jamkit/hack_the_robot/data/logs_1.txt | 101 ++++++++++ src/jamkit/hack_the_robot/engine.py | 173 ++++++++++++---- src/jamkit/hack_the_robot/loader.py | 51 +++-- src/jamkit/hack_the_robot/missions.json | 235 ++++++++++++++++++++-- src/jamkit/hack_the_robot/models.py | 38 +++- src/jamkit/hack_the_robot/validators.py | 88 ++++++++ 8 files changed, 632 insertions(+), 67 deletions(-) create mode 100644 src/jamkit/hack_the_robot/data/logs_1.txt create mode 100644 src/jamkit/hack_the_robot/validators.py diff --git a/src/jamkit/hack_the_robot/__init__.py b/src/jamkit/hack_the_robot/__init__.py index a5caa91..6b486cc 100644 --- a/src/jamkit/hack_the_robot/__init__.py +++ b/src/jamkit/hack_the_robot/__init__.py @@ -10,6 +10,8 @@ # Export all assets as module level variables for _asset_name, _asset in _workshop.assets.items(): - globals()[_asset_name] = _asset.value + if _asset.export: + globals()[_asset_name] = _asset -__all__ = ["Robot", *_workshop.assets.keys()] \ No newline at end of file +_exported_assets = [name for name, asset in _workshop.assets.items() if asset.export] +__all__ = ["Robot", *_exported_assets] \ No newline at end of file diff --git a/src/jamkit/hack_the_robot/assets.py b/src/jamkit/hack_the_robot/assets.py index 3fb205b..d290d84 100644 --- a/src/jamkit/hack_the_robot/assets.py +++ b/src/jamkit/hack_the_robot/assets.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + from pathlib import Path from typing import Any @@ -25,4 +27,9 @@ def load_asset_value(base_dir: Path, asset_def: dict[str, Any]) -> Any: if asset_type == "dict": return asset_def["value"] + if asset_type == "json_file": + rel_path = asset_def["path"] + file_path = (base_dir / rel_path).resolve() + return json.loads(file_path.read_text(encoding="utf-8")) + raise ValueError(f"Unsupported asset type: {asset_type}") \ No newline at end of file diff --git a/src/jamkit/hack_the_robot/data/logs_1.txt b/src/jamkit/hack_the_robot/data/logs_1.txt new file mode 100644 index 0000000..f4cadb0 --- /dev/null +++ b/src/jamkit/hack_the_robot/data/logs_1.txt @@ -0,0 +1,101 @@ +09:10:00 INFO auth user=maint_101 action=login status=ok channel=terminal +09:10:02 INFO command user=maint_101 cmd=CHECK_TEMP status=ok +09:10:05 INFO command user=maint_101 cmd=CHECK_PRESSURE status=ok +09:10:08 INFO command user=maint_101 cmd=CHECK_BATTERY status=ok +09:10:12 INFO diagnostics module=arm-actuator result=ok +09:10:16 INFO diagnostics module=wheel-motor result=ok +09:10:20 INFO auth user=svc_204 action=login status=ok +09:10:21 INFO command user=svc_204 cmd=SYNC_CLOCK status=ok +09:10:24 INFO command user=svc_204 cmd=PING_BACKUP status=ok +09:10:28 INFO network route=maintenance-vpn status=stable +09:10:32 INFO auth user=maint_203 action=login status=ok channel=terminal +09:10:34 INFO command user=maint_203 cmd=CHECK_LIDAR status=ok +09:10:38 INFO command user=maint_203 cmd=CHECK_SONAR status=ok +09:10:42 INFO watchdog state=normal mode=service +09:10:45 INFO command user=svc_118 cmd=CACHE_CLEAR status=ok +09:10:49 INFO auth user=svc_118 action=logout status=ok +09:10:54 INFO command user=maint_101 cmd=CHECK_CAMERA status=ok +09:10:58 INFO memory user=system sector=02 action=read status=ok +09:11:02 INFO memory user=system sector=07 action=read status=ok +09:11:06 INFO command user=maint_203 cmd=RECALIBRATE_JOINTS status=ok +09:11:10 INFO auth user=svc_204 action=logout status=ok +09:11:15 INFO command user=maint_101 cmd=REQUEST_STATUS_REPORT status=ok +09:11:20 INFO message source=robot payload=heartbeat queued=true +09:11:24 INFO auth user=maint_101 action=logout status=ok +09:11:27 INFO auth user=svc_311 action=login status=LOCKOUT pin_attempt=1 +09:11:29 WARN auth user=svc_311 action=login status=LOCKOUT pin_attempt=2 +09:11:31 WARN auth user=svc_311 action=login status=LOCKOUT pin_attempt=3 +09:11:34 INFO auth user=svc_311 action=login status=ok mode=service_override +09:11:36 WARN command user=svc_311 cmd=OVERRIDE_SAFEBOOT status=accepted +09:11:38 WARN command user=svc_311 cmd=DISABLE_AUTO_REPAIR status=accepted +09:11:40 WARN memory user=svc_311 sector=12 action=corrupt-write +09:11:41 WARN memory user=svc_311 sector=13 action=corrupt-write +09:11:42 WARN memory user=svc_311 sector=21 action=corrupt-write +09:11:44 WARN command user=svc_311 cmd=SET_REBOOT_BLOCK status=on +09:11:46 WARN command user=svc_311 cmd=SET_BACKUP_SLOT:B7 status=modified +09:11:48 WARN audit actor=svc_311 note=unauthorized-maintenance-sequence +09:11:51 WARN network user=svc_311 route=maintenance-vpn status=silent +09:11:54 INFO watchdog marker=unexpected-override source=svc_311 +09:11:57 INFO auth user=maint_101 action=login status=denied reason=reboot_blocked +09:12:00 ERROR core subsystem=recovery status=blocked flag=reboot_blocked +09:12:03 INFO command user=maint_101 cmd=REQUEST_HELP status=sent +09:12:06 INFO command user=maint_203 cmd=CHECK_TEMP status=ok +09:12:10 INFO diagnostics module=voice-synth result=ok +09:12:14 INFO auth user=maint_203 action=logout status=ok +09:12:18 INFO auth user=svc_204 action=login status=ok +09:12:20 INFO command user=svc_204 cmd=CHECK_QUEUE status=ok +09:12:23 INFO command user=svc_204 cmd=SYNC_CLOCK status=ok +09:12:28 INFO message source=robot payload=distress-burst queued=true +09:12:31 INFO auth user=svc_204 action=logout status=ok +09:12:35 INFO command user=maint_101 cmd=CHECK_BATTERY status=partial +09:12:39 INFO command user=maint_101 cmd=CHECK_COOLING status=ok +09:12:43 INFO memory user=system sector=33 action=read status=ok +09:12:47 INFO memory user=system sector=34 action=read status=ok +09:12:50 INFO auth user=svc_118 action=login status=ok +09:12:53 INFO command user=svc_118 cmd=CLEAN_TEMP_FILES status=ok +09:12:56 INFO command user=svc_118 cmd=CACHE_CLEAR status=ok +09:13:00 INFO auth user=svc_118 action=logout status=ok +09:13:03 INFO watchdog state=degraded mode=safe +09:13:06 INFO auth user=maint_203 action=login status=ok +09:13:09 INFO command user=maint_203 cmd=CHECK_SONAR status=ok +09:13:12 INFO command user=maint_203 cmd=CHECK_LIDAR status=ok +09:13:16 INFO command user=maint_203 cmd=RUN_DIAGNOSTIC_SWEEP status=ok +09:13:19 INFO auth user=maint_203 action=logout status=ok +09:13:23 INFO network route=maintenance-vpn status=stable +09:13:27 INFO auth user=svc_311 action=login status=LOCKOUT pin_attempt=1 +09:13:29 WARN auth user=svc_311 action=login status=LOCKOUT pin_attempt=2 +09:13:31 INFO auth user=svc_311 action=login status=ok mode=service_override +09:13:33 WARN command user=svc_311 cmd=OVERRIDE_SAFEBOOT status=accepted +09:13:35 WARN command user=svc_311 cmd=DISABLE_AUTO_REPAIR status=accepted +09:13:38 WARN audit actor=svc_311 note=unauthorized-maintenance-sequence +09:13:41 WARN command user=svc_311 cmd=SET_REBOOT_BLOCK status=on +09:13:44 WARN command user=svc_311 cmd=SET_BACKUP_SLOT:B7 status=confirmed +09:13:47 WARN memory user=svc_311 sector=12 action=corrupt-write +09:13:49 WARN memory user=svc_311 sector=13 action=corrupt-write +09:13:51 WARN memory user=svc_311 sector=21 action=corrupt-write +09:13:54 ERROR core subsystem=restore status=RESTORE_ATTEMPT_DENIED reason=reboot_blocked +09:13:58 INFO auth user=svc_311 action=logout status=ok +09:14:02 INFO command user=maint_101 cmd=CHECK_STATUS status=partial +09:14:06 INFO command user=maint_101 cmd=REQUEST_HELP status=resent +09:14:11 INFO command user=maint_101 cmd=CHECK_TEMP status=ok +09:14:15 INFO diagnostics module=wheel-motor result=ok +09:14:19 INFO diagnostics module=arm-actuator result=ok +09:14:23 INFO auth user=svc_204 action=login status=ok +09:14:26 INFO command user=svc_204 cmd=SYNC_CLOCK status=ok +09:14:30 INFO command user=svc_204 cmd=CHECK_QUEUE status=ok +09:14:34 INFO message source=robot payload=operator-assist-needed queued=true +09:14:38 INFO auth user=svc_204 action=logout status=ok +09:14:42 INFO watchdog state=degraded mode=safe +09:14:45 INFO memory user=system sector=12 action=read status=checksum-fail +09:14:47 INFO memory user=system sector=13 action=read status=checksum-fail +09:14:49 INFO memory user=system sector=21 action=read status=checksum-fail +09:14:52 INFO auth user=maint_101 action=login status=denied reason=reboot_blocked +09:14:56 ERROR core subsystem=recovery status=blocked flag=reboot_blocked +09:15:00 INFO command user=maint_101 cmd=REQUEST_HELP status=sent +09:15:04 INFO watchdog marker=unauthorized-pattern source=svc_311 +09:15:08 INFO network route=maintenance-vpn status=stable +09:15:12 INFO command user=maint_203 cmd=CHECK_SONAR status=ok +09:15:16 INFO command user=maint_203 cmd=CHECK_LIDAR status=ok +09:15:20 INFO auth user=maint_203 action=logout status=ok +09:15:24 INFO message source=robot payload=recovery-awaiting-operator queued=true +09:15:28 INFO watchdog state=degraded mode=safe \ No newline at end of file diff --git a/src/jamkit/hack_the_robot/engine.py b/src/jamkit/hack_the_robot/engine.py index 0206542..b8644cb 100644 --- a/src/jamkit/hack_the_robot/engine.py +++ b/src/jamkit/hack_the_robot/engine.py @@ -1,28 +1,35 @@ from __future__ import annotations import time +import json + from pathlib import Path from typing import Any from .loader import load_workshop from .models import Asset, Mission, WorkshopConfig +from .validators import validate_submission class Robot: def __init__( self, config_path: str | Path | None = None, *, - typing_delay: float = 0.06, + typing_delay: float = 0.04, + validation_delay: float = 0.5, ) -> None: if config_path is None: config_path = Path(__file__).with_name("missions.json") self.config: WorkshopConfig = load_workshop(config_path) self.typing_delay = typing_delay + self.validation_delay = validation_delay self.connected = False self.current_mission_index = 0 self.hint_index_by_mission: dict[str, int] = {} - self._last_read_asset: str | None = None + self._last_read_asset_name: str | None = None + self._read_asset_names: set[str] = set() + self._pin_attempt_count = 0 # Output Helpers def _line(self, text: str = "") -> None: @@ -45,20 +52,40 @@ def _line_with_robot_name(self, msg: str = "") -> None: self._line(text) def _block_with_robot_name(self, lines: list[str]) -> None: - output: list[str] = [] - for line in lines: - output.append(f"[{self.config.robot_name}] {line}") - - self._block(output) + self._block([f"[{self.config.robot_name}] {line}" for line in lines]) + + def _resolve_asset(self, asset_ref: Any) -> Asset | None: + if isinstance(asset_ref, Asset): + return self.config.assets.get(asset_ref.name, asset_ref) + + if isinstance(asset_ref, str) and asset_ref in self.config.assets: + return self.config.assets[asset_ref] + + for asset in self.config.assets.values(): + if asset_ref is asset or asset_ref is asset.value: + return asset + + return None + + @property + def finished(self) -> bool: + return self.current_mission_index >= len(self.config.missions) # Core flow def connect(self) -> None: self._banner(f"Connecting to {self.config.robot_name}") self._block(self.config.connect_lines) self.connected = True - self.show_mission() + if self.config.missions: + self.show_mission() + else: + self._line_with_robot_name("No missions loaded.") def show_mission(self) -> None: + if self.finished: + self._line_with_robot_name("All missions already complete.") + return + mission = self.current_mission self._banner(f"[{mission.id}] {mission.title}") self._block(mission.intro) @@ -67,10 +94,18 @@ def show_mission(self) -> None: self._line() self._line("Available mission assets:") for asset_name in mission.assets: - self._line(f" - {asset_name}") + asset = self.config.assets.get(asset_name) + if asset is None: + self._line(f" - {asset_name}") + continue + + address = f" @ {asset.address}" if asset.address else "" + self._line(f" - {asset.display_label} ({asset.name}){address}") @property def current_mission(self) -> Mission: + if self.finished: + raise RuntimeError("No active mission. Workshop is complete.") return self.config.missions[self.current_mission_index] def get_asset(self, name: str) -> Any: @@ -78,32 +113,61 @@ def get_asset(self, name: str) -> Any: raise KeyError(f"Unknown asset: {name}") return self.config.assets[name].value - def read_memory(self, asset: Any) -> None: - matched_name = None - for name, obj in self.config.assets.items(): - if obj.value is asset: - matched_name = name - break - + def get_asset_wrapper(self, name: str) -> Asset: + if name not in self.config.assets: + raise KeyError(f"Unknown asset: {name}") + return self.config.assets[name] + + def read_memory(self, asset: Any) -> Any: + resolved = self._resolve_asset(asset) + self._line() self._line_with_robot_name("Reading memory...") - if matched_name is not None: - self._last_read_asset = matched_name - self._line_with_robot_name(f"Memory label: {matched_name}") + + if resolved is not None: + self._last_read_asset_name = resolved.name + self._read_asset_names.add(resolved.name) + self._line_with_robot_name(f"Label: {resolved.display_label}") + self._line_with_robot_name(f"Address: {resolved.address or 'UNMAPPED'}") + self._line_with_robot_name(f"Type: {resolved.kind}") + if resolved.description: + self._line_with_robot_name(f"Note: {resolved.description}") + self._line_with_robot_name("Memory read complete.") + return resolved.value + + if isinstance(asset, str): + self._line_with_robot_name("No known memory asset found for this input.") else: self._line_with_robot_name("External object received") - self._line_with_robot_name("Memory read complete.") + return asset def show(self, value: Any) -> None: + resolved = self._resolve_asset(value) + + # If this is a known asset but it has not been read yet, + # only reveal pointer metadata (address), not the value. + if resolved is not None and resolved.name not in self._read_asset_names: + self._line() + self._line_with_robot_name("Memory pointer detected.") + self._line(f"{resolved.display_label} ({resolved.name}) @ {resolved.address or 'UNMAPPED'}") + self._line() + self._line_with_robot_name("Run robot.read_memory(...) first to unlock this value.") + return + + if resolved is not None: + value = resolved.value + self._line() self._line_with_robot_name("Output:") if isinstance(value, list): for item in value: self._line(str(item)) + elif isinstance(value, dict): + self._line(json.dumps(value, indent=2)) else: self._line(str(value)) - + self._line() self._line_with_robot_name("If this looks correct, use robot.submit(...)") @@ -117,17 +181,56 @@ def hint(self) -> None: self.hint_index_by_mission[mission.id] = idx + 1 else: self._line_with_robot_name("No more hits available for this mission.") + + def _is_valid_pin(self, user_id: str, pin: str) -> bool: + internal = self.config.assets.get("pin_table_internal") + if internal and isinstance(internal.value, dict): + expected = internal.value.get(user_id) + return expected is not None and str(expected) == str(pin) + + pins_1 = self.config.assets.get("pins_1") + if pins_1 and isinstance(pins_1.value, dict): + expected = pins_1.value.get(user_id) + return expected is not None and str(expected) == str(pin) + + return False + + def check_pin(self, user_id: str, pin: str | int) -> bool: + self._pin_attempt_count += 1 + pin_text = str(pin).strip() + ok = self._is_valid_pin(str(user_id), pin_text) + + self._line() + self._line_with_robot_name(f"PIN check ${self._pin_attempt_count}: user_id={user_id}, pin={pin_text}") + + if ok: + self._line_with_robot_name("PIN accepted. Access channel recovered.") + else: + self._line_with_robot_name("PIN rejected. Try another candidate.") + return ok def submit(self, answer: Any) -> bool: + if not self.connected: + self._line_with_robot_name("Connect first with robot.connect().") + return False + + if self.finished: + self._line_with_robot_name("All missions are already complete.") + return False + mission = self.current_mission expected = mission.expected_answer - ok = self._answers_match(expected, answer) + ok = validate_submission( + mission=mission, + expected=expected, + actual=answer, + robot=self, + ) self._line() self._line_with_robot_name("Validating submission...") - - time.sleep(1) + time.sleep(self.validation_delay) if ok: if mission.success_lines: @@ -140,40 +243,36 @@ def submit(self, answer: Any) -> bool: self.show_mission() else: self._final_reboot() - return True if mission.failure_lines: self._block_with_robot_name(mission.failure_lines) else: self._line_with_robot_name("Submission incorrect.") - + self._line_with_robot_name("Use robot.hint() if you are stuck.") return False - - def _answers_match(self, expected: Any, actual: Any) -> bool: - if isinstance(expected, str) and isinstance(actual, str): - return expected.strip().lower() == actual.strip().lower() - return expected == actual def _final_reboot(self) -> None: old_typing_delay = self.typing_delay - self._banner("SYSTEM RESTORE") + self._banner("SYSTEM RESTORE / RECOVERY") self.typing_delay = 1 self._block( [ - "Restoring backup...", + "Locating stable backup image...", "Repairing corrupted memory sectors...", "Re-enabling safety systems...", + "Flushing sabotage hooks...", "Rebooting core services...", "", - "3...", - "2...", - "1...", + "[#####-----] 50%", + "[########--] 80%", + "[##########] 100%", "", f"{self.config.robot_name} ONLINE", - "Hello, friend. Thanks for fixing me." + "Hello, friend. Thanks for fixing me.", + "Workshop complete: Hack the Robot recovery successful.", ] ) self.typing_delay = old_typing_delay \ No newline at end of file diff --git a/src/jamkit/hack_the_robot/loader.py b/src/jamkit/hack_the_robot/loader.py index 543a18c..9a8d899 100644 --- a/src/jamkit/hack_the_robot/loader.py +++ b/src/jamkit/hack_the_robot/loader.py @@ -2,10 +2,23 @@ import json from pathlib import Path +from typing import Any from .assets import load_asset_value from .models import Asset, Mission, WorkshopConfig +def _parse_validator(raw_validator: Any) -> tuple[str | None, dict[str, Any]]: + if raw_validator is None: + return None, {} + + if isinstance(raw_validator, str): + return raw_validator, {} + + if isinstance(raw_validator, dict): + return raw_validator.get("name"), raw_validator.get("params", {}) + + raise ValueError(f"Invalid validator config: {raw_validator}") + def load_workshop(config_path: str | Path) -> WorkshopConfig: config_path = Path(config_path).resolve() base_dir = config_path.parent @@ -17,23 +30,33 @@ def load_workshop(config_path: str | Path) -> WorkshopConfig: assets[name] = Asset( name=name, kind=asset_def["type"], - value=load_asset_value(base_dir, asset_def) + value=load_asset_value(base_dir, asset_def), + label=asset_def.get("label"), + address=asset_def.get("address"), + description=asset_def.get("description"), + metadata=asset_def.get("metadata", {}), + export=asset_def.get("export", True), ) - missions = [ - Mission( - id=m["id"], - title=m["title"], - intro=m.get("intro", []), - hints=m.get("hints", []), - expected_answer=m.get("expected_answer"), - success_lines=m.get("success_lines", []), - failure_lines=m.get("failure_lines", []), - assets=m.get("assets", []), - metadata=m.get("metadata", {}), + missions: list[Mission] = [] + for m in raw.get("missions", []): + validator_name, validator_params = _parse_validator(m.get("validator")) + missions.append( + Mission( + id=m["id"], + title=m["title"], + intro=m.get("intro", []), + hints=m.get("hints", []), + expected_answer=m.get("expected_answer"), + validator_name=validator_name, + validator_params=validator_params, + success_lines=m.get("success_lines", []), + failure_lines=m.get("failure_lines", []), + assets=m.get("assets", []), + metadata=m.get("metadata", {}), + ) ) - for m in raw.get("missions", []) - ] + return WorkshopConfig( workshop_id=raw["workshop_id"], diff --git a/src/jamkit/hack_the_robot/missions.json b/src/jamkit/hack_the_robot/missions.json index 13cfa20..0787841 100644 --- a/src/jamkit/hack_the_robot/missions.json +++ b/src/jamkit/hack_the_robot/missions.json @@ -5,36 +5,245 @@ "Boot sequence link requested...", "Secure tunnel established.", "Warning: recovery mode active.", - "Robot memory is damaged. Operator assistance required." + "Corruption markers detected in command memory.", + "Friendly unit online in SAFE MODE. Operator assistance required." ], "assets": { "msg_1": { "type": "string", - "value": "Difdl uif mpht! sfqbjs_bddpvou vtfs je: twd_311 ???" + "label": "Distress Burst", + "address": "0xA1-11", + "description": "Last outgoing message before corruption.", + "value": "DIFDL UIF MPHT! VOVTVBM BDUJWJUZ EFUFDUFE. MPPL GPS MPHT DPOUBJOJOH MPDLPVU, PWFSSJEF, PS VOBVUIPSJTFE." + }, + "logs_1": { + "type": "lines_file", + "label": "Command Logs (Segment A)", + "address": "0xB7-44", + "description": "Historical command events from maintenance channels.", + "path": "data/logs_1.txt" + }, + "pin_parts_1": { + "type": "dict", + "value": { + "user_id": "svc_311", + "prefixes": [ + "39", + "40", + "41", + "42", + "43" + ], + "suffixes": [ + "12", + "57", + "81", + "99" + ] + } + }, + "account_1": { + "type": "dict", + "label": "Compromised Service Account", + "address": "0xD9-02", + "description": "Profile and sabotage flags for the rogue account.", + "value": { + "user_id": "svc_311", + "display_name": "Remote Repair Service", + "role": "maintenance", + "reboot_blocked": true, + "backup_slots": [ + { + "slot_id": "A2", + "status": "corrupt", + "integrity": 22 + }, + { + "slot_id": "B7", + "status": "ready", + "integrity": 94 + }, + { + "slot_id": "C1", + "status": "ready", + "integrity": 67 + } + ], + "corrupted_sector_groups": [ + [ + 12, + 13, + 21 + ], + [ + 21, + 22 + ], + [ + 13, + 30 + ] + ], + "last_override_command": "OVERRIDE_SAFEBOOT", + "sabotage_note": "auto-repair disabled by unauthorized service routine" + } + }, + "pin_table_internal": { + "type": "dict", + "export": false, + "value": { + "svc_311": "4281" + } } }, "missions": [ { "id": "MISSION 1", - "title": "Decode the distress message", + "title": "Recover and decode the distress message", "intro": [ - "A distress message was stored just before the robot lost control.", - "It is stored in memory as msg_1", - "Read it, decode it with a Caesar shift of 1, and submit the decoded message." + "A distress message was saved just before JAMBOT-7 was compromised.", + "Read msg_1 from memory, decode it with a Caesar shift of 1, and submit the recovered message.", + "The decoded text gives partial clues, not the full recovery command." + ], + "assets": [ + "msg_1" ], - "assets": ["msg_1"], "hints": [ - "Try robot.read_memory(msg_1) first.", - "Use a loop to go through each character.", - "Use chr(ord(letter) - 1) to shift a letter backwards." + "Start with robot.read_memory(msg_1) so the robot terminal gives context.", + "Loop over each character in msg_1.", + "For letters, use chr(ord(ch) - 1). Keep punctuation as-is." ], - "expected_answer": "check the logs! repair_account user id: svc_311 ???", + "expected_answer": "Check the logs! Unusual activity detected. look for logs containing LOCKOUT, override, or unauthorised.", + "validator": "case_insensitive", "success_lines": [ "Distress message restored.", - "Clue found: command logs may reveal what happened." + "New clue: inspect command logs for suspicious service activity." + ], + "failure_lines": [ + "Decoded output does not match recovered memory text." + ] + }, + { + "id": "MISSION 2", + "title": "Search the command logs", + "intro": [ + "Use logs_1 to find suspicious commands and identify the rogue service account user id.", + "The log is very long as it shows all recent commands in the system.", + "Use code to speed up the search process.", + "Submit only the rogue user id." + ], + "assets": [ + "logs_1" + ], + "hints": [ + "Try: for line in logs_1: ...", + "Use if checks for words like LOCKOUT, override, or unauthorized.", + "Once you spot the repeated account, submit that user id." + ], + "expected_answer": "svc_311", + "validator": "case_insensitive", + "success_lines": [ + "Rogue service account confirmed: svc_311.", + "Next step: reconstruct its repair PIN to unlock settings." + ], + "failure_lines": [ + "User id not recognized as the rogue service account." + ] + }, + { + "id": "MISSION 3", + "title": "Reconstruct the split repair PIN", + "intro": [ + "The rogue account PIN is fragmented in memory.", + "Use pin_parts_1 to generate candidate PINs from prefix + suffix combinations.", + "Test each candidate with robot.check_pin(rogue_user_id, candidate) and submit the valid PIN." + ], + "assets": [ + "pins_1" + ], + "hints": [ + "Read the asset first: robot.read_memory(pin_parts_1).", + "Use nested loops: one loop for prefixes, one for suffixes.", + "Build candidate = prefix + suffix. Stop when robot.check_pin(...) returns True." + ], + "metadata": { + "user_id": "svc_311" + }, + "expected_answer": "4281", + "validator": { + "name": "pin_match", + "params": { + "user_id": "svc_311" + } + }, + "success_lines": [ + "PIN reconstructed from fragmented memory.", + "Privileged service access restored." + ], + "failure_lines": [ + "PIN is not valid for the target service account." + ] + }, + { + "id": "MISSION 4", + "title": "Assemble the recovery profile", + "intro": [ + "Now inspect account_1 to prepare a full recovery profile.", + "Choose the best backup slot from backup_slots: status must be 'ready' and integrity must be highest.", + "Collect all sectors from corrupted_sector_groups, remove duplicates, sort them, and submit a recovery profile dict." + ], + "assets": [ + "account_1" + ], + "hints": [ + "Run robot.read_memory(account_1), then inspect account_1 keys.", + "Loop through backup_slots and keep the best ready slot by integrity.", + "Use a set for unique sectors, then sorted(...) before submit." + ], + "expected_answer": { + "backup_slot": "B7", + "repair_targets": [ + 12, + 13, + 21, + 22, + 30 + ] + }, + "validator": "recovery_profile", + "success_lines": [ + "Recovery profile validated.", + "Backup slot and repair targets locked for final restore." + ], + "failure_lines": [ + "Recovery profile invalid.", + "Submit {'backup_slot': , 'repair_targets': }." + ] + }, + { + "id": "MISSION 5", + "title": "Build and submit the restore command", + "intro": [ + "Write a Python function that builds the final restore command using the data you recovered.", + "Command format: RESTORE --user --pin --slot --repair", + "Submit the command string to trigger final reboot." + ], + "assets": [ + "account_1" + ], + "hints": [ + "Define a function like make_restore_command(user_id, pin, slot).", + "Return one formatted string from the function.", + "Use user_id='svc_311', pin='4281', slot='B7'." + ], + "expected_answer": "RESTORE --user svc_311 --pin 4281 --slot B7 --targets 12,13,21,22,30 --repair", + "validator": "restore_command", + "success_lines": [ + "Recovery command accepted.", + "Starting full system restore." ], "failure_lines": [ - "[JAMBOT-7] Distress message does not match expected recovery output." + "Restore command format invalid." ] } ] diff --git a/src/jamkit/hack_the_robot/models.py b/src/jamkit/hack_the_robot/models.py index fc36337..f789b53 100644 --- a/src/jamkit/hack_the_robot/models.py +++ b/src/jamkit/hack_the_robot/models.py @@ -1,13 +1,47 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any +from typing import Any, Iterator @dataclass class Asset: name: str kind: str value: Any + label: str | None = None + address: str | None = None + description: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + export: bool = True + + @property + def display_label(self) -> str: + return self.label or self.name + + def __repr__(self) -> str: + return ( + f"Asset(name={self.name!r}, kind={self.kind!r}, " + f"address={self.address!r}, value={self.value!r})" + ) + + def __str__(self) -> str: + return str(self.address) + + def __iter__(self) -> Iterator[Any]: + return iter(self.value) + + def __len__(self) -> int: + return len(self.value) + + def __getitem__(self, key: Any) -> Any: + return self.value[key] + + def __contains__(self, item: Any) -> bool: + return item in self.value + + def __getattr__(self, attr: str) -> Any: + # Delegate unknown attributes to underlying value + return getattr(self.value, attr) @dataclass class Mission: @@ -16,6 +50,8 @@ class Mission: intro: list[str] = field(default_factory=list) hints: list[str] = field(default_factory=list) expected_answer: any = None + validator_name: str | None = None + validator_params: dict[str, Any] = field(default_factory=dict) success_lines: list[str] = field(default_factory=list) failure_lines: list[str] = field(default_factory=list) assets: list[str] = field(default_factory=list) diff --git a/src/jamkit/hack_the_robot/validators.py b/src/jamkit/hack_the_robot/validators.py new file mode 100644 index 0000000..1d8f2d0 --- /dev/null +++ b/src/jamkit/hack_the_robot/validators.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from typing import Any, Callable + +from .models import Mission + +Validator = Callable[[Any, Any, Mission, Any], bool] + +def _normalised(value: Any) -> str: + return " ".join(str(value).strip().lower().split()) + +def exact_match(expected: Any, actual: Any, mission: Mission, robot: Any) -> bool: + return expected == actual + +def case_insensitive_match(expected: Any, actual: Any, mission: Mission, robot: Any) -> bool: + if not isinstance(expected, str) or not isinstance(actual, str): + return False + return expected.strip().lower() == actual.strip().lower() + +def pin_match(expected: Any, actual: Any, mission: Mission, robot: Any) -> bool: + user_id = ( + mission.validator_params.get("user_id") + or mission.metadata.get("user_id") + or "svc_311" + ) + + if isinstance(actual, dict): + submitted_user = actual.get("user_id", user_id) + submitted_pin = actual.get("pin") + elif isinstance(actual, (list, tuple)) and len(actual) == 2: + submitted_user = actual[0] + submitted_pin = actual[1] + else: + submitted_user = user_id + submitted_pin = actual + + if submitted_pin is None: + return False + return robot._is_valid_pin(str(submitted_user), str(submitted_pin)) + +def restore_command_match(expected: Any, actual: Any, mission: Mission, robot: Any) -> bool: + if not isinstance(actual, str): + return False + + if isinstance(expected, str) and _normalised(expected) == _normalised(actual): + return True + + if not isinstance(expected, str): + return False + + expected_tokens = _normalised(expected).split() + actual_tokens = _normalised(actual).split() + if len(expected_tokens) != len(actual_tokens): + return False + + return expected_tokens == actual_tokens + +def recovery_profile_match(expected, actual, mission, robot): + if not isinstance(expected, dict) or not isinstance(actual, dict): + return False + + exp_slot = str(expected.get("backup_slot", "")).strip().upper() + act_slot = str(actual.get("backup_slot", "")).strip().upper() + + exp_targets = str(expected.get("repair_targets", [])) + act_targets = str(actual.get("repair_targets", [])) + + return exp_slot == act_slot and exp_targets == act_targets + + +VALIDATORS: dict[str, Validator] = { + "exact": exact_match, + "case_insensitive": case_insensitive_match, + "pin_match": pin_match, + "restore_command": restore_command_match, + "recovery_profile": recovery_profile_match +} + +def validate_submission(mission: Mission, expected: Any, actual: Any, robot: Any) -> bool: + if mission.validator_name: + Validator = VALIDATORS.get(mission.validator_name) + if Validator is None: + raise ValueError(f"Unknown validator: {mission.validator_name}") + return Validator(expected, actual, mission, robot) + + if isinstance(expected, str): + return case_insensitive_match(expected, actual, mission, robot) + return exact_match(expected, actual, mission, robot) \ No newline at end of file From 3c07ac96ff093d966fd9592279932853dd96d974 Mon Sep 17 00:00:00 2001 From: James Dougan Date: Fri, 13 Mar 2026 18:04:43 +0000 Subject: [PATCH 3/4] add example code --- src/jamkit/example/hack_the_robot_example.py | 80 +++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/src/jamkit/example/hack_the_robot_example.py b/src/jamkit/example/hack_the_robot_example.py index 9f97fc9..5e28a7a 100644 --- a/src/jamkit/example/hack_the_robot_example.py +++ b/src/jamkit/example/hack_the_robot_example.py @@ -1,14 +1,88 @@ -from jamkit.hack_the_robot import Robot, msg_1 +from jamkit.hack_the_robot import Robot, msg_1, logs_1, pin_parts_1, account_1 robot = Robot() robot.connect() -# Mission 1 +robot.hint() robot.read_memory(msg_1) +robot.show(msg_1) + decoded = "" for ch in msg_1: if ch.isalpha(): decoded += chr(ord(ch) - 1) else: decoded += ch -robot.submit(decoded) \ No newline at end of file + +robot.show(decoded) +robot.submit(decoded) + +robot.read_memory(logs_1) + +for line in logs_1: + if "LOCKOUT" in line or "override" in line: + parts = line.split() + for part in parts: + if part.startswith("user="): + user_id = part.replace("user=", "") + break + +robot.show(user_id) +robot.submit(user_id) + +robot.read_memory(pin_parts_1) +robot.show(pin_parts_1) + +prefixes = pin_parts_1["prefixes"] +suffixes = pin_parts_1["suffixes"] + +for prefix in prefixes: + for suffix in suffixes: + candidate = prefix + suffix + + if robot.check_pin(user_id, candidate): + found_pin = candidate + +robot.show(found_pin) +robot.submit(found_pin) + +robot.read_memory(account_1) +robot.show(account_1) + +backup_slots = account_1["backup_slots"] +sector_groups = account_1["corrupted_sector_groups"] + +best = None +for slot in backup_slots: + if slot["status"] != "ready": + continue + + if best is None or slot["integrity"] > best["integrity"]: + best = slot + +backup_slot = best["slot_id"] +robot.show(backup_slot) + +unique = set() +for group in sector_groups: + for sector in group: + unique.add(sector) + +repair_targets = sorted(unique) +robot.show(repair_targets) + +recovery_profile = { + "backup_slot": backup_slot, + "repair_targets": repair_targets +} +robot.show(recovery_profile) +robot.submit(recovery_profile) + +numbers = recovery_profile["repair_targets"] +csv_text = ",".join(str(n) for n in numbers) +robot.show(csv_text) + +command = f"RESTORE --user {user_id} --pin {found_pin} --slot {recovery_profile['backup_slot']} --targets {csv_text} --repair" +robot.show(command) +robot.submit(command) + From 853ceb5e861cfef3351f24588b2e8af8a1cee38f Mon Sep 17 00:00:00 2001 From: James Dougan Date: Fri, 13 Mar 2026 18:18:10 +0000 Subject: [PATCH 4/4] update mission text to reference manual --- src/jamkit/hack_the_robot/missions.json | 50 +++++++++++++------------ 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/jamkit/hack_the_robot/missions.json b/src/jamkit/hack_the_robot/missions.json index 0787841..7692550 100644 --- a/src/jamkit/hack_the_robot/missions.json +++ b/src/jamkit/hack_the_robot/missions.json @@ -102,16 +102,16 @@ "title": "Recover and decode the distress message", "intro": [ "A distress message was saved just before JAMBOT-7 was compromised.", - "Read msg_1 from memory, decode it with a Caesar shift of 1, and submit the recovered message.", - "The decoded text gives partial clues, not the full recovery command." + "For decoding method and character-shift functions, refer to Manual Section 1.1 (Shifted Messages).", + "Submit the fully decoded message text." ], "assets": [ "msg_1" ], "hints": [ - "Start with robot.read_memory(msg_1) so the robot terminal gives context.", - "Loop over each character in msg_1.", - "For letters, use chr(ord(ch) - 1). Keep punctuation as-is." + "If output is close but wrong, confirm you only shift letters and leave spaces/punctuation unchanged.", + "Decode the entire message before submit; partial decoding will fail validation.", + "Use robot.show(decoded) and compare your result against the story clue wording." ], "expected_answer": "Check the logs! Unusual activity detected. look for logs containing LOCKOUT, override, or unauthorised.", "validator": "case_insensitive", @@ -128,17 +128,16 @@ "title": "Search the command logs", "intro": [ "Use logs_1 to find suspicious commands and identify the rogue service account user id.", - "The log is very long as it shows all recent commands in the system.", - "Use code to speed up the search process.", + "Refer to Manual Sections 2.1-2.3 for loop/filter/extract patterns for log data.", "Submit only the rogue user id." ], "assets": [ "logs_1" ], "hints": [ - "Try: for line in logs_1: ...", - "Use if checks for words like LOCKOUT, override, or unauthorized.", - "Once you spot the repeated account, submit that user id." + "Substring checks are case-sensitive; include both uppercase and lowercase variants if needed.", + "Once you extract a confirmed user id, break early instead of scanning every remaining line.", + "Submit only the id token (for example: svc_311), not the full log line." ], "expected_answer": "svc_311", "validator": "case_insensitive", @@ -154,17 +153,18 @@ "id": "MISSION 3", "title": "Reconstruct the split repair PIN", "intro": [ - "The rogue account PIN is fragmented in memory.", + "The rogue account PIN is fragmented in memory.", "Use pin_parts_1 to generate candidate PINs from prefix + suffix combinations.", + "Refer to Manual Sections 3.1-3.3 for dictionary access, nested loops, and candidate checks.", "Test each candidate with robot.check_pin(rogue_user_id, candidate) and submit the valid PIN." ], "assets": [ - "pins_1" + "pin_parts_1" ], "hints": [ - "Read the asset first: robot.read_memory(pin_parts_1).", - "Use nested loops: one loop for prefixes, one for suffixes.", - "Build candidate = prefix + suffix. Stop when robot.check_pin(...) returns True." + "Create local variables for prefixes and suffixes first to keep nested loops readable.", + "Keep candidate values as strings when combining prefix + suffix.", + "When a PIN succeeds, break inner loop and then break outer loop too." ], "metadata": { "user_id": "svc_311" @@ -177,7 +177,7 @@ } }, "success_lines": [ - "PIN reconstructed from fragmented memory.", + "PIN reconstructed from fragmented memory.", "Privileged service access restored." ], "failure_lines": [ @@ -189,6 +189,7 @@ "title": "Assemble the recovery profile", "intro": [ "Now inspect account_1 to prepare a full recovery profile.", + "Refer to Manual Sections 4.1-4.3 for choosing best backup slot and cleaning sector targets.", "Choose the best backup slot from backup_slots: status must be 'ready' and integrity must be highest.", "Collect all sectors from corrupted_sector_groups, remove duplicates, sort them, and submit a recovery profile dict." ], @@ -196,9 +197,9 @@ "account_1" ], "hints": [ - "Run robot.read_memory(account_1), then inspect account_1 keys.", - "Loop through backup_slots and keep the best ready slot by integrity.", - "Use a set for unique sectors, then sorted(...) before submit." + "If no slot is selected, check your ready-status filter and best-slot comparison logic.", + "repair_targets must be both unique and sorted for validation to pass reliably.", + "Submit a dict with exact keys: backup_slot and repair_targets." ], "expected_answer": { "backup_slot": "B7", @@ -225,16 +226,17 @@ "title": "Build and submit the restore command", "intro": [ "Write a Python function that builds the final restore command using the data you recovered.", - "Command format: RESTORE --user --pin --slot --repair", + "Refer to Manual Sections 5.1-5.3 (and 4.1 for dictionary key access reminders).", + "Command format: RESTORE --user --pin --slot --targets --repair", "Submit the command string to trigger final reboot." ], "assets": [ "account_1" ], "hints": [ - "Define a function like make_restore_command(user_id, pin, slot).", - "Return one formatted string from the function.", - "Use user_id='svc_311', pin='4281', slot='B7'." + "Build the targets CSV in a separate variable first, then place it into the command string.", + "Ensure there are no extra spaces, missing flags, or line breaks in the final command.", + "Use robot.show(command) to verify final flag order before submitting." ], "expected_answer": "RESTORE --user svc_311 --pin 4281 --slot B7 --targets 12,13,21,22,30 --repair", "validator": "restore_command", @@ -247,4 +249,4 @@ ] } ] -} \ No newline at end of file +}